mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Encrypted app catalog relay list, make secure storage the source of truth for now
This commit is contained in:
+12
-4
@@ -201,6 +201,8 @@ class ZapstoreHome extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
const _kDefaultAppCatalogRelay = 'wss://relay.zapstore.dev';
|
||||
|
||||
final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||
@@ -208,7 +210,13 @@ final appInitializationProvider = FutureProvider<void>((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<void>((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',
|
||||
|
||||
@@ -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<String?> 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<Set<String>?> 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<String>.from(list.cast<String>());
|
||||
} 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<void> setAppCatalogRelays(Set<String> relays) async {
|
||||
await _storage.write(
|
||||
key: _appCatalogRelaysKey,
|
||||
value: jsonEncode(relays.toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the AmberSigner pubkey in flutter_secure_storage.
|
||||
|
||||
@@ -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<StorageState<AppCatalogRelayList>?>((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 ?? <String>{}).toList()
|
||||
final remoteRelays = (existingRelayList?.readRelays ?? <String>{}).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<String> 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<List<String>?>(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<void> applyChanges() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
// 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-14
@@ -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:
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user