mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
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:
co-authored by
Cursor
parent
b4d43928c8
commit
57878a774e
@@ -12,15 +12,20 @@ const kFranzapPubkey =
|
|||||||
/// Identifier for storing user saved apps
|
/// Identifier for storing user saved apps
|
||||||
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
|
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
|
/// Event filter for app stacks - excludes saved apps and stacks with zero App references
|
||||||
bool appStackEventFilter(Map<String, dynamic> event) {
|
bool appStackEventFilter(Map<String, dynamic> event) {
|
||||||
final tags = event['tags'] as List<dynamic>?;
|
final tags = event['tags'] as List<dynamic>?;
|
||||||
if (tags == null) return false;
|
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) {
|
for (final tag in tags) {
|
||||||
if (tag is List && tag.isNotEmpty && tag[0] == 'd') {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-1
@@ -11,6 +11,7 @@ import 'package:zapstore/screens/user_screen.dart';
|
|||||||
import 'package:zapstore/screens/search_screen.dart';
|
import 'package:zapstore/screens/search_screen.dart';
|
||||||
import 'package:zapstore/screens/updates_screen.dart';
|
import 'package:zapstore/screens/updates_screen.dart';
|
||||||
import 'package:zapstore/screens/profile_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';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
|
||||||
/// Root paths for each navigation branch (used for back navigation handling)
|
/// 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
|
/// Helper to build user route
|
||||||
GoRoute _userRoute() {
|
GoRoute _userRoute() {
|
||||||
return GoRoute(
|
return GoRoute(
|
||||||
@@ -156,7 +176,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
state: state,
|
state: state,
|
||||||
child: const ProfileScreen(),
|
child: const ProfileScreen(),
|
||||||
),
|
),
|
||||||
routes: [_appDetailRoute(), _stackDetailRoute(), _userRoute()],
|
routes: [
|
||||||
|
_appDetailRoute(),
|
||||||
|
_stackDetailRoute(),
|
||||||
|
_userRoute(),
|
||||||
|
_restoreRoute(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
import 'package:purplebase/purplebase.dart';
|
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/installed_apps_backup_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
import 'package:zapstore/widgets/common/profile_identity_row.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 {
|
class _DataManagementSection extends ConsumerWidget {
|
||||||
const _DataManagementSection();
|
const _DataManagementSection();
|
||||||
|
|
||||||
@@ -1350,6 +1435,33 @@ class _DataManagementSection extends ConsumerWidget {
|
|||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 18,
|
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;
|
||||||
|
}
|
||||||
@@ -5,12 +5,17 @@ import 'package:zapstore/services/package_manager/package_manager.dart';
|
|||||||
import 'package:zapstore/theme.dart';
|
import 'package:zapstore/theme.dart';
|
||||||
import 'package:zapstore/utils/extensions.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.
|
/// Progress and "All done" states are handled by sticky banners in _UpdatesListBody.
|
||||||
class UpdateAllRow extends ConsumerWidget {
|
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 List<App> allUpdates;
|
||||||
|
final String label;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -38,7 +43,7 @@ class UpdateAllRow extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
icon: const Icon(Icons.download, size: 16, color: Colors.white),
|
icon: const Icon(Icons.download, size: 16, color: Colors.white),
|
||||||
label: Text(
|
label: Text(
|
||||||
'Update All (${allUpdates.length})',
|
'$label (${allUpdates.length})',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
@@ -299,7 +299,11 @@ class _AddToStackDialogSignedIn extends HookConsumerWidget {
|
|||||||
(t) => t is List && t.isNotEmpty && t[0] == 'd',
|
(t) => t is List && t.isNotEmpty && t[0] == 'd',
|
||||||
orElse: () => null,
|
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)
|
// Check has at least one 'a' tag (app reference)
|
||||||
final hasAppRef = tags.any(
|
final hasAppRef = tags.any(
|
||||||
(t) => t is List && t.isNotEmpty && t[0] == 'a',
|
(t) => t is List && t.isNotEmpty && t[0] == 'a',
|
||||||
|
|||||||
Reference in New Issue
Block a user