From a362e1c2c881000dee8401ed1b9ee532f74fd2ad Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 9 Apr 2026 21:49:08 -0300 Subject: [PATCH] Back up apps (part of #20) --- lib/constants/app_constants.dart | 10 +++- lib/screens/profile_screen.dart | 32 ++++++++++++ lib/services/secure_storage_service.dart | 25 +++++++++ lib/services/updates_service.dart | 64 ++++++++++++++++++++++++ spec/guidelines/INVARIANTS.md | 4 +- 5 files changed, 130 insertions(+), 5 deletions(-) diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index 902b622..9157fa5 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -22,12 +22,15 @@ const kZapstoreCommunityPubkey = /// Identifier for storing user saved apps const kAppBookmarksIdentifier = 'zapstore-bookmarks'; +/// Identifier for the encrypted backup of installed apps +const kInstalledAppsBackupIdentifier = 'zapstore-installed-backup'; + /// Event filter for app stacks - must run as schemaFilter so rejected events /// are never stored in local SQLite. /// /// Rejects: /// - Private/encrypted stacks (non-empty content, kind 30267 only) -/// - The user's own saved-apps bookmark stack +/// - The user's own saved-apps bookmark stack and installed-apps backup /// - Stacks with no public App (32267) references bool appStackEventFilter(Map event) { // Guard: only apply to AppStack events (kind 30267). @@ -44,7 +47,10 @@ bool appStackEventFilter(Map event) { for (final tag in tags) { if (tag is! List || tag.isEmpty) continue; - if (tag[0] == 'd' && tag.length > 1 && tag[1] == kAppBookmarksIdentifier) { + if (tag[0] == 'd' && + tag.length > 1 && + (tag[1] == kAppBookmarksIdentifier || + tag[1] == kInstalledAppsBackupIdentifier)) { return false; } } diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index e5e6c26..fdefb98 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -16,6 +16,7 @@ import 'package:purplebase/purplebase.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/nostr_route.dart'; import 'package:zapstore/widgets/common/profile_identity_row.dart'; @@ -1397,6 +1398,35 @@ class _EmptyState extends StatelessWidget { } } +class _InstalledAppsBackupToggle extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + if (pubkey == null) return const SizedBox.shrink(); + + final backupAsync = ref.watch(installedAppsBackupEnabledProvider); + final enabled = backupAsync.valueOrNull ?? false; + + return SwitchListTile( + secondary: CircleAvatar( + radius: 18, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon(Icons.backup, color: Theme.of(context).colorScheme.primary), + ), + title: const Text('Back up installed apps'), + subtitle: const Text('Encrypted backup to Nostr relays'), + value: enabled, + contentPadding: EdgeInsets.zero, + onChanged: (value) async { + await ref + .read(secureStorageServiceProvider) + .setInstalledAppsBackupEnabled(value); + ref.invalidate(installedAppsBackupEnabledProvider); + }, + ); + } +} + class _DataManagementSection extends ConsumerWidget { const _DataManagementSection(); @@ -1413,6 +1443,8 @@ class _DataManagementSection extends ConsumerWidget { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 16), + _InstalledAppsBackupToggle(), + const SizedBox(height: 8), ListTile( leading: CircleAvatar( radius: 18, diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart index 3c48569..99174b4 100644 --- a/lib/services/secure_storage_service.dart +++ b/lib/services/secure_storage_service.dart @@ -154,6 +154,21 @@ class SecureStorageService { value: jsonEncode(relays.toList()), ); } + + // ========================================================================= + // Installed Apps Backup + // ========================================================================= + + static const _installedAppsBackupKey = 'installed_apps_backup_enabled'; + + Future isInstalledAppsBackupEnabled() async { + final value = await _storage.read(key: _installedAppsBackupKey); + return value == 'true'; + } + + Future setInstalledAppsBackupEnabled(bool enabled) async { + await _storage.write(key: _installedAppsBackupKey, value: '$enabled'); + } } /// Persists the AmberSigner pubkey in flutter_secure_storage. @@ -189,3 +204,13 @@ final hasNwcStringProvider = FutureProvider.autoDispose((ref) async { final secureStorage = ref.watch(secureStorageServiceProvider); return secureStorage.hasNWCString(); }); + +/// Whether the installed apps backup setting is enabled. +/// +/// Use `ref.invalidate(installedAppsBackupEnabledProvider)` after toggling +/// the setting to refresh UI. +final installedAppsBackupEnabledProvider = + FutureProvider.autoDispose((ref) async { + final secureStorage = ref.watch(secureStorageServiceProvider); + return secureStorage.isInstalledAppsBackupEnabled(); +}); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index f87e1a2..4f34dc9 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -92,6 +92,7 @@ class UpdatePollerNotifier extends StateNotifier { final Ref ref; Timer? _pollTimer; + List _lastBackedUpIds = []; void _init() { ref.listen>(appInitializationProvider, (prev, next) { @@ -126,6 +127,7 @@ class UpdatePollerNotifier extends StateNotifier { lastCheckTime: DateTime.now(), clearError: true, ); + unawaited(_backupInstalledApps()); } catch (e) { debugPrint('[UpdatePoller] Check failed: $e'); state = state.copyWith( @@ -186,6 +188,68 @@ class UpdatePollerNotifier extends StateNotifier { state = state.copyWith(catalogedIds: result.catalogedIds); } + /// Best-effort backup of installed apps as an encrypted private stack. + /// Runs after each successful update check. Only publishes when the set + /// of cataloged installed apps has changed since the last backup. + Future _backupInstalledApps() async { + final pubkey = ref.read(Signer.activePubkeyProvider); + if (pubkey == null) return; + + final enabled = await ref + .read(secureStorageServiceProvider) + .isInstalledAppsBackupEnabled(); + if (!enabled) return; + + final signer = ref.read(Signer.activeSignerProvider)!; + final pmNotifier = ref.read(packageManagerProvider.notifier); + final installed = ref.read(packageManagerProvider).installed; + final platform = pmNotifier.platform; + final storage = ref.read(storageNotifierProvider.notifier); + + try { + final apps = await storage.query( + RequestFilter( + tags: { + '#d': installed.keys.toSet(), + '#f': {platform}, + }, + ).toRequest(), + source: const LocalSource(), + subscriptionPrefix: 'app-backup-resolve', + ); + + final appIds = + apps + .map((a) => '${a.event.kind}:${a.event.pubkey}:${a.identifier}') + .toList() + ..sort(); + + if (_listEquals(appIds, _lastBackedUpIds)) return; + + final partialStack = PartialAppStack.withEncryptedApps( + name: 'Installed Apps', + identifier: kInstalledAppsBackupIdentifier, + apps: appIds, + platform: platform, + ); + + final signed = await partialStack.signWith(signer); + await storage.save({signed}); + await storage.publish({signed}, relays: {'AppCatalog', 'social'}); + _lastBackedUpIds = appIds; + } catch (e) { + debugPrint('[InstalledAppsBackup] Backup failed: $e'); + } + } + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + @override void dispose() { _pollTimer?.cancel(); diff --git a/spec/guidelines/INVARIANTS.md b/spec/guidelines/INVARIANTS.md index cb0119d..73c8496 100644 --- a/spec/guidelines/INVARIANTS.md +++ b/spec/guidelines/INVARIANTS.md @@ -41,9 +41,7 @@ If any invariant is violated, the implementation is incorrect. ## App Stack (kind 30267) Tag Rules -- Encrypted stacks (private collections, e.g. "Saved Apps") MUST NOT include a community `p` tag. -- Only public stacks may carry a community `p` tag to associate them with a community. -- The `h` tag is used for relay grouping/routing and may appear on both public and private stacks. +- Encrypted stacks (private collections, e.g. "Saved Apps") MUST NOT include a community `h` tag. ## Lifecycle Safety