From 788b371f32c2b374231d3df7cef664473a1df97e Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sat, 27 Jun 2026 17:20:21 -0300 Subject: [PATCH] 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. --- lib/main.dart | 97 ++-- lib/services/app_catalog_relay_service.dart | 451 ++++++++++++++++++ lib/services/settings_service.dart | 79 +-- lib/widgets/relay_management_card.dart | 81 ++-- spec/features/FEAT-008-device-relays.md | 58 +++ spec/work/WORK-014-device-relays.md | 62 +++ .../app_catalog_relay_service_test.dart | 76 +++ test/services/settings_service_test.dart | 10 + 8 files changed, 793 insertions(+), 121 deletions(-) create mode 100644 lib/services/app_catalog_relay_service.dart create mode 100644 spec/features/FEAT-008-device-relays.md create mode 100644 spec/work/WORK-014-device-relays.md create mode 100644 test/services/app_catalog_relay_service_test.dart diff --git a/lib/main.dart b/lib/main.dart index b036796..6c9cd46 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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((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((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((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((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((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( /// 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 _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 _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 _recordAppOpened() async { - await SettingsService().update((s) => s.copyWith(lastAppOpened: DateTime.now())); + await SettingsService().update( + (s) => s.copyWith(lastAppOpened: DateTime.now()), + ); } } diff --git a/lib/services/app_catalog_relay_service.dart b/lib/services/app_catalog_relay_service.dart new file mode 100644 index 0000000..758d465 --- /dev/null +++ b/lib/services/app_catalog_relay_service.dart @@ -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 relays; + final bool isChecking; + final Object? error; + + AppCatalogRelayState copyWith({ + Set? 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( + (_) => const AppCatalogRelayState(), +); + +final appCatalogRelayServiceProvider = Provider((ref) { + final service = AppCatalogRelayService(ref); + ref.onDispose(service.cancelCurrentCheck); + return service; +}); + +class AppCatalogRelayService { + AppCatalogRelayService(this.ref); + + final Ref ref; + Request? _activeRequest; + bool _isChecking = false; + bool _cancelRequested = false; + + Future 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 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 createAndRestart(Set 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 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( + 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 _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.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 _loadAcceptedLocal() async { + final devicePubkey = ref.read(devicePubkeyProvider); + if (devicePubkey == null) return null; + + final events = await ref + .read(storageNotifierProvider.notifier) + .query( + RequestFilter( + authors: {devicePubkey}, + limit: 1, + ).toRequest(), + source: const LocalSource(), + subscriptionPrefix: 'app-relay-list-local', + ); + return _latestValid(events, devicePubkey); + } + + AppCatalogRelayList? _latestValid( + Iterable 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? _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 _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 _confirmRemoteChange( + Set current, + Set 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( + 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 _stageAndRestart(AppCatalogRelayList event) async { + await _handoffStorage.write( + key: _handoffKey, + value: jsonEncode(event.toMap()), + ); + try { + await restartApp(); + } catch (_) { + await _clearHandoff(); + rethrow; + } + } + + Future _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 _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 relays) { + final normalized = Set.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 normalizeRelaySet(Iterable relays) { + final normalized = {}; + 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 currentRelays, + required DateTime? localCreatedAt, + required Set? 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 a, Set b) => + a.length == b.length && a.containsAll(b); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index b91e7a8..a064a63 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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? 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 json) { return LocalSettings( nwcConnectionString: json['nwc'] as String?, - appCatalogRelays: (json['relays'] as List?)?.cast().toSet(), lastAppOpened: _parseDateTime(json['lastAppOpened']), seenUntil: _parseDateTime(json['seenUntil']), deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']), @@ -49,22 +46,20 @@ class LocalSettings { } Map 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? 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); + final decoded = jsonDecode(json) as Map; + 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 _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? relays; - if (relaysJson != null && relaysJson.isNotEmpty) { - try { - relays = Set.from((jsonDecode(relaysJson) as List).cast()); - } 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 update( - LocalSettings Function(LocalSettings) updater) async { + LocalSettings Function(LocalSettings) updater, + ) async { final current = await load(); final updated = updater(current); await save(updated); diff --git a/lib/widgets/relay_management_card.dart b/lib/widgets/relay_management_card.dart index 4dc3d2b..f05e3d6 100644 --- a/lib/widgets/relay_management_card.dart +++ b/lib/widgets/relay_management_card.dart @@ -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?>(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.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( diff --git a/spec/features/FEAT-008-device-relays.md b/spec/features/FEAT-008-device-relays.md new file mode 100644 index 0000000..c51c6e7 --- /dev/null +++ b/spec/features/FEAT-008-device-relays.md @@ -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. diff --git a/spec/work/WORK-014-device-relays.md b/spec/work/WORK-014-device-relays.md new file mode 100644 index 0000000..ed2a0ab --- /dev/null +++ b/spec/work/WORK-014-device-relays.md @@ -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. diff --git a/test/services/app_catalog_relay_service_test.dart b/test/services/app_catalog_relay_service_test.dart new file mode 100644 index 0000000..4b5fec8 --- /dev/null +++ b/test/services/app_catalog_relay_service_test.dart @@ -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, + ); + }); + }); +} diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart index 717ed2f..a2bb733 100644 --- a/test/services/settings_service_test.dart +++ b/test/services/settings_service_test.dart @@ -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'); + }); }); }