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
|
/// Identifier for storing user saved apps
|
||||||
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
|
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
|
/// Event filter for app stacks - must run as schemaFilter so rejected events
|
||||||
/// are never stored in local SQLite.
|
/// are never stored in local SQLite.
|
||||||
///
|
///
|
||||||
/// Rejects:
|
/// Rejects:
|
||||||
/// - Private/encrypted stacks (non-empty content, kind 30267 only)
|
/// - 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
|
/// - Stacks with no public App (32267) references
|
||||||
bool appStackEventFilter(Map<String, dynamic> event) {
|
bool appStackEventFilter(Map<String, dynamic> event) {
|
||||||
// Guard: only apply to AppStack events (kind 30267).
|
// Guard: only apply to AppStack events (kind 30267).
|
||||||
@@ -44,7 +47,10 @@ bool appStackEventFilter(Map<String, dynamic> event) {
|
|||||||
|
|
||||||
for (final tag in tags) {
|
for (final tag in tags) {
|
||||||
if (tag is! List || tag.isEmpty) continue;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'package:purplebase/purplebase.dart';
|
|||||||
import 'package:zapstore/main.dart';
|
import 'package:zapstore/main.dart';
|
||||||
import 'package:zapstore/services/bookmarks_service.dart';
|
import 'package:zapstore/services/bookmarks_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.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/extensions.dart';
|
||||||
import 'package:zapstore/utils/nostr_route.dart';
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
import 'package:zapstore/widgets/common/profile_identity_row.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 {
|
class _DataManagementSection extends ConsumerWidget {
|
||||||
const _DataManagementSection();
|
const _DataManagementSection();
|
||||||
|
|
||||||
@@ -1413,6 +1443,8 @@ class _DataManagementSection extends ConsumerWidget {
|
|||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_InstalledAppsBackupToggle(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 18,
|
radius: 18,
|
||||||
|
|||||||
@@ -154,6 +154,21 @@ class SecureStorageService {
|
|||||||
value: jsonEncode(relays.toList()),
|
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.
|
/// Persists the AmberSigner pubkey in flutter_secure_storage.
|
||||||
@@ -189,3 +204,13 @@ final hasNwcStringProvider = FutureProvider.autoDispose<bool>((ref) async {
|
|||||||
final secureStorage = ref.watch(secureStorageServiceProvider);
|
final secureStorage = ref.watch(secureStorageServiceProvider);
|
||||||
return secureStorage.hasNWCString();
|
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;
|
final Ref ref;
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
|
List<String> _lastBackedUpIds = [];
|
||||||
|
|
||||||
void _init() {
|
void _init() {
|
||||||
ref.listen<AsyncValue<void>>(appInitializationProvider, (prev, next) {
|
ref.listen<AsyncValue<void>>(appInitializationProvider, (prev, next) {
|
||||||
@@ -126,6 +127,7 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
|||||||
lastCheckTime: DateTime.now(),
|
lastCheckTime: DateTime.now(),
|
||||||
clearError: true,
|
clearError: true,
|
||||||
);
|
);
|
||||||
|
unawaited(_backupInstalledApps());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[UpdatePoller] Check failed: $e');
|
debugPrint('[UpdatePoller] Check failed: $e');
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@@ -186,6 +188,68 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
|||||||
state = state.copyWith(catalogedIds: result.catalogedIds);
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ If any invariant is violated, the implementation is incorrect.
|
|||||||
|
|
||||||
## App Stack (kind 30267) Tag Rules
|
## App Stack (kind 30267) Tag Rules
|
||||||
|
|
||||||
- Encrypted stacks (private collections, e.g. "Saved Apps") MUST NOT include a community `p` tag.
|
- Encrypted stacks (private collections, e.g. "Saved Apps") MUST NOT include a community `h` 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.
|
|
||||||
|
|
||||||
## Lifecycle Safety
|
## Lifecycle Safety
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user