diff --git a/lib/main.dart b/lib/main.dart index 15ee050..cf0cf6c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:purplebase/purplebase.dart'; import 'package:amber_signer/amber_signer.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/background_update_service.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/theme.dart'; @@ -218,7 +219,8 @@ final appInitializationProvider = FutureProvider((ref) async { ), defaultRelays: { 'default': {'wss://relay.zapstore.dev'}, - 'bootstrap': {'wss://purplepag.es', 'wss://relay.zapstore.dev'}, + 'bootstrap': {'wss://relay.zapstore.dev'}, + // TODO: add 'wss://purplepag.es' back when it's fixed 'AppCatalog': {'wss://relay.zapstore.dev'}, 'social': { 'wss://relay.damus.io', @@ -245,7 +247,10 @@ final appInitializationProvider = FutureProvider((ref) async { }); // AmberSigner provider for Nostr authentication -final amberSignerProvider = Provider(AmberSigner.new); +// Uses SecureStoragePubkeyPersistence to survive database clears +final amberSignerProvider = Provider( + (ref) => AmberSigner(ref, persistence: SecureStoragePubkeyPersistence()), +); Future _attemptAutoSignIn(Ref ref) async { try { diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 0a04536..f9bf4b7 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -23,9 +23,7 @@ import 'package:zapstore/theme.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; import 'package:zapstore/widgets/nwc_widgets.dart'; - -// Note: Relay debugging features have been removed as they depend on internal APIs -// that are no longer public in purplebase 0.3.3+ +import 'package:zapstore/widgets/relay_management_card.dart'; /// Profile screen for authentication and app settings class ProfileScreen extends ConsumerWidget { @@ -64,6 +62,11 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), + // App Catalog Relay Management Section + const RelayManagementCard(), + + const SizedBox(height: 16), + // Data Management Section const _DataManagementSection(), @@ -1410,8 +1413,8 @@ class _DataManagementSection extends ConsumerWidget { ], ), content: const Text( - 'Clears all cached data (except NWC secret) and restarts the app. ' - 'You will be signed out.', + 'Clears all cached data and restarts the app. ' + 'Your sign-in and wallet connection will be preserved.', ), actions: [ TextButton( diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart index 7af1ae1..25e5496 100644 --- a/lib/services/secure_storage_service.dart +++ b/lib/services/secure_storage_service.dart @@ -1,3 +1,4 @@ +import 'package:amber_signer/amber_signer.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -34,8 +35,27 @@ class SecureStorageService { final value = await _storage.read(key: _nwcKey); return value?.isNotEmpty == true; } +} - /// Check if the install permission dialog has been shown +/// Persists the AmberSigner pubkey in flutter_secure_storage. +/// This survives app data clears (database deletion) and is encrypted. +class SecureStoragePubkeyPersistence implements AmberPubkeyPersistence { + static const _key = 'amber_pubkey'; + + @override + Future persistPubkey(String pubkey) async { + await SecureStorageService._storage.write(key: _key, value: pubkey); + } + + @override + Future loadPubkey() async { + return SecureStorageService._storage.read(key: _key); + } + + @override + Future clearPubkey() async { + await SecureStorageService._storage.delete(key: _key); + } } final secureStorageServiceProvider = Provider( diff --git a/lib/widgets/relay_management_card.dart b/lib/widgets/relay_management_card.dart new file mode 100644 index 0000000..d2c1d0d --- /dev/null +++ b/lib/widgets/relay_management_card.dart @@ -0,0 +1,592 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +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/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Provider for the user's app catalog relay list. +final _appCatalogRelayListProvider = + Provider?>((ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + + if (pubkey == null) { + return null; + } + + return ref.watch( + query( + authors: {pubkey}, + limit: 1, + source: const LocalAndRemoteSource( + relays: 'bootstrap', + stream: false, + ), + subscriptionPrefix: 'user-appcatalog-relays', + ), + ); +}); + +/// App Catalog Relay Management Card - manages app catalog relays (kind 10067) +/// These are relays for discovering apps, NOT social relays like Damus/Primal. +/// +/// Changes are accumulated in memory and applied with "Apply Changes" which +/// publishes the relay list and restarts the app with a fresh database. +class RelayManagementCard extends HookConsumerWidget { + const RelayManagementCard({super.key}); + + static const _kDefaultRelay = 'wss://relay.zapstore.dev'; + + @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 relayUrlController = useTextEditingController(); + final hasText = useState(false); + final isApplying = useState(false); + + // Watch pool state for relay connection status + final poolState = ref.watch(poolStateProvider); + + // Watch remote relay list + final relayListState = ref.watch(_appCatalogRelayListProvider); + final existingRelayList = relayListState?.models.firstOrNull; + final savedRelays = (existingRelayList?.readRelays ?? {}).toList() + ..sort(); + + // If no relays saved, show default relay + final effectiveSavedRelays = + savedRelays.isEmpty ? [_kDefaultRelay] : savedRelays; + + // Local pending state - initialized from effective saved relays + final pendingRelays = useState?>(null); + + // Initialize pending from effective saved when first loaded + useEffect(() { + if (pendingRelays.value == null && relayListState is StorageData) { + pendingRelays.value = effectiveSavedRelays; + } + return null; + }, [relayListState]); + + // Current display relays (pending if modified, else effective saved) + final displayRelays = pendingRelays.value ?? effectiveSavedRelays; + final hasChanges = + pendingRelays.value != null && + !const ListEquality().equals( + pendingRelays.value, + effectiveSavedRelays, + ); + + // Listen to text changes to enable/disable add button + useEffect(() { + void listener() { + hasText.value = relayUrlController.text.trim().isNotEmpty; + } + + relayUrlController.addListener(listener); + return () => relayUrlController.removeListener(listener); + }, [relayUrlController]); + + void addRelay(String relayUrl) { + // Validate and normalize URL + final normalizedUrl = _validateAndNormalizeRelayUrl(relayUrl); + if (normalizedUrl == null) { + context.showError( + 'Invalid relay URL', + description: 'Must be a valid WebSocket URL (ws:// or wss://)', + ); + return; + } + + // Check for duplicates + final currentRelays = pendingRelays.value ?? savedRelays; + if (_isDuplicateRelay(normalizedUrl, currentRelays.toSet())) { + context.showError( + 'Relay already exists', + description: 'This relay is already in your list.', + ); + return; + } + + pendingRelays.value = [...currentRelays, normalizedUrl]..sort(); + relayUrlController.clear(); + } + + void removeRelay(String relayUrl) { + final currentRelays = pendingRelays.value ?? savedRelays; + 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) { + context.showError( + 'Cannot remove last relay', + description: 'App catalog relays cannot be empty. ' + 'Add another relay before removing this one.', + ); + return; + } + pendingRelays.value = newRelays; + } + + Future applyChanges() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + const 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'), + ), + ], + ), + ); + + if (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'); + } + 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); + + // Publish to bootstrap relays + await ref.storage.publish({ + signedRelayList, + }, source: const RemoteSource(relays: 'bootstrap')); + + // Show loading dialog + if (context.mounted) { + loadingDialogShown = true; + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: AppColors.darkSkeletonHighlight, + backgroundColor: AppColors.darkSkeletonBase, + ), + SizedBox(height: 16), + Text('Restarting...'), + ], + ), + ), + ); + } + + // Restart app with database clear + await restartApp(); + } catch (e) { + isApplying.value = false; + if (context.mounted) { + if (loadingDialogShown) { + Navigator.of(context, rootNavigator: true).pop(); + } + context.showError( + 'Failed to apply relay changes', + description: '$e', + actions: [('Retry', () => applyChanges())], + ); + } + } + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'App Catalog Relays', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Info text + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'These relays are used to discover apps, not for social content.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Current relays list + if (displayRelays.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Using default catalog relays. Add a relay to override.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.55), + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.separated( + shrinkWrap: true, + physics: displayRelays.length > 4 + ? const BouncingScrollPhysics() + : const NeverScrollableScrollPhysics(), + itemCount: displayRelays.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final relayUrl = displayRelays[index]; + + // Get relay connection status from pool state + final statusColor = + _getRelayStatusColor(poolState, relayUrl); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Status dot + Container( + width: 10, + height: 10, + margin: const EdgeInsets.only(left: 4, right: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + boxShadow: statusColor != Colors.grey + ? [ + BoxShadow( + color: + statusColor.withValues(alpha: 0.4), + blurRadius: 4, + spreadRadius: 1, + ), + ] + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + relayUrl, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + width: 24, + height: 24, + child: IconButton( + icon: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.error, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 24, + height: 24, + ), + onPressed: isApplying.value + ? null + : () => removeRelay(relayUrl), + tooltip: 'Remove relay', + ), + ), + ], + ), + ); + }, + ), + ), + + const SizedBox(height: 8), + + // Add relay input + Row( + children: [ + Expanded( + child: TextField( + controller: relayUrlController, + enabled: !isApplying.value, + decoration: InputDecoration( + hintText: 'wss://relay.example.com', + hintStyle: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + isDense: true, + ), + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + onSubmitted: isApplying.value + ? null + : (value) { + if (value.trim().isNotEmpty) { + addRelay(value.trim()); + } + }, + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: isApplying.value || !hasText.value + ? null + : () { + final url = relayUrlController.text.trim(); + if (url.isNotEmpty) { + addRelay(url); + } + }, + icon: const Icon(Icons.add), + tooltip: 'Add relay', + style: IconButton.styleFrom( + backgroundColor: isApplying.value || !hasText.value + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3) + : Theme.of(context).colorScheme.primaryContainer, + foregroundColor: isApplying.value || !hasText.value + ? Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4) + : Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ], + ), + + // Apply Changes button (only shown when there are changes) + if (hasChanges) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: isApplying.value ? null : applyChanges, + icon: isApplying.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.refresh), + label: Text( + isApplying.value ? 'Applying...' : 'Apply Changes', + ), + ), + ), + ], + ], + ), + ), + ); + } + + /// Validates and normalizes a relay URL. + /// Uses wss:// unless ws:// is explicitly specified. + /// If no protocol is provided, wss:// is assumed. + /// Normalizes default ports (443 for wss, 80 for ws) by omitting them. + static String? _validateAndNormalizeRelayUrl(String input) { + var url = input.trim(); + + // Add wss:// if no protocol specified + if (!url.startsWith('ws://') && !url.startsWith('wss://')) { + url = 'wss://$url'; + } + + final uri = Uri.tryParse(url); + if (uri == null || uri.host.isEmpty) return null; + + // Use wss:// unless ws:// is explicitly specified + final scheme = uri.scheme == 'ws' ? 'ws' : 'wss'; + final path = uri.path.endsWith('/') + ? uri.path.substring(0, uri.path.length - 1) + : uri.path; + + // Normalize default ports: omit 443 for wss and 80 for ws + final isDefaultPort = (scheme == 'wss' && uri.port == 443) || + (scheme == 'ws' && uri.port == 80); + final normalizedPort = + uri.hasPort && !isDefaultPort ? uri.port : null; + + return Uri( + scheme: scheme, + host: uri.host.toLowerCase(), + port: normalizedPort, + path: path.isEmpty ? null : path, + ).toString(); + } + + /// Checks if a relay URL already exists in the set (case-insensitive). + static bool _isDuplicateRelay( + String normalizedUrl, + Set existingRelays, + ) { + final newLower = normalizedUrl.toLowerCase(); + for (final existing in existingRelays) { + if (existing.toLowerCase() == newLower) { + return true; + } + } + return false; + } + + /// Gets the best connection status color for a relay from pool state. + /// Returns green for streaming/loading, yellow for connecting/waiting, + /// red for failed, grey for disconnected/not found. + static Color _getRelayStatusColor(PoolState? poolState, String relayUrl) { + if (poolState == null) return Colors.grey; + + final subscriptions = poolState.subscriptions; + final relayLower = relayUrl.toLowerCase(); + + RelaySubPhase? bestPhase; + + for (final sub in subscriptions.values) { + for (final entry in sub.relays.entries) { + if (entry.key.toLowerCase() == relayLower) { + final phase = entry.value.phase; + // Prioritize: streaming > loading > connecting > waiting > others + if (bestPhase == null || + _phasePriority(phase) > _phasePriority(bestPhase)) { + bestPhase = phase; + } + } + } + } + + if (bestPhase == null) return Colors.grey; + + return switch (bestPhase) { + RelaySubPhase.streaming => Colors.green, + RelaySubPhase.loading => Colors.green, + RelaySubPhase.connecting => Colors.amber, + RelaySubPhase.waiting => Colors.amber, + RelaySubPhase.failed => Colors.red, + RelaySubPhase.disconnected => Colors.grey, + RelaySubPhase.closed => Colors.grey, + }; + } + + static int _phasePriority(RelaySubPhase phase) { + return switch (phase) { + RelaySubPhase.streaming => 6, + RelaySubPhase.loading => 5, + RelaySubPhase.connecting => 4, + RelaySubPhase.waiting => 3, + RelaySubPhase.failed => 2, + RelaySubPhase.disconnected => 1, + RelaySubPhase.closed => 0, + }; + } +} diff --git a/pubspec.lock b/pubspec.lock index 96efb8a..41adf14 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,8 +5,8 @@ packages: dependency: "direct main" description: path: "." - ref: "4d065ba" - resolved-ref: "4d065badcef18ea397dff40c46fcce114ecca12a" + ref: "6cbedbe" + resolved-ref: "6cbedbe6ae58a952c564979002258840921b041b" url: "https://github.com/purplebase/amber_signer" source: git version: "0.2.0" diff --git a/pubspec.yaml b/pubspec.yaml index 453f415..cda80bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,7 +71,7 @@ dependency_overrides: # path: ../amber_signer git: url: https://github.com/purplebase/amber_signer - ref: 4d065ba + ref: 6cbedbe dev_dependencies: flutter_test: