diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 459d08c7..ae0fd91b 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -917,7 +917,6 @@ 82D6FBE42CD99F7900C925F4 /* ZapSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2429DDDF2600516EAC /* ZapSettingsView.swift */; }; 82D6FBE52CD99F7900C925F4 /* TranslationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2629DDE31900516EAC /* TranslationSettingsView.swift */; }; 82D6FBE62CD99F7900C925F4 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; }; - 82D6FBE72CD99F7900C925F4 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */; }; 82D6FBE82CD99F7900C925F4 /* FirstAidSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FD12252BD345A700CF195B /* FirstAidSettingsView.swift */; }; 82D6FBE92CD99F7900C925F4 /* ImageContextMenuModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */; }; 82D6FBEA2CD99F7900C925F4 /* FullScreenCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6629CC9E3A008DB934 /* FullScreenCarouselView.swift */; }; @@ -1240,6 +1239,8 @@ D723411A2B6864F200E1E135 /* DamusPurpleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */; }; D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = D723C38D2AB8D83400065664 /* ContentFilters.swift */; }; D724D8272B64B40B00ABE789 /* DamusPurpleAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724D8262B64B40B00ABE789 /* DamusPurpleAccountView.swift */; }; + D72734282F08914C00F90677 /* DatabaseSnapshotManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72734272F08912F00F90677 /* DatabaseSnapshotManagerTests.swift */; }; + D727342A2F089EEE00F90677 /* NdbMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72734292F089EE600F90677 /* NdbMigrationTests.swift */; }; D72927AD2BAB515C00F93E90 /* RelayURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72927AC2BAB515C00F93E90 /* RelayURLTests.swift */; }; D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */; }; D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */; }; @@ -1465,7 +1466,6 @@ D73E5EE02C6A97F4007EB227 /* ZapSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2429DDDF2600516EAC /* ZapSettingsView.swift */; }; D73E5EE12C6A97F4007EB227 /* TranslationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1A9A2629DDE31900516EAC /* TranslationSettingsView.swift */; }; D73E5EE22C6A97F4007EB227 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; }; - D73E5EE32C6A97F4007EB227 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */; }; D73E5EE42C6A97F4007EB227 /* FirstAidSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FD12252BD345A700CF195B /* FirstAidSettingsView.swift */; }; D73E5EE52C6A97F4007EB227 /* ImageContextMenuModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */; }; D73E5EE72C6A97F4007EB227 /* ProfilePicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6439E013296790CF0020672B /* ProfilePicImageView.swift */; }; @@ -1669,11 +1669,11 @@ D74EC8522E1856B70091DC51 /* NonCopyableLinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */; }; D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F43092B23F0BE00425B75 /* DamusPurple.swift */; }; D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; }; - D751FA992EF62C8100E10F1B /* ProfilesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */; }; D75154BF2EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; }; D75154C02EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; }; D75154C12EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; }; D75154C22EC5910A00BF2CB2 /* NdbUseLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */; }; + D751FA992EF62C8100E10F1B /* ProfilesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */; }; D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D753CEA92BE9DE04001C3A5D /* MutingTests.swift */; }; D755B28D2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; }; D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */ = {isa = PBXBuildFile; fileRef = D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */; }; @@ -1785,6 +1785,7 @@ D7CB5D602B11770C00AD4105 /* FollowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5E2B11770C00AD4105 /* FollowState.swift */; }; D7CBD1D42B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */; }; D7CBD1D62B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */; }; + D7CCDB8A2F034FBC00218972 /* DatabaseSnapshotManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */; }; D7CCFC072B05833200323D86 /* NdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90548A2A6AEDEE00811EEC /* NdbNote.swift */; }; D7CCFC082B05834500323D86 /* NoteId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC14FF42A740BB7007AEB17 /* NoteId.swift */; }; D7CCFC0F2B0587F600323D86 /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C285C8B28398BC6008A31F1 /* Keys.swift */; }; @@ -1890,6 +1891,10 @@ D7F360262CEBBD8B009D34DA /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; }; D7F360272CEBBDC0009D34DA /* DamusVideoControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EFBA362CC322F300F45588 /* DamusVideoControlsView.swift */; }; D7F360292CEBBE34009D34DA /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D7F360282CEBBE34009D34DA /* CodeScanner */; }; + D7F4F0B92F03689300B61683 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */; }; + D7F4F0BA2F03689300B61683 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */; }; + D7F4F0BB2F0371D500B61683 /* DatabaseSnapshotManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */; }; + D7F4F0BC2F0371D500B61683 /* DatabaseSnapshotManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */; }; D7F563102DEE71C0008509DE /* NdbFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F5630F2DEE71BB008509DE /* NdbFilter.swift */; }; D7F563112DEE71C0008509DE /* NdbFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F5630F2DEE71BB008509DE /* NdbFilter.swift */; }; D7F563122DEE71C0008509DE /* NdbFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7F5630F2DEE71BB008509DE /* NdbFilter.swift */; }; @@ -2737,6 +2742,8 @@ D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleEnvironment.swift; sourceTree = ""; }; D723C38D2AB8D83400065664 /* ContentFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilters.swift; sourceTree = ""; }; D724D8262B64B40B00ABE789 /* DamusPurpleAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleAccountView.swift; sourceTree = ""; }; + D72734272F08912F00F90677 /* DatabaseSnapshotManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSnapshotManagerTests.swift; sourceTree = ""; }; + D72734292F089EE600F90677 /* NdbMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbMigrationTests.swift; sourceTree = ""; }; D72927AC2BAB515C00F93E90 /* RelayURLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayURLTests.swift; sourceTree = ""; }; D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventViewTests.swift; sourceTree = ""; }; D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDamusState.swift; sourceTree = ""; }; @@ -2774,8 +2781,8 @@ D74EC84E2E1856AF0091DC51 /* NonCopyableLinkedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonCopyableLinkedList.swift; sourceTree = ""; }; D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = ""; }; D74F430B2B23FB9B00425B75 /* StoreObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreObserver.swift; sourceTree = ""; }; - D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesManagerTests.swift; sourceTree = ""; }; D75154BE2EC5910600BF2CB2 /* NdbUseLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NdbUseLock.swift; sourceTree = ""; }; + D751FA982EF62C8100E10F1B /* ProfilesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesManagerTests.swift; sourceTree = ""; }; D753CEA92BE9DE04001C3A5D /* MutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutingTests.swift; sourceTree = ""; }; D755B28C2D3E7D7D00BBEEFA /* NIP37Draft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP37Draft.swift; sourceTree = ""; }; D76556D52B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleWelcomeView.swift; sourceTree = ""; }; @@ -2820,6 +2827,7 @@ D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = ""; }; D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNotificationManagement.swift; sourceTree = ""; }; D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleImpendingExpirationTests.swift; sourceTree = ""; }; + D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSnapshotManager.swift; sourceTree = ""; }; D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinosDeterministicAccountClient.swift; sourceTree = ""; }; D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationClient.swift; sourceTree = ""; }; D7D68FF82C9E01B60015A515 /* KFClickable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFClickable.swift; sourceTree = ""; }; @@ -3822,6 +3830,8 @@ 4CE6DEF627F7A08200C66700 /* damusTests */ = { isa = PBXGroup; children = ( + D72734292F089EE600F90677 /* NdbMigrationTests.swift */, + D72734272F08912F00F90677 /* DatabaseSnapshotManagerTests.swift */, D7100CB52EEA3E20008D94B7 /* AutoSaveViewModelTests.swift */, D7EBF8BC2E5946F9004EAE29 /* NostrNetworkManagerTests */, D7DB1FED2D5AC50F00CF06DA /* NIP44v2EncryptionTests.swift */, @@ -4916,6 +4926,7 @@ 5C78A7BD2E306D6000CF177D /* Storage */ = { isa = PBXGroup; children = ( + D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */, D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */, D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */, 501F8C7F2A0220E1001AFC1D /* KeychainStorage.swift */, @@ -6123,6 +6134,7 @@ 4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */, 4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */, 4C1253582A76C9060004F4B8 /* PresentSheetNotify.swift in Sources */, + D7CCDB8A2F034FBC00218972 /* DatabaseSnapshotManager.swift in Sources */, D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */, D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */, 4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */, @@ -6267,6 +6279,7 @@ D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */, D751FA992EF62C8100E10F1B /* ProfilesManagerTests.swift in Sources */, 3AAC7A022A60FE72002B50DF /* LocalizationUtilTests.swift in Sources */, + D727342A2F089EEE00F90677 /* NdbMigrationTests.swift in Sources */, D7CBD1D62B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift in Sources */, D7EBF8BB2E59022A004EAE29 /* NostrNetworkManagerTests.swift in Sources */, D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */, @@ -6290,6 +6303,7 @@ D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */, 4CB8839A297322D200DC99E7 /* DMTests.swift in Sources */, D5C1AFC82E5E00690092F72F /* ContactCardManagerTests.swift in Sources */, + D72734282F08914C00F90677 /* DatabaseSnapshotManagerTests.swift in Sources */, D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */, 4C9054852A6AEAA000811EEC /* NdbTests.swift in Sources */, 75AD872B2AA23A460085EF2C /* Block+Tests.swift in Sources */, @@ -6690,7 +6704,6 @@ 82D6FBE52CD99F7900C925F4 /* TranslationSettingsView.swift in Sources */, 5C8F97472EB461DB009399B1 /* EventTags.swift in Sources */, 82D6FBE62CD99F7900C925F4 /* SearchSettingsView.swift in Sources */, - 82D6FBE72CD99F7900C925F4 /* DeveloperSettingsView.swift in Sources */, 82D6FBE82CD99F7900C925F4 /* FirstAidSettingsView.swift in Sources */, 82D6FBE92CD99F7900C925F4 /* ImageContextMenuModifier.swift in Sources */, 82D6FBEA2CD99F7900C925F4 /* FullScreenCarouselView.swift in Sources */, @@ -6829,6 +6842,7 @@ 82D6FC552CD99F7900C925F4 /* EventView.swift in Sources */, 82D6FC562CD99F7900C925F4 /* EventDetailView.swift in Sources */, 82D6FC572CD99F7900C925F4 /* FollowButtonView.swift in Sources */, + D7F4F0BC2F0371D500B61683 /* DatabaseSnapshotManager.swift in Sources */, 82D6FC582CD99F7900C925F4 /* FollowingView.swift in Sources */, 82D6FC592CD99F7900C925F4 /* LoginView.swift in Sources */, 82D6FC5A2CD99F7900C925F4 /* QRScanNSECView.swift in Sources */, @@ -6857,6 +6871,7 @@ 82D6FC6F2CD99F7900C925F4 /* BannerImageView.swift in Sources */, 82D6FC702CD99F7900C925F4 /* ReactionsView.swift in Sources */, 82D6FC712CD99F7900C925F4 /* ReportView.swift in Sources */, + D7F4F0BA2F03689300B61683 /* DeveloperSettingsView.swift in Sources */, 82D6FC722CD99F7900C925F4 /* EULAView.swift in Sources */, 82D6FC732CD99F7900C925F4 /* RepostsView.swift in Sources */, 82D6FC742CD99F7900C925F4 /* Launch.storyboard in Sources */, @@ -7127,7 +7142,6 @@ D73E5F792C6A9C4C007EB227 /* HomeModel.swift in Sources */, D73E5EE12C6A97F4007EB227 /* TranslationSettingsView.swift in Sources */, D73E5EE22C6A97F4007EB227 /* SearchSettingsView.swift in Sources */, - D73E5EE32C6A97F4007EB227 /* DeveloperSettingsView.swift in Sources */, D73E5EE42C6A97F4007EB227 /* FirstAidSettingsView.swift in Sources */, D73E5EE52C6A97F4007EB227 /* ImageContextMenuModifier.swift in Sources */, D73E5EE72C6A97F4007EB227 /* ProfilePicImageView.swift in Sources */, @@ -7136,6 +7150,7 @@ D73E5EEA2C6A97F4007EB227 /* PurpleViewPrimitives.swift in Sources */, D73E5F8C2C6AA6A7007EB227 /* ProfileActionSheetView.swift in Sources */, D73E5EEB2C6A97F4007EB227 /* MarketingContentView.swift in Sources */, + D7F4F0B92F03689300B61683 /* DeveloperSettingsView.swift in Sources */, D73E5EEC2C6A97F4007EB227 /* LogoView.swift in Sources */, D73E5EED2C6A97F4007EB227 /* IAPProductStateView.swift in Sources */, D73E5EEE2C6A97F4007EB227 /* PurpleBackdrop.swift in Sources */, @@ -7317,6 +7332,7 @@ D703D7B12C6710AB00A400EA /* LocalizationUtil.swift in Sources */, D703D74D2C6709D400A400EA /* Zap.swift in Sources */, D73E5E1C2C6A9677007EB227 /* DirectMessagesModel.swift in Sources */, + D7F4F0BB2F0371D500B61683 /* DatabaseSnapshotManager.swift in Sources */, D703D7762C670BCA00A400EA /* Verifier.swift in Sources */, D703D75A2C670A7900A400EA /* LNUrls.swift in Sources */, D703D74B2C6709C900A400EA /* NoteId.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 7fa06231..e91754ab 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -493,7 +493,6 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in print("txn: 📙 DAMUS ACTIVE NOTIFY") Task { - await damusClosingTask?.value // Wait for the closing task to finish before reopening things, to avoid race conditions if damus_state.ndb.reopen() { print("txn: NOSTRDB REOPENED") } else { @@ -527,17 +526,19 @@ struct ContentView: View { case .background: print("txn: 📙 DAMUS BACKGROUNDED") let bgTask = this_app.beginBackgroundTask(withName: "Closing things down gracefully", expirationHandler: { [weak damus_state] in - Log.error("App background signal handling: RUNNING OUT OF TIME! JUST CLOSE NDB DIRECTLY!", for: .app_lifecycle) - // Background time about to expire, so close ndb directly. - // This may still cause a memory error crash if subscription tasks have not been properly closed yet, but that is less likely than a 0xdead10cc crash if we don't do anything here. - damus_state?.ndb.close() }) damusClosingTask = Task { @MainActor in Log.debug("App background signal handling: App being backgrounded", for: .app_lifecycle) let startTime = CFAbsoluteTimeGetCurrent() + + // Stop periodic snapshots + await damus_state.snapshotManager.stopPeriodicSnapshots() + await damus_state.nostrNetwork.handleAppBackgroundRequest() // Close ndb streaming tasks before closing ndb to avoid memory errors - Log.debug("App background signal handling: Nostr network and Ndb closed after %.2f seconds", for: .app_lifecycle, CFAbsoluteTimeGetCurrent() - startTime) + + Log.debug("App background signal handling: Nostr network manager closed after %.2f seconds", for: .app_lifecycle, CFAbsoluteTimeGetCurrent() - startTime) + this_app.endBackgroundTask(bgTask) } break @@ -550,6 +551,9 @@ struct ContentView: View { await damusClosingTask?.value // Wait for the closing task to finish before reopening things, to avoid race conditions damusClosingTask = nil await damus_state.nostrNetwork.handleAppForegroundRequest() + + // Restart periodic snapshots when returning to foreground + await damus_state.snapshotManager.startPeriodicSnapshots() } @unknown default: break @@ -744,6 +748,8 @@ struct ContentView: View { home.damus_state = self.damus_state! + await damus_state.snapshotManager.startPeriodicSnapshots() + if let damus_state, damus_state.purple.enable_purple { // Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases StoreObserver.standard.delegate = damus_state.purple diff --git a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift index 28ef46c7..9b22e0d4 100644 --- a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift @@ -59,17 +59,12 @@ class NostrNetworkManager { await self.pool.disconnect() } - func handleAppBackgroundRequest(beforeClosingNdb operationBeforeClosingNdb: (() async -> Void)? = nil) async { - // Mark NDB as closed without actually closing it, to avoid new tasks from using NostrDB - self.delegate.ndb.markClosed() + func handleAppBackgroundRequest() async { await self.reader.cancelAllTasks() await self.pool.cleanQueuedRequestForSessionEnd() - await operationBeforeClosingNdb?() - self.delegate.ndb.close() } func handleAppForegroundRequest() async { - self.delegate.ndb.reopen() // Pinging the network will automatically reconnect any dead websocket connections await self.ping() } diff --git a/damus/Core/Storage/DamusState.swift b/damus/Core/Storage/DamusState.swift index f73e27be..2896cbdd 100644 --- a/damus/Core/Storage/DamusState.swift +++ b/damus/Core/Storage/DamusState.swift @@ -39,6 +39,7 @@ class DamusState: HeadlessDamusState, ObservableObject { let emoji_provider: EmojiProvider let favicon_cache: FaviconCache private(set) var nostrNetwork: NostrNetworkManager + var snapshotManager: DatabaseSnapshotManager init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, contactCards: ContactCard, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache, addNdbToRelayPool: Bool = true) { self.keypair = keypair @@ -77,12 +78,13 @@ class DamusState: HeadlessDamusState, ObservableObject { let nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate, addNdbToRelayPool: addNdbToRelayPool) self.nostrNetwork = nostrNetwork self.wallet.nostrNetwork = nostrNetwork + self.snapshotManager = .init(ndb: ndb) } @MainActor - convenience init?(keypair: Keypair) { + convenience init?(keypair: Keypair, owns_db_file: Bool) { // nostrdb - var mndb = Ndb() + var mndb = Ndb(owns_db_file: owns_db_file) if mndb == nil { // try recovery print("DB ISSUE! RECOVERING") diff --git a/damus/Core/Storage/DatabaseSnapshotManager.swift b/damus/Core/Storage/DatabaseSnapshotManager.swift new file mode 100644 index 00000000..feca7251 --- /dev/null +++ b/damus/Core/Storage/DatabaseSnapshotManager.swift @@ -0,0 +1,195 @@ +// +// DatabaseSnapshotManager.swift +// damus +// +// Created on 2025-01-20. +// + +import Foundation +import OSLog + +/// Manages periodic snapshots of the main NostrDB database to a shared container location. +/// +/// This allows app extensions (like notification service extensions) to access a recent +/// read-only copy of the database for enhanced UX, while the main database resides in +/// the private container to avoid 0xdead10cc crashes and issues related to holding file locks on shared containers. +/// +/// Snapshots are created periodically while the app is in the foreground, since the database +/// only gets updated when the app is active. +actor DatabaseSnapshotManager { + + /// Minimum interval between snapshots (in seconds) + private static let minimumSnapshotInterval: TimeInterval = 60 * 60 // 1 hour + + /// Key for storing last snapshot timestamp in UserDefaults + private static let lastSnapshotDateKey = "lastDatabaseSnapshotDate" + + private let ndb: Ndb + private var snapshotTimerTask: Task? = nil + var snapshotTimerTickCount: Int = 0 + var snapshotCount: Int = 0 + + /// Initialize the snapshot manager with a NostrDB instance + /// - Parameter ndb: The NostrDB instance to snapshot + init(ndb: Ndb) { + self.ndb = ndb + } + + // MARK: - Periodic tasks management + + /// Start the periodic snapshot timer. + /// + /// This should be called when the app enters the foreground. + /// The timer will fire periodically to check if a snapshot is needed. + func startPeriodicSnapshots() { + // Don't start if already running + guard snapshotTimerTask == nil else { + Log.debug("Snapshot timer already running", for: .storage) + return + } + + Log.info("Starting periodic database snapshot timer", for: .storage) + + snapshotTimerTask = Task(priority: .utility) { [weak self] in + while !Task.isCancelled { + guard let self else { return } + Log.debug("Snapshot timer - tick", for: .storage) + await self.increaseSnapshotTimerTickCount() + do { + try await self.createSnapshotIfNeeded() + } + catch { + Log.error("Failed to create snapshot: %{public}@", for: .storage, error.localizedDescription) + } + try? await Task.sleep(for: .seconds(60 * 5), tolerance: .seconds(10)) + } + } + } + + /// Stop the periodic snapshot timer. + /// + /// This should be called when the app enters the background. + func stopPeriodicSnapshots() async { + guard snapshotTimerTask != nil else { + return + } + + Log.info("Stopping periodic database snapshot timer", for: .storage) + snapshotTimerTask?.cancel() + await snapshotTimerTask?.value + snapshotTimerTask = nil + } + + + // MARK: - Snapshotting + + /// Perform a database snapshot if needed. + /// + /// This method checks if enough time has passed since the last snapshot and creates a new one if necessary. + @discardableResult + func createSnapshotIfNeeded() async throws -> Bool { + guard shouldCreateSnapshot() else { + Log.debug("Skipping snapshot - minimum interval not yet elapsed", for: .storage) + return false + } + + try await self.performSnapshot() + return true + } + + /// Check if a snapshot should be created based on the last snapshot time. + private func shouldCreateSnapshot() -> Bool { + guard let lastSnapshotDate = UserDefaults.standard.object(forKey: Self.lastSnapshotDateKey) as? Date else { + return true // No snapshot has been created yet + } + + let timeSinceLastSnapshot = Date().timeIntervalSince(lastSnapshotDate) + return timeSinceLastSnapshot >= Self.minimumSnapshotInterval + } + + /// Perform the actual snapshot operation. + func performSnapshot() async throws { + guard let snapshotPath = Ndb.snapshot_db_path else { + throw SnapshotError.pathsUnavailable + } + + Log.info("Starting nostrdb snapshot to %{public}@", for: .storage, snapshotPath) + + try await copyDatabase(to: snapshotPath) + + // Update the last snapshot date + UserDefaults.standard.set(Date(), forKey: Self.lastSnapshotDateKey) + + Log.info("Database snapshot completed successfully", for: .storage) + self.snapshotCount += 1 + } + + /// Copy the database using LMDB's native copy function. + private func copyDatabase(to snapshotPath: String) async throws { + return try await withCheckedThrowingContinuation { continuation in + let fileManager = FileManager.default + + // Delete existing database files at the destination if they exist + // LMDB creates multiple files (data.mdb, lock.mdb), so we remove the entire directory + if fileManager.fileExists(atPath: snapshotPath) { + do { + try fileManager.removeItem(atPath: snapshotPath) + Log.debug("Removed existing snapshot at %{public}@", for: .storage, snapshotPath) + } catch { + continuation.resume(throwing: SnapshotError.removeFailed(error)) + return + } + } + + Log.debug("Recreate the snapshot directory", for: .storage, snapshotPath) + // Recreate the snapshot directory + do { + try fileManager.createDirectory(atPath: snapshotPath, withIntermediateDirectories: true) + } catch { + continuation.resume(throwing: SnapshotError.directoryCreationFailed(error)) + return + } + + do { + try ndb.snapshot(path: snapshotPath) + continuation.resume(returning: ()) + } + catch { + continuation.resume(throwing: SnapshotError.copyFailed(error)) + } + } + } + + // MARK: - Stats functions + + private func increaseSnapshotTimerTickCount() async { + self.snapshotTimerTickCount += 1 + } + + func resetStats() async { + self.snapshotTimerTickCount = 0 + self.snapshotCount = 0 + } +} + +// MARK: - Error Types + +enum SnapshotError: Error, LocalizedError { + case pathsUnavailable + case copyFailed(any Error) + case removeFailed(Error) + case directoryCreationFailed(Error) + + var errorDescription: String? { + switch self { + case .pathsUnavailable: + return "Database paths are not available" + case .copyFailed(let code): + return "Failed to copy database (error code: \(code))" + case .removeFailed(let error): + return "Failed to remove existing snapshot: \(error.localizedDescription)" + case .directoryCreationFailed(let error): + return "Failed to create snapshot directory: \(error.localizedDescription)" + } + } +} diff --git a/damus/Features/Settings/Views/DeveloperSettingsView.swift b/damus/Features/Settings/Views/DeveloperSettingsView.swift index 69dfba0d..b86a1bbf 100644 --- a/damus/Features/Settings/Views/DeveloperSettingsView.swift +++ b/damus/Features/Settings/Views/DeveloperSettingsView.swift @@ -10,6 +10,7 @@ import SwiftUI struct DeveloperSettingsView: View { @ObservedObject var settings: UserSettingsStore + let damus_state: DamusState var body: some View { Form { @@ -101,9 +102,74 @@ struct DeveloperSettingsView: View { Toggle(NSLocalizedString("Reset tips on launch", comment: "Developer mode setting to reset tips upon app first launch. Tips are visual contextual hints that highlight new, interesting, or unused features users have not discovered yet."), isOn: $settings.reset_tips_on_launch) .toggleStyle(.switch) } + + SnapshotNdbButton(damus_state: self.damus_state) } } } .navigationTitle(NSLocalizedString("Developer", comment: "Navigation title for developer settings")) } } + +extension DeveloperSettingsView { + struct SnapshotNdbButton: View { + let damus_state: DamusState + @State var snapshotState: SnapshotState = .notDone + @State var snapshotTask: Task? = nil + + var body: some View { + Button(action: { self.snapshot() }, label: { + HStack(spacing: 6) { + switch snapshotState { + case .notDone: + Text("Snapshot Ndb to shared container", comment: "Developer settings button to snapshot ndb to shared container.") + case .inProgress: + ProgressView() + Text("Snapshotting Ndb to shared container", comment: "Developer settings loading message indicating that ndb is being snapshotted to the shared container.") + case .done: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Ndb has been snapshotted successfully", comment: "Developer settings message indicating that ndb was successfully snapshotted.") + case .error(let errorMessage): + Image(systemName: "xmark.circle") + .foregroundColor(.red) + Text(errorMessage) + } + } + }) + .disabled(self.snapshotState.isInProgress()) + } + + func snapshot() { + Task { + snapshotTask?.cancel() + await snapshotTask?.value + snapshotTask = Task { + self.snapshotState = .inProgress + do { + try await damus_state.snapshotManager.performSnapshot() + self.snapshotState = .done + } catch { + self.snapshotState = .error(error.localizedDescription) + } + } + } + } + + enum SnapshotState { + case notDone + case inProgress + case done + case error(String) + + func isInProgress() -> Bool { + if case .inProgress = self { + return true + } + else { + return false + } + } + } + } +} diff --git a/damus/Shared/Utilities/OrientationTracker.swift b/damus/Shared/Utilities/OrientationTracker.swift new file mode 100644 index 00000000..e69de29b diff --git a/damus/Shared/Utilities/Router.swift b/damus/Shared/Utilities/Router.swift index 11eb96d1..03685af7 100644 --- a/damus/Shared/Utilities/Router.swift +++ b/damus/Shared/Utilities/Router.swift @@ -97,7 +97,7 @@ enum Route: Hashable { case .SearchSettings(let settings): SearchSettingsView(settings: settings) case .DeveloperSettings(let settings): - DeveloperSettingsView(settings: settings) + DeveloperSettingsView(settings: settings, damus_state: damusState) case .FirstAidSettings(settings: let settings): FirstAidSettingsView(damus_state: damusState, settings: settings) case .Thread(let thread): diff --git a/damus/damusApp.swift b/damus/damusApp.swift index a53f5784..59d3a3b7 100644 --- a/damus/damusApp.swift +++ b/damus/damusApp.swift @@ -82,6 +82,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele registerNotificationCategories() ImageCacheMigrations.migrateKingfisherCacheIfNeeded() configureKingfisherCache() + return true } diff --git a/damusTests/DatabaseSnapshotManagerTests.swift b/damusTests/DatabaseSnapshotManagerTests.swift new file mode 100644 index 00000000..19a806ad --- /dev/null +++ b/damusTests/DatabaseSnapshotManagerTests.swift @@ -0,0 +1,325 @@ +// +// DatabaseSnapshotManagerTests.swift +// damus +// +// Created by Daniel D'Aquino on 2026-01-02. +// + +import XCTest +@testable import damus + +final class DatabaseSnapshotManagerTests: XCTestCase { + + var tempDirectory: URL! + var manager: DatabaseSnapshotManager! + var testNdb: Ndb! + + override func setUp() async throws { + try await super.setUp() + + // Create a temporary directory for test files + tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, conformingTo: .directory) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + + self.testNdb = Ndb(path: test_ndb_dir(), owns_db_file: true)! + + // Create the manager + manager = DatabaseSnapshotManager(ndb: self.testNdb) + + // Clear UserDefaults for consistent testing + UserDefaults.standard.removeObject(forKey: "lastDatabaseSnapshotDate") + } + + override func tearDown() async throws { + // Clean up temporary directory + if let tempDirectory = tempDirectory { + try? FileManager.default.removeItem(at: tempDirectory) + } + + // Clear UserDefaults + UserDefaults.standard.removeObject(forKey: "lastDatabaseSnapshotDate") + + // Stop any running snapshots + await manager.stopPeriodicSnapshots() + + manager = nil + tempDirectory = nil + + try await super.tearDown() + } + + // MARK: - Snapshot Creation Tests + + func testCreateSnapshotIfNeeded_CreatesSnapshotWhenNeverCreatedBefore() async throws { + // Given: No previous snapshot exists + XCTAssertNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate")) + + // When: createSnapshotIfNeeded is called + try await manager.createSnapshotIfNeeded() + + // Then: A snapshot should be created + XCTAssertNotNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate")) + } + + func testCreateSnapshotIfNeeded_SkipsSnapshotWhenRecentSnapshotExists() async throws { + // Given: A recent snapshot was just created + UserDefaults.standard.set(Date(), forKey: "lastDatabaseSnapshotDate") + + // When: createSnapshotIfNeeded is called + let snapshotMade = try await manager.createSnapshotIfNeeded() + + // Then: No snapshot should be created + XCTAssertFalse(snapshotMade) + } + + func testCreateSnapshotIfNeeded_CreatesSnapshotWhenIntervalHasPassed() async throws { + // Given: A snapshot was created more than 1 hour ago + let oldDate = Date().addingTimeInterval(-60 * 61) // 61 minutes ago + UserDefaults.standard.set(oldDate, forKey: "lastDatabaseSnapshotDate") + + // When: createSnapshotIfNeeded is called + let snapshotMade = try await manager.createSnapshotIfNeeded() + + // Then: A snapshot should be created + XCTAssertTrue(snapshotMade) + + // And: The last snapshot date should be updated + let lastDate = UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate") as? Date + XCTAssertNotNil(lastDate) + XCTAssertTrue(lastDate! > oldDate) + } + + // MARK: - Perform Snapshot Tests + + func testPerformSnapshot_WritesFile() async throws { + // Given: No previous snapshot exists + XCTAssertNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate")) + let fileManager = FileManager.default + guard let snapshotPath = Ndb.snapshot_db_path else { + XCTFail("Snapshot path should be available") + return + } + try fileManager.removeItem(atPath: snapshotPath) + XCTAssertFalse(fileManager.fileExists(atPath: snapshotPath), "Snapshot directory should not exist at \(snapshotPath)") + + + // When: Creating a snapshot + let snapshotMade = try await manager.createSnapshotIfNeeded() + + // Then: Snapshot should be created + XCTAssertTrue(snapshotMade) + + // And: The snapshot should be there + var isDirectory: ObjCBool = false + let exists = fileManager.fileExists(atPath: snapshotPath, isDirectory: &isDirectory) + + XCTAssertTrue(exists, "Snapshot directory should exist at \(snapshotPath)") + XCTAssertTrue(isDirectory.boolValue, "Snapshot path should be a directory") + + // And: LMDB database files should exist + let dataFile = "\(snapshotPath)/data.mdb" + XCTAssertTrue(fileManager.fileExists(atPath: dataFile), "data.mdb should exist") + } + + func testPerformSnapshot_UpdatesTimestamp() async throws { + // Given: No previous snapshot + XCTAssertNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate")) + + let beforeDate = Date() + + // When: Performing a snapshot + try await manager.performSnapshot() + + let afterDate = Date() + + // Then: The timestamp should be set and within the time window + let savedDate = UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate") as? Date + XCTAssertNotNil(savedDate) + XCTAssertGreaterThanOrEqual(savedDate!, beforeDate) + XCTAssertLessThanOrEqual(savedDate!, afterDate) + } + + func testPerformSnapshot_CanBeCalledMultipleTimes() async throws { + // Given: A snapshot already exists + try await manager.performSnapshot() + + // When: Performing another snapshot (this should replace the old one) + try await manager.performSnapshot() + + // Then: No error should occur + XCTAssertNotNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate")) + } + + // MARK: - Periodic Snapshot Timer Tests + + func testStartPeriodicSnapshots_StartsTimer() async throws { + // Given: Manager is initialized + + + // When: startPeriodicSnapshots is called + await manager.startPeriodicSnapshots() + + // Give the timer task a moment to execute + try await Task.sleep(for: .milliseconds(100)) + + // Then: A snapshot should be attempted + let tickCount = await manager.snapshotTimerTickCount + XCTAssertGreaterThan(tickCount, 0) + } + + func testStartPeriodicSnapshots_DoesNotStartMultipleTimes() async throws { + // Given: Timer is already started + await manager.startPeriodicSnapshots() + + // Give the timer a moment to start + try await Task.sleep(for: .milliseconds(500)) + + let firstTickCount = await manager.snapshotTimerTickCount + + // When: startPeriodicSnapshots is called again + await manager.startPeriodicSnapshots() + + // Give it a moment + try await Task.sleep(for: .milliseconds(500)) + + let secondTickCount = await manager.snapshotTimerTickCount + + // Then: The tick count should not have increased significantly + // (proving we didn't start a second timer) + XCTAssertEqual(secondTickCount, firstTickCount, "Starting twice should not create multiple timers") + } + + func testStopPeriodicSnapshots_StopsTimer() async throws { + // Given: Timer is running + await manager.startPeriodicSnapshots() + + // When: stopPeriodicSnapshots is called and stats are reset + await manager.stopPeriodicSnapshots() + await manager.resetStats() + + // Wait longer than the timer interval + try await Task.sleep(for: .milliseconds(200)) + + // Then: No more snapshots should be created + let snapshotCount = await manager.snapshotCount + XCTAssertEqual(snapshotCount, 0) + } + + func testStopPeriodicSnapshots_CanBeCalledMultipleTimes() async throws { + // Given: Timer is running + await manager.startPeriodicSnapshots() + + // When: stopPeriodicSnapshots is called multiple times + await manager.stopPeriodicSnapshots() + await manager.stopPeriodicSnapshots() + + // Then: No crash should occur (test passes if we get here) + XCTAssertTrue(true) + } + + // MARK: - Integration Tests + + func testSnapshotLifecycle_StartStopRestart() async throws { + // Given: A manager with valid configuration + + // When: Starting, stopping, and restarting the timer + await manager.startPeriodicSnapshots() + try await Task.sleep(for: .milliseconds(100)) + + await manager.stopPeriodicSnapshots() + + await manager.startPeriodicSnapshots() + try await Task.sleep(for: .milliseconds(1000)) + + // Then: Snapshots should be created appropriately + let snapshotCount = await manager.snapshotCount + XCTAssertGreaterThan(snapshotCount, 0) + } + + func testSnapshotTimestampUpdates() async throws { + // Given: No previous snapshot + XCTAssertNil(UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate")) + + // When: Creating first snapshot + try await manager.performSnapshot() + let firstDate = UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate") as? Date + XCTAssertNotNil(firstDate) + + // Wait to ensure time difference + try await Task.sleep(for: .milliseconds(500)) + + // Force another snapshot by clearing the date + UserDefaults.standard.removeObject(forKey: "lastDatabaseSnapshotDate") + try await manager.performSnapshot() + let secondDate = UserDefaults.standard.object(forKey: "lastDatabaseSnapshotDate") as? Date + XCTAssertNotNil(secondDate) + + // Then: Second date should be after first date + XCTAssertGreaterThan(secondDate!, firstDate!, "Second snapshot timestamp should be later than first") + } + + // MARK: - Error Handling Tests + + func testCreateSnapshotIfNeeded_HandlesErrors() async throws { + // This test verifies that errors from performSnapshot are propagated + // We can't easily test the actual error cases without mocking, + // but we verify the method signature allows throwing + + // Given: A recent snapshot exists + UserDefaults.standard.set(Date(), forKey: "lastDatabaseSnapshotDate") + + // When: Attempting to create a snapshot (should skip) + let result = try await manager.createSnapshotIfNeeded() + + // Then: Should return false without throwing + XCTAssertFalse(result) + } + + // MARK: - Edge Case Tests + + func testSnapshotInterval_BoundaryCondition() async throws { + // Given: A snapshot was created exactly 1 hour ago (the minimum interval) + let exactlyOneHourAgo = Date().addingTimeInterval(-60 * 60) + UserDefaults.standard.set(exactlyOneHourAgo, forKey: "lastDatabaseSnapshotDate") + + // When: Attempting to create a snapshot at the exact boundary + let shouldCreate = try await manager.createSnapshotIfNeeded() + + // Then: A snapshot should be created (>= rather than > comparison) + XCTAssertTrue(shouldCreate, "Snapshot should be created when exactly at minimum interval") + } + + func testSnapshotInterval_JustBeforeBoundary() async throws { + // Given: A snapshot was created 59 minutes and 59 seconds ago (just before the interval) + let justBeforeOneHour = Date().addingTimeInterval(-60 * 59 - 59) + UserDefaults.standard.set(justBeforeOneHour, forKey: "lastDatabaseSnapshotDate") + + // When: Attempting to create a snapshot + let shouldCreate = try await manager.createSnapshotIfNeeded() + + // Then: No snapshot should be created + XCTAssertFalse(shouldCreate, "Snapshot should not be created before minimum interval") + } +} + + +// MARK: - SnapshotError Equatable Conformance for Testing + +extension SnapshotError: Equatable { + public static func == (lhs: SnapshotError, rhs: SnapshotError) -> Bool { + switch (lhs, rhs) { + case (.pathsUnavailable, .pathsUnavailable): + return true + case (.copyFailed, .copyFailed): + return true + case (.removeFailed, .removeFailed): + return true + case (.directoryCreationFailed, .directoryCreationFailed): + return true + default: + return false + } + } +} + diff --git a/damusTests/NdbMigrationTests.swift b/damusTests/NdbMigrationTests.swift new file mode 100644 index 00000000..25d44481 --- /dev/null +++ b/damusTests/NdbMigrationTests.swift @@ -0,0 +1,253 @@ +// +// NdbMigrationTests.swift +// damus +// +// Created by Daniel D'Aquino on 2026-01-02. +// + +import XCTest +@testable import damus + +final class NdbMigrationTests: XCTestCase { + + var testDirectory: URL! + var legacyPath: String! + var privatePath: String! + + override func setUp() async throws { + try await super.setUp() + + // Create a temporary directory for tests + testDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("NdbMigrationTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: testDirectory, withIntermediateDirectories: true) + + // Set up test paths + legacyPath = testDirectory.appendingPathComponent("legacy").path + privatePath = testDirectory.appendingPathComponent("private").path + } + + override func tearDown() async throws { + // Clean up test directory + if let testDirectory = testDirectory { + try? FileManager.default.removeItem(at: testDirectory) + } + + try await super.tearDown() + } + + // MARK: - Helper Methods + + /// Creates mock database files in the specified directory + /// - Parameters: + /// - path: The directory path where database files should be created + /// - content: The content to write to the database files. If nil, uses a default content string + /// - modificationDate: The modification date to set on the data.mdb file + private func createMockDatabaseFiles(at path: String, content: String? = nil, modificationDate: Date = Date()) throws { + let fileManager = FileManager.default + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true) + + // Create both data.mdb and lock.mdb files + let dataMdbPath = "\(path)/data.mdb" + let lockMdbPath = "\(path)/lock.mdb" + + // Write content (use provided content or default) + let fileContent = content ?? "Mock database content" + let dummyData = fileContent.data(using: .utf8)! + try dummyData.write(to: URL(fileURLWithPath: dataMdbPath)) + try dummyData.write(to: URL(fileURLWithPath: lockMdbPath)) + + // Set modification date + try fileManager.setAttributes([.modificationDate: modificationDate], ofItemAtPath: dataMdbPath) + } + + /// Verifies that database files exist at the specified path + private func verifyDatabaseFilesExist(at path: String) -> Bool { + let fileManager = FileManager.default + let dataMdbExists = fileManager.fileExists(atPath: "\(path)/data.mdb") + let lockMdbExists = fileManager.fileExists(atPath: "\(path)/lock.mdb") + return dataMdbExists && lockMdbExists + } + + /// Verifies that database files exist at the specified path + private func verifyDataDotMdbExists(at path: String) -> Bool { + let fileManager = FileManager.default + let dataMdbExists = fileManager.fileExists(atPath: "\(path)/data.mdb") + return dataMdbExists + } + + /// Verifies that database files do not exist at the specified path + private func verifyDatabaseFilesDoNotExist(at path: String) -> Bool { + let fileManager = FileManager.default + let dataMdbExists = fileManager.fileExists(atPath: "\(path)/data.mdb") + let lockMdbExists = fileManager.fileExists(atPath: "\(path)/lock.mdb") + return !dataMdbExists && !lockMdbExists + } + + // MARK: - Tests + + func testDbMigrateIfNeeded_migratesFromLegacyToPrivate() throws { + // Given: Legacy database files exist with a newer modification date than private + let legacyModificationDate = Date() + let legacyContent = "Legacy database content" + try createMockDatabaseFiles(at: legacyPath, content: legacyContent, modificationDate: legacyModificationDate) + + // Verify initial state: legacy files exist, private files don't + XCTAssertTrue(verifyDatabaseFilesExist(at: legacyPath), "Legacy database files should exist before migration") + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: privatePath), "Private database files should not exist before migration") + + // When: Migration is triggered + try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath) + + // Then: Files should be migrated to private path + XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should exist after migration") + + // Verify the content was actually copied/moved + let privateDataContent = try String(contentsOfFile: "\(privatePath!)/data.mdb") + XCTAssertEqual(privateDataContent, legacyContent, "Migrated database content should match original") + + // The original files should be gone (moved, not copied) + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist after migration (files should be moved, not copied)") + } + + func testDbMigrateIfNeeded_noMigrationWhenPrivateHasLatestFiles() throws { + // Given: Both locations have database files, but private has a newer modification date + let legacyModificationDate = Date(timeIntervalSinceNow: -3600) // 1 hour ago + let privateModificationDate = Date() // Now (newer) + + let legacyContent = "Legacy database content" + let privateContent = "Private database content (newer)" + + try createMockDatabaseFiles(at: legacyPath, content: legacyContent, modificationDate: legacyModificationDate) + try createMockDatabaseFiles(at: privatePath, content: privateContent, modificationDate: privateModificationDate) + + // Store original private content to verify it doesn't change + let originalPrivateContent = try String(contentsOfFile: "\(privatePath!)/data.mdb") + XCTAssertEqual(originalPrivateContent, privateContent, "Initial private content should match") + + // When: Migration is triggered + try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath) + + // Then: Old files should be deleted to preserve storage + XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should still exist") + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not still exist, to save storage space (deleted)") + + let currentPrivateContent = try String(contentsOfFile: "\(privatePath!)/data.mdb") + XCTAssertEqual(currentPrivateContent, privateContent, "Private database content should be unchanged") + XCTAssertNotEqual(currentPrivateContent, legacyContent, "Private content should not have been replaced with legacy content") + } + + func testDbMigrateIfNeeded_noMigrationWhenOnlyPrivateFilesExist() throws { + // Given: Only private path has database files (no legacy files) + let privateModificationDate = Date() + let privateContent = "Private database content only" + try createMockDatabaseFiles(at: privatePath, content: privateContent, modificationDate: privateModificationDate) + + // Verify initial state + XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should exist") + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist") + + let originalContent = try String(contentsOfFile: "\(privatePath!)/data.mdb") + + // When: Migration is triggered + try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath) + + // Then: Nothing should change + XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should still exist") + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should still not exist") + + let currentContent = try String(contentsOfFile: "\(privatePath!)/data.mdb") + XCTAssertEqual(currentContent, originalContent, "Private content should remain unchanged") + } + + func testDbMigrateIfNeeded_noMigrationWhenNoDatabaseFilesExist() throws { + // Given: No database files exist in either location (fresh install) + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: privatePath), "Private database files should not exist") + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist") + + // When: Migration is triggered + try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath) + + // Then: Nothing should happen, no files should be created + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: privatePath), "Private database files should still not exist") + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should still not exist") + } + + func testDbMigrateIfNeeded_replacesExistingPrivateFilesWithNewerLegacyFiles() throws { + // Given: Both locations have database files, but legacy has newer files + let privateModificationDate = Date(timeIntervalSinceNow: -3600) // 1 hour ago (older) + let legacyModificationDate = Date() // Now (newer) + + let oldPrivateContent = "Old private database content" + let newLegacyContent = "New legacy database content" + + try createMockDatabaseFiles(at: privatePath, content: oldPrivateContent, modificationDate: privateModificationDate) + try createMockDatabaseFiles(at: legacyPath, content: newLegacyContent, modificationDate: legacyModificationDate) + + // Verify initial state + let initialPrivateContent = try String(contentsOfFile: "\(privatePath!)/data.mdb") + XCTAssertEqual(initialPrivateContent, oldPrivateContent, "Private should have old content initially") + + // When: Migration is triggered + try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath) + + // Then: Private files should be replaced with legacy content + XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should exist") + + let finalPrivateContent = try String(contentsOfFile: "\(privatePath!)/data.mdb") + XCTAssertEqual(finalPrivateContent, newLegacyContent, "Private database should now contain the newer legacy content") + XCTAssertNotEqual(finalPrivateContent, oldPrivateContent, "Old private content should be replaced") + + // Legacy files should be gone (moved, not copied) + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist after migration") + } + + func testDbMigrateIfNeeded_migratesPartialDatabaseFiles() throws { + // Given: Legacy location has only one database file (data.mdb but no lock.mdb) + let fileManager = FileManager.default + try fileManager.createDirectory(atPath: legacyPath, withIntermediateDirectories: true) + + // Create only data.mdb + let partialContent = "Partial database content" + try partialContent.data(using: .utf8)!.write(to: URL(fileURLWithPath: "\(legacyPath!)/data.mdb")) + + // Verify initial state - only one file exists + XCTAssertTrue(fileManager.fileExists(atPath: "\(legacyPath!)/data.mdb"), "data.mdb should exist") + XCTAssertFalse(fileManager.fileExists(atPath: "\(legacyPath!)/lock.mdb"), "lock.mdb should not exist") + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: privatePath), "Private database files should not exist") + + // When: Migration is triggered + try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath) + + // Then: The partial file SHOULD be migrated + XCTAssertTrue(verifyDataDotMdbExists(at: privatePath), "Private database files should exist (partial migration should occur)") + XCTAssertFalse(fileManager.fileExists(atPath: "\(legacyPath!)/data.mdb"), "Legacy data.mdb should not still exist") + } + + func testDbMigrateIfNeeded_migratesWhenPrivatePathDoesNotExist() throws { + // Given: Legacy files exist, but private directory doesn't exist yet + let legacyModificationDate = Date() + let legacyContent = "Legacy database content for new migration" + try createMockDatabaseFiles(at: legacyPath, content: legacyContent, modificationDate: legacyModificationDate) + + let fileManager = FileManager.default + + // Verify initial state + XCTAssertTrue(verifyDatabaseFilesExist(at: legacyPath), "Legacy database files should exist") + XCTAssertFalse(fileManager.fileExists(atPath: privatePath), "Private directory should not exist yet") + + // When: Migration is triggered + try Ndb.migrate_db_location_if_needed(db_path: privatePath, legacy_path: legacyPath) + + // Then: Private directory should be created and files should be migrated + XCTAssertTrue(fileManager.fileExists(atPath: privatePath), "Private directory should now exist") + XCTAssertTrue(verifyDatabaseFilesExist(at: privatePath), "Private database files should exist after migration") + + // Verify content was migrated correctly + let privateDataContent = try String(contentsOfFile: "\(privatePath!)/data.mdb") + XCTAssertEqual(privateDataContent, legacyContent, "Migrated database content should match original") + + // Legacy files should be gone (moved) + XCTAssertTrue(verifyDatabaseFilesDoNotExist(at: legacyPath), "Legacy database files should not exist after migration") + } +} diff --git a/highlighter action extension/ActionViewController.swift b/highlighter action extension/ActionViewController.swift index d886cbf4..1ff09a01 100644 --- a/highlighter action extension/ActionViewController.swift +++ b/highlighter action extension/ActionViewController.swift @@ -134,7 +134,7 @@ struct ShareExtensionView: View { self.highlighter_state = .not_logged_in return } - self.state = DamusState(keypair: keypair) + self.state = DamusState(keypair: keypair, owns_db_file: false) Task { await self.state?.nostrNetwork.connect() } }) .onChange(of: self.highlighter_state) { diff --git a/nostrdb/Ndb.swift b/nostrdb/Ndb.swift index 6a266ad5..ddd3935c 100644 --- a/nostrdb/Ndb.swift +++ b/nostrdb/Ndb.swift @@ -44,10 +44,10 @@ class Ndb { } static func safemode() -> Ndb? { - guard let path = db_path ?? old_db_path else { return nil } + guard let path = db_path else { return nil } // delete the database and start fresh - if Self.db_files_exist(path: path) { + if Self.db_file_exists(path: path) { let file_manager = FileManager.default for db_file in db_files { try? file_manager.removeItem(atPath: "\(path)/\(db_file)") @@ -61,24 +61,31 @@ class Ndb { return ndb } - // NostrDB used to be stored on the app container's document directory - static private var old_db_path: String? { + static var db_path: String? { guard let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.absoluteString else { return nil } return remove_file_prefix(path) } - static var db_path: String? { - // Use the `group.com.damus` container, so that it can be accessible from other targets - // e.g. The notification service extension needs to access Ndb data, which is done through this shared file container. + // Shared app group container retained for legacy installs that still host the database there. + static private var legacy_db_path: String? { guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APPLICATION_GROUP_IDENTIFIER) else { return nil } return remove_file_prefix(containerURL.absoluteString) } - static private var db_files: [String] = ["data.mdb", "lock.mdb"] + // DB read-only snapshot in the shared container so that extensions can get access to recent NostrDB data to enhance UX. + static var snapshot_db_path: String? { + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APPLICATION_GROUP_IDENTIFIER) else { + return nil + } + return remove_file_prefix(containerURL.appendingPathComponent("snapshot", conformingTo: .directory).absoluteString) + } + + static private let main_db_file_name: String = "data.mdb" + static private let db_files: [String] = ["data.mdb", "lock.mdb"] static var empty: Ndb { print("txn: NOSTRDB EMPTY") @@ -103,14 +110,18 @@ class Ndb { } } - guard let db_path = Self.db_path, - owns_db_file || Self.db_files_exist(path: db_path) else { - return nil // If the caller claims to not own the DB file, and the DB files do not exist, then we should not initialize Ndb - } - - guard let path = path.map(remove_file_prefix) ?? Ndb.db_path else { + // The path should be, in order of priority: + // 1. The path specified by the caller + // 2. If not specified, use a default path. The default path depends: + // a. If the process owns the db file, `Ndb.db_path` is the default. + // b. If the process does not own the db file, a read-only snapshot file (`Ndb.snapshot_db_path`) is used. + guard let path = path.map(remove_file_prefix) ?? (owns_db_file ? Ndb.db_path : Ndb.snapshot_db_path) else { return nil } + + guard owns_db_file || Self.db_file_exists(path: path) else { + return nil // If the caller claims to not own the DB file, and the DB files do not exist, then we should not initialize Ndb + } let ok = path.withCString { testdir in var ok = false @@ -163,41 +174,95 @@ class Ndb { self.ndbAccessLock.markNdbOpen() } - private static func migrate_db_location_if_needed() throws { - guard let old_db_path, let db_path else { + static func migrate_db_location_if_needed(db_path: String? = nil, legacy_path: String? = nil) throws { + let db_path = db_path ?? Self.db_path + let legacy_path = legacy_path ?? Self.legacy_db_path + guard let db_path, let legacy_path else { throw Errors.cannot_find_db_path } - let file_manager = FileManager.default + // Determine which location holds the freshest database copy and ensure it resides in the private container. + let fileManager = FileManager.default + let private_db_file_exists = Self.db_file_exists(path: db_path) + let legacy_db_file_exists = Self.db_file_exists(path: legacy_path) - let old_db_files_exist = Self.db_files_exist(path: old_db_path) - let new_db_files_exist = Self.db_files_exist(path: db_path) + guard private_db_file_exists || legacy_db_file_exists else { return } - // Migration rules: - // 1. If DB files exist in the old path but not the new one, move files to the new path - // 2. If files do not exist anywhere, do nothing (let new DB be initialized) - // 3. If files exist in the new path, but not the old one, nothing needs to be done - // 4. If files exist on both, do nothing. - // Scenario 4 likely means that user has downgraded and re-upgraded. - // Although it might make sense to get the most recent DB, it might lead to data loss. - // If we leave both intact, it makes it easier to fix later, as no data loss would occur. - if old_db_files_exist && !new_db_files_exist { - Log.info("Migrating NostrDB to new file location…", for: .storage) - do { - try db_files.forEach { db_file in - let old_path = "\(old_db_path)/\(db_file)" - let new_path = "\(db_path)/\(db_file)" - try file_manager.moveItem(atPath: old_path, toPath: new_path) + guard let latest_path = Self.latestDatabasePath(primaryPath: db_path, + legacyPath: legacy_path, + fileManager: fileManager) else { return } + + guard latest_path != db_path else { + // Desired path is already the latest path. No need to migrate. + if legacy_db_file_exists { + // Legacy db file still exists for some reason. To save space, delete this old copy + Log.info("Deleting legacy NostrDB files to save storage space…", for: .storage) + do { + try db_files.forEach { db_file in + let legacyFileURL = URL(fileURLWithPath: "\(legacy_path)/\(db_file)") + if fileManager.fileExists(atPath: legacyFileURL.path) { + try fileManager.removeItem(at: legacyFileURL) + } + } + Log.info("Legacy NostrDB files successfully deleted", for: .storage) + } catch { + Log.error("Failed to delete legacy NostrDB files: %@", for: .storage, String(describing: error)) } - Log.info("NostrDB files successfully migrated to the new location", for: .storage) - } catch { - throw Errors.db_file_migration_error } + return + } + + Log.info("Migrating NostrDB files to the private container…", for: .storage) + do { + try fileManager.createDirectory(atPath: db_path, withIntermediateDirectories: true) + + try db_files.forEach { db_file in + let sourceURL = URL(fileURLWithPath: "\(legacy_path)/\(db_file)") + let destinationURL = URL(fileURLWithPath: "\(db_path)/\(db_file)") + + if db_file != self.main_db_file_name && !fileManager.fileExists(atPath: sourceURL.path) { + // A non-essential db file does not exist at the source, there is nothing to move. Move on to the next file + return + } + + if fileManager.fileExists(atPath: destinationURL.path) { + // Use replaceItemAt for atomic replacement + _ = try fileManager.replaceItemAt(destinationURL, withItemAt: sourceURL, backupItemName: nil, options: [.usingNewMetadataOnly]) + } else { + // If destination doesn't exist, just move it + try fileManager.moveItem(at: sourceURL, to: destinationURL) + } + } + + Log.info("NostrDB files successfully migrated to the private container", for: .storage) + } catch { + Log.error("Failed to migrate NostrDB files: %@", for: .storage, String(describing: error)) + throw Errors.db_file_migration_error } } - private static func db_files_exist(path: String) -> Bool { - return db_files.allSatisfy { FileManager.default.fileExists(atPath: "\(path)/\($0)") } + private static func db_file_exists(path: String) -> Bool { + return FileManager.default.fileExists(atPath: "\(path)/\(Self.main_db_file_name)") + } + + /// Returns the path whose `data.mdb` file was modified most recently. + private static func latestDatabasePath(primaryPath: String, + legacyPath: String?, + fileManager: FileManager) -> String? { + guard let legacyPath else { return primaryPath } + guard let legacyDate = Self.lastModifiedDate(for: legacyPath, fileManager: fileManager) else { return primaryPath } + guard let primaryDate = Self.lastModifiedDate(for: primaryPath, fileManager: fileManager) else { return legacyPath } + return primaryDate > legacyDate ? primaryPath : legacyPath + } + + private static func lastModifiedDate(for path: String, fileManager: FileManager) -> Date? { + let dataFilePath = "\(path)/data.mdb" + guard fileManager.fileExists(atPath: dataFilePath), + let attributes = try? fileManager.attributesOfItem(atPath: dataFilePath), + let modificationDate = attributes[.modificationDate] as? Date else { + return nil + } + return modificationDate } init(ndb: ndb_t) { @@ -242,6 +307,23 @@ class Ndb { return true } + /// Makes a copy of the database in a separate location + /// + /// This uses `mdb_env_copy2` which creates a consistent snapshot without blocking writers for long periods. + func snapshot(path: String) throws { + enum SnapshotError: Error { + case mdbOperationError(errno: Int32) + } + + try withNdb({ + try path.withCString({ pathCString in + let rc = ndb_snapshot(self.ndb.ndb, pathCString, UInt32(0)) + guard rc == 0 else { + throw SnapshotError.mdbOperationError(errno: rc) + } + }) + }) + } // MARK: Thread safety mechanisms // Use these for all externally accessible methods that interact with the nostrdb database to prevent race conditions with app lifecycle events (i.e. NostrDB opening and closing) diff --git a/nostrdb/src/nostrdb.c b/nostrdb/src/nostrdb.c index 0546afa5..ecf84a55 100644 --- a/nostrdb/src/nostrdb.c +++ b/nostrdb/src/nostrdb.c @@ -6058,6 +6058,10 @@ int ndb_init(struct ndb **pndb, const char *filename, const struct ndb_config *c return 1; } +int ndb_snapshot(struct ndb *ndb, const char *path, unsigned int flags) { + return mdb_env_copy2(ndb->lmdb.env, path, flags); +} + void ndb_destroy(struct ndb *ndb) { if (ndb == NULL) diff --git a/nostrdb/src/nostrdb.h b/nostrdb/src/nostrdb.h index c25c0fe6..50c2741c 100644 --- a/nostrdb/src/nostrdb.h +++ b/nostrdb/src/nostrdb.h @@ -502,6 +502,9 @@ int ndb_note_verify(void *secp_ctx, unsigned char *scratch, size_t scratch_size, // NDB int ndb_init(struct ndb **ndb, const char *dbdir, const struct ndb_config *); int ndb_db_version(struct ndb_txn *txn); +/// Takes a snapshot of the NostrDB contents to a separate path +/// See `mdb_env_copy2` header for documentation on `path` and `flags` +int ndb_snapshot(struct ndb *ndb, const char *path, unsigned int flags); // NOTE PROCESSING int ndb_process_event(struct ndb *, const char *json, int len); diff --git a/share extension/ShareViewController.swift b/share extension/ShareViewController.swift index 6b17d894..2b4004c2 100644 --- a/share extension/ShareViewController.swift +++ b/share extension/ShareViewController.swift @@ -249,7 +249,7 @@ struct ShareExtensionView: View { self.share_state = .not_logged_in return false } - state = DamusState(keypair: keypair) + state = DamusState(keypair: keypair, owns_db_file: false) Task { await state?.nostrNetwork.connect() } return true }