diff --git a/lib/main.dart b/lib/main.dart index 31806bf..bfd67bb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -201,6 +201,8 @@ class ZapstoreHome extends StatelessWidget { } } +const _kDefaultAppCatalogRelay = 'wss://relay.zapstore.dev'; + final appInitializationProvider = FutureProvider((ref) async { final dir = await getApplicationSupportDirectory(); final dbPath = path.join(dir.path, 'zapstore.db'); @@ -208,7 +210,13 @@ final appInitializationProvider = FutureProvider((ref) async { // Clear storage if requested from a clear all operation await maybeClearStorage(dbPath); - // Initialize storage + // Load local relay config BEFORE storage init + // This ensures custom relays work even when signed out + final secureStorage = ref.read(secureStorageServiceProvider); + final localRelays = await secureStorage.getAppCatalogRelays(); + final appCatalogRelays = localRelays ?? {_kDefaultAppCatalogRelay}; + + // Initialize storage with local relay config await ref.read( initializationProvider( StorageConfiguration( @@ -218,10 +226,10 @@ final appInitializationProvider = FutureProvider((ref) async { stream: false, ), defaultRelays: { - 'default': {'wss://relay.zapstore.dev'}, - 'bootstrap': {'wss://relay.zapstore.dev'}, + 'default': {_kDefaultAppCatalogRelay}, + 'bootstrap': {_kDefaultAppCatalogRelay}, // TODO: add 'wss://purplepag.es' back when it's fixed - 'AppCatalog': {'wss://relay.zapstore.dev'}, + 'AppCatalog': appCatalogRelays, 'social': { 'wss://relay.damus.io', 'wss://relay.primal.net', diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart index 25e5496..b982177 100644 --- a/lib/services/secure_storage_service.dart +++ b/lib/services/secure_storage_service.dart @@ -1,9 +1,12 @@ +import 'dart:convert'; + import 'package:amber_signer/amber_signer.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -/// Service for securely storing sensitive data (NWC connection strings) -/// using platform-native secure storage (Keychain on iOS, KeyStore on Android). +/// Service for securely storing sensitive data (NWC connection strings, +/// app catalog relays) using platform-native secure storage +/// (Keychain on iOS, KeyStore on Android). /// /// This does NOT require user authentication - data is encrypted at rest /// by the platform's secure storage mechanism. @@ -13,6 +16,7 @@ class SecureStorageService { static const _storage = FlutterSecureStorage(); static const _nwcKey = 'nwc_connection_string'; + static const _appCatalogRelaysKey = 'app_catalog_relays'; /// Get the stored NWC connection string Future getNWCString() async { @@ -35,6 +39,38 @@ class SecureStorageService { final value = await _storage.read(key: _nwcKey); return value?.isNotEmpty == true; } + + // ========================================================================= + // App Catalog Relays + // ========================================================================= + + /// Get the stored app catalog relay URLs. + /// + /// Returns null if no relays have been stored (use defaults). + /// Returns empty set if user explicitly cleared all relays (invalid state, + /// but handled gracefully). + Future?> getAppCatalogRelays() async { + final json = await _storage.read(key: _appCatalogRelaysKey); + if (json == null || json.isEmpty) return null; + try { + final list = jsonDecode(json) as List; + return Set.from(list.cast()); + } catch (e) { + // Corrupted data - treat as unset + return null; + } + } + + /// Store app catalog relay URLs. + /// + /// This is the local source of truth for relay configuration, + /// used to initialize the app before sign-in. + Future setAppCatalogRelays(Set relays) async { + await _storage.write( + key: _appCatalogRelaysKey, + value: jsonEncode(relays.toList()), + ); + } } /// Persists the AmberSigner pubkey in flutter_secure_storage. diff --git a/lib/widgets/relay_management_card.dart b/lib/widgets/relay_management_card.dart index 5835a28..3e469c5 100644 --- a/lib/widgets/relay_management_card.dart +++ b/lib/widgets/relay_management_card.dart @@ -6,10 +6,12 @@ import 'package:models/models.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; -/// Provider for the user's app catalog relay list. +/// Provider for the user's app catalog relay list (from signed 10067 event). +/// Returns null when signed out. final _appCatalogRelayListProvider = Provider?>((ref) { final pubkey = ref.watch(Signer.activePubkeyProvider); @@ -31,6 +33,7 @@ final _appCatalogRelayListProvider = ); }); + /// App Catalog Relay Management Card - manages app catalog relays (kind 10067) /// These are relays for discovering apps, NOT social relays like Damus/Primal. /// @@ -44,11 +47,7 @@ class RelayManagementCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - - // Only show when user is signed in - if (signedInPubkey == null) { - return const SizedBox.shrink(); - } + final isSignedIn = signedInPubkey != null; final relayUrlController = useTextEditingController(); final hasText = useState(false); @@ -57,27 +56,50 @@ class RelayManagementCard extends HookConsumerWidget { // Watch pool state for relay connection status final poolState = ref.watch(poolStateProvider); - // Watch remote relay list - final relayListState = ref.watch(_appCatalogRelayListProvider); + // Load local relays once - they only change on app restart + final localRelaysFuture = useMemoized( + () => ref.read(secureStorageServiceProvider).getAppCatalogRelays(), + ); + final localRelaysSnapshot = useFuture(localRelaysFuture); + final localRelays = localRelaysSnapshot.data?.toList()?..sort(); + final hasLocalRelays = localRelays != null && localRelays.isNotEmpty; + + // Only check 10067 if no local relays are stored + // This makes secure storage the authoritative local source + final relayListState = hasLocalRelays + ? null + : ref.watch(_appCatalogRelayListProvider); final existingRelayList = relayListState?.models.firstOrNull; - final savedRelays = (existingRelayList?.readRelays ?? {}).toList() + final remoteRelays = (existingRelayList?.readRelays ?? {}).toList() ..sort(); - // If no relays saved, show default relay - final effectiveSavedRelays = savedRelays.isEmpty - ? [_kDefaultRelay] - : savedRelays; + // Determine effective saved relays: + // 1. Local secure storage (if set) - always wins + // 2. Remote 10067 (if signed in and no local relays) + // 3. Default relay + List effectiveSavedRelays; + if (hasLocalRelays) { + effectiveSavedRelays = localRelays; + } else if (remoteRelays.isNotEmpty) { + effectiveSavedRelays = remoteRelays; + } else { + effectiveSavedRelays = [_kDefaultRelay]; + } // Local pending state - initialized from effective saved relays final pendingRelays = useState?>(null); + // Determine if data is still loading + final isLoading = localRelaysSnapshot.connectionState == ConnectionState.waiting || + (!hasLocalRelays && relayListState is StorageLoading); + // Initialize pending from effective saved when first loaded useEffect(() { - if (pendingRelays.value == null && relayListState is StorageData) { + if (pendingRelays.value == null && !isLoading) { pendingRelays.value = effectiveSavedRelays; } return null; - }, [relayListState]); + }, [isLoading, effectiveSavedRelays]); // Current display relays (pending if modified, else effective saved) final displayRelays = pendingRelays.value ?? effectiveSavedRelays; @@ -110,7 +132,7 @@ class RelayManagementCard extends HookConsumerWidget { } // Check for duplicates - final currentRelays = pendingRelays.value ?? savedRelays; + final currentRelays = pendingRelays.value ?? effectiveSavedRelays; if (_isDuplicateRelay(normalizedUrl, currentRelays.toSet())) { context.showError( 'Relay already exists', @@ -124,7 +146,7 @@ class RelayManagementCard extends HookConsumerWidget { } void removeRelay(String relayUrl) { - final currentRelays = pendingRelays.value ?? savedRelays; + final currentRelays = pendingRelays.value ?? effectiveSavedRelays; final newRelays = currentRelays.where((r) => r != relayUrl).toList(); // App catalog relays can never be empty - show error if trying to remove last if (newRelays.isEmpty) { @@ -140,65 +162,57 @@ class RelayManagementCard extends HookConsumerWidget { } Future applyChanges() async { - final confirmed = await showDialog( + // Show confirmation dialog with privacy option (only when signed in) + final result = await showDialog<({bool confirmed, bool makePrivate})>( context: context, - builder: (context) => AlertDialog( - title: Row( - children: [ - Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 8), - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text('Apply Relay Changes'), - ), - ), - ], - ), - content: const Text( - 'Changing app catalog relays will clear cached app data and restart the app. ' - 'Your sign-in and wallet connection will be preserved.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Apply Changes'), - ), - ], - ), + builder: (context) => _ApplyRelayChangesDialog(isSignedIn: isSignedIn), ); - if (confirmed != true || !context.mounted) return; + if (result?.confirmed != true || !context.mounted) return; isApplying.value = true; var loadingDialogShown = false; try { - final signer = ref.read(Signer.activeSignerProvider); - if (signer == null) { - isApplying.value = false; - if (context.mounted) { - context.showError('Sign in required'); + final secureStorage = ref.read(secureStorageServiceProvider); + final relaysToSave = displayRelays.toSet(); + + // Always save to local secure storage (works signed out or in) + await secureStorage.setAppCatalogRelays(relaysToSave); + + // If signed in, also publish 10067 event for cross-device sync + if (isSignedIn) { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + isApplying.value = false; + if (context.mounted) { + context.showError('Sign in required to publish relay list'); + } + return; } - return; - } - // Create and sign the relay list (use displayRelays which reflects user's intent) - final partialRelayList = PartialAppCatalogRelayList(); - for (final relay in displayRelays) { - partialRelayList.addReadRelay(relay); - } - final signedRelayList = await partialRelayList.signWith(signer); + // Create relay list (private or public based on user choice) + final PartialAppCatalogRelayList partialRelayList; + if (result!.makePrivate) { + // Encrypted: all relays in content field + partialRelayList = PartialAppCatalogRelayList.withEncryptedRelays( + publicRelays: {}, + privateRelays: relaysToSave, + ); + } else { + // Public: relays in r tags + partialRelayList = PartialAppCatalogRelayList(); + for (final relay in displayRelays) { + partialRelayList.addReadRelay(relay); + } + } + final signedRelayList = await partialRelayList.signWith(signer); - // Publish to bootstrap relays - await ref.storage.publish({ - signedRelayList, - }, source: const RemoteSource(relays: 'bootstrap')); + // Publish to bootstrap relays + await ref.storage.publish({ + signedRelayList, + }, source: const RemoteSource(relays: 'bootstrap')); + } // Show loading dialog if (context.mounted) { @@ -281,7 +295,9 @@ class RelayManagementCard extends HookConsumerWidget { const SizedBox(width: 10), Expanded( child: Text( - 'These relays are used to discover apps, not social content. Modify this list at your own risk.', + isSignedIn + ? 'These relays are used to discover apps, not social content. Modify this list at your own risk.' + : 'These relays are used to discover apps. Sign in to sync relay settings across devices.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of( context, @@ -599,3 +615,94 @@ class RelayManagementCard extends HookConsumerWidget { }; } } + +/// Dialog for confirming relay changes with privacy option. +class _ApplyRelayChangesDialog extends HookWidget { + const _ApplyRelayChangesDialog({required this.isSignedIn}); + + final bool isSignedIn; + + @override + Widget build(BuildContext context) { + final makePrivate = useState(false); + + return AlertDialog( + title: Row( + children: [ + Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text('Apply Relay Changes'), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Changing app catalog relays will clear cached app data and restart the app. ' + 'Your sign-in and wallet connection will be preserved.', + ), + if (isSignedIn) ...[ + const SizedBox(height: 16), + CheckboxListTile( + value: makePrivate.value, + onChanged: (v) => makePrivate.value = v ?? false, + title: const Text('Make relay selection private'), + subtitle: const Text( + 'Encrypted — only you can see these relays', + ), + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + ], + if (!isSignedIn) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Sign in to sync relay settings across devices.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop( + context, + (confirmed: true, makePrivate: makePrivate.value), + ), + child: const Text('Apply Changes'), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 339a6ce..f4a64b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -581,26 +581,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -653,10 +653,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -669,8 +669,8 @@ packages: dependency: "direct main" description: path: "." - ref: "641e29c" - resolved-ref: "641e29caa931bae99214cd44d8708c3da10ff4dd" + ref: "3c76e2e" + resolved-ref: "3c76e2e1cd5145cb57303d3ac4ff25ddb71d73bb" url: "https://github.com/purplebase/models" source: git version: "0.3.3" @@ -1141,10 +1141,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.7" timezone: dependency: transitive description: @@ -1261,10 +1261,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 742ba9d..53ff45e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,7 +61,7 @@ dependency_overrides: # path: ../../purplebase/models git: url: https://github.com/purplebase/models - ref: 641e29c + ref: 3c76e2e purplebase: # path: ../../purplebase/purplebase git: