From 368f94a209e76b0f6a2ff20706462c5f2b2b9081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 29 Dec 2025 15:43:47 -0800 Subject: [PATCH] Background 0xdead10cc crash fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the background crashes with termination code 0xdead10cc. Those crashes were caused by the fact that NostrDB was being stored on the shared app container (Because our app extensions need NostrDB data), and iOS kills any process that holds a file lock after the process is backgrounded. Other developers in the field have run into similar problems in the past (with shared SQLite databases or shared SwiftData), and they generally recommend not to place those database in shared containers at all, mentioning that 0xdead10cc crashes are almost inevitable otherwise: - https://ryanashcraft.com/sqlite-databases-in-app-group-containers/ - https://inessential.com/2020/02/13/how_we_fixed_the_dreaded_0xdead10cc_cras.html Since iOS aggressively backgrounds and terminates processes with tight timing constraints that are mostly outside our control (despite using Apple's recommended mechanisms, such as requesting more time to perform closing operations), this fix aims to address the issue by a different storage architecture. Instead of keeping NostrDB data on the shared app container and handling the closure/opening of the database with the app lifecycle signals, keep the main NostrDB database file in the app's private container, and instead take periodic read-only snapshots of NostrDB in the shared container, so as to allow extensions to have recent NostrDB data without all the complexities of keeping the main file in the shared container. This does have the tradeoff that more storage will be used by NostrDB due to file duplication, but that can be mitigated via other techniques if necessary. Closes: https://github.com/damus-io/damus/issues/2638 Closes: https://github.com/damus-io/damus/issues/3463 Changelog-Fixed: Fixed background crashes with error code 0xdead10cc Signed-off-by: Daniel D’Aquino --- damus.xcodeproj/project.pbxproj | 28 +- damus/ContentView.swift | 18 +- .../NostrNetworkManager.swift | 7 +- damus/Core/Storage/DamusState.swift | 6 +- .../Storage/DatabaseSnapshotManager.swift | 195 +++++++++++ .../Views/DeveloperSettingsView.swift | 66 ++++ .../Shared/Utilities/OrientationTracker.swift | 0 damus/Shared/Utilities/Router.swift | 2 +- damus/damusApp.swift | 1 + damusTests/DatabaseSnapshotManagerTests.swift | 325 ++++++++++++++++++ damusTests/NdbMigrationTests.swift | 253 ++++++++++++++ .../ActionViewController.swift | 2 +- nostrdb/Ndb.swift | 160 ++++++--- nostrdb/src/nostrdb.c | 4 + nostrdb/src/nostrdb.h | 3 + share extension/ShareViewController.swift | 2 +- 16 files changed, 1010 insertions(+), 62 deletions(-) create mode 100644 damus/Core/Storage/DatabaseSnapshotManager.swift create mode 100644 damus/Shared/Utilities/OrientationTracker.swift create mode 100644 damusTests/DatabaseSnapshotManagerTests.swift create mode 100644 damusTests/NdbMigrationTests.swift 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 }