feat: backup and restore installed apps (Issue #20)

- Add backup/restore for cataloged installed apps as encrypted AppStack (kind 30267)
- New service: installed_apps_backup_service with backup + restore providers
- Backup: saves addressable IDs to Nostr social relays via NIP-44 encryption
- Restore: fetches from relays, decrypts, resolves to App models; full-screen route
- Profile Data Management: Backup and Restore tiles (sign-in required)
- Restore screen: sections "To install" / "Already installed", Install All button
- Exclude backup identifier from bookmark/stack picker (bookmark_widgets)
- UpdateAllRow: optional label for "Install All" in restore screen
- Add /profile/restore route; no back arrow (matches app navigation style)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Henrique Velloso
2026-02-24 21:06:47 -03:00
co-authored by Cursor
parent b4d43928c8
commit 57878a774e
7 changed files with 557 additions and 7 deletions
+7 -2
View File
@@ -12,15 +12,20 @@ const kFranzapPubkey =
/// Identifier for storing user saved apps
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
/// Identifier for encrypted backup of installed apps
const kInstalledAppsBackupIdentifier = 'zapstore-installed';
/// Event filter for app stacks - excludes saved apps and stacks with zero App references
bool appStackEventFilter(Map<String, dynamic> event) {
final tags = event['tags'] as List<dynamic>?;
if (tags == null) return false;
// Check for saved apps identifier in 'd' tag
// Check for saved apps / backup identifiers in 'd' tag (exclude from public stacks)
for (final tag in tags) {
if (tag is List && tag.isNotEmpty && tag[0] == 'd') {
if (tag.length > 1 && tag[1] == kAppBookmarksIdentifier) {
if (tag.length > 1 &&
(tag[1] == kAppBookmarksIdentifier ||
tag[1] == kInstalledAppsBackupIdentifier)) {
return false;
}
}
+26 -1
View File
@@ -11,6 +11,7 @@ import 'package:zapstore/screens/user_screen.dart';
import 'package:zapstore/screens/search_screen.dart';
import 'package:zapstore/screens/updates_screen.dart';
import 'package:zapstore/screens/profile_screen.dart';
import 'package:zapstore/screens/restore_installed_apps_screen.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
/// Root paths for each navigation branch (used for back navigation handling)
@@ -83,6 +84,25 @@ GoRoute _stackDetailRoute() {
);
}
/// Helper to build restore installed apps route
GoRoute _restoreRoute() {
return GoRoute(
path: 'restore',
redirect: (context, state) {
final extra = state.extra;
if (extra is! List<String> || extra.isEmpty) return '/profile';
return null;
},
pageBuilder: (context, state) {
final ids = state.extra as List<String>;
return _noTransitionPage(
state: state,
child: RestoreInstalledAppsScreen(addressableIds: ids),
);
},
);
}
/// Helper to build user route
GoRoute _userRoute() {
return GoRoute(
@@ -156,7 +176,12 @@ final routerProvider = Provider<GoRouter>((ref) {
state: state,
child: const ProfileScreen(),
),
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
routes: [
_appDetailRoute(),
_stackDetailRoute(),
_userRoute(),
_restoreRoute(),
],
),
],
),
+112
View File
@@ -15,6 +15,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/installed_apps_backup_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/common/profile_identity_row.dart';
@@ -1334,6 +1335,90 @@ class _EmptyState extends StatelessWidget {
}
}
class _InstalledAppsTile extends ConsumerWidget {
const _InstalledAppsTile({
required this.icon,
required this.title,
required this.onAction,
});
final IconData icon;
final String title;
final Future<void> Function(BuildContext context, WidgetRef ref) onAction;
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final isSignedIn = signedInPubkey != null;
return AsyncButtonBuilder(
onPressed: () async {
if (!isSignedIn) {
if (context.mounted) {
context.showError(
'Sign in required',
description: 'You need to sign in to use this feature.',
);
}
return;
}
try {
await onAction(context, ref);
} catch (e) {
if (context.mounted) {
context.showError(
'Operation failed',
technicalDetails: '$e',
);
}
}
},
builder: (context, child, callback, buttonState) {
final isLoading = buttonState.maybeWhen(
loading: () => true,
orElse: () => false,
);
return ListTile(
leading: CircleAvatar(
radius: 18,
backgroundColor: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.12),
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.primary,
),
)
: Icon(
icon,
color: Theme.of(context).colorScheme.primary,
),
),
title: AutoSizeText(
title,
style: TextStyle(
color: isSignedIn
? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
fontWeight: FontWeight.w600,
),
maxLines: 1,
minFontSize: 12,
),
contentPadding: EdgeInsets.zero,
onTap: isLoading || !isSignedIn ? null : callback,
);
},
child: const SizedBox.shrink(),
);
}
}
class _DataManagementSection extends ConsumerWidget {
const _DataManagementSection();
@@ -1350,6 +1435,33 @@ class _DataManagementSection extends ConsumerWidget {
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
_InstalledAppsTile(
icon: Icons.backup,
title: 'Backup installed apps',
onAction: (context, ref) async {
final count = await ref.read(backupInstalledAppsProvider)();
if (context.mounted) {
context.showInfo(
count == 0
? 'No cataloged apps to backup'
: 'Backed up $count app${count == 1 ? '' : 's'}',
);
}
},
),
_InstalledAppsTile(
icon: Icons.restore,
title: 'Restore installed apps',
onAction: (context, ref) async {
final ids = await ref.read(restoreInstalledAppsProvider)();
if (!context.mounted) return;
if (ids.isEmpty) {
context.showInfo('No backup found');
return;
}
context.push('/profile/restore', extra: ids);
},
),
ListTile(
leading: CircleAvatar(
radius: 18,
@@ -0,0 +1,275 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/app_card.dart';
import 'package:zapstore/widgets/batch_progress_banner.dart';
import 'package:zapstore/widgets/common/badges.dart';
import 'package:zapstore/theme.dart';
/// Full-screen restore from backup. Mirrors [AppStackScreen] pattern:
/// query with nested relations, skeleton on loading, sort uninstalled first.
class RestoreInstalledAppsScreen extends HookConsumerWidget {
const RestoreInstalledAppsScreen({
super.key,
required this.addressableIds,
});
final List<String> addressableIds;
@override
Widget build(BuildContext context, WidgetRef ref) {
final appIdentifiers = addressableIds
.where((id) => id.startsWith('32267:'))
.map((id) => id.split(':').skip(2).join(':'))
.toSet();
if (appIdentifiers.isEmpty) {
return Scaffold(
appBar: AppBar(
title: const Text('Restore from backup'),
automaticallyImplyLeading: false,
),
body: Center(
child: Text(
'No apps to restore',
style: context.textTheme.bodyLarge,
),
),
);
}
final platform = ref.read(packageManagerProvider.notifier).platform;
final appsState = ref.watch(
query<App>(
tags: {
'#d': appIdentifiers,
'#f': {platform},
},
and: (app) => {
app.latestRelease.query(
and: (release) => {
release.latestMetadata.query(),
release.latestAsset.query(),
},
),
},
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'restore-backup-apps',
),
);
return switch (appsState) {
StorageLoading() => Scaffold(
appBar: AppBar(
title: const Text('Restore from backup'),
automaticallyImplyLeading: false,
),
body: SingleChildScrollView(
padding: const EdgeInsets.only(top: 8, bottom: 32),
child: _RestoreSkeleton(),
),
),
StorageError(:final exception) => Scaffold(
appBar: AppBar(
title: const Text('Restore from backup'),
automaticallyImplyLeading: false,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(exception.toString(), textAlign: TextAlign.center),
),
],
),
),
),
StorageData(:final models) => _RestoreContent(
allApps: models,
appIdentifiers: appIdentifiers,
),
};
}
}
class _RestoreContent extends HookConsumerWidget {
const _RestoreContent({
required this.allApps,
required this.appIdentifiers,
});
final List<App> allApps;
final Set<String> appIdentifiers;
@override
Widget build(BuildContext context, WidgetRef ref) {
final installedIds =
ref.watch(packageManagerProvider).installed.keys.toSet();
final appsMap = {for (final app in allApps) app.identifier: app};
// Preserve backup order for both lists
final toInstall = appIdentifiers
.map((id) => appsMap[id])
.whereType<App>()
.where((app) => !installedIds.contains(app.identifier))
.toList();
final alreadyInstalled = appIdentifiers
.map((id) => appsMap[id])
.whereType<App>()
.where((app) => installedIds.contains(app.identifier))
.toList();
return Scaffold(
appBar: AppBar(
title: const Text('Restore from backup'),
automaticallyImplyLeading: false,
),
body: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 32),
children: [
// "Install All" button — same style as UpdateAllRow in updates screen
if (toInstall.length > 1)
UpdateAllRow(allUpdates: toInstall, label: 'Install All'),
// "To install" section
if (toInstall.isNotEmpty)
_SectionHeader(
icon: Icons.download_outlined,
title: 'To install',
count: toInstall.length,
),
...toInstall.map(
(app) => AppCard(
key: ValueKey('restore_${app.identifier}'),
app: app,
showUpdateButton: true,
showUpdateArrow: false,
showSignedBy: false,
showZapEncouragement: false,
showDescription: false,
),
),
// "Already installed" section
if (alreadyInstalled.isNotEmpty) ...[
_SectionHeader(
icon: Icons.check_circle,
title: 'Already installed',
count: alreadyInstalled.length,
iconColor: Colors.green.shade400,
),
...alreadyInstalled.map(
(app) => AppCard(
key: ValueKey('installed_${app.identifier}'),
app: app,
showUpdateButton: false,
showUpdateArrow: app.hasUpdate,
showSignedBy: false,
showZapEncouragement: false,
showDescription: false,
),
),
],
// Empty state — only when backup has nothing at all
if (toInstall.isEmpty && alreadyInstalled.isEmpty)
Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
'No apps found in backup',
style: context.textTheme.bodyLarge?.copyWith(
color:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.icon,
required this.title,
required this.count,
this.iconColor,
});
final IconData icon;
final String title;
final int count;
final Color? iconColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(icon, size: 20, color: iconColor ?? AppColors.darkActionPrimary),
const SizedBox(width: 8),
Text(title, style: context.textTheme.titleMedium),
const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground),
],
),
);
}
}
class _RestoreSkeleton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SkeletonizerConfig(
data: AppColors.getSkeletonizerConfig(Theme.of(context).brightness),
child: Skeletonizer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section header skeleton — same padding as _SectionHeader
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
height: 20,
width: 100,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Container(
height: 20,
width: 24,
decoration: BoxDecoration(
color: AppColors.darkSkeletonBase,
borderRadius: BorderRadius.circular(10),
),
),
],
),
),
const SizedBox(height: 4),
...List.generate(3, (_) => const AppCard(isLoading: true)),
],
),
),
);
}
}
@@ -0,0 +1,124 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
/// Provider that exposes the backup function with correct Ref.
final backupInstalledAppsProvider = Provider<Future<int> Function()>((ref) {
return () => _backupInstalledApps(ref);
});
/// Backs up the list of cataloged installed apps as an encrypted AppStack (kind 30267)
/// to Nostr social relays. Only apps that are both installed and in the Zapstore
/// catalog are included.
///
/// Returns the count of backed-up apps.
/// Throws if user is not signed in or on failure.
Future<int> _backupInstalledApps(Ref ref) async {
final signer = ref.read(Signer.activeSignerProvider);
final signedInPubkey = ref.read(Signer.activePubkeyProvider);
if (signer == null || signedInPubkey == null) {
throw StateError('Sign in required to backup installed apps');
}
final pmState = ref.read(packageManagerProvider);
final installedIds = pmState.installed.keys.toSet();
if (installedIds.isEmpty) {
return _saveBackup(ref, signer, []);
}
final platform = ref.read(packageManagerProvider.notifier).platform;
final storage = ref.read(storageNotifierProvider.notifier);
final apps = await storage.query(
RequestFilter<App>(
tags: {
'#d': installedIds,
'#f': {platform},
},
).toRequest(subscriptionPrefix: 'installed-backup'),
source: const LocalSource(),
);
final appIds = apps
.map((app) => '${app.event.kind}:${app.pubkey}:${app.identifier}')
.toList();
return _saveBackup(ref, signer, appIds);
}
Future<int> _saveBackup(Ref ref, Signer signer, List<String> appIds) async {
final platform = ref.read(packageManagerProvider.notifier).platform;
final storage = ref.read(storageNotifierProvider.notifier);
final partialStack = PartialAppStack.withEncryptedApps(
name: 'Installed Apps',
identifier: kInstalledAppsBackupIdentifier,
apps: appIds,
platform: platform,
);
final signedStack = await partialStack.signWith(signer);
await storage.save({signedStack});
storage.publish(
{signedStack},
source: RemoteSource(relays: 'social'),
);
return appIds.length;
}
/// Provider that exposes the restore function with correct Ref.
/// Returns decrypted addressable IDs from the backup (lightweight, no catalog queries).
final restoreInstalledAppsProvider = Provider<Future<List<String>> Function()>((ref) {
return () => _restoreInstalledApps(ref);
});
/// Fetches the encrypted backup from Nostr relays and decrypts it.
/// Returns raw addressable IDs (e.g. "32267:pubkey:identifier").
/// The UI is responsible for resolving these to App models reactively.
Future<List<String>> _restoreInstalledApps(Ref ref) async {
final signer = ref.read(Signer.activeSignerProvider);
final signedInPubkey = ref.read(Signer.activePubkeyProvider);
if (signer == null || signedInPubkey == null) {
throw StateError('Sign in required to restore installed apps');
}
final storage = ref.read(storageNotifierProvider.notifier);
final stacks = await storage.query(
RequestFilter<AppStack>(
authors: {signedInPubkey},
tags: {'#d': {kInstalledAppsBackupIdentifier}},
).toRequest(subscriptionPrefix: 'installed-restore'),
source: const LocalAndRemoteSource(relays: 'social', stream: false),
);
final stack = stacks.firstOrNull;
if (stack == null || stack.content.isEmpty) {
return [];
}
List<String> addressableIds;
try {
final decryptedContent = await signer.nip44Decrypt(
stack.content,
signedInPubkey,
);
addressableIds = (jsonDecode(decryptedContent) as List).cast<String>();
} catch (e) {
debugPrint('[InstalledAppsBackup] Restore decrypt failed: $e');
return [];
}
return addressableIds;
}
+8 -3
View File
@@ -5,12 +5,17 @@ import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/utils/extensions.dart';
/// "Update All" button row.
/// "Update All" / "Install All" button row.
/// Progress and "All done" states are handled by sticky banners in _UpdatesListBody.
class UpdateAllRow extends ConsumerWidget {
const UpdateAllRow({super.key, required this.allUpdates});
const UpdateAllRow({
super.key,
required this.allUpdates,
this.label = 'Update All',
});
final List<App> allUpdates;
final String label;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -38,7 +43,7 @@ class UpdateAllRow extends ConsumerWidget {
},
icon: const Icon(Icons.download, size: 16, color: Colors.white),
label: Text(
'Update All (${allUpdates.length})',
'$label (${allUpdates.length})',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
+5 -1
View File
@@ -299,7 +299,11 @@ class _AddToStackDialogSignedIn extends HookConsumerWidget {
(t) => t is List && t.isNotEmpty && t[0] == 'd',
orElse: () => null,
);
if (dTag != null && dTag[1] == kAppBookmarksIdentifier) return false;
if (dTag != null &&
(dTag[1] == kAppBookmarksIdentifier ||
dTag[1] == kInstalledAppsBackupIdentifier)) {
return false;
}
// Check has at least one 'a' tag (app reference)
final hasAppRef = tags.any(
(t) => t is List && t.isNotEmpty && t[0] == 'a',