Back up apps (part of #20)

This commit is contained in:
franzap
2026-04-09 21:49:08 -03:00
parent 115b032e4d
commit a362e1c2c8
5 changed files with 130 additions and 5 deletions
+8 -2
View File
@@ -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<String, dynamic> event) {
// Guard: only apply to AppStack events (kind 30267).
@@ -44,7 +47,10 @@ bool appStackEventFilter(Map<String, dynamic> 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;
}
}
+32
View File
@@ -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,
+25
View File
@@ -154,6 +154,21 @@ class SecureStorageService {
value: jsonEncode(relays.toList()),
);
}
// =========================================================================
// Installed Apps Backup
// =========================================================================
static const _installedAppsBackupKey = 'installed_apps_backup_enabled';
Future<bool> isInstalledAppsBackupEnabled() async {
final value = await _storage.read(key: _installedAppsBackupKey);
return value == 'true';
}
Future<void> 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<bool>((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<bool>((ref) async {
final secureStorage = ref.watch(secureStorageServiceProvider);
return secureStorage.isInstalledAppsBackupEnabled();
});
+64
View File
@@ -92,6 +92,7 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
final Ref ref;
Timer? _pollTimer;
List<String> _lastBackedUpIds = [];
void _init() {
ref.listen<AsyncValue<void>>(appInitializationProvider, (prev, next) {
@@ -126,6 +127,7 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
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<UpdatePollerState> {
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<void> _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<App>(
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<String> a, List<String> 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();
+1 -3
View File
@@ -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