Store app catalog relays as device-signed kind 10067 events.

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