mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Device key restore UX
This commit is contained in:
+4
-6
@@ -400,9 +400,10 @@ final storageReadyProvider = FutureProvider<void>((ref) async {
|
||||
|
||||
// Initialize device key signer — must be registered before any encrypted
|
||||
// queries fire, so EncryptableModel.prepareAfterLoading can find it.
|
||||
final deviceKey = await ref
|
||||
.read(deviceKeyServiceProvider)
|
||||
.getOrCreatePrivateKey();
|
||||
final deviceKeyService = ref.read(deviceKeyServiceProvider);
|
||||
final hasExistingDeviceKey = await deviceKeyService.hasPrivateKey();
|
||||
ref.read(isNewDeviceKeyProvider.notifier).state = !hasExistingDeviceKey;
|
||||
final deviceKey = await deviceKeyService.getOrCreatePrivateKey();
|
||||
final deviceSigner = Bip340PrivateKeySigner(deviceKey, ref);
|
||||
await deviceSigner.signIn(setAsActive: false);
|
||||
ref.read(devicePubkeyProvider.notifier).state = deviceSigner.pubkey;
|
||||
@@ -447,9 +448,6 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
await _attemptAutoSignIn(ref);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (ref.read(Signer.activePubkeyProvider) == null) {
|
||||
unawaited(maybeOfferInitialDeviceRestore(ref));
|
||||
}
|
||||
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||
});
|
||||
});
|
||||
|
||||
+149
-51
@@ -15,15 +15,19 @@ import 'package:zapstore/services/background_update_service.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/main.dart';
|
||||
import 'package:zapstore/services/device_backup_service.dart';
|
||||
import 'package:zapstore/services/device_key_service.dart';
|
||||
import 'package:zapstore/services/device_private_sync_service.dart';
|
||||
import 'package:zapstore/services/device_state_service.dart';
|
||||
import 'package:zapstore/services/log_service.dart' as app_logs;
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/debug_utils.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
import 'package:zapstore/widgets/common/profile_identity_row.dart';
|
||||
import 'package:zapstore/widgets/common/stack_link_card.dart';
|
||||
import 'package:zapstore/widgets/device_restore_dialog.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/widgets/common/note_parser.dart';
|
||||
@@ -63,13 +67,13 @@ class ProfileScreen extends ConsumerWidget {
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// App Catalog Relay Management Section
|
||||
const RelayManagementCard(),
|
||||
// Data Management Section
|
||||
const _DataManagementSection(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Data Management Section
|
||||
const _DataManagementSection(),
|
||||
// App Catalog Relay Management Section
|
||||
const RelayManagementCard(),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -1412,6 +1416,7 @@ class _DeviceKeyCard extends HookConsumerWidget {
|
||||
final shortened =
|
||||
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
|
||||
final isCopying = useState(false);
|
||||
final isRestoring = useState(false);
|
||||
final powElapsed = useState(Duration.zero);
|
||||
useEffect(() {
|
||||
final startedAt = deviceState.startedAt;
|
||||
@@ -1510,55 +1515,148 @@ class _DeviceKeyCard extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: !deviceState.isReady || isCopying.value
|
||||
? null
|
||||
: () async {
|
||||
final shouldCopy = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Copy device private key?'),
|
||||
content: const Text(
|
||||
'This nsec controls your private Zapstore data, including saved apps '
|
||||
'and unmanaged apps. Anyone with it can read and modify that data.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: !deviceState.isReady || isCopying.value
|
||||
? null
|
||||
: () async {
|
||||
final shouldCopy = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Copy device private key?'),
|
||||
content: const Text(
|
||||
'This nsec controls your private Zapstore data, including saved apps '
|
||||
'and unmanaged apps. Anyone with it can read and modify that data.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, true),
|
||||
child: const Text('Copy nsec'),
|
||||
),
|
||||
],
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Copy nsec'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (shouldCopy != true) return;
|
||||
|
||||
isCopying.value = true;
|
||||
try {
|
||||
final nsec = await ref
|
||||
.read(deviceKeyServiceProvider)
|
||||
.getNsec();
|
||||
await Clipboard.setData(ClipboardData(text: nsec));
|
||||
if (context.mounted) {
|
||||
context.showInfo('Device nsec copied');
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showError(
|
||||
'Could not copy device key',
|
||||
technicalDetails: e.toString(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isCopying.value = false;
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: const Text('Copy nsec'),
|
||||
if (shouldCopy != true) return;
|
||||
|
||||
isCopying.value = true;
|
||||
try {
|
||||
final nsec = await ref
|
||||
.read(deviceKeyServiceProvider)
|
||||
.getNsec();
|
||||
await Clipboard.setData(ClipboardData(text: nsec));
|
||||
if (context.mounted) {
|
||||
context.showInfo('Device nsec copied');
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showError(
|
||||
'Could not copy device key',
|
||||
technicalDetails: e.toString(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isCopying.value = false;
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: const Text('Copy nsec'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: isRestoring.value
|
||||
? null
|
||||
: () async {
|
||||
final result = await showDialog<DeviceRestoreResult>(
|
||||
context: context,
|
||||
builder: (_) => const DeviceRestoreDialog(),
|
||||
);
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
if (result.action == DeviceRestoreAction.amber) {
|
||||
isRestoring.value = true;
|
||||
try {
|
||||
await ref
|
||||
.read(deviceBackupServiceProvider)
|
||||
.restoreFromAmber(ref: ref.read(refProvider));
|
||||
if (context.mounted) {
|
||||
context.showInfo('Device key restored');
|
||||
}
|
||||
} catch (error, stack) {
|
||||
app_logs.LogService.I.warn(
|
||||
'Amber device key restore failed',
|
||||
tag: 'backup',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.showError(
|
||||
'Could not restore device key',
|
||||
technicalDetails: error.toString(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isRestoring.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final privateKeyHex = ref
|
||||
.read(deviceKeyServiceProvider)
|
||||
.parsePrivateKey(result.key ?? '');
|
||||
if (privateKeyHex == null) {
|
||||
context.showError('Enter a valid device nsec.');
|
||||
return;
|
||||
}
|
||||
|
||||
isRestoring.value = true;
|
||||
try {
|
||||
await ref
|
||||
.read(deviceBackupServiceProvider)
|
||||
.restoreDeviceKey(
|
||||
ref: ref.read(refProvider),
|
||||
privateKeyHex: privateKeyHex,
|
||||
);
|
||||
await ref
|
||||
.read(devicePrivateSyncProvider.notifier)
|
||||
.syncRestoredKey();
|
||||
if (!ref.read(deviceStateProvider).isReady) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(deviceStateProvider.notifier)
|
||||
.bootstrap(),
|
||||
);
|
||||
}
|
||||
if (context.mounted) {
|
||||
context.showInfo('Device key restored');
|
||||
}
|
||||
} catch (error, stack) {
|
||||
app_logs.LogService.I.warn(
|
||||
'device key restore failed',
|
||||
tag: 'backup',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.showError(
|
||||
'Could not restore device key',
|
||||
technicalDetails: error.toString(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
isRestoring.value = false;
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.restore, size: 18),
|
||||
label: const Text('Restore'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+130
-46
@@ -10,6 +10,7 @@ import '../widgets/latest_releases_container.dart';
|
||||
import '../widgets/search_app_card.dart';
|
||||
import '../utils/extensions.dart';
|
||||
import '../main.dart';
|
||||
import '../services/device_key_service.dart';
|
||||
import '../services/package_manager/package_manager.dart';
|
||||
|
||||
/// Main search and app discovery screen
|
||||
@@ -67,58 +68,71 @@ class SearchScreen extends HookConsumerWidget {
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Column(
|
||||
children: [
|
||||
// Professional search bar with better spacing
|
||||
// Sticky search bar (+ optional device-key reminder)
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 18),
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: searchController,
|
||||
builder: (context, value, _) {
|
||||
final hasText = value.text.isNotEmpty;
|
||||
child: Column(
|
||||
children: [
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: searchController,
|
||||
builder: (context, value, _) {
|
||||
final hasText = value.text.isNotEmpty;
|
||||
|
||||
return SearchBar(
|
||||
controller: searchController,
|
||||
focusNode: searchFocusNode,
|
||||
hintText: 'Search apps',
|
||||
leading: Icon(
|
||||
Icons.search_rounded,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
trailing: [
|
||||
if (hasText)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear_rounded,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
return SearchBar(
|
||||
controller: searchController,
|
||||
focusNode: searchFocusNode,
|
||||
hintText: 'Search apps',
|
||||
leading: Icon(
|
||||
Icons.search_rounded,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
trailing: [
|
||||
if (hasText)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
searchQuery.value = '';
|
||||
searchFocusNode.requestFocus();
|
||||
},
|
||||
tooltip: 'Clear search',
|
||||
),
|
||||
],
|
||||
onSubmitted: performSearch,
|
||||
elevation: WidgetStateProperty.all(0),
|
||||
backgroundColor: WidgetStateProperty.all(
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.8),
|
||||
),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
searchQuery.value = '';
|
||||
searchFocusNode.requestFocus();
|
||||
},
|
||||
tooltip: 'Clear search',
|
||||
),
|
||||
],
|
||||
onSubmitted: performSearch,
|
||||
elevation: WidgetStateProperty.all(0),
|
||||
backgroundColor: WidgetStateProperty.all(
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.8),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (ref.watch(isNewDeviceKeyProvider)) ...[
|
||||
const SizedBox(height: 12),
|
||||
_NewDeviceKeyReminder(
|
||||
onTap: () => context.go('/profile'),
|
||||
onDismiss: () =>
|
||||
ref.read(isNewDeviceKeyProvider.notifier).state = false,
|
||||
),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Scrollable content
|
||||
@@ -163,6 +177,76 @@ class SearchScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _NewDeviceKeyReminder extends StatelessWidget {
|
||||
const _NewDeviceKeyReminder({required this.onTap, required this.onDismiss});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Dark yellow (hue ~50°), not muddy brown — reads as yellow on dark UI.
|
||||
const toastBackground = Color(0xFFC9A000);
|
||||
const toastForeground = Colors.white;
|
||||
|
||||
return Material(
|
||||
color: toastBackground,
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black38,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 4, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 1),
|
||||
child: Icon(
|
||||
Icons.vpn_key_outlined,
|
||||
color: toastForeground,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome to Zapstore',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: toastForeground,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'Used Zapstore before? Restore your device key via nsec or by signing in with Amber.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: toastForeground.withValues(alpha: 0.92),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onDismiss,
|
||||
tooltip: 'Dismiss',
|
||||
icon: const Icon(Icons.close_rounded, color: toastForeground),
|
||||
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchResultsSection extends HookConsumerWidget {
|
||||
const _SearchResultsSection({
|
||||
required this.searchQuery,
|
||||
|
||||
@@ -13,15 +13,17 @@ import 'package:zapstore/services/device_private_event_service.dart';
|
||||
import 'package:zapstore/services/device_private_sync_service.dart';
|
||||
import 'package:zapstore/services/device_state_service.dart';
|
||||
import 'package:zapstore/services/log_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
import 'package:zapstore/widgets/device_backup_dialog.dart';
|
||||
import 'package:zapstore/widgets/device_restore_dialog.dart';
|
||||
|
||||
/// Backs up the device key to the active Amber identity and restores it again.
|
||||
///
|
||||
/// The relay record is authored by Amber, unlike portable device state which is
|
||||
/// always authored by the recovered device key.
|
||||
class DeviceBackupService {
|
||||
Future<void>? _amberRestore;
|
||||
|
||||
bool get isRestoringFromAmber => _amberRestore != null;
|
||||
|
||||
/// Retained as a lifecycle hook for callers from the previous recovery
|
||||
/// implementation. Device-key backup has no long-lived foreground request.
|
||||
void beginWork() {}
|
||||
@@ -126,106 +128,74 @@ class DeviceBackupService {
|
||||
await signer.signIn(setAsActive: false);
|
||||
ref.read(devicePubkeyProvider.notifier).state = pubkey;
|
||||
}
|
||||
|
||||
/// Signs in to Amber, verifies its backup, and offers to replace the key.
|
||||
///
|
||||
/// This is explicitly user-initiated from Device key management. It prevents
|
||||
/// the regular sign-in backup from overwriting the remote backup first.
|
||||
Future<void> restoreFromAmber({required Ref ref}) {
|
||||
return _amberRestore ??= _restoreFromAmber(ref).whenComplete(() {
|
||||
_amberRestore = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _restoreFromAmber(Ref ref) async {
|
||||
await ref.read(amberSignerProvider).signIn();
|
||||
final amberSigner = ref.read(Signer.activeSignerProvider);
|
||||
if (amberSigner == null) {
|
||||
throw const DeviceBackupException('Could not sign in with Amber.');
|
||||
}
|
||||
|
||||
final privateKeyHex = await fetchAmberBackup(
|
||||
ref: ref,
|
||||
amberSigner: amberSigner,
|
||||
);
|
||||
if (privateKeyHex == null) {
|
||||
throw const DeviceBackupException(
|
||||
'No device key backup was found for this Amber identity.',
|
||||
);
|
||||
}
|
||||
|
||||
final context = rootNavigatorKey.currentState?.overlay?.context;
|
||||
if (context == null || !context.mounted) {
|
||||
throw const DeviceBackupException('Restore screen is unavailable.');
|
||||
}
|
||||
final restore = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => DeviceBackupRestoreDialog(
|
||||
onRestore: () => Navigator.of(context).pop(true),
|
||||
onKeepCurrent: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
);
|
||||
if (restore != true) return;
|
||||
|
||||
await restoreDeviceKey(ref: ref, privateKeyHex: privateKeyHex);
|
||||
await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey();
|
||||
if (!ref.read(deviceStateProvider).isReady) {
|
||||
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final deviceBackupServiceProvider = Provider<DeviceBackupService>(
|
||||
(ref) => DeviceBackupService(),
|
||||
);
|
||||
|
||||
/// Runs the one-time recovery choice after the navigator overlay is available.
|
||||
Future<void> maybeOfferInitialDeviceRestore(Ref ref) async {
|
||||
final settings = ref.read(settingsServiceProvider);
|
||||
final temp = await settings.loadTemp();
|
||||
if (temp.restoreOnboardingComplete) return;
|
||||
final context = rootNavigatorKey.currentState?.overlay?.context;
|
||||
if (context == null || !context.mounted) return;
|
||||
|
||||
final result = await showDialog<DeviceRestoreResult>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const DeviceRestoreDialog(),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
switch (result.action) {
|
||||
case DeviceRestoreAction.startFresh:
|
||||
await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true));
|
||||
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
|
||||
break;
|
||||
case DeviceRestoreAction.pasteKey:
|
||||
final privateKeyHex = ref
|
||||
.read(deviceKeyServiceProvider)
|
||||
.parsePrivateKey(result.key ?? '');
|
||||
if (privateKeyHex == null) {
|
||||
LogService.I.warn('invalid pasted device key', tag: 'backup');
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(deviceBackupServiceProvider)
|
||||
.restoreDeviceKey(ref: ref, privateKeyHex: privateKeyHex);
|
||||
await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey();
|
||||
final restored = await ref
|
||||
.read(deviceStateProvider.notifier)
|
||||
.restoreFromLocalEvent();
|
||||
if (!restored) {
|
||||
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
|
||||
}
|
||||
await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true));
|
||||
break;
|
||||
case DeviceRestoreAction.amber:
|
||||
await ref.read(amberSignerProvider).signIn();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Offers Amber recovery only once per fresh local installation.
|
||||
/// Backs up the current device key after a normal Amber sign-in.
|
||||
Future<void> maybeOfferDeviceBackup(Ref ref) async {
|
||||
final amberSigner = ref.read(Signer.activeSignerProvider);
|
||||
if (amberSigner == null) return;
|
||||
|
||||
final settings = ref.read(settingsServiceProvider);
|
||||
final temp = await settings.loadTemp();
|
||||
final service = ref.read(deviceBackupServiceProvider);
|
||||
if (service.isRestoringFromAmber) return;
|
||||
try {
|
||||
if (!temp.restoreOnboardingComplete) {
|
||||
final privateKeyHex = await service.fetchAmberBackup(
|
||||
ref: ref,
|
||||
amberSigner: amberSigner,
|
||||
);
|
||||
if (privateKeyHex != null) {
|
||||
final context = rootNavigatorKey.currentState?.overlay?.context;
|
||||
if (context != null && context.mounted) {
|
||||
final restore = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => DeviceBackupRestoreDialog(
|
||||
onRestore: () => Navigator.of(context).pop(true),
|
||||
onKeepCurrent: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
);
|
||||
if (restore == true) {
|
||||
await service.restoreDeviceKey(
|
||||
ref: ref,
|
||||
privateKeyHex: privateKeyHex,
|
||||
);
|
||||
await ref
|
||||
.read(devicePrivateSyncProvider.notifier)
|
||||
.syncRestoredKey();
|
||||
if (!ref.read(deviceStateProvider).isReady) {
|
||||
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true));
|
||||
}
|
||||
await service.backupDeviceKey(ref: ref, amberSigner: amberSigner);
|
||||
if (!ref.read(deviceStateProvider).isReady) {
|
||||
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
|
||||
}
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'device key backup or recovery failed',
|
||||
'device key backup failed',
|
||||
tag: 'backup',
|
||||
err: error,
|
||||
stack: stack,
|
||||
|
||||
@@ -12,6 +12,12 @@ const _kDeviceKey = 'device_key';
|
||||
/// Manages the device private key. The nsec is intentionally isolated from
|
||||
/// portable settings and temporary local state.
|
||||
class DeviceKeyService {
|
||||
/// Whether secure storage already contains a usable device key.
|
||||
Future<bool> hasPrivateKey() async {
|
||||
final existing = await _storage.read(key: _kDeviceKey);
|
||||
return existing != null && existing.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Load existing device key or generate a new one. Returns hex private key.
|
||||
Future<String> getOrCreatePrivateKey() async {
|
||||
final existing = await _storage.read(key: _kDeviceKey);
|
||||
@@ -113,3 +119,9 @@ final deviceKeyServiceProvider = Provider<DeviceKeyService>(
|
||||
|
||||
/// The device pubkey (hex). Available after storageReadyProvider resolves.
|
||||
final devicePubkeyProvider = StateProvider<String?>((_) => null);
|
||||
|
||||
/// Whether this process generated the device key for a fresh installation.
|
||||
///
|
||||
/// This is intentionally session-only: once the new key is stored, subsequent
|
||||
/// launches have no need to show the recovery reminder.
|
||||
final isNewDeviceKeyProvider = StateProvider<bool>((_) => false);
|
||||
|
||||
@@ -51,14 +51,12 @@ class TempSettings {
|
||||
final DateTime? seenUntil;
|
||||
final DateTime? deletionSyncedUntil;
|
||||
final LogLevel logLevel;
|
||||
final bool restoreOnboardingComplete;
|
||||
|
||||
const TempSettings({
|
||||
this.lastAppOpened,
|
||||
this.seenUntil,
|
||||
this.deletionSyncedUntil,
|
||||
this.logLevel = LogLevel.debug,
|
||||
this.restoreOnboardingComplete = false,
|
||||
});
|
||||
|
||||
factory TempSettings.fromJson(Map<String, dynamic> json) => TempSettings(
|
||||
@@ -66,8 +64,6 @@ class TempSettings {
|
||||
seenUntil: _parseDateTime(json['seenUntil']),
|
||||
deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']),
|
||||
logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug,
|
||||
restoreOnboardingComplete:
|
||||
json['restoreOnboardingComplete'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -77,7 +73,6 @@ class TempSettings {
|
||||
if (deletionSyncedUntil != null)
|
||||
'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch,
|
||||
if (logLevel != LogLevel.debug) 'logLevel': logLevel.name,
|
||||
if (restoreOnboardingComplete) 'restoreOnboardingComplete': true,
|
||||
};
|
||||
|
||||
TempSettings copyWith({
|
||||
@@ -85,14 +80,11 @@ class TempSettings {
|
||||
DateTime? seenUntil,
|
||||
DateTime? deletionSyncedUntil,
|
||||
LogLevel? logLevel,
|
||||
bool? restoreOnboardingComplete,
|
||||
}) => TempSettings(
|
||||
lastAppOpened: lastAppOpened ?? this.lastAppOpened,
|
||||
seenUntil: seenUntil ?? this.seenUntil,
|
||||
deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil,
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
restoreOnboardingComplete:
|
||||
restoreOnboardingComplete ?? this.restoreOnboardingComplete,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +117,6 @@ class LocalSettings {
|
||||
bool? backgroundAutoUpdatesEnabled,
|
||||
Set<String>? trustedSigners,
|
||||
LogLevel? logLevel,
|
||||
bool? restoreOnboardingComplete,
|
||||
bool clearNwc = false,
|
||||
}) => LocalSettings(
|
||||
nwcConnectionString: clearNwc
|
||||
@@ -140,7 +131,6 @@ class LocalSettings {
|
||||
seenUntil: seenUntil,
|
||||
deletionSyncedUntil: deletionSyncedUntil,
|
||||
logLevel: logLevel,
|
||||
restoreOnboardingComplete: restoreOnboardingComplete,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:zapstore/constants/app_constants.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/widgets/common/base_dialog.dart';
|
||||
|
||||
enum DeviceRestoreAction { startFresh, pasteKey, amber }
|
||||
enum DeviceRestoreAction { pasteKey, amber }
|
||||
|
||||
class DeviceRestoreResult {
|
||||
const DeviceRestoreResult(this.action, {this.key});
|
||||
@@ -15,7 +15,7 @@ class DeviceRestoreResult {
|
||||
final String? key;
|
||||
}
|
||||
|
||||
/// First-run choice for recovering an existing device identity.
|
||||
/// Lets a user replace the current device identity with a recovered one.
|
||||
class DeviceRestoreDialog extends HookConsumerWidget {
|
||||
const DeviceRestoreDialog({super.key});
|
||||
|
||||
@@ -29,7 +29,7 @@ class DeviceRestoreDialog extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return BaseDialog(
|
||||
title: const BaseDialogTitle('Restore device'),
|
||||
title: const BaseDialogTitle('Restore device key'),
|
||||
titleIcon: const Icon(Icons.restore, size: 20),
|
||||
content: BaseDialogContent(
|
||||
children: [
|
||||
@@ -38,10 +38,7 @@ class DeviceRestoreDialog extends HookConsumerWidget {
|
||||
'device nsec or Amber.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Older local settings are not migrated. If you are updating, '
|
||||
'copy your old device nsec before continuing.',
|
||||
),
|
||||
const Text('Restoring replaces this device’s current key.'),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
@@ -88,11 +85,8 @@ class DeviceRestoreDialog extends HookConsumerWidget {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(
|
||||
context,
|
||||
const DeviceRestoreResult(DeviceRestoreAction.startFresh),
|
||||
),
|
||||
child: const Text('Start fresh'),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# WORK-026 - New Device Key Reminder
|
||||
|
||||
**Feature:** FEAT-006-device-key.md
|
||||
**Status:** In Progress
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Detect a missing device key before generating the fresh key
|
||||
- Files: `lib/main.dart`, `lib/services/device_key_service.dart`
|
||||
- Keep the result in memory for the current app session only.
|
||||
- [x] 2. Replace the startup restore dialog with an inline reminder
|
||||
- Files: `lib/screens/search_screen.dart`, `lib/services/device_backup_service.dart`
|
||||
- Render the reminder between search and stacks only for a newly generated key.
|
||||
- [x] 3. Support restoring a pasted nsec from Device key management
|
||||
- Files: `lib/screens/profile_screen.dart`, `lib/widgets/device_restore_dialog.dart`
|
||||
`lib/services/device_backup_service.dart`
|
||||
- Validate, replace, and sync a pasted-nsec or Amber-restored key without
|
||||
blocking the UI.
|
||||
- [x] 4. Verify analysis and targeted tests
|
||||
- `fvm flutter test test/services/device_key_service_test.dart
|
||||
test/services/device_backup_service_test.dart
|
||||
test/services/settings_service_test.dart`
|
||||
- `HOME=/tmp fvm flutter analyze`
|
||||
|
||||
## Verification
|
||||
|
||||
- The startup flow captures `device_key` presence before generating the key;
|
||||
only an absent key enables the session-only reminder.
|
||||
- Pasting an invalid nsec surfaces an error without replacing the current key.
|
||||
- A successful restore replaces the device key before the one-shot private-data
|
||||
synchronization begins.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 2026-07-15 - New-key reminder is session-only
|
||||
|
||||
**Context:** A first-run restore dialog blocks discovery and requires users to
|
||||
choose a recovery method before using the store.
|
||||
|
||||
**Decision:** Capture whether `device_key` was absent before creating it, then
|
||||
show a non-interactive reminder for the current app session. The key's presence
|
||||
suppresses it on later launches; no onboarding state is written.
|
||||
|
||||
**Rationale:** Device-key recovery remains accessible from Data Management at
|
||||
any time without treating Amber as a prerequisite or gating the UI.
|
||||
|
||||
The obsolete persisted `restoreOnboardingComplete` value is removed. Amber
|
||||
recovery is now explicitly initiated from Device key management, which prevents
|
||||
normal Amber sign-in backup from overwriting a recovery record first.
|
||||
Reference in New Issue
Block a user