mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Store app catalog relays as device-signed kind 10067 events.
Relay lists now load from the accepted local event with a hardcoded default fallback, sync in the background from relay.zapstore.dev, and require confirmation before the existing clear-and-restart apply path.
This commit is contained in:
+56
-41
@@ -26,6 +26,7 @@ import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
||||
import 'package:zapstore/services/deep_link_service.dart';
|
||||
import 'package:zapstore/services/device_backup_service.dart';
|
||||
import 'package:zapstore/services/device_key_service.dart';
|
||||
import 'package:zapstore/services/app_catalog_relay_service.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/widgets/breathing_logo.dart';
|
||||
|
||||
@@ -56,16 +57,18 @@ void main() {
|
||||
|
||||
// Bring up disk logging. Not awaited — if path_provider fails the
|
||||
// LogService falls back to ring-buffer-only and we continue.
|
||||
unawaited(LogService.I.init(isolateName: 'main').then((_) {
|
||||
LogService.I.info(
|
||||
'app starting',
|
||||
tag: 'app',
|
||||
fields: {
|
||||
'platform': Platform.operatingSystem,
|
||||
'version': Platform.operatingSystemVersion,
|
||||
},
|
||||
);
|
||||
}));
|
||||
unawaited(
|
||||
LogService.I.init(isolateName: 'main').then((_) {
|
||||
LogService.I.info(
|
||||
'app starting',
|
||||
tag: 'app',
|
||||
fields: {
|
||||
'platform': Platform.operatingSystem,
|
||||
'version': Platform.operatingSystemVersion,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
_providerContainer = ProviderContainer(
|
||||
overrides: [
|
||||
@@ -100,8 +103,12 @@ void main() {
|
||||
void _installErrorHandlers() {
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
_logUncaught(details.exception, details.stack,
|
||||
source: 'flutter', library: details.library);
|
||||
_logUncaught(
|
||||
details.exception,
|
||||
details.stack,
|
||||
source: 'flutter',
|
||||
library: details.library,
|
||||
);
|
||||
};
|
||||
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
@@ -140,10 +147,7 @@ void _logUncaught(
|
||||
LogService.I.fatal(
|
||||
'uncaught error',
|
||||
tag: 'crash',
|
||||
fields: {
|
||||
'source': source,
|
||||
if (library != null) 'library': library,
|
||||
},
|
||||
fields: {'source': source, if (library != null) 'library': library},
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
@@ -180,11 +184,15 @@ class ZapstoreApp extends HookConsumerWidget {
|
||||
// Check initial connectivity state
|
||||
connectivity.checkConnectivity().then((results) {
|
||||
notifier.connect();
|
||||
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||
});
|
||||
|
||||
// Listen to connectivity changes
|
||||
subscription = connectivity.onConnectivityChanged.listen((results) {
|
||||
notifier.connect();
|
||||
if (results.any((result) => result != ConnectivityResult.none)) {
|
||||
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription?.cancel();
|
||||
@@ -319,8 +327,6 @@ class ZapstoreHome extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
const _kDefaultAppCatalogRelay = 'wss://relay.zapstore.dev';
|
||||
|
||||
/// Ready as soon as local storage is usable.
|
||||
///
|
||||
/// UI skeleton gates MUST depend on this provider — NOT on
|
||||
@@ -330,18 +336,16 @@ const _kDefaultAppCatalogRelay = 'wss://relay.zapstore.dev';
|
||||
final storageReadyProvider = FutureProvider<void>((ref) async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||
final relayService = ref.read(appCatalogRelayServiceProvider);
|
||||
final hasRelayHandoff = await relayService.hasRestartHandoff();
|
||||
|
||||
// Clear storage if requested from a clear all operation
|
||||
await maybeClearStorage(dbPath);
|
||||
|
||||
// Seed database on first launch so new users see content immediately
|
||||
await _maybeCopySeedDatabase(dbPath);
|
||||
await _maybeCopySeedDatabase(dbPath, skip: hasRelayHandoff);
|
||||
|
||||
// Load local relay config BEFORE storage init
|
||||
// This ensures custom relays work even when signed out
|
||||
final settings = await ref.read(settingsServiceProvider).load();
|
||||
final appCatalogRelays =
|
||||
settings.appCatalogRelays ?? {_kDefaultAppCatalogRelay};
|
||||
|
||||
// Apply persisted log level (default is `debug`).
|
||||
LogService.I.level = settings.logLevel;
|
||||
@@ -354,14 +358,17 @@ final storageReadyProvider = FutureProvider<void>((ref) async {
|
||||
initializationProvider(
|
||||
StorageConfiguration(
|
||||
databasePath: dbPath,
|
||||
// Accepted kind 10067 events must remain publishable and restorable
|
||||
// after a remote preview, so their verified signatures are retained.
|
||||
keepSignatures: true,
|
||||
defaultQuerySource: LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
defaultRelays: {
|
||||
'default': {_kDefaultAppCatalogRelay},
|
||||
'bootstrap': {_kDefaultAppCatalogRelay},
|
||||
'AppCatalog': appCatalogRelays,
|
||||
'default': {kDefaultRelay},
|
||||
'bootstrap': {kDefaultRelay},
|
||||
'AppCatalog': {kDefaultRelay},
|
||||
'social': {
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
@@ -376,10 +383,16 @@ final storageReadyProvider = FutureProvider<void>((ref) async {
|
||||
|
||||
// Initialize device key signer — must be registered before any encrypted
|
||||
// queries fire, so EncryptableModel.prepareAfterLoading can find it.
|
||||
final deviceKey = await ref.read(deviceKeyServiceProvider).getOrCreatePrivateKey();
|
||||
final deviceKey = await ref
|
||||
.read(deviceKeyServiceProvider)
|
||||
.getOrCreatePrivateKey();
|
||||
final deviceSigner = Bip340PrivateKeySigner(deviceKey, ref);
|
||||
await deviceSigner.signIn(setAsActive: false);
|
||||
ref.read(devicePubkeyProvider.notifier).state = deviceSigner.pubkey;
|
||||
|
||||
// Resolve the accepted device-authored relay list from local storage. A
|
||||
// restart handoff is restored into the fresh database here, then erased.
|
||||
await relayService.initializeAcceptedRelays();
|
||||
});
|
||||
|
||||
/// Full app initialization — runs everything non-UI-critical after
|
||||
@@ -393,9 +406,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
await DeviceCapabilitiesCache.initialize();
|
||||
|
||||
// Record app open time for background notification throttling
|
||||
await ref.read(settingsServiceProvider).update(
|
||||
(s) => s.copyWith(lastAppOpened: DateTime.now()),
|
||||
);
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.update((s) => s.copyWith(lastAppOpened: DateTime.now()));
|
||||
|
||||
// Ensure installed packages are available before anything categorizes
|
||||
final packageManager = ref.read(packageManagerProvider.notifier);
|
||||
@@ -407,6 +420,10 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
await ref.read(deepLinkServiceProvider).initialize();
|
||||
|
||||
await _attemptAutoSignIn(ref);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||
});
|
||||
});
|
||||
|
||||
// AmberSigner provider for Nostr authentication
|
||||
@@ -417,17 +434,11 @@ final amberSignerProvider = Provider<AmberSigner>(
|
||||
|
||||
/// Copy the bundled seed database on first launch so the UI has content
|
||||
/// before relay data arrives. No-op if the database already exists.
|
||||
/// Skipped when the user has configured a non-default relay, since the
|
||||
/// seed was built from [_kDefaultAppCatalogRelay] and would be wrong.
|
||||
Future<void> _maybeCopySeedDatabase(String dbPath) async {
|
||||
/// Skipped during a relay-event restart handoff because the seed was built
|
||||
/// from [kDefaultRelay] and would be wrong for the accepted custom list.
|
||||
Future<void> _maybeCopySeedDatabase(String dbPath, {bool skip = false}) async {
|
||||
final dbFile = File(dbPath);
|
||||
if (dbFile.existsSync()) return;
|
||||
|
||||
final settings = await SettingsService().load();
|
||||
final isDefault = settings.appCatalogRelays == null ||
|
||||
(settings.appCatalogRelays!.length == 1 &&
|
||||
settings.appCatalogRelays!.contains(_kDefaultAppCatalogRelay));
|
||||
if (!isDefault) return;
|
||||
if (skip || dbFile.existsSync()) return;
|
||||
|
||||
try {
|
||||
final seedData = await rootBundle.load('assets/seed.db');
|
||||
@@ -548,7 +559,9 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||
|
||||
// Reconnect storage/relay connections
|
||||
notifier.connect();
|
||||
unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
_ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck();
|
||||
notifier.disconnect();
|
||||
// Flush any pending log entries to disk before the OS may freeze
|
||||
// or kill us, so diagnostics survive backgrounding.
|
||||
@@ -562,6 +575,8 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||
/// Record that the user opened the app.
|
||||
/// This is used to check inactivity for background notifications.
|
||||
Future<void> _recordAppOpened() async {
|
||||
await SettingsService().update((s) => s.copyWith(lastAppOpened: DateTime.now()));
|
||||
await SettingsService().update(
|
||||
(s) => s.copyWith(lastAppOpened: DateTime.now()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/constants/app_constants.dart';
|
||||
import 'package:zapstore/router.dart';
|
||||
import 'package:zapstore/services/app_restart_service.dart';
|
||||
import 'package:zapstore/services/device_key_service.dart';
|
||||
import 'package:zapstore/services/log_service.dart';
|
||||
|
||||
const _handoffKey = 'pending_app_catalog_relay_event';
|
||||
const _handoffStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
class AppCatalogRelayState {
|
||||
const AppCatalogRelayState({
|
||||
this.relays = const {kDefaultRelay},
|
||||
this.isChecking = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final Set<String> relays;
|
||||
final bool isChecking;
|
||||
final Object? error;
|
||||
|
||||
AppCatalogRelayState copyWith({
|
||||
Set<String>? relays,
|
||||
bool? isChecking,
|
||||
Object? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return AppCatalogRelayState(
|
||||
relays: relays ?? this.relays,
|
||||
isChecking: isChecking ?? this.isChecking,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum RelayUpdateAction { none, offerRemote, publishLocal }
|
||||
|
||||
final appCatalogRelayStateProvider = StateProvider<AppCatalogRelayState>(
|
||||
(_) => const AppCatalogRelayState(),
|
||||
);
|
||||
|
||||
final appCatalogRelayServiceProvider = Provider<AppCatalogRelayService>((ref) {
|
||||
final service = AppCatalogRelayService(ref);
|
||||
ref.onDispose(service.cancelCurrentCheck);
|
||||
return service;
|
||||
});
|
||||
|
||||
class AppCatalogRelayService {
|
||||
AppCatalogRelayService(this.ref);
|
||||
|
||||
final Ref ref;
|
||||
Request<AppCatalogRelayList>? _activeRequest;
|
||||
bool _isChecking = false;
|
||||
bool _cancelRequested = false;
|
||||
|
||||
Future<bool> hasRestartHandoff() async {
|
||||
try {
|
||||
final raw = await _handoffStorage.read(key: _handoffKey);
|
||||
return raw != null && raw.isNotEmpty;
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'relay restart handoff check failed',
|
||||
tag: 'relays',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores a one-restart event handoff and loads the accepted local event.
|
||||
///
|
||||
/// Must run after Purplebase and the device signer are initialized.
|
||||
Future<void> initializeAcceptedRelays() async {
|
||||
final restored = await _restoreHandoff();
|
||||
final local = restored ?? await _loadAcceptedLocal();
|
||||
final relays = _validRelays(local) ?? const {kDefaultRelay};
|
||||
_applyRelayGroup(relays);
|
||||
|
||||
if (restored != null) {
|
||||
unawaited(_publish(restored));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createAndRestart(Set<String> relayUrls) async {
|
||||
final relays = normalizeRelaySet(relayUrls);
|
||||
if (relays.isEmpty) {
|
||||
throw StateError('At least one valid relay is required');
|
||||
}
|
||||
|
||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
||||
if (devicePubkey == null) {
|
||||
throw StateError('Device key is not ready');
|
||||
}
|
||||
final signer = ref.read(Signer.signerProvider(devicePubkey));
|
||||
if (signer == null) {
|
||||
throw StateError('Device signer is not available');
|
||||
}
|
||||
|
||||
final event = await PartialAppCatalogRelayList(
|
||||
relays: relays,
|
||||
).signWith(signer);
|
||||
await _stageAndRestart(event);
|
||||
}
|
||||
|
||||
/// Checks only the hardcoded Zapstore relay for this device's list.
|
||||
Future<void> checkForUpdates() async {
|
||||
if (_isChecking) return;
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
if (!storage.isInitialized) return;
|
||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
||||
if (devicePubkey == null) return;
|
||||
|
||||
_isChecking = true;
|
||||
_cancelRequested = false;
|
||||
_setState(isChecking: true, clearError: true);
|
||||
|
||||
try {
|
||||
final local = await _loadAcceptedLocal();
|
||||
if (_cancelRequested) return;
|
||||
final request = RequestFilter<AppCatalogRelayList>(
|
||||
authors: {devicePubkey},
|
||||
limit: 1,
|
||||
).toRequest();
|
||||
_activeRequest = request;
|
||||
|
||||
final remote = await storage.query(
|
||||
request,
|
||||
source: const RemoteSource(relays: {kDefaultRelay}, stream: false),
|
||||
subscriptionPrefix: 'app-relay-list-check',
|
||||
);
|
||||
final candidate = _latestValid(remote, devicePubkey);
|
||||
final currentRelays = ref.read(appCatalogRelayStateProvider).relays;
|
||||
final candidateRelays = _validRelays(candidate);
|
||||
final action = decideRelayUpdate(
|
||||
currentRelays: currentRelays,
|
||||
localCreatedAt: local?.createdAt,
|
||||
remoteRelays: candidateRelays,
|
||||
remoteCreatedAt: candidate?.createdAt,
|
||||
);
|
||||
switch (action) {
|
||||
case RelayUpdateAction.none:
|
||||
return;
|
||||
case RelayUpdateAction.publishLocal:
|
||||
if (local != null) await _publish(local);
|
||||
return;
|
||||
case RelayUpdateAction.offerRemote:
|
||||
break;
|
||||
}
|
||||
|
||||
final offeredCandidate = candidate!;
|
||||
final offeredRelays = candidateRelays!;
|
||||
|
||||
// Remote queries persist results. Put the accepted event back before
|
||||
// asking so an unconfirmed candidate can never become active.
|
||||
await _restoreAcceptedAfterPreview(offeredCandidate, local);
|
||||
|
||||
final confirmed = await _confirmRemoteChange(
|
||||
currentRelays,
|
||||
offeredRelays,
|
||||
);
|
||||
if (confirmed) {
|
||||
await _stageAndRestart(offeredCandidate);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
if (!_cancelRequested) {
|
||||
LogService.I.warn(
|
||||
'app catalog relay check failed',
|
||||
tag: 'relays',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
_setState(error: error);
|
||||
}
|
||||
} finally {
|
||||
_activeRequest = null;
|
||||
_isChecking = false;
|
||||
_cancelRequested = false;
|
||||
_setState(isChecking: false);
|
||||
}
|
||||
}
|
||||
|
||||
void cancelCurrentCheck() {
|
||||
_cancelRequested = true;
|
||||
final request = _activeRequest;
|
||||
_activeRequest = null;
|
||||
_setState(isChecking: false);
|
||||
if (request != null) {
|
||||
unawaited(ref.read(storageNotifierProvider.notifier).cancel(request));
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppCatalogRelayList?> _restoreHandoff() async {
|
||||
String? raw;
|
||||
try {
|
||||
raw = await _handoffStorage.read(key: _handoffKey);
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'relay restart handoff read failed',
|
||||
tag: 'relays',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final map = Map<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
||||
if (devicePubkey == null ||
|
||||
map['kind'] != 10067 ||
|
||||
map['pubkey'] != devicePubkey ||
|
||||
!ref.read(verifierProvider).verify(map)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final event = AppCatalogRelayList.fromMap(map, ref);
|
||||
if (_validRelays(event) == null) return null;
|
||||
final saved = await ref.read(storageNotifierProvider.notifier).save({
|
||||
event,
|
||||
});
|
||||
return saved ? event : null;
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'relay restart handoff restore failed',
|
||||
tag: 'relays',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
await _clearHandoff();
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppCatalogRelayList?> _loadAcceptedLocal() async {
|
||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
||||
if (devicePubkey == null) return null;
|
||||
|
||||
final events = await ref
|
||||
.read(storageNotifierProvider.notifier)
|
||||
.query(
|
||||
RequestFilter<AppCatalogRelayList>(
|
||||
authors: {devicePubkey},
|
||||
limit: 1,
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
subscriptionPrefix: 'app-relay-list-local',
|
||||
);
|
||||
return _latestValid(events, devicePubkey);
|
||||
}
|
||||
|
||||
AppCatalogRelayList? _latestValid(
|
||||
Iterable<AppCatalogRelayList> events,
|
||||
String devicePubkey,
|
||||
) {
|
||||
AppCatalogRelayList? latest;
|
||||
for (final event in events) {
|
||||
if (event.pubkey != devicePubkey ||
|
||||
event.content.isNotEmpty ||
|
||||
_validRelays(event) == null ||
|
||||
!ref.read(verifierProvider).verify(event.toMap())) {
|
||||
continue;
|
||||
}
|
||||
if (latest == null || event.createdAt.isAfter(latest.createdAt)) {
|
||||
latest = event;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
Set<String>? _validRelays(AppCatalogRelayList? event) {
|
||||
if (event == null || event.content.isNotEmpty) return null;
|
||||
final normalized = normalizeRelaySet(event.relays);
|
||||
if (normalized.isEmpty || normalized.length != event.relays.length) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Future<void> _restoreAcceptedAfterPreview(
|
||||
AppCatalogRelayList candidate,
|
||||
AppCatalogRelayList? accepted,
|
||||
) async {
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
if (storage is PurplebaseStorageNotifier) {
|
||||
await storage.delete({candidate.event.id});
|
||||
}
|
||||
if (accepted != null) {
|
||||
await storage.save({accepted});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _confirmRemoteChange(
|
||||
Set<String> current,
|
||||
Set<String> proposed,
|
||||
) async {
|
||||
final context = rootNavigatorKey.currentState?.overlay?.context;
|
||||
if (context == null || !context.mounted) return false;
|
||||
|
||||
final currentText = (current.toList()..sort()).join('\n');
|
||||
final proposedText = (proposed.toList()..sort()).join('\n');
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Relay list changed'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'A new device relay list was found. Applying it will '
|
||||
'clear cached app data and restart Zapstore.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Current',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SelectableText(currentText),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Proposed',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SelectableText(proposedText),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Keep Current'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Apply & Restart'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> _stageAndRestart(AppCatalogRelayList event) async {
|
||||
await _handoffStorage.write(
|
||||
key: _handoffKey,
|
||||
value: jsonEncode(event.toMap()),
|
||||
);
|
||||
try {
|
||||
await restartApp();
|
||||
} catch (_) {
|
||||
await _clearHandoff();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearHandoff() async {
|
||||
try {
|
||||
await _handoffStorage.delete(key: _handoffKey);
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'relay restart handoff cleanup failed',
|
||||
tag: 'relays',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _publish(AppCatalogRelayList event) async {
|
||||
try {
|
||||
await ref
|
||||
.read(storageNotifierProvider.notifier)
|
||||
.publish({event}, relays: const {kDefaultRelay});
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'app catalog relay publish failed',
|
||||
tag: 'relays',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
_setState(error: error);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyRelayGroup(Set<String> relays) {
|
||||
final normalized = Set<String>.unmodifiable(relays);
|
||||
ref
|
||||
.read(storageNotifierProvider.notifier)
|
||||
.config
|
||||
.defaultRelays['AppCatalog'] =
|
||||
normalized;
|
||||
ref.read(appCatalogRelayStateProvider.notifier).state =
|
||||
AppCatalogRelayState(relays: normalized);
|
||||
}
|
||||
|
||||
void _setState({bool? isChecking, Object? error, bool clearError = false}) {
|
||||
final notifier = ref.read(appCatalogRelayStateProvider.notifier);
|
||||
notifier.state = notifier.state.copyWith(
|
||||
isChecking: isChecking,
|
||||
error: error,
|
||||
clearError: clearError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> normalizeRelaySet(Iterable<String> relays) {
|
||||
final normalized = <String>{};
|
||||
for (final relay in relays) {
|
||||
if (!relay.startsWith('ws://') && !relay.startsWith('wss://')) continue;
|
||||
final value = normalizeRelayUrl(relay);
|
||||
if (value != null) normalized.add(value);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
RelayUpdateAction decideRelayUpdate({
|
||||
required Set<String> currentRelays,
|
||||
required DateTime? localCreatedAt,
|
||||
required Set<String>? remoteRelays,
|
||||
required DateTime? remoteCreatedAt,
|
||||
}) {
|
||||
if (remoteRelays == null || remoteCreatedAt == null) {
|
||||
return localCreatedAt == null
|
||||
? RelayUpdateAction.none
|
||||
: RelayUpdateAction.publishLocal;
|
||||
}
|
||||
if (localCreatedAt != null && remoteCreatedAt.isBefore(localCreatedAt)) {
|
||||
return RelayUpdateAction.publishLocal;
|
||||
}
|
||||
if (_sameRelays(remoteRelays, currentRelays)) {
|
||||
return RelayUpdateAction.none;
|
||||
}
|
||||
return RelayUpdateAction.offerRemote;
|
||||
}
|
||||
|
||||
bool _sameRelays(Set<String> a, Set<String> b) =>
|
||||
a.length == b.length && a.containsAll(b);
|
||||
@@ -13,7 +13,6 @@ const _storage = FlutterSecureStorage(
|
||||
/// All local settings stored as a single JSON blob in secure storage.
|
||||
class LocalSettings {
|
||||
final String? nwcConnectionString;
|
||||
final Set<String>? appCatalogRelays;
|
||||
final DateTime? lastAppOpened;
|
||||
final DateTime? seenUntil;
|
||||
final DateTime? deletionSyncedUntil;
|
||||
@@ -23,7 +22,6 @@ class LocalSettings {
|
||||
|
||||
const LocalSettings({
|
||||
this.nwcConnectionString,
|
||||
this.appCatalogRelays,
|
||||
this.lastAppOpened,
|
||||
this.seenUntil,
|
||||
this.deletionSyncedUntil,
|
||||
@@ -37,7 +35,6 @@ class LocalSettings {
|
||||
factory LocalSettings.fromJson(Map<String, dynamic> json) {
|
||||
return LocalSettings(
|
||||
nwcConnectionString: json['nwc'] as String?,
|
||||
appCatalogRelays: (json['relays'] as List?)?.cast<String>().toSet(),
|
||||
lastAppOpened: _parseDateTime(json['lastAppOpened']),
|
||||
seenUntil: _parseDateTime(json['seenUntil']),
|
||||
deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']),
|
||||
@@ -49,22 +46,20 @@ class LocalSettings {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (nwcConnectionString != null) 'nwc': nwcConnectionString,
|
||||
if (appCatalogRelays != null) 'relays': appCatalogRelays!.toList(),
|
||||
if (lastAppOpened != null)
|
||||
'lastAppOpened': lastAppOpened!.millisecondsSinceEpoch,
|
||||
if (seenUntil != null) 'seenUntil': seenUntil!.millisecondsSinceEpoch,
|
||||
if (deletionSyncedUntil != null)
|
||||
'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch,
|
||||
if (installedAppsBackupEnabled) 'backupEnabled': true,
|
||||
if (backgroundAutoUpdatesEnabled) 'backgroundAutoUpdates': true,
|
||||
// Only persist non-default value to keep blob small.
|
||||
if (logLevel != LogLevel.debug) 'logLevel': logLevel.name,
|
||||
};
|
||||
if (nwcConnectionString != null) 'nwc': nwcConnectionString,
|
||||
if (lastAppOpened != null)
|
||||
'lastAppOpened': lastAppOpened!.millisecondsSinceEpoch,
|
||||
if (seenUntil != null) 'seenUntil': seenUntil!.millisecondsSinceEpoch,
|
||||
if (deletionSyncedUntil != null)
|
||||
'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch,
|
||||
if (installedAppsBackupEnabled) 'backupEnabled': true,
|
||||
if (backgroundAutoUpdatesEnabled) 'backgroundAutoUpdates': true,
|
||||
// Only persist non-default value to keep blob small.
|
||||
if (logLevel != LogLevel.debug) 'logLevel': logLevel.name,
|
||||
};
|
||||
|
||||
LocalSettings copyWith({
|
||||
String? nwcConnectionString,
|
||||
Set<String>? appCatalogRelays,
|
||||
DateTime? lastAppOpened,
|
||||
DateTime? seenUntil,
|
||||
DateTime? deletionSyncedUntil,
|
||||
@@ -74,9 +69,9 @@ class LocalSettings {
|
||||
bool clearNwc = false,
|
||||
}) {
|
||||
return LocalSettings(
|
||||
nwcConnectionString:
|
||||
clearNwc ? null : (nwcConnectionString ?? this.nwcConnectionString),
|
||||
appCatalogRelays: appCatalogRelays ?? this.appCatalogRelays,
|
||||
nwcConnectionString: clearNwc
|
||||
? null
|
||||
: (nwcConnectionString ?? this.nwcConnectionString),
|
||||
lastAppOpened: lastAppOpened ?? this.lastAppOpened,
|
||||
seenUntil: seenUntil ?? this.seenUntil,
|
||||
deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil,
|
||||
@@ -95,10 +90,10 @@ class LocalSettings {
|
||||
/// Service for reading and writing local settings.
|
||||
class SettingsService {
|
||||
static const _key = 'settings';
|
||||
static const _discardedLegacyRelaysKey = 'app_catalog_relays';
|
||||
|
||||
// Legacy keys for migration
|
||||
static const _legacyNwcKey = 'nwc_connection_string';
|
||||
static const _legacyRelaysKey = 'app_catalog_relays';
|
||||
static const _legacyLastAppOpenedKey = 'last_app_opened';
|
||||
static const _legacySeenUntilKey = 'seen_until';
|
||||
static const _legacyDeletionSyncedUntilKey = 'deletion_synced_until';
|
||||
@@ -108,7 +103,22 @@ class SettingsService {
|
||||
final json = await _storage.read(key: _key);
|
||||
if (json != null && json.isNotEmpty) {
|
||||
try {
|
||||
return LocalSettings.fromJson(jsonDecode(json) as Map<String, dynamic>);
|
||||
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||
final settings = LocalSettings.fromJson(decoded);
|
||||
try {
|
||||
if (decoded.containsKey('relays')) {
|
||||
await save(settings);
|
||||
}
|
||||
await _storage.delete(key: _discardedLegacyRelaysKey);
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'legacy relay settings cleanup failed',
|
||||
tag: 'settings',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
} catch (_) {
|
||||
return const LocalSettings();
|
||||
}
|
||||
@@ -120,29 +130,26 @@ class SettingsService {
|
||||
|
||||
Future<LocalSettings> _migrateFromLegacy() async {
|
||||
final nwc = await _storage.read(key: _legacyNwcKey);
|
||||
final relaysJson = await _storage.read(key: _legacyRelaysKey);
|
||||
final lastAppOpened = await _storage.read(key: _legacyLastAppOpenedKey);
|
||||
final seenUntil = await _storage.read(key: _legacySeenUntilKey);
|
||||
final deletionSynced = await _storage.read(key: _legacyDeletionSyncedUntilKey);
|
||||
final deletionSynced = await _storage.read(
|
||||
key: _legacyDeletionSyncedUntilKey,
|
||||
);
|
||||
final backupEnabled = await _storage.read(key: _legacyBackupKey);
|
||||
|
||||
// No legacy data found
|
||||
if ([nwc, relaysJson, lastAppOpened, seenUntil, deletionSynced, backupEnabled]
|
||||
.every((v) => v == null || v.isEmpty)) {
|
||||
if ([
|
||||
nwc,
|
||||
lastAppOpened,
|
||||
seenUntil,
|
||||
deletionSynced,
|
||||
backupEnabled,
|
||||
].every((v) => v == null || v.isEmpty)) {
|
||||
return const LocalSettings();
|
||||
}
|
||||
|
||||
// Parse legacy values
|
||||
Set<String>? relays;
|
||||
if (relaysJson != null && relaysJson.isNotEmpty) {
|
||||
try {
|
||||
relays = Set<String>.from((jsonDecode(relaysJson) as List).cast<String>());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final settings = LocalSettings(
|
||||
nwcConnectionString: (nwc?.isNotEmpty == true) ? nwc : null,
|
||||
appCatalogRelays: relays,
|
||||
lastAppOpened: _parseLegacyDateTime(lastAppOpened),
|
||||
seenUntil: _parseLegacyDateTime(seenUntil),
|
||||
deletionSyncedUntil: _parseLegacyDateTime(deletionSynced),
|
||||
@@ -153,7 +160,6 @@ class SettingsService {
|
||||
await save(settings);
|
||||
await Future.wait([
|
||||
_storage.delete(key: _legacyNwcKey),
|
||||
_storage.delete(key: _legacyRelaysKey),
|
||||
_storage.delete(key: _legacyLastAppOpenedKey),
|
||||
_storage.delete(key: _legacySeenUntilKey),
|
||||
_storage.delete(key: _legacyDeletionSyncedUntilKey),
|
||||
@@ -174,7 +180,8 @@ class SettingsService {
|
||||
}
|
||||
|
||||
Future<LocalSettings> update(
|
||||
LocalSettings Function(LocalSettings) updater) async {
|
||||
LocalSettings Function(LocalSettings) updater,
|
||||
) async {
|
||||
final current = await load();
|
||||
final updated = updater(current);
|
||||
await save(updated);
|
||||
|
||||
@@ -3,21 +3,16 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/services/app_restart_service.dart';
|
||||
import 'package:zapstore/services/app_catalog_relay_service.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
|
||||
/// App Catalog Relay Management Card - manages app catalog relays.
|
||||
/// These are relays for discovering apps, NOT social relays like Damus/Primal.
|
||||
///
|
||||
/// Relay configuration is stored locally in secure storage.
|
||||
/// Changes are accumulated in memory and applied with "Apply Changes" which
|
||||
/// saves the relay list and restarts the app with a fresh database.
|
||||
/// Relay configuration is a device-signed kind 10067 event.
|
||||
class RelayManagementCard extends HookConsumerWidget {
|
||||
const RelayManagementCard({super.key});
|
||||
|
||||
static const _kDefaultRelay = 'wss://relay.zapstore.dev';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final relayUrlController = useTextEditingController();
|
||||
@@ -26,33 +21,19 @@ class RelayManagementCard extends HookConsumerWidget {
|
||||
|
||||
// Watch pool state for relay connection status
|
||||
final poolState = ref.watch(poolStateProvider);
|
||||
|
||||
// Load local relays once - they only change on app restart
|
||||
final localRelaysFuture = useMemoized(
|
||||
() async => (await ref.read(settingsServiceProvider).load()).appCatalogRelays,
|
||||
);
|
||||
final localRelaysSnapshot = useFuture(localRelaysFuture);
|
||||
final localRelays = localRelaysSnapshot.data?.toList()?..sort();
|
||||
|
||||
// Use local relays if set, otherwise default
|
||||
final effectiveSavedRelays = (localRelays != null && localRelays.isNotEmpty)
|
||||
? localRelays
|
||||
: [_kDefaultRelay];
|
||||
final relayState = ref.watch(appCatalogRelayStateProvider);
|
||||
final effectiveSavedRelays = relayState.relays.toList()..sort();
|
||||
|
||||
// Local pending state - initialized from effective saved relays
|
||||
final pendingRelays = useState<List<String>?>(null);
|
||||
|
||||
// Determine if data is still loading
|
||||
final isLoading =
|
||||
localRelaysSnapshot.connectionState == ConnectionState.waiting;
|
||||
|
||||
// Initialize pending from effective saved when first loaded
|
||||
useEffect(() {
|
||||
if (pendingRelays.value == null && !isLoading) {
|
||||
if (pendingRelays.value == null) {
|
||||
pendingRelays.value = effectiveSavedRelays;
|
||||
}
|
||||
return null;
|
||||
}, [isLoading, effectiveSavedRelays]);
|
||||
}, [effectiveSavedRelays]);
|
||||
|
||||
// Current display relays (pending if modified, else effective saved)
|
||||
final displayRelays = pendingRelays.value ?? effectiveSavedRelays;
|
||||
@@ -154,25 +135,9 @@ class RelayManagementCard extends HookConsumerWidget {
|
||||
isApplying.value = true;
|
||||
|
||||
try {
|
||||
final settingsService = ref.read(settingsServiceProvider);
|
||||
final relaysToSave = displayRelays.toSet();
|
||||
|
||||
// Save to local settings
|
||||
final updated = await settingsService.update(
|
||||
(s) => s.copyWith(appCatalogRelays: relaysToSave));
|
||||
|
||||
// Verify write succeeded
|
||||
if (updated.appCatalogRelays == null ||
|
||||
!updated.appCatalogRelays!.containsAll(relaysToSave)) {
|
||||
throw StateError('Failed to persist relay configuration');
|
||||
}
|
||||
|
||||
// Delay to ensure the platform-side write is fully committed
|
||||
// before the native restart kills the process
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
// Restart app with database clear
|
||||
await restartApp();
|
||||
await ref
|
||||
.read(appCatalogRelayServiceProvider)
|
||||
.createAndRestart(displayRelays.toSet());
|
||||
} catch (e) {
|
||||
isApplying.value = false;
|
||||
if (context.mounted) {
|
||||
@@ -205,6 +170,34 @@ class RelayManagementCard extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (relayState.isChecking) ...[
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (relayState.error != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Could not check relay settings. Current relays remain active.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: relayState.isChecking
|
||||
? null
|
||||
: () => ref
|
||||
.read(appCatalogRelayServiceProvider)
|
||||
.checkForUpdates(),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Info text
|
||||
Container(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# FEAT-008 — Device Relay List
|
||||
|
||||
## Goal
|
||||
|
||||
Store the app-catalog relay list as a device-signed kind 10067 event while
|
||||
keeping `wss://relay.zapstore.dev` as the hardcoded offline-safe default.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Migrating relay settings or legacy Amber-signed kind 10067 events
|
||||
- Private or encrypted relay entries
|
||||
- Discovering kind 10067 events from any relay except the hardcoded default
|
||||
- Applying relay changes without an app restart
|
||||
|
||||
## User-Visible Behavior
|
||||
|
||||
- The app starts with the latest accepted device-authored kind 10067 event from
|
||||
local storage, or the hardcoded default when no event exists.
|
||||
- The app checks the hardcoded default relay for a newer device-authored event
|
||||
in the background without blocking local UI.
|
||||
- A changed remote relay list is shown to the user and requires confirmation
|
||||
before the app clears cached data and restarts.
|
||||
- Declining keeps the current relay list. The same change may be offered again
|
||||
after a later background check.
|
||||
- Manual relay changes create a public kind 10067 event, signed by the device
|
||||
key, and use the same confirm-and-restart flow.
|
||||
- Network errors leave the current/default relay list usable.
|
||||
|
||||
## Event Contract
|
||||
|
||||
- Kind: `10067` (replaceable)
|
||||
- Author: the local device key
|
||||
- Public relays: one `r` tag per normalized WebSocket URL
|
||||
- Content: empty in this phase
|
||||
- Bootstrap/query/publish relay: `wss://relay.zapstore.dev` only
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Missing, empty, malformed, or non-device-authored events are ignored.
|
||||
- An empty relay list is invalid; the hardcoded default remains active.
|
||||
- A fetched event is not applied before confirmation.
|
||||
- Restart clears SQLite, so an accepted signed event is held temporarily in
|
||||
secure storage, restored into the fresh database, then removed.
|
||||
- If the app is paused during a background check, the request is cancelled.
|
||||
- A local event newer than the relay copy is republished on a later
|
||||
connectivity or lifecycle trigger; no polling is used.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Relay configuration no longer persists in `LocalSettings`.
|
||||
- [ ] The hardcoded default renders and queries successfully offline.
|
||||
- [ ] Only device-authored kind 10067 events can become active.
|
||||
- [ ] Remote changes require confirmation before restart.
|
||||
- [ ] Declining a change preserves the current relay list.
|
||||
- [ ] Accepted events survive the clear-and-restart cycle without remaining in
|
||||
secure storage.
|
||||
- [ ] Manual changes publish a device-signed event with public `r` tags.
|
||||
- [ ] Background failure and cancellation are explicit and do not block UI.
|
||||
@@ -0,0 +1,62 @@
|
||||
# WORK-014 — Device Relay List
|
||||
|
||||
**Feature:** FEAT-008-device-relays.md
|
||||
**Status:** Complete
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Remove relay persistence from local settings
|
||||
- Files: `lib/services/settings_service.dart`, `lib/main.dart`
|
||||
- [x] 2. Make kind 10067 app-managed instead of active-signer-resolved
|
||||
- Files: `../models/lib/src/models/relay_list.dart`
|
||||
- [x] 3. Add temporary restart handoff and background default-relay check
|
||||
- Files: `lib/services/app_catalog_relay_service.dart`
|
||||
- [x] 4. Integrate startup, lifecycle cancellation, and relay management UI
|
||||
- Files: `lib/main.dart`, `lib/widgets/relay_management_card.dart`
|
||||
- [x] 5. Cover local-first, confirmation, failure, and handoff behavior
|
||||
- [x] 6. Self-review against `spec/guidelines/INVARIANTS.md`
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Scenario | Expected | Status |
|
||||
|----------|----------|--------|
|
||||
| No local kind 10067 | Hardcoded default is active | [x] |
|
||||
| Accepted local event | Device event relays are active before UI readiness | [x] |
|
||||
| Changed remote event | User confirms before restart | [x] |
|
||||
| Declined remote event | Current relays remain active | [x] |
|
||||
| SQLite-clearing restart | Temporary event is restored, then erased | [x] |
|
||||
| Offline check | Current/default list remains usable | [x] |
|
||||
| Paused check | Remote request is cancelled | [x] |
|
||||
|
||||
## Decisions
|
||||
|
||||
### 2026-07-10 — SQLite event with temporary restart handoff
|
||||
|
||||
**Context:** Relay changes clear SQLite, but secure storage must not remain the
|
||||
relay configuration source.
|
||||
**Decision:** Store accepted configuration as kind 10067 in SQLite. Copy only
|
||||
the signed event map to a dedicated secure-storage key immediately before
|
||||
restart, restore it after database initialization, then delete the key.
|
||||
**Rationale:** Preserves the accepted event through the existing restart while
|
||||
keeping secure storage out of normal relay resolution.
|
||||
|
||||
### 2026-07-10 — Fixed bootstrap relay
|
||||
|
||||
**Context:** Discovering the relay-list event through the configured relay list
|
||||
is circular.
|
||||
**Decision:** Query and publish kind 10067 only through the hardcoded
|
||||
`wss://relay.zapstore.dev`.
|
||||
**Rationale:** The fallback is deterministic and available without stored
|
||||
configuration.
|
||||
|
||||
## Spec Issues
|
||||
|
||||
_None_
|
||||
|
||||
## Progress Notes
|
||||
|
||||
**2026-07-10:** Scope intentionally excludes migration and private relay
|
||||
content. Legacy local settings and Amber-authored events are ignored.
|
||||
|
||||
**2026-07-10:** Implementation complete. Zapstore analysis and all 57 app tests
|
||||
pass; models analysis and the full models test suite pass.
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:zapstore/services/app_catalog_relay_service.dart';
|
||||
|
||||
void main() {
|
||||
group('normalizeRelaySet', () {
|
||||
test('normalizes and deduplicates websocket relay URLs', () {
|
||||
expect(
|
||||
normalizeRelaySet({
|
||||
'wss://RELAY.example:443/',
|
||||
'wss://relay.example',
|
||||
'ws://localhost:80/',
|
||||
}),
|
||||
{'wss://relay.example', 'ws://localhost'},
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-websocket and malformed URLs', () {
|
||||
expect(
|
||||
normalizeRelaySet({'https://relay.example', 'not a relay', 'wss://'}),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('decideRelayUpdate', () {
|
||||
final now = DateTime.utc(2026, 7, 10);
|
||||
|
||||
test('offers a changed current-or-newer remote list', () {
|
||||
expect(
|
||||
decideRelayUpdate(
|
||||
currentRelays: {'wss://current.example'},
|
||||
localCreatedAt: now,
|
||||
remoteRelays: {'wss://new.example'},
|
||||
remoteCreatedAt: now,
|
||||
),
|
||||
RelayUpdateAction.offerRemote,
|
||||
);
|
||||
});
|
||||
|
||||
test('does nothing when the normalized relay set is unchanged', () {
|
||||
expect(
|
||||
decideRelayUpdate(
|
||||
currentRelays: {'wss://same.example'},
|
||||
localCreatedAt: now,
|
||||
remoteRelays: {'wss://same.example'},
|
||||
remoteCreatedAt: now.add(const Duration(minutes: 1)),
|
||||
),
|
||||
RelayUpdateAction.none,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes the accepted list when the relay copy is older', () {
|
||||
expect(
|
||||
decideRelayUpdate(
|
||||
currentRelays: {'wss://current.example'},
|
||||
localCreatedAt: now,
|
||||
remoteRelays: {'wss://old.example'},
|
||||
remoteCreatedAt: now.subtract(const Duration(minutes: 1)),
|
||||
),
|
||||
RelayUpdateAction.publishLocal,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the hardcoded default when no event exists anywhere', () {
|
||||
expect(
|
||||
decideRelayUpdate(
|
||||
currentRelays: {'wss://relay.zapstore.dev'},
|
||||
localCreatedAt: null,
|
||||
remoteRelays: null,
|
||||
remoteCreatedAt: null,
|
||||
),
|
||||
RelayUpdateAction.none,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -19,5 +19,15 @@ void main() {
|
||||
final updated = settings.copyWith(backgroundAutoUpdatesEnabled: true);
|
||||
expect(updated.backgroundAutoUpdatesEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('discards legacy relay settings', () {
|
||||
final restored = LocalSettings.fromJson({
|
||||
'relays': ['wss://legacy.example'],
|
||||
'logLevel': 'info',
|
||||
});
|
||||
|
||||
expect(restored.toJson(), isNot(contains('relays')));
|
||||
expect(restored.toJson()['logLevel'], 'info');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user