mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Back up apps (part of #20)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user