diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index a353984..a886e6e 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -188,47 +188,6 @@ Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { subscriptionPrefix: 'app-bg', ); - // NIP-09: fetch kind-5 deletion events since last sync. - // No author filter — the AppCatalog relay is curated and the `since` - // cursor keeps incremental runs cheap. The DB layer auto-processes - // each kind-5 event (removes referenced events, records in deletions - // table). We then clean up the kind-5 events themselves as housekeeping, - // but save the cursor regardless — a cleanup failure is non-critical. - final secureStorage = SecureStorageService(); - final lastDeletionSync = await secureStorage.getDeletionsSyncedUntil(); - final deletionRequests = await storage.query( - RequestFilter( - since: lastDeletionSync, - limit: 99, - ).toRequest(), - source: const RemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-bg-deletions', - ); - await secureStorage.setDeletionsSyncedUntil(DateTime.now()); - if (deletionRequests.isNotEmpty) { - // Purplebase's NIP-09 processing only deletes events whose pubkey - // matches the kind-5 author. Relay-authority deletions (blacklists) - // are signed by zapstore/community keys, not the app author, so - // those targets survive. Collect them and delete directly by ID. - const trustedAuthorities = {kZapstorePubkey, kZapstoreCommunityPubkey}; - final authorityTargetIds = deletionRequests - .where((dr) => trustedAuthorities.contains(dr.event.pubkey)) - .expand((dr) => dr.deletedEventIds) - .toSet(); - - try { - final storageNotifier = storage as PurplebaseStorageNotifier; - await storageNotifier - .delete(deletionRequests.map((d) => d.id).toSet()); - if (authorityTargetIds.isNotEmpty) { - await storageNotifier.delete(authorityTargetIds); - } - } catch (_) { - // Orphaned kind-5 rows are harmless; referenced events are - // already removed by the DB layer during save. - } - } - final updatableInstallables = {}; for (final entry in catalog.installableByApp.entries) { if (packageManager.hasUpdate(entry.key, entry.value)) { diff --git a/lib/services/deletion_processor.dart b/lib/services/deletion_processor.dart new file mode 100644 index 0000000..58c08da --- /dev/null +++ b/lib/services/deletion_processor.dart @@ -0,0 +1,51 @@ +import 'package:models/models.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; + +/// Fetches NIP-09 kind-5 deletion events from the AppCatalog relay since the +/// last sync cursor, applies them to local storage, and advances the cursor. +/// +/// The AppCatalog relay is curated, so no author filter is needed. The `since` +/// cursor (persisted in secure storage) keeps incremental runs cheap. +/// +/// Purplebase auto-processes each kind-5 event on save (removes referenced +/// events whose pubkey matches the kind-5 author). However, relay-authority +/// deletions (blacklists signed by zapstore/community keys) won't match the +/// original author — those targets are deleted explicitly here. +Future processDeletions({ + required PurplebaseStorageNotifier storage, + required SecureStorageService secureStorage, + required String subscriptionPrefix, +}) async { + final lastSync = await secureStorage.getDeletionsSyncedUntil(); + + final deletionRequests = await storage.query( + RequestFilter( + since: lastSync, + limit: 99, + ).toRequest(), + source: const RemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: subscriptionPrefix, + ); + + await secureStorage.setDeletionsSyncedUntil(DateTime.now()); + + if (deletionRequests.isEmpty) return; + + const trustedAuthorities = {kZapstorePubkey, kZapstoreCommunityPubkey}; + final authorityTargetIds = deletionRequests + .where((dr) => trustedAuthorities.contains(dr.event.pubkey)) + .expand((dr) => dr.deletedEventIds) + .toSet(); + + try { + await storage.delete(deletionRequests.map((d) => d.id).toSet()); + if (authorityTargetIds.isNotEmpty) { + await storage.delete(authorityTargetIds); + } + } catch (_) { + // Orphaned kind-5 rows are harmless; referenced events are + // already removed by the DB layer during save. + } +} diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index a6fe1bd..a14e804 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -4,8 +4,11 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/main.dart'; +import 'package:purplebase/purplebase.dart'; import 'package:zapstore/services/catalog_fetcher.dart'; +import 'package:zapstore/services/deletion_processor.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/utils/extensions.dart'; /// How often to poll for updates from remote relays @@ -143,13 +146,22 @@ class UpdatePollerNotifier extends StateNotifier { return; } + final storage = + ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier; + final result = await fetchCatalog( - storage: ref.read(storageNotifierProvider.notifier), + storage: storage, installedIds: pmState.installed.keys.toSet(), platform: ref.read(packageManagerProvider.notifier).platform, subscriptionPrefix: 'app-updates-poll', ); + await processDeletions( + storage: storage, + secureStorage: ref.read(secureStorageServiceProvider), + subscriptionPrefix: 'app-deletions-poll', + ); + state = state.copyWith(catalogedIds: result.catalogedIds); } @@ -211,8 +223,9 @@ final categorizedUpdatesProvider = Provider((ref) { } final catalogedIds = pollerState.catalogedIds; - final catalogedInstalledIds = - installed.keys.where(catalogedIds.contains).toSet(); + final catalogedInstalledIds = installed.keys + .where(catalogedIds.contains) + .toSet(); if (catalogedInstalledIds.isEmpty) { return CategorizedUpdates( @@ -221,9 +234,9 @@ final categorizedUpdatesProvider = Provider((ref) { upToDateApps: const [], uncatalogedApps: installed.values.toList() ..sort( - (a, b) => (a.name ?? a.appId) - .toLowerCase() - .compareTo((b.name ?? b.appId).toLowerCase()), + (a, b) => (a.name ?? a.appId).toLowerCase().compareTo( + (b.name ?? b.appId).toLowerCase(), + ), ), ); } @@ -236,7 +249,10 @@ final categorizedUpdatesProvider = Provider((ref) { // the same path used by the detail screen and install button. final appsState = ref.watch( query( - tags: {'#d': catalogedInstalledIds, '#f': {platform}}, + tags: { + '#d': catalogedInstalledIds, + '#f': {platform}, + }, and: (app) => { app.latestAsset.query(source: const LocalSource()), app.latestRelease.query(