mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Rewrite device keypair architecture
This commit is contained in:
@@ -18,7 +18,7 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.7.3" apply false
|
id("com.android.application") version "8.9.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const kInstalledAppsIdentifier = 'zapstore-installed-apps';
|
|||||||
/// Identifier for the encrypted stack of apps the user chose as unmanaged
|
/// Identifier for the encrypted stack of apps the user chose as unmanaged
|
||||||
const kUnmanagedAppsIdentifier = 'zapstore-unmanaged-apps';
|
const kUnmanagedAppsIdentifier = 'zapstore-unmanaged-apps';
|
||||||
|
|
||||||
|
/// Identifier for device-private settings and recovery capsules
|
||||||
|
const kSettingsIdentifier = 'zapstore-settings';
|
||||||
|
|
||||||
|
/// Identifier for the local encrypted trusted-signer preference
|
||||||
|
const kTrustedSignersIdentifier = 'trusted-signers';
|
||||||
|
|
||||||
/// Authoritative relay for NIP-82 software application events (kind 32267).
|
/// Authoritative relay for NIP-82 software application events (kind 32267).
|
||||||
/// Used as the relay hint in naddr encoding so other clients can resolve app events.
|
/// Used as the relay hint in naddr encoding so other clients can resolve app events.
|
||||||
|
|||||||
+17
-1
@@ -26,7 +26,9 @@ import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
|||||||
import 'package:zapstore/services/deep_link_service.dart';
|
import 'package:zapstore/services/deep_link_service.dart';
|
||||||
import 'package:zapstore/services/device_backup_service.dart';
|
import 'package:zapstore/services/device_backup_service.dart';
|
||||||
import 'package:zapstore/services/device_key_service.dart';
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
|
import 'package:zapstore/services/device_private_sync_service.dart';
|
||||||
import 'package:zapstore/services/app_catalog_relay_service.dart';
|
import 'package:zapstore/services/app_catalog_relay_service.dart';
|
||||||
|
import 'package:zapstore/utils/debug_utils.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
import 'package:zapstore/widgets/breathing_logo.dart';
|
import 'package:zapstore/widgets/breathing_logo.dart';
|
||||||
|
|
||||||
@@ -169,6 +171,13 @@ class ZapstoreApp extends HookConsumerWidget {
|
|||||||
// Watch initialization state for error overlay display
|
// Watch initialization state for error overlay display
|
||||||
final initState = ref.watch(appInitializationProvider);
|
final initState = ref.watch(appInitializationProvider);
|
||||||
|
|
||||||
|
// One coordinator handles every successful Amber key transition, including
|
||||||
|
// auto sign-in and every manual sign-in entry point.
|
||||||
|
ref.listen<String?>(Signer.activePubkeyProvider, (previous, next) {
|
||||||
|
if (next == null || next == previous) return;
|
||||||
|
onSignInSuccess(ref.read(refProvider));
|
||||||
|
});
|
||||||
|
|
||||||
// Listen to app lifecycle and check for updates when app regains focus
|
// Listen to app lifecycle and check for updates when app regains focus
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
final observer = _AppLifecycleObserver(ref);
|
final observer = _AppLifecycleObserver(ref);
|
||||||
@@ -402,6 +411,9 @@ final storageReadyProvider = FutureProvider<void>((ref) async {
|
|||||||
final appInitializationProvider = FutureProvider<void>((ref) async {
|
final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||||
await ref.read(storageReadyProvider.future);
|
await ref.read(storageReadyProvider.future);
|
||||||
|
|
||||||
|
// Private relay ingestion is one-shot, non-streaming, and never gates UI.
|
||||||
|
unawaited(ref.read(devicePrivateSyncProvider.notifier).start());
|
||||||
|
|
||||||
// Initialize device capabilities (used for dynamic download concurrency)
|
// Initialize device capabilities (used for dynamic download concurrency)
|
||||||
await DeviceCapabilitiesCache.initialize();
|
await DeviceCapabilitiesCache.initialize();
|
||||||
|
|
||||||
@@ -464,7 +476,6 @@ Future<void> _maybeCopySeedDatabase(String dbPath, {bool skip = false}) async {
|
|||||||
Future<void> _attemptAutoSignIn(Ref ref) async {
|
Future<void> _attemptAutoSignIn(Ref ref) async {
|
||||||
try {
|
try {
|
||||||
await ref.read(amberSignerProvider).attemptAutoSignIn();
|
await ref.read(amberSignerProvider).attemptAutoSignIn();
|
||||||
onSignInSuccess(ref);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
// Auto sign-in fails on first install — that's fine, just continue.
|
// Auto sign-in fails on first install — that's fine, just continue.
|
||||||
// Logged at debug because this is expected for new users.
|
// Logged at debug because this is expected for new users.
|
||||||
@@ -486,6 +497,7 @@ Future<void> _attemptAutoSignIn(Ref ref) async {
|
|||||||
void onSignInSuccess(Ref ref) {
|
void onSignInSuccess(Ref ref) {
|
||||||
final pubkey = ref.read(Signer.activePubkeyProvider);
|
final pubkey = ref.read(Signer.activePubkeyProvider);
|
||||||
if (pubkey == null) return;
|
if (pubkey == null) return;
|
||||||
|
ref.read(deviceBackupServiceProvider).beginWork();
|
||||||
|
|
||||||
final storage =
|
final storage =
|
||||||
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
|
ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier;
|
||||||
@@ -562,6 +574,10 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
|||||||
unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||||
} else if (state == AppLifecycleState.paused) {
|
} else if (state == AppLifecycleState.paused) {
|
||||||
_ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck();
|
_ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck();
|
||||||
|
_ref.read(devicePrivateSyncProvider.notifier).cancel();
|
||||||
|
_ref
|
||||||
|
.read(deviceBackupServiceProvider)
|
||||||
|
.cancelCurrentWork(_ref.read(refProvider));
|
||||||
notifier.disconnect();
|
notifier.disconnect();
|
||||||
// Flush any pending log entries to disk before the OS may freeze
|
// Flush any pending log entries to disk before the OS may freeze
|
||||||
// or kill us, so diagnostics survive backgrounding.
|
// or kill us, so diagnostics survive backgrounding.
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
import 'package:zapstore/services/device_key_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/utils/nostr_route.dart';
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
@@ -17,7 +15,7 @@ import 'package:zapstore/widgets/common/time_utils.dart';
|
|||||||
import 'package:zapstore/widgets/floating_overflow_menu.dart';
|
import 'package:zapstore/widgets/floating_overflow_menu.dart';
|
||||||
import 'package:zapstore/theme.dart';
|
import 'package:zapstore/theme.dart';
|
||||||
|
|
||||||
class AppStackScreen extends HookConsumerWidget {
|
class AppStackScreen extends ConsumerWidget {
|
||||||
const AppStackScreen({super.key, required this.stackId, this.authorPubkey});
|
const AppStackScreen({super.key, required this.stackId, this.authorPubkey});
|
||||||
|
|
||||||
final String stackId;
|
final String stackId;
|
||||||
@@ -25,6 +23,8 @@ class AppStackScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isDeviceOwned =
|
||||||
|
authorPubkey != null && authorPubkey == ref.watch(devicePubkeyProvider);
|
||||||
final stackState = ref.watch(
|
final stackState = ref.watch(
|
||||||
query<AppStack>(
|
query<AppStack>(
|
||||||
authors: authorPubkey != null ? {authorPubkey!} : null,
|
authors: authorPubkey != null ? {authorPubkey!} : null,
|
||||||
@@ -32,10 +32,9 @@ class AppStackScreen extends HookConsumerWidget {
|
|||||||
'#d': {stackId},
|
'#d': {stackId},
|
||||||
},
|
},
|
||||||
limit: 1,
|
limit: 1,
|
||||||
source: const LocalAndRemoteSource(
|
source: isDeviceOwned
|
||||||
relays: 'AppCatalog',
|
? const LocalSource()
|
||||||
stream: false,
|
: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
),
|
|
||||||
subscriptionPrefix: 'app-stack-detail-$stackId',
|
subscriptionPrefix: 'app-stack-detail-$stackId',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -65,7 +64,7 @@ class AppStackScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Intermediate widget that loads apps with their release relationships
|
/// Intermediate widget that loads apps with their release relationships
|
||||||
class _AppStackContentWithApps extends HookConsumerWidget {
|
class _AppStackContentWithApps extends ConsumerWidget {
|
||||||
final AppStack stack;
|
final AppStack stack;
|
||||||
|
|
||||||
const _AppStackContentWithApps({required this.stack});
|
const _AppStackContentWithApps({required this.stack});
|
||||||
@@ -73,58 +72,18 @@ class _AppStackContentWithApps extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isEncrypted = stack.content.isNotEmpty;
|
final isEncrypted = stack.content.isNotEmpty;
|
||||||
|
if (isEncrypted && !stack.isDecrypted) {
|
||||||
// For encrypted stacks, decrypt content to get app IDs
|
|
||||||
final decryptedAppIds = useState<Set<String>?>(null);
|
|
||||||
final decryptError = useState<String?>(null);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (!isEncrypted) return null;
|
|
||||||
|
|
||||||
Future<void> decrypt() async {
|
|
||||||
final signer = ref.read(Signer.activeSignerProvider);
|
|
||||||
final pubkey = ref.read(Signer.activePubkeyProvider);
|
|
||||||
|
|
||||||
if (signer == null || pubkey == null) {
|
|
||||||
decryptError.value = 'Sign in required to view this stack';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final decrypted = await signer.nip44Decrypt(stack.content, pubkey);
|
|
||||||
final ids = (jsonDecode(decrypted) as List).cast<String>().toSet();
|
|
||||||
decryptedAppIds.value = ids;
|
|
||||||
} catch (e) {
|
|
||||||
decryptError.value = 'Failed to decrypt stack';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decrypt();
|
|
||||||
return null;
|
|
||||||
}, [stack.content]);
|
|
||||||
|
|
||||||
// Handle decrypt error
|
|
||||||
if (decryptError.value != null) {
|
|
||||||
return _AppStackContent(
|
return _AppStackContent(
|
||||||
stack: stack,
|
stack: stack,
|
||||||
apps: const [],
|
apps: const [],
|
||||||
errorMessage: decryptError.value,
|
errorMessage:
|
||||||
);
|
'This private stack could not be decrypted on this device',
|
||||||
}
|
|
||||||
|
|
||||||
// For encrypted stacks, wait for decryption
|
|
||||||
if (isEncrypted && decryptedAppIds.value == null) {
|
|
||||||
return Scaffold(
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: _AppStackSkeleton(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get app addressable IDs from either tags (public) or decrypted content (private)
|
// Get app addressable IDs from either tags (public) or decrypted content (private)
|
||||||
final appAddressableIds = isEncrypted
|
final appAddressableIds = isEncrypted
|
||||||
? decryptedAppIds.value!
|
? stack.privateAppIds.toSet()
|
||||||
: stack.event
|
: stack.event
|
||||||
.getTagSetValues('a')
|
.getTagSetValues('a')
|
||||||
.where((id) => id.startsWith('32267:'))
|
.where((id) => id.startsWith('32267:'))
|
||||||
@@ -316,7 +275,8 @@ class _AppStackContent extends HookConsumerWidget {
|
|||||||
_EmptyAppsPlaceholder()
|
_EmptyAppsPlaceholder()
|
||||||
else
|
else
|
||||||
...sortedApps.map(
|
...sortedApps.map(
|
||||||
(app) => AppCard(app: app, showUpdateArrow: app.hasUpdate),
|
(app) =>
|
||||||
|
AppCard(app: app, showUpdateArrow: app.hasUpdate),
|
||||||
),
|
),
|
||||||
// Comments section - hidden for private/encrypted stacks
|
// Comments section - hidden for private/encrypted stacks
|
||||||
if (stack.content.isEmpty)
|
if (stack.content.isEmpty)
|
||||||
@@ -374,9 +334,9 @@ class _StackHeader extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
final subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
final subtitleStyle = Theme.of(context).textTheme.bodySmall?.copyWith(
|
final subtitleStyle = Theme.of(
|
||||||
color: subtitleColor,
|
context,
|
||||||
);
|
).textTheme.bodySmall?.copyWith(color: subtitleColor);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -434,7 +394,9 @@ class _StackHeader extends StatelessWidget {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Private',
|
'Private',
|
||||||
style: subtitleStyle?.copyWith(fontWeight: FontWeight.w600),
|
style: subtitleStyle?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -82,8 +82,10 @@ class StacksNotifier extends PagedSubscriptionNotifier<AppStack> {
|
|||||||
final items = await storage
|
final items = await storage
|
||||||
.query(
|
.query(
|
||||||
req,
|
req,
|
||||||
source:
|
source: const LocalAndRemoteSource(
|
||||||
const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
relays: 'AppCatalog',
|
||||||
|
stream: false,
|
||||||
|
),
|
||||||
subscriptionPrefix: 'app-stacks-older',
|
subscriptionPrefix: 'app-stacks-older',
|
||||||
)
|
)
|
||||||
.timeout(
|
.timeout(
|
||||||
@@ -158,6 +160,9 @@ class AppStacksScreen extends HookConsumerWidget {
|
|||||||
? ref.watch(
|
? ref.watch(
|
||||||
query<AppStack>(
|
query<AppStack>(
|
||||||
authors: {signedInPubkey},
|
authors: {signedInPubkey},
|
||||||
|
tags: {
|
||||||
|
'#h': {kZapstoreCommunityPubkey},
|
||||||
|
},
|
||||||
where: (s) => stackNeedsMigration(s, platform),
|
where: (s) => stackNeedsMigration(s, platform),
|
||||||
source: LocalAndRemoteSource(
|
source: LocalAndRemoteSource(
|
||||||
relays: {'social', 'AppCatalog'},
|
relays: {'social', 'AppCatalog'},
|
||||||
|
|||||||
@@ -263,6 +263,9 @@ class _StackMigrationWarning extends ConsumerWidget {
|
|||||||
final stacksState = ref.watch(
|
final stacksState = ref.watch(
|
||||||
query<AppStack>(
|
query<AppStack>(
|
||||||
authors: {pubkey},
|
authors: {pubkey},
|
||||||
|
tags: {
|
||||||
|
'#h': {kZapstoreCommunityPubkey},
|
||||||
|
},
|
||||||
where: (s) => stackNeedsMigration(s, platform),
|
where: (s) => stackNeedsMigration(s, platform),
|
||||||
source: LocalAndRemoteSource(relays: {'AppCatalog'}, stream: false),
|
source: LocalAndRemoteSource(relays: {'AppCatalog'}, stream: false),
|
||||||
subscriptionPrefix: 'app-user-stacks-migration-profile',
|
subscriptionPrefix: 'app-user-stacks-migration-profile',
|
||||||
@@ -1796,7 +1799,7 @@ class _UserStacksSection extends ConsumerWidget {
|
|||||||
final stacksState = ref.watch(
|
final stacksState = ref.watch(
|
||||||
query<AppStack>(
|
query<AppStack>(
|
||||||
authors: {devicePubkey},
|
authors: {devicePubkey},
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
source: const LocalSource(),
|
||||||
subscriptionPrefix: 'app-profile-user-stacks',
|
subscriptionPrefix: 'app-profile-user-stacks',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/constants/app_constants.dart';
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
import 'package:zapstore/services/device_key_service.dart';
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
|
import 'package:zapstore/services/device_private_event_service.dart';
|
||||||
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
|
||||||
/// Reactive set of bookmarked app addressable IDs.
|
final _persistedBookmarksProvider = Provider<AppStack?>((ref) {
|
||||||
///
|
|
||||||
/// Backed by an encrypted AppStack signed by the device key. Auto-decrypted
|
|
||||||
/// by EncryptableModel since the device signer is always registered.
|
|
||||||
final bookmarksProvider = Provider<Set<String>>((ref) {
|
|
||||||
final devicePubkey = ref.watch(devicePubkeyProvider);
|
final devicePubkey = ref.watch(devicePubkeyProvider);
|
||||||
if (devicePubkey == null) return const {};
|
if (devicePubkey == null) return null;
|
||||||
|
|
||||||
final stackState = ref.watch(
|
final stackState = ref.watch(
|
||||||
query<AppStack>(
|
query<AppStack>(
|
||||||
@@ -17,21 +18,140 @@ final bookmarksProvider = Provider<Set<String>>((ref) {
|
|||||||
tags: {
|
tags: {
|
||||||
'#d': {kAppBookmarksIdentifier},
|
'#d': {kAppBookmarksIdentifier},
|
||||||
},
|
},
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
source: const LocalSource(),
|
||||||
subscriptionPrefix: 'app-user-saved-apps',
|
subscriptionPrefix: 'app-user-saved-apps',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final stack = switch (stackState) {
|
return switch (stackState) {
|
||||||
StorageLoading() => null,
|
StorageLoading(:final models) => models.firstOrNull,
|
||||||
StorageError() => null,
|
StorageError() => null,
|
||||||
StorageData(:final models) => models.firstOrNull,
|
StorageData(:final models) => models.firstOrNull,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (stack == null) return const {};
|
|
||||||
return stack.privateAppIds.toSet();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
typedef BookmarksWriter =
|
||||||
|
Future<void> Function(Set<String> appIds, DateTime createdAt);
|
||||||
|
|
||||||
|
/// Owns bookmarked IDs and serializes replacement writes.
|
||||||
|
class BookmarksNotifier extends StateNotifier<Set<String>> {
|
||||||
|
BookmarksNotifier(this._write, {DateTime Function()? clock})
|
||||||
|
: _clock = clock ?? DateTime.now,
|
||||||
|
super(const {});
|
||||||
|
|
||||||
|
static const _equality = SetEquality<String>();
|
||||||
|
final BookmarksWriter _write;
|
||||||
|
final DateTime Function() _clock;
|
||||||
|
Future<void> _writeQueue = Future.value();
|
||||||
|
Set<String> _lastPersisted = const {};
|
||||||
|
DateTime? _lastPersistedAt;
|
||||||
|
DateTime? _lastIssuedAt;
|
||||||
|
int _pendingWrites = 0;
|
||||||
|
|
||||||
|
void acceptPersisted(AppStack? stack) {
|
||||||
|
if (stack == null || !stack.isDecrypted) return;
|
||||||
|
if (_lastPersistedAt == null ||
|
||||||
|
!stack.createdAt.isBefore(_lastPersistedAt!)) {
|
||||||
|
_lastPersisted = Set.unmodifiable(stack.privateAppIds);
|
||||||
|
_lastPersistedAt = stack.createdAt;
|
||||||
|
}
|
||||||
|
if (_pendingWrites > 0 ||
|
||||||
|
(_lastIssuedAt != null && stack.createdAt.isBefore(_lastIssuedAt!))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_setState(stack.privateAppIds.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> toggle(String appId) {
|
||||||
|
final updated = {...state};
|
||||||
|
final removed = updated.remove(appId);
|
||||||
|
if (!removed) updated.add(appId);
|
||||||
|
_setState(updated);
|
||||||
|
|
||||||
|
final createdAt = _nextCreatedAt();
|
||||||
|
final snapshot = Set<String>.unmodifiable(updated);
|
||||||
|
final completer = Completer<bool>();
|
||||||
|
_pendingWrites++;
|
||||||
|
_writeQueue = _writeQueue.then((_) async {
|
||||||
|
try {
|
||||||
|
await _write(snapshot, createdAt);
|
||||||
|
completer.complete(!removed);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
if (error is DevicePrivateSaveException && _pendingWrites == 1) {
|
||||||
|
_lastIssuedAt = _lastPersistedAt;
|
||||||
|
_setState(_lastPersisted);
|
||||||
|
}
|
||||||
|
completer.completeError(error, stackTrace);
|
||||||
|
} finally {
|
||||||
|
_pendingWrites--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _nextCreatedAt() {
|
||||||
|
final now = _clock();
|
||||||
|
final candidate = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
(now.millisecondsSinceEpoch ~/ 1000) * 1000,
|
||||||
|
isUtc: now.isUtc,
|
||||||
|
);
|
||||||
|
final baseline = switch ((_lastIssuedAt, _lastPersistedAt)) {
|
||||||
|
(final issued?, final persisted?) =>
|
||||||
|
issued.isAfter(persisted) ? issued : persisted,
|
||||||
|
(final issued?, null) => issued,
|
||||||
|
(null, final persisted?) => persisted,
|
||||||
|
(null, null) => null,
|
||||||
|
};
|
||||||
|
final next = baseline != null && !candidate.isAfter(baseline)
|
||||||
|
? baseline.add(const Duration(seconds: 1))
|
||||||
|
: candidate;
|
||||||
|
_lastIssuedAt = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setState(Set<String> value) {
|
||||||
|
if (!_equality.equals(state, value)) {
|
||||||
|
state = Set.unmodifiable(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reactive set of bookmarked app addressable IDs.
|
||||||
|
final bookmarksProvider = StateNotifierProvider<BookmarksNotifier, Set<String>>(
|
||||||
|
(ref) {
|
||||||
|
final notifier = BookmarksNotifier(
|
||||||
|
(ids, createdAt) => _writeBookmarks(ref, ids, createdAt),
|
||||||
|
);
|
||||||
|
ref.listen<AppStack?>(
|
||||||
|
_persistedBookmarksProvider,
|
||||||
|
(_, stack) => notifier.acceptPersisted(stack),
|
||||||
|
fireImmediately: true,
|
||||||
|
);
|
||||||
|
return notifier;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _writeBookmarks(
|
||||||
|
Ref ref,
|
||||||
|
Set<String> appIds,
|
||||||
|
DateTime createdAt,
|
||||||
|
) async {
|
||||||
|
final platform = ref.read(packageManagerProvider.notifier).platform;
|
||||||
|
final partial = PartialAppStack.withEncryptedApps(
|
||||||
|
name: 'Saved Apps',
|
||||||
|
identifier: kAppBookmarksIdentifier,
|
||||||
|
apps: appIds.toList(growable: false),
|
||||||
|
platform: platform,
|
||||||
|
);
|
||||||
|
partial.event.createdAt = createdAt;
|
||||||
|
await ref.read(devicePrivateEventServiceProvider).signAndSave(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> toggleBookmark(WidgetRef ref, App app) {
|
||||||
|
final appId = '${app.event.kind}:${app.pubkey}:${app.identifier}';
|
||||||
|
return ref.read(bookmarksProvider.notifier).toggle(appId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Extension to check if an app is saved
|
/// Extension to check if an app is saved
|
||||||
extension SavedAppsChecker on WidgetRef {
|
extension SavedAppsChecker on WidgetRef {
|
||||||
bool isAppSaved(App app) {
|
bool isAppSaved(App app) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -8,206 +10,289 @@ import 'package:models/models.dart';
|
|||||||
import 'package:zapstore/constants/app_constants.dart';
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
import 'package:zapstore/router.dart';
|
import 'package:zapstore/router.dart';
|
||||||
import 'package:zapstore/services/device_key_service.dart';
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
|
import 'package:zapstore/services/device_private_event_service.dart';
|
||||||
|
import 'package:zapstore/services/device_private_sync_service.dart';
|
||||||
import 'package:zapstore/services/log_service.dart';
|
import 'package:zapstore/services/log_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
import 'package:zapstore/services/trusted_signers_service.dart';
|
||||||
import 'package:zapstore/widgets/device_backup_dialog.dart';
|
import 'package:zapstore/widgets/device_backup_dialog.dart';
|
||||||
|
|
||||||
const kSettingsIdentifier = 'zapstore-settings';
|
const _kSettingsFormatVersion = 2;
|
||||||
const _kDeviceBackupsKey = 'deviceBackups';
|
const _kRecoveriesKey = 'recoveries';
|
||||||
|
const _kRecoveryAuthorization = 'zapstore-device-key-authorization-v1';
|
||||||
const _kLegacyInstalledAppsBackupIdentifier = 'zapstore-installed-backup';
|
const _kLegacyInstalledAppsBackupIdentifier = 'zapstore-installed-backup';
|
||||||
const _kLegacyUnmanagedAppsIdentifier = 'zapstore-ignored-apps';
|
const _kLegacyUnmanagedAppsIdentifier = 'zapstore-ignored-apps';
|
||||||
|
|
||||||
/// Manages device key backup/restore via the Amber-signed settings event.
|
/// Manages device-signed settings recovery and legacy Amber stack migration.
|
||||||
///
|
|
||||||
/// The settings event is a single replaceable CustomData (kind 30078) event
|
|
||||||
/// with d-tag "zapstore-settings". Its content is always NIP-44 encrypted to
|
|
||||||
/// the Amber key and contains a JSON object. Device key backups live under
|
|
||||||
/// "deviceBackups": [{"pk": "<hex>", "name": "Pixel 7", "ts": 1715...}, ...].
|
|
||||||
class DeviceBackupService {
|
class DeviceBackupService {
|
||||||
/// Fetch the existing encrypted settings event for the given Amber pubkey.
|
final Set<Request<Model<dynamic>>> _activeRequests = {};
|
||||||
Future<CustomData?> fetchExistingSettings(Ref ref, String amberPubkey) async {
|
bool _cancelled = false;
|
||||||
final storage = ref.read(storageNotifierProvider.notifier);
|
|
||||||
final results = await storage.query(
|
void beginWork() => _cancelled = false;
|
||||||
|
|
||||||
|
void _checkCancelled() {
|
||||||
|
if (_cancelled) throw const DeviceBackupCancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CustomData?> fetchExistingSettings(
|
||||||
|
Ref ref,
|
||||||
|
String devicePubkey,
|
||||||
|
) async {
|
||||||
|
final results = await _queryTracked(
|
||||||
|
ref,
|
||||||
RequestFilter<CustomData>(
|
RequestFilter<CustomData>(
|
||||||
authors: {amberPubkey},
|
authors: {devicePubkey},
|
||||||
tags: {
|
tags: {
|
||||||
'#d': {kSettingsIdentifier},
|
'#d': {kSettingsIdentifier},
|
||||||
},
|
},
|
||||||
|
limit: 1,
|
||||||
).toRequest(),
|
).toRequest(),
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
subscriptionPrefix: 'app-settings-check',
|
subscriptionPrefix: 'app-device-settings-upsert',
|
||||||
);
|
);
|
||||||
return results.firstOrNull;
|
return results.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt the settings content. Invalid or legacy list content degrades
|
/// Finds device-authored settings events recoverable by [amberSigner].
|
||||||
/// gracefully to an empty object.
|
Future<List<DeviceBackupInfo>> fetchRecoveryCandidates({
|
||||||
Future<Map<String, dynamic>> decryptSettings(
|
required Ref ref,
|
||||||
Signer signer,
|
required Signer amberSigner,
|
||||||
CustomData settings,
|
}) async {
|
||||||
) async {
|
_checkCancelled();
|
||||||
try {
|
final request = RequestFilter<CustomData>(
|
||||||
final decrypted = await signer.nip44Decrypt(
|
tags: {
|
||||||
settings.content,
|
'#d': {kSettingsIdentifier},
|
||||||
signer.pubkey,
|
'#p': {amberSigner.pubkey},
|
||||||
|
},
|
||||||
|
limit: 50,
|
||||||
|
).toRequest();
|
||||||
|
final settingsEvents = await _queryTracked(
|
||||||
|
ref,
|
||||||
|
request,
|
||||||
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
|
subscriptionPrefix: 'app-device-recovery',
|
||||||
);
|
);
|
||||||
final decoded = jsonDecode(decrypted);
|
|
||||||
if (decoded is Map<String, dynamic>) return decoded;
|
final candidates = <String, DeviceBackupInfo>{};
|
||||||
if (decoded is List) {
|
for (final settings in settingsEvents) {
|
||||||
return {_kDeviceBackupsKey: decoded};
|
_checkCancelled();
|
||||||
|
if (!verifySignedEvent(ref, settings.event) ||
|
||||||
|
!Nip13.isValid(
|
||||||
|
settings.event,
|
||||||
|
minimumDifficulty: kPrivateEventPowDifficulty,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
final envelope = _decodeSettingsEnvelope(settings.content);
|
||||||
|
if (envelope == null) continue;
|
||||||
|
final recoveries = envelope[_kRecoveriesKey];
|
||||||
|
if (recoveries is! List) continue;
|
||||||
|
|
||||||
|
for (final raw in recoveries.whereType<Map>()) {
|
||||||
|
final recovery = Map<String, dynamic>.from(raw);
|
||||||
|
if (recovery['p'] != amberSigner.pubkey) continue;
|
||||||
|
final ciphertext = recovery['content'];
|
||||||
|
if (ciphertext is! String || ciphertext.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final plaintext = await amberSigner.nip44Decrypt(
|
||||||
|
ciphertext,
|
||||||
|
settings.pubkey,
|
||||||
|
);
|
||||||
|
_checkCancelled();
|
||||||
|
final capsule = Map<String, dynamic>.from(
|
||||||
|
jsonDecode(plaintext) as Map,
|
||||||
|
);
|
||||||
|
final privateKey = capsule['pk'];
|
||||||
|
final authorization = capsule['authorization'];
|
||||||
|
if (privateKey is! String ||
|
||||||
|
privateKey.length != 64 ||
|
||||||
|
Utils.derivePublicKey(privateKey) != settings.pubkey ||
|
||||||
|
authorization is! Map ||
|
||||||
|
!validateRecoveryAuthorization(
|
||||||
|
ref,
|
||||||
|
Map<String, dynamic>.from(authorization),
|
||||||
|
amberPubkey: amberSigner.pubkey,
|
||||||
|
devicePubkey: settings.pubkey,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates[privateKey] = DeviceBackupInfo(
|
||||||
|
privateKeyHex: privateKey,
|
||||||
|
deviceName: capsule['name'] as String? ?? 'Android device',
|
||||||
|
backedUpAt: capsule['ts'] is int
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(capsule['ts'] as int)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return {};
|
// Invalid or unrelated recovery capsules degrade gracefully.
|
||||||
}
|
}
|
||||||
return {};
|
}
|
||||||
|
}
|
||||||
|
return candidates.values.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode device backup entries from an encrypted settings event.
|
/// Upserts a recovery capsule for the active Amber identity.
|
||||||
Future<List<Map<String, dynamic>>> decryptEntries(
|
|
||||||
Signer signer,
|
|
||||||
CustomData settings,
|
|
||||||
) async {
|
|
||||||
final decodedSettings = await decryptSettings(signer, settings);
|
|
||||||
final entries = decodedSettings[_kDeviceBackupsKey];
|
|
||||||
if (entries is! List) return [];
|
|
||||||
return entries.whereType<Map<String, dynamic>>().toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Back up the current device key. Reads existing entries, upserts the
|
|
||||||
/// current device, then publishes the updated array.
|
|
||||||
Future<void> backupDeviceKey({
|
Future<void> backupDeviceKey({
|
||||||
required Ref ref,
|
required Ref ref,
|
||||||
required Signer amberSigner,
|
required Signer amberSigner,
|
||||||
}) async {
|
}) async {
|
||||||
final deviceKeyService = ref.read(deviceKeyServiceProvider);
|
_checkCancelled();
|
||||||
final privateKeyHex = await deviceKeyService.getOrCreatePrivateKey();
|
final privateEvents = ref.read(devicePrivateEventServiceProvider);
|
||||||
|
final keyService = ref.read(deviceKeyServiceProvider);
|
||||||
|
final privateKeyHex = await keyService.getOrCreatePrivateKey();
|
||||||
|
final devicePubkey = privateEvents.devicePubkey;
|
||||||
|
final existing = await fetchExistingSettings(ref, devicePubkey);
|
||||||
|
final envelope = existing != null
|
||||||
|
? _decodeSettingsEnvelope(existing.content) ?? _emptySettingsEnvelope()
|
||||||
|
: _emptySettingsEnvelope();
|
||||||
|
|
||||||
final deviceName = await _getDeviceName();
|
final deviceName = await _getDeviceName();
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
// Load and preserve existing encrypted settings fields.
|
final authorizationPartial = PartialNote(_kRecoveryAuthorization);
|
||||||
final existing = await fetchExistingSettings(ref, amberSigner.pubkey);
|
authorizationPartial.event.addTagValue('device', devicePubkey);
|
||||||
final settings = existing != null
|
authorizationPartial.event.addTagValue('p', amberSigner.pubkey);
|
||||||
? await decryptSettings(amberSigner, existing)
|
final authorization = await authorizationPartial.signWith(amberSigner);
|
||||||
: <String, dynamic>{};
|
_checkCancelled();
|
||||||
final entries = (settings[_kDeviceBackupsKey] as List?)
|
final capsule = await privateEvents.encryptFor(
|
||||||
?.whereType<Map<String, dynamic>>()
|
jsonEncode({
|
||||||
.toList() ??
|
|
||||||
<Map<String, dynamic>>[];
|
|
||||||
|
|
||||||
// Upsert this device's entry (match by pk)
|
|
||||||
entries.removeWhere((e) => e['pk'] == privateKeyHex);
|
|
||||||
entries.add({
|
|
||||||
'pk': privateKeyHex,
|
'pk': privateKeyHex,
|
||||||
'name': deviceName,
|
'name': deviceName,
|
||||||
'ts': DateTime.now().millisecondsSinceEpoch,
|
'ts': timestamp,
|
||||||
});
|
'authorization': authorization.event.toMap(),
|
||||||
settings[_kDeviceBackupsKey] = entries;
|
}),
|
||||||
|
|
||||||
final encrypted = await amberSigner.nip44Encrypt(
|
|
||||||
jsonEncode(settings),
|
|
||||||
amberSigner.pubkey,
|
amberSigner.pubkey,
|
||||||
);
|
);
|
||||||
|
_checkCancelled();
|
||||||
|
|
||||||
|
final recoveries =
|
||||||
|
(envelope[_kRecoveriesKey] as List?)
|
||||||
|
?.whereType<Map>()
|
||||||
|
.map((entry) => Map<String, dynamic>.from(entry))
|
||||||
|
.toList() ??
|
||||||
|
<Map<String, dynamic>>[];
|
||||||
|
recoveries.removeWhere((entry) => entry['p'] == amberSigner.pubkey);
|
||||||
|
recoveries.add({
|
||||||
|
'p': amberSigner.pubkey,
|
||||||
|
'content': capsule,
|
||||||
|
'ts': timestamp,
|
||||||
|
});
|
||||||
|
envelope[_kRecoveriesKey] = recoveries;
|
||||||
|
|
||||||
final partial = PartialCustomData(
|
final partial = PartialCustomData(
|
||||||
identifier: kSettingsIdentifier,
|
identifier: kSettingsIdentifier,
|
||||||
content: encrypted,
|
content: jsonEncode(envelope),
|
||||||
);
|
);
|
||||||
|
for (final recovery in recoveries) {
|
||||||
final signed = await partial.signWith(amberSigner);
|
final pubkey = recovery['p'];
|
||||||
final storage = ref.read(storageNotifierProvider.notifier);
|
if (pubkey is String) partial.event.addTagValue('p', pubkey);
|
||||||
await storage.save({signed});
|
}
|
||||||
storage.publish({signed}, relays: 'AppCatalog');
|
partial.event.setTagValue('format', 'zapstore-device-settings-v2');
|
||||||
|
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
|
||||||
|
existing?.createdAt,
|
||||||
|
);
|
||||||
|
await privateEvents.signAndSave(partial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move Amber-authored encrypted AppStacks to equivalent device-key stacks.
|
/// Merges every Amber-authored encrypted AppStack into device ownership.
|
||||||
///
|
|
||||||
/// Existing device-key stacks are merged, not overwritten. Legacy identifiers
|
|
||||||
/// from the pre-device-key branch are normalized to the current d-tags.
|
|
||||||
Future<bool> migratePrivateStacksToDeviceKey({
|
Future<bool> migratePrivateStacksToDeviceKey({
|
||||||
required Ref ref,
|
required Ref ref,
|
||||||
required Signer amberSigner,
|
required Signer amberSigner,
|
||||||
}) async {
|
}) async {
|
||||||
final keyService = ref.read(deviceKeyServiceProvider);
|
_checkCancelled();
|
||||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
final request = RequestFilter<AppStack>(
|
||||||
if (devicePubkey == null) return false;
|
authors: {amberSigner.pubkey},
|
||||||
|
).toRequest();
|
||||||
if (await keyService.hasPrivateStacksMigrated(
|
final amberStacks = await _queryTracked(
|
||||||
amberSigner.pubkey,
|
ref,
|
||||||
devicePubkey,
|
request,
|
||||||
)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
final deviceSigner = ref.read(Signer.signerProvider(devicePubkey));
|
|
||||||
if (deviceSigner == null) return false;
|
|
||||||
|
|
||||||
final storage = ref.read(storageNotifierProvider.notifier);
|
|
||||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
|
||||||
|
|
||||||
final amberStacks = await storage.query(
|
|
||||||
RequestFilter<AppStack>(authors: {amberSigner.pubkey}).toRequest(),
|
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
subscriptionPrefix: 'app-private-stack-migration',
|
subscriptionPrefix: 'app-private-stack-migration',
|
||||||
);
|
);
|
||||||
|
|
||||||
final encryptedAmberStacks = amberStacks
|
final privateEvents = ref.read(devicePrivateEventServiceProvider);
|
||||||
.where((stack) => stack.content.isNotEmpty)
|
final storage = ref.read(storageNotifierProvider.notifier);
|
||||||
.toList();
|
final platform = ref.read(packageManagerProvider.notifier).platform;
|
||||||
if (encryptedAmberStacks.isEmpty) {
|
var migratedAny = false;
|
||||||
return false;
|
|
||||||
|
for (final stack in amberStacks.where(
|
||||||
|
(stack) => stack.content.isNotEmpty,
|
||||||
|
)) {
|
||||||
|
_checkCancelled();
|
||||||
|
if (!verifySignedEvent(ref, stack.event)) {
|
||||||
|
LogService.I.warn(
|
||||||
|
'ignored unverified Amber private stack',
|
||||||
|
tag: 'backup',
|
||||||
|
fields: {'identifier': stack.identifier},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final oldAppIds = await decryptAmberStackAppIds(amberSigner, stack);
|
||||||
|
_checkCancelled();
|
||||||
|
if (oldAppIds == null) {
|
||||||
|
LogService.I.warn(
|
||||||
|
'could not decrypt Amber private stack; migration remains retryable',
|
||||||
|
tag: 'backup',
|
||||||
|
fields: {'identifier': stack.identifier},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var migratedAny = false;
|
|
||||||
for (final stack in encryptedAmberStacks) {
|
|
||||||
final identifier = _deviceIdentifierFor(stack.identifier);
|
final identifier = _deviceIdentifierFor(stack.identifier);
|
||||||
final oldAppIds = stack.privateAppIds;
|
final existing = (await storage.query(
|
||||||
if (oldAppIds.isEmpty) continue;
|
|
||||||
|
|
||||||
final existingDeviceStacks = await storage.query(
|
|
||||||
RequestFilter<AppStack>(
|
RequestFilter<AppStack>(
|
||||||
authors: {devicePubkey},
|
authors: {privateEvents.devicePubkey},
|
||||||
tags: {
|
tags: {
|
||||||
'#d': {identifier},
|
'#d': {identifier},
|
||||||
},
|
},
|
||||||
|
limit: 1,
|
||||||
).toRequest(),
|
).toRequest(),
|
||||||
source: const LocalSource(),
|
source: const LocalSource(),
|
||||||
subscriptionPrefix: 'app-private-stack-migration-device',
|
subscriptionPrefix: 'app-private-stack-migration-device',
|
||||||
|
)).firstOrNull;
|
||||||
|
if (existing != null) {
|
||||||
|
await existing.prepareAfterLoading(ref);
|
||||||
|
_checkCancelled();
|
||||||
|
if (!existing.isDecrypted) {
|
||||||
|
LogService.I.warn(
|
||||||
|
'could not decrypt device stack; migration remains retryable',
|
||||||
|
tag: 'backup',
|
||||||
|
fields: {'identifier': identifier},
|
||||||
);
|
);
|
||||||
final existingAppIds =
|
continue;
|
||||||
existingDeviceStacks.firstOrNull?.privateAppIds ?? const <String>[];
|
}
|
||||||
final mergedAppIds = [
|
}
|
||||||
...existingAppIds,
|
|
||||||
...oldAppIds.where((id) => !existingAppIds.contains(id)),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (mergedAppIds.length == existingAppIds.length) continue;
|
final merged = LinkedHashSet<String>.of(
|
||||||
|
existing?.privateAppIds ?? const [],
|
||||||
|
)..addAll(oldAppIds);
|
||||||
|
if (existing != null &&
|
||||||
|
merged.length == existing.privateAppIds.toSet().length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
final partial = PartialAppStack.withEncryptedApps(
|
final partial = PartialAppStack.withEncryptedApps(
|
||||||
name: stack.name ?? identifier,
|
name: stack.name ?? identifier,
|
||||||
identifier: identifier,
|
identifier: identifier,
|
||||||
apps: mergedAppIds,
|
description: stack.description,
|
||||||
platform: platform,
|
apps: merged.toList(growable: false),
|
||||||
|
platform: stack.platform ?? platform,
|
||||||
);
|
);
|
||||||
|
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
|
||||||
final signed = await partial.signWith(deviceSigner);
|
existing?.createdAt,
|
||||||
await storage.save({signed});
|
);
|
||||||
storage.publish({signed}, relays: 'AppCatalog');
|
await privateEvents.signAndSave(partial);
|
||||||
migratedAny = true;
|
migratedAny = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await keyService.markPrivateStacksMigrated(
|
|
||||||
amberSigner.pubkey,
|
|
||||||
devicePubkey,
|
|
||||||
);
|
|
||||||
if (migratedAny) {
|
if (migratedAny) {
|
||||||
LogService.I.info(
|
LogService.I.info(
|
||||||
'migrated Amber private stacks to device key',
|
'migrated Amber private stacks to device key',
|
||||||
tag: 'backup',
|
tag: 'backup',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return migratedAny;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the stored device key and register the restored signer immediately.
|
|
||||||
Future<void> restoreDeviceKey({
|
Future<void> restoreDeviceKey({
|
||||||
required Ref ref,
|
required Ref ref,
|
||||||
required String privateKeyHex,
|
required String privateKeyHex,
|
||||||
@@ -218,21 +303,115 @@ class DeviceBackupService {
|
|||||||
ref.read(devicePubkeyProvider.notifier).state = restoredSigner.pubkey;
|
ref.read(devicePubkeyProvider.notifier).state = restoredSigner.pubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<E>> _queryTracked<E extends Model<dynamic>>(
|
||||||
|
Ref ref,
|
||||||
|
Request<E> request, {
|
||||||
|
required Source source,
|
||||||
|
required String subscriptionPrefix,
|
||||||
|
}) async {
|
||||||
|
_checkCancelled();
|
||||||
|
_activeRequests.add(request);
|
||||||
|
try {
|
||||||
|
final results = await ref
|
||||||
|
.read(storageNotifierProvider.notifier)
|
||||||
|
.query(
|
||||||
|
request,
|
||||||
|
source: source,
|
||||||
|
subscriptionPrefix: subscriptionPrefix,
|
||||||
|
);
|
||||||
|
_checkCancelled();
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
_activeRequests.remove(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancelCurrentWork(Ref ref) {
|
||||||
|
_cancelled = true;
|
||||||
|
final storage = ref.read(storageNotifierProvider.notifier);
|
||||||
|
for (final request in _activeRequests.toList(growable: false)) {
|
||||||
|
unawaited(storage.cancel(request));
|
||||||
|
}
|
||||||
|
_activeRequests.clear();
|
||||||
|
ref.read(devicePrivateEventServiceProvider).cancelMining();
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> _getDeviceName() async {
|
Future<String> _getDeviceName() async {
|
||||||
try {
|
try {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final info = await DeviceInfoPlugin().androidInfo;
|
return (await DeviceInfoPlugin().androidInfo).model;
|
||||||
return info.model;
|
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return 'Android device';
|
return 'Android device';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicitly decrypts an imperatively queried Amber stack.
|
||||||
|
///
|
||||||
|
/// Imperative storage queries do not run EncryptableModel preparation, so
|
||||||
|
/// migration must not rely on [AppStack.privateAppIds] being populated.
|
||||||
|
Future<List<String>?> decryptAmberStackAppIds(
|
||||||
|
Signer amberSigner,
|
||||||
|
AppStack stack,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final plaintext = await amberSigner.nip44Decrypt(
|
||||||
|
stack.content,
|
||||||
|
amberSigner.pubkey,
|
||||||
|
);
|
||||||
|
final decoded = jsonDecode(plaintext);
|
||||||
|
return decoded is List ? decoded.whereType<String>().toList() : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final deviceBackupServiceProvider = Provider<DeviceBackupService>(
|
final deviceBackupServiceProvider = Provider<DeviceBackupService>(
|
||||||
(ref) => DeviceBackupService(),
|
(ref) => DeviceBackupService(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _emptySettingsEnvelope() => {
|
||||||
|
'version': _kSettingsFormatVersion,
|
||||||
|
_kRecoveriesKey: <Map<String, dynamic>>[],
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic>? _decodeSettingsEnvelope(String content) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(content);
|
||||||
|
if (decoded is! Map) return null;
|
||||||
|
final envelope = Map<String, dynamic>.from(decoded);
|
||||||
|
if (envelope['version'] != _kSettingsFormatVersion) return null;
|
||||||
|
return envelope;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies that Amber explicitly authorized the recovered device pubkey.
|
||||||
|
bool validateRecoveryAuthorization(
|
||||||
|
Ref ref,
|
||||||
|
Map<String, dynamic> authorization, {
|
||||||
|
required String amberPubkey,
|
||||||
|
required String devicePubkey,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
if (authorization['kind'] != 1 ||
|
||||||
|
authorization['pubkey'] != amberPubkey ||
|
||||||
|
authorization['content'] != _kRecoveryAuthorization) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final event = PartialEvent<Model<dynamic>>(authorization, 1);
|
||||||
|
if (!event.getTagSetValues('device').contains(devicePubkey) ||
|
||||||
|
!event.getTagSetValues('p').contains(amberPubkey) ||
|
||||||
|
authorization['id'] != Utils.getEventId(event, amberPubkey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ref.read(verifierProvider).verify(authorization);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _deviceIdentifierFor(String identifier) {
|
String _deviceIdentifierFor(String identifier) {
|
||||||
return switch (identifier) {
|
return switch (identifier) {
|
||||||
_kLegacyInstalledAppsBackupIdentifier => kInstalledAppsIdentifier,
|
_kLegacyInstalledAppsBackupIdentifier => kInstalledAppsIdentifier,
|
||||||
@@ -241,7 +420,6 @@ String _deviceIdentifierFor(String identifier) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decoded device entry for display in the restore dialog.
|
|
||||||
class DeviceBackupInfo {
|
class DeviceBackupInfo {
|
||||||
DeviceBackupInfo({
|
DeviceBackupInfo({
|
||||||
required this.privateKeyHex,
|
required this.privateKeyHex,
|
||||||
@@ -254,80 +432,74 @@ class DeviceBackupInfo {
|
|||||||
final DateTime? backedUpAt;
|
final DateTime? backedUpAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called after successful Amber sign-in. Automatically backs up the device
|
final class DeviceBackupCancelled implements Exception {
|
||||||
/// key, then shows a restore dialog only if other device entries exist.
|
const DeviceBackupCancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs recovery, migration, and backup after every successful Amber sign-in.
|
||||||
Future<void> maybeOfferDeviceBackup(Ref ref) async {
|
Future<void> maybeOfferDeviceBackup(Ref ref) async {
|
||||||
final amberPubkey = ref.read(Signer.activePubkeyProvider);
|
final amberPubkey = ref.read(Signer.activePubkeyProvider);
|
||||||
if (amberPubkey == null) return;
|
final amberSigner = ref.read(Signer.activeSignerProvider);
|
||||||
|
if (amberPubkey == null || amberSigner == null) return;
|
||||||
|
|
||||||
final keyService = ref.read(deviceKeyServiceProvider);
|
final keyService = ref.read(deviceKeyServiceProvider);
|
||||||
|
|
||||||
final service = ref.read(deviceBackupServiceProvider);
|
final service = ref.read(deviceBackupServiceProvider);
|
||||||
final signer = ref.read(Signer.activeSignerProvider);
|
|
||||||
if (signer == null) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final hasBackupBeenOffered = await keyService.hasBackupBeenOffered(
|
final alreadyOffered = await keyService.hasBackupBeenOffered(amberPubkey);
|
||||||
amberPubkey,
|
List<DeviceBackupInfo> candidates = const [];
|
||||||
|
try {
|
||||||
|
candidates = await service.fetchRecoveryCandidates(
|
||||||
|
ref: ref,
|
||||||
|
amberSigner: amberSigner,
|
||||||
|
);
|
||||||
|
} catch (error, stack) {
|
||||||
|
LogService.I.warn(
|
||||||
|
'device recovery query failed',
|
||||||
|
tag: 'backup',
|
||||||
|
err: error,
|
||||||
|
stack: stack,
|
||||||
);
|
);
|
||||||
final backup = hasBackupBeenOffered
|
|
||||||
? null
|
|
||||||
: await service.fetchExistingSettings(ref, amberPubkey);
|
|
||||||
|
|
||||||
final entries = backup != null
|
|
||||||
? await service.decryptEntries(signer, backup)
|
|
||||||
: const <Map<String, dynamic>>[];
|
|
||||||
final currentPrivateKey = await keyService.getOrCreatePrivateKey();
|
|
||||||
|
|
||||||
final otherDevices = entries
|
|
||||||
.where((e) => e['pk'] != currentPrivateKey)
|
|
||||||
.map(
|
|
||||||
(e) => DeviceBackupInfo(
|
|
||||||
privateKeyHex: e['pk'] as String,
|
|
||||||
deviceName: e['name'] as String? ?? 'Unknown device',
|
|
||||||
backedUpAt: e['ts'] != null
|
|
||||||
? DateTime.fromMillisecondsSinceEpoch(e['ts'] as int)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (otherDevices.isNotEmpty) {
|
|
||||||
final context = rootNavigatorKey.currentState?.overlay?.context;
|
|
||||||
if (context == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final selectedBackup = await showDialog<DeviceBackupInfo>(
|
final currentPrivateKey = await keyService.getOrCreatePrivateKey();
|
||||||
|
final otherDevices = candidates
|
||||||
|
.where((entry) => entry.privateKeyHex != currentPrivateKey)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
if (!alreadyOffered && otherDevices.isNotEmpty) {
|
||||||
|
final context = rootNavigatorKey.currentState?.overlay?.context;
|
||||||
|
if (context != null && context.mounted) {
|
||||||
|
final selected = await showDialog<DeviceBackupInfo>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (_) => DeviceBackupRestoreDialog(backups: otherDevices),
|
builder: (_) => DeviceBackupRestoreDialog(backups: otherDevices),
|
||||||
);
|
);
|
||||||
|
if (selected != null) {
|
||||||
if (selectedBackup != null) {
|
|
||||||
await service.restoreDeviceKey(
|
await service.restoreDeviceKey(
|
||||||
ref: ref,
|
ref: ref,
|
||||||
privateKeyHex: selectedBackup.privateKeyHex,
|
privateKeyHex: selected.privateKeyHex,
|
||||||
);
|
);
|
||||||
|
await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await service.migratePrivateStacksToDeviceKey(
|
await service.migratePrivateStacksToDeviceKey(
|
||||||
ref: ref,
|
ref: ref,
|
||||||
amberSigner: signer,
|
amberSigner: amberSigner,
|
||||||
);
|
);
|
||||||
|
await ref.read(trustServiceProvider).migrateLegacyAmberRecord(amberSigner);
|
||||||
if (hasBackupBeenOffered) return;
|
await service.backupDeviceKey(ref: ref, amberSigner: amberSigner);
|
||||||
|
if (!alreadyOffered) {
|
||||||
// Always back up whichever device key is active after optional restore.
|
|
||||||
await service.backupDeviceKey(ref: ref, amberSigner: signer);
|
|
||||||
await keyService.markBackupOffered(amberPubkey);
|
await keyService.markBackupOffered(amberPubkey);
|
||||||
} catch (e, st) {
|
}
|
||||||
|
} catch (error, stack) {
|
||||||
LogService.I.warn(
|
LogService.I.warn(
|
||||||
'device backup/restore check failed',
|
'device recovery/migration/backup failed',
|
||||||
tag: 'backup',
|
tag: 'backup',
|
||||||
err: e,
|
err: error,
|
||||||
stack: st,
|
stack: stack,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:models/models.dart';
|
||||||
|
import 'package:purplebase/purplebase.dart';
|
||||||
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
|
|
||||||
|
const kPrivateEventPowDifficulty = 16;
|
||||||
|
|
||||||
|
final privateEventPowExecutorProvider = Provider<IsolateProofOfWorkExecutor>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
final executor = IsolateProofOfWorkExecutor();
|
||||||
|
ref.onDispose(executor.dispose);
|
||||||
|
return executor;
|
||||||
|
});
|
||||||
|
|
||||||
|
final devicePrivateEventServiceProvider = Provider<DevicePrivateEventService>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return DevicePrivateEventService(
|
||||||
|
ref,
|
||||||
|
executor: ref.watch(privateEventPowExecutorProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Enforces the signing and publication contract for device-private events.
|
||||||
|
class DevicePrivateEventService {
|
||||||
|
DevicePrivateEventService(
|
||||||
|
this.ref, {
|
||||||
|
required ProofOfWorkExecutor executor,
|
||||||
|
this.difficulty = kPrivateEventPowDifficulty,
|
||||||
|
this.timeout = const Duration(seconds: 10),
|
||||||
|
this.maxAttempts = 1 << 21,
|
||||||
|
this.batchSize = 512,
|
||||||
|
}) : _executor = executor;
|
||||||
|
|
||||||
|
final Ref ref;
|
||||||
|
final ProofOfWorkExecutor _executor;
|
||||||
|
final int difficulty;
|
||||||
|
final Duration timeout;
|
||||||
|
final int maxAttempts;
|
||||||
|
final int batchSize;
|
||||||
|
|
||||||
|
String get devicePubkey {
|
||||||
|
final pubkey = ref.read(devicePubkeyProvider);
|
||||||
|
if (pubkey == null) {
|
||||||
|
throw const DevicePrivateEventException('Device key is unavailable.');
|
||||||
|
}
|
||||||
|
return pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
Signer get deviceSigner {
|
||||||
|
final signer = ref.read(Signer.signerProvider(devicePubkey));
|
||||||
|
if (signer == null) {
|
||||||
|
throw const DevicePrivateEventException('Device signer is unavailable.');
|
||||||
|
}
|
||||||
|
return signer;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProofOfWorkOptions get proofOfWork => ProofOfWorkOptions(
|
||||||
|
difficulty: difficulty,
|
||||||
|
timeout: timeout,
|
||||||
|
maxAttempts: maxAttempts,
|
||||||
|
batchSize: batchSize,
|
||||||
|
executor: _executor,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<String> encryptToDevice(String plaintext) =>
|
||||||
|
deviceSigner.nip44Encrypt(plaintext, devicePubkey);
|
||||||
|
|
||||||
|
Future<String> decryptFromDevice(String ciphertext) =>
|
||||||
|
deviceSigner.nip44Decrypt(ciphertext, devicePubkey);
|
||||||
|
|
||||||
|
Future<String> encryptFor(String plaintext, String recipientPubkey) =>
|
||||||
|
deviceSigner.nip44Encrypt(plaintext, recipientPubkey);
|
||||||
|
|
||||||
|
Future<E> signAndSave<E extends Model<dynamic>>(
|
||||||
|
PartialModel<E> partial, {
|
||||||
|
bool publish = true,
|
||||||
|
}) async {
|
||||||
|
_validatePartial(partial);
|
||||||
|
final signer = deviceSigner;
|
||||||
|
final signed = await partial.signWith(signer, proofOfWork: proofOfWork);
|
||||||
|
_validateSigned(signed);
|
||||||
|
|
||||||
|
final storage = ref.read(storageNotifierProvider.notifier);
|
||||||
|
final saved = await storage.save({signed});
|
||||||
|
if (!saved) {
|
||||||
|
throw const DevicePrivateSaveException(
|
||||||
|
'Could not save private data locally.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
final response = await storage.publish({signed}, relays: 'AppCatalog');
|
||||||
|
final accepted =
|
||||||
|
response.results[signed.event.id]?.any((result) => result.accepted) ??
|
||||||
|
false;
|
||||||
|
if (!accepted) {
|
||||||
|
throw const DevicePrivatePublishException(
|
||||||
|
'Saved locally, but no AppCatalog relay accepted the event.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return signed;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime nextReplaceableTimestamp(
|
||||||
|
DateTime? existing, {
|
||||||
|
DateTime Function()? clock,
|
||||||
|
}) {
|
||||||
|
final now = (clock ?? DateTime.now)();
|
||||||
|
final candidate = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
(now.millisecondsSinceEpoch ~/ 1000) * 1000,
|
||||||
|
isUtc: now.isUtc,
|
||||||
|
);
|
||||||
|
if (existing == null || candidate.isAfter(existing)) return candidate;
|
||||||
|
return existing.add(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancelMining() {
|
||||||
|
final executor = _executor;
|
||||||
|
if (executor is IsolateProofOfWorkExecutor) {
|
||||||
|
executor.cancelAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validatePartial(PartialModel<dynamic> partial) {
|
||||||
|
final event = partial.event;
|
||||||
|
switch (event.kind) {
|
||||||
|
case 30267:
|
||||||
|
if (event.content.isEmpty) {
|
||||||
|
throw const DevicePrivateEventException(
|
||||||
|
'Public app stacks must not use the private event signer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (event.containsTag('h')) {
|
||||||
|
throw const DevicePrivateEventException(
|
||||||
|
'Encrypted app stacks must not carry a community h tag.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 30078:
|
||||||
|
final identifier = event.identifier;
|
||||||
|
if (identifier != kSettingsIdentifier &&
|
||||||
|
identifier != kTrustedSignersIdentifier) {
|
||||||
|
throw DevicePrivateEventException(
|
||||||
|
'Unsupported private CustomData identifier: $identifier',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw DevicePrivateEventException(
|
||||||
|
'Unsupported private event kind: ${event.kind}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validateSigned(Model<dynamic> signed) {
|
||||||
|
if (signed.pubkey != devicePubkey) {
|
||||||
|
throw const DevicePrivateEventException(
|
||||||
|
'Private event was not signed by the device key.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!verifySignedEvent(ref, signed.event)) {
|
||||||
|
throw const DevicePrivateEventException(
|
||||||
|
'Private event signature or event ID is invalid.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Nip13.isValid(signed.event, minimumDifficulty: difficulty)) {
|
||||||
|
throw DevicePrivateEventException(
|
||||||
|
'Private event does not meet $difficulty-bit proof of work.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies both the canonical Nostr ID and its BIP-340 signature.
|
||||||
|
bool verifySignedEvent(Ref ref, EventBase event) {
|
||||||
|
try {
|
||||||
|
final map = event.toMap();
|
||||||
|
final pubkey = map['pubkey'];
|
||||||
|
if (pubkey is! String) return false;
|
||||||
|
final partial = PartialEvent<Model<dynamic>>(map, event.kind)
|
||||||
|
..tags = [for (final tag in event.tags) List<String>.of(tag)];
|
||||||
|
return map['id'] == Utils.getEventId(partial, pubkey) &&
|
||||||
|
ref.read(verifierProvider).verify(map);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DevicePrivateEventException implements Exception {
|
||||||
|
const DevicePrivateEventException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DevicePrivateSaveException extends DevicePrivateEventException {
|
||||||
|
const DevicePrivateSaveException(super.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DevicePrivatePublishException extends DevicePrivateEventException {
|
||||||
|
const DevicePrivatePublishException(super.message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
|
import 'package:zapstore/services/device_private_event_service.dart';
|
||||||
|
import 'package:zapstore/services/log_service.dart';
|
||||||
|
|
||||||
|
enum DevicePrivateSyncPhase { idle, syncing, success, error, cancelled }
|
||||||
|
|
||||||
|
class DevicePrivateSyncState {
|
||||||
|
const DevicePrivateSyncState(this.phase, {this.error});
|
||||||
|
|
||||||
|
const DevicePrivateSyncState.idle() : this(DevicePrivateSyncPhase.idle);
|
||||||
|
|
||||||
|
final DevicePrivateSyncPhase phase;
|
||||||
|
final Object? error;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef DevicePrivateQuery =
|
||||||
|
Future<List<Model<dynamic>>> Function(
|
||||||
|
Request<Model<dynamic>> request,
|
||||||
|
Source source,
|
||||||
|
String subscriptionPrefix,
|
||||||
|
);
|
||||||
|
|
||||||
|
class DevicePrivateSyncNotifier extends StateNotifier<DevicePrivateSyncState> {
|
||||||
|
DevicePrivateSyncNotifier(this.ref, {DevicePrivateQuery? query})
|
||||||
|
: _query = query,
|
||||||
|
super(const DevicePrivateSyncState.idle());
|
||||||
|
|
||||||
|
final Ref ref;
|
||||||
|
final DevicePrivateQuery? _query;
|
||||||
|
Request<Model<dynamic>>? _activeRequest;
|
||||||
|
Future<void>? _runFuture;
|
||||||
|
bool _started = false;
|
||||||
|
bool _cancelled = false;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_started) return;
|
||||||
|
_started = true;
|
||||||
|
_runFuture = _runSync();
|
||||||
|
await _runFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit recovery exception for a device key restored after app boot.
|
||||||
|
Future<void> syncRestoredKey() async {
|
||||||
|
cancel();
|
||||||
|
await _runFuture;
|
||||||
|
_runFuture = _runSync();
|
||||||
|
await _runFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runSync() async {
|
||||||
|
_cancelled = false;
|
||||||
|
state = const DevicePrivateSyncState(DevicePrivateSyncPhase.syncing);
|
||||||
|
|
||||||
|
final devicePubkey = ref.read(devicePubkeyProvider);
|
||||||
|
if (devicePubkey == null) {
|
||||||
|
state = const DevicePrivateSyncState(
|
||||||
|
DevicePrivateSyncPhase.error,
|
||||||
|
error: 'Device key is unavailable.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final request = RequestFilter<Model<dynamic>>(
|
||||||
|
kinds: const {30267, 30078},
|
||||||
|
authors: {devicePubkey},
|
||||||
|
).toRequest();
|
||||||
|
_activeRequest = request;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _queryStorage(
|
||||||
|
request,
|
||||||
|
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
|
subscriptionPrefix: 'app-device-private-boot',
|
||||||
|
);
|
||||||
|
if (_cancelled) return;
|
||||||
|
await _upgradeLegacyProofs(request);
|
||||||
|
if (_cancelled) return;
|
||||||
|
state = const DevicePrivateSyncState(DevicePrivateSyncPhase.success);
|
||||||
|
} catch (error, stack) {
|
||||||
|
if (_cancelled) return;
|
||||||
|
LogService.I.warn(
|
||||||
|
'device private boot sync failed',
|
||||||
|
tag: 'private-sync',
|
||||||
|
err: error,
|
||||||
|
stack: stack,
|
||||||
|
);
|
||||||
|
state = DevicePrivateSyncState(
|
||||||
|
DevicePrivateSyncPhase.error,
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_activeRequest = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _upgradeLegacyProofs(Request<Model<dynamic>> request) async {
|
||||||
|
final privateEvents = ref.read(devicePrivateEventServiceProvider);
|
||||||
|
final models = await _queryStorage(
|
||||||
|
request,
|
||||||
|
source: const LocalSource(),
|
||||||
|
subscriptionPrefix: 'app-device-private-upgrade',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final model in models) {
|
||||||
|
if (_cancelled ||
|
||||||
|
Nip13.isValid(
|
||||||
|
model.event,
|
||||||
|
minimumDifficulty: kPrivateEventPowDifficulty,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (model) {
|
||||||
|
case AppStack stack when stack.content.isNotEmpty:
|
||||||
|
await stack.prepareAfterLoading(ref);
|
||||||
|
if (!stack.isDecrypted) {
|
||||||
|
LogService.I.warn(
|
||||||
|
'legacy private stack could not be decrypted for PoW upgrade',
|
||||||
|
tag: 'private-sync',
|
||||||
|
fields: {'identifier': stack.identifier},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final partial = PartialAppStack.withEncryptedApps(
|
||||||
|
name: stack.name ?? stack.identifier,
|
||||||
|
identifier: stack.identifier,
|
||||||
|
description: stack.description,
|
||||||
|
apps: stack.privateAppIds,
|
||||||
|
platform: stack.platform,
|
||||||
|
);
|
||||||
|
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
|
||||||
|
stack.createdAt,
|
||||||
|
);
|
||||||
|
await privateEvents.signAndSave(partial);
|
||||||
|
case CustomData data
|
||||||
|
when data.identifier == kSettingsIdentifier ||
|
||||||
|
data.identifier == kTrustedSignersIdentifier:
|
||||||
|
final partial = PartialCustomData(
|
||||||
|
identifier: data.identifier,
|
||||||
|
content: data.content,
|
||||||
|
);
|
||||||
|
for (final tag in data.event.tags) {
|
||||||
|
if (tag.first == 'd' || tag.first == 'nonce') continue;
|
||||||
|
partial.event.tags.add(List<String>.of(tag));
|
||||||
|
}
|
||||||
|
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
|
||||||
|
data.createdAt,
|
||||||
|
);
|
||||||
|
await privateEvents.signAndSave(
|
||||||
|
partial,
|
||||||
|
publish: data.identifier != kTrustedSignersIdentifier,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Model<dynamic>>> _queryStorage(
|
||||||
|
Request<Model<dynamic>> request, {
|
||||||
|
required Source source,
|
||||||
|
required String subscriptionPrefix,
|
||||||
|
}) {
|
||||||
|
final query = _query;
|
||||||
|
if (query != null) {
|
||||||
|
return query(request, source, subscriptionPrefix);
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
.read(storageNotifierProvider.notifier)
|
||||||
|
.query(request, source: source, subscriptionPrefix: subscriptionPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
_cancelled = true;
|
||||||
|
final request = _activeRequest;
|
||||||
|
_activeRequest = null;
|
||||||
|
if (request != null) {
|
||||||
|
unawaited(ref.read(storageNotifierProvider.notifier).cancel(request));
|
||||||
|
}
|
||||||
|
ref.read(devicePrivateEventServiceProvider).cancelMining();
|
||||||
|
if (state.phase == DevicePrivateSyncPhase.syncing) {
|
||||||
|
state = const DevicePrivateSyncState(DevicePrivateSyncPhase.cancelled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final devicePrivateSyncProvider =
|
||||||
|
StateNotifierProvider<DevicePrivateSyncNotifier, DevicePrivateSyncState>(
|
||||||
|
(ref) => DevicePrivateSyncNotifier(ref),
|
||||||
|
);
|
||||||
@@ -2,9 +2,9 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
/// Identifier used to store trusted signer preferences
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
const String kTrustedSignersIdentifier = 'trusted-signers';
|
import 'package:zapstore/services/device_private_event_service.dart';
|
||||||
|
|
||||||
/// Simple helper service to manage trusted signers persisted via CustomData
|
/// Simple helper service to manage trusted signers persisted via CustomData
|
||||||
class TrustedSignersService {
|
class TrustedSignersService {
|
||||||
@@ -12,45 +12,39 @@ class TrustedSignersService {
|
|||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
/// Returns whether a signer pubkey is trusted by the active user
|
/// Returns whether a signer pubkey is trusted by this device.
|
||||||
Future<bool> isSignerTrusted(String signerPubkey) async {
|
Future<bool> isSignerTrusted(String signerPubkey) async {
|
||||||
final trusted = await _loadTrustedSigners();
|
final trusted = await _loadTrustedSigners();
|
||||||
return trusted.contains(signerPubkey);
|
return trusted.contains(signerPubkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a signer pubkey to the trusted list for the active user.
|
/// Adds a signer pubkey to the device-private trusted list.
|
||||||
/// If no signer is available (not signed in), this method is a no-op.
|
|
||||||
Future<void> addTrustedSigner(String signerPubkey) async {
|
Future<void> addTrustedSigner(String signerPubkey) async {
|
||||||
final signer = ref.read(Signer.activeSignerProvider);
|
|
||||||
final activePubkey = ref.read(Signer.activePubkeyProvider);
|
|
||||||
if (signer == null || activePubkey == null) return;
|
|
||||||
|
|
||||||
final trusted = await _loadTrustedSigners();
|
final trusted = await _loadTrustedSigners();
|
||||||
if (trusted.contains(signerPubkey)) return;
|
if (trusted.contains(signerPubkey)) return;
|
||||||
trusted.add(signerPubkey);
|
trusted.add(signerPubkey);
|
||||||
|
await _saveTrustedSigners(trusted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveTrustedSigners(Set<String> trusted) async {
|
||||||
|
final privateEvents = ref.read(devicePrivateEventServiceProvider);
|
||||||
final content = jsonEncode({'trusted': trusted.toList()});
|
final content = jsonEncode({'trusted': trusted.toList()});
|
||||||
|
final encrypted = await privateEvents.encryptToDevice(content);
|
||||||
final partial = PartialCustomData(
|
final partial = PartialCustomData(
|
||||||
identifier: kTrustedSignersIdentifier,
|
identifier: kTrustedSignersIdentifier,
|
||||||
content: content,
|
content: encrypted,
|
||||||
);
|
);
|
||||||
|
await privateEvents.signAndSave(partial, publish: false);
|
||||||
// Sign with the active signer so the data is addressable under the user's pubkey
|
|
||||||
final model = await partial.signWith(signer);
|
|
||||||
|
|
||||||
// Save locally only (do not publish)
|
|
||||||
await model.save();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Set<String>> _loadTrustedSigners() async {
|
Future<Set<String>> _loadTrustedSigners() async {
|
||||||
final activePubkey = ref.read(Signer.activePubkeyProvider);
|
final devicePubkey = ref.read(devicePubkeyProvider);
|
||||||
if (activePubkey == null) return <String>{};
|
if (devicePubkey == null) return <String>{};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final request = Request<CustomData>([
|
final request = Request<CustomData>([
|
||||||
RequestFilter<CustomData>(
|
RequestFilter<CustomData>(
|
||||||
authors: {activePubkey},
|
authors: {devicePubkey},
|
||||||
tags: {
|
tags: {
|
||||||
'#d': {kTrustedSignersIdentifier},
|
'#d': {kTrustedSignersIdentifier},
|
||||||
},
|
},
|
||||||
@@ -66,7 +60,10 @@ class TrustedSignersService {
|
|||||||
|
|
||||||
if (models.isEmpty) return <String>{};
|
if (models.isEmpty) return <String>{};
|
||||||
final model = models.first;
|
final model = models.first;
|
||||||
final map = jsonDecode(model.content) as Map<String, dynamic>;
|
final decrypted = await ref
|
||||||
|
.read(devicePrivateEventServiceProvider)
|
||||||
|
.decryptFromDevice(model.content);
|
||||||
|
final map = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||||
final list =
|
final list =
|
||||||
(map['trusted'] as List?)?.cast<String>() ?? const <String>[];
|
(map['trusted'] as List?)?.cast<String>() ?? const <String>[];
|
||||||
return list.toSet();
|
return list.toSet();
|
||||||
@@ -74,6 +71,33 @@ class TrustedSignersService {
|
|||||||
return <String>{};
|
return <String>{};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Imports the legacy local Amber-authored preference without publishing it.
|
||||||
|
Future<void> migrateLegacyAmberRecord(Signer amberSigner) async {
|
||||||
|
final storage = ref.read(storageNotifierProvider.notifier);
|
||||||
|
final legacy = await storage.query(
|
||||||
|
RequestFilter<CustomData>(
|
||||||
|
authors: {amberSigner.pubkey},
|
||||||
|
tags: {
|
||||||
|
'#d': {kTrustedSignersIdentifier},
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
).toRequest(),
|
||||||
|
source: const LocalSource(),
|
||||||
|
);
|
||||||
|
if (legacy.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(legacy.first.content) as Map<String, dynamic>;
|
||||||
|
final oldTrusted =
|
||||||
|
(map['trusted'] as List?)?.whereType<String>().toSet() ?? const {};
|
||||||
|
if (oldTrusted.isEmpty) return;
|
||||||
|
final merged = {...await _loadTrustedSigners(), ...oldTrusted};
|
||||||
|
await _saveTrustedSigners(merged);
|
||||||
|
} catch (_) {
|
||||||
|
// Malformed legacy local preferences are ignored.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final trustServiceProvider = Provider<TrustedSignersService>(
|
final trustServiceProvider = Provider<TrustedSignersService>(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/constants/app_constants.dart';
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
|
import 'package:zapstore/services/device_private_event_service.dart';
|
||||||
import 'package:zapstore/services/device_key_service.dart';
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
import 'package:zapstore/services/log_service.dart';
|
import 'package:zapstore/services/log_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
@@ -32,14 +33,15 @@ final _persistedUnmanagedAppsProvider = Provider<_UnmanagedAppsSnapshot?>((
|
|||||||
tags: {
|
tags: {
|
||||||
'#d': {kUnmanagedAppsIdentifier},
|
'#d': {kUnmanagedAppsIdentifier},
|
||||||
},
|
},
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
source: const LocalSource(),
|
||||||
subscriptionPrefix: 'app-unmanaged-apps',
|
subscriptionPrefix: 'app-unmanaged-apps',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final stack = switch (stackState) {
|
final stack = switch (stackState) {
|
||||||
StorageLoading() || StorageError() => null,
|
StorageLoading(:final models) ||
|
||||||
StorageData(:final models) => models.firstOrNull,
|
StorageData(:final models) => models.firstOrNull,
|
||||||
|
StorageError() => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (stack == null) {
|
if (stack == null) {
|
||||||
@@ -196,8 +198,9 @@ class UnmanagedAppsPublishException implements Exception {
|
|||||||
String toString() => message;
|
String toString() => message;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool wasUnmanagedStackAccepted(PublishResponse response, String eventId) {
|
bool wasUnmanagedStackAccepted(PublishResponse response, AppStack stack) {
|
||||||
return response.results[eventId]?.any((result) => result.accepted) ?? false;
|
return response.results[stack.event.id]?.any((result) => result.accepted) ??
|
||||||
|
false;
|
||||||
}
|
}
|
||||||
|
|
||||||
PartialAppStack createUnmanagedAppsStack({
|
PartialAppStack createUnmanagedAppsStack({
|
||||||
@@ -220,17 +223,6 @@ Future<void> _writeUnmanagedApps(
|
|||||||
Set<String> appIds, {
|
Set<String> appIds, {
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
}) async {
|
}) async {
|
||||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
|
||||||
if (devicePubkey == null) {
|
|
||||||
throw const UnmanagedAppsSaveException('Device key is unavailable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final signer = ref.read(Signer.signerProvider(devicePubkey));
|
|
||||||
if (signer == null) {
|
|
||||||
throw const UnmanagedAppsSaveException('Device signer is unavailable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final storage = ref.read(storageNotifierProvider.notifier);
|
|
||||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
final platform = ref.read(packageManagerProvider.notifier).platform;
|
||||||
final partial = createUnmanagedAppsStack(
|
final partial = createUnmanagedAppsStack(
|
||||||
appIds: appIds,
|
appIds: appIds,
|
||||||
@@ -238,19 +230,12 @@ Future<void> _writeUnmanagedApps(
|
|||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
final signed = await partial.signWith(signer);
|
try {
|
||||||
final saved = await storage.save({signed});
|
await ref.read(devicePrivateEventServiceProvider).signAndSave(partial);
|
||||||
if (!saved) {
|
} on DevicePrivateSaveException catch (error) {
|
||||||
throw const UnmanagedAppsSaveException(
|
throw UnmanagedAppsSaveException(error.message);
|
||||||
'Could not save the unmanaged apps list locally.',
|
} on DevicePrivatePublishException catch (error) {
|
||||||
);
|
throw UnmanagedAppsPublishException(error.message);
|
||||||
}
|
|
||||||
|
|
||||||
final response = await storage.publish({signed}, relays: 'AppCatalog');
|
|
||||||
if (!wasUnmanagedStackAccepted(response, signed.id)) {
|
|
||||||
throw const UnmanagedAppsPublishException(
|
|
||||||
'Saved locally, but no AppCatalog relay accepted the event.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:zapstore/services/catalog_fetcher.dart';
|
|||||||
import 'package:zapstore/services/log_service.dart';
|
import 'package:zapstore/services/log_service.dart';
|
||||||
import 'package:zapstore/services/deletion_processor.dart';
|
import 'package:zapstore/services/deletion_processor.dart';
|
||||||
import 'package:zapstore/services/device_key_service.dart';
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
|
import 'package:zapstore/services/device_private_event_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/services/unmanaged_apps_service.dart';
|
import 'package:zapstore/services/unmanaged_apps_service.dart';
|
||||||
import 'package:zapstore/services/settings_service.dart';
|
import 'package:zapstore/services/settings_service.dart';
|
||||||
@@ -289,9 +290,6 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
|||||||
final settings = await ref.read(settingsServiceProvider).load();
|
final settings = await ref.read(settingsServiceProvider).load();
|
||||||
if (!settings.installedAppsBackupEnabled) return;
|
if (!settings.installedAppsBackupEnabled) return;
|
||||||
|
|
||||||
final signer = ref.read(Signer.signerProvider(devicePubkey));
|
|
||||||
if (signer == null) return;
|
|
||||||
|
|
||||||
final pmNotifier = ref.read(packageManagerProvider.notifier);
|
final pmNotifier = ref.read(packageManagerProvider.notifier);
|
||||||
final installed = ref.read(packageManagerProvider).installed;
|
final installed = ref.read(packageManagerProvider).installed;
|
||||||
final platform = pmNotifier.platform;
|
final platform = pmNotifier.platform;
|
||||||
@@ -324,9 +322,9 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
|||||||
platform: platform,
|
platform: platform,
|
||||||
);
|
);
|
||||||
|
|
||||||
final signed = await partialStack.signWith(signer);
|
await ref
|
||||||
await storage.save({signed});
|
.read(devicePrivateEventServiceProvider)
|
||||||
await storage.publish({signed}, relays: 'AppCatalog');
|
.signAndSave(partialStack);
|
||||||
_lastBackedUpIds = appIds;
|
_lastBackedUpIds = appIds;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
LogService.I.warn(
|
LogService.I.warn(
|
||||||
@@ -387,9 +385,11 @@ final categorizedUpdatesProvider = Provider<CategorizedUpdates>((ref) {
|
|||||||
final unmanagedIds = ref.watch(unmanagedAppsProvider);
|
final unmanagedIds = ref.watch(unmanagedAppsProvider);
|
||||||
|
|
||||||
final catalogedIds = pollerState.catalogedIds;
|
final catalogedIds = pollerState.catalogedIds;
|
||||||
|
// Keep the local App query independent of the unmanaged set. Otherwise a
|
||||||
|
// toggle changes its filter, briefly replaces the cached result with a
|
||||||
|
// loading query, and makes the updates list visibly jump.
|
||||||
final catalogedInstalledIds = installed.keys
|
final catalogedInstalledIds = installed.keys
|
||||||
.where(catalogedIds.contains)
|
.where(catalogedIds.contains)
|
||||||
.where((id) => !unmanagedIds.contains(id))
|
|
||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
if (catalogedInstalledIds.isEmpty) {
|
if (catalogedInstalledIds.isEmpty) {
|
||||||
@@ -457,7 +457,7 @@ final categorizedUpdatesProvider = Provider<CategorizedUpdates>((ref) {
|
|||||||
|
|
||||||
for (final app in apps) {
|
for (final app in apps) {
|
||||||
final pkg = installed[app.identifier];
|
final pkg = installed[app.identifier];
|
||||||
if (pkg == null) continue;
|
if (pkg == null || unmanagedIds.contains(app.identifier)) continue;
|
||||||
|
|
||||||
if (app.hasUpdate) {
|
if (app.hasUpdate) {
|
||||||
if (pkg.canInstallSilently) {
|
if (pkg.canInstallSilently) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:zapstore/services/bookmarks_service.dart';
|
import 'package:zapstore/services/bookmarks_service.dart';
|
||||||
import 'package:zapstore/services/device_key_service.dart';
|
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/services/notification_service.dart';
|
import 'package:zapstore/services/notification_service.dart';
|
||||||
import 'package:zapstore/theme.dart';
|
import 'package:zapstore/theme.dart';
|
||||||
@@ -117,12 +116,8 @@ class SocialActionsRow extends HookConsumerWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
flex: 16,
|
flex: 16,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: () => _handleSaveApp(
|
onPressed: () =>
|
||||||
context,
|
_handleSaveApp(context, ref, app, isPrivatelySaved),
|
||||||
ref,
|
|
||||||
app,
|
|
||||||
isPrivatelySaved,
|
|
||||||
),
|
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
backgroundColor: isPrivatelySaved
|
backgroundColor: isPrivatelySaved
|
||||||
@@ -142,9 +137,7 @@ class SocialActionsRow extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
isPrivatelySaved
|
isPrivatelySaved ? Icons.bookmark : Icons.bookmark_border,
|
||||||
? Icons.bookmark
|
|
||||||
: Icons.bookmark_border,
|
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -178,6 +171,7 @@ class SocialActionsRow extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleSaveApp(
|
Future<void> _handleSaveApp(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
@@ -185,53 +179,11 @@ class SocialActionsRow extends HookConsumerWidget {
|
|||||||
bool isPrivatelySaved,
|
bool isPrivatelySaved,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
final added = await toggleBookmark(ref, app);
|
||||||
if (devicePubkey == null) return;
|
|
||||||
|
|
||||||
final signer = ref.read(Signer.signerProvider(devicePubkey));
|
|
||||||
if (signer == null) return;
|
|
||||||
|
|
||||||
final existingStacks = await ref.storage.query(
|
|
||||||
RequestFilter<AppStack>(
|
|
||||||
authors: {devicePubkey},
|
|
||||||
tags: {
|
|
||||||
'#d': {kAppBookmarksIdentifier},
|
|
||||||
},
|
|
||||||
).toRequest(),
|
|
||||||
source: const LocalSource(),
|
|
||||||
);
|
|
||||||
final existingStack = existingStacks.firstOrNull;
|
|
||||||
|
|
||||||
final existingAppIds = List<String>.from(
|
|
||||||
existingStack?.privateAppIds ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
final appAddressableId =
|
|
||||||
'${app.event.kind}:${app.pubkey}:${app.identifier}';
|
|
||||||
|
|
||||||
if (isPrivatelySaved) {
|
|
||||||
existingAppIds.remove(appAddressableId);
|
|
||||||
} else {
|
|
||||||
if (!existingAppIds.contains(appAddressableId)) {
|
|
||||||
existingAppIds.add(appAddressableId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
|
||||||
final partialStack = PartialAppStack.withEncryptedApps(
|
|
||||||
name: 'Saved Apps',
|
|
||||||
identifier: kAppBookmarksIdentifier,
|
|
||||||
apps: existingAppIds,
|
|
||||||
platform: platform,
|
|
||||||
);
|
|
||||||
|
|
||||||
final signedStack = await partialStack.signWith(signer);
|
|
||||||
await ref.storage.save({signedStack});
|
|
||||||
ref.storage.publish({signedStack}, relays: 'AppCatalog');
|
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.showInfo(
|
context.showInfo(
|
||||||
isPrivatelySaved ? 'App removed from saved' : 'App saved privately',
|
added ? 'App saved privately' : 'App removed from saved',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -241,8 +193,6 @@ class SocialActionsRow extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Future<void> _showAddToStackDialog(BuildContext context, App app) async {
|
Future<void> _showAddToStackDialog(BuildContext context, App app) async {
|
||||||
await showBaseDialog(
|
await showBaseDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:zapstore/constants/app_constants.dart';
|
|||||||
import 'package:zapstore/main.dart';
|
import 'package:zapstore/main.dart';
|
||||||
import 'package:zapstore/services/notification_service.dart';
|
import 'package:zapstore/services/notification_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/debug_utils.dart';
|
|
||||||
|
|
||||||
/// Reusable sign-in prompt widget for dialogs and bottom sheets.
|
/// Reusable sign-in prompt widget for dialogs and bottom sheets.
|
||||||
/// Displays a styled tappable message prompting the user to sign in.
|
/// Displays a styled tappable message prompting the user to sign in.
|
||||||
@@ -40,7 +39,6 @@ class SignInPrompt extends HookConsumerWidget {
|
|||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await ref.read(amberSignerProvider).signIn();
|
await ref.read(amberSignerProvider).signIn();
|
||||||
onSignInSuccess(ref.read(refProvider));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.showError(
|
context.showError(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/services/device_key_service.dart';
|
import 'package:zapstore/services/bookmarks_service.dart';
|
||||||
import 'package:zapstore/services/notification_service.dart';
|
import 'package:zapstore/services/notification_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';
|
||||||
@@ -83,54 +83,12 @@ class SaveAppDialog extends HookConsumerWidget {
|
|||||||
bool isCurrentlySaved,
|
bool isCurrentlySaved,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
final added = await toggleBookmark(ref, app);
|
||||||
if (devicePubkey == null) return;
|
|
||||||
|
|
||||||
final signer = ref.read(Signer.signerProvider(devicePubkey));
|
|
||||||
if (signer == null) return;
|
|
||||||
|
|
||||||
final existingStacks = await ref.storage.query(
|
|
||||||
RequestFilter<AppStack>(
|
|
||||||
authors: {devicePubkey},
|
|
||||||
tags: {
|
|
||||||
'#d': {kAppBookmarksIdentifier},
|
|
||||||
},
|
|
||||||
).toRequest(),
|
|
||||||
source: const LocalSource(),
|
|
||||||
);
|
|
||||||
final existingStack = existingStacks.firstOrNull;
|
|
||||||
|
|
||||||
final existingAppIds = List<String>.from(
|
|
||||||
existingStack?.privateAppIds ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
final appAddressableId =
|
|
||||||
'${app.event.kind}:${app.pubkey}:${app.identifier}';
|
|
||||||
|
|
||||||
if (isCurrentlySaved) {
|
|
||||||
existingAppIds.remove(appAddressableId);
|
|
||||||
} else {
|
|
||||||
if (!existingAppIds.contains(appAddressableId)) {
|
|
||||||
existingAppIds.add(appAddressableId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
|
||||||
final partialStack = PartialAppStack.withEncryptedApps(
|
|
||||||
name: 'Saved Apps',
|
|
||||||
identifier: kAppBookmarksIdentifier,
|
|
||||||
apps: existingAppIds,
|
|
||||||
platform: platform,
|
|
||||||
);
|
|
||||||
|
|
||||||
final signedStack = await partialStack.signWith(signer);
|
|
||||||
await ref.storage.save({signedStack});
|
|
||||||
ref.storage.publish({signedStack}, relays: 'AppCatalog');
|
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
context.showInfo(
|
context.showInfo(
|
||||||
isCurrentlySaved ? 'App removed from saved' : 'App saved privately',
|
added ? 'App saved privately' : 'App removed from saved',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
@@ -9,12 +6,8 @@ import 'package:zapstore/utils/nostr_route.dart';
|
|||||||
|
|
||||||
/// Shared stack link card used in profile and user screens.
|
/// Shared stack link card used in profile and user screens.
|
||||||
/// Shows stack name, app count badge, and padlock icon for private stacks.
|
/// Shows stack name, app count badge, and padlock icon for private stacks.
|
||||||
class StackLinkCard extends HookConsumerWidget {
|
class StackLinkCard extends ConsumerWidget {
|
||||||
const StackLinkCard({
|
const StackLinkCard({super.key, required this.stack, this.displayName});
|
||||||
super.key,
|
|
||||||
required this.stack,
|
|
||||||
this.displayName,
|
|
||||||
});
|
|
||||||
|
|
||||||
final AppStack stack;
|
final AppStack stack;
|
||||||
final String? displayName;
|
final String? displayName;
|
||||||
@@ -23,35 +16,12 @@ class StackLinkCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final appCount = useState<int?>(null);
|
final appCount = _isEncrypted
|
||||||
|
? (stack.isDecrypted ? stack.privateAppIds.length : null)
|
||||||
useEffect(() {
|
: stack.event
|
||||||
if (_isEncrypted) {
|
|
||||||
Future<void> decrypt() async {
|
|
||||||
final signer = ref.read(Signer.activeSignerProvider);
|
|
||||||
final pubkey = ref.read(Signer.activePubkeyProvider);
|
|
||||||
if (signer == null || pubkey == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final decrypted = await signer.nip44Decrypt(stack.content, pubkey);
|
|
||||||
final ids = (jsonDecode(decrypted) as List).cast<String>();
|
|
||||||
appCount.value = ids.length;
|
|
||||||
} catch (_) {
|
|
||||||
// Decryption failed, leave count as null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decrypt();
|
|
||||||
} else {
|
|
||||||
// Public stack: count 'a' tags
|
|
||||||
final count = stack.event
|
|
||||||
.getTagSetValues('a')
|
.getTagSetValues('a')
|
||||||
.where((id) => id.startsWith('32267:'))
|
.where((id) => id.startsWith('32267:'))
|
||||||
.length;
|
.length;
|
||||||
appCount.value = count;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [stack.content, stack.id]);
|
|
||||||
|
|
||||||
final title = displayName ?? stack.name ?? stack.identifier;
|
final title = displayName ?? stack.name ?? stack.identifier;
|
||||||
|
|
||||||
@@ -72,7 +42,9 @@ class StackLinkCard extends HookConsumerWidget {
|
|||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.outline.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -98,18 +70,25 @@ class StackLinkCard extends HookConsumerWidget {
|
|||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (appCount.value != null && appCount.value! > 0) ...[
|
if (appCount != null && appCount > 0) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${appCount.value}',
|
'$appCount',
|
||||||
style: context.textTheme.labelSmall?.copyWith(
|
style: context.textTheme.labelSmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -182,53 +182,10 @@ class FloatingOverflowMenu extends HookConsumerWidget {
|
|||||||
if (a == null) return;
|
if (a == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
final added = await toggleBookmark(ref, a);
|
||||||
if (devicePubkey == null) return;
|
|
||||||
|
|
||||||
final signer = ref.read(Signer.signerProvider(devicePubkey));
|
|
||||||
if (signer == null) return;
|
|
||||||
|
|
||||||
final existingStacks = await ref.storage.query(
|
|
||||||
RequestFilter<AppStack>(
|
|
||||||
authors: {devicePubkey},
|
|
||||||
tags: {
|
|
||||||
'#d': {kAppBookmarksIdentifier},
|
|
||||||
},
|
|
||||||
).toRequest(),
|
|
||||||
source: const LocalSource(),
|
|
||||||
);
|
|
||||||
final existingStack = existingStacks.firstOrNull;
|
|
||||||
|
|
||||||
final existingAppIds = List<String>.from(
|
|
||||||
existingStack?.privateAppIds ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
final appAddressableId = '${a.event.kind}:${a.pubkey}:${a.identifier}';
|
|
||||||
|
|
||||||
if (isCurrentlySaved) {
|
|
||||||
existingAppIds.remove(appAddressableId);
|
|
||||||
} else {
|
|
||||||
if (!existingAppIds.contains(appAddressableId)) {
|
|
||||||
existingAppIds.add(appAddressableId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
|
||||||
final partialStack = PartialAppStack.withEncryptedApps(
|
|
||||||
name: 'Saved Apps',
|
|
||||||
identifier: kAppBookmarksIdentifier,
|
|
||||||
apps: existingAppIds,
|
|
||||||
platform: platform,
|
|
||||||
);
|
|
||||||
|
|
||||||
final signedStack = await partialStack.signWith(signer);
|
|
||||||
await ref.storage.save({signedStack});
|
|
||||||
ref.storage.publish({signedStack}, relays: 'AppCatalog');
|
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.showInfo(
|
context.showInfo(added ? 'App saved' : 'App removed from saved');
|
||||||
isCurrentlySaved ? 'App removed from saved' : 'App saved',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:zapstore/constants/app_constants.dart';
|
|||||||
import 'package:zapstore/main.dart';
|
import 'package:zapstore/main.dart';
|
||||||
import 'package:zapstore/services/notification_service.dart';
|
import 'package:zapstore/services/notification_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/debug_utils.dart';
|
|
||||||
|
|
||||||
class SignInButton extends ConsumerWidget {
|
class SignInButton extends ConsumerWidget {
|
||||||
const SignInButton({
|
const SignInButton({
|
||||||
@@ -40,7 +39,6 @@ class SignInButton extends ConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await ref.read(amberSignerProvider).signIn();
|
await ref.read(amberSignerProvider).signIn();
|
||||||
onSignInSuccess(ref.read(refProvider));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.showError('Sign-in failed', technicalDetails: '$e');
|
context.showError('Sign-in failed', technicalDetails: '$e');
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.7.3'
|
classpath 'com.android.tools.build:gradle:8.9.1'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -94,10 +94,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: background_downloader
|
name: background_downloader
|
||||||
sha256: "2d17c211eb9bf1f947f154fb85d1bea96944fb1efb1b0714851caac993c02dc8"
|
sha256: d27fcaf94f738032f1e6db098230799bf31ae7bf184f173fc9a752fcdae75c0e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.4.3"
|
version: "9.5.6"
|
||||||
bech32:
|
bech32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ dependencies:
|
|||||||
collection: ^1.19.1
|
collection: ^1.19.1
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
share_plus: ^12.0.0
|
share_plus: ^12.0.0
|
||||||
background_downloader: ^9.2.6
|
background_downloader: ^9.5.2
|
||||||
connectivity_plus: ^7.0.0
|
connectivity_plus: ^7.0.0
|
||||||
app_links: ^6.4.1
|
app_links: ^6.4.1
|
||||||
flutter_secure_storage: ^9.2.4 # DO NOT UPDATE TO BROKEN ^10.0.0
|
flutter_secure_storage: ^9.2.4 # DO NOT UPDATE TO BROKEN ^10.0.0
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
|
|
||||||
Decouple private data (bookmarks, unmanaged apps, installed backup, settings) from
|
Decouple private data (bookmarks, unmanaged apps, installed backup, settings) from
|
||||||
Amber sign-in by generating a local device key (nsec) that owns all private
|
Amber sign-in by generating a local device key (nsec) that owns all private
|
||||||
encrypted events. Amber becomes purely the identity layer for public actions
|
encrypted events. Every private kind 30267 and 30078 event is signed by the
|
||||||
(sharing stacks, zaps, web of trust).
|
device key and carries NIP-13 proof of work. Amber is only the identity and
|
||||||
|
recovery layer for public actions and encrypted device-key backup capsules.
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
- Multi-device sync without Amber (backup/restore requires one Amber sign-in)
|
- Continuous or multi-device live sync
|
||||||
- Migrating existing Amber-signed private stacks (users start fresh or restore)
|
- Migrating legacy Amber-signed `zapstore-settings` events
|
||||||
- Changing how public stacks work (still Amber-signed with h tag)
|
- Changing how public stacks work (still Amber-signed with h tag)
|
||||||
- Implementing the publish queue (handled in purplebase)
|
- Implementing the publish queue (handled in purplebase)
|
||||||
|
|
||||||
@@ -18,11 +19,15 @@ encrypted events. Amber becomes purely the identity layer for public actions
|
|||||||
|
|
||||||
- On first launch, a device key is silently generated and stored in secure storage
|
- On first launch, a device key is silently generated and stored in secure storage
|
||||||
- Bookmarks, unmanaged apps, and settings work immediately without sign-in
|
- Bookmarks, unmanaged apps, and settings work immediately without sign-in
|
||||||
|
- Private data renders from SQLite; one non-streaming relay sync runs at app boot
|
||||||
- Profile screen shows device key section with ability to copy nsec
|
- Profile screen shows device key section with ability to copy nsec
|
||||||
- On first Amber sign-in, a dialog offers to:
|
- On every Amber sign-in, the app performs one-shot recovery and legacy queries:
|
||||||
- Back up this device (stores device nsec inside encrypted `zapstore-settings`)
|
- Restore is offered when device-signed settings events contain a recovery
|
||||||
- Restore from another device (if settings backups exist for this Amber key)
|
capsule for the Amber key
|
||||||
|
- The final device key is backed up in its device-signed settings event
|
||||||
|
- Amber-authored encrypted AppStacks are merged into device-owned stacks
|
||||||
- Clearing app data (SQLite) does NOT delete device key or NWC string
|
- Clearing app data (SQLite) does NOT delete device key or NWC string
|
||||||
|
- Background sync, migration, and proof-of-work jobs are bounded and cancellable
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
@@ -30,22 +35,35 @@ encrypted events. Amber becomes purely the identity layer for public actions
|
|||||||
- NWC string: secure storage only (existing key)
|
- NWC string: secure storage only (existing key)
|
||||||
- Bookmarks: encrypted AppStack (30267), d=zapstore-bookmarks, signed by device key
|
- Bookmarks: encrypted AppStack (30267), d=zapstore-bookmarks, signed by device key
|
||||||
- Unmanaged apps: encrypted AppStack (30267), d=zapstore-unmanaged-apps, signed by device key
|
- Unmanaged apps: encrypted AppStack (30267), d=zapstore-unmanaged-apps, signed by device key
|
||||||
- Installed backup: encrypted AppStack (30267), d=zapstore-installed-backup, signed by device key
|
- Installed backup: encrypted AppStack (30267), d=zapstore-installed-apps, signed by device key
|
||||||
- App settings: encrypted CustomData (30078), d=zapstore-settings
|
- App settings: versioned private CustomData (30078), d=zapstore-settings,
|
||||||
- Device backup: entries inside encrypted `zapstore-settings`, signed by Amber key
|
signed by the device key
|
||||||
|
- Device backup: NIP-44 recovery capsules encrypted from the device key to Amber
|
||||||
|
identities inside `zapstore-settings`; matching `p` tags allow discovery.
|
||||||
|
Each capsule includes an Amber-signed authorization binding that identity to
|
||||||
|
the device pubkey, preventing third-party recovery-candidate injection.
|
||||||
|
- Trusted signers: encrypted, local-only CustomData (30078), d=trusted-signers,
|
||||||
|
signed by the device key
|
||||||
|
- Every private event commits to at least 16 bits of NIP-13 proof of work
|
||||||
|
|
||||||
## Signer Roles
|
## Signer Roles
|
||||||
|
|
||||||
- Device signer (Bip340PrivateKeySigner): always available, never null. Signs all
|
- Device signer (Bip340PrivateKeySigner): always available, never null. Signs all
|
||||||
private events. Registered on boot, NOT set as active.
|
private events. Registered on boot, NOT set as active.
|
||||||
- Amber signer (AmberSigner): optional. When present, is the active signer. Used
|
- Amber signer (AmberSigner): optional. When present, is the active signer. Used
|
||||||
for public stacks, zaps, WoT queries.
|
for public stacks, zaps, WoT queries, and recovery-capsule encryption/decryption.
|
||||||
|
- NIP-13 mining runs in a Purplebase-owned worker isolate after encryption and
|
||||||
|
before signing; no mining loop runs on Flutter's main isolate.
|
||||||
|
|
||||||
## Filtering Strategy
|
## Filtering Strategy
|
||||||
|
|
||||||
- Public stacks: filtered by #h tag (community pubkey) naturally excludes device stacks
|
- Public stacks: filtered by #h tag (community pubkey) naturally excludes device stacks
|
||||||
- Device private stacks: queried by authors: {devicePubkey} + specific #d tag
|
- Device private stacks: queried by authors: {devicePubkey} + specific #d tag
|
||||||
- appStackEventFilter schema filter removed; #h tag filtering is sufficient
|
- appStackEventFilter schema filter removed; #h tag filtering is sufficient
|
||||||
|
- Device private 30267/30078 events are fetched once with stream=false at boot.
|
||||||
|
Private UI consumers use LocalSource only.
|
||||||
|
- Amber sign-in recovery and migration are explicit one-shot exceptions; they
|
||||||
|
never open streaming subscriptions.
|
||||||
|
|
||||||
## Edge Cases
|
## Edge Cases
|
||||||
|
|
||||||
@@ -54,6 +72,11 @@ encrypted events. Amber becomes purely the identity layer for public actions
|
|||||||
- Restore on device that already has data: ask user to confirm (replace or keep current)
|
- Restore on device that already has data: ask user to confirm (replace or keep current)
|
||||||
- Offline: events save locally, purplebase publish queue syncs when online
|
- Offline: events save locally, purplebase publish queue syncs when online
|
||||||
- Multiple devices with same Amber key: each has own device key; backup stores device name
|
- Multiple devices with same Amber key: each has own device key; backup stores device name
|
||||||
|
- Existing device-authored events without PoW are re-signed in the background;
|
||||||
|
invalid or undecryptable events are never overwritten.
|
||||||
|
- Legacy Amber-authored settings are ignored even if that loses old backups.
|
||||||
|
- Migration is idempotent and rechecks on every Amber sign-in; failed decrypts
|
||||||
|
remain retryable.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
@@ -63,8 +86,14 @@ encrypted events. Amber becomes purely the identity layer for public actions
|
|||||||
- [ ] Unmanaged apps work without Amber sign-in
|
- [ ] Unmanaged apps work without Amber sign-in
|
||||||
- [ ] User can copy device nsec from profile screen
|
- [ ] User can copy device nsec from profile screen
|
||||||
- [ ] First Amber sign-in triggers backup/restore dialog
|
- [ ] First Amber sign-in triggers backup/restore dialog
|
||||||
- [ ] Backup encrypts device nsec inside `zapstore-settings` to Amber key
|
- [ ] Every Amber sign-in upserts a recovery capsule in device-signed settings
|
||||||
- [ ] Restore decrypts `zapstore-settings` and imports device nsec
|
- [ ] Restore discovers device-signed settings by Amber `p` tag and imports nsec
|
||||||
|
- [ ] Legacy Amber-signed settings are ignored
|
||||||
|
- [ ] Amber private bookmarks, installed backups, unmanaged apps, and other
|
||||||
|
encrypted AppStacks migrate safely to the final device key
|
||||||
|
- [ ] All new private 30267/30078 events have valid 16-bit NIP-13 proof of work
|
||||||
|
- [ ] PoW mining does not run on Flutter's main isolate
|
||||||
|
- [ ] Private relay reads happen only at boot and explicit Amber sign-in recovery
|
||||||
- [ ] appStackEventFilter removed; queries use #h tag filtering
|
- [ ] appStackEventFilter removed; queries use #h tag filtering
|
||||||
- [ ] EncryptableModel auto-decrypts device-key stacks (no manual decrypt calls)
|
- [ ] EncryptableModel auto-decrypts device-key stacks (no manual decrypt calls)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ cross-device sync.
|
|||||||
- [x] Include the platform tag and require explicit relay acceptance
|
- [x] Include the platform tag and require explicit relay acceptance
|
||||||
- [x] Surface local-save and relay-publish failures to the user
|
- [x] Surface local-save and relay-publish failures to the user
|
||||||
- [x] Cover accumulation, overlapping writes, timestamps, tags, and failures
|
- [x] Cover accumulation, overlapping writes, timestamps, tags, and failures
|
||||||
|
- [x] Load the decrypted local stack before the background AppCatalog refresh completes
|
||||||
|
- [x] Use a non-streaming background refresh for the unmanaged-apps query
|
||||||
- [ ] Preserve catalog metadata for cataloged unmanaged apps in the Unmanaged Apps section
|
- [ ] Preserve catalog metadata for cataloged unmanaged apps in the Unmanaged Apps section
|
||||||
- [ ] Extend native package scan with Android installer-source metadata
|
- [ ] Extend native package scan with Android installer-source metadata
|
||||||
- [ ] Default apps installed by known third-party app stores to unmanaged
|
- [ ] Default apps installed by known third-party app stores to unmanaged
|
||||||
@@ -57,6 +59,9 @@ cross-device sync.
|
|||||||
map when Android reports no changes.
|
map when Android reports no changes.
|
||||||
- Android package enumeration runs on a lifecycle-owned worker so package,
|
- Android package enumeration runs on a lifecycle-owned worker so package,
|
||||||
signature, and installer-source reads cannot block rendering.
|
signature, and installer-source reads cannot block rendering.
|
||||||
|
- The unmanaged-apps query uses `LocalAndRemoteSource(stream: false)`: local
|
||||||
|
SQLite state is emitted immediately, while the one-shot AppCatalog query
|
||||||
|
refreshes it in the background without leaving a live subscription.
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
- Android can expose source through `PackageManager.getInstallSourceInfo(packageName)` on API 30+ and `getInstallerPackageName(packageName)` on older APIs.
|
- Android can expose source through `PackageManager.getInstallSourceInfo(packageName)` on API 30+ and `getInstallerPackageName(packageName)` on older APIs.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# WORK-011 - Device Key (Phase A + B + D)
|
# WORK-011 - Device Key (Phase A + B + D)
|
||||||
|
|
||||||
**Feature:** FEAT-006-device-key.md
|
**Feature:** FEAT-006-device-key.md
|
||||||
**Status:** In Progress
|
**Status:** Complete
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
@@ -38,13 +38,44 @@
|
|||||||
- Queries Amber-authored encrypted AppStacks after Amber connection
|
- Queries Amber-authored encrypted AppStacks after Amber connection
|
||||||
- Merges them into device-authored encrypted stacks using the device signer
|
- Merges them into device-authored encrypted stacks using the device signer
|
||||||
- Normalizes legacy installed/unmanaged d-tags to current identifiers
|
- Normalizes legacy installed/unmanaged d-tags to current identifiers
|
||||||
- Marks migration complete per Amber pubkey + device pubkey; empty results retry
|
- Rechecks idempotently on every sign-in; failed decrypts remain retryable
|
||||||
- [x] 9. Store device key backups in encrypted settings
|
- [x] 9. Store device key backups in encrypted settings
|
||||||
- Files: `lib/services/device_backup_service.dart`
|
- Files: `lib/services/device_backup_service.dart`
|
||||||
- Uses Amber-signed `CustomData` with `d=zapstore-settings`
|
- Uses device-signed `CustomData` with `d=zapstore-settings`
|
||||||
- Encrypts the full JSON settings object to the Amber key before signing
|
- Stores NIP-44 Amber recovery capsules authorized by Amber signatures
|
||||||
- Stores device backup entries under `deviceBackups`
|
- Ignores legacy Amber-signed settings
|
||||||
- [ ] 10. Self-review against INVARIANTS.md
|
- [x] 10. Self-review against INVARIANTS.md
|
||||||
|
- [x] 11. Centralize private 30267/30078 signing
|
||||||
|
- Device signer only, encrypted-stack h-tag rejection, 16-bit NIP-13
|
||||||
|
- Purplebase worker executor keeps mining off Flutter's main isolate
|
||||||
|
- [x] 12. Add boot-only private event synchronization
|
||||||
|
- One cancellable stream=false request for device-authored 30267/30078
|
||||||
|
- Private consumers use LocalSource only
|
||||||
|
- [x] 13. Replace Amber-owned settings backup
|
||||||
|
- Device-signed `zapstore-settings` with Amber recovery capsules and p tags
|
||||||
|
- Ignore all legacy Amber-signed settings
|
||||||
|
- [x] 14. Repair Amber migration
|
||||||
|
- Explicitly decrypt Amber stacks before merge
|
||||||
|
- Cover bookmarks, installed, unmanaged, and arbitrary encrypted stacks
|
||||||
|
- Recheck idempotently on every Amber sign-in
|
||||||
|
- [x] 15. Move trusted-signers CustomData to device ownership
|
||||||
|
- [x] 16. Consolidate bookmark writes and private stack reads
|
||||||
|
- [x] 17. Add lifecycle, failure, migration, and PoW tests
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
| Scenario | Expected | Status |
|
||||||
|
|----------|----------|--------|
|
||||||
|
| Device private write | Device author, valid PoW, local save | [x] |
|
||||||
|
| Invalid private policy | Unknown 30078 and encrypted h-tag rejected | [x] |
|
||||||
|
| PoW cancellation | Worker terminates and tags remain unchanged | [x] |
|
||||||
|
| Boot sync lifecycle | Single-flight and cancellable | [x] |
|
||||||
|
| Bookmark write overlap | Serialized monotonic replacements | [x] |
|
||||||
|
| Bookmark save failure | Optimistic state rolls back | [x] |
|
||||||
|
| Amber bookmark migration | Imperative result explicitly decrypts | [x] |
|
||||||
|
| Recovery injection | Amber authorization required | [x] |
|
||||||
|
| Recovery cancellation | No work restarts after cancellation | [x] |
|
||||||
|
| Full verification | App, models, and Purplebase suites pass | [x] |
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
@@ -68,13 +99,53 @@
|
|||||||
|
|
||||||
### 2026-05-07 - Device backups live inside settings
|
### 2026-05-07 - Device backups live inside settings
|
||||||
|
|
||||||
|
**Status:** Superseded by the 2026-07-13 device-settings decision below.
|
||||||
**Context:** Device key backup needs to be tied to the user's encrypted settings event, not a separate replaceable event.
|
**Context:** Device key backup needs to be tied to the user's encrypted settings event, not a separate replaceable event.
|
||||||
**Decision:** Store backup entries inside the Amber-signed `CustomData` event with `d=zapstore-settings`, under the `deviceBackups` JSON key.
|
**Decision:** Store backup entries inside the Amber-signed `CustomData` event with `d=zapstore-settings`, under the `deviceBackups` JSON key.
|
||||||
**Rationale:** Keeps backup state with settings and avoids a standalone `zapstore-device-backup` event. The entire settings JSON object is NIP-44 encrypted before signing.
|
**Rationale:** Keeps backup state with settings and avoids a standalone `zapstore-device-backup` event. The entire settings JSON object is NIP-44 encrypted before signing.
|
||||||
|
|
||||||
|
### 2026-07-13 - All private events are device-owned
|
||||||
|
|
||||||
|
**Context:** Amber ownership made private state depend on the current identity
|
||||||
|
and allowed private write paths to drift.
|
||||||
|
**Decision:** Every private kind 30267/30078 event is device-signed. Public
|
||||||
|
30267 stacks remain Amber-signed and carry the community h tag.
|
||||||
|
**Rationale:** The device key is always available and is the stable owner of
|
||||||
|
per-device state.
|
||||||
|
|
||||||
|
### 2026-07-13 - Device settings contain recovery capsules
|
||||||
|
|
||||||
|
**Context:** Amber must recover a device nsec without authoring the settings
|
||||||
|
event or owning device settings.
|
||||||
|
**Decision:** `zapstore-settings` is signed by the device key. Its versioned
|
||||||
|
envelope stores device-private data plus NIP-44 capsules addressed to Amber
|
||||||
|
keys and indexed by p tags. Capsules include an embedded Amber-signed
|
||||||
|
authorization binding the Amber identity to the device pubkey.
|
||||||
|
**Rationale:** Settings remain per-device while Amber remains an orthogonal
|
||||||
|
recovery recipient, while copied or attacker-created capsules cannot inject a
|
||||||
|
device key that Amber never authorized.
|
||||||
|
|
||||||
|
### 2026-07-13 - Boot-only relay ingestion
|
||||||
|
|
||||||
|
**Context:** Private state changes originate on the device and live
|
||||||
|
subscriptions waste relay and lifecycle resources.
|
||||||
|
**Decision:** Fetch device-authored private events once at boot with
|
||||||
|
`stream:false`; UI providers are local-only. Amber recovery/migration is the
|
||||||
|
only sign-in-time one-shot exception.
|
||||||
|
**Rationale:** Preserves local-first rendering and avoids duplicate or leaked
|
||||||
|
subscriptions.
|
||||||
|
|
||||||
|
### 2026-07-13 - 16-bit off-isolate proof of work
|
||||||
|
|
||||||
|
**Context:** Private events need NIP-13 without running CPU-bound mining on
|
||||||
|
Flutter's main isolate.
|
||||||
|
**Decision:** Apply 16-bit PoW through a cancellable Purplebase worker executor.
|
||||||
|
**Rationale:** 16 bits is a practical mobile spam cost; worker execution keeps
|
||||||
|
rendering responsive.
|
||||||
|
|
||||||
## Spec Issues
|
## Spec Issues
|
||||||
|
|
||||||
- `spec/features/FEAT-006-device-key.md` still lists migration as a non-goal and uses legacy d-tags for installed/unmanaged apps. Implementation now follows the product direction from this work session: migrate private stacks to the device pubkey on Amber connection.
|
_None_
|
||||||
|
|
||||||
## Progress Notes
|
## Progress Notes
|
||||||
|
|
||||||
@@ -83,3 +154,7 @@
|
|||||||
**2026-05-07:** Restore ordering hardened: if an Amber backup contains other device keys, the restore/keep-current choice happens before migration and backup. Migration completion is keyed by both Amber pubkey and final device pubkey, and empty migration results are left retryable.
|
**2026-05-07:** Restore ordering hardened: if an Amber backup contains other device keys, the restore/keep-current choice happens before migration and backup. Migration completion is keyed by both Amber pubkey and final device pubkey, and empty migration results are left retryable.
|
||||||
|
|
||||||
**2026-05-07:** Device key backup moved into the encrypted `zapstore-settings` CustomData event. No `zapstore-device-backup` event is written.
|
**2026-05-07:** Device key backup moved into the encrypted `zapstore-settings` CustomData event. No `zapstore-device-backup` event is written.
|
||||||
|
|
||||||
|
**2026-07-13:** FEAT-006 was updated by explicit product authorization. The new
|
||||||
|
contract supersedes the earlier Amber-signed settings decision and ignores
|
||||||
|
legacy Amber settings even when that loses old backups.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:zapstore/services/bookmarks_service.dart';
|
||||||
|
import 'package:zapstore/services/device_private_event_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('optimistically toggles and serializes replacement writes', () async {
|
||||||
|
final writes = <Set<String>>[];
|
||||||
|
final gates = <Completer<void>>[];
|
||||||
|
final notifier = BookmarksNotifier((ids, _) {
|
||||||
|
writes.add(ids);
|
||||||
|
final gate = Completer<void>();
|
||||||
|
gates.add(gate);
|
||||||
|
return gate.future;
|
||||||
|
});
|
||||||
|
addTearDown(notifier.dispose);
|
||||||
|
|
||||||
|
final first = notifier.toggle('app.one');
|
||||||
|
final second = notifier.toggle('app.two');
|
||||||
|
|
||||||
|
expect(notifier.state, {'app.one', 'app.two'});
|
||||||
|
await Future<void>.value();
|
||||||
|
expect(writes, [
|
||||||
|
{'app.one'},
|
||||||
|
]);
|
||||||
|
|
||||||
|
gates.first.complete();
|
||||||
|
await first;
|
||||||
|
await Future<void>.value();
|
||||||
|
expect(writes, [
|
||||||
|
{'app.one'},
|
||||||
|
{'app.one', 'app.two'},
|
||||||
|
]);
|
||||||
|
|
||||||
|
gates.last.complete();
|
||||||
|
await second;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns whether the app was added or removed', () async {
|
||||||
|
final notifier = BookmarksNotifier((_, _) async {});
|
||||||
|
addTearDown(notifier.dispose);
|
||||||
|
|
||||||
|
expect(await notifier.toggle('app.one'), isTrue);
|
||||||
|
expect(await notifier.toggle('app.one'), isFalse);
|
||||||
|
expect(notifier.state, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rolls back optimistic state when the local save fails', () async {
|
||||||
|
final notifier = BookmarksNotifier((_, _) {
|
||||||
|
throw const DevicePrivateSaveException('save failed');
|
||||||
|
});
|
||||||
|
addTearDown(notifier.dispose);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
notifier.toggle('app.one'),
|
||||||
|
throwsA(isA<DevicePrivateSaveException>()),
|
||||||
|
);
|
||||||
|
expect(notifier.state, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
|
import 'package:zapstore/services/device_backup_service.dart';
|
||||||
|
import 'package:zapstore/utils/debug_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
Model.register(
|
||||||
|
kind: 1,
|
||||||
|
constructor: Note.fromMap,
|
||||||
|
partialConstructor: PartialNote.fromMap,
|
||||||
|
);
|
||||||
|
Model.register(
|
||||||
|
kind: 30267,
|
||||||
|
constructor: AppStack.fromMap,
|
||||||
|
partialConstructor: PartialAppStack.fromMap,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'explicitly decrypts an imperatively loaded Amber bookmark stack',
|
||||||
|
() async {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
await container
|
||||||
|
.read(storageNotifierProvider.notifier)
|
||||||
|
.initialize(StorageConfiguration());
|
||||||
|
final amber = Bip340PrivateKeySigner(
|
||||||
|
'2' * 64,
|
||||||
|
container.read(refProvider),
|
||||||
|
);
|
||||||
|
await amber.signIn(setAsActive: false);
|
||||||
|
|
||||||
|
final signed = await PartialAppStack.withEncryptedApps(
|
||||||
|
name: 'Saved Apps',
|
||||||
|
identifier: kAppBookmarksIdentifier,
|
||||||
|
apps: const ['32267:publisher:app-one', '32267:publisher:app-two'],
|
||||||
|
).signWith(amber);
|
||||||
|
final imperativelyLoaded = AppStack.fromMap(
|
||||||
|
signed.event.toMap(),
|
||||||
|
container.read(refProvider),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imperativelyLoaded.privateAppIds, isEmpty);
|
||||||
|
expect(await decryptAmberStackAppIds(amber, imperativelyLoaded), [
|
||||||
|
'32267:publisher:app-one',
|
||||||
|
'32267:publisher:app-two',
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('requires Amber authorization for the recovered device key', () async {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
await container
|
||||||
|
.read(storageNotifierProvider.notifier)
|
||||||
|
.initialize(StorageConfiguration());
|
||||||
|
final amber = Bip340PrivateKeySigner('2' * 64, container.read(refProvider));
|
||||||
|
await amber.signIn(setAsActive: false);
|
||||||
|
final devicePubkey = Utils.derivePublicKey('3' * 64);
|
||||||
|
final attackerPubkey = Utils.derivePublicKey('4' * 64);
|
||||||
|
|
||||||
|
final partial = PartialNote('zapstore-device-key-authorization-v1');
|
||||||
|
partial.event.addTagValue('device', devicePubkey);
|
||||||
|
partial.event.addTagValue('p', amber.pubkey);
|
||||||
|
final authorization = await partial.signWith(amber);
|
||||||
|
final map = authorization.event.toMap();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
validateRecoveryAuthorization(
|
||||||
|
container.read(refProvider),
|
||||||
|
map,
|
||||||
|
amberPubkey: amber.pubkey,
|
||||||
|
devicePubkey: devicePubkey,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
validateRecoveryAuthorization(
|
||||||
|
container.read(refProvider),
|
||||||
|
map,
|
||||||
|
amberPubkey: amber.pubkey,
|
||||||
|
devicePubkey: attackerPubkey,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancellation prevents recovery work from restarting', () async {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
await container
|
||||||
|
.read(storageNotifierProvider.notifier)
|
||||||
|
.initialize(StorageConfiguration());
|
||||||
|
final amber = Bip340PrivateKeySigner('2' * 64, container.read(refProvider));
|
||||||
|
await amber.signIn(setAsActive: false);
|
||||||
|
final service = DeviceBackupService()..beginWork();
|
||||||
|
|
||||||
|
service.cancelCurrentWork(container.read(refProvider));
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
service.fetchRecoveryCandidates(
|
||||||
|
ref: container.read(refProvider),
|
||||||
|
amberSigner: amber,
|
||||||
|
),
|
||||||
|
throwsA(isA<DeviceBackupCancelled>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:models/models.dart';
|
||||||
|
import 'package:purplebase/purplebase.dart';
|
||||||
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
|
import 'package:zapstore/services/device_private_event_service.dart';
|
||||||
|
import 'package:zapstore/utils/debug_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late ProviderContainer container;
|
||||||
|
late Bip340PrivateKeySigner signer;
|
||||||
|
late IsolateProofOfWorkExecutor executor;
|
||||||
|
late DevicePrivateEventService service;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await container
|
||||||
|
.read(storageNotifierProvider.notifier)
|
||||||
|
.initialize(StorageConfiguration());
|
||||||
|
|
||||||
|
signer = Bip340PrivateKeySigner('1' * 64, container.read(refProvider));
|
||||||
|
await signer.signIn(setAsActive: false);
|
||||||
|
container.read(devicePubkeyProvider.notifier).state = signer.pubkey;
|
||||||
|
executor = IsolateProofOfWorkExecutor();
|
||||||
|
service = DevicePrivateEventService(
|
||||||
|
container.read(refProvider),
|
||||||
|
executor: executor,
|
||||||
|
difficulty: 4,
|
||||||
|
maxAttempts: 100000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
executor.dispose();
|
||||||
|
container.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('production policy requires 16 proof-of-work bits', () {
|
||||||
|
expect(kPrivateEventPowDifficulty, 16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('device-signs, mines, and saves a private CustomData event', () async {
|
||||||
|
final ciphertext = await service.encryptToDevice('{"trusted":[]}');
|
||||||
|
final signed = await service.signAndSave(
|
||||||
|
PartialCustomData(
|
||||||
|
identifier: kTrustedSignersIdentifier,
|
||||||
|
content: ciphertext,
|
||||||
|
),
|
||||||
|
publish: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(signed.pubkey, signer.pubkey);
|
||||||
|
expect(Nip13.isValid(signed.event, minimumDifficulty: 4), isTrue);
|
||||||
|
final stored = await container
|
||||||
|
.read(storageNotifierProvider.notifier)
|
||||||
|
.query(
|
||||||
|
RequestFilter<CustomData>(
|
||||||
|
authors: {signer.pubkey},
|
||||||
|
tags: {
|
||||||
|
'#d': {kTrustedSignersIdentifier},
|
||||||
|
},
|
||||||
|
).toRequest(),
|
||||||
|
source: const LocalSource(),
|
||||||
|
);
|
||||||
|
expect(stored.single.event.id, signed.event.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects encrypted stacks carrying a community tag', () async {
|
||||||
|
final partial = PartialAppStack.withEncryptedApps(
|
||||||
|
name: 'Saved Apps',
|
||||||
|
identifier: kAppBookmarksIdentifier,
|
||||||
|
apps: const ['32267:pubkey:app'],
|
||||||
|
)..event.addTagValue('h', kZapstoreCommunityPubkey);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
service.signAndSave(partial, publish: false),
|
||||||
|
throwsA(isA<DevicePrivateEventException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unknown private CustomData identifiers', () async {
|
||||||
|
await expectLater(
|
||||||
|
service.signAndSave(
|
||||||
|
PartialCustomData(identifier: 'unknown', content: 'secret'),
|
||||||
|
publish: false,
|
||||||
|
),
|
||||||
|
throwsA(isA<DevicePrivateEventException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
|
import 'package:zapstore/services/device_private_sync_service.dart';
|
||||||
|
import 'package:zapstore/utils/debug_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('boot sync is single-flight and cancellable', () async {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
await container
|
||||||
|
.read(storageNotifierProvider.notifier)
|
||||||
|
.initialize(StorageConfiguration());
|
||||||
|
container.read(devicePubkeyProvider.notifier).state = 'a' * 64;
|
||||||
|
|
||||||
|
var calls = 0;
|
||||||
|
final remote = Completer<List<Model<dynamic>>>();
|
||||||
|
final notifier = DevicePrivateSyncNotifier(
|
||||||
|
container.read(refProvider),
|
||||||
|
query: (request, source, prefix) {
|
||||||
|
calls++;
|
||||||
|
return remote.future;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
addTearDown(notifier.dispose);
|
||||||
|
|
||||||
|
final first = notifier.start();
|
||||||
|
await notifier.start();
|
||||||
|
expect(calls, 1);
|
||||||
|
expect(notifier.state.phase, DevicePrivateSyncPhase.syncing);
|
||||||
|
|
||||||
|
notifier.cancel();
|
||||||
|
expect(notifier.state.phase, DevicePrivateSyncPhase.cancelled);
|
||||||
|
remote.complete(const []);
|
||||||
|
await first;
|
||||||
|
|
||||||
|
expect(calls, 1);
|
||||||
|
expect(notifier.state.phase, DevicePrivateSyncPhase.cancelled);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/constants/app_constants.dart';
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
import 'package:zapstore/services/unmanaged_apps_service.dart';
|
import 'package:zapstore/services/unmanaged_apps_service.dart';
|
||||||
@@ -118,23 +119,53 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('wasUnmanagedStackAccepted', () {
|
group('wasUnmanagedStackAccepted', () {
|
||||||
test('requires explicit acceptance for the stack event', () {
|
test(
|
||||||
|
'uses the raw event ID for a parameterized replaceable stack',
|
||||||
|
() async {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
await container
|
||||||
|
.read(storageNotifierProvider.notifier)
|
||||||
|
.initialize(StorageConfiguration());
|
||||||
|
final stack = createUnmanagedAppsStack(
|
||||||
|
appIds: {'app.one'},
|
||||||
|
platform: 'android-arm64-v8a',
|
||||||
|
createdAt: DateTime.utc(2026, 7, 10, 20),
|
||||||
|
).dummySign('a' * 64);
|
||||||
|
|
||||||
|
expect(stack.id, isNot(stack.event.id));
|
||||||
|
|
||||||
final accepted = PublishResponse()
|
final accepted = PublishResponse()
|
||||||
..addEvent(
|
..addEvent(
|
||||||
'stack-id',
|
stack.event.id,
|
||||||
relayUrl: 'wss://relay.zapstore.dev',
|
relayUrl: 'wss://relay.zapstore.dev',
|
||||||
accepted: true,
|
accepted: true,
|
||||||
);
|
);
|
||||||
final rejected = PublishResponse()
|
final rejected = PublishResponse()
|
||||||
..addEvent(
|
..addEvent(
|
||||||
'stack-id',
|
stack.event.id,
|
||||||
relayUrl: 'wss://relay.zapstore.dev',
|
relayUrl: 'wss://relay.zapstore.dev',
|
||||||
accepted: false,
|
accepted: false,
|
||||||
);
|
);
|
||||||
|
final addressableIdResponse = PublishResponse()
|
||||||
|
..addEvent(
|
||||||
|
stack.id,
|
||||||
|
relayUrl: 'wss://relay.zapstore.dev',
|
||||||
|
accepted: true,
|
||||||
|
);
|
||||||
|
|
||||||
expect(wasUnmanagedStackAccepted(accepted, 'stack-id'), isTrue);
|
expect(wasUnmanagedStackAccepted(accepted, stack), isTrue);
|
||||||
expect(wasUnmanagedStackAccepted(rejected, 'stack-id'), isFalse);
|
expect(wasUnmanagedStackAccepted(rejected, stack), isFalse);
|
||||||
expect(wasUnmanagedStackAccepted(PublishResponse(), 'stack-id'), isFalse);
|
expect(
|
||||||
});
|
wasUnmanagedStackAccepted(addressableIdResponse, stack),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(wasUnmanagedStackAccepted(PublishResponse(), stack), isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user