From e66c0003626f0206c6d35dfc0bf032b53b78847c Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Thu, 11 Apr 2024 11:48:36 -0700 Subject: [PATCH 01/21] removing legacy code, re-adding WebsocketSyncConnection as a singular remaining piece for example --- MeetingNotes.xcodeproj/project.pbxproj | 35 +- MeetingNotes/MeetingNotesApp.swift | 2 + .../WebsocketSyncConnection.swift | 205 +++--- Packages/automerge-repo/.gitignore | 9 - Packages/automerge-repo/.swift-version | 1 - Packages/automerge-repo/Package.swift | 66 -- .../Sources/AutomergeRepo/DocHandle.swift | 11 - .../Sources/AutomergeRepo/DocumentId.swift | 63 -- .../Documentation.docc/Documentation.md | 31 - .../Resources/websocket_stragegy_request.svg | 1 - .../Resources/websocket_strategy_sync.svg | 1 - .../Resources/websocket_sync_states.svg | 1 - .../Resources/wss_closed.svg | 1 - .../Resources/wss_handshake.svg | 1 - .../Resources/wss_initial.svg | 1 - .../Resources/wss_peered.svg | 1 - .../AutomergeRepo/InternalDocHandle.swift | 92 --- .../Networking/NetworkAdapterEvents.swift | 73 --- .../Networking/NetworkProvider.swift | 76 --- .../Networking/NetworkSubsystem.swift | 194 ------ .../BonjourSyncConnection.swift | 383 ----------- .../NWParameters+peerSyncParameters.swift | 74 --- .../P2PAutomergeSyncProtocol.swift | 182 ------ .../PeerNetworking/TXTRecordKeys.swift | 9 - .../Providers/WebSocketProvider.swift | 274 -------- .../Sources/AutomergeRepo/PeerMetadata.swift | 23 - .../Sources/AutomergeRepo/Repo+Errors.swift | 31 - .../Sources/AutomergeRepo/Repo.swift | 604 ------------------ .../Sources/AutomergeRepo/RepoTypes.swift | 24 - .../Sources/AutomergeRepo/SharePolicy.swift | 29 - .../Storage/DocumentStorage.swift | 235 ------- .../Storage/StorageProvider.swift | 18 - .../AutomergeRepo/Sync/CBORCoder.swift | 8 - .../Sync/DocumentSyncCoordinator.swift | 399 ------------ .../AutomergeRepo/Sync/ProtocolState.swift | 42 -- .../AutomergeRepo/Sync/SyncV1Msg+Errors.swift | 36 -- .../Sync/SyncV1Msg+encode+decode.swift | 314 --------- .../Sync/SyncV1Msg+messages.swift | 396 ------------ .../AutomergeRepo/Sync/SyncV1Msg.swift | 118 ---- .../AutomergeRepo/WeakDocumentRef.swift | 13 - .../extensions/Data+hexEncodedString.swift | 15 - .../extensions/OSLog+extensions.swift | 32 - .../extensions/String+hexEncoding.swift | 61 -- .../TimeInterval+milliseconds.swift | 9 - .../extensions/UUID+bs58String.swift | 32 - .../AutomergeRepoTests/BS58IdTests.swift | 44 -- .../AutomergeRepoTests/BaseRepoTests.swift | 138 ---- .../AutomergeRepoTests/CBORExperiments.swift | 57 -- .../AutomergeRepoTests/DocHandleTests.swift | 57 -- .../AutomergeRepoTests/DocumentIdTests.swift | 52 -- .../RepoWebsocketIntegrationTests.swift | 105 --- .../URLSessionWebSocketTask+sendPing.swift | 26 - .../WebSocketSyncIntegrationTests.swift | 112 ---- .../AutomergeRepoTests/RepoHelpers.swift | 30 - .../AutomergeRepoTests/SharePolicyTests.swift | 12 - .../StorageSubsystemTests.swift | 121 ---- .../InMemoryNetwork.swift | 488 -------------- .../TestOutgoingNetworkProvider.swift | 203 ------ .../UnconfiguredTestNetwork.swift | 7 - .../InMemoryStorage.swift | 74 --- .../TwoReposWithNetworkTests.swift | 297 --------- Packages/automerge-repo/collector-config.yaml | 24 - .../automerge-repo/docker-compose-jaeger.yml | 10 - .../docker-compose-zipkin-jaeger.yml | 26 - Packages/automerge-repo/notes.md | 10 - Packages/automerge-repo/notes/.gitignore | 5 - Packages/automerge-repo/notes/README.md | 17 - Packages/automerge-repo/notes/generate.bash | 26 - .../notes/websocket_strategy_request.mmd | 18 - .../notes/websocket_strategy_sync.mmd | 14 - .../notes/websocket_sync_closed.mmd | 11 - .../notes/websocket_sync_handshake.mmd | 11 - .../notes/websocket_sync_initial.mmd | 11 - .../notes/websocket_sync_peered.mmd | 11 - .../notes/websocket_sync_states.mmd | 11 - 75 files changed, 124 insertions(+), 6130 deletions(-) rename {Packages/automerge-repo/Sources/AutomergeRepo/Networking/WebSocketNetworking => MeetingNotes}/WebsocketSyncConnection.swift (73%) delete mode 100644 Packages/automerge-repo/.gitignore delete mode 100644 Packages/automerge-repo/.swift-version delete mode 100644 Packages/automerge-repo/Package.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/DocHandle.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/DocumentId.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Documentation.md delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_stragegy_request.svg delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_strategy_sync.svg delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_sync_states.svg delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_closed.svg delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_handshake.svg delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_initial.svg delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_peered.svg delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/InternalDocHandle.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkAdapterEvents.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkProvider.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkSubsystem.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/BonjourSyncConnection.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/NWParameters+peerSyncParameters.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/P2PAutomergeSyncProtocol.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/TXTRecordKeys.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Networking/Providers/WebSocketProvider.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/PeerMetadata.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Repo+Errors.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Repo.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/RepoTypes.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/SharePolicy.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Storage/DocumentStorage.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Storage/StorageProvider.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Sync/CBORCoder.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Sync/DocumentSyncCoordinator.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Sync/ProtocolState.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+Errors.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+encode+decode.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+messages.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/WeakDocumentRef.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/extensions/Data+hexEncodedString.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/extensions/OSLog+extensions.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/extensions/String+hexEncoding.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/extensions/TimeInterval+milliseconds.swift delete mode 100644 Packages/automerge-repo/Sources/AutomergeRepo/extensions/UUID+bs58String.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/BS58IdTests.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/BaseRepoTests.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/CBORExperiments.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/DocHandleTests.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/DocumentIdTests.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/RepoWebsocketIntegrationTests.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/URLSessionWebSocketTask+sendPing.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/WebSocketSyncIntegrationTests.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/RepoHelpers.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/SharePolicyTests.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/StorageSubsystemTests.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/InMemoryNetwork.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/TestOutgoingNetworkProvider.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/UnconfiguredTestNetwork.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/TestStorageProviders/InMemoryStorage.swift delete mode 100644 Packages/automerge-repo/Tests/AutomergeRepoTests/TwoReposWithNetworkTests.swift delete mode 100644 Packages/automerge-repo/collector-config.yaml delete mode 100644 Packages/automerge-repo/docker-compose-jaeger.yml delete mode 100644 Packages/automerge-repo/docker-compose-zipkin-jaeger.yml delete mode 100644 Packages/automerge-repo/notes.md delete mode 100644 Packages/automerge-repo/notes/.gitignore delete mode 100644 Packages/automerge-repo/notes/README.md delete mode 100755 Packages/automerge-repo/notes/generate.bash delete mode 100644 Packages/automerge-repo/notes/websocket_strategy_request.mmd delete mode 100644 Packages/automerge-repo/notes/websocket_strategy_sync.mmd delete mode 100644 Packages/automerge-repo/notes/websocket_sync_closed.mmd delete mode 100644 Packages/automerge-repo/notes/websocket_sync_handshake.mmd delete mode 100644 Packages/automerge-repo/notes/websocket_sync_initial.mmd delete mode 100644 Packages/automerge-repo/notes/websocket_sync_peered.mmd delete mode 100644 Packages/automerge-repo/notes/websocket_sync_states.mmd diff --git a/MeetingNotes.xcodeproj/project.pbxproj b/MeetingNotes.xcodeproj/project.pbxproj index 0fdb7f83..88a8909d 100644 --- a/MeetingNotes.xcodeproj/project.pbxproj +++ b/MeetingNotes.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ @@ -29,12 +29,13 @@ 1A6FF21D2B64710700C99F81 /* WebSocketStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A6FF21C2B64710700C99F81 /* WebSocketStatusView.swift */; }; 1A7700C52A67343800869A4D /* PeerSyncView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7700C42A67343800869A4D /* PeerSyncView.swift */; }; 1A7700C72A67479F00869A4D /* NWBrowserResultItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7700C62A67479F00869A4D /* NWBrowserResultItemView.swift */; }; - 1A9D231A2B940D23007F3A16 /* AutomergeRepo in Frameworks */ = {isa = PBXBuildFile; productRef = 1A9D23192B940D23007F3A16 /* AutomergeRepo */; }; + 1A90F7F62BC75BEE00E5B3BA /* AutomergeRepo in Frameworks */ = {isa = PBXBuildFile; productRef = 1A90F7F52BC75BEE00E5B3BA /* AutomergeRepo */; }; 1AC103972B7EB0EF0099296C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1AC103962B7EB0EF0099296C /* PrivacyInfo.xcprivacy */; }; 1AD5DA352A4650520085DF79 /* MeetingNotesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD5DA342A4650520085DF79 /* MeetingNotesModel.swift */; }; 1AD71E8E2A57622B00B965BF /* MeetingNotesDocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71E8D2A57622B00B965BF /* MeetingNotesDocumentView.swift */; }; 1AD71E912A57630B00B965BF /* MergeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71E902A57630B00B965BF /* MergeView.swift */; }; 1AD71E932A5765A800B965BF /* SyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71E922A5765A800B965BF /* SyncStatusView.swift */; }; + 1ADDFBD92BC865900051195D /* WebsocketSyncConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */; }; 1AF4DDDA2B7C57E800B23BF8 /* ExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF4DDD92B7C57E800B23BF8 /* ExportView.swift */; }; /* End PBXBuildFile section */ @@ -70,7 +71,6 @@ 1A0DDC532A464DEB001ECADD /* MeetingNotesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesUITests.swift; sourceTree = ""; }; 1A0DDC552A464DEB001ECADD /* MeetingNotesUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesUITestsLaunchTests.swift; sourceTree = ""; }; 1A0DDC622A464E2D001ECADD /* MeetingNotes.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MeetingNotes.xctestplan; sourceTree = ""; }; - 1A273DC02B93EBD000B321C5 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Packages; sourceTree = ""; }; 1A273DD62B93F64500B321C5 /* Logger+extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+extensions.swift"; sourceTree = ""; }; 1A2A02A42A50E74A0044064B /* EditableAgendaItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableAgendaItemView.swift; sourceTree = ""; }; 1A2AD0302A7437E200EF0C5F /* SyncConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncConnectionView.swift; sourceTree = ""; }; @@ -82,6 +82,7 @@ 1AD71E8D2A57622B00B965BF /* MeetingNotesDocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesDocumentView.swift; sourceTree = ""; }; 1AD71E902A57630B00B965BF /* MergeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MergeView.swift; sourceTree = ""; }; 1AD71E922A5765A800B965BF /* SyncStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusView.swift; sourceTree = ""; }; + 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebsocketSyncConnection.swift; sourceTree = ""; }; 1AF4DDD92B7C57E800B23BF8 /* ExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportView.swift; sourceTree = ""; }; 1AF5DB3A2A4A0C38008DAC6F /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 1AF5DB3B2A4A0C5E008DAC6F /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; @@ -92,6 +93,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1A90F7F62BC75BEE00E5B3BA /* AutomergeRepo in Frameworks */, 1A273DCC2B93EEA600B321C5 /* Base58Swift in Frameworks */, 1A273DCF2B93EEBB00B321C5 /* Automerge in Frameworks */, 1A273DC92B93EE9300B321C5 /* PotentCodables in Frameworks */, @@ -104,7 +106,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1A9D231A2B940D23007F3A16 /* AutomergeRepo in Frameworks */, 1A273DD32B93EEEC00B321C5 /* Base58Swift in Frameworks */, 1A273DD12B93EEE200B321C5 /* PotentCodables in Frameworks */, 1A273DD52B93EEF700B321C5 /* Automerge in Frameworks */, @@ -128,7 +129,6 @@ 1AF5DB3A2A4A0C38008DAC6F /* README.md */, 1AF5DB3B2A4A0C5E008DAC6F /* CONTRIBUTING.md */, 1A0DDC622A464E2D001ECADD /* MeetingNotes.xctestplan */, - 1A273DC02B93EBD000B321C5 /* Packages */, 1A0DDC332A464DEA001ECADD /* MeetingNotes */, 1A0DDC482A464DEB001ECADD /* MeetingNotesTests */, 1A0DDC522A464DEB001ECADD /* MeetingNotesUITests */, @@ -151,6 +151,7 @@ isa = PBXGroup; children = ( 1A0DDC342A464DEA001ECADD /* MeetingNotesApp.swift */, + 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */, 1AB369012A50D82C00F855F8 /* Views */, 1A0DDC362A464DEA001ECADD /* MeetingNotesDocument.swift */, 1AD5DA342A4650520085DF79 /* MeetingNotesModel.swift */, @@ -234,6 +235,7 @@ 1A273DC82B93EE9300B321C5 /* PotentCodables */, 1A273DCB2B93EEA600B321C5 /* Base58Swift */, 1A273DCE2B93EEBB00B321C5 /* Automerge */, + 1A90F7F52BC75BEE00E5B3BA /* AutomergeRepo */, ); productName = MeetingNotes; productReference = 1A0DDC312A464DEA001ECADD /* MeetingNotes.app */; @@ -257,7 +259,6 @@ 1A273DD02B93EEE200B321C5 /* PotentCodables */, 1A273DD22B93EEEC00B321C5 /* Base58Swift */, 1A273DD42B93EEF700B321C5 /* Automerge */, - 1A9D23192B940D23007F3A16 /* AutomergeRepo */, ); productName = MeetingNotesTests; productReference = 1A0DDC452A464DEB001ECADD /* MeetingNotesTests.xctest */; @@ -314,10 +315,10 @@ ); mainGroup = 1A0DDC282A464DEA001ECADD; packageReferences = ( - 1A273DC42B93ECF400B321C5 /* XCLocalSwiftPackageReference "Packages/automerge-repo" */, 1A273DC72B93EE9300B321C5 /* XCRemoteSwiftPackageReference "PotentCodables" */, 1A273DCA2B93EEA600B321C5 /* XCRemoteSwiftPackageReference "Base58Swift" */, 1A273DCD2B93EEBA00B321C5 /* XCRemoteSwiftPackageReference "automerge-swift" */, + 1A90F7F42BC75BEE00E5B3BA /* XCRemoteSwiftPackageReference "automerge-repo-swift" */, ); productRefGroup = 1A0DDC322A464DEA001ECADD /* Products */; projectDirPath = ""; @@ -372,6 +373,7 @@ 1A0DDC352A464DEA001ECADD /* MeetingNotesApp.swift in Sources */, 1AD71E8E2A57622B00B965BF /* MeetingNotesDocumentView.swift in Sources */, 1A273DD72B93F64500B321C5 /* Logger+extensions.swift in Sources */, + 1ADDFBD92BC865900051195D /* WebsocketSyncConnection.swift in Sources */, 1A7700C72A67479F00869A4D /* NWBrowserResultItemView.swift in Sources */, 1AD71E932A5765A800B965BF /* SyncStatusView.swift in Sources */, 1A7700C52A67343800869A4D /* PeerSyncView.swift in Sources */, @@ -768,13 +770,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - 1A273DC42B93ECF400B321C5 /* XCLocalSwiftPackageReference "Packages/automerge-repo" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = "Packages/automerge-repo"; - }; -/* End XCLocalSwiftPackageReference section */ - /* Begin XCRemoteSwiftPackageReference section */ 1A273DC72B93EE9300B321C5 /* XCRemoteSwiftPackageReference "PotentCodables" */ = { isa = XCRemoteSwiftPackageReference; @@ -800,6 +795,14 @@ minimumVersion = 0.5.8; }; }; + 1A90F7F42BC75BEE00E5B3BA /* XCRemoteSwiftPackageReference "automerge-repo-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/automerge/automerge-repo-swift"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -841,9 +844,9 @@ package = 1A273DCD2B93EEBA00B321C5 /* XCRemoteSwiftPackageReference "automerge-swift" */; productName = Automerge; }; - 1A9D23192B940D23007F3A16 /* AutomergeRepo */ = { + 1A90F7F52BC75BEE00E5B3BA /* AutomergeRepo */ = { isa = XCSwiftPackageProductDependency; - package = 1A273DC42B93ECF400B321C5 /* XCLocalSwiftPackageReference "Packages/automerge-repo" */; + package = 1A90F7F42BC75BEE00E5B3BA /* XCRemoteSwiftPackageReference "automerge-repo-swift" */; productName = AutomergeRepo; }; /* End XCSwiftPackageProductDependency section */ diff --git a/MeetingNotes/MeetingNotesApp.swift b/MeetingNotes/MeetingNotesApp.swift index 3050ab32..d0cf86fa 100644 --- a/MeetingNotes/MeetingNotesApp.swift +++ b/MeetingNotes/MeetingNotesApp.swift @@ -1,6 +1,8 @@ import AutomergeRepo import SwiftUI +public let repo = Repo(sharePolicy: SharePolicies.agreeable) + /// The document-based Meeting Notes application. @main struct MeetingNotesApp: App { diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/WebSocketNetworking/WebsocketSyncConnection.swift b/MeetingNotes/WebsocketSyncConnection.swift similarity index 73% rename from Packages/automerge-repo/Sources/AutomergeRepo/Networking/WebSocketNetworking/WebsocketSyncConnection.swift rename to MeetingNotes/WebsocketSyncConnection.swift index b8fded2b..ef060c28 100644 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/WebSocketNetworking/WebsocketSyncConnection.swift +++ b/MeetingNotes/WebsocketSyncConnection.swift @@ -1,12 +1,21 @@ -import Automerge -import Combine -import Foundation -import OSLog -import PotentCBOR + import Automerge +import AutomergeRepo + import Combine + import Foundation + import OSLog + import PotentCBOR + +extension Logger { + /// Using your bundle identifier is a great way to ensure a unique identifier. + private nonisolated static let subsystem = Bundle.main.bundleIdentifier! + + /// Logs the Document interactions, such as saving and loading. + static let legacyWebSocket = Logger(subsystem: subsystem, category: "WebSocket") +} /// A class that provides a WebSocket connection to sync an Automerge document. -@MainActor -public final class WebsocketSyncConnection: ObservableObject, Identifiable { + @MainActor + public final class WebsocketSyncConnection: ObservableObject, Identifiable { private var webSocketTask: URLSessionWebSocketTask? /// This connections "peer identifier" private let senderId: String @@ -22,8 +31,8 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { /// A handle on an unstructured task that accepts and processes WebSocket messages private var receiveHandler: Task? - /// A handle to a cancellable Combine pipeline that watches a document for updates and attempts to start a sync when - /// it changes. + /// A handle to a cancellable Combine pipeline that watches a document for updates and attempts to start a sync + /// when it changes. private var syncTrigger: (any Cancellable)? // TODO: Add a delegate link of some form for a 'ephemeral' msg data handler @@ -42,14 +51,14 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { senderId = UUID().uuidString self.document = document self.documentId = documentId - self.syncInProgress = false + syncInProgress = false } // having register after initialization lets us add within a SwiftUI view, and then // configure and activate things onAppear within the view... public func registerDocument(_ document: Automerge.Document, id: DocumentId) { self.document = document - self.documentId = id + documentId = id } // MARK: static initializers @@ -90,9 +99,8 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { while websocketconnection.syncInProgress { try Task.checkCancellation() - Logger.webSocket - .trace( - "sync in progress, !cancelled - state is: \(websocketconnection.protocolState.rawValue, privacy: .public)" + Logger.legacyWebSocket + .trace("sync in progress, !cancelled - state is: \(websocketconnection.protocolState.rawValue, privacy: .public)" ) // Race a timeout against receiving a Peer message from the other side // of the WebSocket connection. If we fail that race, shut down the connection @@ -129,7 +137,7 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { try Task.checkCancellation() // check the invariants - guard let webSocketTask = self.webSocketTask + guard let webSocketTask else { throw SyncV1Msg.Errors .ConnectionClosed(errorDescription: "Attempting to wait for a websocket message when the task is nil") @@ -173,9 +181,8 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { } else { // In the handshake phase and received anything other than a valid peer message let decodeAttempted = SyncV1Msg.decode(raw_data) - Logger.webSocket - .warning( - "Decoding websocket message, expecting peer only - and it wasn't a peer message. RECEIVED MSG: \(decodeAttempted.debugDescription)" + Logger.legacyWebSocket + .warning("Decoding websocket message, expecting peer only - and it wasn't a peer message. RECEIVED MSG: \(decodeAttempted.debugDescription)" ) throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodeAttempted) } @@ -189,12 +196,12 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { case let .string(string): // In the handshake phase and received anything other than a valid peer message - Logger.webSocket + Logger.legacyWebSocket .warning("Unknown websocket message received: .string(\(string))") throw SyncV1Msg.Errors.UnexpectedMsg(msg: msg) @unknown default: // In the handshake phase and received anything other than a valid peer message - Logger.webSocket + Logger.legacyWebSocket .error("Unknown websocket message received: \(String(describing: msg))") throw SyncV1Msg.Errors.UnexpectedMsg(msg: msg) } @@ -209,16 +216,16 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { guard protocolState == .setup || protocolState == .closed else { return } - guard self.document != nil, self.documentId != nil else { + guard document != nil, documentId != nil else { #if DEBUG fatalError("Attempting to join a connection without a document registered") #else - Logger.webSocket.error("Attempting to join a connection without a document registered") + Logger.legacyWebSocket.error("Attempting to join a connection without a document registered") return #endif } guard let url = URL(string: destination) else { - Logger.webSocket.error("Destination provided is not a valid URL") + Logger.legacyWebSocket.error("Destination provided is not a valid URL") throw SyncV1Msg.Errors.InvalidURL(urlString: destination) } @@ -237,7 +244,7 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { #endif } - Logger.webSocket.trace("Activating websocket to \(url, privacy: .public)") + Logger.legacyWebSocket.trace("Activating websocket to \(url, privacy: .public)") // start the websocket processing things webSocketTask.resume() @@ -246,7 +253,7 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { let joinMessage = SyncV1Msg.JoinMsg(senderId: senderId) let data = try SyncV1Msg.encode(joinMessage) try await webSocketTask.send(.data(data)) - Logger.webSocket.trace("SEND: \(joinMessage.debugDescription)") + Logger.legacyWebSocket.trace("SEND: \(joinMessage.debugDescription)") await MainActor.run { self.protocolState = .preparing } @@ -255,7 +262,7 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { // Race a timeout against receiving a Peer message from the other side // of the WebSocket connection. If we fail that race, shut down the connection // and move into a .closed connectionState - let websocketMsg = try await self.nextMessage(withTimeout: .seconds(3.5)) + let websocketMsg = try await nextMessage(withTimeout: .seconds(3.5)) // Now that we have the WebSocket message, figure out if we got what we expected. // For the sync protocol handshake phase, it's essentially "peer or die" since @@ -264,7 +271,7 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { throw SyncV1Msg.Errors.UnexpectedMsg(msg: websocketMsg) } - Logger.webSocket.trace("Peered to targetId: \(peerMsg.senderId) \(peerMsg.debugDescription)") + Logger.legacyWebSocket.trace("Peered to targetId: \(peerMsg.senderId) \(peerMsg.debugDescription)") // TODO: handle the gossip setup - read and process the peer metadata await MainActor.run { self.targetId = peerMsg.senderId @@ -272,17 +279,17 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { } } catch { // if there's an error, disconnect anything that's lingering and cancel it down. - await self.disconnect() + await disconnect() throw error } - assert(self.protocolState == .ready) + assert(protocolState == .ready) } /// Asynchronously disconnect the WebSocket and shut down active sessions. public func disconnect() async { - self.syncTrigger?.cancel() - self.webSocketTask?.cancel(with: .normalClosure, reason: nil) - self.receiveHandler?.cancel() + syncTrigger?.cancel() + webSocketTask?.cancel(with: .normalClosure, reason: nil) + receiveHandler?.cancel() await MainActor.run { self.syncTrigger = nil self.protocolState = .closed @@ -298,12 +305,12 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { // verify we're in the right state before invoking the recursive (async) handler setup // and start the process of synchronizing the document. - if self.protocolState == .ready { + if protocolState == .ready { // NOTE: this is technically a race between do we accept a message and do something // with it (possibly changing state), or do we initiate a sync ourselves. In practice // against Automerge-repo code, it doesn't proactively ask us to do anything, playing // a more reactive role, but it's worth being away its a possibility. - self.receiveHandler = Task { + receiveHandler = Task { try await ongoingHandleWebSocketMessage() } @@ -312,7 +319,7 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { // Watch the Automerge document for update messages, triggering a sync // if one isn't already in flight. - self.syncTrigger = self.document?.objectWillChange.sink { + syncTrigger = document?.objectWillChange.sink { if !self.syncInProgress { Task { [weak self] in await self?.initiateSync() @@ -325,13 +332,13 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { public func sendRequestForDocument() async throws { // verify we're already connected and peered guard protocolState == .ready, - let document = self.document, - let documentId = self.documentId, - let targetId = self.targetId, - let webSocketTask = self.webSocketTask, - let syncData = document.generateSyncMessage(state: self.syncState) + let document, + let documentId, + let targetId, + let webSocketTask, + let syncData = document.generateSyncMessage(state: syncState) else { - Logger.webSocket.warning("Attempting to join a connection without a document identifier registered") + Logger.legacyWebSocket.warning("Attempting to join a connection without a document identifier registered") return } assert( @@ -344,13 +351,13 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { } let requestMsg = SyncV1Msg.RequestMsg( documentId: documentId.description, - senderId: self.senderId, + senderId: senderId, targetId: targetId, sync_message: syncData ) let data = try SyncV1Msg.encode(requestMsg) try await webSocketTask.send(.data(data)) - Logger.webSocket.trace("SEND: \(requestMsg.debugDescription)") + Logger.legacyWebSocket.trace("SEND: \(requestMsg.debugDescription)") } /// Start a synchronization process for the Automerge document @@ -360,12 +367,12 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { else { return } - guard let document = self.document, - let documentId = self.documentId, - let targetId = self.targetId, - let webSocketTask = self.webSocketTask + guard let document, + let documentId, + let targetId, + let webSocketTask else { - Logger.webSocket.warning("Attempting to join a connection without a document identifier registered") + Logger.legacyWebSocket.warning("Attempting to join a connection without a document identifier registered") return } assert( @@ -374,14 +381,14 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { .documentId != nil ) - if let syncData = document.generateSyncMessage(state: self.syncState) { + if let syncData = document.generateSyncMessage(state: syncState) { await MainActor.run { self.protocolState = .ready self.syncInProgress = true } let syncMsg = SyncV1Msg.SyncMsg( documentId: documentId.description, - senderId: self.senderId, + senderId: senderId, targetId: targetId, sync_message: syncData ) @@ -389,7 +396,7 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { do { data = try SyncV1Msg.encode(syncMsg) } catch { - Logger.webSocket.warning("Error encoding data: \(error.localizedDescription, privacy: .public)") + Logger.legacyWebSocket.warning("Error encoding data: \(error.localizedDescription, privacy: .public)") } do { @@ -397,11 +404,11 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { return } try await webSocketTask.send(.data(data)) - Logger.webSocket.trace("SEND: \(syncMsg.debugDescription)") + Logger.legacyWebSocket.trace("SEND: \(syncMsg.debugDescription)") } catch { - Logger.webSocket + Logger.legacyWebSocket .warning("Error in sending websocket data: \(error.localizedDescription, privacy: .public)") - await self.disconnect() + await disconnect() } } } @@ -421,24 +428,22 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { /// received. private func ongoingHandleWebSocketMessage() async throws { while true { - guard let webSocketTask = self.webSocketTask else { - Logger.webSocket.warning("Receive Handler: webSocketTask is nil, terminating handler loop") + guard let webSocketTask else { + Logger.legacyWebSocket.warning("Receive Handler: webSocketTask is nil, terminating handler loop") break } try Task.checkCancellation() - Logger.webSocket - .trace( - "Receive Handler: Task not cancelled, awaiting next message, state is \(self.protocolState.rawValue, privacy: .public)" - ) + Logger.legacyWebSocket + .trace("Receive Handler: Task not cancelled, awaiting next message, state is \(self.protocolState.rawValue, privacy: .public)") let webSocketMessage = try await webSocketTask.receive() do { let msg = try attemptToDecode(webSocketMessage) - await self.handleReceivedMessage(msg: msg) + await handleReceivedMessage(msg: msg) } catch { - await self.disconnect() + await disconnect() } } } @@ -452,7 +457,7 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { private func handleReceivedMessage(msg: SyncV1Msg) async { switch protocolState { case .setup: - Logger.webSocket.warning("RCVD: \(msg.debugDescription, privacy: .public) while in NEW state") + Logger.legacyWebSocket.warning("RCVD: \(msg.debugDescription, privacy: .public) while in NEW state") case .preparing: if case let .peer(peerMsg) = msg { await MainActor.run { @@ -462,111 +467,107 @@ public final class WebsocketSyncConnection: ObservableObject, Identifiable { // TODO: handle the gossip setup - read and process the peer metadata } else { // In the handshake phase and received anything other than a valid peer message - Logger.webSocket - .warning( - "FAILED TO PEER - RECEIVED MSG: \(msg.debugDescription, privacy: .public), shutting down WebSocket" + Logger.legacyWebSocket + .warning("FAILED TO PEER - RECEIVED MSG: \(msg.debugDescription, privacy: .public), shutting down WebSocket" ) - await self.disconnect() + await disconnect() } case .ready: switch msg { case let .error(errorMsg): - Logger.webSocket.warning("RCVD ERROR: \(errorMsg.debugDescription)") + Logger.legacyWebSocket.warning("RCVD ERROR: \(errorMsg.debugDescription)") case let .sync(syncMsg): - guard let document = self.document, - let documentId = self.documentId, - let targetId = self.targetId, - let webSocketTask = self.webSocketTask + guard let document, + let documentId, + let targetId, + let webSocketTask else { return } - guard self.senderId == syncMsg.targetId, + guard senderId == syncMsg.targetId, documentId.description == syncMsg.documentId else { - Logger.webSocket - .warning( - "Sync message target and document Id don't match expected values. Received: \(syncMsg.debugDescription), targetId expected: \(self.senderId), documentId expected: \(documentId.description)" + Logger.legacyWebSocket + .warning("Sync message target and document Id don't match expected values. Received: \(syncMsg.debugDescription), targetId expected: \(self.senderId), documentId expected: \(documentId.description)" ) return } do { - Logger.webSocket.trace("RCVD: Applying sync message: \(syncMsg.debugDescription)") - try document.receiveSyncMessage(state: self.syncState, message: syncMsg.data) + Logger.legacyWebSocket.trace("RCVD: Applying sync message: \(syncMsg.debugDescription)") + try document.receiveSyncMessage(state: syncState, message: syncMsg.data) // TODO: enable gossip of sending changed heads (if in gossip mode) - if let syncData = document.generateSyncMessage(state: self.syncState) { + if let syncData = document.generateSyncMessage(state: syncState) { // if we have a sync message, then sync isn't complete... // verify the state is set correctly, update it if not - if self.syncInProgress != true { + if syncInProgress != true { await MainActor.run { self.syncInProgress = true } } let replyingSyncMsg = SyncV1Msg.SyncMsg( documentId: documentId.description, - senderId: self.senderId, + senderId: senderId, targetId: targetId, sync_message: syncData ) - Logger.webSocket + Logger.legacyWebSocket .trace(" - SYNC: Sending another sync msg after applying updates") let replyData = try SyncV1Msg.encode(replyingSyncMsg) try await webSocketTask.send(.data(replyData)) - Logger.webSocket.trace("SEND: \(replyingSyncMsg.debugDescription)") + Logger.legacyWebSocket.trace("SEND: \(replyingSyncMsg.debugDescription)") } else { await MainActor.run { self.syncInProgress = false } - Logger.webSocket.trace(" - SYNC: No further sync msgs needed - sync complete.") + Logger.legacyWebSocket.trace(" - SYNC: No further sync msgs needed - sync complete.") } } catch { - Logger.webSocket - .error( - "Error while applying sync message \(error.localizedDescription, privacy: .public), DISCONNECTING!" - ) - Logger.webSocket.error("sync data raw bytes: \(syncMsg.data.hexEncodedString(), privacy: .public)") - await self.disconnect() + Logger.legacyWebSocket + .error("Error while applying sync message \(error.localizedDescription, privacy: .public), DISCONNECTING!") + Logger.legacyWebSocket.error("sync data raw bytes: \(syncMsg.data.hexEncodedString(), privacy: .public)") + await disconnect() } case let .ephemeral(msg): - Logger.webSocket.trace("RCVD: Ephemeral message: \(msg.debugDescription, privacy: .public).") + Logger.legacyWebSocket.trace("RCVD: Ephemeral message: \(msg.debugDescription, privacy: .public).") // TODO: enable a callback or something to allow someone external to handle the ephemeral messages case let .remoteHeadsChanged(msg): - Logger.webSocket + Logger.legacyWebSocket .trace("RCVD: remote head's changed message: \(msg.debugDescription, privacy: .public).") // TODO: enable gossiping responses case let .unavailable(inside_msg): - Logger.webSocket.trace("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") + Logger.legacyWebSocket.trace("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") // Messages that are technically allowed, but not common in the "ready" state unless // you're "serving up multiple documents" (this implementation links to a single Automerge // document. case let .request(inside_msg): - Logger.webSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") + Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") case let .remoteSubscriptionChange(inside_msg): - Logger.webSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") + Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") case let .leave(inside_msg): - Logger.webSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") + Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") // Messages that are always unexpected while in the "ready" state case let .peer(inside_msg): - Logger.webSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") + Logger.legacyWebSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") case let .join(inside_msg): - Logger.webSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") + Logger.legacyWebSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") case let .unknown(inside_msg): - Logger.webSocket.warning("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") + Logger.legacyWebSocket.warning("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") } case .closed: - Logger.webSocket.warning("RCVD: \(msg.debugDescription, privacy: .public), disconnecting (again?)") + Logger.legacyWebSocket.warning("RCVD: \(msg.debugDescription, privacy: .public), disconnecting (again?)") // cleanup - we shouldn't ever be here, but just in case... - await self.disconnect() + await disconnect() } } -} + } diff --git a/Packages/automerge-repo/.gitignore b/Packages/automerge-repo/.gitignore deleted file mode 100644 index 81f2a9ff..00000000 --- a/Packages/automerge-repo/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -.vscode/ -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Packages/automerge-repo/.swift-version b/Packages/automerge-repo/.swift-version deleted file mode 100644 index 95ee81a4..00000000 --- a/Packages/automerge-repo/.swift-version +++ /dev/null @@ -1 +0,0 @@ -5.9 diff --git a/Packages/automerge-repo/Package.swift b/Packages/automerge-repo/Package.swift deleted file mode 100644 index 9ce35539..00000000 --- a/Packages/automerge-repo/Package.swift +++ /dev/null @@ -1,66 +0,0 @@ -// swift-tools-version: 5.9 - -import Foundation -import PackageDescription - -var globalSwiftSettings: [PackageDescription.SwiftSetting] = [] - -if ProcessInfo.processInfo.environment["LOCAL_BUILD"] != nil { - globalSwiftSettings.append(.enableExperimentalFeature("StrictConcurrency")) -} - -let package = Package( - name: "automerge-repo", - platforms: [.iOS(.v16), .macOS(.v13)], - products: [ - .library( - name: "AutomergeRepo", - targets: ["AutomergeRepo"] - ), - ], - dependencies: [ - .package(url: "https://github.com/automerge/automerge-swift", .upToNextMajor(from: "0.5.7")), - .package(url: "https://github.com/outfoxx/PotentCodables", .upToNextMajor(from: "3.1.0")), - .package(url: "https://github.com/keefertaylor/Base58Swift", .upToNextMajor(from: "2.1.14")), - // Combine replacement for OSS - // .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), - // Distributed Tracing - .package(url: "https://github.com/apple/swift-distributed-tracing", from: "1.0.0"), - // Testing Tracing - .package(url: "https://github.com/heckj/DistributedTracer", branch: "main"), - // this ^^ brings in a MASSIVE cascade of dependencies - ], - targets: [ - .target( - name: "AutomergeRepo", - dependencies: [ - .product(name: "Automerge", package: "automerge-swift"), - // CBOR encoding and decoding - .product(name: "PotentCodables", package: "PotentCodables"), - // BS58 representations of data - .product(name: "Base58Swift", package: "Base58Swift"), - - // Combine replacement for OSS - // .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), - - // Distributed Tracing - .product(name: "Tracing", package: "swift-distributed-tracing"), - ], - // borrowing a set of Swift6 enabling features to double-check against - // future proofing concurrency, safety, and exportable feature-creep. - swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency"), - .enableUpcomingFeature("ExistentialAny"), - .enableExperimentalFeature("AccessLevelOnImport"), - .enableUpcomingFeature("InternalImportsByDefault"), - ] - ), - .testTarget( - name: "AutomergeRepoTests", - dependencies: [ - "AutomergeRepo", - .product(name: "DistributedTracer", package: "DistributedTracer"), - ] - ), - ] -) diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/DocHandle.swift b/Packages/automerge-repo/Sources/AutomergeRepo/DocHandle.swift deleted file mode 100644 index 807cce96..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/DocHandle.swift +++ /dev/null @@ -1,11 +0,0 @@ -import class Automerge.Document - -public struct DocHandle: Sendable { - public let id: DocumentId - public let doc: Document - - init(id: DocumentId, doc: Document) { - self.id = id - self.doc = doc - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/DocumentId.swift b/Packages/automerge-repo/Sources/AutomergeRepo/DocumentId.swift deleted file mode 100644 index 9a262b97..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/DocumentId.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Base58Swift -import struct Foundation.Data -import struct Foundation.UUID - -/// A type that represents an Automerge-repo compatible document identifier -public struct DocumentId: Sendable, Hashable, Comparable, Identifiable { - /// A bs58 encoded string that represents the identifier - public let id: String - // Data? - // [UInt8] - - /// Creates a new, random document identifier. - public init() { - id = UUID().bs58String - } - - /// Creates a document identifier from a UUID v4 - /// - Parameter id: the v4 UUID to use as a document identifier. - public init(_ id: UUID) { - self.id = id.bs58String - } - - /// Creates a document identifier from an optional string. - /// - Parameter id: The string to use as a document identifier. - public init?(_ id: String?) { - guard let id else { - return nil - } - guard let uint_array = Base58.base58CheckDecode(id) else { - return nil - } - if uint_array.count != 16 { - return nil - } - self.id = id - } - - /// Creates a document identifier from a string. - /// - Parameter id: The string to use as a document identifier. - public init?(_ id: String) { - guard let uint_array = Base58.base58CheckDecode(id) else { - return nil - } - if uint_array.count != 16 { - return nil - } - self.id = id - } - - // Comparable conformance - public static func < (lhs: DocumentId, rhs: DocumentId) -> Bool { - lhs.id < rhs.id - } -} - -extension DocumentId: Codable {} - -extension DocumentId: CustomStringConvertible { - /// The string representation of the Document identifier - public var description: String { - id - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Documentation.md b/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Documentation.md deleted file mode 100644 index d76e6abe..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Documentation.md +++ /dev/null @@ -1,31 +0,0 @@ -# ``AutomergeRepo`` - -A summary of automerge-repo - the key of what you do with it - -## Overview - -introduction to automerge repo and what it provides, what problem it solves - -## Topics - -### Managing a collection of Automerge documents - -- ``AutomergeRepo/DocumentSyncCoordinator`` -- ``AutomergeRepo/DocumentId`` - -### Network Adapters - -- ``AutomergeRepo/NetworkSyncProvider`` -- ``AutomergeRepo/BonjourSyncConnection`` -- ``AutomergeRepo/WebsocketSyncConnection`` - -- ``AutomergeRepo/NetworkSubsystem`` -- ``AutomergeRepo/NetworkAdapterEvents`` -- ``AutomergeRepo/SyncUserDefaultsKeys`` -- ``AutomergeRepo/SynchronizerDefaultKeys -- ``AutomergeRepo/SyncV1`` - -### Storage Adapters - -- ``AutomergeRepo/StorageProvider`` -- ``AutomergeRepo/StorageSubsystem`` diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_stragegy_request.svg b/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_stragegy_request.svg deleted file mode 100644 index bcf085ce..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_stragegy_request.svg +++ /dev/null @@ -1 +0,0 @@ -remotelocalremotelocalstate = "new" or "closed"state = "handshake"critical[handshaking phase]state = "peered"alt[: if unavailable]joinpeerrequestunavailablesync (if needed) \ No newline at end of file diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_strategy_sync.svg b/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_strategy_sync.svg deleted file mode 100644 index cc42cf3f..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_strategy_sync.svg +++ /dev/null @@ -1 +0,0 @@ -remotelocalremotelocalstate = "new" or "closed"state = "handshake"critical[handshaking phase]state = "peered"joinpeersyncsync (if needed) \ No newline at end of file diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_sync_states.svg b/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_sync_states.svg deleted file mode 100644 index 7f9e6c16..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/websocket_sync_states.svg +++ /dev/null @@ -1 +0,0 @@ -
WebsocketSyncConnection.init()
registerDocument()
await connect()
connect timeout expired
connection failed
websocket peer response
await disconnect()
websocket error
await connect()
new
handshake
closed
peered
WebSocket Sync Protocol
\ No newline at end of file diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_closed.svg b/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_closed.svg deleted file mode 100644 index 416db124..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_closed.svg +++ /dev/null @@ -1 +0,0 @@ -
new
handshake
closed
peered
\ No newline at end of file diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_handshake.svg b/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_handshake.svg deleted file mode 100644 index f1745df1..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_handshake.svg +++ /dev/null @@ -1 +0,0 @@ -
new
handshake
closed
peered
\ No newline at end of file diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_initial.svg b/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_initial.svg deleted file mode 100644 index 2a15058d..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_initial.svg +++ /dev/null @@ -1 +0,0 @@ -
new
handshake
closed
peered
\ No newline at end of file diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_peered.svg b/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_peered.svg deleted file mode 100644 index 034ba72b..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Documentation.docc/Resources/wss_peered.svg +++ /dev/null @@ -1 +0,0 @@ -
new
handshake
closed
peered
\ No newline at end of file diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/InternalDocHandle.swift b/Packages/automerge-repo/Sources/AutomergeRepo/InternalDocHandle.swift deleted file mode 100644 index 29153bdc..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/InternalDocHandle.swift +++ /dev/null @@ -1,92 +0,0 @@ -import struct Automerge.ChangeHash -import class Automerge.Document -import struct Automerge.SyncState -import struct Foundation.Data - -final class InternalDocHandle { - enum DocHandleState { - case idle - case loading - case requesting - case ready - case unavailable - case deleted - } - - // NOTE: heckj - what I was originally researching how this all goes together, I - // wondered if there wasn't the concept of unloading/reloading the bytes from memory and - // onto disk when there was a storage system available - in that case, we'd need a few - // more states to this diagram (originally from `automerge-repo`) - one for 'purged' and - // an associated action PURGE - the idea being that might be invoked when an app is coming - // under memory pressure. - // - // The state itself is driven from Repo, in the `resolveDocHandle(id:)` method - - /** - * Internally we use a state machine to orchestrate document loading and/or syncing, in order to - * avoid requesting data we already have, or surfacing intermediate values to the consumer. - * - * ┌─────────────────────┬─────────TIMEOUT────►┌─────────────┐ - * ┌───┴─────┐ ┌───┴────────┐ │ unavailable │ - * ┌───────┐ ┌──FIND──┤ loading ├─REQUEST──►│ requesting ├─UPDATE──┐ └─────────────┘ - * │ idle ├──┤ └───┬─────┘ └────────────┘ │ - * └───────┘ │ │ └─►┌────────┐ - * │ └───────LOAD───────────────────────────────►│ ready │ - * └──CREATE───────────────────────────────────────────────►└────────┘ - */ - - let id: DocumentId - var doc: Automerge.Document? - var state: DocHandleState - var remoteHeads: [STORAGE_ID: Set] - var syncStates: [PEER_ID: SyncState] - - // TODO: verify that we want a timeout delay per Document, as opposed to per-Repo - var timeoutDelay: Double - - init(id: DocumentId, isNew: Bool, initialValue: Automerge.Document? = nil, timeoutDelay: Double = 1.0) { - self.id = id - self.timeoutDelay = timeoutDelay - remoteHeads = [:] - syncStates = [:] - // isNew is when we're creating content and it needs to get stored locally in a storage - // provider, if available. - if isNew { - if let newDoc = initialValue { - self.doc = newDoc - self.state = .loading - } else { - self.doc = nil - self.state = .idle - } - } else if let newDoc = initialValue { - self.doc = newDoc - self.state = .ready - } else { - self.doc = nil - self.state = .idle - } - } - - var isReady: Bool { - self.state == .ready - } - - var isDeleted: Bool { - self.state == .deleted - } - - var isUnavailable: Bool { - self.state == .unavailable - } - - // not entirely sure why this is holding data about remote heads... convenience? - // why not track within Repo? - func getRemoteHeads(id: STORAGE_ID) async -> Set? { - remoteHeads[id] - } - - func setRemoteHeads(id: STORAGE_ID, heads: Set) { - remoteHeads[id] = heads - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkAdapterEvents.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkAdapterEvents.swift deleted file mode 100644 index b773e112..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkAdapterEvents.swift +++ /dev/null @@ -1,73 +0,0 @@ -public struct PeerConnection: Sendable, CustomStringConvertible { - public var description: String { - if let meta = self.peerMetadata { - "\(peerId),\(meta)" - } else { - "\(peerId),nil" - } - } - - public let peerId: PEER_ID - public let peerMetadata: PeerMetadata? - - public init(peerId: PEER_ID, peerMetadata: PeerMetadata?) { - self.peerId = peerId - self.peerMetadata = peerMetadata - } -} - -public enum NetworkAdapterEvents: Sendable, CustomDebugStringConvertible { - public var debugDescription: String { - switch self { - case let .ready(payload): - "NetworkAdapterEvents.ready[\(payload)]" - case .close: - "NetworkAdapterEvents.close[]" - case let .peerCandidate(payload): - "NetworkAdapterEvents.peerCandidate[\(payload)]" - case let .peerDisconnect(payload): - "NetworkAdapterEvents.peerDisconnect[\(payload)]" - case let .message(payload): - "NetworkAdapterEvents.message[\(payload)]" - } - } - - public struct PeerDisconnectPayload: Sendable, CustomStringConvertible { - public var description: String { - "\(peerId)" - } - - // handled by Repo, relevant to Sync - let peerId: PEER_ID - - public init(peerId: PEER_ID) { - self.peerId = peerId - } - } - - case ready(payload: PeerConnection) // a network connection has been established and peered - sent by both listening - // and initiating connections - case close // handled by Repo, relevant to sync - case peerCandidate(payload: PeerConnection) // sent when a listening network adapter receives a proposed connection - // message (aka 'join') - case peerDisconnect(payload: PeerDisconnectPayload) // send when a peer connection terminates - case message(payload: SyncV1Msg) // handled by Sync -} - -// network connection overview: -// - connection established -// - initiating side sends "join" message -// - receiving side send "peer" message -// ONLY after peer message is received is the connection considered valid - -// for an outgoing connection: -// - network is ready for action -// - connect(to: SOMETHING) -// - when it receives the "peer" message, it's ready for ongoing work - -// for an incoming connection: -// - network is ready for action -// - remove peer opens a connection, we receive a "join" message -// - (peer candidate is known at that point) -// - if all is good (version matches, etc) then we send "peer" message to acknowledge -// - after that, we're ready to process protocol messages diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkProvider.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkProvider.swift deleted file mode 100644 index db8b1364..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkProvider.swift +++ /dev/null @@ -1,76 +0,0 @@ -// import AsyncAlgorithms - -// import protocol Combine.Publisher -import Automerge - -// https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo/src/network/NetworkAdapterInterface.ts - -/// A type that is responsible for establishing, and maintaining, a network connection for Automerge -/// -/// Types conforming to this protocol are responsible for the setup and initial handshake with other -/// peers, and flow through messages to component that owns the reference to the network adapter. -/// A higher level object is responsible for responding to sync, gossip, and other messages appropriately. -/// -/// A NetworkProvider instance can be either initiating or listening for - and responding to - a connection. -/// -/// The expected behavior when a network provide initiates a connection: -/// -/// - After the underlying transport connection is established due to a call to `connect`, the provider emits -/// ``NetworkAdapterEvents/ready(payload:)``, which includes a payload that indicates a -/// reference to the network provider (`any NetworkAdapter`). -/// - After the connection is established, the adapter sends a ``SyncV1/join(_:)`` message to request peering. -/// - When the NetworkAdapter receives a ``SyncV1/peer(_:)`` message, it emits -/// ``NetworkAdapterEvents/peerCandidate(payload:)``. -/// - If a message other than `peer` is received, the adapter should terminate the connection and emit -/// ``NetworkAdapterEvents/close``. -/// - All other messages are emitted as ``NetworkAdapterEvents/message(payload:)``. -/// - When a transport connection is closed, the adapter should emit ``NetworkAdapterEvents/peerDisconnect(payload:)``. -/// - When `disconnect` is invoked on a network provider, it should send a ``SyncV1/leave(_:)`` message, terminate the -/// connection, and emit ``NetworkAdapterEvents/close``. -/// -/// A connecting transport may optionally enable automatic reconnection on connection failure. Any configurable -/// reconnection logic exists, -/// it should be configured with a `configure` call with the relevant configuration type for the network provider. -/// -/// The expected behavior when listening for, and responding to, an incoming connection: -/// - When a connection is established, emit ``NetworkAdapterEvents/ready(payload:)``. -/// - When the transport receives a `join` message, verify that the protocols being requested are compatible. If they -/// are not, -/// return an ``SyncV1/error(_:)`` message, close the connection, and emit ``NetworkAdapterEvents/close``. -/// - When any other message is received, it is emitted with ``NetworkAdapterEvents/message(payload:)``. -/// - When the transport receives a `leave` message, close the connection and emit ``NetworkAdapterEvents/close``. -public protocol NetworkProvider: Sendable { - /// A list of all active, peered connections that the provider is maintaining. - /// - /// For an outgoing connection, this is typically a single connection. - /// For a listening connection, this could be quite a few. - var peeredConnections: [PeerConnection] { get async } - - /// For outgoing connections, the type that represents the endpoint to connect - /// For example, it could be `URL`, `NWEndpoint` for a Bonjour network, or a custom type. - associatedtype NetworkConnectionEndpoint: Sendable - - /// Initiate an outgoing connection. - func connect(to: NetworkConnectionEndpoint) async throws // aka "activate" - - /// Disconnect and terminate any existing connection. - func disconnect() async // aka "deactivate" - - /// Requests the network transport to send a message. - /// - Parameter message: The message to send. - /// - Parameter to: An option peerId to identify the recipient for the message. If nil, the message is sent to all - /// connected peers. - func send(message: SyncV1Msg, to: PEER_ID?) async - - /// Sets the delegate and configures the peer information for a Network Provider - /// - Parameter to: The instance that accepts asynchronous network events from the provider. - /// - Parameter peer: The peer ID for the network provider to use. - func setDelegate(_ delegate: any NetworkEventReceiver, as peer: PEER_ID, with metadata: PeerMetadata?) async -} - -/// A type that accepts provides a method for a Network Provider to call with network events. -public protocol NetworkEventReceiver: Sendable { - /// Receive and process an event from a Network Provider. - /// - Parameter event: The event to process. - func receiveEvent(event: NetworkAdapterEvents) async -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkSubsystem.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkSubsystem.swift deleted file mode 100644 index 498ab848..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/NetworkSubsystem.swift +++ /dev/null @@ -1,194 +0,0 @@ -// import AsyncAlgorithms -import Automerge -import struct Foundation.Data -import OSLog -import PotentCBOR - -// riff -// https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo/src/network/NetworkSubsystem.ts - -/// A type that hosts network subsystems to connect to peers. -/// -/// The NetworkSubsystem instance is responsible for setting up and configuring any network providers, and responding to -/// messages from remote peers after the connection has been established. The connection handshake and peer negotiation -/// is -/// the responsibility of the network provider instance. -public actor NetworkSubsystem { - // a list of documents with a pending request for a documentId - var requestedDocuments: [DocumentId: [PEER_ID]] = [:] - - public static let encoder = CBOREncoder() - public static let decoder = CBORDecoder() - - // repo is a weak var to avoid a retain cycle - a network subsystem is - // (so far) always created with a Repo and uses it for remote data storage of documents that - // it fetches, syncs, or gossips about. - // - // TODO: revisit this and consider if the callbacks to repo should be exposed as a delegate - weak var repo: Repo? - var adapters: [any NetworkProvider] - - init() { - self.adapters = [] - } - - func setRepo(_ repo: Repo) async { - self.repo = repo - } - - func addAdapter(adapter: some NetworkProvider) async { - guard let peerId = repo?.peerId else { - fatalError("NO REPO CONFIGURED WHEN ADDING ADAPTERS") - } - await adapter.setDelegate(self, as: peerId, with: repo?.localPeerMetadata) - self.adapters.append(adapter) - } - - func startRemoteFetch(id: DocumentId) async throws { - // attempt to fetch the provided document Id from all (current) peers, returning the document - // or returning nil if the document is unavailable. - // Save the throwing scenarios for failures in connection, etc. - guard let repo else { - // invariant that there should be a valid doc handle available from the repo - throw Errors.Unavailable(id: id) - } - - let newDocument = Document() - for adapter in adapters { - for peerConnection in await adapter.peeredConnections { - // upsert the requested document into the list by peer - if var existingList = requestedDocuments[id] { - existingList.append(peerConnection.peerId) - requestedDocuments[id] = existingList - } else { - requestedDocuments[id] = [peerConnection.peerId] - } - // get a current sync state (creating one if needed for a fresh sync) - let syncState = await repo.syncState(id: id, peer: peerConnection.peerId) - - if let syncRequestData = newDocument.generateSyncMessage(state: syncState) { - await adapter.send(message: .request(SyncV1Msg.RequestMsg( - documentId: id.description, - senderId: repo.peerId, - targetId: peerConnection.peerId, - sync_message: syncRequestData - )), to: peerConnection.peerId) - } - } - } - } - - func send(message: SyncV1Msg, to: PEER_ID?) async { - for adapter in adapters { - await adapter.send(message: message, to: to) - } - } -} - -extension NetworkSubsystem: NetworkEventReceiver { - // Collection point for messages coming in from all network adapters. - // The network subsystem forwards messages from network peers to the relevant places, - // and forwards messages out to peers as needed - // - // In automerge-repo code, it appears to update information on an ephemeral information ( - // a sort of middleware) before emitting it upwards. - public func receiveEvent(event: NetworkAdapterEvents) async { - // Logger.network.trace("received event from network adapter: \(event.debugDescription)") - guard let repo else { - // No-op if there's no repo to update state or handle - // further message passing - return - } - switch event { - case let .ready(payload): - await repo.addPeerWithMetadata(peer: payload.peerId, metadata: payload.peerMetadata) - case .close: - break - // attempt to reconnect, or remove from active adapters? - case let .peerCandidate(payload): - await repo.addPeerWithMetadata(peer: payload.peerId, metadata: payload.peerMetadata) - case let .peerDisconnect(payload): - await repo.removePeer(peer: payload.peerId) - case let .message(payload): - switch payload { - case .peer, .join, .leave, .unknown: - // ERROR FOR THESE MSG TYPES - expected to be handled at adapter - Logger.network - .error( - "Unexpected message type received by network subsystem: \(payload.debugDescription, privacy: .public)" - ) - #if DEBUG - fatalError("UNEXPECTED MSG") - #endif - case let .error(errorMsg): - Logger.network - .warning( - "Error message received by network subsystem: \(errorMsg.debugDescription, privacy: .public)" - ) - case let .request(requestMsg): - await repo.handleRequest(msg: requestMsg) - case let .sync(syncMsg): - await repo.handleSync(msg: syncMsg) - case let .unavailable(unavailableMsg): - guard let docId = DocumentId(unavailableMsg.documentId) else { - Logger.network - .error( - "Invalid message Id \(unavailableMsg.documentId, privacy: .public) in unavailable msg: \(unavailableMsg.debugDescription, privacy: .public)" - ) - return - } - if let peersRequested = requestedDocuments[docId] { - // if we receive an unavailable from one peer, record it and wait until - // we receive unavailable from all available peers before marking it unavailable - let remainingPeersPending = peersRequested.filter { peerId in - // include the peers OTHER than the one sending the unavailable msg - peerId != unavailableMsg.senderId - } - if remainingPeersPending.isEmpty { - await repo.markDocUnavailable(id: docId) - requestedDocuments.removeValue(forKey: docId) - } else { - // handle the scenario where we started with more adapters but - // lost a connection... - - var currentConnectedPeers: [PEER_ID] = [] - for adapter in self.adapters { - let connectedPeers: [PEER_ID] = await adapter.peeredConnections - .map { peerConnection in - peerConnection.peerId - } - currentConnectedPeers.append(contentsOf: connectedPeers) - } - let stillPending = remainingPeersPending.compactMap { peerId in - if currentConnectedPeers.contains(peerId) { - peerId - } else { - nil - } - } - // save the data back for other adapters to respond later... - requestedDocuments[docId] = stillPending - } - } else { - // no peers are waiting to hear about a requested document, ignore - return - } - case let .ephemeral(ephemeralMsg): - Logger.network - .error( - "UNIMPLEMENTED EPHEMERAL MESSAGE PASSING: \(ephemeralMsg.debugDescription, privacy: .public)" - ) - case let .remoteSubscriptionChange(remoteSubscriptionChangeMsg): - Logger.network - .error( - "UNIMPLEMENTED EPHEMERAL MESSAGE PASSING: \(remoteSubscriptionChangeMsg.debugDescription, privacy: .public)" - ) - case let .remoteHeadsChanged(remoteHeadsChangedMsg): - Logger.network - .error( - "UNIMPLEMENTED EPHEMERAL MESSAGE PASSING: \(remoteHeadsChangedMsg.debugDescription, privacy: .public)" - ) - } - } - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/BonjourSyncConnection.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/BonjourSyncConnection.swift deleted file mode 100644 index 0c4edc60..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/BonjourSyncConnection.swift +++ /dev/null @@ -1,383 +0,0 @@ -/* - Copyright © 2022 Apple Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - WWDC Video references aligned with this code: - - https://developer.apple.com/videos/play/wwdc2019/713/ - - https://developer.apple.com/videos/play/wwdc2020/10110/ - */ - -import Automerge -import Combine -import Foundation -import Network -import OSLog - -/// A peer to peer sync connection to receive and send sync messages. -/// -/// As soon as it is established, it attempts to commence a sync operation (send and expect to receive sync messages). -/// In addition, it includes an optional `trigger` in its initializer that, when it receives any signal value, kicks off -/// another attempt to sync the relevant Automerge document. -@MainActor -public final class BonjourSyncConnection: ObservableObject { - /// A unique identifier to track the connections for comparison against existing connections. - var connectionId = UUID() - public var shortId: String { - // "41ee739d-c827-4be8-9a4f-c44a492e76cf" - String(connectionId.uuidString.lowercased().suffix(8)) - } - - /// The document to which this connection is linked - var documentId: DocumentId - - var connection: NWConnection? - /// A Boolean value that indicates this app initiated this connection. - - @Published public var connectionState: NWConnection.State = .setup - @Published public var endpoint: NWEndpoint? - /// The peer Id for the connection endpoint, only set on outbound connections. - var peerId: String? - - /// The synchronization state associated with this connection. - var syncState: SyncState - - /// The cancellable subscription to the trigger mechanism that attempts sync updates. - var syncTriggerCancellable: (any Cancellable)? - - /// Initiate a connection to a network endpoint to synchronise an Automerge Document. - /// - Parameters: - /// - endpoint: The endpoint to attempt to connect. - /// - delegate: A delegate that can process Automerge sync protocol messages. - /// - trigger: A publisher that provides a recurring signal to trigger a sync request. - /// - docId: The document Id to use as a pre-shared key in TLS establishment of the connection. - init( - endpoint: NWEndpoint, - peerId: String, - trigger: AnyPublisher, - documentId: DocumentId - ) { - self.documentId = documentId - syncState = SyncState() - let connection = NWConnection( - to: endpoint, - using: NWParameters.peerSyncParameters(documentId: documentId) - ) - self.connection = connection - self.endpoint = endpoint - self.peerId = peerId - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): Initiating connection to \(endpoint.debugDescription, privacy: .public)" - ) - - startConnection(trigger) - } - - /// Accepts and runs a connection from another network endpoint to synchronise an Automerge Document. - /// - Parameters: - /// - connection: The connection provided by a listener to accept. - /// - delegate: A delegate that can process Automerge sync protocol messages. - init(connection: NWConnection, trigger: AnyPublisher, documentId: DocumentId) { - self.documentId = documentId - self.connection = connection - self.endpoint = connection.endpoint - syncState = SyncState() - Logger.syncConnection - .info( - "\(self.shortId, privacy: .public): Receiving connection from \(connection.endpoint.debugDescription, privacy: .public)" - ) - - startConnection(trigger) - } - - /// Cancels the current connection. - public func cancel() { - if let connection { - syncTriggerCancellable?.cancel() - if let peerId { - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): Cancelling outbound connection to peer \(peerId, privacy: .public)" - ) - } else { - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): Cancelling inbound connection from endpoint \(connection.endpoint.debugDescription, privacy: .public)" - ) - } - connection.cancel() - self.connectionState = .cancelled - self.connection = nil - } - } - - // Handle starting the peer-to-peer connection for both inbound and outbound connections. - private func startConnection(_ trigger: AnyPublisher) { - guard let connection else { - return - } - - syncTriggerCancellable = trigger.sink(receiveValue: { @MainActor _ in - if let automergeDoc = DocumentSyncCoordinator.shared.documents[self.documentId]?.value, - let syncData = automergeDoc.generateSyncMessage(state: self.syncState), - self.connectionState == .ready - { - Logger.syncConnection - .info( - "\(self.shortId, privacy: .public): Syncing \(syncData.count, privacy: .public) bytes to \(connection.endpoint.debugDescription, privacy: .public)" - ) - self.sendSyncMsg(syncData) - } - }) - - connection.stateUpdateHandler = { @MainActor [weak self] newState in - guard let self else { return } - - self.connectionState = newState - - switch newState { - case .ready: - if let endpoint = self.connection?.endpoint { - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): connection to \(endpoint.debugDescription, privacy: .public) ready." - ) - } else { - Logger.syncConnection.warning("\(self.shortId, privacy: .public): connection ready (no endpoint)") - } - // When the connection is ready, start receiving messages. - self.receiveNextMessage() - - case let .failed(error): - Logger.syncConnection - .warning( - "\(self.shortId, privacy: .public): FAILED \(String(describing: connection), privacy: .public) : \(error, privacy: .public)" - ) - // Cancel the connection upon a failure. - connection.cancel() - self.syncTriggerCancellable?.cancel() - DocumentSyncCoordinator.shared.removeConnection(self.connectionId) - self.syncTriggerCancellable = nil - - case .cancelled: - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): CANCEL \(endpoint.debugDescription, privacy: .public) connection." - ) - self.syncTriggerCancellable?.cancel() - DocumentSyncCoordinator.shared.removeConnection(self.connectionId) - self.syncTriggerCancellable = nil - - case let .waiting(nWError): - // from Network headers - // `Waiting connections have not yet been started, or do not have a viable network` - // So if we drop into this state, it's likely the network has shifted to non-viable - // (for example, the wifi was disabled or dropped). - // - // Unclear if this is something we should retry ourselves when the associated network - // path is again viable, or if this is something that the Network framework does on our - // behalf. - if let endpoint = self.connection?.endpoint { - Logger.syncConnection - .warning( - "\(self.shortId, privacy: .public): connection to \(endpoint.debugDescription, privacy: .public) waiting: \(nWError.debugDescription, privacy: .public)." - ) - } else { - Logger.syncConnection.debug("\(self.shortId, privacy: .public): connection waiting (no endpoint)") - } - - case .preparing: - if let endpoint = self.connection?.endpoint { - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): connection to \(endpoint.debugDescription, privacy: .public) preparing." - ) - } else { - Logger.syncConnection.debug("\(self.shortId, privacy: .public): connection preparing (no endpoint)") - } - - case .setup: - if let endpoint = self.connection?.endpoint { - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): connection to \(endpoint.debugDescription, privacy: .public) in setup." - ) - } else { - Logger.syncConnection.debug("\(self.shortId, privacy: .public): connection setup (no endpoint)") - } - default: - break - } - } - - // Start the connection establishment. - connection.start(queue: .main) - } - - /// Receive a message from the sync protocol framing, deliver it to the delegate for processing, and continue - /// receiving messages. - private func receiveNextMessage() { - guard let connection else { - return - } - - connection.receiveMessage { content, context, isComplete, error in - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): Received a \(isComplete ? "complete" : "incomplete", privacy: .public) msg on connection" - ) - if let content { - Logger.syncConnection.debug(" - received \(content.count) bytes") - } else { - Logger.syncConnection.debug(" - received no data with msg") - } - // Extract your message type from the received context. - if let syncMessage = context? - .protocolMetadata(definition: P2PAutomergeSyncProtocol.definition) as? NWProtocolFramer.Message, - let endpoint = self.connection?.endpoint - { - self.receivedMessage(content: content, message: syncMessage, from: endpoint) - } - if error == nil { - // Continue to receive more messages until you receive an error. - self.receiveNextMessage() - } else { - Logger.syncConnection.error(" - error on received message: \(error)") - self.cancel() - } - } - } - - // MARK: Automerge data to Automerge Sync Protocol transforms - - /// Sends an Automerge document Id. - /// - Parameter documentId: The document Id to send. - func sendDocumentId(_ documentId: DocumentId) { - // corresponds to SyncMessageType.id - guard let connection else { - return - } - - // Create a message object to hold the command type. - let message = NWProtocolFramer.Message(syncMessageType: .id) - let context = NWConnection.ContentContext( - identifier: "DocumentId", - metadata: [message] - ) - - // Send the app content along with the message. - connection.send( - content: documentId.description.data(using: .utf8), - contentContext: context, - isComplete: true, - completion: .idempotent - ) - } - - /// Sends an Automerge sync data packet. - /// - Parameter syncMsg: The data to send. - func sendSyncMsg(_ syncMsg: Data) { - guard let connection else { - Logger.syncConnection - .error("\(self.shortId, privacy: .public): PeerConnection doesn't have an active connection!") - return - } - - // Create a message object to hold the command type. - let message = NWProtocolFramer.Message(syncMessageType: .sync) - let context = NWConnection.ContentContext( - identifier: "Sync", - metadata: [message] - ) - - // Send the app content along with the message. - connection.send( - content: syncMsg, - contentContext: context, - isComplete: true, - completion: .idempotent - ) - } - - @MainActor func receivedMessage(content data: Data?, message: NWProtocolFramer.Message, from endpoint: NWEndpoint) { - guard let document = DocumentSyncCoordinator.shared.documents[self.documentId]?.value else { - Logger.syncConnection - .warning( - "\(self.shortId, privacy: .public): received msg for unregistered document \(self.documentId, privacy: .public) from \(endpoint.debugDescription, privacy: .public)" - ) - - return - } - switch message.syncMessageType { - case .unknown: - Logger.syncConnection - .error( - "\(self.shortId, privacy: .public): Invalid message received from \(endpoint.debugDescription, privacy: .public)" - ) - case .sync: - guard let data else { - Logger.syncConnection - .error( - "\(self.shortId, privacy: .public): Sync message received without data from \(endpoint.debugDescription, privacy: .public)" - ) - return - } - do { - // When we receive a complete sync message from the underlying transport, - // update our automerge document, and the associated SyncState. - let patches = try document.receiveSyncMessageWithPatches( - state: syncState, - message: data - ) - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): Received \(patches.count, privacy: .public) patches in \(data.count, privacy: .public) bytes" - ) - - // Once the Automerge doc is updated, check (using the SyncState) to see if - // we believe we need to send additional messages to the peer to keep it in sync. - if let response = document.generateSyncMessage(state: syncState) { - sendSyncMsg(response) - } else { - // When generateSyncMessage returns nil, the remote endpoint represented by - // SyncState should be up to date. - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): Sync complete with \(endpoint.debugDescription, privacy: .public)" - ) - } - } catch { - Logger.syncConnection - .error("\(self.shortId, privacy: .public): Error applying sync message: \(error, privacy: .public)") - } - case .id: - Logger.syncConnection.info("\(self.shortId, privacy: .public): received request for document ID") - sendDocumentId(self.documentId) - case .peer: - break - case .leave: - break - case .join: - break - case .request: - break - case .unavailable: - break - case .ephemeral: - break - case .syncerror: - break - case .remoteHeadsChanged: - break - case .remoteSubscriptionChange: - break - } - } -} - -extension BonjourSyncConnection: Identifiable {} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/NWParameters+peerSyncParameters.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/NWParameters+peerSyncParameters.swift deleted file mode 100644 index 8f132402..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/NWParameters+peerSyncParameters.swift +++ /dev/null @@ -1,74 +0,0 @@ -/* - - Portions Copyright © 2022 Apple Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - WWDC Video references aligned with this code: - - https://developer.apple.com/videos/play/wwdc2019/713/ - - https://developer.apple.com/videos/play/wwdc2020/10110/ - */ - -import CryptoKit -import Network - -extension NWParameters { - /// Returns listener and connection network parameters using default TLS for peer to peer connections. - static func peerSyncParameters(documentId: DocumentId) -> NWParameters { - let tcpOptions = NWProtocolTCP.Options() - tcpOptions.enableKeepalive = true - tcpOptions.keepaliveIdle = 2 - - let params = NWParameters(tls: tlsOptions(passcode: documentId.description), tcp: tcpOptions) - let syncOptions = NWProtocolFramer.Options(definition: P2PAutomergeSyncProtocol.definition) - params.defaultProtocolStack.applicationProtocols.insert(syncOptions, at: 0) - - params.includePeerToPeer = true - return params - } - - // Create TLS options using a passcode to derive a pre-shared key. - private static func tlsOptions(passcode: String) -> NWProtocolTLS.Options { - let tlsOptions = NWProtocolTLS.Options() - - let authenticationKey = SymmetricKey(data: passcode.data(using: .utf8)!) - let authenticationCode = HMAC.authenticationCode( - for: "MeetingNotes".data(using: .utf8)!, - using: authenticationKey - ) - - let authenticationDispatchData = authenticationCode.withUnsafeBytes { - DispatchData(bytes: $0) - } - - sec_protocol_options_add_pre_shared_key( - tlsOptions.securityProtocolOptions, - authenticationDispatchData as __DispatchData, - stringToDispatchData("MeetingNotes")! as __DispatchData - ) - // Forcing non-standard cipher suite value to UInt16 because for - // whatever reason, it can get returned as UInt32 - such as in - // GitHub actions CI. - let ciphersuiteValue = UInt16(TLS_PSK_WITH_AES_128_GCM_SHA256) - sec_protocol_options_append_tls_ciphersuite( - tlsOptions.securityProtocolOptions, - tls_ciphersuite_t(rawValue: ciphersuiteValue)! - ) - return tlsOptions - } - - // Create a utility function to encode strings as preshared key data. - private static func stringToDispatchData(_ string: String) -> DispatchData? { - guard let stringData = string.data(using: .utf8) else { - return nil - } - let dispatchData = stringData.withUnsafeBytes { - DispatchData(bytes: $0) - } - return dispatchData - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/P2PAutomergeSyncProtocol.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/P2PAutomergeSyncProtocol.swift deleted file mode 100644 index b95a83ab..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/P2PAutomergeSyncProtocol.swift +++ /dev/null @@ -1,182 +0,0 @@ -/* - - Copyright © 2022 Apple Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - WWDC Video references aligned with this code: - - https://developer.apple.com/videos/play/wwdc2019/713/ - - https://developer.apple.com/videos/play/wwdc2020/10110/ - */ - -import Foundation -import Network -import OSLog - -/// The type of sync message for the Automerge network sync protocol. -enum P2PSyncMessageType: UInt32 { - case unknown = 0 // msg isn't a recognized type - case sync = 1 // msg is generated sync data to merge into an Automerge document - case id = 2 // msg is a unique for the source/master of a document to know if they've been cloned - case peer = 3 - case join = 4 - case request = 5 - case unavailable = 6 - case ephemeral = 7 - case syncerror = 8 - case remoteHeadsChanged = 9 - case remoteSubscriptionChange = 10 - case leave = 11 -} - -/// The definition of the Automerge network sync protocol. -class P2PAutomergeSyncProtocol: NWProtocolFramerImplementation { - // Create a global definition of your game protocol to add to connections. - static let definition = NWProtocolFramer.Definition(implementation: P2PAutomergeSyncProtocol.self) - - // Set a name for your protocol for use in debugging. - static var label: String { "AutomergeSync" } - - static var bonjourType: String { "_automergesync._tcp" } - static var applicationService: String { "AutomergeSync" } - - // Set the default behavior for most framing protocol functions. - required init(framer _: NWProtocolFramer.Instance) {} - func start(framer _: NWProtocolFramer.Instance) -> NWProtocolFramer.StartResult { .ready } - func wakeup(framer _: NWProtocolFramer.Instance) {} - func stop(framer _: NWProtocolFramer.Instance) -> Bool { true } - func cleanup(framer _: NWProtocolFramer.Instance) {} - - // Whenever the application sends a message, add your protocol header and forward the bytes. - func handleOutput( - framer: NWProtocolFramer.Instance, - message: NWProtocolFramer.Message, - messageLength: Int, - isComplete _: Bool - ) { - // Extract the type of message. - let type = message.syncMessageType - - // Create a header using the type and length. - let header = P2PAutomergeSyncProtocolHeader(type: type.rawValue, length: UInt32(messageLength)) - - // Write the header. - framer.writeOutput(data: header.encodedData) - - // Ask the connection to insert the content of the app message after your header. - do { - try framer.writeOutputNoCopy(length: messageLength) - } catch { - Logger.syncController.error("Error writing protocol data into frame: \(error, privacy: .public)") - } - } - - // Whenever new bytes are available to read, try to parse out your message format. - func handleInput(framer: NWProtocolFramer.Instance) -> Int { - while true { - // Try to read out a single header. - var tempHeader: P2PAutomergeSyncProtocolHeader? = nil - let headerSize = P2PAutomergeSyncProtocolHeader.encodedSize - let parsed = framer.parseInput( - minimumIncompleteLength: headerSize, - maximumLength: headerSize - ) { buffer, _ -> Int in - guard let buffer else { - return 0 - } - if buffer.count < headerSize { - return 0 - } - tempHeader = P2PAutomergeSyncProtocolHeader(buffer) - return headerSize - } - - // If you can't parse out a complete header, stop parsing and return headerSize, - // which asks for that many more bytes. - guard parsed, let header = tempHeader else { - return headerSize - } - - // Create an object to deliver the message. - var messageType = P2PSyncMessageType.unknown - if let parsedMessageType = P2PSyncMessageType(rawValue: header.type) { - messageType = parsedMessageType - } - let message = NWProtocolFramer.Message(syncMessageType: messageType) - - // Deliver the body of the message, along with the message object. - if !framer.deliverInputNoCopy(length: Int(header.length), message: message, isComplete: true) { - return 0 - } - } - } -} - -// Extend framer messages to handle storing your command types in the message metadata. -extension NWProtocolFramer.Message { - /// Create a new protocol-framed message for the Automerge network sync protocol. - /// - Parameter syncMessageType: The type of sync message for this Automerge peer to peer sync protocol - convenience init(syncMessageType: P2PSyncMessageType) { - self.init(definition: P2PAutomergeSyncProtocol.definition) - self["SyncMessageType"] = syncMessageType - } - - /// The type of sync message. - var syncMessageType: P2PSyncMessageType { - if let type = self["SyncMessageType"] as? P2PSyncMessageType { - type - } else { - .unknown - } - } -} - -// Define a protocol header structure to help encode and decode bytes. - -/// The Automerge network sync protocol header structure. -struct P2PAutomergeSyncProtocolHeader: Codable { - let type: UInt32 - let length: UInt32 - - init(type: UInt32, length: UInt32) { - self.type = type - self.length = length - } - - init(_ buffer: UnsafeMutableRawBufferPointer) { - var tempType: UInt32 = 0 - var tempLength: UInt32 = 0 - withUnsafeMutableBytes(of: &tempType) { typePtr in - typePtr.copyMemory(from: UnsafeRawBufferPointer( - start: buffer.baseAddress!.advanced(by: 0), - count: MemoryLayout.size - )) - } - withUnsafeMutableBytes(of: &tempLength) { lengthPtr in - lengthPtr - .copyMemory(from: UnsafeRawBufferPointer( - start: buffer.baseAddress! - .advanced(by: MemoryLayout.size), - count: MemoryLayout.size - )) - } - type = tempType - length = tempLength - } - - var encodedData: Data { - var tempType = type - var tempLength = length - var data = Data(bytes: &tempType, count: MemoryLayout.size) - data.append(Data(bytes: &tempLength, count: MemoryLayout.size)) - return data - } - - static var encodedSize: Int { - MemoryLayout.size * 2 - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/TXTRecordKeys.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/TXTRecordKeys.swift deleted file mode 100644 index 5f509700..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/PeerNetworking/TXTRecordKeys.swift +++ /dev/null @@ -1,9 +0,0 @@ -/// A type that provides type-safe strings for TXTRecord publications with Bonjour -public enum TXTRecordKeys: Sendable { - /// The document identifier. - public static let doc_id = "doc_id" - /// The peer identifier. - public static let peer_id = "peer_id" - /// The human-readable name for the peer. - public static let name = "name" -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/Providers/WebSocketProvider.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Networking/Providers/WebSocketProvider.swift deleted file mode 100644 index 384e59f0..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Networking/Providers/WebSocketProvider.swift +++ /dev/null @@ -1,274 +0,0 @@ -import OSLog - -public actor WebSocketProvider: NetworkProvider { - public typealias ProviderConfiguration = WebSocketProviderConfiguration - public struct WebSocketProviderConfiguration: Sendable { - let reconnectOnError: Bool - - public static let `default` = WebSocketProviderConfiguration(reconnectOnError: true) - } - - public var peeredConnections: [PeerConnection] - var delegate: (any NetworkEventReceiver)? - var peerId: PEER_ID? - var peerMetadata: PeerMetadata? - var webSocketTask: URLSessionWebSocketTask? - var backgroundWebSocketReceiveTask: Task? - var config: WebSocketProviderConfiguration - var endpoint: URL? - - public init(_ config: WebSocketProviderConfiguration = .default) { - self.config = config - self.peeredConnections = [] - self.delegate = nil - self.peerId = nil - self.peerMetadata = nil - self.webSocketTask = nil - self.backgroundWebSocketReceiveTask = nil - } - - // MARK: NetworkProvider Methods - - public func connect(to url: URL) async throws { - // TODO: refactor the connection logic to separate connecting and handling the peer/join - // messaging, from setting up the ongoing looping to allow for multiple retry attempts - // that return a concrete value of "good/no-good" separate from a protocol failure. - // ... something like - // func attemptConnect(to url: URL) async throws -> URLSessionWebSocketTask? - guard let peerId = self.peerId, - let delegate = self.delegate - else { - fatalError("Attempting to connect before connected to a delegate") - } - - // establish the WebSocket connection - self.endpoint = url - let request = URLRequest(url: url) - webSocketTask = URLSession.shared.webSocketTask(with: request) - guard let webSocketTask else { - #if DEBUG - fatalError("Attempting to configure and join a nil webSocketTask") - #else - return - #endif - } - - Logger.webSocket.trace("Activating websocket to \(url, privacy: .public)") - // start the websocket processing things - webSocketTask.resume() - - // since we initiated the WebSocket, it's on us to send an initial 'join' - // protocol message to start the handshake phase of the protocol - let joinMessage = SyncV1Msg.JoinMsg(senderId: peerId, metadata: self.peerMetadata) - let data = try SyncV1Msg.encode(joinMessage) - try await webSocketTask.send(.data(data)) - Logger.webSocket.trace("SEND: \(joinMessage.debugDescription)") - - do { - // Race a timeout against receiving a Peer message from the other side - // of the WebSocket connection. If we fail that race, shut down the connection - // and move into a .closed connectionState - let websocketMsg = try await self.nextMessage(withTimeout: .seconds(3.5)) - - // Now that we have the WebSocket message, figure out if we got what we expected. - // For the sync protocol handshake phase, it's essentially "peer or die" since - // we were the initiating side of the connection. - guard case let .peer(peerMsg) = try attemptToDecode(websocketMsg, peerOnly: true) else { - throw SyncV1Msg.Errors.UnexpectedMsg(msg: websocketMsg) - } - let newPeerConnection = PeerConnection(peerId: peerMsg.senderId, peerMetadata: peerMsg.peerMetadata) - self.peeredConnections = [newPeerConnection] - await delegate.receiveEvent(event: .ready(payload: newPeerConnection)) - Logger.webSocket.trace("Peered to targetId: \(peerMsg.senderId) \(peerMsg.debugDescription)") - } catch { - // if there's an error, disconnect anything that's lingering and cancel it down. - await self.disconnect() - throw error - } - - // If we have an existing task there, looping over messages, it means there was - // one previously set up, and there was a connection failure - at which point - // a reconnect was created to re-establish the webSocketTask. - if self.backgroundWebSocketReceiveTask == nil { - // infinitely loop and receive messages, but "out of band" - backgroundWebSocketReceiveTask = Task.detached { - try await self.ongoingRecieveWebSocketMessage() - } - } - } - - public func disconnect() async { - self.webSocketTask?.cancel(with: .normalClosure, reason: nil) - self.webSocketTask = nil - self.backgroundWebSocketReceiveTask?.cancel() - self.backgroundWebSocketReceiveTask = nil - self.endpoint = nil - - if let connectedPeer = self.peeredConnections.first { - self.peeredConnections.removeAll() - await delegate?.receiveEvent(event: .peerDisconnect(payload: .init(peerId: connectedPeer.peerId))) - } - - await delegate?.receiveEvent(event: .close) - } - - public func send(message: SyncV1Msg, to _: PEER_ID?) async { - guard let webSocketTask = self.webSocketTask else { - Logger.webSocket.warning("Attempt to send a message without a connection") - return - } - - do { - let data = try SyncV1Msg.encode(message) - try await webSocketTask.send(.data(data)) - } catch { - Logger.webSocket.error("Unable to encode and send message: \(error.localizedDescription, privacy: .public)") - } - } - - public func setDelegate( - _ delegate: any NetworkEventReceiver, - as peer: PEER_ID, - with metadata: PeerMetadata? - ) async { - self.delegate = delegate - self.peerId = peer - self.peerMetadata = metadata - } - - // MARK: utility methods - - private func attemptToDecode(_ msg: URLSessionWebSocketTask.Message, peerOnly: Bool = false) throws -> SyncV1Msg { - // Now that we have the WebSocket message, figure out if we got what we expected. - // For the sync protocol handshake phase, it's essentially "peer or die" since - // we were the initiating side of the connection. - switch msg { - case let .data(raw_data): - if peerOnly { - let msg = SyncV1Msg.decodePeer(raw_data) - if case .peer = msg { - return msg - } else { - // In the handshake phase and received anything other than a valid peer message - let decodeAttempted = SyncV1Msg.decode(raw_data) - Logger.webSocket - .warning( - "Decoding websocket message, expecting peer only - and it wasn't a peer message. RECEIVED MSG: \(decodeAttempted.debugDescription)" - ) - throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodeAttempted) - } - } else { - let decodedMsg = SyncV1Msg.decode(raw_data) - if case .unknown = decodedMsg { - throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodedMsg) - } - return decodedMsg - } - - case let .string(string): - // In the handshake phase and received anything other than a valid peer message - Logger.webSocket - .warning("Unknown websocket message received: .string(\(string))") - throw SyncV1Msg.Errors.UnexpectedMsg(msg: msg) - @unknown default: - // In the handshake phase and received anything other than a valid peer message - Logger.webSocket - .error("Unknown websocket message received: \(String(describing: msg))") - throw SyncV1Msg.Errors.UnexpectedMsg(msg: msg) - } - } - - // throw error on timeout - // throw error on cancel - // otherwise return the msg - private func nextMessage( - withTimeout: ContinuousClock.Instant - .Duration = .seconds(3.5) - ) async throws -> URLSessionWebSocketTask.Message { - // Co-operatively check to see if we're cancelled, and if so - we can bail out before - // going into the receive loop. - try Task.checkCancellation() - - // check the invariants - guard let webSocketTask = self.webSocketTask - else { - throw SyncV1Msg.Errors - .ConnectionClosed(errorDescription: "Attempting to wait for a websocket message when the task is nil") - } - - // Race a timeout against receiving a Peer message from the other side - // of the WebSocket connection. If we fail that race, shut down the connection - // and move into a .closed connectionState - let websocketMsg = try await withThrowingTaskGroup(of: URLSessionWebSocketTask.Message.self) { group in - group.addTask { - // retrieve the next websocket message - try await webSocketTask.receive() - } - - group.addTask { - // Race against the receive call with a continuous timer - try await Task.sleep(for: withTimeout) - throw SyncV1Msg.Errors.Timeout() - } - - guard let msg = try await group.next() else { - throw CancellationError() - } - // cancel all ongoing tasks (the websocket receive request, in this case) - group.cancelAll() - return msg - } - return websocketMsg - } - - /// Infinitely loops over incoming messages from the websocket and updates the state machine based on the messages - /// received. - private func ongoingRecieveWebSocketMessage() async throws { - var msgFromWebSocket: URLSessionWebSocketTask.Message? - while true { - guard let webSocketTask = self.webSocketTask else { - Logger.webSocket.warning("Receive Handler: webSocketTask is nil, terminating handler loop") - break - } - - try Task.checkCancellation() - - do { - msgFromWebSocket = try await webSocketTask.receive() - } catch { - if self.config.reconnectOnError, let endpoint = self.endpoint { - // TODO: add in some jitter/backoff logic, and potentially refactor to attempt to retry multiple times - try await self.connect(to: endpoint) - } else { - throw error - } - } - - do { - if let encodedMessage = msgFromWebSocket { - let msg = try attemptToDecode(encodedMessage) - await self.handleMessage(msg: msg) - } - } catch { - // catch decode failures, but don't terminate the whole shebang - // on a failure - Logger.webSocket - .warning("Unable to decode websocket message: \(error.localizedDescription, privacy: .public)") - } - } - } - - func handleMessage(msg: SyncV1Msg) async { - switch msg { - case let .leave(msg): - Logger.webSocket.trace("\(msg.senderId) requests to kill the connection") - await self.disconnect() - case let .join(msg): - Logger.webSocket.error("Unexpected message received: \(msg.debugDescription)") - case let .peer(msg): - Logger.webSocket.error("Unexpected message received: \(msg.debugDescription)") - default: - await self.delegate?.receiveEvent(event: .message(payload: msg)) - } - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/PeerMetadata.swift b/Packages/automerge-repo/Sources/AutomergeRepo/PeerMetadata.swift deleted file mode 100644 index ef85702c..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/PeerMetadata.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -// ; Metadata sent in either the join or peer message types -// peer_metadata = { -// ; The storage ID of this peer -// ? storageId: storage_id, -// ; Whether the sender expects to connect again with this storage ID -// isEphemeral: bool -// } - -public struct PeerMetadata: Sendable, Codable, CustomDebugStringConvertible { - public var storageId: STORAGE_ID? - public var isEphemeral: Bool - - public init(storageId: STORAGE_ID? = nil, isEphemeral: Bool) { - self.storageId = storageId - self.isEphemeral = isEphemeral - } - - public var debugDescription: String { - "[storageId: \(storageId ?? "nil"), ephemeral: \(isEphemeral)]" - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Repo+Errors.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Repo+Errors.swift deleted file mode 100644 index 777ffb55..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Repo+Errors.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -enum Errors: Sendable { - public struct Unavailable: Sendable, LocalizedError { - let id: DocumentId - public var errorDescription: String? { - "Unknown document Id: \(self.id)" - } - } - - public struct DocDeleted: Sendable, LocalizedError { - let id: DocumentId - public var errorDescription: String? { - "Document with Id: \(self.id) has been deleted." - } - } - - public struct DocUnavailable: Sendable, LocalizedError { - let id: DocumentId - public var errorDescription: String? { - "Document with Id: \(self.id) is unavailable." - } - } - - public struct BigBadaBoom: Sendable, LocalizedError { - let msg: String - public var errorDescription: String? { - "Something went quite wrong: \(self.msg)." - } - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Repo.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Repo.swift deleted file mode 100644 index 2dbcf687..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Repo.swift +++ /dev/null @@ -1,604 +0,0 @@ -import Automerge -import AutomergeUtilities -import Foundation -import OSLog - -/// A type that accepts ephemeral messages as they arrive from connected network peers. -public protocol EphemeralMessageDelegate: Sendable { - /// Receive and process an event from a Network Provider. - /// - Parameter event: The event to process. - func receiveEphemeralMessage(_ msg: SyncV1Msg.EphemeralMsg) async -} - -public actor Repo { - public let peerId: PEER_ID - public var localPeerMetadata: PeerMetadata - - private var handles: [DocumentId: InternalDocHandle] = [:] - private var storage: DocumentStorage? - private var network: NetworkSubsystem - - // saveDebounceRate = 100 - var sharePolicy: any SharePolicy - - /** maps peer id to to persistence information (storageId, isEphemeral), access by collection synchronizer */ - /** @hidden */ - private var peerMetadataByPeerId: [PEER_ID: PeerMetadata] = [:] - - private let maxRetriesForFetch: Int = 300 - private let pendingRequestWaitDuration: Duration = .seconds(1) - private var pendingRequestReadAttempts: [DocumentId: Int] = [:] - -// #remoteHeadsSubscriptions = new RemoteHeadsSubscriptions() -// export class RemoteHeadsSubscriptions extends EventEmitter { -// // Storage IDs we have received remote heads from -// #knownHeads: Map> = new Map() - // ^^^ DUPLICATES DATA stored in DocHandle... - -// // Storage IDs we have subscribed to via Repo.subscribeToRemoteHeads -// #ourSubscriptions: Set = new Set() - -// // Storage IDs other peers have subscribed to by sending us a control message -// #theirSubscriptions: Map> = new Map() - -// // Peers we will always share remote heads with even if they are not subscribed -// #generousPeers: Set = new Set() - -// // Documents each peer has open, we need this information so we only send remote heads of documents that the -// /peer knows -// #subscribedDocsByPeer: Map> = new Map() - - private var remoteHeadsGossipingEnabled = false - - private var _ephemeralMessageDelegate: (any EphemeralMessageDelegate)? - - // REPO - // https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo/src/Repo.ts - // - looks like it's the rough equivalent to the overall synchronization coordinator - - // - owns synchronizer, network, and storage subsystems - // - it "just" manages the connections, adds, and removals - when documents "appear", they're - // added to the synchronizer, which is the thing that accepts sync messages and tries to keep documents - // up to date with any registered peers. It emits (at a debounced rate) events to let anyone watching - // a document know that changes have occurred. - // - // Looks like it also has the idea of a sharePolicy per document, and if provided, then a document - // will be shared with peers (or positively respond to requests for the document if it's requested) - - // Repo - // property: peers [PeerId] - all (currently) connected peers - // property: handles [DocHandle] - list of all the DocHandles - // - func clone(Document) -> Document - // - func export(DocumentId) -> uint8[] - // - func import(uint8[]) -> Document - // - func create() -> Document - // - func find(DocumentId) -> Document - // - func delete(DocumentId) - // - func storageId() -> StorageId (async) - // - func storageIdForPeer(peerId) -> StorageId - // - func subscribeToRemotes([StorageId]) - - public init( - sharePolicy: some SharePolicy - ) { - self.peerId = UUID().uuidString - self.handles = [:] - self.peerMetadataByPeerId = [:] - self.storage = nil - self.localPeerMetadata = PeerMetadata(storageId: nil, isEphemeral: true) - self.sharePolicy = sharePolicy - self.network = NetworkSubsystem() - } - - /// Add a persistent storage provider to the repo. - /// - Parameter provider: The storage provider to add. - public func addStorageProvider(_ provider: some StorageProvider) { - self.storage = DocumentStorage(provider) - self.localPeerMetadata = PeerMetadata(storageId: provider.id, isEphemeral: false) - } - - /// Add a configured network provider to the repo - /// - Parameter adapter: The network provider to add. - public func addNetworkAdapter(adapter: any NetworkProvider) async { - if await self.network.repo == nil { - await self.network.setRepo(self) - } - await network.addAdapter(adapter: adapter) - } - - /// Set the delegate that to receive ephemeral messages from Automerge-repo peers - /// - Parameter delegate: The object that Automerge-repo calls with ephemeral messages. - public func setDelegate(_ delegate: some EphemeralMessageDelegate) { - self._ephemeralMessageDelegate = delegate - } - - /// Returns a list of repository documentIds. - /// - /// The list does not reflect deleted or unavailable documents that have been requested, but may return - /// Ids for documents still being creating, stored, or transferring from a peer. - public func documentIds() async -> [DocumentId] { - handles.values - .filter { handle in - handle.state == .ready || handle.state == .loading || handle.state == .requesting - } - .map(\.id) - } - - // MARK: Synchronization Pieces - Peers - - /// Returns a list of the ids of available peers. - public func peers() async -> [PEER_ID] { - peerMetadataByPeerId.keys.sorted() - } - - /// Returns the storage Id of for the id of the peer that you provide. - /// - Parameter peer: The peer to request - func getStorageIdOfPeer(peer: PEER_ID) async -> STORAGE_ID? { - if let metaForPeer = peerMetadataByPeerId[peer] { - metaForPeer.storageId - } else { - nil - } - } - - func beginSync(docId: DocumentId, to peer: PEER_ID) async { - do { - let handle = try await self.resolveDocHandle(id: docId) - let syncState = self.syncState(id: docId, peer: peer) - if let syncData = handle.doc.generateSyncMessage(state: syncState) { - let syncMsg: SyncV1Msg = .sync(.init( - documentId: docId.description, - senderId: self.peerId, - targetId: peer, - sync_message: syncData - )) - await network.send(message: syncMsg, to: peer) - } - } catch { - Logger.repo - .error("Failed to generate sync on peer connection: \(error.localizedDescription, privacy: .public)") - } - } - - func addPeerWithMetadata(peer: PEER_ID, metadata: PeerMetadata?) async { - peerMetadataByPeerId[peer] = metadata - for docId in await self.documentIds() { - if await sharePolicy.share(peer: peer, docId: docId) { - await beginSync(docId: docId, to: peer) - } - } - } - - func removePeer(peer: PEER_ID) { - peerMetadataByPeerId.removeValue(forKey: peer) - } - - // MARK: Handle pass-back of Ephemeral Messages - - func handleEphemeralMessage(_ msg: SyncV1Msg.EphemeralMsg) async { - await self._ephemeralMessageDelegate?.receiveEphemeralMessage(msg) - } - - // MARK: Synchronization Pieces - For Network Subsystem Access - - func handleSync(msg: SyncV1Msg.SyncMsg) async { - Logger.repo.trace("PEER: \(self.peerId) - handling a sync msg from \(msg.senderId) to \(msg.targetId)") - guard let docId = DocumentId(msg.documentId) else { - Logger.repo - .warning("Invalid documentId \(msg.documentId) received in a sync message \(msg.debugDescription)") - return - } - do { - if handles[docId] == nil { - // There is no in-memory handle for the document being synced, so this is a request - // to create a local copy of the document encapsulated in the sync message. - let newDocument = Document() - let newHandle = InternalDocHandle(id: docId, isNew: true, initialValue: newDocument) - - // must update the repo with the new handle and empty document _before_ - // using syncState, since it needs to resolve the documentId - handles[docId] = newHandle - _ = try await self.resolveDocHandle(id: docId) - } - guard let handle = handles[docId] else { fatalError("HANDLE DOESN'T EXIST") } - let docFromHandle = handle.doc ?? Document() - let syncState = self.syncState(id: docId, peer: msg.senderId) - // Apply the request message as a sync update - try docFromHandle.receiveSyncMessage(state: syncState, message: msg.data) - // Stash the updated document and sync state - await self.updateDoc(id: docId, doc: docFromHandle) - await self.updateSyncState(id: docId, peer: msg.senderId, syncState: syncState) - // Attempt to generate a sync message to reply - - // DEBUG ONLY - // print("\(self.peerId): STATE OF \(handle.id)") - // try docFromHandle.walk() - - if let syncData = docFromHandle.generateSyncMessage(state: syncState) { - let syncMsg: SyncV1Msg = .sync(.init( - documentId: docId.description, - senderId: self.peerId, - targetId: msg.senderId, - sync_message: syncData - )) - Logger.repo.trace("Sync received and applied, replying with a sync msg back to \(msg.senderId)") - await network.send(message: syncMsg, to: msg.senderId) - } - // else no sync is needed, as the last sync state reports that it knows about - // all the changes it needs - that it's up to date with the local document - } catch { - let err: SyncV1Msg = - .error(.init(message: "Error receiving sync: \(error.localizedDescription)")) - Logger.repo.warning("Error receiving initial sync for \(docId, privacy: .public)") - await network.send(message: err, to: msg.senderId) - } - } - - func handleRequest(msg: SyncV1Msg.RequestMsg) async { - guard let docId = DocumentId(msg.documentId) else { - Logger.repo - .warning("Invalid documentId \(msg.documentId) received in a sync message \(msg.debugDescription)") - return - } - if handles[docId] != nil { - // If we have the document, see if we're agreeable to sending a copy - if await sharePolicy.share(peer: msg.senderId, docId: docId) { - do { - let handle = try await self.resolveDocHandle(id: docId) - let syncState = self.syncState(id: docId, peer: msg.senderId) - // Apply the request message as a sync update - try handle.doc.receiveSyncMessage(state: syncState, message: msg.data) - // Stash the updated doc and sync state - await self.updateDoc(id: docId, doc: handle.doc) - await self.updateSyncState(id: docId, peer: msg.senderId, syncState: syncState) - // Attempt to generate a sync message to reply - if let syncData = handle.doc.generateSyncMessage(state: syncState) { - let syncMsg: SyncV1Msg = .sync(.init( - documentId: docId.description, - senderId: self.peerId, - targetId: msg.senderId, - sync_message: syncData - )) - await network.send(message: syncMsg, to: msg.senderId) - } // else no sync is needed, syncstate reports that they have everything they need - } catch { - let err: SyncV1Msg = - .error(.init(message: "Unable to resolve document: \(error.localizedDescription)")) - await network.send(message: err, to: msg.senderId) - } - } else { - let nope = SyncV1Msg.UnavailableMsg( - documentId: msg.documentId, - senderId: self.peerId, - targetId: msg.senderId - ) - await network.send(message: .unavailable(nope), to: msg.senderId) - } - - } else { - let nope = SyncV1Msg.UnavailableMsg( - documentId: msg.documentId, - senderId: self.peerId, - targetId: msg.senderId - ) - await network.send(message: .unavailable(nope), to: msg.senderId) - } - } - - // MARK: PUBLIC API - - /// Creates a new Automerge document, storing it and sharing the creation with connected peers. - /// - Returns: The Automerge document. - public func create() async throws -> DocHandle { - let handle = InternalDocHandle(id: DocumentId(), isNew: true, initialValue: Document()) - self.handles[handle.id] = handle - let resolved = try await resolveDocHandle(id: handle.id) - return resolved - } - - /// Creates a new Automerge document, storing it and sharing the creation with connected peers. - /// - Returns: The Automerge document. - /// - Parameter id: The Id of the Automerge document. - public func create(id: DocumentId) async throws -> DocHandle { - let handle = InternalDocHandle(id: id, isNew: true, initialValue: Document()) - self.handles[handle.id] = handle - let resolved = try await resolveDocHandle(id: handle.id) - return resolved - } - - /// Creates a new Automerge document, storing it and sharing the creation with connected peers. - /// - Parameter doc: The Automerge document to use for the new, shared document - /// - Returns: The Automerge document. - public func create(doc: Document, id: DocumentId? = nil) async throws -> DocHandle { - let creationId = id ?? DocumentId() - let handle = InternalDocHandle(id: creationId, isNew: true, initialValue: doc) - self.handles[handle.id] = handle - let resolved = try await resolveDocHandle(id: handle.id) - return resolved - } - - /// Creates a new Automerge document, storing it and sharing the creation with connected peers. - /// - Parameter data: The data to load as an Automerge document for the new, shared document. - /// - Returns: The Automerge document. - public func create(data: Data, id: DocumentId? = nil) async throws -> DocHandle { - let creationId = id ?? DocumentId() - let handle = try InternalDocHandle(id: creationId, isNew: true, initialValue: Document(data)) - self.handles[handle.id] = handle - let resolved = try await resolveDocHandle(id: handle.id) - return resolved - } - - /// Clones a document the repo already knows to create a new, shared document. - /// - Parameter id: The id of the document to clone. - /// - Returns: The Automerge document. - public func clone(id: DocumentId) async throws -> DocHandle { - let handle = try await resolveDocHandle(id: id) - let fork = handle.doc.fork() - let newId = DocumentId() - let newHandle = InternalDocHandle(id: newId, isNew: false, initialValue: fork) - handles[newHandle.id] = newHandle - let resolved = try await resolveDocHandle(id: newHandle.id) - return resolved - } - - public func find(id: DocumentId) async throws -> DocHandle { - // generally of the idea that we'll drive DocHandle state updates from within Repo - // and these async methods - let handle: InternalDocHandle - if let knownHandle = handles[id] { - handle = knownHandle - } else { - let newHandle = InternalDocHandle(id: id, isNew: false) - handles[id] = newHandle - handle = newHandle - } - return try await resolveDocHandle(id: handle.id) - } - - /// Deletes an automerge document from the repo. - /// - Parameter id: The id of the document to remove. - /// - /// > NOTE: deletes do not propagate to connected peers. - public func delete(id: DocumentId) async throws { - guard let originalDocHandle = handles[id] else { - throw Errors.Unavailable(id: id) - } - originalDocHandle.state = .deleted - originalDocHandle.doc = nil - // STRUCT ONLY handles[id] = originalDocHandle - - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await self.purgeFromStorage(id: id) - } - // specifically call/wait in case we get an error from - // the delete process in purging the document. - try await group.next() - } - } - - /// Export the data associated with an Automerge document from the repo. - /// - Parameter id: The id of the document to export. - /// - Returns: The latest, compacted data of the Automerge document. - public func export(id: DocumentId) async throws -> Data { - let handle = try await self.resolveDocHandle(id: id) - return handle.doc.save() - } - - /// Imports data as a new Automerge document - /// - Parameter data: The data to import as an Automerge document - /// - Returns: The id of the document that was created on import. - public func `import`(data: Data) async throws -> DocHandle { - let handle = try InternalDocHandle(id: DocumentId(), isNew: true, initialValue: Document(data)) - self.handles[handle.id] = handle - return try await self.resolveDocHandle(id: handle.id) - } - - public func subscribeToRemotes(remotes _: [STORAGE_ID]) async {} - - /// The storage id of this repo, if any. - /// - Returns: The storage id from the repo's storage provider or nil. - public func storageId() async -> STORAGE_ID? { - if let storage { - return await storage.id - } - return nil - } - - // MARK: Methods to expose retrieving DocHandles to the subsystems - - func syncState(id: DocumentId, peer: PEER_ID) -> SyncState { - guard let handle = handles[id] else { - fatalError("No stored dochandle for id: \(id)") - } - if let handleSyncState = handle.syncStates[peer] { - Logger.repo.trace("Providing stored sync state for doc \(id)") - return handleSyncState - } else { - // TODO: add attempt to load from storage and return it before creating a new one - Logger.repo.trace("No stored sync state for doc \(id) and peer \(peer).") - Logger.repo.trace("Creating a new sync state for doc \(id)") - return SyncState() - } - } - - func updateSyncState(id: DocumentId, peer: PEER_ID, syncState: SyncState) async { - guard let handle = handles[id] else { - fatalError("No stored dochandle for id: \(id)") - } - Logger.repo.trace("Storing updated sync state for doc \(id) and peer \(peer).") - handle.syncStates[peer] = syncState - } - - func markDocUnavailable(id: DocumentId) async { - // handling a requested document being marked as unavailable after all peers have been checked - guard let handle = handles[id] else { - Logger.repo.error("missing handle for documentId \(id.description) while attempt to mark unavailable") - return - } - assert(handle.state == .requesting) - handle.state = .unavailable - handles[id] = handle - } - - func updateDoc(id: DocumentId, doc: Document) async { - // handling a requested document being marked as ready after document contents received - guard let handle = handles[id] else { - fatalError("No stored document handle for document id: \(id)") - } - if handle.state == .requesting { - handle.state = .ready - } - assert(handle.state == .ready) - handle.doc = doc - if let storage = self.storage { - do { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await storage.saveDoc(id: id, doc: doc) - } - // specifically call/wait in case we get an error from - // the delete process in purging the document. - try await group.next() - } - } catch { - Logger.repo - .warning( - "Error received while attempting to store document ID \(id): \(error.localizedDescription)" - ) - } - } - } - - // MARK: Methods to resolve docHandles - - func merge(id: DocumentId, with: DocumentId) async throws { - guard let handle1 = handles[id] else { - throw Errors.DocUnavailable(id: id) - } - guard let handle2 = handles[with] else { - throw Errors.DocUnavailable(id: with) - } - - let doc1 = try await resolveDocHandle(id: handle1.id) - // Start with updating from storage changes, if any - if let doc1Storage = try await storage?.loadDoc(id: handle1.id) { - try doc1.doc.merge(other: doc1Storage) - } - - // merge in the provided second document from memory - let doc2 = try await resolveDocHandle(id: handle2.id) - try doc1.doc.merge(other: doc2.doc) - - // JUST IN CASE, try and load doc2 from storage and merge that if available - if let doc2Storage = try await storage?.loadDoc(id: handle2.id) { - try doc1.doc.merge(other: doc2Storage) - } - // finally, update the repo - await self.updateDoc(id: doc1.id, doc: doc1.doc) - } - - private func loadFromStorage(id: DocumentId) async throws -> Document? { - guard let storage = self.storage else { - return nil - } - return try await storage.loadDoc(id: id) - } - - private func purgeFromStorage(id: DocumentId) async throws { - guard let storage = self.storage else { - return - } - try await storage.purgeDoc(id: id) - } - - private func resolveDocHandle(id: DocumentId) async throws -> DocHandle { - if let handle: InternalDocHandle = handles[id] { - switch handle.state { - case .idle: - if handle.doc != nil { - // if there's an Automerge document in memory, jump to ready - handle.state = .ready - // STRUCT ONLY handles[id] = handle - } else { - // otherwise, first attempt to load it from persistent storage - // (if available) - handle.state = .loading - // STRUCT ONLY handles[id] = handle - } - return try await resolveDocHandle(id: id) - case .loading: - // Do we have the document - if let docFromHandle = handle.doc { - // We have the document - so being in loading means "try to save this to - // a storage provider, if one exists", then hand it back as good. - if let storage = self.storage { - await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await storage.saveDoc(id: id, doc: docFromHandle) - } - // DO NOT wait/see if there's an error in the repo attempting to - // store the document - this gives us a bit of "best effort" functionality - // TODO: consider making this a parameter, or review this choice before release - // specifically call/wait in case we get an error from - // the delete process in purging the document. - // try await group.next() - // - // if we want to change this, uncomment the `try await` above and - // convert the `withThrowingTaskGroup` to `try await` as well. - } - } - // TODO: if we're allowed and prolific in gossip, notify any connected - // peers there's a new document before jumping to the 'ready' state - handle.state = .ready - // STRUCT ONLY handles[id] = handle - return DocHandle(id: id, doc: docFromHandle) - } else { - // We don't have the underlying Automerge document, so attempt - // to load it from storage, and failing that - if the storage provider - // doesn't exist, for example - jump forward to attempting to fetch - // it from a peer. - if let doc = try await loadFromStorage(id: id) { - handle.state = .ready - // STRUCT ONLY handles[id] = handle - return DocHandle(id: id, doc: doc) - } else { - handle.state = .requesting - // STRUCT ONLY handles[id] = handle - pendingRequestReadAttempts[id] = 0 - try await self.network.startRemoteFetch(id: handle.id) - return try await resolveDocHandle(id: id) - } - } - case .requesting: - guard let updatedHandle = handles[id] else { - throw Errors.DocUnavailable(id: handle.id) - } - if let doc = updatedHandle.doc, updatedHandle.state == .ready { - return DocHandle(id: id, doc: doc) - } else { - guard let previousRequests = pendingRequestReadAttempts[id] else { - throw Errors.DocUnavailable(id: id) - } - if previousRequests < maxRetriesForFetch { - // we are racing against the receipt of a network result - // to see what we get at the end - try await Task.sleep(for: pendingRequestWaitDuration) - return try await resolveDocHandle(id: id) - } else { - throw Errors.DocUnavailable(id: id) - } - } - case .ready: - guard let doc = handle.doc else { fatalError("DocHandle state is ready, but ._doc is null") } - return DocHandle(id: id, doc: doc) - case .unavailable: - throw Errors.DocUnavailable(id: handle.id) - case .deleted: - throw Errors.DocDeleted(id: handle.id) - } - } else { - throw Errors.DocUnavailable(id: id) - } - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/RepoTypes.swift b/Packages/automerge-repo/Sources/AutomergeRepo/RepoTypes.swift deleted file mode 100644 index 6bb0e39c..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/RepoTypes.swift +++ /dev/null @@ -1,24 +0,0 @@ -import struct Foundation.Data -import struct Foundation.UUID - -/// A type that represents a peer -/// -/// Typically a UUID4 in string form. -public typealias PEER_ID = String - -/// A type that represents an identity for the storage of a peer. -/// -/// Typically a UUID4 in string form. Receiving peers may tie cached sync state for documents to this identifier. -public typealias STORAGE_ID = String - -/// The external representation of a document Id. -/// -/// Typically a string that is 16 bytes of data encoded in bs58 format. -public typealias MSG_DOCUMENT_ID = String -// internally, DOCUMENT_ID is represented by the internal type DocumentId - -/// A type that represents the raw bytes of an Automerge sync message. -public typealias SYNC_MESSAGE = Data - -/// A type that represents the raw bytes of a set of encoded changes to an Automerge document. -public typealias CHUNK = Data diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/SharePolicy.swift b/Packages/automerge-repo/Sources/AutomergeRepo/SharePolicy.swift deleted file mode 100644 index ffff6df9..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/SharePolicy.swift +++ /dev/null @@ -1,29 +0,0 @@ -/// A type that determines if a document may be shared with a peer -public protocol SharePolicy: Sendable { - /// Returns a Boolean value that indicates whether a document may be shared. - /// - Parameters: - /// - peer: The peer to potentially share with - /// - docId: The document Id to share - func share(peer: PEER_ID, docId: DocumentId) async -> Bool -} - -#warning("REWORK THIS SETUP") -// it's annoying as hell to have to specify the SharePolicies.agreeable kind of setup just to get -// this. Seems better to make SharePolicy a struct, rename the protocol to allow for -// generics/existential use, and add some static let variants onto the type itself. -public enum SharePolicies: Sendable { - public static let agreeable = AlwaysPolicy() - public static let readonly = NeverPolicy() - - public struct AlwaysPolicy: SharePolicy { - public func share(peer _: PEER_ID, docId _: DocumentId) async -> Bool { - true - } - } - - public struct NeverPolicy: SharePolicy { - public func share(peer _: PEER_ID, docId _: DocumentId) async -> Bool { - false - } - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Storage/DocumentStorage.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Storage/DocumentStorage.swift deleted file mode 100644 index 34b84eca..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Storage/DocumentStorage.swift +++ /dev/null @@ -1,235 +0,0 @@ -import Automerge -import Foundation -import OSLog - -// inspired from automerge-repo: -// https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo/src/storage/StorageSubsystem.ts - -/// A type that provides coordinated, concurrency safe access to persist Automerge documents. -public actor DocumentStorage { - let chunkNamespace = "incrChanges" - var compacting: Bool - let _storage: any StorageProvider - var latestHeads: [DocumentId: Set] - - var storedChunkSize: [DocumentId: Int] - var memoryChunkSize: [DocumentId: Int] - var storedDocSize: [DocumentId: Int] - - var chunks: [DocumentId: [Data]] - - /// Creates a new concurrency safe document storage instance to manage changes to Automerge documents. - /// - Parameter storage: The storage provider - public init(_ storage: some StorageProvider) { - compacting = false - _storage = storage - latestHeads = [:] - chunks = [:] - - // memo-ized sizes of documents and chunks so that we don't always have to - // iterate through the storage provider (disk accesses, or even network accesses) - // to get a size determination to know if we should compact or not. - // (used in function`shouldCompact(:DocumentId)`) - storedChunkSize = [:] - memoryChunkSize = [:] - storedDocSize = [:] - } - - public var id: STORAGE_ID { - _storage.id - } - - /// Removes a document from persistent storage. - /// - Parameter id: The id of the document to remove. - public func purgeDoc(id: DocumentId) async throws { - try await _storage.remove(id: id) - } - - /// Returns an existing, or creates a new, document for the document Id you provide. - /// - /// The method throws errors from the underlying storage system or Document errors if the - /// loaded data was corrupt or incorrect. - /// - /// - Parameter id: The document Id - /// - Returns: An automerge document. - public func loadDoc(id: DocumentId) async throws -> Document { - var combined: Data - let storageChunks = try await _storage.loadRange(id: id, prefix: chunkNamespace) - if chunks[id] == nil { - chunks[id] = [] - } - let inMemChunks: [Data] = chunks[id] ?? [] - - if let baseData = try await _storage.load(id: id) { - // loading all the changes from the base document and any incremental saves available - combined = baseData - storedDocSize[id] = baseData.count - } else { - // loading only incremental saves available, the base document doesn't exist in storage - combined = Data() - storedDocSize[id] = 0 - } - - var inMemSize = memoryChunkSize[id] ?? 0 - for chunk in inMemChunks { - inMemSize += chunk.count - combined.append(chunk) - } - memoryChunkSize[id] = inMemSize - - var storedChunks = storedChunkSize[id] ?? 0 - for chunk in storageChunks { - storedChunks += chunk.count - combined.append(chunk) - } - storedChunkSize[id] = storedChunks - let combinedDoc = try Document(combined) - latestHeads[id] = combinedDoc.heads() - - return combinedDoc - } - - /// Determine if a documentId should be compacted. - /// - Parameter key: the document Id to analyze - /// - Returns: a Boolean value that indicates whether the document should be compacted. - func shouldCompact(_ key: DocumentId) async throws -> Bool { - if compacting { - return false - } - let inMemSize = memoryChunkSize[key] ?? (chunks[key] ?? []).reduce(0) { incrSize, data in - incrSize + data.count - } - - let baseSize = if let i = storedDocSize[key] { - i - } else { - try await _storage.load(id: key)?.count ?? 0 - } - - let chunkSize = if let j = storedChunkSize[key] { - j - } else { - try await _storage.loadRange(id: key, prefix: chunkNamespace).reduce(0) { incrSize, data in - incrSize + data.count - } - } - return chunkSize > baseSize || inMemSize > baseSize - } - - /// Determine if the document provided has changes not represented by the underlying storage system - /// - Parameters: - /// - key: The Id of the document - /// - doc: The Automerge document - /// - Returns: A Boolean value that indicates the document has changes. - func shouldSave(for key: DocumentId, doc: Document) -> Bool { - guard let storedHeads = self.latestHeads[key] else { - return true - } - let newHeads = doc.heads() - if newHeads == storedHeads { - return false - } - return true - } - - /// Saves a document to the storage backend, compacting it if needed. - /// - Parameters: - /// - id: The Id of the document - /// - doc: The automerge document - public func saveDoc(id: DocumentId, doc: Document) async throws { - if shouldSave(for: id, doc: doc) { - if try await shouldCompact(id) { - try await compact(id: id, doc: doc) - self.chunks[id] = [] - } else { - try await self.saveIncremental(id: id, doc: doc) - } - } - } - - /// A concurrency safe compaction routine to consolidate in-memory and stored incremental changes into a compacted - /// Automerge document. - /// - Parameters: - /// - id: The document Id to compact - /// - doc: The document to compact. - public func compact(id: DocumentId, doc: Document) async throws { - compacting = true - let providedData = doc.save() - var combined: Data = if let baseData = try await _storage.load(id: id) { - // loading all the changes from the base document and any incremental saves available - baseData - } else { - // loading only incremental saves available, the base document doesn't exist in storage - Data() - } - - combined.append(providedData) - - let inMemChunks: [Data] = chunks[id] ?? [] - var foundChunkHashValues: [Int] = [] - for chunk in inMemChunks { - foundChunkHashValues.append(chunk.hashValue) - combined.append(chunk) - } - - let storageChunks = try await _storage.loadRange(id: id, prefix: chunkNamespace) - for chunk in storageChunks { - combined.append(chunk) - } - - let compactedDoc = try Document(combined) - - let compactedData = compactedDoc.save() - // only remove the chunks AFTER the save is complete - try await _storage.save(id: id, data: compactedData) - storedDocSize[id] = compactedData.count - latestHeads[id] = compactedDoc.heads() - - // refresh the inMemChunks in case its changed (possible with re-entrancy, due to - // the possible suspension points at each of the above `await` statements since we - // grabbed the in memeory reference and made a copy) - var updatedMemChunks = chunks[id] ?? [] - for d in inMemChunks { - if let indexToRemove = updatedMemChunks.firstIndex(of: d) { - updatedMemChunks.remove(at: indexToRemove) - } - } - chunks[id] = updatedMemChunks - memoryChunkSize[id] = updatedMemChunks.reduce(0) { incrSize, data in - incrSize + data.count - } - - // now iterate through and remove the stored chunks we loaded earlier - // Doing this last, intentionally - it's another suspension point, and IF someone - // reads the base document and appends the found changes in a load, they'll still - // end up with the same document, so these can safely be removed _after_ the new - // compacted document has been stored away by the underlying storage provider. - try await _storage.removeRange(id: id, prefix: chunkNamespace, data: storageChunks) - storedChunkSize[id] = try await _storage.loadRange(id: id, prefix: chunkNamespace) - .reduce(0) { incrSize, data in - incrSize + data.count - } - - compacting = false - } - - /// Save incremental changes of the existing Automerge document. - /// - Parameters: - /// - id: The Id of the document - /// - doc: The automerge document - public func saveIncremental(id: DocumentId, doc: Document) async throws { - var chunkCollection = chunks[id] ?? [] - let oldHeads = latestHeads[id] ?? Set() - let incrementalChanges = try doc.encodeChangesSince(heads: oldHeads) - chunkCollection.append(incrementalChanges) - chunks[id] = chunkCollection - try await _storage.addToRange(id: id, prefix: chunkNamespace, data: incrementalChanges) - latestHeads[id] = doc.heads() - } - -// public func loadSyncState(id _: DocumentId, storageId _: SyncV1.STORAGE_ID) async -> SyncState { -// SyncState() -// } -// -// public func saveSyncState(id _: DocumentId, storageId _: SyncV1.STORAGE_ID, state _: SyncState) async {} -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Storage/StorageProvider.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Storage/StorageProvider.swift deleted file mode 100644 index f6b448d8..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Storage/StorageProvider.swift +++ /dev/null @@ -1,18 +0,0 @@ -import struct Foundation.Data - -// loose adaptation from automerge-repo storage interface -// https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo/src/storage/StorageAdapter.ts -/// A type that provides a an interface for persisting the changes of Automerge documents by Id -public protocol StorageProvider: Sendable { - var id: STORAGE_ID { get } - - func load(id: DocumentId) async throws -> Data? - func save(id: DocumentId, data: Data) async throws - func remove(id: DocumentId) async throws - - // MARK: Incremental Load Support - - func addToRange(id: DocumentId, prefix: String, data: Data) async throws - func loadRange(id: DocumentId, prefix: String) async throws -> [Data] - func removeRange(id: DocumentId, prefix: String, data: [Data]) async throws -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/CBORCoder.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Sync/CBORCoder.swift deleted file mode 100644 index 9160ac73..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/CBORCoder.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation -import PotentCBOR - -/// A type that provides concurrency-safe access to the CBOR encoder and decoder. -public actor CBORCoder { - public static let encoder = CBOREncoder() - public static let decoder = CBORDecoder() -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/DocumentSyncCoordinator.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Sync/DocumentSyncCoordinator.swift deleted file mode 100644 index 42344e19..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/DocumentSyncCoordinator.swift +++ /dev/null @@ -1,399 +0,0 @@ -import Automerge -import Combine -import Foundation -import Network -import OSLog -#if os(iOS) -import UIKit // for UIDevice.name access -#endif - -/// A collection of User Default keys for the app. -public enum SynchronizerDefaultKeys: Sendable { - /// The key to the string that the app broadcasts to represent you when sharing and syncing Automerge Documents. - public static let publicPeerName = "sharingIdentity" -} - -/// A global actor for safely isolating the state updates for the DocumentSyncCoordinator -@globalActor -public actor SyncController { - public static let shared = SyncController() -} - -/// A application-shared sync controller that supports coordinates documents and network connections with peers. -@MainActor -public final class DocumentSyncCoordinator: ObservableObject { - public static let shared = DocumentSyncCoordinator() - - var documents: [DocumentId: WeakDocumentRef] = [:] - var txtRecords: [DocumentId: NWTXTRecord] = [:] - var listeners: [DocumentId: NWListener] = [:] - - @Published public var listenerState: [DocumentId: NWListener.State] = [:] - - /// Looks up and returns a reference for a document for an initiated Peer Connection - /// - /// Primarily in order to attempt to send and receive sync updates. - public func automergeDocument(for docId: DocumentId) -> Document? { - documents[docId]?.value - } - - public func registerDocument(document: Automerge.Document, id: DocumentId? = nil) { - let documentId: DocumentId = id ?? DocumentId() - documents[documentId] = WeakDocumentRef(document) - var txtRecord = NWTXTRecord() - txtRecord[TXTRecordKeys.name] = name - txtRecord[TXTRecordKeys.peer_id] = peerId.uuidString - txtRecord[TXTRecordKeys.doc_id] = documentId.description - txtRecords[documentId] = txtRecord - } - - @Published public var name: String { - didSet { - // update a listener, if running, with the new name. - resetName(name) - } - } - - var browser: NWBrowser? - @Published public var browserResults: [NWBrowser.Result] = [] - @Published public var browserState: NWBrowser.State = .setup - public var autoconnect: Bool - - @Published public var connections: [BonjourSyncConnection] = [] - - func removeConnection(_ connectionId: UUID) { - connections.removeAll { $0.connectionId == connectionId } - } - - @Published public var listenerSetupError: (any Error)? = nil - @Published public var listenerStatusError: NWError? = nil - - let peerId = UUID() - let syncQueue = DispatchQueue(label: "PeerSyncQueue") - var timerCancellable: (any Cancellable)? - var syncTrigger: PassthroughSubject = PassthroughSubject() - - public static func defaultSharingIdentity() -> String { - #if os(iOS) - UIDevice().name - #elseif os(macOS) - Host.current().localizedName ?? "Automerge User" - #endif - } - - init() { - self.name = UserDefaults.standard - .string(forKey: SynchronizerDefaultKeys.publicPeerName) ?? DocumentSyncCoordinator.defaultSharingIdentity() - Logger.syncController.debug("SYNC CONTROLLER INIT, peer \(self.peerId.uuidString, privacy: .public)") - #if os(iOS) - autoconnect = true - #elseif os(macOS) - autoconnect = false - #endif - } - - public func activate() { - Logger.syncController.debug("SYNC PEER \(self.peerId.uuidString, privacy: .public): ACTIVATE") - browserState = .setup - startBrowsing() - for documentId in documents.keys { - listenerState[documentId] = .setup - setupBonjourListener(for: documentId) - } - timerCancellable = Timer.publish(every: .milliseconds(100), on: .main, in: .default) - .autoconnect() - .receive(on: syncQueue) - .sink(receiveValue: { [weak self] _ in - self?.syncTrigger.send() - }) - } - - public func deactivate() { - Logger.syncController.debug("SYNC PEER \(self.peerId.uuidString, privacy: .public): CANCEL") - timerCancellable?.cancel() - stopBrowsing() - stopListening() - cancelAllConnections() - timerCancellable = nil - } - - // MARK: NWBrowser - - public func attemptToConnectToPeer(_ endpoint: NWEndpoint, forPeer peerId: String, withDoc documentId: DocumentId) { - Logger.syncController - .debug( - "Attempting to establish connection to \(peerId, privacy: .public) through \(endpoint.debugDescription, privacy: .public) " - ) - if connections.filter({ conn in - conn.peerId == peerId - }).isEmpty { - Logger.syncController - .debug("No connection stored for \(peerId, privacy: .public)") - let newConnection = BonjourSyncConnection( - endpoint: endpoint, - peerId: peerId, - trigger: syncTrigger.eraseToAnyPublisher(), - documentId: documentId - ) - DispatchQueue.main.async { - self.connections.append(newConnection) - } - } - } - - func delayAndAttemptToConnect(_ endpoint: NWEndpoint, forPeer peerId: String, withDoc documentId: DocumentId) { - Task { - let delay = Int.random(in: 250 ... 1000) - Logger.syncController - .info( - "Delaying \(delay, privacy: .public) ms before attempting connect to \(peerId, privacy: .public) at \(endpoint.debugDescription, privacy: .public)" - ) - try await Task.sleep(until: .now + .milliseconds(delay), clock: .continuous) - self.attemptToConnectToPeer(endpoint, forPeer: peerId, withDoc: documentId) - } - } - - // Start browsing for services. - fileprivate func startBrowsing() { - // Create parameters, and allow browsing over a peer-to-peer link. - let browserNetworkParameters = NWParameters() - browserNetworkParameters.includePeerToPeer = true - - // Browse for the Automerge sync bonjour service type. - let newNetworkBrowser = NWBrowser( - for: .bonjourWithTXTRecord(type: P2PAutomergeSyncProtocol.bonjourType, domain: nil), - using: browserNetworkParameters - ) - - newNetworkBrowser.stateUpdateHandler = { @MainActor newState in - switch newState { - case let .failed(error): - self.browserState = .failed(error) - // Restart the browser if it loses its connection. - if error == NWError.dns(DNSServiceErrorType(kDNSServiceErr_DefunctConnection)) { - Logger.syncController.info("Browser failed with \(error, privacy: .public), restarting") - newNetworkBrowser.cancel() - self.startBrowsing() - } else { - Logger.syncController.warning("Browser failed with \(error, privacy: .public), stopping") - newNetworkBrowser.cancel() - } - case .ready: - self.browserState = .ready - case .cancelled: - self.browserState = .cancelled - self.browserResults = [] - default: - break - } - } - - newNetworkBrowser.browseResultsChangedHandler = { @MainActor [weak self] results, _ in - Logger.syncController.debug("browser update shows \(results.count, privacy: .public) result(s):") - for res in results { - Logger.syncController - .debug( - " \(res.endpoint.debugDescription, privacy: .public) \(res.metadata.debugDescription, privacy: .public)" - ) - } - guard let self else { - return - } - // Only show broadcasting peers that doesn't have the name provided by this app. - let filtered = results.filter { result in - if case let .bonjour(txtrecord) = result.metadata, - txtrecord[TXTRecordKeys.peer_id] != self.peerId.uuidString - { - return true - } - return false - } - .sorted(by: { - $0.hashValue < $1.hashValue - }) - - self.browserResults = filtered - - if self.autoconnect { - // check list of current connections, if not in it - enqueue for connecting - for potentialPeer in filtered { - Logger.syncController - .debug("Checking potential peer \(potentialPeer.endpoint.debugDescription, privacy: .public)") - if case let .bonjour(txtrecord) = potentialPeer.metadata { - if let remotePeerIdString = txtrecord[TXTRecordKeys.peer_id], - remotePeerIdString != self.peerId.uuidString, - let peerDocumentId = DocumentId(txtrecord[TXTRecordKeys.doc_id]) - { - if documents[peerDocumentId] != nil { - self.delayAndAttemptToConnect( - potentialPeer.endpoint, - forPeer: remotePeerIdString, - withDoc: peerDocumentId - ) - } - } - } - } - } - } - - Logger.syncController.info("Activating NWBrowser \(newNetworkBrowser.debugDescription, privacy: .public)") - self.browser = newNetworkBrowser - // Start browsing and ask for updates on the main queue. - newNetworkBrowser.start(queue: .main) - } - - fileprivate func stopBrowsing() { - guard let browser else { return } - Logger.syncController.info("Terminating NWBrowser") - browser.cancel() - self.browser = nil - } - - fileprivate func cancelAllConnections() { - for conn in connections { - conn.cancel() - } - } - - // MARK: NWListener handlers - - // Start listening and advertising. - fileprivate func setupBonjourListener(for documentId: DocumentId) { - guard let txtRecordForDoc = txtRecords[documentId] else { - Logger.syncController - .warning( - "Attempting to establish listener for unregistered document: \(documentId, privacy: .public)" - ) - return - } - do { - // Create the listener object. - let listener = try NWListener(using: NWParameters.peerSyncParameters(documentId: documentId)) - // Set the service to advertise. - listener.service = NWListener.Service( - type: P2PAutomergeSyncProtocol.bonjourType, - txtRecord: txtRecordForDoc - ) - listener.stateUpdateHandler = { @MainActor [weak self] newState in - self?.listenerState[documentId] = newState - switch newState { - case .ready: - if let port = listener.port { - Logger.syncController - .info("Bonjour listener ready on \(port.rawValue, privacy: .public)") - } else { - Logger.syncController - .info("Bonjour listener ready (no port listed)") - } - self?.listenerStatusError = nil - case let .failed(error): - if error == NWError.dns(DNSServiceErrorType(kDNSServiceErr_DefunctConnection)) { - Logger.syncController - .warning("Bonjour listener failed with \(error, privacy: .public), restarting.") - listener.cancel() - self?.listeners.removeValue(forKey: documentId) - self?.setupBonjourListener(for: documentId) - } else { - Logger.syncController - .error("Bonjour listener failed with \(error, privacy: .public), stopping.") - self?.listenerStatusError = error - listener.cancel() - } - default: - self?.listenerStatusError = nil - } - } - - // The system calls this when a new connection arrives at the listener. - // Start the connection to accept it, or cancel to reject it. - listener.newConnectionHandler = { @MainActor [weak self] newConnection in - Logger.syncController - .debug( - "Receiving connection request from \(newConnection.endpoint.debugDescription, privacy: .public)" - ) - Logger.syncController - .debug( - " Connection details: \(newConnection.debugDescription, privacy: .public)" - ) - guard let self else { return } - - if connections.filter({ conn in - conn.endpoint == newConnection.endpoint - }).isEmpty { - Logger.syncController - .info( - "Endpoint not yet recorded, accepting connection from \(newConnection.endpoint.debugDescription, privacy: .public)" - ) - let peerConnection = BonjourSyncConnection( - connection: newConnection, - trigger: syncTrigger.eraseToAnyPublisher(), - documentId: documentId - ) - connections.append(peerConnection) - } else { - Logger.syncController - .info( - "Inbound connection already exists for \(newConnection.endpoint.debugDescription, privacy: .public), cancelling the connection request." - ) - // If we already have a connection to that endpoint, don't add another - newConnection.cancel() - } - } - - // Start listening, and request updates on the main queue. - listener.start(queue: .main) - listeners[documentId] = listener - Logger.syncController - .debug( - "Starting bonjour network listener for document id \(documentId, privacy: .public)" - ) - - } catch { - Logger.syncController - .critical( - "Failed to create bonjour listener for document id \(documentId, privacy: .public): \(error, privacy: .public)" - ) - listenerSetupError = error - } - } - - // Stop all listeners. - fileprivate func stopListening() { - for (documentId, listener) in listeners { - Logger.syncController.debug("Terminating NWListener for \(documentId, privacy: .public)") - listener.cancel() - listeners.removeValue(forKey: documentId) - } - listeners = [:] - } - - // Update the advertised name on the network. - fileprivate func resetName(_ name: String) { - for documentId in documents.keys { - if var txtRecord = txtRecords[documentId] { - txtRecord[TXTRecordKeys.name] = name - txtRecords[documentId] = txtRecord - - // Reset the service to advertise. - listeners[documentId]?.service = NWListener.Service( - type: P2PAutomergeSyncProtocol.bonjourType, - txtRecord: txtRecord - ) - Logger.syncController - .debug( - "Updated bonjour network listener to name \(name, privacy: .public) for document id \(documentId, privacy: .public)" - ) - } else { - Logger.syncController - .error( - "Unable to find TXTRecord for the registered Document: \(documentId, privacy: .public)" - ) - } - } - } -} - -// public extension DocumentSyncCoordinator { -// static let shared = DocumentSyncCoordinator() -// } diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/ProtocolState.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Sync/ProtocolState.swift deleted file mode 100644 index 05ab4e9a..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/ProtocolState.swift +++ /dev/null @@ -1,42 +0,0 @@ -/// The state of a sync protocol connection. -public enum ProtocolState: String { - /// The connection that has been created but not yet connected - case setup - - /// The connection is established, waiting to successfully peer with the recipient. - case preparing - - /// The connection successfully peered and is ready for use. - case ready - - /// The connection is cancelled, failed, or terminated. - case closed -} - -#if canImport(Network) -import class Network.NWConnection - -extension ProtocolState { - /// Translates a Network connection state into a protocol state - /// - Parameter connectState: The state of the network connection - /// - Returns: The corresponding protocol state - func from(_ connectState: NWConnection.State) -> Self { - switch connectState { - case .setup: - .setup - case .waiting: - .preparing - case .preparing: - .preparing - case .ready: - .ready - case .failed: - .closed - case .cancelled: - .closed - @unknown default: - fatalError() - } - } -} -#endif diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+Errors.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+Errors.swift deleted file mode 100644 index 7d8343d5..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+Errors.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation - -public extension SyncV1Msg { - enum Errors: Sendable { - public struct Timeout: Sendable, LocalizedError { - public var errorDescription: String = "Task timed out before completion" - } - - public struct SyncComplete: Sendable, LocalizedError { - public var errorDescription: String = "The synchronization process is complete" - } - - public struct ConnectionClosed: Sendable, LocalizedError { - public var errorDescription: String = "The websocket task was closed and/or nil" - } - - #warning("MOVE TO REPO ERRORS") - public struct InvalidURL: Sendable, LocalizedError { - public var urlString: String - public var errorDescription: String? { - "Invalid URL: \(urlString)" - } - } - - public struct UnexpectedMsg: Sendable, LocalizedError { - public var msg: MSG - public var errorDescription: String? { - "Received an unexpected message: \(msg)" - } - } - - public struct DocumentUnavailable: Sendable, LocalizedError { - public var errorDescription: String = "The requested document isn't available" - } - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+encode+decode.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+encode+decode.swift deleted file mode 100644 index 7879eff0..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+encode+decode.swift +++ /dev/null @@ -1,314 +0,0 @@ -import Foundation // Data -import OSLog -import PotentCBOR - -public extension SyncV1Msg { - /// Attempts to decode the data you provide as a peer message. - /// - /// - Parameter data: The data to decode - /// - Returns: The decoded message, or ``SyncV1/unknown(_:)`` if the decoding attempt failed. - static func decodePeer(_ data: Data) -> SyncV1Msg { - if let peerMsg = attemptPeer(data) { - .peer(peerMsg) - } else { - .unknown(data) - } - } - - /// Decodes a Peer2Peer message data block using the message type you provide - /// - Parameters: - /// - data: The data to be decoded - /// - msgType: The type of message to decode. - /// - Returns: The decoded message. - internal static func decode(_ data: Data, as msgType: P2PSyncMessageType) -> SyncV1Msg { - switch msgType { - case .unknown: - return .unknown(data) - case .sync: - if let msgData = attemptSync(data) { - return .sync(msgData) - } - case .id: - return .unknown(data) - - case .leave: - if let msgData = attemptLeave(data) { - return .leave(msgData) - } - case .peer: - if let msgData = attemptPeer(data) { - return .peer(msgData) - } - case .join: - if let msgData = attemptJoin(data) { - return .join(msgData) - } - case .request: - if let msgData = attemptRequest(data) { - return .request(msgData) - } - case .unavailable: - if let msgData = attemptUnavailable(data) { - return .unavailable(msgData) - } - case .ephemeral: - if let msgData = attemptEphemeral(data) { - return .ephemeral(msgData) - } - case .syncerror: - if let msgData = attemptError(data) { - return .error(msgData) - } - case .remoteHeadsChanged: - if let msgData = attemptRemoteHeadsChanged(data) { - return .remoteHeadsChanged(msgData) - } - case .remoteSubscriptionChange: - if let msgData = attemptRemoteSubscriptionChange(data) { - return .remoteSubscriptionChange(msgData) - } - } - return .unknown(data) - } - - /// Exhaustively attempt to decode incoming data as V1 protocol messages. - /// - /// - Parameters: - /// - data: The data to decode. - /// - withGossip: A Boolean value that indicates whether to include decoding of handshake messages. - /// - withHandshake: A Boolean value that indicates whether to include decoding of gossip messages. - /// - Returns: The decoded message, or ``SyncV1/unknown(_:)`` if the previous decoding attempts failed. - /// - /// The decoding is ordered from the perspective of an initiating client expecting a response to minimize attempts. - /// Enable `withGossip` to attempt to decode head gossip messages, and `withHandshake` to include handshake phase - /// messages. - /// With both `withGossip` and `withHandshake` set to `true`, the decoding is exhaustive over all V1 messages. - static func decode(_ data: Data) -> SyncV1Msg { - var cborMsg: CBOR? = nil - - // attempt to deserialize CBOR message (in order to read the type from it) - do { - cborMsg = try CBORSerialization.cbor(from: data) - } catch { - Logger.webSocket.warning("Unable to CBOR decode incoming data: \(data)") - return .unknown(data) - } - // read the "type" of the message in order to choose the appropriate decoding path - guard let msgType = cborMsg?.mapValue?["type"]?.utf8StringValue else { - return .unknown(data) - } - - switch msgType { - case MsgTypes.peer: - if let peerMsg = attemptPeer(data) { - return .peer(peerMsg) - } - case MsgTypes.sync: - if let syncMsg = attemptSync(data) { - return .sync(syncMsg) - } - case MsgTypes.ephemeral: - if let ephemeralMsg = attemptEphemeral(data) { - return .ephemeral(ephemeralMsg) - } - case MsgTypes.error: - if let errorMsg = attemptError(data) { - return .error(errorMsg) - } - case MsgTypes.unavailable: - if let unavailableMsg = attemptUnavailable(data) { - return .unavailable(unavailableMsg) - } - case MsgTypes.join: - if let joinMsg = attemptJoin(data) { - return .join(joinMsg) - } - case MsgTypes.remoteHeadsChanged: - if let remoteHeadsChanged = attemptRemoteHeadsChanged(data) { - return .remoteHeadsChanged(remoteHeadsChanged) - } - case MsgTypes.request: - if let requestMsg = attemptRequest(data) { - return .request(requestMsg) - } - case MsgTypes.remoteSubscriptionChange: - if let remoteSubChangeMsg = attemptRemoteSubscriptionChange(data) { - return .remoteSubscriptionChange(remoteSubChangeMsg) - } - - default: - return .unknown(data) - } - return .unknown(data) - } - - // sync phase messages - - internal static func attemptSync(_ data: Data) -> SyncMsg? { - do { - return try CBORCoder.decoder.decode(SyncMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as SyncMsg") - } - return nil - } - - internal static func attemptRequest(_ data: Data) -> RequestMsg? { - do { - return try CBORCoder.decoder.decode(RequestMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as RequestMsg") - } - return nil - } - - internal static func attemptUnavailable(_ data: Data) -> UnavailableMsg? { - do { - return try CBORCoder.decoder.decode(UnavailableMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as UnavailableMsg") - } - return nil - } - - // handshake phase messages - - internal static func attemptPeer(_ data: Data) -> PeerMsg? { - do { - return try CBORCoder.decoder.decode(PeerMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as PeerMsg") - } - return nil - } - - internal static func attemptJoin(_ data: Data) -> JoinMsg? { - do { - return try CBORCoder.decoder.decode(JoinMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as JoinMsg") - } - return nil - } - - internal static func attemptLeave(_ data: Data) -> LeaveMsg? { - do { - return try CBORCoder.decoder.decode(LeaveMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as LeaveMsg") - } - return nil - } - - // error - - internal static func attemptError(_ data: Data) -> ErrorMsg? { - do { - return try CBORCoder.decoder.decode(ErrorMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as ErrorMsg") - } - return nil - } - - // ephemeral - - internal static func attemptEphemeral(_ data: Data) -> EphemeralMsg? { - do { - return try CBORCoder.decoder.decode(EphemeralMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as EphemeralMsg") - } - return nil - } - - // gossip - - internal static func attemptRemoteHeadsChanged(_ data: Data) -> RemoteHeadsChangedMsg? { - do { - return try CBORCoder.decoder.decode(RemoteHeadsChangedMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as RemoteHeadsChangedMsg") - } - return nil - } - - internal static func attemptRemoteSubscriptionChange(_ data: Data) -> RemoteSubscriptionChangeMsg? { - do { - return try CBORCoder.decoder.decode(RemoteSubscriptionChangeMsg.self, from: data) - } catch { - Logger.webSocket.warning("Failed to decode data as RemoteSubscriptionChangeMsg") - } - return nil - } - - // encode messages - - static func encode(_ msg: JoinMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: RequestMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: LeaveMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: SyncMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: PeerMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: UnavailableMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: EphemeralMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: RemoteSubscriptionChangeMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: RemoteHeadsChangedMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: ErrorMsg) throws -> Data { - try CBORCoder.encoder.encode(msg) - } - - static func encode(_ msg: SyncV1Msg) throws -> Data { - // not sure this is useful, but might as well finish out the set... - switch msg { - case let .peer(peerMsg): - try CBORCoder.encoder.encode(peerMsg) - case let .join(joinMsg): - try CBORCoder.encoder.encode(joinMsg) - case let .leave(leaveMsg): - try CBORCoder.encoder.encode(leaveMsg) - case let .error(errorMsg): - try CBORCoder.encoder.encode(errorMsg) - case let .request(requestMsg): - try CBORCoder.encoder.encode(requestMsg) - case let .sync(syncMsg): - try CBORCoder.encoder.encode(syncMsg) - case let .unavailable(unavailableMsg): - try CBORCoder.encoder.encode(unavailableMsg) - case let .ephemeral(ephemeralMsg): - try CBORCoder.encoder.encode(ephemeralMsg) - case let .remoteSubscriptionChange(remoteSubscriptionChangeMsg): - try CBORCoder.encoder.encode(remoteSubscriptionChangeMsg) - case let .remoteHeadsChanged(remoteHeadsChangedMsg): - try CBORCoder.encoder.encode(remoteHeadsChangedMsg) - case let .unknown(data): - data - } - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+messages.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+messages.swift deleted file mode 100644 index 53caceea..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg+messages.swift +++ /dev/null @@ -1,396 +0,0 @@ -import Foundation - -public extension SyncV1Msg { - // - join - - // { - // type: "join", - // senderId: peer_id, - // supportedProtocolVersions: protocol_version - // ? metadata: peer_metadata, - // } - - // MARK: Join/Peer - - /// A message that indicates a desire to peer and sync documents. - /// - /// Sent by the initiating peer (represented by `senderId`) to initiate a connection to manage documents between - /// peers. - /// The next response is expected to be a ``PeerMsg``. If any other message is received after sending `JoinMsg`, the - /// initiating client should disconnect. - /// If the receiving peer receives any message other than a `JoinMsg` from the initiating peer, it is expected to - /// terminate the connection. - struct JoinMsg: Sendable, Codable, CustomDebugStringConvertible { - public var type: String = SyncV1Msg.MsgTypes.join - public let senderId: PEER_ID - public var supportedProtocolVersions: String = "1" - public var peerMetadata: PeerMetadata? - - public init(senderId: PEER_ID, metadata: PeerMetadata? = nil) { - self.senderId = senderId - if let metadata { - self.peerMetadata = metadata - } - } - - public var debugDescription: String { - "JOIN[version: \(supportedProtocolVersions), sender: \(senderId), metadata: \(peerMetadata?.debugDescription ?? "nil")]" - } - } - - // - peer - (expected response to join) - // { - // type: "peer", - // senderId: peer_id, - // selectedProtocolVersion: protocol_version, - // targetId: peer_id, - // ? metadata: peer_metadata, - // } - - // example output from sync.automerge.org: - // { - // "type": "peer", - // "senderId": "storage-server-sync-automerge-org", - // "peerMetadata": {"storageId": "3760df37-a4c6-4f66-9ecd-732039a9385d", "isEphemeral": false}, - // "selectedProtocolVersion": "1", - // "targetId": "FA38A1B2-1433-49E7-8C3C-5F63C117DF09" - // } - - /// A message that acknowledges a join request. - /// - /// A response sent by a receiving peer (represented by `targetId`) after receiving a ``JoinMsg`` that indicates - /// sync, - /// gossiping, and ephemeral messages may now be initiated. - struct PeerMsg: Sendable, Codable, CustomDebugStringConvertible { - public var type: String = SyncV1Msg.MsgTypes.peer - public let senderId: PEER_ID - public let targetId: PEER_ID - public var peerMetadata: PeerMetadata? - public var selectedProtocolVersion: String - - public init(senderId: PEER_ID, targetId: PEER_ID, storageId: String?, ephemeral: Bool = true) { - self.senderId = senderId - self.targetId = targetId - self.selectedProtocolVersion = "1" - self.peerMetadata = PeerMetadata(storageId: storageId, isEphemeral: ephemeral) - } - - public var debugDescription: String { - "PEER[version: \(selectedProtocolVersion), sender: \(senderId), target: \(targetId), metadata: \(peerMetadata?.debugDescription ?? "nil")]" - } - } - - // - leave - - // { - // type: "leave" - // senderId: this.peerId - // } - - struct LeaveMsg: Sendable, Codable, CustomDebugStringConvertible { - public var type: String = SyncV1Msg.MsgTypes.leave - public let senderId: PEER_ID - - public init(senderId: PEER_ID) { - self.senderId = senderId - } - - public var debugDescription: String { - "LEAVE[sender: \(senderId)" - } - } - - // - error - - // { - // type: "error", - // message: str, - // } - - /// A sync error message - struct ErrorMsg: Sendable, Codable, CustomDebugStringConvertible { - public var type: String = SyncV1Msg.MsgTypes.error - public let message: String - - public init(message: String) { - self.message = message - } - - public var debugDescription: String { - "ERROR[msg: \(message)" - } - } - - // MARK: Sync - - // - request - - // { - // type: "request", - // documentId: document_id, - // ; The peer requesting to begin sync - // senderId: peer_id, - // targetId: peer_id, - // ; The initial automerge sync message from the sender - // data: sync_message - // } - - /// A request to synchronize an Automerge document. - /// - /// Sent when the initiating peer (represented by `senderId`) is asking to begin sync for the given document ID. - /// Identical to ``SyncMsg`` but indicates to the receiving peer that the sender would like an ``UnavailableMsg`` - /// message if the receiving peer (represented by `targetId` does not have the document (identified by - /// `documentId`). - struct RequestMsg: Sendable, Codable, CustomDebugStringConvertible { - public var type: String = SyncV1Msg.MsgTypes.request - public let documentId: MSG_DOCUMENT_ID - public let senderId: PEER_ID // The peer requesting to begin sync - public let targetId: PEER_ID - public let data: Data // The initial automerge sync message from the sender - - public init(documentId: MSG_DOCUMENT_ID, senderId: PEER_ID, targetId: PEER_ID, sync_message: Data) { - self.documentId = documentId - self.senderId = senderId - self.targetId = targetId - self.data = sync_message - } - - public var debugDescription: String { - "REQUEST[documentId: \(documentId), sender: \(senderId), target: \(targetId), data: \(data.count) bytes]" - } - } - - // - sync - - // { - // type: "sync", - // documentId: document_id, - // ; The peer requesting to begin sync - // senderId: peer_id, - // targetId: peer_id, - // ; The initial automerge sync message from the sender - // data: sync_message - // } - - /// A request to synchronize an Automerge document. - /// - /// Sent when the initiating peer (represented by `senderId`) is asking to begin sync for the given document ID. - /// Use `SyncMsg` instead of `RequestMsg` when you are creating a new Automerge document that you want to share. - /// - /// If the receiving peer doesn't have an Automerge document represented by `documentId` and can't or won't store - /// the - /// document. - struct SyncMsg: Sendable, Codable, CustomDebugStringConvertible { - public var type = SyncV1Msg.MsgTypes.sync - public let documentId: MSG_DOCUMENT_ID - public let senderId: PEER_ID // The peer requesting to begin sync - public let targetId: PEER_ID - public let data: Data // The initial automerge sync message from the sender - - public init(documentId: MSG_DOCUMENT_ID, senderId: PEER_ID, targetId: PEER_ID, sync_message: Data) { - self.documentId = documentId - self.senderId = senderId - self.targetId = targetId - self.data = sync_message - } - - public var debugDescription: String { - "SYNC[documentId: \(documentId), sender: \(senderId), target: \(targetId), data: \(data.count) bytes]" - } - } - - // - unavailable - - // { - // type: "doc-unavailable", - // senderId: peer_id, - // targetId: peer_id, - // documentId: document_id, - // } - - /// A message that indicates a document is unavailable. - /// - /// Generally a response for a ``RequestMsg`` from an initiating peer (represented by `senderId`) that the receiving - /// peer (represented by `targetId`) doesn't have a copy of the requested Document, or is unable to share it. - struct UnavailableMsg: Sendable, Codable, CustomDebugStringConvertible { - public var type = SyncV1Msg.MsgTypes.unavailable - public let documentId: MSG_DOCUMENT_ID - public let senderId: PEER_ID - public let targetId: PEER_ID - - public init(documentId: MSG_DOCUMENT_ID, senderId: PEER_ID, targetId: PEER_ID) { - self.documentId = documentId - self.senderId = senderId - self.targetId = targetId - } - - public var debugDescription: String { - "UNAVAILABLE[documentId: \(documentId), sender: \(senderId), target: \(targetId)]" - } - } - - // MARK: Ephemeral - - // - ephemeral - - // { - // type: "ephemeral", - // ; The peer who sent this message - // senderId: peer_id, - // ; The target of this message - // targetId: peer_id, - // ; The sequence number of this message within its session - // count: uint, - // ; The unique session identifying this stream of ephemeral messages - // sessionId: str, - // ; The document ID this ephemera relates to - // documentId: document_id, - // ; The data of this message (in practice this is arbitrary CBOR) - // data: bstr - // } - - struct EphemeralMsg: Sendable, Codable, CustomDebugStringConvertible { - public var type = SyncV1Msg.MsgTypes.ephemeral - public let senderId: PEER_ID - public let targetId: PEER_ID - public let count: UInt - public let sessionId: String - public let documentId: MSG_DOCUMENT_ID - public let data: Data - - public init( - senderId: PEER_ID, - targetId: PEER_ID, - count: UInt, - sessionId: String, - documentId: MSG_DOCUMENT_ID, - data: Data - ) { - self.senderId = senderId - self.targetId = targetId - self.count = count - self.sessionId = sessionId - self.documentId = documentId - self.data = data - } - - public var debugDescription: String { - "EPHEMERAL[documentId: \(documentId), sender: \(senderId), target: \(targetId), count: \(count), sessionId: \(sessionId), data: \(data.count) bytes]" - } - } - - // MARK: Head's Gossiping - - // - remote subscription changed - - // { - // type: "remote-subscription-change" - // senderId: peer_id - // targetId: peer_id - // - // ; The storage IDs to add to the subscription - // ? add: [* storage_id] - // - // ; The storage IDs to remove from the subscription - // remove: [* storage_id] - // } - - struct RemoteSubscriptionChangeMsg: Sendable, Codable, CustomDebugStringConvertible { - public var type = SyncV1Msg.MsgTypes.remoteSubscriptionChange - public let senderId: PEER_ID - public let targetId: PEER_ID - public var add: [STORAGE_ID]? - public var remove: [STORAGE_ID] - - public init(senderId: PEER_ID, targetId: PEER_ID, add: [STORAGE_ID]? = nil, remove: [STORAGE_ID]) { - self.senderId = senderId - self.targetId = targetId - self.add = add - self.remove = remove - } - - public var debugDescription: String { - var returnString = "REMOTE_SUBSCRIPTION_CHANGE[sender: \(senderId), target: \(targetId)]" - if let add { - returnString.append("\n add: [") - returnString.append(add.joined(separator: ",")) - returnString.append("]") - } - returnString.append("\n remove: [") - returnString.append(remove.joined(separator: ",")) - returnString.append("]") - return returnString - } - } - - // - remote heads changed - // { - // type: "remote-heads-changed" - // senderId: peer_id - // targetId: peer_id - // - // ; The document ID of the document that has changed - // documentId: document_id - // - // ; A map from storage ID to the heads advertised for a given storage ID - // newHeads: { - // * storage_id => { - // ; The heads of the new document for the given storage ID as - // ; a list of base64 encoded SHA2 hashes - // heads: [* string] - // ; The local time on the node which initially sent the remote-heads-changed - // ; message as milliseconds since the unix epoch - // timestamp: uint - // } - // } - // } - - struct RemoteHeadsChangedMsg: Sendable, Codable, CustomDebugStringConvertible { - public struct HeadsAtTime: Codable, CustomDebugStringConvertible, Sendable { - public var heads: [String] - public let timestamp: uint - - public init(heads: [String], timestamp: uint) { - self.heads = heads - self.timestamp = timestamp - } - - public var debugDescription: String { - "\(timestamp):[\(heads.joined(separator: ","))]" - } - } - - public var type = SyncV1Msg.MsgTypes.remoteHeadsChanged - public let senderId: PEER_ID - public let targetId: PEER_ID - public let documentId: MSG_DOCUMENT_ID - public var newHeads: [STORAGE_ID: HeadsAtTime] - public var add: [STORAGE_ID] - public var remove: [STORAGE_ID] - - public init( - senderId: PEER_ID, - targetId: PEER_ID, - documentId: MSG_DOCUMENT_ID, - newHeads: [STORAGE_ID: HeadsAtTime], - add: [STORAGE_ID], - remove: [STORAGE_ID] - ) { - self.senderId = senderId - self.targetId = targetId - self.documentId = documentId - self.newHeads = newHeads - self.add = add - self.remove = remove - } - - public var debugDescription: String { - var returnString = - "REMOTE_HEADS_CHANGED[documentId: \(documentId), sender: \(senderId), target: \(targetId)]" - returnString.append("\n heads:") - for (storage_id, headsAtTime) in newHeads { - returnString.append("\n \(storage_id) : \(headsAtTime.debugDescription)") - } - returnString.append("\n add: [") - returnString.append(add.joined(separator: ", ")) - returnString.append("]") - - returnString.append("\n remove: [") - returnString.append(remove.joined(separator: ", ")) - returnString.append("]") - return returnString - } - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg.swift b/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg.swift deleted file mode 100644 index 37052361..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/Sync/SyncV1Msg.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// SyncV1Msg.swift -// MeetingNotes -// -// Created by Joseph Heck on 1/24/24. -// - -import Foundation -import OSLog -import PotentCBOR - -// Automerge Repo WebSocket sync details: -// https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo-network-websocket/README.md -// explicitly using a protocol version "1" here - make sure to specify (and verify?) that - -// related source for the automerge-repo sync code: -// https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo-network-websocket/src/BrowserWebSocketClientAdapter.ts -// All the WebSocket messages are CBOR encoded and sent as data streams - -/// A type that encapsulates valid V1 Automerge-repo sync protocol messages. -public indirect enum SyncV1Msg: Sendable { - // CDDL pre-amble - // ; The base64 encoded bytes of a Peer ID - // peer_id = str - // ; The base64 encoded bytes of a Storage ID - // storage_id = str - // ; The possible protocol versions (currently always the string "1") - // protocol_version = "1" - // ; The bytes of an automerge sync message - // sync_message = bstr - // ; The base58check encoded bytes of a document ID - // document_id = str - - /// The collection of value "type" strings for the V1 automerge-repo protocol. - public enum MsgTypes: Sendable { - public static let peer = "peer" - public static let join = "join" - public static let leave = "leave" - public static let request = "request" - public static let sync = "sync" - public static let ephemeral = "ephemeral" - public static let error = "error" - public static let unavailable = "doc-unavailable" - public static let remoteHeadsChanged = "remote-heads-changed" - public static let remoteSubscriptionChange = "remote-subscription-change" - } - - case peer(PeerMsg) - case join(JoinMsg) - case leave(LeaveMsg) - case error(ErrorMsg) - case request(RequestMsg) - case sync(SyncMsg) - case unavailable(UnavailableMsg) - // ephemeral - case ephemeral(EphemeralMsg) - // gossip additions - case remoteSubscriptionChange(RemoteSubscriptionChangeMsg) - case remoteHeadsChanged(RemoteHeadsChangedMsg) - // fall-through scenario - unknown message - case unknown(Data) - - var peerMessageType: P2PSyncMessageType { - switch self { - case .peer: - P2PSyncMessageType.peer - case .join: - P2PSyncMessageType.join - case .leave: - P2PSyncMessageType.leave - case .error: - P2PSyncMessageType.syncerror - case .request: - P2PSyncMessageType.request - case .sync: - P2PSyncMessageType.sync - case .unavailable: - P2PSyncMessageType.unavailable - case .ephemeral: - P2PSyncMessageType.ephemeral - case .remoteSubscriptionChange: - P2PSyncMessageType.remoteSubscriptionChange - case .remoteHeadsChanged: - P2PSyncMessageType.remoteHeadsChanged - case .unknown: - P2PSyncMessageType.unknown - } - } -} - -extension SyncV1Msg: CustomDebugStringConvertible { - public var debugDescription: String { - switch self { - case let .peer(interior_msg): - interior_msg.debugDescription - case let .join(interior_msg): - interior_msg.debugDescription - case let .leave(interior_msg): - interior_msg.debugDescription - case let .error(interior_msg): - interior_msg.debugDescription - case let .request(interior_msg): - interior_msg.debugDescription - case let .sync(interior_msg): - interior_msg.debugDescription - case let .unavailable(interior_msg): - interior_msg.debugDescription - case let .ephemeral(interior_msg): - interior_msg.debugDescription - case let .remoteSubscriptionChange(interior_msg): - interior_msg.debugDescription - case let .remoteHeadsChanged(interior_msg): - interior_msg.debugDescription - case let .unknown(data): - "UNKNOWN[data: \(data.hexEncodedString(uppercase: false))]" - } - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/WeakDocumentRef.swift b/Packages/automerge-repo/Sources/AutomergeRepo/WeakDocumentRef.swift deleted file mode 100644 index bb72266d..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/WeakDocumentRef.swift +++ /dev/null @@ -1,13 +0,0 @@ -import class Automerge.Document - -/// A weak reference to an Automerge document -/// -/// Allow a global singleton keep references to documents without incurring memory leaks as Documents are opened and -/// closed. -final class WeakDocumentRef { - weak var value: Automerge.Document? - - init(_ value: Automerge.Document? = nil) { - self.value = value - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/Data+hexEncodedString.swift b/Packages/automerge-repo/Sources/AutomergeRepo/extensions/Data+hexEncodedString.swift deleted file mode 100644 index b260ec95..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/Data+hexEncodedString.swift +++ /dev/null @@ -1,15 +0,0 @@ -import struct Foundation.Data - -public extension Data { - /// Returns the data as a hex-encoded string. - /// - Parameter uppercase: A Boolean value that indicates whether the hex encoded string uses uppercase letters. - func hexEncodedString(uppercase: Bool = false) -> String { - let format = uppercase ? "%02hhX" : "%02hhx" - return map { String(format: format, $0) }.joined() - } - - /// The data as an array of bytes. - var bytes: [UInt8] { // fancy pretty call: myData.bytes -> [UInt8] - [UInt8](self) - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/OSLog+extensions.swift b/Packages/automerge-repo/Sources/AutomergeRepo/extensions/OSLog+extensions.swift deleted file mode 100644 index 36e40dc6..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/OSLog+extensions.swift +++ /dev/null @@ -1,32 +0,0 @@ -import OSLog - -extension Logger: @unchecked Sendable {} -// https://forums.developer.apple.com/forums/thread/747816?answerId=781922022#781922022 -// Per Quinn: -// `Logger` should be sendable. Under the covers, it’s an immutable struct with a single -// OSLog property, and that in turn is just a wrapper around the C os_log_t which is -// definitely thread safe. -#if swift(>=6.0) -#warning("Reevaluate whether this decoration is necessary.") -#endif - -extension Logger { - /// Using your bundle identifier is a great way to ensure a unique identifier. - private static let subsystem = Bundle.main.bundleIdentifier! - - /// Logs updates and interaction related to watching for external peer systems. - static let syncController = Logger(subsystem: subsystem, category: "SyncController") - - /// Logs updates and interaction related to the process of synchronization over the network. - static let syncConnection = Logger(subsystem: subsystem, category: "SyncConnection") - - /// Logs updates and interaction related to the process of synchronization over the network. - static let webSocket = Logger(subsystem: subsystem, category: "WebSocket") - - /// Logs updates and interaction related to the process of synchronization over the network. - static let storage = Logger(subsystem: subsystem, category: "storageSubsystem") - - static let repo = Logger(subsystem: subsystem, category: "automerge-repo") - - static let network = Logger(subsystem: subsystem, category: "networkSubsystem") -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/String+hexEncoding.swift b/Packages/automerge-repo/Sources/AutomergeRepo/extensions/String+hexEncoding.swift deleted file mode 100644 index bab90b6f..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/String+hexEncoding.swift +++ /dev/null @@ -1,61 +0,0 @@ -import struct Foundation.Data - -// https://stackoverflow.com/a/56870030/19477 -// Licensed: CC BY-SA 4.0 for [itMaxence](https://stackoverflow.com/users/3328736/itmaxence) -extension String { - enum ExtendedEncoding { - case hexadecimal - } - - func data(using _: ExtendedEncoding) -> Data? { - let hexStr = self.dropFirst(self.hasPrefix("0x") ? 2 : 0) - - guard hexStr.count % 2 == 0 else { return nil } - - var newData = Data(capacity: hexStr.count / 2) - - var indexIsEven = true - for i in hexStr.indices { - if indexIsEven { - let byteRange = i ... hexStr.index(after: i) - guard let byte = UInt8(hexStr[byteRange], radix: 16) else { return nil } - newData.append(byte) - } - indexIsEven.toggle() - } - return newData - } -} - -// usage: -// "5413".data(using: .hexadecimal) -// "0x1234FF".data(using: .hexadecimal) - -// extension Data { -// Could make a more optimized one~ -// func hexa(prefixed isPrefixed: Bool = true) -> String { -// self.bytes.reduce(isPrefixed ? "0x" : "") { $0 + String(format: "%02X", $1) } -// } -// print("000204ff5400".data(using: .hexadecimal)?.hexa() ?? "failed") // OK -// print("0x000204ff5400".data(using: .hexadecimal)?.hexa() ?? "failed") // OK -// print("541".data(using: .hexadecimal)?.hexa() ?? "failed") // fails -// print("5413".data(using: .hexadecimal)?.hexa() ?? "failed") // OK -// } - -// https://stackoverflow.com/a/73731660/19477 -// Licensed: CC BY-SA 4.0 for [Nick](https://stackoverflow.com/users/392986/nick) -extension Data { - init(hexString: String) { - self = hexString - .dropFirst(hexString.hasPrefix("0x") ? 2 : 0) - .compactMap { $0.hexDigitValue.map { UInt8($0) } } - .reduce(into: (data: Data(capacity: hexString.count / 2), byte: nil as UInt8?)) { partialResult, nibble in - if let p = partialResult.byte { - partialResult.data.append(p + nibble) - partialResult.byte = nil - } else { - partialResult.byte = nibble << 4 - } - }.data - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/TimeInterval+milliseconds.swift b/Packages/automerge-repo/Sources/AutomergeRepo/extensions/TimeInterval+milliseconds.swift deleted file mode 100644 index cd1061b5..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/TimeInterval+milliseconds.swift +++ /dev/null @@ -1,9 +0,0 @@ -import struct Foundation.TimeInterval - -extension TimeInterval { - /// Returns a time interval from the number of milliseconds you provide. - /// - Parameter value: The number of milliseconds. - static func milliseconds(_ value: Int) -> Self { - 0.001 * Double(value) - } -} diff --git a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/UUID+bs58String.swift b/Packages/automerge-repo/Sources/AutomergeRepo/extensions/UUID+bs58String.swift deleted file mode 100644 index 0d63db2e..00000000 --- a/Packages/automerge-repo/Sources/AutomergeRepo/extensions/UUID+bs58String.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Base58Swift -import struct Foundation.Data -import struct Foundation.UUID - -public extension UUID { - /// The contents of the UUID as data. - var data: Data { - var byteblob = Data(count: 16) - byteblob[0] = self.uuid.0 - byteblob[1] = self.uuid.1 - byteblob[2] = self.uuid.2 - byteblob[3] = self.uuid.3 - byteblob[4] = self.uuid.4 - byteblob[5] = self.uuid.5 - byteblob[6] = self.uuid.6 - byteblob[7] = self.uuid.7 - byteblob[8] = self.uuid.8 - byteblob[9] = self.uuid.9 - byteblob[10] = self.uuid.10 - byteblob[11] = self.uuid.11 - byteblob[12] = self.uuid.12 - byteblob[13] = self.uuid.13 - byteblob[14] = self.uuid.14 - byteblob[15] = self.uuid.15 - return byteblob - } - - /// The contents of UUID as a BS58 encoded string. - var bs58String: String { - Base58.base58CheckEncode(self.data.bytes) - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/BS58IdTests.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/BS58IdTests.swift deleted file mode 100644 index 6c0f8c23..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/BS58IdTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -@testable import AutomergeRepo -import Base58Swift -import XCTest - -final class BS58IdTests: XCTestCase { - func testDataLengthUUIDandAutomergeID() throws { - let exampleUUID = UUID() - let bytes: Data = exampleUUID.data - // example from AutomergeRepo docs/blog - // https://automerge.org/blog/2023/11/06/automerge-repo/ - // let full = "automerge:2j9knpCseyhnK8izDmLpGP5WMdZQ" - let partial = "2j9knpCseyhnK8izDmLpGP5WMdZQ" - XCTAssertEqual(Base58.base58Decode(partial)?.count, 20) - if let decodedBytes = Base58.base58CheckDecode(partial) { - // both are 16 bytes of data - XCTAssertEqual(bytes.count, Data(decodedBytes).count) - } - } - - func testDisplayingUUIDWithBase58() throws { - let exampleUUID = try XCTUnwrap(UUID(uuidString: "1654A0B5-43B9-48FF-B7FB-83F58F4D1D75")) - // print("hexencoded: \(exampleUUID.data.hexEncodedString())") - XCTAssertEqual("1654a0b543b948ffb7fb83f58f4d1d75", exampleUUID.data.hexEncodedString()) - let bs58Converted = Base58.base58CheckEncode(exampleUUID.data.bytes) - // print("Converted: \(bs58Converted)") - XCTAssertEqual("K3YptshN5CcFZNpnnXcStizSNPU", bs58Converted) - XCTAssertEqual(exampleUUID.bs58String, bs58Converted) - } - - func testDataInAndOutWithBase58() throws { - // let full = "automerge:2j9knpCseyhnK8izDmLpGP5WMdZQ" - let partial = "2j9knpCseyhnK8izDmLpGP5WMdZQ" - if let decodedBytes = Base58.base58CheckDecode(partial) { - print(decodedBytes.count) - // AutomergeID is 16 bytes of data - XCTAssertEqual(16, Data(decodedBytes).count) - XCTAssertEqual("7bf18580944c450ea740c1f23be047ca", Data(decodedBytes).hexEncodedString()) - // print(Data(decodedBytes).hexEncodedString()) - - let reversed = Base58.base58CheckEncode(decodedBytes) - XCTAssertEqual(reversed, partial) - } - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/BaseRepoTests.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/BaseRepoTests.swift deleted file mode 100644 index a03da444..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/BaseRepoTests.swift +++ /dev/null @@ -1,138 +0,0 @@ -import Automerge -@testable import AutomergeRepo -import AutomergeUtilities -import XCTest - -final class BaseRepoTests: XCTestCase { - var repo: Repo! - - override func setUp() async throws { - repo = Repo(sharePolicy: SharePolicies.agreeable) - } - - func testMostBasicRepoStartingPoints() async throws { - // Repo - // property: peers [PeerId] - all (currently) connected peers - let peers = await repo.peers() - XCTAssertEqual(peers, []) - - // let peerId = await repo.peerId - // print(peerId) - - // - func storageId() -> StorageId (async) - let storageId = await repo.storageId() - XCTAssertNil(storageId) - - let knownIds = await repo.documentIds() - XCTAssertEqual(knownIds, []) - } - - func testCreate() async throws { - let newDoc = try await repo.create() - XCTAssertNotNil(newDoc) - let knownIds = await repo.documentIds() - XCTAssertEqual(knownIds.count, 1) - } - - func testCreateWithId() async throws { - let myId = DocumentId() - let handle = try await repo.create(id: myId) - XCTAssertEqual(myId, handle.id) - - let knownIds = await repo.documentIds() - XCTAssertEqual(knownIds.count, 1) - XCTAssertEqual(knownIds[0], myId) - } - - func testCreateWithExistingDoc() async throws { - let handle = try await repo.create(doc: Document()) - var knownIds = await repo.documentIds() - XCTAssertEqual(knownIds.count, 1) - XCTAssertEqual(knownIds[0], handle.id) - - let myId = DocumentId() - let _ = try await repo.create(doc: Document(), id: myId) - knownIds = await repo.documentIds() - XCTAssertEqual(knownIds.count, 2) - } - - func testFind() async throws { - let myId = DocumentId() - let handle = try await repo.create(id: myId) - XCTAssertEqual(myId, handle.id) - - let foundDoc = try await repo.find(id: myId) - XCTAssertEqual(foundDoc.doc.actor, handle.doc.actor) - } - - func testFindFailed() async throws { - do { - let _ = try await repo.find(id: DocumentId()) - XCTFail() - } catch {} - } - - func testDelete() async throws { - let myId = DocumentId() - let _ = try await repo.create(id: myId) - var knownIds = await repo.documentIds() - XCTAssertEqual(knownIds.count, 1) - - try await repo.delete(id: myId) - knownIds = await repo.documentIds() - XCTAssertEqual(knownIds.count, 0) - - do { - let _ = try await repo.find(id: DocumentId()) - XCTFail() - } catch {} - } - - func testClone() async throws { - let myId = DocumentId() - let handle = try await repo.create(id: myId) - XCTAssertEqual(myId, handle.id) - - let clonedHandle = try await repo.clone(id: myId) - XCTAssertNotEqual(handle.id, clonedHandle.id) - XCTAssertNotEqual(handle.doc.actor, clonedHandle.doc.actor) - - let knownIds = await repo.documentIds() - XCTAssertEqual(knownIds.count, 2) - } - - func testExportFailureUnknownId() async throws { - do { - _ = try await repo.export(id: DocumentId()) - XCTFail() - } catch {} - } - - func testExport() async throws { - let newDoc = try RepoHelpers.documentWithData() - let newHandle = try await repo.create(doc: newDoc) - - let exported = try await repo.export(id: newHandle.id) - XCTAssertEqual(exported, newDoc.save()) - } - - func testImport() async throws { - let newDoc = try RepoHelpers.documentWithData() - - let handle = try await repo.import(data: newDoc.save()) - XCTAssertTrue(RepoHelpers.equalContents(doc1: handle.doc, doc2: newDoc)) - } - - // TBD: - // - func storageIdForPeer(peerId) -> StorageId - // - func subscribeToRemotes([StorageId]) - - func testRepoSetup() async throws { - let repoA = Repo(sharePolicy: SharePolicies.agreeable) - let storage = await InMemoryStorage() - await repoA.addStorageProvider(storage) - - let storageId = await repoA.storageId() - XCTAssertNotNil(storageId) - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/CBORExperiments.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/CBORExperiments.swift deleted file mode 100644 index d302c8d1..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/CBORExperiments.swift +++ /dev/null @@ -1,57 +0,0 @@ -import AutomergeRepo -import PotentCBOR -import XCTest - -// public extension Data { -// func hexEncodedString(uppercase: Bool = false) -> String { -// let format = uppercase ? "%02hhX" : "%02hhx" -// return map { String(format: format, $0) }.joined() -// } -// } - -struct AnotherType: Codable { - var name: String - var blah: Data -} - -struct Message: Codable { - var first: String - var second: Int? - var notexisting: AnotherType? -} - -struct ExtendedMessage: Codable { - var first: String - var second: Int - var third: String - var fourth: AnotherType? -} - -final class CBORExperiments: XCTestCase { - static let encoder = CBOREncoder() - static let decoder = CBORDecoder() - - func testCBORSerialization() throws { - let peerMsg = SyncV1Msg.PeerMsg(senderId: "senderUUID", targetId: "targetUUID", storageId: "something") - let encodedPeerMsg = try SyncV1Msg.encode(peerMsg) - - let x = try CBORSerialization.cbor(from: encodedPeerMsg) - XCTAssertEqual(x.mapValue?["type"]?.utf8StringValue, SyncV1Msg.MsgTypes.peer) - // print("CBOR data: \(x)") - } - - func testDecodingWithAdditionalData() throws { - let data = try Self.encoder.encode(ExtendedMessage( - first: "one", - second: 2, - third: "three", - fourth: AnotherType(name: "foo", blah: Data()) - )) - print("Encoded form: \(data.hexEncodedString())") - // data format decoded with CBOR.me: - // {"first": "one", "second": 2, "third": "three", "fourth": {"name": "foo", "blah": h''}} - let decodedData = try Self.decoder.decode(Message.self, from: data) - XCTAssertEqual(decodedData.first, "one") - XCTAssertEqual(decodedData.second, 2) - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/DocHandleTests.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/DocHandleTests.swift deleted file mode 100644 index fd652668..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/DocHandleTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Automerge -@testable import AutomergeRepo -import XCTest - -final class DocHandleTests: XCTestCase { - func testNewDocHandleData() async throws { - let id = DocumentId() - let new = InternalDocHandle(id: id, isNew: true) - - XCTAssertEqual(new.id, id) - XCTAssertEqual(new.state, .idle) - XCTAssertEqual(new.isDeleted, false) - XCTAssertEqual(new.isReady, false) - XCTAssertEqual(new.isUnavailable, false) - XCTAssertEqual(new.remoteHeads.count, 0) - XCTAssertNil(new.doc) - } - - func testNewDocHandleDataWithDocument() async throws { - let id = DocumentId() - let new = InternalDocHandle(id: id, isNew: true, initialValue: Document()) - - XCTAssertEqual(new.id, id) - XCTAssertEqual(new.state, .loading) - XCTAssertEqual(new.isDeleted, false) - XCTAssertEqual(new.isReady, false) - XCTAssertEqual(new.isUnavailable, false) - XCTAssertEqual(new.remoteHeads.count, 0) - XCTAssertNotNil(new.doc) - } - - func testDocHandleRequestData() async throws { - let id = DocumentId() - let new = InternalDocHandle(id: id, isNew: false) - - XCTAssertEqual(new.id, id) - XCTAssertEqual(new.state, .idle) - XCTAssertEqual(new.isDeleted, false) - XCTAssertEqual(new.isReady, false) - XCTAssertEqual(new.isUnavailable, false) - XCTAssertEqual(new.remoteHeads.count, 0) - XCTAssertNil(new.doc) - } - - func testDocHandleRequestDataWithData() async throws { - let id = DocumentId() - let new = InternalDocHandle(id: id, isNew: false, initialValue: Document()) - - XCTAssertEqual(new.id, id) - XCTAssertEqual(new.state, .ready) - XCTAssertEqual(new.isDeleted, false) - XCTAssertEqual(new.isReady, true) - XCTAssertEqual(new.isUnavailable, false) - XCTAssertEqual(new.remoteHeads.count, 0) - XCTAssertNotNil(new.doc) - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/DocumentIdTests.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/DocumentIdTests.swift deleted file mode 100644 index 28e164af..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/DocumentIdTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -@testable import AutomergeRepo -import Base58Swift -import XCTest - -final class DocumentIdTests: XCTestCase { - func testInvalidDocumentIdString() async throws { - XCTAssertNil(DocumentId("some random string")) - } - - func testDocumentId() async throws { - let someUUID = UUID() - let id = DocumentId(someUUID) - XCTAssertEqual(id.description, someUUID.bs58String) - } - - func testDocumentIdFromString() async throws { - let someUUID = UUID() - let bs58String = someUUID.bs58String - let id = DocumentId(bs58String) - XCTAssertEqual(id?.description, bs58String) - - let invalidOptionalString: String? = "SomeRandomNonBS58String" - XCTAssertNil(DocumentId(invalidOptionalString)) - - let invalidString = "SomeRandomNonBS58String" - XCTAssertNil(DocumentId(invalidString)) - - let optionalString: String? = bs58String - XCTAssertEqual(DocumentId(optionalString)?.description, bs58String) - - XCTAssertNil(DocumentId(nil)) - } - - func testInvalidTooMuchDataDocumentId() async throws { - let tooBig = [UInt8](UUID().data + UUID().data) - let bs58StringFromData = Base58.base58CheckEncode(tooBig) - let tooLargeOptionalString: String? = bs58StringFromData - XCTAssertNil(DocumentId(bs58StringFromData)) - XCTAssertNil(DocumentId(tooLargeOptionalString)) - - let optionalString: String? = bs58StringFromData - XCTAssertNil(DocumentId(optionalString)) - } - - func testComparisonOnData() async throws { - let first = DocumentId() - let second = DocumentId() - let compareFirstAndSecond = first < second - let compareFirstAndSecondDescription = first.description < second.description - XCTAssertEqual(compareFirstAndSecond, compareFirstAndSecondDescription) - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/RepoWebsocketIntegrationTests.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/RepoWebsocketIntegrationTests.swift deleted file mode 100644 index bbff92cd..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/RepoWebsocketIntegrationTests.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Automerge -import AutomergeRepo -import AutomergeUtilities -import OSLog -import XCTest - -// NOTE(heckj): This integration test expects that you have a websocket server with the -// Automerge-repo sync protocol running at localhost:3030. If you're testing from the local -// repository, run the `./scripts/interop.sh` script to start up a local instance to -// respond. - -final class RepoWebsocketIntegrationTests: XCTestCase { - private static let subsystem = Bundle.main.bundleIdentifier! - - static let test = Logger(subsystem: subsystem, category: "WebSocketSyncIntegrationTests") - let syncDestination = "ws://localhost:3030/" - // Switch to the following line to run a test against the public hosted automerge-repo instance -// let syncDestination = "wss://sync.automerge.org/" - - override func setUp() async throws { - let isWebSocketConnectable = await webSocketAvailable(destination: syncDestination) - try XCTSkipUnless(isWebSocketConnectable, "websocket unavailable for integration test") - } - - override func tearDown() async throws { - // teardown - } - - func webSocketAvailable(destination: String) async -> Bool { - guard let url = URL(string: destination) else { - Self.test.error("invalid URL: \(destination, privacy: .public) - endpoint unavailable") - return false - } - // establishes the websocket - let request = URLRequest(url: url) - let ws: URLSessionWebSocketTask = URLSession.shared.webSocketTask(with: request) - ws.resume() - Self.test.info("websocket to \(destination, privacy: .public) prepped, sending ping") - do { - try await ws.sendPing() - Self.test.info("PING OK - returning true") - ws.cancel(with: .normalClosure, reason: nil) - return true - } catch { - Self.test.error("PING FAILED: \(error.localizedDescription, privacy: .public) - returning false") - ws.cancel(with: .abnormalClosure, reason: nil) - return false - } - } - - func testSync() async throws { - // document structure for test - struct ExampleStruct: Identifiable, Codable, Hashable { - let id: UUID - var title: String - var discussion: AutomergeText - - init(title: String, discussion: String) { - self.id = UUID() - self.title = title - self.discussion = AutomergeText(discussion) - } - } - - // set up repo (with a client-websocket) - let repo = Repo(sharePolicy: SharePolicies.agreeable) - let websocket = WebSocketProvider() - await repo.addNetworkAdapter(adapter: websocket) - - // add the document to the repo - let handle: DocHandle = try await repo.create(doc: Document(), id: DocumentId()) - - // initial setup and encoding of Automerge doc to sync it - let encoder = AutomergeEncoder(doc: handle.doc) - let model = ExampleStruct(title: "new item", discussion: "editable text") - try encoder.encode(model) - - let url = try XCTUnwrap(URL(string: syncDestination)) - try await websocket.connect(to: url) - - // With the websocket protocol, we don't get confirmation of a sync being complete - - // if the other side has everything and nothing new, they just won't send a response - // back. In that case, we don't get any further responses - but we don't _know_ that - // it's complete. In an initial sync there will always be at least one response, but - // we can't quite count on this always being an initial sync... so I'm shimming in a - // short "wait" here to leave the background tasks that receive WebSocket messages - // running to catch any updates, and hoping that'll be enough time to complete it. - try await Task.sleep(for: .seconds(5)) - await websocket.disconnect() - - // Create a second, empty repo that doesn't have the document and request it - - // set up repo (with a client-websocket) - let repoTwo = Repo(sharePolicy: SharePolicies.agreeable) - let websocketTwo = WebSocketProvider() - await repoTwo.addNetworkAdapter(adapter: websocketTwo) - - // connect the repo to the external automerge-repo - try await websocketTwo.connect(to: url) - - let foundDocHandle = try await repoTwo.find(id: handle.id) - XCTAssertEqual(foundDocHandle.id, handle.id) - XCTAssertTrue(RepoHelpers.equalContents(doc1: foundDocHandle.doc, doc2: handle.doc)) - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/URLSessionWebSocketTask+sendPing.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/URLSessionWebSocketTask+sendPing.swift deleted file mode 100644 index 9457c5a6..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/URLSessionWebSocketTask+sendPing.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -extension URLSessionWebSocketTask { - struct WebSocketPingError: LocalizedError { - var errorDescription: String { - "WebSocket ping() returned an error: \(wrappedError.localizedDescription)" - } - - let wrappedError: any Error - init(wrappedError: any Error) { - self.wrappedError = wrappedError - } - } - - func sendPing() async throws { - let _: Bool = try await withCheckedThrowingContinuation { continuation in - self.sendPing { err in - if let err { - continuation.resume(throwing: WebSocketPingError(wrappedError: err)) - } else { - continuation.resume(returning: true) - } - } - } - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/WebSocketSyncIntegrationTests.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/WebSocketSyncIntegrationTests.swift deleted file mode 100644 index ef76137b..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/IntegrationTests/WebSocketSyncIntegrationTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// WebSocketSyncIntegrationTests.swift -// MeetingNotesTests -// -// Created by Joseph Heck on 2/12/24. -// - -import Automerge -import AutomergeRepo -import AutomergeUtilities -import OSLog -import XCTest - -// NOTE(heckj): This integration test expects that you have a websocket server with the -// Automerge-repo sync protocol running at localhost:3030. If you're testing from the local -// repository, run the `./scripts/interop.sh` script to start up a local instance to -// respond. - -final class WebSocketSyncIntegrationTests: XCTestCase { - private static let subsystem = Bundle.main.bundleIdentifier! - - static let test = Logger(subsystem: subsystem, category: "WebSocketSyncIntegrationTests") - let syncDestination = "ws://localhost:3030/" -// let syncDestination = "wss://sync.automerge.org/" - - override func setUp() async throws { - let isWebSocketConnectable = await webSocketAvailable(destination: syncDestination) - try XCTSkipUnless(isWebSocketConnectable, "websocket unavailable for integration test") - } - - override func tearDown() async throws { - // teardown - } - - func webSocketAvailable(destination: String) async -> Bool { - guard let url = URL(string: destination) else { - Self.test.error("invalid URL: \(destination, privacy: .public) - endpoint unavailable") - return false - } - // establishes the websocket - let request = URLRequest(url: url) - let ws: URLSessionWebSocketTask = URLSession.shared.webSocketTask(with: request) - ws.resume() - Self.test.info("websocket to \(destination, privacy: .public) prepped, sending ping") - do { - try await ws.sendPing() - Self.test.info("PING OK - returning true") - ws.cancel(with: .normalClosure, reason: nil) - return true - } catch { - Self.test.error("PING FAILED: \(error.localizedDescription, privacy: .public) - returning false") - ws.cancel(with: .abnormalClosure, reason: nil) - return false - } - } - - func testSync() async throws { - // document structure for test - struct ExampleStruct: Identifiable, Codable, Hashable { - let id: UUID - var title: String - var discussion: AutomergeText - - init(title: String, discussion: String) { - self.id = UUID() - self.title = title - self.discussion = AutomergeText(discussion) - } - } - - // initial setup and encoding of Automerge doc to sync it - let document = Document() - let documentId = DocumentId() - let encoder = AutomergeEncoder(doc: document) - let model = ExampleStruct(title: "new item", discussion: "editable text") - try encoder.encode(model) - - // establish and sync the document - // SwiftUI does it in a two-step: define and then add data through onAppear: - let websocket = await WebsocketSyncConnection(nil, id: nil) - await websocket.registerDocument(document, id: documentId) - print("SYNCING DOCUMENT: \(documentId.description)") - - try await websocket.connect(syncDestination) - try await websocket.runOngoingSync() - - // With the websocket protocol, we don't get confirmation of a sync being complete - - // if the other side has everything and nothing new, they just won't send a response - // back. In that case, we don't get any further responses - but we don't _know_ that - // it's complete. In an initial sync there will always be at least one response, but - // we can't quite count on this always being an initial sync... so I'm shimming in a - // short "wait" here to leave the background tasks that receive WebSocket messages - // running to catch any updates, and hoping that'll be enough time to complete it. - try await Task.sleep(for: .seconds(5)) - await websocket.disconnect() - - // Spin up another websocket and try to get the document we just pushed into place - print("REQUESTING DOCUMENT: \(documentId.description)") - if let (copyOfDocument, _) = try await WebsocketSyncConnection.requestDocument( - documentId, - from: self.syncDestination - ) { - let decoder = AutomergeDecoder(doc: copyOfDocument) - XCTAssertFalse(try copyOfDocument.isEmpty()) - // print(try copyOfDocument.schema().description) - let modelReplica = try decoder.decode(ExampleStruct.self) - XCTAssertEqual(modelReplica, model) - } else { - XCTFail() - } - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/RepoHelpers.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/RepoHelpers.swift deleted file mode 100644 index 3d929e4c..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/RepoHelpers.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Automerge -@testable import AutomergeRepo -import AutomergeUtilities -import Foundation - -public enum RepoHelpers { - static func documentWithData() throws -> Document { - let newDoc = Document() - let txt = try newDoc.putObject(obj: .ROOT, key: "words", ty: .Text) - try newDoc.updateText(obj: txt, value: "Hello World!") - return newDoc - } - - static func docHandleWithData() throws -> DocHandle { - let newDoc = Document() - let txt = try newDoc.putObject(obj: .ROOT, key: "words", ty: .Text) - try newDoc.updateText(obj: txt, value: "Hello World!") - return DocHandle(id: DocumentId(), doc: newDoc) - } - - static func equalContents(doc1: Document, doc2: Document) -> Bool { - do { - let doc1Contents = try doc1.parseToSchema(doc1, from: .ROOT) - let doc2Contents = try doc2.parseToSchema(doc1, from: .ROOT) - return doc1Contents == doc2Contents - } catch { - return false - } - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/SharePolicyTests.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/SharePolicyTests.swift deleted file mode 100644 index 227ec8de..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/SharePolicyTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -@testable import AutomergeRepo -import XCTest - -final class SharePolicyTests: XCTestCase { - func testSharePolicy() async throws { - let agreeableShareResult = await SharePolicies.agreeable.share(peer: "A", docId: DocumentId()) - XCTAssertTrue(agreeableShareResult) - - let readOnlyShareResult = await SharePolicies.readonly.share(peer: "A", docId: DocumentId()) - XCTAssertFalse(readOnlyShareResult) - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/StorageSubsystemTests.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/StorageSubsystemTests.swift deleted file mode 100644 index e84cb622..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/StorageSubsystemTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -import Automerge -@testable import AutomergeRepo -import AutomergeUtilities -import XCTest - -final class StorageSubsystemTests: XCTestCase { - var subsystem: DocumentStorage! - var testStorageProvider: InMemoryStorage! - - override func setUp() async throws { - let storageProvider = await InMemoryStorage() - let incrementalKeys = await storageProvider.incrementalKeys() - let docIds = await storageProvider.storageKeys() - XCTAssertEqual(docIds.count, 0) - XCTAssertEqual(incrementalKeys.count, 0) - - subsystem = DocumentStorage(storageProvider) - testStorageProvider = storageProvider - } - - func assertCounts(docIds: Int, incrementals: Int) async { - let countOfIncrementalKeys = await testStorageProvider?.incrementalKeys().count - let countOfDocumentIdKeys = await testStorageProvider?.storageKeys().count - XCTAssertEqual(countOfDocumentIdKeys, docIds) - XCTAssertEqual(countOfIncrementalKeys, incrementals) - } - - func docDataSize(id: DocumentId) async -> Int { - await testStorageProvider?.load(id: id)?.count ?? 0 - } - - func combinedIncData(id: DocumentId) async -> Int { - if let inc = await testStorageProvider?.loadRange(id: id, prefix: subsystem.chunkNamespace) { - return inc.reduce(0) { partialResult, data in - partialResult + data.count - } - } - return 0 - } - - func testSubsystemSetup() async throws { - XCTAssertNotNil(subsystem) - let newDoc = Document() - let newDocId = DocumentId() - - try await subsystem.saveDoc(id: newDocId, doc: newDoc) - await assertCounts(docIds: 0, incrementals: 1) - - let combinedKeys = await testStorageProvider?.incrementalKeys() - XCTAssertEqual(combinedKeys?.count, 1) - XCTAssertEqual(combinedKeys?[0].id, newDocId) - XCTAssertEqual(combinedKeys?[0].prefix, "incrChanges") - let incData: [Data]? = await testStorageProvider?.loadRange(id: newDocId, prefix: "incrChanges") - let incDataUnwrapped = try XCTUnwrap(incData) - XCTAssertEqual(incDataUnwrapped.count, 1) - XCTAssertEqual(incDataUnwrapped[0].count, 0) - - let txt = try newDoc.putObject(obj: .ROOT, key: "words", ty: .Text) - try await subsystem.saveDoc(id: newDocId, doc: newDoc) - - await assertCounts(docIds: 0, incrementals: 1) - var incSize = await combinedIncData(id: newDocId) - XCTAssertEqual(incSize, 58) - - try newDoc.updateText(obj: txt, value: "Hello World!") - try await subsystem.saveDoc(id: newDocId, doc: newDoc) - - await assertCounts(docIds: 1, incrementals: 1) - incSize = await combinedIncData(id: newDocId) - var docSize = await docDataSize(id: newDocId) - XCTAssertEqual(docSize, 176) - XCTAssertEqual(incSize, 0) - - try await subsystem.compact(id: newDocId, doc: newDoc) - - await assertCounts(docIds: 1, incrementals: 1) - incSize = await combinedIncData(id: newDocId) - docSize = await docDataSize(id: newDocId) - XCTAssertEqual(docSize, 176) - XCTAssertEqual(incSize, 0) -// if let incrementals = await testStorageProvider?.loadRange(id: newDocId, prefix: subsystem.chunkNamespace) { -// print(incrementals) -// } - } - - func testSubsystemLoadDoc() async throws { - let newDoc = try RepoHelpers.documentWithData() - let newDocId = DocumentId() - try await subsystem.saveDoc(id: newDocId, doc: newDoc) - - let loadedDoc = try await subsystem.loadDoc(id: newDocId) - - XCTAssertTrue(RepoHelpers.equalContents(doc1: newDoc, doc2: loadedDoc)) - } - - func testSubsystemPurgeDoc() async throws { - let newDoc = try RepoHelpers.documentWithData() - let newDocId = DocumentId() - try await subsystem.saveDoc(id: newDocId, doc: newDoc) - - await assertCounts(docIds: 0, incrementals: 1) - let incSize = await combinedIncData(id: newDocId) - let docSize = await docDataSize(id: newDocId) - XCTAssertEqual(docSize, 0) - XCTAssertEqual(incSize, 106) - - try await subsystem.compact(id: newDocId, doc: newDoc) - await assertCounts(docIds: 1, incrementals: 1) - let compactedIncSize = await combinedIncData(id: newDocId) - let compactedDocSize = await docDataSize(id: newDocId) - XCTAssertEqual(compactedDocSize, 170) - XCTAssertEqual(compactedIncSize, 0) - - try await subsystem.purgeDoc(id: newDocId) - await assertCounts(docIds: 0, incrementals: 1) - let purgedIncSize = await combinedIncData(id: newDocId) - let purgedDocSize = await docDataSize(id: newDocId) - XCTAssertEqual(purgedDocSize, 0) - XCTAssertEqual(purgedIncSize, 0) - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/InMemoryNetwork.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/InMemoryNetwork.swift deleted file mode 100644 index 360f40cd..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/InMemoryNetwork.swift +++ /dev/null @@ -1,488 +0,0 @@ -import Automerge -import AutomergeRepo -import Foundation -import OSLog -import Tracing - -extension Logger { - static let testNetwork = Logger(subsystem: "InMemoryNetwork", category: "testNetwork") -} - -enum InMemoryNetworkErrors: Sendable { - public struct NoSuchEndpoint: Sendable, LocalizedError { - let name: String - public var errorDescription: String { - "Endpoint \(name) doesn't exist." - } - } - - public struct EndpointNotListening: Sendable, LocalizedError { - let name: String - public var errorDescription: String { - "Endpoint \(name) isn't listening for connections." - } - } -} - -// Tracing experimentation -struct InMemoryNetworkMsgBaggageManipulator: Injector, Extractor { - func inject(_ value: String, forKey key: String, into request: inout InMemoryNetworkMsg) { - request.appendHeader(key: key, value: value) - } - - func extract(key: String, from carrier: InMemoryNetworkMsg) -> String? { - if let valueForKey = carrier.headers[key] { - return valueForKey - } - return nil - } -} - -/// Emulates a protocol that supports headers or other embedded details -public struct InMemoryNetworkMsg: Sendable, CustomDebugStringConvertible { - var headers: [String: String] = [:] - var payload: SyncV1Msg - - public var debugDescription: String { - var str = "" - for (k, v) in headers { - str.append("[\(k):\(v)]") - } - str.append(" - \(payload.debugDescription)") - return str - } - - init(headers: [String: String] = [:], _ payload: SyncV1Msg) { - self.headers = headers - self.payload = payload - } - - mutating func appendHeader(key: String, value: String) { - headers[key] = value - } -} - -@InMemoryNetwork -public final class InMemoryNetworkConnection { - public var description: String { - get async { - let i = initiatingEndpoint.endpointName ?? "?" - let j = receivingEndpoint.endpointName ?? "?" - return "\(id.uuidString) [\(i)(\(initiatingEndpoint.peerId ?? "unconfigured"))] --> [\(j)(\(receivingEndpoint.peerId ?? "unconfigured"))])" - } - } - - let id: UUID - let initiatingEndpoint: InMemoryNetworkEndpoint - let receivingEndpoint: InMemoryNetworkEndpoint - let transferLatency: Duration? - let trace: Bool - - init(from: InMemoryNetworkEndpoint, to: InMemoryNetworkEndpoint, latency: Duration?, trace: Bool) { - self.id = UUID() - self.initiatingEndpoint = from - self.receivingEndpoint = to - self.transferLatency = latency - self.trace = trace - } - - func close() async { - await self.initiatingEndpoint.connectionTerminated(self.id) - await self.receivingEndpoint.connectionTerminated(self.id) - } - - func send(sender: String, msg: InMemoryNetworkMsg) async { - do { - if initiatingEndpoint.endpointName == sender { - if let latency = transferLatency { - try await Task.sleep(for: latency) - if trace { - Logger.testNetwork - .trace( - "XMIT[\(self.id.bs58String)] \(msg.debugDescription) from \(sender) with delay \(latency)" - ) - } - } else { - if trace { - Logger.testNetwork.trace("XMIT[\(self.id.bs58String)] \(msg.debugDescription) from \(sender)") - } - } - await receivingEndpoint.receiveMessage(msg: msg.payload) - } else if receivingEndpoint.endpointName == sender { - if let latency = transferLatency { - try await Task.sleep(for: latency) - if trace { - Logger.testNetwork - .trace( - "XMIT[\(self.id.bs58String)] \(msg.debugDescription) from \(sender) with delay \(latency)" - ) - } - } else { - if trace { - Logger.testNetwork.trace("XMIT[\(self.id.bs58String)] \(msg.debugDescription) from \(sender)") - } - } - await initiatingEndpoint.receiveMessage(msg: msg.payload) - } - } catch { - Logger.testNetwork.error("Failure during latency sleep: \(error.localizedDescription)") - } - } -} - -@InMemoryNetwork // isolate all calls to this class using the InMemoryNetwork global actor -public final class InMemoryNetworkEndpoint: NetworkProvider { - public typealias ProviderConfiguration = BasicNetworkConfiguration - public typealias NetworkConnectionEndpoint = String - - public struct BasicNetworkConfiguration: Sendable { - let listeningNetwork: Bool - let name: String - } - - public init(_ config: BasicNetworkConfiguration) async { - self.peeredConnections = [] - self._connections = [] - self.listening = false - - self.delegate = nil - self.peerId = nil - self.peerMetadata = nil - - // testing spies - self.received_messages = [] - self.sent_messages = [] - // logging control - self.logReceivedMessages = false - self.config = config - if config.listeningNetwork { - self.listening = true - } - } - - public func configure(_ config: BasicNetworkConfiguration) async { - self.config = config - if config.listeningNetwork { - self.listening = true - } - } - - public var debugDescription: String { - if let peerId = self.peerId { - "In-Memory Network: \(peerId)" - } else { - "Unconfigured In-Memory Network" - } - } - - public var peeredConnections: [PeerConnection] - var _connections: [InMemoryNetworkConnection] - var delegate: (any NetworkEventReceiver)? - var config: BasicNetworkConfiguration - var listening: Bool - var logReceivedMessages: Bool - - public var peerId: PEER_ID? - var peerMetadata: PeerMetadata? - - var received_messages: [SyncV1Msg] - var sent_messages: [SyncV1Msg] - - func wipe() { - self.peeredConnections = [] - self._connections = [] - self.received_messages = [] - self.sent_messages = [] - } - - public func logReceivedMessages(_ enableLogging: Bool) { - self.logReceivedMessages = enableLogging - } - - public var endpointName: String? { - self.config.name - } - - public func acceptNewConnection(_ connection: InMemoryNetworkConnection) async { - withSpan("accept-new-connection") { _ in - if listening { - self._connections.append(connection) - } else { - fatalError("Can't accept connection on a non-listening interface") - } - } - } - - public func connectionTerminated(_ id: UUID) async { - withSpan("connection-terminated") { _ in - self._connections.removeAll { connection in - connection.id == id - } - } - } - - public func connect(to: String) async throws { - guard let name = self.endpointName, - let peerId = self.peerId, - let peerMetadata = self.peerMetadata - else { - fatalError("Can't connect an unconfigured network") - } - // aka "activate" - try await withSpan("connect") { span in - - let connection = try await InMemoryNetwork.shared.connect(from: name, to: to, latency: nil) - - self._connections.append(connection) - - let attributes: [String: SpanAttribute] = [ - "type": SpanAttribute(stringLiteral: "join"), - "peerId": SpanAttribute(stringLiteral: peerId), - ] - - span.addEvent(SpanEvent(name: "message send", attributes: SpanAttributes(attributes))) - - await connection.send( - sender: name, - - msg: InMemoryNetworkMsg( - .join(.init(senderId: peerId, metadata: peerMetadata)) - ) - ) - } - } - - public func disconnect() async { - await withSpan("disconnect") { _ in - for connection in _connections { - await connection.close() - } - _connections = [] - peeredConnections = [] - } - } - - func receiveWrappedMessage(msg: InMemoryNetworkMsg) async { - await withSpan("receiveWrappedMessage") { _ in - if var context = ServiceContext.current { - InstrumentationSystem.instrument.extract( - msg, - into: &context, - using: InMemoryNetworkMsgBaggageManipulator() - ) - } - await self.receiveMessage(msg: msg.payload) - } - } - - func receiveMessage(msg: SyncV1Msg) async { - await withSpan("receiveWrappedMessage") { span in - guard let peerId = self.peerId else { - fatalError("Attempting to receive message with unconfigured network adapter") - } - if logReceivedMessages { - Logger.testNetwork.trace("\(peerId) RECEIVED MSG: \(msg.debugDescription)") - } - received_messages.append(msg) - switch msg { - case let .leave(msg): - span.addEvent(SpanEvent(name: "leave msg received")) - await self.delegate?.receiveEvent(event: .close) - _connections.removeAll { connection in - connection.initiatingEndpoint.peerId == msg.senderId || - connection.receivingEndpoint.peerId == msg.senderId - } - peeredConnections.removeAll { peerConnection in - peerConnection.peerId == msg.senderId - } - case let .join(msg): - if listening { - span.addEvent(SpanEvent(name: "join msg received")) - await self.delegate?.receiveEvent( - event: .peerCandidate( - payload: .init( - peerId: msg.senderId, - peerMetadata: msg.peerMetadata - ) - ) - ) - peeredConnections.append(PeerConnection(peerId: msg.senderId, peerMetadata: msg.peerMetadata)) - span.addEvent(SpanEvent(name: "replying with peer msg")) - await self.send( - message: .peer( - .init( - senderId: peerId, - targetId: msg.senderId, - storageId: self.peerMetadata?.storageId, - ephemeral: self.peerMetadata?.isEphemeral ?? true - ) - ), - to: msg.senderId - ) - } else { - fatalError("non-listening endpoint received a join message") - } - case let .peer(msg): - span.addEvent(SpanEvent(name: "peer msg received")) - peeredConnections.append(PeerConnection(peerId: msg.senderId, peerMetadata: msg.peerMetadata)) - await self.delegate?.receiveEvent( - event: .ready( - payload: .init( - peerId: msg.senderId, - peerMetadata: msg.peerMetadata - ) - ) - ) - default: - if self.delegate == nil, logReceivedMessages { - Logger.testNetwork - .warning("ADAPTER \(self.debugDescription) has no delegate, ignoring received message") - } - span.addEvent(SpanEvent(name: "forwarding received msg to delegate")) - await self.delegate?.receiveEvent(event: .message(payload: msg)) - } - } - } - - public func send(message: SyncV1Msg, to: PEER_ID?) async { - guard let endpointName = self.endpointName else { - fatalError("Can't send without a configured endpoint") - } - await withSpan("send message") { span in - sent_messages.append(message) - - var wrappedMsg = InMemoryNetworkMsg(message) - if let context = ServiceContext.current { - InstrumentationSystem.instrument.inject( - context, - into: &wrappedMsg, - using: InMemoryNetworkMsgBaggageManipulator() - ) - } - - if let peerTarget = to { - let connectionsWithPeer = _connections.filter { connection in - connection.initiatingEndpoint.peerId == peerTarget || - connection.receivingEndpoint.peerId == peerTarget - } - for connection in connectionsWithPeer { - span.addEvent( - SpanEvent(name: "send message to peer", attributes: SpanAttributes([ - "msg": SpanAttribute(stringLiteral: wrappedMsg.debugDescription), - "destination": SpanAttribute(stringLiteral: peerTarget), - ])) - ) - await connection.send(sender: endpointName, msg: wrappedMsg) - } - } else { - // broadcast - for connection in _connections { - await connection.send(sender: endpointName, msg: wrappedMsg) - } - } - } - } - - public func setDelegate( - _ delegate: any NetworkEventReceiver, - as peer: PEER_ID, - with metadata: AutomergeRepo.PeerMetadata? - ) async { - self.peerId = peer - self.peerMetadata = metadata - self.delegate = delegate - } -} - -/// A Test network that operates in memory -/// -/// Acts akin to an outbound connection - doesn't "connect" and trigger messages until you explicitly ask -@globalActor public actor InMemoryNetwork { - public static let shared = InMemoryNetwork() - - private init() {} - - var endpoints: [String: InMemoryNetworkEndpoint] = [:] - var simulatedConnections: [InMemoryNetworkConnection] = [] - var enableTracing: Bool = false - - public func traceConnections(_ enableTracing: Bool) { - self.enableTracing = enableTracing - } - - public func networkEndpoint(named: String) -> InMemoryNetworkEndpoint? { - let x = endpoints[named] - return x - } - - public func connections() -> [InMemoryNetworkConnection] { - simulatedConnections - } - - // MARK: TESTING SPECIFIC API - - public func createNetworkEndpoint( - config: InMemoryNetworkEndpoint.BasicNetworkConfiguration - ) async -> InMemoryNetworkEndpoint { - let x = await InMemoryNetworkEndpoint(config) - endpoints[config.name] = x - return x - } - - public func connect(from: String, to: String, latency: Duration?) async throws -> InMemoryNetworkConnection { - if let initiator = networkEndpoint(named: from), let destination = networkEndpoint(named: to) { - guard await destination.listening == true else { - throw InMemoryNetworkErrors.EndpointNotListening(name: to) - } - - let newConnection = await InMemoryNetworkConnection( - from: initiator, - to: destination, - latency: latency, - trace: self.enableTracing - ) - simulatedConnections.append(newConnection) - await destination.acceptNewConnection(newConnection) - return newConnection - } else { - throw InMemoryNetworkErrors.NoSuchEndpoint(name: to) - } - } - - public func terminateConnection(_ id: UUID) async { - if let connectionIndex = simulatedConnections.firstIndex(where: { $0.id == id }) { - let connection = simulatedConnections[connectionIndex] - await connection.close() - simulatedConnections.remove(at: connectionIndex) - } - } - - public func messagesReceivedBy(name: String) async -> [SyncV1Msg] { - if let msgs = await self.endpoints[name]?.received_messages { - msgs - } else { - [] - } - } - - public func messagesSentBy(name: String) async -> [SyncV1Msg] { - if let msgs = await self.endpoints[name]?.sent_messages { - msgs - } else { - [] - } - } - - /// WIPES TEST NETWORK and resets all connections, but leaves endpoints intact and configured - public func resetTestNetwork() async { - for endpoint in self.endpoints.values { - await endpoint.wipe() - } - endpoints.removeAll() - - for connection in simulatedConnections { - await connection.close() - } - simulatedConnections = [] - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/TestOutgoingNetworkProvider.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/TestOutgoingNetworkProvider.swift deleted file mode 100644 index b8e31f40..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/TestOutgoingNetworkProvider.swift +++ /dev/null @@ -1,203 +0,0 @@ -import Automerge -import AutomergeRepo -import Foundation - -public struct TestOutgoingNetworkConfiguration: Sendable, CustomDebugStringConvertible { - let remotePeer: PEER_ID - let remotePeerMetadata: PeerMetadata? - let msgResponse: @Sendable (SyncV1Msg) async -> SyncV1Msg? - - public var debugDescription: String { - "peer: \(remotePeer), metadata: \(remotePeerMetadata?.debugDescription ?? "none")" - } - - init( - remotePeer: PEER_ID, - remotePeerMetadata: PeerMetadata?, - msgResponse: @Sendable @escaping (SyncV1Msg) async -> SyncV1Msg - ) { - self.remotePeer = remotePeer - self.remotePeerMetadata = remotePeerMetadata - self.msgResponse = msgResponse - } - - public static let simple: @Sendable (SyncV1Msg) async -> SyncV1Msg? = { msg in - var doc = Document() - var syncState = SyncState() - let peerId: PEER_ID = "SIMPLE REMOTE TEST" - let peerMetadata: PeerMetadata? = PeerMetadata(storageId: "SIMPLE STORAGE", isEphemeral: true) - switch msg { - case let .join(msg): - return .peer(.init( - senderId: peerId, - targetId: msg.senderId, - storageId: peerMetadata?.storageId, - ephemeral: peerMetadata?.isEphemeral ?? false - )) - case .peer: - return nil - case .leave: - return nil - case .error: - return nil - case let .request(msg): - // everything is always unavailable - return .unavailable(.init(documentId: msg.documentId, senderId: peerId, targetId: msg.senderId)) - case let .sync(msg): - do { - try doc.receiveSyncMessage(state: syncState, message: msg.data) - if let returnData = doc.generateSyncMessage(state: syncState) { - return .sync(.init( - documentId: msg.documentId, - senderId: peerId, - targetId: msg.senderId, - sync_message: returnData - )) - } - } catch { - return .error(.init(message: error.localizedDescription)) - } - return nil - case .unavailable: - return nil - case .ephemeral: - return nil // TODO: RESPONSE EXAMPLE - case .remoteSubscriptionChange: - return nil - case .remoteHeadsChanged: - return nil - case .unknown: - return nil - } - } -} - -/// A Test network that operates in memory -/// -/// Acts akin to an outbound connection - doesn't "connect" and trigger messages until you explicitly ask -public actor TestOutgoingNetworkProvider: NetworkProvider { - public var peeredConnections: [PeerConnection] = [] - - public typealias NetworkConnectionEndpoint = String - - public nonisolated var debugDescription: String { - "TestOutgoingNetworkProvider" - } - - public nonisolated var description: String { - "TestNetwork" - } - - var delegate: (any NetworkEventReceiver)? - - var config: TestOutgoingNetworkConfiguration? - var connected: Bool - var messages: [SyncV1Msg] = [] - - public typealias ProviderConfiguration = TestOutgoingNetworkConfiguration - - init() { - self.connected = false - self.delegate = nil - } - - public func configure(_ config: TestOutgoingNetworkConfiguration) async { - self.config = config - } - - public var connectedPeer: PEER_ID? { - get async { - if let config = self.config, self.connected == true { - return config.remotePeer - } - return nil - } - } - - public func connect(to _: String) async throws { - do { - guard let config = self.config else { - throw UnconfiguredTestNetwork() - } - self.peeredConnections.append(PeerConnection( - peerId: config.remotePeer, - peerMetadata: config.remotePeerMetadata - )) - await self.delegate?.receiveEvent( - event: .peerCandidate( - payload: .init( - peerId: config.remotePeer, - peerMetadata: config.remotePeerMetadata - ) - ) - ) - try await Task.sleep(for: .milliseconds(250)) - await self.delegate?.receiveEvent( - event: .ready( - payload: .init( - peerId: config.remotePeer, - peerMetadata: config.remotePeerMetadata - ) - ) - ) - self.connected = true - - } catch { - self.connected = false - } - } - - public func disconnect() async { - self.connected = false - } - - public func ready() async -> Bool { - self.connected - } - - public func send(message: SyncV1Msg, to _: PEER_ID?) async { - self.messages.append(message) - if let response = await config?.msgResponse(message) { - await delegate?.receiveEvent(event: .message(payload: response)) - } - } - - public func receiveMessage(msg _: SyncV1Msg) async { - // no-op on the receive, as all "responses" are generated by a closure provided - // by the configuration of this test network provider. - } - - public func setDelegate( - _ delegate: any AutomergeRepo.NetworkEventReceiver, - as _: AutomergeRepo.PEER_ID, - with _: AutomergeRepo.PeerMetadata? - ) async { - self.delegate = delegate - } - - // MARK: TESTING SPECIFIC API - - public func disconnectNow() async { - guard let config = self.config else { - fatalError("Attempting to disconnect an unconfigured testing network") - } - if self.connected { - self.connected = false - await delegate?.receiveEvent(event: .peerDisconnect(payload: .init(peerId: config.remotePeer))) - } - } - - public func messagesReceivedByRemotePeer() async -> [SyncV1Msg] { - self.messages - } - - /// WIPES TEST NETWORK AND ERASES DELEGATE SETTING - public func resetTestNetwork() async { - guard self.config != nil else { - fatalError("Attempting to reset an unconfigured testing network") - } - self.connected = false - self.messages = [] - self.delegate = nil - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/UnconfiguredTestNetwork.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/UnconfiguredTestNetwork.swift deleted file mode 100644 index 6d76f716..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/TestNetworkProviders/UnconfiguredTestNetwork.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -struct UnconfiguredTestNetwork: LocalizedError { - public var errorDescription: String? { - "The test network is not configured." - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/TestStorageProviders/InMemoryStorage.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/TestStorageProviders/InMemoryStorage.swift deleted file mode 100644 index 29cf9f74..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/TestStorageProviders/InMemoryStorage.swift +++ /dev/null @@ -1,74 +0,0 @@ -import AutomergeRepo -import struct Foundation.Data -import struct Foundation.UUID - -@globalActor public actor TestActor { - public static var shared = TestActor() -} - -/// An in-memory only storage provider. -@TestActor -public final class InMemoryStorage: StorageProvider { - public nonisolated let id: STORAGE_ID = UUID().uuidString - - var _storage: [DocumentId: Data] = [:] - var _incrementalChunks: [CombinedKey: [Data]] = [:] - - public init() {} - - public struct CombinedKey: Hashable, Comparable { - public static func < (lhs: InMemoryStorage.CombinedKey, rhs: InMemoryStorage.CombinedKey) -> Bool { - if lhs.prefix == rhs.prefix { - return lhs.id < rhs.id - } - return lhs.prefix < rhs.prefix - } - - public let id: DocumentId - public let prefix: String - } - - public func load(id: DocumentId) async -> Data? { - _storage[id] - } - - public func save(id: DocumentId, data: Data) async { - _storage[id] = data - } - - public func remove(id: DocumentId) async { - _storage.removeValue(forKey: id) - } - - // MARK: Incremental Load Support - - public func addToRange(id: DocumentId, prefix: String, data: Data) async { - var dataArray: [Data] = _incrementalChunks[CombinedKey(id: id, prefix: prefix)] ?? [] - dataArray.append(data) - _incrementalChunks[CombinedKey(id: id, prefix: prefix)] = dataArray - } - - public func loadRange(id: DocumentId, prefix: String) async -> [Data] { - _incrementalChunks[CombinedKey(id: id, prefix: prefix)] ?? [] - } - - public func removeRange(id: DocumentId, prefix: String, data: [Data]) async { - var chunksForKey: [Data] = _incrementalChunks[CombinedKey(id: id, prefix: prefix)] ?? [] - for d in data { - if let indexToRemove = chunksForKey.firstIndex(of: d) { - chunksForKey.remove(at: indexToRemove) - } - } - _incrementalChunks[CombinedKey(id: id, prefix: prefix)] = chunksForKey - } - - // MARK: Testing Spies/Support - - public func storageKeys() -> [DocumentId] { - _storage.keys.sorted() - } - - public func incrementalKeys() -> [CombinedKey] { - _incrementalChunks.keys.sorted() - } -} diff --git a/Packages/automerge-repo/Tests/AutomergeRepoTests/TwoReposWithNetworkTests.swift b/Packages/automerge-repo/Tests/AutomergeRepoTests/TwoReposWithNetworkTests.swift deleted file mode 100644 index 2316cade..00000000 --- a/Packages/automerge-repo/Tests/AutomergeRepoTests/TwoReposWithNetworkTests.swift +++ /dev/null @@ -1,297 +0,0 @@ -import Automerge -@testable import AutomergeRepo -import AutomergeUtilities -import DistributedTracer -import Foundation -import Logging -import OTel -import OTLPGRPC -import RegexBuilder -import ServiceLifecycle -import Tracing -import XCTest - -final class TwoReposWithNetworkTests: XCTestCase { - let network = InMemoryNetwork.shared - var repoOne: Repo! - var repoTwo: Repo! - - var adapterOne: InMemoryNetworkEndpoint! - var adapterTwo: InMemoryNetworkEndpoint! - - override func setUp() async throws { - await TestTracer.shared.bootstrap(serviceName: "RepoTests") - await withSpan("setUp") { _ in - - await withSpan("resetTestNetwork") { _ in - await network.resetTestNetwork() - } - - await withSpan("TwoReposWithNetworkTests_setup") { _ in - - let endpoints = await network.endpoints - XCTAssertEqual(endpoints.count, 0) - - repoOne = Repo(sharePolicy: SharePolicies.readonly) - // Repo setup WITHOUT any storage subsystem - let storageId = await repoOne.storageId() - XCTAssertNil(storageId) - - adapterOne = await network.createNetworkEndpoint( - config: .init( - listeningNetwork: false, - name: "One" - ) - ) - await repoOne.addNetworkAdapter(adapter: adapterOne) - - let peersOne = await repoOne.peers() - XCTAssertEqual(peersOne, []) - - repoTwo = Repo(sharePolicy: SharePolicies.agreeable) - adapterTwo = await network.createNetworkEndpoint( - config: .init( - listeningNetwork: true, - name: "Two" - ) - ) - await repoTwo.addNetworkAdapter(adapter: adapterTwo) - - let peersTwo = await repoTwo.peers() - XCTAssertEqual(peersTwo, []) - - let connections = await network.connections() - XCTAssertEqual(connections.count, 0) - - let endpointRecount = await network.endpoints - XCTAssertEqual(endpointRecount.count, 2) - } - } - } - - override func tearDown() async throws { - if let tracer = await TestTracer.shared.tracer { - tracer.forceFlush() - // Testing does NOT have a polite shutdown waiting for a flush to complete, so - // we explicitly give it some extra time here to flush out any spans remaining. - try await Task.sleep(for: .seconds(1)) - } - } - - func testMostBasicRepoStartingPoints() async throws { - // Repo - // property: peers [PeerId] - all (currently) connected peers - let peersOne = await repoOne.peers() - let peersTwo = await repoTwo.peers() - XCTAssertEqual(peersOne, []) - XCTAssertEqual(peersOne, peersTwo) - - let knownIdsOne = await repoOne.documentIds() - XCTAssertEqual(knownIdsOne, []) - - let knownIdsTwo = await repoOne.documentIds() - XCTAssertEqual(knownIdsTwo, knownIdsOne) - } - - func testCreateNetworkEndpoint() async throws { - let _ = await network.createNetworkEndpoint( - config: .init( - listeningNetwork: false, - name: "Z" - ) - ) - let endpoints = await network.endpoints - XCTAssertEqual(endpoints.count, 3) - let z = endpoints["Z"] - XCTAssertNotNil(z) - } - - func testConnect() async throws { - // Enable the following line to see the messages from the connections - // point of view: - - // await network.traceConnections(true) - - // Enable logging of received for the adapter: - await adapterOne.logReceivedMessages(true) - await adapterTwo.logReceivedMessages(true) - // Logging doesn't show up in exported test output - it's interleaved into Xcode's console - // which is useful for debugging tests - - try await withSpan("testConnect") { _ in - try await adapterOne.connect(to: "Two") - - let connectionIdFromOne = await adapterOne._connections.first?.id - let connectionIdFromTwo = await adapterTwo._connections.first?.id - XCTAssertEqual(connectionIdFromOne, connectionIdFromTwo) - - let peersOne = await adapterOne.peeredConnections - let peersTwo = await adapterTwo.peeredConnections - XCTAssertFalse(peersOne.isEmpty) - XCTAssertFalse(peersTwo.isEmpty) - } - } - - func testCreate() async throws { - try await withSpan("testCreate") { _ in - - // initial conditions - var knownOnTwo = await repoTwo.documentIds() - var knownOnOne = await repoOne.documentIds() - XCTAssertEqual(knownOnOne.count, 0) - XCTAssertEqual(knownOnTwo.count, 0) - - // Create and add some doc content to the "server" repo - RepoTwo - let newDocId = DocumentId() - let newDoc = try await withSpan("repoTwo.create") { _ in - try await repoTwo.create(id: newDocId) - } - // add some content to the new document - try newDoc.doc.put(obj: .ROOT, key: "title", value: .String("INITIAL VALUE")) - - XCTAssertNotNil(newDoc) - knownOnTwo = await repoTwo.documentIds() - XCTAssertEqual(knownOnTwo.count, 1) - XCTAssertEqual(knownOnTwo[0], newDocId) - - knownOnOne = await repoOne.documentIds() - XCTAssertEqual(knownOnOne.count, 0) - - // "GO ONLINE" - // await network.traceConnections(true) - // await adapterTwo.logReceivedMessages(true) - try await withSpan("adapterOne.connect") { _ in - try await adapterOne.connect(to: "Two") - } - - // verify that after sync, both repos have a copy of the document - knownOnOne = await repoOne.documentIds() - XCTAssertEqual(knownOnOne.count, 1) - XCTAssertEqual(knownOnOne[0], newDocId) - } - } - - func testFind() async throws { - // initial conditions - var knownOnTwo = await repoTwo.documentIds() - var knownOnOne = await repoOne.documentIds() - XCTAssertEqual(knownOnOne.count, 0) - XCTAssertEqual(knownOnTwo.count, 0) - - // "GO ONLINE" - // await network.traceConnections(true) - // await adapterTwo.logReceivedMessages(true) - try await withSpan("adapterOne.connect") { _ in - try await adapterOne.connect(to: "Two") - } - - // Create and add some doc content to the "server" repo - RepoTwo - let newDocId = DocumentId() - let newDoc = try await withSpan("repoTwo.create") { _ in - try await repoTwo.create(id: newDocId) - } - XCTAssertNotNil(newDoc.doc) - // add some content to the new document - try newDoc.doc.put(obj: .ROOT, key: "title", value: .String("INITIAL VALUE")) - - // Introducing a doc _after_ connecting shouldn't share it automatically - knownOnTwo = await repoTwo.documentIds() - XCTAssertEqual(knownOnTwo.count, 1) - XCTAssertEqual(knownOnTwo[0], newDocId) - - knownOnOne = await repoOne.documentIds() - XCTAssertEqual(knownOnOne.count, 0) - - // We can _request_ the document, and should find it - do { - let foundDoc = try await repoOne.find(id: newDocId) - XCTAssertTrue( - RepoHelpers.equalContents(doc1: foundDoc.doc, doc2: newDoc.doc) - ) - } catch { - let errMsg = error.localizedDescription - print(errMsg) - } - } - - func testFindFail() async throws { - // initial conditions - var knownOnTwo = await repoTwo.documentIds() - var knownOnOne = await repoOne.documentIds() - XCTAssertEqual(knownOnOne.count, 0) - XCTAssertEqual(knownOnTwo.count, 0) - - // Create and add some doc content to the "client" repo - RepoOne - let newDocId = DocumentId() - let newDoc = try await withSpan("repoTwo.create") { _ in - try await repoOne.create(id: newDocId) - } - XCTAssertNotNil(newDoc.doc) - // add some content to the new document - try newDoc.doc.put(obj: .ROOT, key: "title", value: .String("INITIAL VALUE")) - - knownOnTwo = await repoTwo.documentIds() - XCTAssertEqual(knownOnTwo.count, 0) - - knownOnOne = await repoOne.documentIds() - XCTAssertEqual(knownOnOne.count, 1) - XCTAssertEqual(knownOnOne[0], newDocId) - // "GO ONLINE" - await network.traceConnections(true) - // await adapterTwo.logReceivedMessages(true) - try await withSpan("adapterOne.connect") { _ in - try await adapterOne.connect(to: "Two") - } - - // Two doesn't automatically get the document because RepoOne - // isn't configured to "share" automatically on connect - // (it's not "agreeable") - knownOnTwo = await repoTwo.documentIds() - XCTAssertEqual(knownOnTwo.count, 0) - - knownOnOne = await repoOne.documentIds() - XCTAssertEqual(knownOnOne.count, 1) - - // We can _request_ the document, but should be denied - do { - let _ = try await repoTwo.find(id: newDocId) - XCTFail("RepoOne is private and should NOT share the document") - } catch { - let errMsg = error.localizedDescription - print(errMsg) - } - } -// -// func testDelete() async throws { -// let myId = DocumentId() -// let _ = try await repo.create(id: myId) -// var knownIds = await repo.documentIds() -// XCTAssertEqual(knownIds.count, 1) -// -// try await repo.delete(id: myId) -// knownIds = await repo.documentIds() -// XCTAssertEqual(knownIds.count, 0) -// -// do { -// let _ = try await repo.find(id: DocumentId()) -// XCTFail() -// } catch {} -// } -// -// func testClone() async throws { -// let myId = DocumentId() -// let handle = try await repo.create(id: myId) -// XCTAssertEqual(myId, handle.id) -// -// let clonedHandle = try await repo.clone(id: myId) -// XCTAssertNotEqual(handle.id, clonedHandle.id) -// XCTAssertNotEqual(handle.doc.actor, clonedHandle.doc.actor) -// -// let knownIds = await repo.documentIds() -// XCTAssertEqual(knownIds.count, 2) -// } - - // TBD: - // - func storageIdForPeer(peerId) -> StorageId - // - func subscribeToRemotes([StorageId]) -} diff --git a/Packages/automerge-repo/collector-config.yaml b/Packages/automerge-repo/collector-config.yaml deleted file mode 100644 index df239f54..00000000 --- a/Packages/automerge-repo/collector-config.yaml +++ /dev/null @@ -1,24 +0,0 @@ -receivers: - otlp: - protocols: - grpc: - endpoint: otel-collector:4317 - -exporters: - logging: - verbosity: detailed - - otlp: - endpoint: jaeger:4317 - tls: - insecure: true - - zipkin: - endpoint: "http://zipkin:9411/api/v2/spans" - - -service: - pipelines: - traces: - receivers: otlp - exporters: [logging, otlp, zipkin] diff --git a/Packages/automerge-repo/docker-compose-jaeger.yml b/Packages/automerge-repo/docker-compose-jaeger.yml deleted file mode 100644 index 0ffc197a..00000000 --- a/Packages/automerge-repo/docker-compose-jaeger.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: '3' -services: - jaeger: - # Jaeger is one of many options we can choose from as our distributed tracing backend. - # It supports OTLP out of the box so it's very easy to get started. - # https://www.jaegertracing.io - image: jaegertracing/all-in-one - ports: - - "4317:4317" # This is where the OTLPGRPCSpanExporter sends its spans - - "16686:16686" # This is Jaeger's Web UI, visualizing recorded traces diff --git a/Packages/automerge-repo/docker-compose-zipkin-jaeger.yml b/Packages/automerge-repo/docker-compose-zipkin-jaeger.yml deleted file mode 100644 index b4522b41..00000000 --- a/Packages/automerge-repo/docker-compose-zipkin-jaeger.yml +++ /dev/null @@ -1,26 +0,0 @@ -version: '3' -services: - otel-collector: - image: otel/opentelemetry-collector-contrib:latest - command: ["--config=/etc/config.yaml"] - volumes: - - ./collector-config.yaml:/etc/config.yaml - ports: - - "4317:4317" - networks: [exporter] - depends_on: [zipkin, jaeger] - - zipkin: - image: openzipkin/zipkin:latest - ports: - - "9411:9411" - networks: [exporter] - - jaeger: - image: jaegertracing/all-in-one - ports: - - "16686:16686" - networks: [exporter] - -networks: - exporter: diff --git a/Packages/automerge-repo/notes.md b/Packages/automerge-repo/notes.md deleted file mode 100644 index 35a049e9..00000000 --- a/Packages/automerge-repo/notes.md +++ /dev/null @@ -1,10 +0,0 @@ -# using docker-compose - -`docker-compose -f someDockerComposefile up -d`, for example: - -```bash -docker-compose -f docker-compose.yml up -d -``` - -there's an equiv for Tempo, and another for sigNoz -https://github.com/SigNoz/signoz/tree/develop/deploy/docker/clickhouse-setup diff --git a/Packages/automerge-repo/notes/.gitignore b/Packages/automerge-repo/notes/.gitignore deleted file mode 100644 index cc4de8dc..00000000 --- a/Packages/automerge-repo/notes/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -package.json -package-lock.json -node_modules -*.svg -yarn.lock diff --git a/Packages/automerge-repo/notes/README.md b/Packages/automerge-repo/notes/README.md deleted file mode 100644 index 5f5e3ddf..00000000 --- a/Packages/automerge-repo/notes/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Technical Notes - -## Generating SVG from Mermaid Diagrams - -To manually generate SVG files from the existing mermaid diagrams: - - npm i @mermaid-js/mermaid-cli - ./node_modules/.bin/mmdc -i websocket_sync_states.mmd -o websocket_sync_states.svg - -Generate the compact left-to-right views with a single state highlighted, for annotating other bits of documentation: - - npm i @mermaid-js/mermaid-cli - ./node_modules/.bin/mmdc -i websocket_sync_initial.mmd -o wss_initial.svg - ./node_modules/.bin/mmdc -i websocket_sync_handshake.mmd -o wss_handshake.svg - ./node_modules/.bin/mmdc -i websocket_sync_peered_waiting.mmd -o wss_peered_waiting.svg - ./node_modules/.bin/mmdc -i websocket_sync_peered_syncing.mmd -o wss_peered_syncing.svg - ./node_modules/.bin/mmdc -i websocket_sync_closed.mmd -o wss_closed.svg diff --git a/Packages/automerge-repo/notes/generate.bash b/Packages/automerge-repo/notes/generate.bash deleted file mode 100755 index bc3dd1d9..00000000 --- a/Packages/automerge-repo/notes/generate.bash +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -# -set -eou pipefail - -# see https://stackoverflow.com/questions/4774054/reliable-way-for-a-bash-script-to-get-the-full-path-to-itself -THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" - -pushd $THIS_SCRIPT_DIR - -if [ ! -d node_modules ]; then - yarn install -fi - -./node_modules/.bin/mmdc -i websocket_sync_states.mmd -o websocket_sync_states.svg - -./node_modules/.bin/mmdc -i websocket_sync_initial.mmd -o wss_initial.svg -./node_modules/.bin/mmdc -i websocket_sync_handshake.mmd -o wss_handshake.svg -./node_modules/.bin/mmdc -i websocket_sync_peered.mmd -o wss_peered.svg -./node_modules/.bin/mmdc -i websocket_sync_closed.mmd -o wss_closed.svg - -./node_modules/.bin/mmdc -i websocket_strategy_sync.mmd -o websocket_strategy_sync.svg -./node_modules/.bin/mmdc -i websocket_strategy_request.mmd -o websocket_stragegy_request.svg - -mv *.svg ../Sources/AutomergeRepo/Documentation.docc/Resources/ - -popd diff --git a/Packages/automerge-repo/notes/websocket_strategy_request.mmd b/Packages/automerge-repo/notes/websocket_strategy_request.mmd deleted file mode 100644 index e8ccc19b..00000000 --- a/Packages/automerge-repo/notes/websocket_strategy_request.mmd +++ /dev/null @@ -1,18 +0,0 @@ -sequenceDiagram - participant local - participant remote - - critical handshaking phase - Note over local,remote: state = "new" or "closed" - local->>remote: join - Note over local,remote: state = "handshake" - remote->>local: peer - end - - Note over local,remote: state = "peered" - local->>remote: request - alt: if unavailable - remote->>local: unavailable - else - remote-->>local: sync (if needed) - end \ No newline at end of file diff --git a/Packages/automerge-repo/notes/websocket_strategy_sync.mmd b/Packages/automerge-repo/notes/websocket_strategy_sync.mmd deleted file mode 100644 index b5e2358f..00000000 --- a/Packages/automerge-repo/notes/websocket_strategy_sync.mmd +++ /dev/null @@ -1,14 +0,0 @@ -sequenceDiagram - participant local - participant remote - - critical handshaking phase - Note over local,remote: state = "new" or "closed" - local->>remote: join - Note over local,remote: state = "handshake" - remote->>local: peer - end - - Note over local,remote: state = "peered" - local->>remote: sync - remote-->>local: sync (if needed) \ No newline at end of file diff --git a/Packages/automerge-repo/notes/websocket_sync_closed.mmd b/Packages/automerge-repo/notes/websocket_sync_closed.mmd deleted file mode 100644 index ed3d3cd2..00000000 --- a/Packages/automerge-repo/notes/websocket_sync_closed.mmd +++ /dev/null @@ -1,11 +0,0 @@ -stateDiagram-v2 - direction LR - - classDef currentState fill:#0CC,font-weight:bold,strike-width:2px - - [*] --> new - new --> handshake - handshake --> closed:::currentState - handshake --> peered - peered --> closed - closed --> handshake diff --git a/Packages/automerge-repo/notes/websocket_sync_handshake.mmd b/Packages/automerge-repo/notes/websocket_sync_handshake.mmd deleted file mode 100644 index ace0f10e..00000000 --- a/Packages/automerge-repo/notes/websocket_sync_handshake.mmd +++ /dev/null @@ -1,11 +0,0 @@ -stateDiagram-v2 - direction LR - - classDef currentState fill:#0CC,font-weight:bold,strike-width:2px - - [*] --> new - new --> handshake:::currentState - handshake --> closed - handshake --> peered - peered --> closed - closed --> handshake diff --git a/Packages/automerge-repo/notes/websocket_sync_initial.mmd b/Packages/automerge-repo/notes/websocket_sync_initial.mmd deleted file mode 100644 index 9d5ff995..00000000 --- a/Packages/automerge-repo/notes/websocket_sync_initial.mmd +++ /dev/null @@ -1,11 +0,0 @@ -stateDiagram-v2 - direction LR - - classDef currentState fill:#0CC,font-weight:bold,strike-width:2px - - [*] --> new:::currentState - new --> handshake - handshake --> closed - handshake --> peered - peered --> closed - closed --> handshake diff --git a/Packages/automerge-repo/notes/websocket_sync_peered.mmd b/Packages/automerge-repo/notes/websocket_sync_peered.mmd deleted file mode 100644 index dc4b063b..00000000 --- a/Packages/automerge-repo/notes/websocket_sync_peered.mmd +++ /dev/null @@ -1,11 +0,0 @@ -stateDiagram-v2 - direction LR - - classDef currentState fill:#0CC,font-weight:bold,strike-width:2px - - [*] --> new - new --> handshake - handshake --> closed - handshake --> peered:::currentState - peered --> closed - closed --> handshake diff --git a/Packages/automerge-repo/notes/websocket_sync_states.mmd b/Packages/automerge-repo/notes/websocket_sync_states.mmd deleted file mode 100644 index 08a0ea32..00000000 --- a/Packages/automerge-repo/notes/websocket_sync_states.mmd +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: WebSocket Sync Protocol ---- - -stateDiagram-v2 - [*] --> new : WebsocketSyncConnection.init() - new --> handshake : registerDocument()\nawait connect() - handshake --> closed : connect timeout expired\nconnection failed - handshake --> peered : websocket peer response - peered --> closed : await disconnect()\nwebsocket error - closed --> handshake : await connect() From 5cefbe2f28c899bc424fd3c11eec2f3de39e8e8b Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Fri, 12 Apr 2024 10:57:46 -0700 Subject: [PATCH 02/21] making a global repo --- MeetingNotes.xcodeproj/project.pbxproj | 10 +++++++++- .../{ => Legacy}/WebsocketSyncConnection.swift | 10 +++++----- MeetingNotes/MeetingNotesApp.swift | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) rename MeetingNotes/{ => Legacy}/WebsocketSyncConnection.swift (98%) diff --git a/MeetingNotes.xcodeproj/project.pbxproj b/MeetingNotes.xcodeproj/project.pbxproj index 88a8909d..b5b28fba 100644 --- a/MeetingNotes.xcodeproj/project.pbxproj +++ b/MeetingNotes.xcodeproj/project.pbxproj @@ -151,10 +151,10 @@ isa = PBXGroup; children = ( 1A0DDC342A464DEA001ECADD /* MeetingNotesApp.swift */, - 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */, 1AB369012A50D82C00F855F8 /* Views */, 1A0DDC362A464DEA001ECADD /* MeetingNotesDocument.swift */, 1AD5DA342A4650520085DF79 /* MeetingNotesModel.swift */, + 1AEC38032BC870CC00EA4B41 /* Legacy */, 1A0916FF2A4A171C00D80BF7 /* Documentation.docc */, 1A0DDC3A2A464DEB001ECADD /* Assets.xcassets */, 1A0DDC3E2A464DEB001ECADD /* Preview Content */, @@ -213,6 +213,14 @@ path = Views; sourceTree = ""; }; + 1AEC38032BC870CC00EA4B41 /* Legacy */ = { + isa = PBXGroup; + children = ( + 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */, + ); + path = Legacy; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ diff --git a/MeetingNotes/WebsocketSyncConnection.swift b/MeetingNotes/Legacy/WebsocketSyncConnection.swift similarity index 98% rename from MeetingNotes/WebsocketSyncConnection.swift rename to MeetingNotes/Legacy/WebsocketSyncConnection.swift index ef060c28..1c8396cc 100644 --- a/MeetingNotes/WebsocketSyncConnection.swift +++ b/MeetingNotes/Legacy/WebsocketSyncConnection.swift @@ -184,12 +184,12 @@ extension Logger { Logger.legacyWebSocket .warning("Decoding websocket message, expecting peer only - and it wasn't a peer message. RECEIVED MSG: \(decodeAttempted.debugDescription)" ) - throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodeAttempted) + throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodeAttempted.debugDescription) } } else { let decodedMsg = SyncV1Msg.decode(raw_data) if case .unknown = decodedMsg { - throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodedMsg) + throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodedMsg.debugDescription) } return decodedMsg } @@ -198,12 +198,12 @@ extension Logger { // In the handshake phase and received anything other than a valid peer message Logger.legacyWebSocket .warning("Unknown websocket message received: .string(\(string))") - throw SyncV1Msg.Errors.UnexpectedMsg(msg: msg) + throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: msg)) @unknown default: // In the handshake phase and received anything other than a valid peer message Logger.legacyWebSocket .error("Unknown websocket message received: \(String(describing: msg))") - throw SyncV1Msg.Errors.UnexpectedMsg(msg: msg) + throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: msg)) } } @@ -268,7 +268,7 @@ extension Logger { // For the sync protocol handshake phase, it's essentially "peer or die" since // we were the initiating side of the connection. guard case let .peer(peerMsg) = try attemptToDecode(websocketMsg, peerOnly: true) else { - throw SyncV1Msg.Errors.UnexpectedMsg(msg: websocketMsg) + throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: websocketMsg)) } Logger.legacyWebSocket.trace("Peered to targetId: \(peerMsg.senderId) \(peerMsg.debugDescription)") diff --git a/MeetingNotes/MeetingNotesApp.swift b/MeetingNotes/MeetingNotesApp.swift index d0cf86fa..ff1700f1 100644 --- a/MeetingNotes/MeetingNotesApp.swift +++ b/MeetingNotes/MeetingNotesApp.swift @@ -1,11 +1,20 @@ import AutomergeRepo import SwiftUI + public let repo = Repo(sharePolicy: SharePolicies.agreeable) +public let websocket = WebSocketProvider() +public let peerToPeer = PeerToPeerProvider( + PeerToPeerProviderConfiguration(passcode: "AutomergeMeetingNotes", + listening: true, + reconnectOnError: true, + autoconnect: true) +) /// The document-based Meeting Notes application. @main struct MeetingNotesApp: App { + var body: some Scene { DocumentGroup { MeetingNotesDocument() @@ -18,4 +27,11 @@ struct MeetingNotesApp: App { } } } + + init() { + Task { + await repo.addNetworkAdapter(adapter: websocket) + await repo.addNetworkAdapter(adapter: peerToPeer) + } + } } From 5f4aa075d9912996b1ccbc354b52ccdd703d18ff Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Sat, 13 Apr 2024 17:03:45 -0700 Subject: [PATCH 03/21] rebuilding up views around exposed Repo structs and provider details --- MeetingNotes.xcodeproj/project.pbxproj | 16 +++--- MeetingNotes/Views/AvailablePeer.swift | 25 +++++++++ .../Views/MeetingNotesDocumentView.swift | 4 +- .../Views/NWBrowserResultItemView.swift | 43 -------------- MeetingNotes/Views/PeerConnectionView.swift | 46 +++++++++++++++ MeetingNotes/Views/PeerSyncView.swift | 53 +++++++----------- MeetingNotes/Views/SyncConnectionView.swift | 56 ------------------- MeetingNotes/Views/SyncStatusView.swift | 9 ++- 8 files changed, 109 insertions(+), 143 deletions(-) create mode 100644 MeetingNotes/Views/AvailablePeer.swift delete mode 100644 MeetingNotes/Views/NWBrowserResultItemView.swift create mode 100644 MeetingNotes/Views/PeerConnectionView.swift delete mode 100644 MeetingNotes/Views/SyncConnectionView.swift diff --git a/MeetingNotes.xcodeproj/project.pbxproj b/MeetingNotes.xcodeproj/project.pbxproj index b5b28fba..c1cb3315 100644 --- a/MeetingNotes.xcodeproj/project.pbxproj +++ b/MeetingNotes.xcodeproj/project.pbxproj @@ -25,10 +25,10 @@ 1A273DD52B93EEF700B321C5 /* Automerge in Frameworks */ = {isa = PBXBuildFile; productRef = 1A273DD42B93EEF700B321C5 /* Automerge */; }; 1A273DD72B93F64500B321C5 /* Logger+extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A273DD62B93F64500B321C5 /* Logger+extensions.swift */; }; 1A2A02A52A50E74B0044064B /* EditableAgendaItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2A02A42A50E74A0044064B /* EditableAgendaItemView.swift */; }; - 1A2AD0312A7437E200EF0C5F /* SyncConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2AD0302A7437E200EF0C5F /* SyncConnectionView.swift */; }; + 1A2AD0312A7437E200EF0C5F /* PeerConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2AD0302A7437E200EF0C5F /* PeerConnectionView.swift */; }; 1A6FF21D2B64710700C99F81 /* WebSocketStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A6FF21C2B64710700C99F81 /* WebSocketStatusView.swift */; }; 1A7700C52A67343800869A4D /* PeerSyncView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7700C42A67343800869A4D /* PeerSyncView.swift */; }; - 1A7700C72A67479F00869A4D /* NWBrowserResultItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7700C62A67479F00869A4D /* NWBrowserResultItemView.swift */; }; + 1A7700C72A67479F00869A4D /* AvailablePeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7700C62A67479F00869A4D /* AvailablePeer.swift */; }; 1A90F7F62BC75BEE00E5B3BA /* AutomergeRepo in Frameworks */ = {isa = PBXBuildFile; productRef = 1A90F7F52BC75BEE00E5B3BA /* AutomergeRepo */; }; 1AC103972B7EB0EF0099296C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1AC103962B7EB0EF0099296C /* PrivacyInfo.xcprivacy */; }; 1AD5DA352A4650520085DF79 /* MeetingNotesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD5DA342A4650520085DF79 /* MeetingNotesModel.swift */; }; @@ -73,10 +73,10 @@ 1A0DDC622A464E2D001ECADD /* MeetingNotes.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MeetingNotes.xctestplan; sourceTree = ""; }; 1A273DD62B93F64500B321C5 /* Logger+extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+extensions.swift"; sourceTree = ""; }; 1A2A02A42A50E74A0044064B /* EditableAgendaItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableAgendaItemView.swift; sourceTree = ""; }; - 1A2AD0302A7437E200EF0C5F /* SyncConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncConnectionView.swift; sourceTree = ""; }; + 1A2AD0302A7437E200EF0C5F /* PeerConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerConnectionView.swift; sourceTree = ""; }; 1A6FF21C2B64710700C99F81 /* WebSocketStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketStatusView.swift; sourceTree = ""; }; 1A7700C42A67343800869A4D /* PeerSyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerSyncView.swift; sourceTree = ""; }; - 1A7700C62A67479F00869A4D /* NWBrowserResultItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWBrowserResultItemView.swift; sourceTree = ""; }; + 1A7700C62A67479F00869A4D /* AvailablePeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailablePeer.swift; sourceTree = ""; }; 1AC103962B7EB0EF0099296C /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 1AD5DA342A4650520085DF79 /* MeetingNotesModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MeetingNotesModel.swift; sourceTree = ""; }; 1AD71E8D2A57622B00B965BF /* MeetingNotesDocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesDocumentView.swift; sourceTree = ""; }; @@ -203,8 +203,8 @@ 1AD71E8D2A57622B00B965BF /* MeetingNotesDocumentView.swift */, 1A2A02A42A50E74A0044064B /* EditableAgendaItemView.swift */, 1A7700C42A67343800869A4D /* PeerSyncView.swift */, - 1A7700C62A67479F00869A4D /* NWBrowserResultItemView.swift */, - 1A2AD0302A7437E200EF0C5F /* SyncConnectionView.swift */, + 1A7700C62A67479F00869A4D /* AvailablePeer.swift */, + 1A2AD0302A7437E200EF0C5F /* PeerConnectionView.swift */, 1AD71E902A57630B00B965BF /* MergeView.swift */, 1AF4DDD92B7C57E800B23BF8 /* ExportView.swift */, 1AD71E922A5765A800B965BF /* SyncStatusView.swift */, @@ -373,7 +373,7 @@ files = ( 1A0DDC372A464DEA001ECADD /* MeetingNotesDocument.swift in Sources */, 1AD5DA352A4650520085DF79 /* MeetingNotesModel.swift in Sources */, - 1A2AD0312A7437E200EF0C5F /* SyncConnectionView.swift in Sources */, + 1A2AD0312A7437E200EF0C5F /* PeerConnectionView.swift in Sources */, 1A2A02A52A50E74B0044064B /* EditableAgendaItemView.swift in Sources */, 1A0917002A4A171C00D80BF7 /* Documentation.docc in Sources */, 1AD71E912A57630B00B965BF /* MergeView.swift in Sources */, @@ -382,7 +382,7 @@ 1AD71E8E2A57622B00B965BF /* MeetingNotesDocumentView.swift in Sources */, 1A273DD72B93F64500B321C5 /* Logger+extensions.swift in Sources */, 1ADDFBD92BC865900051195D /* WebsocketSyncConnection.swift in Sources */, - 1A7700C72A67479F00869A4D /* NWBrowserResultItemView.swift in Sources */, + 1A7700C72A67479F00869A4D /* AvailablePeer.swift in Sources */, 1AD71E932A5765A800B965BF /* SyncStatusView.swift in Sources */, 1A7700C52A67343800869A4D /* PeerSyncView.swift in Sources */, 1AF4DDDA2B7C57E800B23BF8 /* ExportView.swift in Sources */, diff --git a/MeetingNotes/Views/AvailablePeer.swift b/MeetingNotes/Views/AvailablePeer.swift new file mode 100644 index 00000000..5e7bcd87 --- /dev/null +++ b/MeetingNotes/Views/AvailablePeer.swift @@ -0,0 +1,25 @@ +import AutomergeRepo +import Network +import SwiftUI + +/// A view that shows nearby peers available for sync. +@MainActor +struct AvailablePeerView: View { + let result: AvailablePeer + + var body: some View { + VStack { + HStack { + Text(result.name) + Spacer() + Button { + Task { + try await peerToPeer.connect(to: result.endpoint) + } + } label: { + Text("Connect") + } + } + }.font(.caption) + } +} diff --git a/MeetingNotes/Views/MeetingNotesDocumentView.swift b/MeetingNotes/Views/MeetingNotesDocumentView.swift index a568365a..8ef22e41 100644 --- a/MeetingNotes/Views/MeetingNotesDocumentView.swift +++ b/MeetingNotes/Views/MeetingNotesDocumentView.swift @@ -82,7 +82,9 @@ struct MeetingNotesDocumentView: View { // including sometimes regenerating them when disk contents are updated // in the background, so register the current instance with the // sync coordinator as they become visible. - DocumentSyncCoordinator.shared.registerDocument(document: document.doc, id: document.id) + Task { + try await repo.create(doc: document.doc, id: document.id) + } } .onReceive(document.objectWillChange, perform: { _ in if !document.model.agendas.contains(where: { agendaItem in diff --git a/MeetingNotes/Views/NWBrowserResultItemView.swift b/MeetingNotes/Views/NWBrowserResultItemView.swift deleted file mode 100644 index facade55..00000000 --- a/MeetingNotes/Views/NWBrowserResultItemView.swift +++ /dev/null @@ -1,43 +0,0 @@ -import AutomergeRepo -import Network -import SwiftUI - -/// A view that shows nearby peers available for sync. -@MainActor -struct NWBrowserResultItemView: View { - var documentId: DocumentId - @ObservedObject var syncController: DocumentSyncCoordinator - var result: NWBrowser.Result - - func nameFromResultMetadata() -> String { - if case let .bonjour(txtrecord) = result.metadata { - return txtrecord[TXTRecordKeys.name] ?? "" - } - return "" - } - - func peerIdFromResultMetadata() -> String { - if case let .bonjour(txtrecord) = result.metadata { - return txtrecord[TXTRecordKeys.peer_id] ?? "" - } - return "" - } - - var body: some View { - VStack { - HStack { - Text(nameFromResultMetadata()) - Spacer() - Button { - syncController.attemptToConnectToPeer( - result.endpoint, - forPeer: peerIdFromResultMetadata(), - withDoc: documentId - ) - } label: { - Text("Connect") - } - } - }.font(.caption) - } -} diff --git a/MeetingNotes/Views/PeerConnectionView.swift b/MeetingNotes/Views/PeerConnectionView.swift new file mode 100644 index 00000000..be1e1fef --- /dev/null +++ b/MeetingNotes/Views/PeerConnectionView.swift @@ -0,0 +1,46 @@ +import AutomergeRepo +import Network +import SwiftUI + +/// A view that displays a sync connection and its state. +@MainActor +struct PeerConnectionView: View { + let peerConnection: PeerConnection + + func stateRepresentationView() -> some View { + if peerConnection.peered { + if peerConnection.initiated { + return Image(systemName: "arrow.up.circle").foregroundColor(.blue) + } else { + return Image(systemName: "arrow.down.circle").foregroundColor(.blue) + } + } else { + return Image(systemName: "questionmark.square.dashed").foregroundColor(.primary) + } + } + + var body: some View { + HStack(alignment: .firstTextBaseline) { + stateRepresentationView() + Text("\(peerConnection.peerId) at \(peerConnection.endpoint)") + Spacer() + Button { + Task { + await peerToPeer.disconnect(peerId: peerConnection.peerId) + } + } label: { + Image(systemName: "xmark.square") + } + } + .font(.caption) + .padding(4) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal) + } +} + +// struct SyncConnectionView_Previews: PreviewProvider { +// static var previews: some View { +// PeerConnectionView() +// } +// } diff --git a/MeetingNotes/Views/PeerSyncView.swift b/MeetingNotes/Views/PeerSyncView.swift index c2234944..a2256936 100644 --- a/MeetingNotes/Views/PeerSyncView.swift +++ b/MeetingNotes/Views/PeerSyncView.swift @@ -1,19 +1,20 @@ import AutomergeRepo import Network import SwiftUI +@preconcurrency import Combine /// A view that shows the status of peers and network syncing. @MainActor struct PeerSyncView: View { var documentId: DocumentId - @ObservedObject var syncController: DocumentSyncCoordinator = .shared + @State var availablePeers: [AvailablePeer] = [] + @State var connectionList: [PeerConnection] = [] @State var browserActive: Bool = false @State var browserStyling: Color = .primary + @State private var nameToDisplay: String = "" @State private var editNamePopoverShown: Bool = false - @AppStorage(SynchronizerDefaultKeys.publicPeerName) private var sharingIdentity: String = DocumentSyncCoordinator - .defaultSharingIdentity() var body: some View { VStack { @@ -22,20 +23,22 @@ struct PeerSyncView: View { Button(action: { editNamePopoverShown.toggle() }, label: { - Text("\(syncController.name)").font(.headline) + Text("\(nameToDisplay)").font(.headline) }) .buttonStyle(.borderless) .popover(isPresented: $editNamePopoverShown, content: { Form { Text("What name should we show for collaboration?") - TextField("identity", text: $sharingIdentity) + TextField("identity", text: $nameToDisplay) .textFieldStyle(.roundedBorder) .onSubmit { // Require a name to continue - if !sharingIdentity.isEmpty { + if !nameToDisplay.isEmpty { editNamePopoverShown.toggle() } - syncController.name = sharingIdentity + Task { + await peerToPeer.peerName = nameToDisplay + } } Button(role: .cancel) { editNamePopoverShown.toggle() @@ -51,7 +54,7 @@ struct PeerSyncView: View { .foregroundStyle(browserStyling) } .padding(.horizontal) - if !syncController.browserResults.isEmpty { + if !availablePeers.isEmpty { Divider() HStack { Text("Peers").bold() @@ -59,8 +62,8 @@ struct PeerSyncView: View { } .padding(.leading) LazyVStack { - ForEach(syncController.browserResults, id: \.hashValue) { result in - NWBrowserResultItemView(documentId: documentId, syncController: syncController, result: result) + ForEach(availablePeers, id: \.peerId) { result in + AvailablePeerView(result: result) .padding(4) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) .padding(.horizontal) @@ -68,34 +71,18 @@ struct PeerSyncView: View { } } LazyVStack { - ForEach(syncController.connections) { connection in - SyncConnectionView(syncConnection: connection) + ForEach(connectionList) { connection in + PeerConnectionView(peerConnection: connection) .padding(.leading, 4) } } } .padding(.vertical) - .onReceive(syncController.$browserState, perform: { status in - switch status { - case .cancelled: - browserActive = false - browserStyling = .orange - case .failed: - browserActive = false - browserStyling = .red - case .ready: - browserActive = true - browserStyling = .green - case .setup: - browserActive = false - browserStyling = .yellow - case .waiting: - browserActive = true - browserStyling = .gray - @unknown default: - browserActive = false - browserStyling = .gray - } + .onReceive(peerToPeer.connectionPublisher, perform: { connectionList in + self.connectionList = connectionList + }) + .onReceive(peerToPeer.availablePeerPublisher, perform: { availablePeerList in + availablePeers = availablePeerList }) } } diff --git a/MeetingNotes/Views/SyncConnectionView.swift b/MeetingNotes/Views/SyncConnectionView.swift deleted file mode 100644 index 1d70fa96..00000000 --- a/MeetingNotes/Views/SyncConnectionView.swift +++ /dev/null @@ -1,56 +0,0 @@ -import AutomergeRepo -import Network -import SwiftUI - -/// A view that displays a sync connection and its state. -@MainActor -struct SyncConnectionView: View { - @ObservedObject var syncConnection: BonjourSyncConnection - - func stateRepresentationView() -> some View { - switch syncConnection.connectionState { - case .setup: - return Image(systemName: "arrow.up.circle").foregroundColor(.gray) - case .waiting: - return Image(systemName: "exclamationmark.triangle").foregroundColor(.yellow) - case .preparing: - return Image(systemName: "arrow.up.circle").foregroundColor(.yellow) - case .ready: - return Image(systemName: "arrow.up.circle").foregroundColor(.blue) - case .failed: - return Image(systemName: "x.square").foregroundColor(.red) - case .cancelled: - return Image(systemName: "x.square").foregroundColor(.gray) - default: - return Image(systemName: "questionmark.square.dashed").foregroundColor(.primary) - } - } - - var body: some View { - HStack(alignment: .firstTextBaseline) { - stateRepresentationView() - if let txtRecord = syncConnection.endpoint?.txtRecord { - Text(txtRecord[TXTRecordKeys.name] ?? "unknown") - } else { - Text(syncConnection.shortId) - } - Text(syncConnection.endpoint?.interface?.name ?? "") - Spacer() - Button { - syncConnection.cancel() - } label: { - Image(systemName: "xmark.square") - } - } - .font(.caption) - .padding(4) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) - .padding(.horizontal) - } -} - -// struct SyncConnectionView_Previews: PreviewProvider { -// static var previews: some View { -// SyncConnectionView() -// } -// } diff --git a/MeetingNotes/Views/SyncStatusView.swift b/MeetingNotes/Views/SyncStatusView.swift index 64474602..e11afa53 100644 --- a/MeetingNotes/Views/SyncStatusView.swift +++ b/MeetingNotes/Views/SyncStatusView.swift @@ -10,9 +10,14 @@ struct SyncStatusView: View { syncEnabledIndicator.toggle() if syncEnabledIndicator { // only enable listening if an identity has been chosen - DocumentSyncCoordinator.shared.activate() + Task { + try await peerToPeer.startListening() + } + } else { - DocumentSyncCoordinator.shared.deactivate() + Task { + await peerToPeer.stopListening() + } } } label: { Image( From 0b1935101152a5f803caf7ae38ddb97e2d1a2c5c Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Sun, 14 Apr 2024 16:13:33 -0700 Subject: [PATCH 04/21] removed sync coorindator legacy, full compiling - still need to migrate WebSocket to repo/provider setup --- .../Legacy/WebsocketSyncConnection.swift | 39 ++++++++++++------- MeetingNotes/MeetingNotesApp.swift | 14 +++---- .../Views/MeetingNotesDocumentView.swift | 8 ++-- MeetingNotes/Views/PeerSyncView.swift | 4 +- MeetingNotes/Views/SyncStatusView.swift | 2 +- 5 files changed, 39 insertions(+), 28 deletions(-) diff --git a/MeetingNotes/Legacy/WebsocketSyncConnection.swift b/MeetingNotes/Legacy/WebsocketSyncConnection.swift index 1c8396cc..b5e04e74 100644 --- a/MeetingNotes/Legacy/WebsocketSyncConnection.swift +++ b/MeetingNotes/Legacy/WebsocketSyncConnection.swift @@ -1,9 +1,9 @@ - import Automerge +import Automerge import AutomergeRepo - import Combine - import Foundation - import OSLog - import PotentCBOR +import Combine +import Foundation +import OSLog +import PotentCBOR extension Logger { /// Using your bundle identifier is a great way to ensure a unique identifier. @@ -14,8 +14,8 @@ extension Logger { } /// A class that provides a WebSocket connection to sync an Automerge document. - @MainActor - public final class WebsocketSyncConnection: ObservableObject, Identifiable { +@MainActor +public final class WebsocketSyncConnection: ObservableObject, Identifiable { private var webSocketTask: URLSessionWebSocketTask? /// This connections "peer identifier" private let senderId: String @@ -100,7 +100,8 @@ extension Logger { while websocketconnection.syncInProgress { try Task.checkCancellation() Logger.legacyWebSocket - .trace("sync in progress, !cancelled - state is: \(websocketconnection.protocolState.rawValue, privacy: .public)" + .trace( + "sync in progress, !cancelled - state is: \(websocketconnection.protocolState.rawValue, privacy: .public)" ) // Race a timeout against receiving a Peer message from the other side // of the WebSocket connection. If we fail that race, shut down the connection @@ -182,7 +183,8 @@ extension Logger { // In the handshake phase and received anything other than a valid peer message let decodeAttempted = SyncV1Msg.decode(raw_data) Logger.legacyWebSocket - .warning("Decoding websocket message, expecting peer only - and it wasn't a peer message. RECEIVED MSG: \(decodeAttempted.debugDescription)" + .warning( + "Decoding websocket message, expecting peer only - and it wasn't a peer message. RECEIVED MSG: \(decodeAttempted.debugDescription)" ) throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodeAttempted.debugDescription) } @@ -436,7 +438,9 @@ extension Logger { try Task.checkCancellation() Logger.legacyWebSocket - .trace("Receive Handler: Task not cancelled, awaiting next message, state is \(self.protocolState.rawValue, privacy: .public)") + .trace( + "Receive Handler: Task not cancelled, awaiting next message, state is \(self.protocolState.rawValue, privacy: .public)" + ) let webSocketMessage = try await webSocketTask.receive() do { @@ -468,7 +472,8 @@ extension Logger { } else { // In the handshake phase and received anything other than a valid peer message Logger.legacyWebSocket - .warning("FAILED TO PEER - RECEIVED MSG: \(msg.debugDescription, privacy: .public), shutting down WebSocket" + .warning( + "FAILED TO PEER - RECEIVED MSG: \(msg.debugDescription, privacy: .public), shutting down WebSocket" ) await disconnect() } @@ -490,7 +495,8 @@ extension Logger { documentId.description == syncMsg.documentId else { Logger.legacyWebSocket - .warning("Sync message target and document Id don't match expected values. Received: \(syncMsg.debugDescription), targetId expected: \(self.senderId), documentId expected: \(documentId.description)" + .warning( + "Sync message target and document Id don't match expected values. Received: \(syncMsg.debugDescription), targetId expected: \(self.senderId), documentId expected: \(documentId.description)" ) return } @@ -526,8 +532,11 @@ extension Logger { } } catch { Logger.legacyWebSocket - .error("Error while applying sync message \(error.localizedDescription, privacy: .public), DISCONNECTING!") - Logger.legacyWebSocket.error("sync data raw bytes: \(syncMsg.data.hexEncodedString(), privacy: .public)") + .error( + "Error while applying sync message \(error.localizedDescription, privacy: .public), DISCONNECTING!" + ) + Logger.legacyWebSocket + .error("sync data raw bytes: \(syncMsg.data.hexEncodedString(), privacy: .public)") await disconnect() } case let .ephemeral(msg): @@ -570,4 +579,4 @@ extension Logger { await disconnect() } } - } +} diff --git a/MeetingNotes/MeetingNotesApp.swift b/MeetingNotes/MeetingNotesApp.swift index ff1700f1..dfe0bda4 100644 --- a/MeetingNotes/MeetingNotesApp.swift +++ b/MeetingNotes/MeetingNotesApp.swift @@ -1,20 +1,20 @@ import AutomergeRepo import SwiftUI - public let repo = Repo(sharePolicy: SharePolicies.agreeable) public let websocket = WebSocketProvider() public let peerToPeer = PeerToPeerProvider( - PeerToPeerProviderConfiguration(passcode: "AutomergeMeetingNotes", - listening: true, - reconnectOnError: true, - autoconnect: true) + PeerToPeerProviderConfiguration( + passcode: "AutomergeMeetingNotes", + listening: true, + reconnectOnError: true, + autoconnect: true + ) ) /// The document-based Meeting Notes application. @main struct MeetingNotesApp: App { - var body: some Scene { DocumentGroup { MeetingNotesDocument() @@ -27,7 +27,7 @@ struct MeetingNotesApp: App { } } } - + init() { Task { await repo.addNetworkAdapter(adapter: websocket) diff --git a/MeetingNotes/Views/MeetingNotesDocumentView.swift b/MeetingNotes/Views/MeetingNotesDocumentView.swift index 8ef22e41..a945a346 100644 --- a/MeetingNotes/Views/MeetingNotesDocumentView.swift +++ b/MeetingNotes/Views/MeetingNotesDocumentView.swift @@ -77,13 +77,15 @@ struct MeetingNotesDocumentView: View { // upon choosing a new selection on macOS .id(selection) } - .onAppear { + .task { // SwiftUI controls the lifecycle of MeetingNoteDocument instances, // including sometimes regenerating them when disk contents are updated // in the background, so register the current instance with the // sync coordinator as they become visible. - Task { - try await repo.create(doc: document.doc, id: document.id) + do { + _ = try await repo.create(doc: document.doc, id: document.id) + } catch { + fatalError("Crashed loading the document: \(error.localizedDescription)") } } .onReceive(document.objectWillChange, perform: { _ in diff --git a/MeetingNotes/Views/PeerSyncView.swift b/MeetingNotes/Views/PeerSyncView.swift index a2256936..ca32a5e5 100644 --- a/MeetingNotes/Views/PeerSyncView.swift +++ b/MeetingNotes/Views/PeerSyncView.swift @@ -1,7 +1,7 @@ import AutomergeRepo +@preconcurrency import Combine import Network import SwiftUI -@preconcurrency import Combine /// A view that shows the status of peers and network syncing. @MainActor @@ -37,7 +37,7 @@ struct PeerSyncView: View { editNamePopoverShown.toggle() } Task { - await peerToPeer.peerName = nameToDisplay + await peerToPeer.setName(nameToDisplay) } } Button(role: .cancel) { diff --git a/MeetingNotes/Views/SyncStatusView.swift b/MeetingNotes/Views/SyncStatusView.swift index e11afa53..c1d73d99 100644 --- a/MeetingNotes/Views/SyncStatusView.swift +++ b/MeetingNotes/Views/SyncStatusView.swift @@ -13,7 +13,7 @@ struct SyncStatusView: View { Task { try await peerToPeer.startListening() } - + } else { Task { await peerToPeer.stopListening() From 2a137c8ee5685194f92583673a09ce510af5a46a Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Mon, 15 Apr 2024 10:35:22 -0700 Subject: [PATCH 05/21] fixing issue receiving publishers generated off main thread --- MeetingNotes/Views/PeerSyncView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MeetingNotes/Views/PeerSyncView.swift b/MeetingNotes/Views/PeerSyncView.swift index ca32a5e5..58a3772d 100644 --- a/MeetingNotes/Views/PeerSyncView.swift +++ b/MeetingNotes/Views/PeerSyncView.swift @@ -78,10 +78,10 @@ struct PeerSyncView: View { } } .padding(.vertical) - .onReceive(peerToPeer.connectionPublisher, perform: { connectionList in + .onReceive(peerToPeer.connectionPublisher.receive(on: DispatchQueue.main), perform: { connectionList in self.connectionList = connectionList }) - .onReceive(peerToPeer.availablePeerPublisher, perform: { availablePeerList in + .onReceive(peerToPeer.availablePeerPublisher.receive(on: DispatchQueue.main), perform: { availablePeerList in availablePeers = availablePeerList }) } From ae35d7ba7bd93b2a3d8914a4918fe861ec84acbf Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Mon, 15 Apr 2024 10:55:38 -0700 Subject: [PATCH 06/21] cleanup --- MeetingNotes/Views/PeerSyncView.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/MeetingNotes/Views/PeerSyncView.swift b/MeetingNotes/Views/PeerSyncView.swift index 58a3772d..02339d52 100644 --- a/MeetingNotes/Views/PeerSyncView.swift +++ b/MeetingNotes/Views/PeerSyncView.swift @@ -10,10 +10,9 @@ struct PeerSyncView: View { @State var availablePeers: [AvailablePeer] = [] @State var connectionList: [PeerConnection] = [] - @State var browserActive: Bool = false @State var browserStyling: Color = .primary - @State private var nameToDisplay: String = "" + @AppStorage(UserDefaultKeys.publicPeerName) var nameToDisplay: String = "???" @State private var editNamePopoverShown: Bool = false var body: some View { @@ -48,9 +47,8 @@ struct PeerSyncView: View { } .padding() }) - Spacer() - Image(systemName: browserActive ? "bolt.horizontal.fill" : "bolt.horizontal") + Image(systemName: "bolt.horizontal") .foregroundStyle(browserStyling) } .padding(.horizontal) From 544c645df85e600eeefb7021bf0acc105f979634 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Mon, 15 Apr 2024 11:16:40 -0700 Subject: [PATCH 07/21] moving userdefault usage into App from library, need to filter self-peers from syncview --- MeetingNotes.xcodeproj/project.pbxproj | 4 ++++ MeetingNotes/UserDefaultKeys.swift | 5 +++++ MeetingNotes/Views/PeerSyncView.swift | 9 +++++++++ 3 files changed, 18 insertions(+) create mode 100644 MeetingNotes/UserDefaultKeys.swift diff --git a/MeetingNotes.xcodeproj/project.pbxproj b/MeetingNotes.xcodeproj/project.pbxproj index c1cb3315..f5dd8d8c 100644 --- a/MeetingNotes.xcodeproj/project.pbxproj +++ b/MeetingNotes.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ 1AD71E912A57630B00B965BF /* MergeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71E902A57630B00B965BF /* MergeView.swift */; }; 1AD71E932A5765A800B965BF /* SyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71E922A5765A800B965BF /* SyncStatusView.swift */; }; 1ADDFBD92BC865900051195D /* WebsocketSyncConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */; }; + 1AEFB5682BCDA2B50096D5DF /* UserDefaultKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AEFB5672BCDA2B50096D5DF /* UserDefaultKeys.swift */; }; 1AF4DDDA2B7C57E800B23BF8 /* ExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF4DDD92B7C57E800B23BF8 /* ExportView.swift */; }; /* End PBXBuildFile section */ @@ -83,6 +84,7 @@ 1AD71E902A57630B00B965BF /* MergeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MergeView.swift; sourceTree = ""; }; 1AD71E922A5765A800B965BF /* SyncStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusView.swift; sourceTree = ""; }; 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebsocketSyncConnection.swift; sourceTree = ""; }; + 1AEFB5672BCDA2B50096D5DF /* UserDefaultKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultKeys.swift; sourceTree = ""; }; 1AF4DDD92B7C57E800B23BF8 /* ExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportView.swift; sourceTree = ""; }; 1AF5DB3A2A4A0C38008DAC6F /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 1AF5DB3B2A4A0C5E008DAC6F /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; @@ -151,6 +153,7 @@ isa = PBXGroup; children = ( 1A0DDC342A464DEA001ECADD /* MeetingNotesApp.swift */, + 1AEFB5672BCDA2B50096D5DF /* UserDefaultKeys.swift */, 1AB369012A50D82C00F855F8 /* Views */, 1A0DDC362A464DEA001ECADD /* MeetingNotesDocument.swift */, 1AD5DA342A4650520085DF79 /* MeetingNotesModel.swift */, @@ -376,6 +379,7 @@ 1A2AD0312A7437E200EF0C5F /* PeerConnectionView.swift in Sources */, 1A2A02A52A50E74B0044064B /* EditableAgendaItemView.swift in Sources */, 1A0917002A4A171C00D80BF7 /* Documentation.docc in Sources */, + 1AEFB5682BCDA2B50096D5DF /* UserDefaultKeys.swift in Sources */, 1AD71E912A57630B00B965BF /* MergeView.swift in Sources */, 1A6FF21D2B64710700C99F81 /* WebSocketStatusView.swift in Sources */, 1A0DDC352A464DEA001ECADD /* MeetingNotesApp.swift in Sources */, diff --git a/MeetingNotes/UserDefaultKeys.swift b/MeetingNotes/UserDefaultKeys.swift new file mode 100644 index 00000000..a796f8f8 --- /dev/null +++ b/MeetingNotes/UserDefaultKeys.swift @@ -0,0 +1,5 @@ +/// A collection of User Default keys for the app. +public enum UserDefaultKeys: Sendable { + /// The key to the string that the app broadcasts to represent you when sharing and syncing Automerge Documents. + public static let publicPeerName = "sharingIdentity" +} diff --git a/MeetingNotes/Views/PeerSyncView.swift b/MeetingNotes/Views/PeerSyncView.swift index 02339d52..1f459653 100644 --- a/MeetingNotes/Views/PeerSyncView.swift +++ b/MeetingNotes/Views/PeerSyncView.swift @@ -82,6 +82,15 @@ struct PeerSyncView: View { .onReceive(peerToPeer.availablePeerPublisher.receive(on: DispatchQueue.main), perform: { availablePeerList in availablePeers = availablePeerList }) + .task { + if nameToDisplay == "???" { + // no user default is setup, so load a default value from the library + nameToDisplay = await peerToPeer.peerName + } else { + // overrides the library default name + await peerToPeer.setName(nameToDisplay) + } + } } } From 0ffe8d601196039e015266502897960306d5d897 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Mon, 15 Apr 2024 16:22:17 -0700 Subject: [PATCH 08/21] converting PeerSyncView to peer to peer/repo structures and updating it to match new repo API --- MeetingNotes/MeetingNotesApp.swift | 3 +- .../Views/EditableAgendaItemView.swift | 4 +- MeetingNotes/Views/PeerSyncView.swift | 54 +++++++++++++++++-- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/MeetingNotes/MeetingNotesApp.swift b/MeetingNotes/MeetingNotesApp.swift index dfe0bda4..155f8f62 100644 --- a/MeetingNotes/MeetingNotesApp.swift +++ b/MeetingNotes/MeetingNotesApp.swift @@ -6,9 +6,8 @@ public let websocket = WebSocketProvider() public let peerToPeer = PeerToPeerProvider( PeerToPeerProviderConfiguration( passcode: "AutomergeMeetingNotes", - listening: true, reconnectOnError: true, - autoconnect: true + autoconnect: false ) ) diff --git a/MeetingNotes/Views/EditableAgendaItemView.swift b/MeetingNotes/Views/EditableAgendaItemView.swift index e55d3725..b24486e6 100644 --- a/MeetingNotes/Views/EditableAgendaItemView.swift +++ b/MeetingNotes/Views/EditableAgendaItemView.swift @@ -72,8 +72,10 @@ struct EditableAgendaItemView: View { Text("Delete") } .buttonStyle(.borderedProminent) - }.padding([.horizontal, .bottom]) + } + .padding([.horizontal, .bottom]) } + .padding(.top) .focused($titleIsFocused) .onAppear(perform: { if let indexPosition = document.model.agendas.firstIndex(where: { $0.id == agendaItemId }) { diff --git a/MeetingNotes/Views/PeerSyncView.swift b/MeetingNotes/Views/PeerSyncView.swift index 1f459653..e0a7e1ad 100644 --- a/MeetingNotes/Views/PeerSyncView.swift +++ b/MeetingNotes/Views/PeerSyncView.swift @@ -11,10 +11,46 @@ struct PeerSyncView: View { @State var availablePeers: [AvailablePeer] = [] @State var connectionList: [PeerConnection] = [] @State var browserStyling: Color = .primary + @State var browserState: NWBrowser.State = .setup + @State var listenerState: NWListener.State = .setup @AppStorage(UserDefaultKeys.publicPeerName) var nameToDisplay: String = "???" @State private var editNamePopoverShown: Bool = false + func browserColor() -> Color { + switch browserState { + case .setup: + return .gray + case .ready: + return .blue + case .failed: + return .red + case .cancelled: + return .orange + case .waiting: + return .orange + @unknown default: + fatalError("unknown NWBrowser state: \(browserState)") + } + } + + func listenerColor() -> Color { + switch listenerState { + case .setup: + return .gray + case .waiting: + return .orange + case .ready: + return .blue + case .failed: + return .red + case .cancelled: + return .orange + @unknown default: + fatalError("unknown NWBrowser state: \(browserState)") + } + } + var body: some View { VStack { HStack { @@ -48,8 +84,10 @@ struct PeerSyncView: View { .padding() }) Spacer() - Image(systemName: "bolt.horizontal") - .foregroundStyle(browserStyling) + Image(systemName: "arrow.up.circle") + .foregroundStyle(browserColor()) + Image(systemName: "arrow.down.circle") + .foregroundStyle(listenerColor()) } .padding(.horizontal) if !availablePeers.isEmpty { @@ -80,7 +118,17 @@ struct PeerSyncView: View { self.connectionList = connectionList }) .onReceive(peerToPeer.availablePeerPublisher.receive(on: DispatchQueue.main), perform: { availablePeerList in - availablePeers = availablePeerList + // display all peers _except_ the one that represents ourself + let reducedPeers = availablePeerList.filter { peer in + peer.peerId != repo.peerId + } + availablePeers = reducedPeers + }) + .onReceive(peerToPeer.browserStatePublisher.receive(on: DispatchQueue.main), perform: { state in + browserState = state + }) + .onReceive(peerToPeer.listenerStatePublisher.receive(on: DispatchQueue.main), perform: { state in + listenerState = state }) .task { if nameToDisplay == "???" { From 059e72fb5868cf9272e6bc9a14c03d43c62059bb Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Tue, 16 Apr 2024 15:56:47 -0700 Subject: [PATCH 09/21] latest repo code --- MeetingNotes/Views/PeerConnectionView.swift | 2 +- MeetingNotes/Views/PeerSyncView.swift | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/MeetingNotes/Views/PeerConnectionView.swift b/MeetingNotes/Views/PeerConnectionView.swift index be1e1fef..4164de2d 100644 --- a/MeetingNotes/Views/PeerConnectionView.swift +++ b/MeetingNotes/Views/PeerConnectionView.swift @@ -5,7 +5,7 @@ import SwiftUI /// A view that displays a sync connection and its state. @MainActor struct PeerConnectionView: View { - let peerConnection: PeerConnection + let peerConnection: PeerConnectionInfo func stateRepresentationView() -> some View { if peerConnection.peered { diff --git a/MeetingNotes/Views/PeerSyncView.swift b/MeetingNotes/Views/PeerSyncView.swift index e0a7e1ad..02963982 100644 --- a/MeetingNotes/Views/PeerSyncView.swift +++ b/MeetingNotes/Views/PeerSyncView.swift @@ -1,6 +1,7 @@ import AutomergeRepo @preconcurrency import Combine import Network +import OSLog import SwiftUI /// A view that shows the status of peers and network syncing. @@ -9,7 +10,7 @@ struct PeerSyncView: View { var documentId: DocumentId @State var availablePeers: [AvailablePeer] = [] - @State var connectionList: [PeerConnection] = [] + @State var connectionList: [PeerConnectionInfo] = [] @State var browserStyling: Color = .primary @State var browserState: NWBrowser.State = .setup @State var listenerState: NWListener.State = .setup @@ -26,7 +27,7 @@ struct PeerSyncView: View { case .failed: return .red case .cancelled: - return .orange + return .gray case .waiting: return .orange @unknown default: @@ -125,9 +126,11 @@ struct PeerSyncView: View { availablePeers = reducedPeers }) .onReceive(peerToPeer.browserStatePublisher.receive(on: DispatchQueue.main), perform: { state in + Logger.document.debug("Browser state update to \(String(describing: state))") browserState = state }) .onReceive(peerToPeer.listenerStatePublisher.receive(on: DispatchQueue.main), perform: { state in + Logger.document.debug("Listener state update to \(String(describing: state))") listenerState = state }) .task { From 1f9d495923be40bd802a038807f9d69a8cceb328 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Fri, 19 Apr 2024 15:00:42 -0700 Subject: [PATCH 10/21] resolving missing sync to disk with repo-induced change to a document --- .../xcshareddata/xcschemes/MeetingNotes.xcscheme | 15 ++++++++++++++- MeetingNotes/Views/MeetingNotesDocumentView.swift | 11 +++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/MeetingNotes.xcodeproj/xcshareddata/xcschemes/MeetingNotes.xcscheme b/MeetingNotes.xcodeproj/xcshareddata/xcschemes/MeetingNotes.xcscheme index 1e8afe77..78471dfa 100644 --- a/MeetingNotes.xcodeproj/xcshareddata/xcschemes/MeetingNotes.xcscheme +++ b/MeetingNotes.xcodeproj/xcshareddata/xcschemes/MeetingNotes.xcscheme @@ -30,7 +30,8 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + reference = "container:MeetingNotes.xctestplan" + default = "YES"> @@ -78,6 +79,18 @@ ReferencedContainer = "container:MeetingNotes.xcodeproj"> + + + + + + Date: Mon, 22 Apr 2024 19:34:50 -0700 Subject: [PATCH 11/21] updating to align with latest API updates --- MeetingNotes/MeetingNotesApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MeetingNotes/MeetingNotesApp.swift b/MeetingNotes/MeetingNotesApp.swift index 155f8f62..9a609fed 100644 --- a/MeetingNotes/MeetingNotesApp.swift +++ b/MeetingNotes/MeetingNotesApp.swift @@ -1,7 +1,7 @@ import AutomergeRepo import SwiftUI -public let repo = Repo(sharePolicy: SharePolicies.agreeable) +public let repo = Repo(sharePolicy: SharePolicy.agreeable) public let websocket = WebSocketProvider() public let peerToPeer = PeerToPeerProvider( PeerToPeerProviderConfiguration( From e32bd1a932e7c96b5a20be9ec9398fb64975dc61 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Wed, 24 Apr 2024 14:48:28 -0700 Subject: [PATCH 12/21] curation update for rebuild --- .../Documentation.docc/Documentation.md | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/MeetingNotes/Documentation.docc/Documentation.md b/MeetingNotes/Documentation.docc/Documentation.md index 91fb7973..4711c58c 100644 --- a/MeetingNotes/Documentation.docc/Documentation.md +++ b/MeetingNotes/Documentation.docc/Documentation.md @@ -25,25 +25,23 @@ The source for the MeetingNotes app is [available on Github](https://github.com/ ### Core Application - ``MeetingNotesApp`` -- ``sharedSyncCoordinator`` -- ``MeetingNotesDefaultKeys`` - ``MergeError`` ### Logger extensions - ``MeetingNotes/os/Logger/document`` -- ``MeetingNotes/os/Logger/syncController`` -- ``MeetingNotes/os/Logger/syncConnection`` ### Views - ``MeetingNotesDocumentView`` - ``EditableAgendaItemView`` -- ``NWBrowserResultItemView`` +- ``AvailablePeerView`` +- ``PeerConnectionView`` - ``PeerSyncView`` -- ``SyncConnectionView`` +- ``SyncStatusView`` - ``MergeView`` - +- ``ExportView`` +- ``WebSocketStatusView`` ### Previews @@ -52,19 +50,12 @@ The source for the MeetingNotes app is [available on Github](https://github.com/ - ``PeerBrowserView_Previews`` - ``MergeView_Previews`` - ``SyncView_Previews`` +- ``ExportView_Previews`` +- ``WebSocketView_Previews`` -### Shared Peer Networking Components - -- ``DocumentSyncCoordinator`` -- ``SyncConnection`` -- ``TXTRecordKeys`` - -### Peer to Peer Syncing Protocol +### Legacy Sync Connection -- ``P2PAutomergeSyncProtocol`` -- ``P2PSyncMessageType`` -- ``P2PAutomergeSyncProtocolHeader`` -- ``MeetingNotes/Network/NWParameters/peerSyncParameters(documentId:)`` +- ``WebsocketSyncConnection`` ### Application Resources From 19e85a10c6c7fb21a3ce78e1f704911e7d9049eb Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Sat, 27 Apr 2024 15:51:40 -0700 Subject: [PATCH 13/21] partial updates --- .../Legacy/WebsocketSyncConnection.swift | 1154 ++++++++--------- MeetingNotes/Views/WebSocketStatusView.swift | 13 +- 2 files changed, 581 insertions(+), 586 deletions(-) diff --git a/MeetingNotes/Legacy/WebsocketSyncConnection.swift b/MeetingNotes/Legacy/WebsocketSyncConnection.swift index b5e04e74..662dbbbe 100644 --- a/MeetingNotes/Legacy/WebsocketSyncConnection.swift +++ b/MeetingNotes/Legacy/WebsocketSyncConnection.swift @@ -1,582 +1,582 @@ -import Automerge -import AutomergeRepo -import Combine -import Foundation -import OSLog -import PotentCBOR - -extension Logger { - /// Using your bundle identifier is a great way to ensure a unique identifier. - private nonisolated static let subsystem = Bundle.main.bundleIdentifier! - - /// Logs the Document interactions, such as saving and loading. - static let legacyWebSocket = Logger(subsystem: subsystem, category: "WebSocket") -} - -/// A class that provides a WebSocket connection to sync an Automerge document. -@MainActor -public final class WebsocketSyncConnection: ObservableObject, Identifiable { - private var webSocketTask: URLSessionWebSocketTask? - /// This connections "peer identifier" - private let senderId: String - /// The peer identifier for the receiving end of the websocket. - private var targetId: String? = nil - - private var syncState: Automerge.SyncState - /// The Automerge document that this connection interacts with - private weak var document: Automerge.Document? - /// The identifier for this Automerge document - private var documentId: DocumentId? - - /// A handle on an unstructured task that accepts and processes WebSocket messages - private var receiveHandler: Task? - - /// A handle to a cancellable Combine pipeline that watches a document for updates and attempts to start a sync - /// when it changes. - private var syncTrigger: (any Cancellable)? - - // TODO: Add a delegate link of some form for a 'ephemeral' msg data handler - // TODO: Add an indicator of if we should involve ourselves in "gossip" about updates - - @Published public var protocolState: ProtocolState - @Published public var syncInProgress: Bool - - // MARK: Initializers, registration/setup - - // having register after initialization lets us add within a SwiftUI view, and then - // configure and activate things onAppear within the view... - public init(_ document: Automerge.Document?, id documentId: DocumentId?) { - protocolState = .setup - syncState = SyncState() - senderId = UUID().uuidString - self.document = document - self.documentId = documentId - syncInProgress = false - } - - // having register after initialization lets us add within a SwiftUI view, and then - // configure and activate things onAppear within the view... - public func registerDocument(_ document: Automerge.Document, id: DocumentId) { - self.document = document - documentId = id - } - - // MARK: static initializers - - public static func syncDocument( - _ document: Automerge.Document, - id: DocumentId, - to destination: String - ) async throws -> WebsocketSyncConnection { - let websocketconnection = WebsocketSyncConnection(document, id: id) - - try await websocketconnection.connect(destination) - try await websocketconnection.runOngoingSync() - return websocketconnection - } - - public static func requestDocument( - _ id: DocumentId, - from destination: String, - setupOngoingSync: Bool = false - ) async throws -> (Automerge.Document, WebsocketSyncConnection)? { - let tempDocument = Document() - - let websocketconnection = WebsocketSyncConnection(tempDocument, id: id) - - assert(id == websocketconnection.documentId!) - try await websocketconnection.connect(destination) - - try Task.checkCancellation() - - guard websocketconnection.protocolState == .ready else { return nil } - - // enable the request... - websocketconnection.receiveHandler = nil - try await websocketconnection.sendRequestForDocument() - - assert(websocketconnection.syncInProgress == true) - - while websocketconnection.syncInProgress { - try Task.checkCancellation() - Logger.legacyWebSocket - .trace( - "sync in progress, !cancelled - state is: \(websocketconnection.protocolState.rawValue, privacy: .public)" - ) - // Race a timeout against receiving a Peer message from the other side - // of the WebSocket connection. If we fail that race, shut down the connection - // and move into a .closed connectionState - let websocketMsg = try await websocketconnection.nextMessage(withTimeout: .seconds(3.5)) - let decodedMsg = try websocketconnection.attemptToDecode(websocketMsg, peerOnly: false) - await websocketconnection.handleReceivedMessage(msg: decodedMsg) - } - - try Task.checkCancellation() - - if setupOngoingSync { - // fire up an ongoing process to maintain synchronization - websocketconnection.receiveHandler = Task { - try await websocketconnection.ongoingHandleWebSocketMessage() - } - await websocketconnection.initiateSync() - } - - return (tempDocument, websocketconnection) - } - - // MARK: - Utility functions for stitching together async workflows of tasks - - // throw error on timeout - // throw error on cancel - // otherwise return the msg - private func nextMessage( - withTimeout: ContinuousClock.Instant - .Duration = .seconds(3.5) - ) async throws -> URLSessionWebSocketTask.Message { - // Co-operatively check to see if we're cancelled, and if so - we can bail out before - // going into the receive loop. - try Task.checkCancellation() - - // check the invariants - guard let webSocketTask - else { - throw SyncV1Msg.Errors - .ConnectionClosed(errorDescription: "Attempting to wait for a websocket message when the task is nil") - } - - // Race a timeout against receiving a Peer message from the other side - // of the WebSocket connection. If we fail that race, shut down the connection - // and move into a .closed connectionState - let websocketMsg = try await withThrowingTaskGroup(of: URLSessionWebSocketTask.Message.self) { group in - group.addTask { - // retrieve the next websocket message - try await webSocketTask.receive() - } - - group.addTask { - // Race against the receive call with a continuous timer - try await Task.sleep(for: withTimeout) - throw SyncV1Msg.Errors.Timeout() - } - - guard let msg = try await group.next() else { - throw CancellationError() - } - // cancel all ongoing tasks (the websocket receive request, in this case) - group.cancelAll() - return msg - } - return websocketMsg - } - - private func attemptToDecode(_ msg: URLSessionWebSocketTask.Message, peerOnly: Bool = false) throws -> SyncV1Msg { - // Now that we have the WebSocket message, figure out if we got what we expected. - // For the sync protocol handshake phase, it's essentially "peer or die" since - // we were the initiating side of the connection. - switch msg { - case let .data(raw_data): - if peerOnly { - let msg = SyncV1Msg.decodePeer(raw_data) - if case .peer = msg { - return msg - } else { - // In the handshake phase and received anything other than a valid peer message - let decodeAttempted = SyncV1Msg.decode(raw_data) - Logger.legacyWebSocket - .warning( - "Decoding websocket message, expecting peer only - and it wasn't a peer message. RECEIVED MSG: \(decodeAttempted.debugDescription)" - ) - throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodeAttempted.debugDescription) - } - } else { - let decodedMsg = SyncV1Msg.decode(raw_data) - if case .unknown = decodedMsg { - throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodedMsg.debugDescription) - } - return decodedMsg - } - - case let .string(string): - // In the handshake phase and received anything other than a valid peer message - Logger.legacyWebSocket - .warning("Unknown websocket message received: .string(\(string))") - throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: msg)) - @unknown default: - // In the handshake phase and received anything other than a valid peer message - Logger.legacyWebSocket - .error("Unknown websocket message received: \(String(describing: msg))") - throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: msg)) - } - } - - // MARK: Connect - - /// Initiates a WebSocket connection to a remote peer. - /// - /// throws an error if something is awry, otherwise returns Void, with the connection established - public func connect(_ destination: String) async throws { - guard protocolState == .setup || protocolState == .closed else { - return - } - guard document != nil, documentId != nil else { - #if DEBUG - fatalError("Attempting to join a connection without a document registered") - #else - Logger.legacyWebSocket.error("Attempting to join a connection without a document registered") - return - #endif - } - guard let url = URL(string: destination) else { - Logger.legacyWebSocket.error("Destination provided is not a valid URL") - throw SyncV1Msg.Errors.InvalidURL(urlString: destination) - } - - // establishes the websocket - let request = URLRequest(url: url) - await MainActor.run { - // reset the document's synchronization state maintained by the connection - syncState = SyncState() - webSocketTask = URLSession.shared.webSocketTask(with: request) - } - guard let webSocketTask else { - #if DEBUG - fatalError("Attempting to configure and join a nil webSocketTask") - #else - return - #endif - } - - Logger.legacyWebSocket.trace("Activating websocket to \(url, privacy: .public)") - // start the websocket processing things - webSocketTask.resume() - - // since we initiated the WebSocket, it's on us to send an initial 'join' - // protocol message to start the handshake phase of the protocol - let joinMessage = SyncV1Msg.JoinMsg(senderId: senderId) - let data = try SyncV1Msg.encode(joinMessage) - try await webSocketTask.send(.data(data)) - Logger.legacyWebSocket.trace("SEND: \(joinMessage.debugDescription)") - await MainActor.run { - self.protocolState = .preparing - } - - do { - // Race a timeout against receiving a Peer message from the other side - // of the WebSocket connection. If we fail that race, shut down the connection - // and move into a .closed connectionState - let websocketMsg = try await nextMessage(withTimeout: .seconds(3.5)) - - // Now that we have the WebSocket message, figure out if we got what we expected. - // For the sync protocol handshake phase, it's essentially "peer or die" since - // we were the initiating side of the connection. - guard case let .peer(peerMsg) = try attemptToDecode(websocketMsg, peerOnly: true) else { - throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: websocketMsg)) - } - - Logger.legacyWebSocket.trace("Peered to targetId: \(peerMsg.senderId) \(peerMsg.debugDescription)") - // TODO: handle the gossip setup - read and process the peer metadata - await MainActor.run { - self.targetId = peerMsg.senderId - self.protocolState = .ready - } - } catch { - // if there's an error, disconnect anything that's lingering and cancel it down. - await disconnect() - throw error - } - assert(protocolState == .ready) - } - - /// Asynchronously disconnect the WebSocket and shut down active sessions. - public func disconnect() async { - syncTrigger?.cancel() - webSocketTask?.cancel(with: .normalClosure, reason: nil) - receiveHandler?.cancel() - await MainActor.run { - self.syncTrigger = nil - self.protocolState = .closed - self.webSocketTask = nil - self.syncInProgress = false - } - } - - public func runOngoingSync() async throws { - // Co-operatively check to see if we're cancelled, and if so - we can bail out before - // going into the receive loop. - try Task.checkCancellation() - - // verify we're in the right state before invoking the recursive (async) handler setup - // and start the process of synchronizing the document. - if protocolState == .ready { - // NOTE: this is technically a race between do we accept a message and do something - // with it (possibly changing state), or do we initiate a sync ourselves. In practice - // against Automerge-repo code, it doesn't proactively ask us to do anything, playing - // a more reactive role, but it's worth being away its a possibility. - receiveHandler = Task { - try await ongoingHandleWebSocketMessage() - } - - // kick off an initial sync - await initiateSync() - - // Watch the Automerge document for update messages, triggering a sync - // if one isn't already in flight. - syncTrigger = document?.objectWillChange.sink { - if !self.syncInProgress { - Task { [weak self] in - await self?.initiateSync() - } - } - } - } - } - - public func sendRequestForDocument() async throws { - // verify we're already connected and peered - guard protocolState == .ready, - let document, - let documentId, - let targetId, - let webSocketTask, - let syncData = document.generateSyncMessage(state: syncState) - else { - Logger.legacyWebSocket.warning("Attempting to join a connection without a document identifier registered") - return - } - assert( - // should be assured by the state diagram, but just in case. - self.document != nil && self - .documentId != nil - ) - await MainActor.run { - self.syncInProgress = true - } - let requestMsg = SyncV1Msg.RequestMsg( - documentId: documentId.description, - senderId: senderId, - targetId: targetId, - sync_message: syncData - ) - let data = try SyncV1Msg.encode(requestMsg) - try await webSocketTask.send(.data(data)) - Logger.legacyWebSocket.trace("SEND: \(requestMsg.debugDescription)") - } - - /// Start a synchronization process for the Automerge document - private func initiateSync() async { - guard protocolState == .ready, - syncInProgress == false - else { - return - } - guard let document, - let documentId, - let targetId, - let webSocketTask - else { - Logger.legacyWebSocket.warning("Attempting to join a connection without a document identifier registered") - return - } - assert( - // should be assured by the state diagram, but just in case. - self.document != nil && self - .documentId != nil - ) - - if let syncData = document.generateSyncMessage(state: syncState) { - await MainActor.run { - self.protocolState = .ready - self.syncInProgress = true - } - let syncMsg = SyncV1Msg.SyncMsg( - documentId: documentId.description, - senderId: senderId, - targetId: targetId, - sync_message: syncData - ) - var data: Data? = nil - do { - data = try SyncV1Msg.encode(syncMsg) - } catch { - Logger.legacyWebSocket.warning("Error encoding data: \(error.localizedDescription, privacy: .public)") - } - - do { - guard let data else { - return - } - try await webSocketTask.send(.data(data)) - Logger.legacyWebSocket.trace("SEND: \(syncMsg.debugDescription)") - } catch { - Logger.legacyWebSocket - .warning("Error in sending websocket data: \(error.localizedDescription, privacy: .public)") - await disconnect() - } - } - } - - // ideas for additional async API for this? -// public func sendGossip() async { +//import Automerge +//import AutomergeRepo +//import Combine +//import Foundation +//import OSLog +//import PotentCBOR // +//extension Logger { +// /// Using your bundle identifier is a great way to ensure a unique identifier. +// private nonisolated static let subsystem = Bundle.main.bundleIdentifier! +// +// /// Logs the Document interactions, such as saving and loading. +// static let legacyWebSocket = Logger(subsystem: subsystem, category: "WebSocket") +//} +// +///// A class that provides a WebSocket connection to sync an Automerge document. +//@MainActor +//public final class WebsocketSyncConnection: ObservableObject, Identifiable { +// private var webSocketTask: URLSessionWebSocketTask? +// /// This connections "peer identifier" +// private let senderId: String +// /// The peer identifier for the receiving end of the websocket. +// private var targetId: String? = nil +// +// private var syncState: Automerge.SyncState +// /// The Automerge document that this connection interacts with +// private weak var document: Automerge.Document? +// /// The identifier for this Automerge document +// private var documentId: DocumentId? +// +// /// A handle on an unstructured task that accepts and processes WebSocket messages +// private var receiveHandler: Task? +// +// /// A handle to a cancellable Combine pipeline that watches a document for updates and attempts to start a sync +// /// when it changes. +// private var syncTrigger: (any Cancellable)? +// +// // TODO: Add a delegate link of some form for a 'ephemeral' msg data handler +// // TODO: Add an indicator of if we should involve ourselves in "gossip" about updates +// +// @Published public var protocolState: ProtocolState +// @Published public var syncInProgress: Bool +// +// // MARK: Initializers, registration/setup +// +// // having register after initialization lets us add within a SwiftUI view, and then +// // configure and activate things onAppear within the view... +// public init(_ document: Automerge.Document?, id documentId: DocumentId?) { +// protocolState = .setup +// syncState = SyncState() +// senderId = UUID().uuidString +// self.document = document +// self.documentId = documentId +// syncInProgress = false +// } +// +// // having register after initialization lets us add within a SwiftUI view, and then +// // configure and activate things onAppear within the view... +// public func registerDocument(_ document: Automerge.Document, id: DocumentId) { +// self.document = document +// documentId = id +// } +// +// // MARK: static initializers +// +// public static func syncDocument( +// _ document: Automerge.Document, +// id: DocumentId, +// to destination: String +// ) async throws -> WebsocketSyncConnection { +// let websocketconnection = WebsocketSyncConnection(document, id: id) +// +// try await websocketconnection.connect(destination) +// try await websocketconnection.runOngoingSync() +// return websocketconnection +// } +// +// public static func requestDocument( +// _ id: DocumentId, +// from destination: String, +// setupOngoingSync: Bool = false +// ) async throws -> (Automerge.Document, WebsocketSyncConnection)? { +// let tempDocument = Document() +// +// let websocketconnection = WebsocketSyncConnection(tempDocument, id: id) +// +// assert(id == websocketconnection.documentId!) +// try await websocketconnection.connect(destination) +// +// try Task.checkCancellation() +// +// guard websocketconnection.protocolState == .ready else { return nil } +// +// // enable the request... +// websocketconnection.receiveHandler = nil +// try await websocketconnection.sendRequestForDocument() +// +// assert(websocketconnection.syncInProgress == true) +// +// while websocketconnection.syncInProgress { +// try Task.checkCancellation() +// Logger.legacyWebSocket +// .trace( +// "sync in progress, !cancelled - state is: \(websocketconnection.protocolState.rawValue, privacy: .public)" +// ) +// // Race a timeout against receiving a Peer message from the other side +// // of the WebSocket connection. If we fail that race, shut down the connection +// // and move into a .closed connectionState +// let websocketMsg = try await websocketconnection.nextMessage(withTimeout: .seconds(3.5)) +// let decodedMsg = try websocketconnection.attemptToDecode(websocketMsg, peerOnly: false) +// await websocketconnection.handleReceivedMessage(msg: decodedMsg) +// } +// +// try Task.checkCancellation() +// +// if setupOngoingSync { +// // fire up an ongoing process to maintain synchronization +// websocketconnection.receiveHandler = Task { +// try await websocketconnection.ongoingHandleWebSocketMessage() +// } +// await websocketconnection.initiateSync() +// } +// +// return (tempDocument, websocketconnection) +// } +// +// // MARK: - Utility functions for stitching together async workflows of tasks +// +// // throw error on timeout +// // throw error on cancel +// // otherwise return the msg +// private func nextMessage( +// withTimeout: ContinuousClock.Instant +// .Duration = .seconds(3.5) +// ) async throws -> URLSessionWebSocketTask.Message { +// // Co-operatively check to see if we're cancelled, and if so - we can bail out before +// // going into the receive loop. +// try Task.checkCancellation() +// +// // check the invariants +// guard let webSocketTask +// else { +// throw SyncV1Msg.Errors +// .ConnectionClosed(errorDescription: "Attempting to wait for a websocket message when the task is nil") +// } +// +// // Race a timeout against receiving a Peer message from the other side +// // of the WebSocket connection. If we fail that race, shut down the connection +// // and move into a .closed connectionState +// let websocketMsg = try await withThrowingTaskGroup(of: URLSessionWebSocketTask.Message.self) { group in +// group.addTask { +// // retrieve the next websocket message +// try await webSocketTask.receive() +// } +// +// group.addTask { +// // Race against the receive call with a continuous timer +// try await Task.sleep(for: withTimeout) +// throw SyncV1Msg.Errors.Timeout() +// } +// +// guard let msg = try await group.next() else { +// throw CancellationError() +// } +// // cancel all ongoing tasks (the websocket receive request, in this case) +// group.cancelAll() +// return msg +// } +// return websocketMsg +// } +// +// private func attemptToDecode(_ msg: URLSessionWebSocketTask.Message, peerOnly: Bool = false) throws -> SyncV1Msg { +// // Now that we have the WebSocket message, figure out if we got what we expected. +// // For the sync protocol handshake phase, it's essentially "peer or die" since +// // we were the initiating side of the connection. +// switch msg { +// case let .data(raw_data): +// if peerOnly { +// let msg = SyncV1Msg.decodePeer(raw_data) +// if case .peer = msg { +// return msg +// } else { +// // In the handshake phase and received anything other than a valid peer message +// let decodeAttempted = SyncV1Msg.decode(raw_data) +// Logger.legacyWebSocket +// .warning( +// "Decoding websocket message, expecting peer only - and it wasn't a peer message. RECEIVED MSG: \(decodeAttempted.debugDescription)" +// ) +// throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodeAttempted.debugDescription) +// } +// } else { +// let decodedMsg = SyncV1Msg.decode(raw_data) +// if case .unknown = decodedMsg { +// throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodedMsg.debugDescription) +// } +// return decodedMsg +// } +// +// case let .string(string): +// // In the handshake phase and received anything other than a valid peer message +// Logger.legacyWebSocket +// .warning("Unknown websocket message received: .string(\(string))") +// throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: msg)) +// @unknown default: +// // In the handshake phase and received anything other than a valid peer message +// Logger.legacyWebSocket +// .error("Unknown websocket message received: \(String(describing: msg))") +// throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: msg)) +// } +// } +// +// // MARK: Connect +// +// /// Initiates a WebSocket connection to a remote peer. +// /// +// /// throws an error if something is awry, otherwise returns Void, with the connection established +// public func connect(_ destination: String) async throws { +// guard protocolState == .setup || protocolState == .closed else { +// return +// } +// guard document != nil, documentId != nil else { +// #if DEBUG +// fatalError("Attempting to join a connection without a document registered") +// #else +// Logger.legacyWebSocket.error("Attempting to join a connection without a document registered") +// return +// #endif +// } +// guard let url = URL(string: destination) else { +// Logger.legacyWebSocket.error("Destination provided is not a valid URL") +// throw SyncV1Msg.Errors.InvalidURL(urlString: destination) +// } +// +// // establishes the websocket +// let request = URLRequest(url: url) +// await MainActor.run { +// // reset the document's synchronization state maintained by the connection +// syncState = SyncState() +// webSocketTask = URLSession.shared.webSocketTask(with: request) +// } +// guard let webSocketTask else { +// #if DEBUG +// fatalError("Attempting to configure and join a nil webSocketTask") +// #else +// return +// #endif +// } +// +// Logger.legacyWebSocket.trace("Activating websocket to \(url, privacy: .public)") +// // start the websocket processing things +// webSocketTask.resume() +// +// // since we initiated the WebSocket, it's on us to send an initial 'join' +// // protocol message to start the handshake phase of the protocol +// let joinMessage = SyncV1Msg.JoinMsg(senderId: senderId) +// let data = try SyncV1Msg.encode(joinMessage) +// try await webSocketTask.send(.data(data)) +// Logger.legacyWebSocket.trace("SEND: \(joinMessage.debugDescription)") +// await MainActor.run { +// self.protocolState = .preparing +// } +// +// do { +// // Race a timeout against receiving a Peer message from the other side +// // of the WebSocket connection. If we fail that race, shut down the connection +// // and move into a .closed connectionState +// let websocketMsg = try await nextMessage(withTimeout: .seconds(3.5)) +// +// // Now that we have the WebSocket message, figure out if we got what we expected. +// // For the sync protocol handshake phase, it's essentially "peer or die" since +// // we were the initiating side of the connection. +// guard case let .peer(peerMsg) = try attemptToDecode(websocketMsg, peerOnly: true) else { +// throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: websocketMsg)) +// } +// +// Logger.legacyWebSocket.trace("Peered to targetId: \(peerMsg.senderId) \(peerMsg.debugDescription)") +// // TODO: handle the gossip setup - read and process the peer metadata +// await MainActor.run { +// self.targetId = peerMsg.senderId +// self.protocolState = .ready +// } +// } catch { +// // if there's an error, disconnect anything that's lingering and cancel it down. +// await disconnect() +// throw error +// } +// assert(protocolState == .ready) +// } +// +// /// Asynchronously disconnect the WebSocket and shut down active sessions. +// public func disconnect() async { +// syncTrigger?.cancel() +// webSocketTask?.cancel(with: .normalClosure, reason: nil) +// receiveHandler?.cancel() +// await MainActor.run { +// self.syncTrigger = nil +// self.protocolState = .closed +// self.webSocketTask = nil +// self.syncInProgress = false +// } +// } +// +// public func runOngoingSync() async throws { +// // Co-operatively check to see if we're cancelled, and if so - we can bail out before +// // going into the receive loop. +// try Task.checkCancellation() +// +// // verify we're in the right state before invoking the recursive (async) handler setup +// // and start the process of synchronizing the document. +// if protocolState == .ready { +// // NOTE: this is technically a race between do we accept a message and do something +// // with it (possibly changing state), or do we initiate a sync ourselves. In practice +// // against Automerge-repo code, it doesn't proactively ask us to do anything, playing +// // a more reactive role, but it's worth being away its a possibility. +// receiveHandler = Task { +// try await ongoingHandleWebSocketMessage() +// } +// +// // kick off an initial sync +// await initiateSync() +// +// // Watch the Automerge document for update messages, triggering a sync +// // if one isn't already in flight. +// syncTrigger = document?.objectWillChange.sink { +// if !self.syncInProgress { +// Task { [weak self] in +// await self?.initiateSync() +// } +// } +// } +// } +// } +// +// public func sendRequestForDocument() async throws { +// // verify we're already connected and peered +// guard protocolState == .ready, +// let document, +// let documentId, +// let targetId, +// let webSocketTask, +// let syncData = document.generateSyncMessage(state: syncState) +// else { +// Logger.legacyWebSocket.warning("Attempting to join a connection without a document identifier registered") +// return +// } +// assert( +// // should be assured by the state diagram, but just in case. +// self.document != nil && self +// .documentId != nil +// ) +// await MainActor.run { +// self.syncInProgress = true +// } +// let requestMsg = SyncV1Msg.RequestMsg( +// documentId: documentId.description, +// senderId: senderId, +// targetId: targetId, +// sync_message: syncData +// ) +// let data = try SyncV1Msg.encode(requestMsg) +// try await webSocketTask.send(.data(data)) +// Logger.legacyWebSocket.trace("SEND: \(requestMsg.debugDescription)") +// } +// +// /// Start a synchronization process for the Automerge document +// private func initiateSync() async { +// guard protocolState == .ready, +// syncInProgress == false +// else { +// return +// } +// guard let document, +// let documentId, +// let targetId, +// let webSocketTask +// else { +// Logger.legacyWebSocket.warning("Attempting to join a connection without a document identifier registered") +// return +// } +// assert( +// // should be assured by the state diagram, but just in case. +// self.document != nil && self +// .documentId != nil +// ) +// +// if let syncData = document.generateSyncMessage(state: syncState) { +// await MainActor.run { +// self.protocolState = .ready +// self.syncInProgress = true +// } +// let syncMsg = SyncV1Msg.SyncMsg( +// documentId: documentId.description, +// senderId: senderId, +// targetId: targetId, +// sync_message: syncData +// ) +// var data: Data? = nil +// do { +// data = try SyncV1Msg.encode(syncMsg) +// } catch { +// Logger.legacyWebSocket.warning("Error encoding data: \(error.localizedDescription, privacy: .public)") +// } +// +// do { +// guard let data else { +// return +// } +// try await webSocketTask.send(.data(data)) +// Logger.legacyWebSocket.trace("SEND: \(syncMsg.debugDescription)") +// } catch { +// Logger.legacyWebSocket +// .warning("Error in sending websocket data: \(error.localizedDescription, privacy: .public)") +// await disconnect() +// } +// } +// } +// +// // ideas for additional async API for this? +//// public func sendGossip() async { +//// +//// } +//// +//// public func sendSubscribe() async { +//// +//// } +// +// // MARK: WebSocket Message handlers +// +// /// Infinitely loops over incoming messages from the websocket and updates the state machine based on the messages +// /// received. +// private func ongoingHandleWebSocketMessage() async throws { +// while true { +// guard let webSocketTask else { +// Logger.legacyWebSocket.warning("Receive Handler: webSocketTask is nil, terminating handler loop") +// break +// } +// +// try Task.checkCancellation() +// +// Logger.legacyWebSocket +// .trace( +// "Receive Handler: Task not cancelled, awaiting next message, state is \(self.protocolState.rawValue, privacy: .public)" +// ) +// +// let webSocketMessage = try await webSocketTask.receive() +// do { +// let msg = try attemptToDecode(webSocketMessage) +// await handleReceivedMessage(msg: msg) +// } catch { +// await disconnect() +// } +// } // } // -// public func sendSubscribe() async { +// /// Asynchronously updates the state machine and side-effect values in `WebsocketSyncConnection` +// /// +// /// this function doesn't throw on error conditions, but in some circumstances: +// /// - if it `connectionState` is in ``SyncProtocolState/handshake`` and receives anything other than a peer msg +// /// - if it is invoked while `connectionState` is reporting a ``SyncProtocolState/closed`` state +// /// it disconnects and shuts down the web-socket. +// private func handleReceivedMessage(msg: SyncV1Msg) async { +// switch protocolState { +// case .setup: +// Logger.legacyWebSocket.warning("RCVD: \(msg.debugDescription, privacy: .public) while in NEW state") +// case .preparing: +// if case let .peer(peerMsg) = msg { +// await MainActor.run { +// self.targetId = peerMsg.targetId +// self.protocolState = .ready +// } +// // TODO: handle the gossip setup - read and process the peer metadata +// } else { +// // In the handshake phase and received anything other than a valid peer message +// Logger.legacyWebSocket +// .warning( +// "FAILED TO PEER - RECEIVED MSG: \(msg.debugDescription, privacy: .public), shutting down WebSocket" +// ) +// await disconnect() +// } +// case .ready: +// switch msg { +// case let .error(errorMsg): +// Logger.legacyWebSocket.warning("RCVD ERROR: \(errorMsg.debugDescription)") +// +// case let .sync(syncMsg): +// guard let document, +// let documentId, +// let targetId, +// let webSocketTask +// else { +// return +// } +// +// guard senderId == syncMsg.targetId, +// documentId.description == syncMsg.documentId +// else { +// Logger.legacyWebSocket +// .warning( +// "Sync message target and document Id don't match expected values. Received: \(syncMsg.debugDescription), targetId expected: \(self.senderId), documentId expected: \(documentId.description)" +// ) +// return +// } +// +// do { +// Logger.legacyWebSocket.trace("RCVD: Applying sync message: \(syncMsg.debugDescription)") +// try document.receiveSyncMessage(state: syncState, message: syncMsg.data) +// // TODO: enable gossip of sending changed heads (if in gossip mode) +// if let syncData = document.generateSyncMessage(state: syncState) { +// // if we have a sync message, then sync isn't complete... +// // verify the state is set correctly, update it if not +// if syncInProgress != true { +// await MainActor.run { +// self.syncInProgress = true +// } +// } +// let replyingSyncMsg = SyncV1Msg.SyncMsg( +// documentId: documentId.description, +// senderId: senderId, +// targetId: targetId, +// sync_message: syncData +// ) +// Logger.legacyWebSocket +// .trace(" - SYNC: Sending another sync msg after applying updates") +// let replyData = try SyncV1Msg.encode(replyingSyncMsg) +// try await webSocketTask.send(.data(replyData)) +// Logger.legacyWebSocket.trace("SEND: \(replyingSyncMsg.debugDescription)") +// } else { +// await MainActor.run { +// self.syncInProgress = false +// } +// Logger.legacyWebSocket.trace(" - SYNC: No further sync msgs needed - sync complete.") +// } +// } catch { +// Logger.legacyWebSocket +// .error( +// "Error while applying sync message \(error.localizedDescription, privacy: .public), DISCONNECTING!" +// ) +// Logger.legacyWebSocket +// .error("sync data raw bytes: \(syncMsg.data.hexEncodedString(), privacy: .public)") +// await disconnect() +// } +// case let .ephemeral(msg): +// Logger.legacyWebSocket.trace("RCVD: Ephemeral message: \(msg.debugDescription, privacy: .public).") +// // TODO: enable a callback or something to allow someone external to handle the ephemeral messages +// case let .remoteHeadsChanged(msg): +// Logger.legacyWebSocket +// .trace("RCVD: remote head's changed message: \(msg.debugDescription, privacy: .public).") +// // TODO: enable gossiping responses +// +// case let .unavailable(inside_msg): +// Logger.legacyWebSocket.trace("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") +// +// // Messages that are technically allowed, but not common in the "ready" state unless +// // you're "serving up multiple documents" (this implementation links to a single Automerge +// // document. +// +// case let .request(inside_msg): +// Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") +// +// case let .remoteSubscriptionChange(inside_msg): +// Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") +// +// case let .leave(inside_msg): +// Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") +// +// // Messages that are always unexpected while in the "ready" state +// +// case let .peer(inside_msg): +// Logger.legacyWebSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") +// case let .join(inside_msg): +// Logger.legacyWebSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") +// case let .unknown(inside_msg): +// Logger.legacyWebSocket.warning("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") +// } // +// case .closed: +// Logger.legacyWebSocket.warning("RCVD: \(msg.debugDescription, privacy: .public), disconnecting (again?)") +// // cleanup - we shouldn't ever be here, but just in case... +// await disconnect() +// } // } - - // MARK: WebSocket Message handlers - - /// Infinitely loops over incoming messages from the websocket and updates the state machine based on the messages - /// received. - private func ongoingHandleWebSocketMessage() async throws { - while true { - guard let webSocketTask else { - Logger.legacyWebSocket.warning("Receive Handler: webSocketTask is nil, terminating handler loop") - break - } - - try Task.checkCancellation() - - Logger.legacyWebSocket - .trace( - "Receive Handler: Task not cancelled, awaiting next message, state is \(self.protocolState.rawValue, privacy: .public)" - ) - - let webSocketMessage = try await webSocketTask.receive() - do { - let msg = try attemptToDecode(webSocketMessage) - await handleReceivedMessage(msg: msg) - } catch { - await disconnect() - } - } - } - - /// Asynchronously updates the state machine and side-effect values in `WebsocketSyncConnection` - /// - /// this function doesn't throw on error conditions, but in some circumstances: - /// - if it `connectionState` is in ``SyncProtocolState/handshake`` and receives anything other than a peer msg - /// - if it is invoked while `connectionState` is reporting a ``SyncProtocolState/closed`` state - /// it disconnects and shuts down the web-socket. - private func handleReceivedMessage(msg: SyncV1Msg) async { - switch protocolState { - case .setup: - Logger.legacyWebSocket.warning("RCVD: \(msg.debugDescription, privacy: .public) while in NEW state") - case .preparing: - if case let .peer(peerMsg) = msg { - await MainActor.run { - self.targetId = peerMsg.targetId - self.protocolState = .ready - } - // TODO: handle the gossip setup - read and process the peer metadata - } else { - // In the handshake phase and received anything other than a valid peer message - Logger.legacyWebSocket - .warning( - "FAILED TO PEER - RECEIVED MSG: \(msg.debugDescription, privacy: .public), shutting down WebSocket" - ) - await disconnect() - } - case .ready: - switch msg { - case let .error(errorMsg): - Logger.legacyWebSocket.warning("RCVD ERROR: \(errorMsg.debugDescription)") - - case let .sync(syncMsg): - guard let document, - let documentId, - let targetId, - let webSocketTask - else { - return - } - - guard senderId == syncMsg.targetId, - documentId.description == syncMsg.documentId - else { - Logger.legacyWebSocket - .warning( - "Sync message target and document Id don't match expected values. Received: \(syncMsg.debugDescription), targetId expected: \(self.senderId), documentId expected: \(documentId.description)" - ) - return - } - - do { - Logger.legacyWebSocket.trace("RCVD: Applying sync message: \(syncMsg.debugDescription)") - try document.receiveSyncMessage(state: syncState, message: syncMsg.data) - // TODO: enable gossip of sending changed heads (if in gossip mode) - if let syncData = document.generateSyncMessage(state: syncState) { - // if we have a sync message, then sync isn't complete... - // verify the state is set correctly, update it if not - if syncInProgress != true { - await MainActor.run { - self.syncInProgress = true - } - } - let replyingSyncMsg = SyncV1Msg.SyncMsg( - documentId: documentId.description, - senderId: senderId, - targetId: targetId, - sync_message: syncData - ) - Logger.legacyWebSocket - .trace(" - SYNC: Sending another sync msg after applying updates") - let replyData = try SyncV1Msg.encode(replyingSyncMsg) - try await webSocketTask.send(.data(replyData)) - Logger.legacyWebSocket.trace("SEND: \(replyingSyncMsg.debugDescription)") - } else { - await MainActor.run { - self.syncInProgress = false - } - Logger.legacyWebSocket.trace(" - SYNC: No further sync msgs needed - sync complete.") - } - } catch { - Logger.legacyWebSocket - .error( - "Error while applying sync message \(error.localizedDescription, privacy: .public), DISCONNECTING!" - ) - Logger.legacyWebSocket - .error("sync data raw bytes: \(syncMsg.data.hexEncodedString(), privacy: .public)") - await disconnect() - } - case let .ephemeral(msg): - Logger.legacyWebSocket.trace("RCVD: Ephemeral message: \(msg.debugDescription, privacy: .public).") - // TODO: enable a callback or something to allow someone external to handle the ephemeral messages - case let .remoteHeadsChanged(msg): - Logger.legacyWebSocket - .trace("RCVD: remote head's changed message: \(msg.debugDescription, privacy: .public).") - // TODO: enable gossiping responses - - case let .unavailable(inside_msg): - Logger.legacyWebSocket.trace("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") - - // Messages that are technically allowed, but not common in the "ready" state unless - // you're "serving up multiple documents" (this implementation links to a single Automerge - // document. - - case let .request(inside_msg): - Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") - - case let .remoteSubscriptionChange(inside_msg): - Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") - - case let .leave(inside_msg): - Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") - - // Messages that are always unexpected while in the "ready" state - - case let .peer(inside_msg): - Logger.legacyWebSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") - case let .join(inside_msg): - Logger.legacyWebSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") - case let .unknown(inside_msg): - Logger.legacyWebSocket.warning("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") - } - - case .closed: - Logger.legacyWebSocket.warning("RCVD: \(msg.debugDescription, privacy: .public), disconnecting (again?)") - // cleanup - we shouldn't ever be here, but just in case... - await disconnect() - } - } -} +//} diff --git a/MeetingNotes/Views/WebSocketStatusView.swift b/MeetingNotes/Views/WebSocketStatusView.swift index 89297740..6e590a19 100644 --- a/MeetingNotes/Views/WebSocketStatusView.swift +++ b/MeetingNotes/Views/WebSocketStatusView.swift @@ -10,18 +10,17 @@ struct WebSocketStatusView: View { // Identifiable conformance var id: Self { self } // URL string assist - var urlString: String { + var url: URL { switch self { case .local: - return "ws://localhost:3030/" + return URL(string: "ws://localhost:3030/")! case .automerge: - return "wss://sync.automerge.org/" + return URL(string: "wss://sync.automerge.org/")! } } } @ObservedObject var document: MeetingNotesDocument - @StateObject private var websocket = WebsocketSyncConnection(nil, id: nil) @State private var syncEnabledIndicator: Bool = false @State private var syncDestination: SyncTargets = .automerge @@ -39,8 +38,7 @@ struct WebSocketStatusView: View { syncEnabledIndicator.toggle() if syncEnabledIndicator { Task { - try await websocket.connect(syncDestination.urlString) - try await websocket.runOngoingSync() + try await websocket.connect(to: syncDestination.url) } } else { Task { @@ -58,9 +56,6 @@ struct WebSocketStatusView: View { .buttonStyle(.borderless) #endif } - .onAppear { - websocket.registerDocument(document.doc, id: document.id) - } } } From e9cf6a80069fcef55c1d4d6651a66f0ce5dd48dc Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Sat, 27 Apr 2024 16:09:55 -0700 Subject: [PATCH 14/21] finish removing legacy types, all in on repo --- MeetingNotes.xcodeproj/project.pbxproj | 12 - .../Legacy/WebsocketSyncConnection.swift | 582 ------------------ 2 files changed, 594 deletions(-) delete mode 100644 MeetingNotes/Legacy/WebsocketSyncConnection.swift diff --git a/MeetingNotes.xcodeproj/project.pbxproj b/MeetingNotes.xcodeproj/project.pbxproj index f5dd8d8c..9fd9473f 100644 --- a/MeetingNotes.xcodeproj/project.pbxproj +++ b/MeetingNotes.xcodeproj/project.pbxproj @@ -35,7 +35,6 @@ 1AD71E8E2A57622B00B965BF /* MeetingNotesDocumentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71E8D2A57622B00B965BF /* MeetingNotesDocumentView.swift */; }; 1AD71E912A57630B00B965BF /* MergeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71E902A57630B00B965BF /* MergeView.swift */; }; 1AD71E932A5765A800B965BF /* SyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD71E922A5765A800B965BF /* SyncStatusView.swift */; }; - 1ADDFBD92BC865900051195D /* WebsocketSyncConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */; }; 1AEFB5682BCDA2B50096D5DF /* UserDefaultKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AEFB5672BCDA2B50096D5DF /* UserDefaultKeys.swift */; }; 1AF4DDDA2B7C57E800B23BF8 /* ExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AF4DDD92B7C57E800B23BF8 /* ExportView.swift */; }; /* End PBXBuildFile section */ @@ -83,7 +82,6 @@ 1AD71E8D2A57622B00B965BF /* MeetingNotesDocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesDocumentView.swift; sourceTree = ""; }; 1AD71E902A57630B00B965BF /* MergeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MergeView.swift; sourceTree = ""; }; 1AD71E922A5765A800B965BF /* SyncStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusView.swift; sourceTree = ""; }; - 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebsocketSyncConnection.swift; sourceTree = ""; }; 1AEFB5672BCDA2B50096D5DF /* UserDefaultKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultKeys.swift; sourceTree = ""; }; 1AF4DDD92B7C57E800B23BF8 /* ExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportView.swift; sourceTree = ""; }; 1AF5DB3A2A4A0C38008DAC6F /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -157,7 +155,6 @@ 1AB369012A50D82C00F855F8 /* Views */, 1A0DDC362A464DEA001ECADD /* MeetingNotesDocument.swift */, 1AD5DA342A4650520085DF79 /* MeetingNotesModel.swift */, - 1AEC38032BC870CC00EA4B41 /* Legacy */, 1A0916FF2A4A171C00D80BF7 /* Documentation.docc */, 1A0DDC3A2A464DEB001ECADD /* Assets.xcassets */, 1A0DDC3E2A464DEB001ECADD /* Preview Content */, @@ -216,14 +213,6 @@ path = Views; sourceTree = ""; }; - 1AEC38032BC870CC00EA4B41 /* Legacy */ = { - isa = PBXGroup; - children = ( - 1ADDFBD82BC865900051195D /* WebsocketSyncConnection.swift */, - ); - path = Legacy; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -385,7 +374,6 @@ 1A0DDC352A464DEA001ECADD /* MeetingNotesApp.swift in Sources */, 1AD71E8E2A57622B00B965BF /* MeetingNotesDocumentView.swift in Sources */, 1A273DD72B93F64500B321C5 /* Logger+extensions.swift in Sources */, - 1ADDFBD92BC865900051195D /* WebsocketSyncConnection.swift in Sources */, 1A7700C72A67479F00869A4D /* AvailablePeer.swift in Sources */, 1AD71E932A5765A800B965BF /* SyncStatusView.swift in Sources */, 1A7700C52A67343800869A4D /* PeerSyncView.swift in Sources */, diff --git a/MeetingNotes/Legacy/WebsocketSyncConnection.swift b/MeetingNotes/Legacy/WebsocketSyncConnection.swift deleted file mode 100644 index 662dbbbe..00000000 --- a/MeetingNotes/Legacy/WebsocketSyncConnection.swift +++ /dev/null @@ -1,582 +0,0 @@ -//import Automerge -//import AutomergeRepo -//import Combine -//import Foundation -//import OSLog -//import PotentCBOR -// -//extension Logger { -// /// Using your bundle identifier is a great way to ensure a unique identifier. -// private nonisolated static let subsystem = Bundle.main.bundleIdentifier! -// -// /// Logs the Document interactions, such as saving and loading. -// static let legacyWebSocket = Logger(subsystem: subsystem, category: "WebSocket") -//} -// -///// A class that provides a WebSocket connection to sync an Automerge document. -//@MainActor -//public final class WebsocketSyncConnection: ObservableObject, Identifiable { -// private var webSocketTask: URLSessionWebSocketTask? -// /// This connections "peer identifier" -// private let senderId: String -// /// The peer identifier for the receiving end of the websocket. -// private var targetId: String? = nil -// -// private var syncState: Automerge.SyncState -// /// The Automerge document that this connection interacts with -// private weak var document: Automerge.Document? -// /// The identifier for this Automerge document -// private var documentId: DocumentId? -// -// /// A handle on an unstructured task that accepts and processes WebSocket messages -// private var receiveHandler: Task? -// -// /// A handle to a cancellable Combine pipeline that watches a document for updates and attempts to start a sync -// /// when it changes. -// private var syncTrigger: (any Cancellable)? -// -// // TODO: Add a delegate link of some form for a 'ephemeral' msg data handler -// // TODO: Add an indicator of if we should involve ourselves in "gossip" about updates -// -// @Published public var protocolState: ProtocolState -// @Published public var syncInProgress: Bool -// -// // MARK: Initializers, registration/setup -// -// // having register after initialization lets us add within a SwiftUI view, and then -// // configure and activate things onAppear within the view... -// public init(_ document: Automerge.Document?, id documentId: DocumentId?) { -// protocolState = .setup -// syncState = SyncState() -// senderId = UUID().uuidString -// self.document = document -// self.documentId = documentId -// syncInProgress = false -// } -// -// // having register after initialization lets us add within a SwiftUI view, and then -// // configure and activate things onAppear within the view... -// public func registerDocument(_ document: Automerge.Document, id: DocumentId) { -// self.document = document -// documentId = id -// } -// -// // MARK: static initializers -// -// public static func syncDocument( -// _ document: Automerge.Document, -// id: DocumentId, -// to destination: String -// ) async throws -> WebsocketSyncConnection { -// let websocketconnection = WebsocketSyncConnection(document, id: id) -// -// try await websocketconnection.connect(destination) -// try await websocketconnection.runOngoingSync() -// return websocketconnection -// } -// -// public static func requestDocument( -// _ id: DocumentId, -// from destination: String, -// setupOngoingSync: Bool = false -// ) async throws -> (Automerge.Document, WebsocketSyncConnection)? { -// let tempDocument = Document() -// -// let websocketconnection = WebsocketSyncConnection(tempDocument, id: id) -// -// assert(id == websocketconnection.documentId!) -// try await websocketconnection.connect(destination) -// -// try Task.checkCancellation() -// -// guard websocketconnection.protocolState == .ready else { return nil } -// -// // enable the request... -// websocketconnection.receiveHandler = nil -// try await websocketconnection.sendRequestForDocument() -// -// assert(websocketconnection.syncInProgress == true) -// -// while websocketconnection.syncInProgress { -// try Task.checkCancellation() -// Logger.legacyWebSocket -// .trace( -// "sync in progress, !cancelled - state is: \(websocketconnection.protocolState.rawValue, privacy: .public)" -// ) -// // Race a timeout against receiving a Peer message from the other side -// // of the WebSocket connection. If we fail that race, shut down the connection -// // and move into a .closed connectionState -// let websocketMsg = try await websocketconnection.nextMessage(withTimeout: .seconds(3.5)) -// let decodedMsg = try websocketconnection.attemptToDecode(websocketMsg, peerOnly: false) -// await websocketconnection.handleReceivedMessage(msg: decodedMsg) -// } -// -// try Task.checkCancellation() -// -// if setupOngoingSync { -// // fire up an ongoing process to maintain synchronization -// websocketconnection.receiveHandler = Task { -// try await websocketconnection.ongoingHandleWebSocketMessage() -// } -// await websocketconnection.initiateSync() -// } -// -// return (tempDocument, websocketconnection) -// } -// -// // MARK: - Utility functions for stitching together async workflows of tasks -// -// // throw error on timeout -// // throw error on cancel -// // otherwise return the msg -// private func nextMessage( -// withTimeout: ContinuousClock.Instant -// .Duration = .seconds(3.5) -// ) async throws -> URLSessionWebSocketTask.Message { -// // Co-operatively check to see if we're cancelled, and if so - we can bail out before -// // going into the receive loop. -// try Task.checkCancellation() -// -// // check the invariants -// guard let webSocketTask -// else { -// throw SyncV1Msg.Errors -// .ConnectionClosed(errorDescription: "Attempting to wait for a websocket message when the task is nil") -// } -// -// // Race a timeout against receiving a Peer message from the other side -// // of the WebSocket connection. If we fail that race, shut down the connection -// // and move into a .closed connectionState -// let websocketMsg = try await withThrowingTaskGroup(of: URLSessionWebSocketTask.Message.self) { group in -// group.addTask { -// // retrieve the next websocket message -// try await webSocketTask.receive() -// } -// -// group.addTask { -// // Race against the receive call with a continuous timer -// try await Task.sleep(for: withTimeout) -// throw SyncV1Msg.Errors.Timeout() -// } -// -// guard let msg = try await group.next() else { -// throw CancellationError() -// } -// // cancel all ongoing tasks (the websocket receive request, in this case) -// group.cancelAll() -// return msg -// } -// return websocketMsg -// } -// -// private func attemptToDecode(_ msg: URLSessionWebSocketTask.Message, peerOnly: Bool = false) throws -> SyncV1Msg { -// // Now that we have the WebSocket message, figure out if we got what we expected. -// // For the sync protocol handshake phase, it's essentially "peer or die" since -// // we were the initiating side of the connection. -// switch msg { -// case let .data(raw_data): -// if peerOnly { -// let msg = SyncV1Msg.decodePeer(raw_data) -// if case .peer = msg { -// return msg -// } else { -// // In the handshake phase and received anything other than a valid peer message -// let decodeAttempted = SyncV1Msg.decode(raw_data) -// Logger.legacyWebSocket -// .warning( -// "Decoding websocket message, expecting peer only - and it wasn't a peer message. RECEIVED MSG: \(decodeAttempted.debugDescription)" -// ) -// throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodeAttempted.debugDescription) -// } -// } else { -// let decodedMsg = SyncV1Msg.decode(raw_data) -// if case .unknown = decodedMsg { -// throw SyncV1Msg.Errors.UnexpectedMsg(msg: decodedMsg.debugDescription) -// } -// return decodedMsg -// } -// -// case let .string(string): -// // In the handshake phase and received anything other than a valid peer message -// Logger.legacyWebSocket -// .warning("Unknown websocket message received: .string(\(string))") -// throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: msg)) -// @unknown default: -// // In the handshake phase and received anything other than a valid peer message -// Logger.legacyWebSocket -// .error("Unknown websocket message received: \(String(describing: msg))") -// throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: msg)) -// } -// } -// -// // MARK: Connect -// -// /// Initiates a WebSocket connection to a remote peer. -// /// -// /// throws an error if something is awry, otherwise returns Void, with the connection established -// public func connect(_ destination: String) async throws { -// guard protocolState == .setup || protocolState == .closed else { -// return -// } -// guard document != nil, documentId != nil else { -// #if DEBUG -// fatalError("Attempting to join a connection without a document registered") -// #else -// Logger.legacyWebSocket.error("Attempting to join a connection without a document registered") -// return -// #endif -// } -// guard let url = URL(string: destination) else { -// Logger.legacyWebSocket.error("Destination provided is not a valid URL") -// throw SyncV1Msg.Errors.InvalidURL(urlString: destination) -// } -// -// // establishes the websocket -// let request = URLRequest(url: url) -// await MainActor.run { -// // reset the document's synchronization state maintained by the connection -// syncState = SyncState() -// webSocketTask = URLSession.shared.webSocketTask(with: request) -// } -// guard let webSocketTask else { -// #if DEBUG -// fatalError("Attempting to configure and join a nil webSocketTask") -// #else -// return -// #endif -// } -// -// Logger.legacyWebSocket.trace("Activating websocket to \(url, privacy: .public)") -// // start the websocket processing things -// webSocketTask.resume() -// -// // since we initiated the WebSocket, it's on us to send an initial 'join' -// // protocol message to start the handshake phase of the protocol -// let joinMessage = SyncV1Msg.JoinMsg(senderId: senderId) -// let data = try SyncV1Msg.encode(joinMessage) -// try await webSocketTask.send(.data(data)) -// Logger.legacyWebSocket.trace("SEND: \(joinMessage.debugDescription)") -// await MainActor.run { -// self.protocolState = .preparing -// } -// -// do { -// // Race a timeout against receiving a Peer message from the other side -// // of the WebSocket connection. If we fail that race, shut down the connection -// // and move into a .closed connectionState -// let websocketMsg = try await nextMessage(withTimeout: .seconds(3.5)) -// -// // Now that we have the WebSocket message, figure out if we got what we expected. -// // For the sync protocol handshake phase, it's essentially "peer or die" since -// // we were the initiating side of the connection. -// guard case let .peer(peerMsg) = try attemptToDecode(websocketMsg, peerOnly: true) else { -// throw SyncV1Msg.Errors.UnexpectedMsg(msg: String(describing: websocketMsg)) -// } -// -// Logger.legacyWebSocket.trace("Peered to targetId: \(peerMsg.senderId) \(peerMsg.debugDescription)") -// // TODO: handle the gossip setup - read and process the peer metadata -// await MainActor.run { -// self.targetId = peerMsg.senderId -// self.protocolState = .ready -// } -// } catch { -// // if there's an error, disconnect anything that's lingering and cancel it down. -// await disconnect() -// throw error -// } -// assert(protocolState == .ready) -// } -// -// /// Asynchronously disconnect the WebSocket and shut down active sessions. -// public func disconnect() async { -// syncTrigger?.cancel() -// webSocketTask?.cancel(with: .normalClosure, reason: nil) -// receiveHandler?.cancel() -// await MainActor.run { -// self.syncTrigger = nil -// self.protocolState = .closed -// self.webSocketTask = nil -// self.syncInProgress = false -// } -// } -// -// public func runOngoingSync() async throws { -// // Co-operatively check to see if we're cancelled, and if so - we can bail out before -// // going into the receive loop. -// try Task.checkCancellation() -// -// // verify we're in the right state before invoking the recursive (async) handler setup -// // and start the process of synchronizing the document. -// if protocolState == .ready { -// // NOTE: this is technically a race between do we accept a message and do something -// // with it (possibly changing state), or do we initiate a sync ourselves. In practice -// // against Automerge-repo code, it doesn't proactively ask us to do anything, playing -// // a more reactive role, but it's worth being away its a possibility. -// receiveHandler = Task { -// try await ongoingHandleWebSocketMessage() -// } -// -// // kick off an initial sync -// await initiateSync() -// -// // Watch the Automerge document for update messages, triggering a sync -// // if one isn't already in flight. -// syncTrigger = document?.objectWillChange.sink { -// if !self.syncInProgress { -// Task { [weak self] in -// await self?.initiateSync() -// } -// } -// } -// } -// } -// -// public func sendRequestForDocument() async throws { -// // verify we're already connected and peered -// guard protocolState == .ready, -// let document, -// let documentId, -// let targetId, -// let webSocketTask, -// let syncData = document.generateSyncMessage(state: syncState) -// else { -// Logger.legacyWebSocket.warning("Attempting to join a connection without a document identifier registered") -// return -// } -// assert( -// // should be assured by the state diagram, but just in case. -// self.document != nil && self -// .documentId != nil -// ) -// await MainActor.run { -// self.syncInProgress = true -// } -// let requestMsg = SyncV1Msg.RequestMsg( -// documentId: documentId.description, -// senderId: senderId, -// targetId: targetId, -// sync_message: syncData -// ) -// let data = try SyncV1Msg.encode(requestMsg) -// try await webSocketTask.send(.data(data)) -// Logger.legacyWebSocket.trace("SEND: \(requestMsg.debugDescription)") -// } -// -// /// Start a synchronization process for the Automerge document -// private func initiateSync() async { -// guard protocolState == .ready, -// syncInProgress == false -// else { -// return -// } -// guard let document, -// let documentId, -// let targetId, -// let webSocketTask -// else { -// Logger.legacyWebSocket.warning("Attempting to join a connection without a document identifier registered") -// return -// } -// assert( -// // should be assured by the state diagram, but just in case. -// self.document != nil && self -// .documentId != nil -// ) -// -// if let syncData = document.generateSyncMessage(state: syncState) { -// await MainActor.run { -// self.protocolState = .ready -// self.syncInProgress = true -// } -// let syncMsg = SyncV1Msg.SyncMsg( -// documentId: documentId.description, -// senderId: senderId, -// targetId: targetId, -// sync_message: syncData -// ) -// var data: Data? = nil -// do { -// data = try SyncV1Msg.encode(syncMsg) -// } catch { -// Logger.legacyWebSocket.warning("Error encoding data: \(error.localizedDescription, privacy: .public)") -// } -// -// do { -// guard let data else { -// return -// } -// try await webSocketTask.send(.data(data)) -// Logger.legacyWebSocket.trace("SEND: \(syncMsg.debugDescription)") -// } catch { -// Logger.legacyWebSocket -// .warning("Error in sending websocket data: \(error.localizedDescription, privacy: .public)") -// await disconnect() -// } -// } -// } -// -// // ideas for additional async API for this? -//// public func sendGossip() async { -//// -//// } -//// -//// public func sendSubscribe() async { -//// -//// } -// -// // MARK: WebSocket Message handlers -// -// /// Infinitely loops over incoming messages from the websocket and updates the state machine based on the messages -// /// received. -// private func ongoingHandleWebSocketMessage() async throws { -// while true { -// guard let webSocketTask else { -// Logger.legacyWebSocket.warning("Receive Handler: webSocketTask is nil, terminating handler loop") -// break -// } -// -// try Task.checkCancellation() -// -// Logger.legacyWebSocket -// .trace( -// "Receive Handler: Task not cancelled, awaiting next message, state is \(self.protocolState.rawValue, privacy: .public)" -// ) -// -// let webSocketMessage = try await webSocketTask.receive() -// do { -// let msg = try attemptToDecode(webSocketMessage) -// await handleReceivedMessage(msg: msg) -// } catch { -// await disconnect() -// } -// } -// } -// -// /// Asynchronously updates the state machine and side-effect values in `WebsocketSyncConnection` -// /// -// /// this function doesn't throw on error conditions, but in some circumstances: -// /// - if it `connectionState` is in ``SyncProtocolState/handshake`` and receives anything other than a peer msg -// /// - if it is invoked while `connectionState` is reporting a ``SyncProtocolState/closed`` state -// /// it disconnects and shuts down the web-socket. -// private func handleReceivedMessage(msg: SyncV1Msg) async { -// switch protocolState { -// case .setup: -// Logger.legacyWebSocket.warning("RCVD: \(msg.debugDescription, privacy: .public) while in NEW state") -// case .preparing: -// if case let .peer(peerMsg) = msg { -// await MainActor.run { -// self.targetId = peerMsg.targetId -// self.protocolState = .ready -// } -// // TODO: handle the gossip setup - read and process the peer metadata -// } else { -// // In the handshake phase and received anything other than a valid peer message -// Logger.legacyWebSocket -// .warning( -// "FAILED TO PEER - RECEIVED MSG: \(msg.debugDescription, privacy: .public), shutting down WebSocket" -// ) -// await disconnect() -// } -// case .ready: -// switch msg { -// case let .error(errorMsg): -// Logger.legacyWebSocket.warning("RCVD ERROR: \(errorMsg.debugDescription)") -// -// case let .sync(syncMsg): -// guard let document, -// let documentId, -// let targetId, -// let webSocketTask -// else { -// return -// } -// -// guard senderId == syncMsg.targetId, -// documentId.description == syncMsg.documentId -// else { -// Logger.legacyWebSocket -// .warning( -// "Sync message target and document Id don't match expected values. Received: \(syncMsg.debugDescription), targetId expected: \(self.senderId), documentId expected: \(documentId.description)" -// ) -// return -// } -// -// do { -// Logger.legacyWebSocket.trace("RCVD: Applying sync message: \(syncMsg.debugDescription)") -// try document.receiveSyncMessage(state: syncState, message: syncMsg.data) -// // TODO: enable gossip of sending changed heads (if in gossip mode) -// if let syncData = document.generateSyncMessage(state: syncState) { -// // if we have a sync message, then sync isn't complete... -// // verify the state is set correctly, update it if not -// if syncInProgress != true { -// await MainActor.run { -// self.syncInProgress = true -// } -// } -// let replyingSyncMsg = SyncV1Msg.SyncMsg( -// documentId: documentId.description, -// senderId: senderId, -// targetId: targetId, -// sync_message: syncData -// ) -// Logger.legacyWebSocket -// .trace(" - SYNC: Sending another sync msg after applying updates") -// let replyData = try SyncV1Msg.encode(replyingSyncMsg) -// try await webSocketTask.send(.data(replyData)) -// Logger.legacyWebSocket.trace("SEND: \(replyingSyncMsg.debugDescription)") -// } else { -// await MainActor.run { -// self.syncInProgress = false -// } -// Logger.legacyWebSocket.trace(" - SYNC: No further sync msgs needed - sync complete.") -// } -// } catch { -// Logger.legacyWebSocket -// .error( -// "Error while applying sync message \(error.localizedDescription, privacy: .public), DISCONNECTING!" -// ) -// Logger.legacyWebSocket -// .error("sync data raw bytes: \(syncMsg.data.hexEncodedString(), privacy: .public)") -// await disconnect() -// } -// case let .ephemeral(msg): -// Logger.legacyWebSocket.trace("RCVD: Ephemeral message: \(msg.debugDescription, privacy: .public).") -// // TODO: enable a callback or something to allow someone external to handle the ephemeral messages -// case let .remoteHeadsChanged(msg): -// Logger.legacyWebSocket -// .trace("RCVD: remote head's changed message: \(msg.debugDescription, privacy: .public).") -// // TODO: enable gossiping responses -// -// case let .unavailable(inside_msg): -// Logger.legacyWebSocket.trace("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") -// -// // Messages that are technically allowed, but not common in the "ready" state unless -// // you're "serving up multiple documents" (this implementation links to a single Automerge -// // document. -// -// case let .request(inside_msg): -// Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") -// -// case let .remoteSubscriptionChange(inside_msg): -// Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") -// -// case let .leave(inside_msg): -// Logger.legacyWebSocket.warning("RCVD unusual msg: \(inside_msg.debugDescription, privacy: .public)") -// -// // Messages that are always unexpected while in the "ready" state -// -// case let .peer(inside_msg): -// Logger.legacyWebSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") -// case let .join(inside_msg): -// Logger.legacyWebSocket.error("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") -// case let .unknown(inside_msg): -// Logger.legacyWebSocket.warning("RCVD unexpected msg: \(inside_msg.debugDescription, privacy: .public)") -// } -// -// case .closed: -// Logger.legacyWebSocket.warning("RCVD: \(msg.debugDescription, privacy: .public), disconnecting (again?)") -// // cleanup - we shouldn't ever be here, but just in case... -// await disconnect() -// } -// } -//} From 359e1026252f94fa53425eaeb6903ff36b5e9917 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Wed, 1 May 2024 15:34:08 -0700 Subject: [PATCH 15/21] log cleanup --- MeetingNotes/Logger+extensions.swift | 3 +++ MeetingNotes/MeetingNotesDocument.swift | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/MeetingNotes/Logger+extensions.swift b/MeetingNotes/Logger+extensions.swift index b0d77f05..362901db 100644 --- a/MeetingNotes/Logger+extensions.swift +++ b/MeetingNotes/Logger+extensions.swift @@ -11,4 +11,7 @@ extension Logger: @unchecked Sendable { /// Logs the Document interactions, such as saving and loading. static let document = Logger(subsystem: subsystem, category: "Document") + + /// Logs messages that might pertain to initiating, or receiving, sync updates + static let syncflow = Logger(subsystem: subsystem, category: "SyncFlow") } diff --git a/MeetingNotes/MeetingNotesDocument.swift b/MeetingNotes/MeetingNotesDocument.swift index 8433476b..7d2d878f 100644 --- a/MeetingNotes/MeetingNotesDocument.swift +++ b/MeetingNotes/MeetingNotesDocument.swift @@ -87,6 +87,7 @@ final class MeetingNotesDocument: ReferenceFileDocument { } syncedDocumentTrigger = doc.objectWillChange.sink { + Logger.syncflow.trace("\(self.id) ** objectWillChange **") self.objectWillChange.send() } } @@ -151,6 +152,7 @@ final class MeetingNotesDocument: ReferenceFileDocument { .throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true) .receive(on: RunLoop.main) .sink { + Logger.syncflow.trace("\(self.id) ** objectWillChange (1 sec delay) **") do { try self.getModelUpdates() } catch { @@ -195,13 +197,14 @@ final class MeetingNotesDocument: ReferenceFileDocument { /// Updates the Automerge document with the current value from the model. func storeModelUpdates() throws { + Logger.syncflow.debug("Storing model updates") try modelEncoder.encode(model) self.objectWillChange.send() } /// Updates the model document with any changed values in the Automerge document. func getModelUpdates() throws { - // Logger.document.debug("Updating model from Automerge document.") + Logger.syncflow.debug("Loading model updates") model = try modelDecoder.decode(MeetingNotesModel.self) } From cbb82396c56b80d0b62aedaac0d349e71e136d48 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Sat, 4 May 2024 13:40:09 -0700 Subject: [PATCH 16/21] activate tracing --- MeetingNotes/MeetingNotesApp.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/MeetingNotes/MeetingNotesApp.swift b/MeetingNotes/MeetingNotesApp.swift index 9a609fed..2662549e 100644 --- a/MeetingNotes/MeetingNotesApp.swift +++ b/MeetingNotes/MeetingNotesApp.swift @@ -2,12 +2,13 @@ import AutomergeRepo import SwiftUI public let repo = Repo(sharePolicy: SharePolicy.agreeable) -public let websocket = WebSocketProvider() +public let websocket = WebSocketProvider(.init(reconnectOnError: true, loggingAt: .tracing)) public let peerToPeer = PeerToPeerProvider( PeerToPeerProviderConfiguration( passcode: "AutomergeMeetingNotes", reconnectOnError: true, - autoconnect: false + autoconnect: false, + logVerbosity: .tracing ) ) @@ -29,6 +30,7 @@ struct MeetingNotesApp: App { init() { Task { + await repo.addNetworkAdapter(adapter: websocket) await repo.addNetworkAdapter(adapter: peerToPeer) } From 2e057962a33cdc3215af7eccc5cfa417464761ab Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Sat, 4 May 2024 13:42:17 -0700 Subject: [PATCH 17/21] enable repo tracing --- MeetingNotes/MeetingNotesApp.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/MeetingNotes/MeetingNotesApp.swift b/MeetingNotes/MeetingNotesApp.swift index 2662549e..d262c95b 100644 --- a/MeetingNotes/MeetingNotesApp.swift +++ b/MeetingNotes/MeetingNotesApp.swift @@ -30,7 +30,11 @@ struct MeetingNotesApp: App { init() { Task { - + // Enable repo tracing + await repo.setLogLevel(.network, to: .tracing) + await repo.setLogLevel(.resolver, to: .tracing) + await repo.setLogLevel(.repo, to: .tracing) + // Enable network adapters await repo.addNetworkAdapter(adapter: websocket) await repo.addNetworkAdapter(adapter: peerToPeer) } From cd59173fbd1a3d6b7652a52be06834a3bc477289 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Mon, 6 May 2024 13:59:20 -0700 Subject: [PATCH 18/21] cleaning up sync issues and tracing --- MeetingNotes/MeetingNotesDocument.swift | 27 +++++++++++++++---------- MeetingNotes/Views/PeerSyncView.swift | 10 ++++++--- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/MeetingNotes/MeetingNotesDocument.swift b/MeetingNotes/MeetingNotesDocument.swift index 7d2d878f..5d18a5c2 100644 --- a/MeetingNotes/MeetingNotesDocument.swift +++ b/MeetingNotes/MeetingNotesDocument.swift @@ -87,7 +87,7 @@ final class MeetingNotesDocument: ReferenceFileDocument { } syncedDocumentTrigger = doc.objectWillChange.sink { - Logger.syncflow.trace("\(self.id) ** objectWillChange **") + Logger.syncflow.trace("APPSYNC: \(self.id) ** objectWillChange **") self.objectWillChange.send() } } @@ -111,13 +111,18 @@ final class MeetingNotesDocument: ReferenceFileDocument { // Set the identifier of this document, external from the Automerge document. id = wrappedDocument.id // Then deserialize the Automerge document from the wrappers data. - doc = try Document(wrappedDocument.data) - Logger.document - .debug( - "Created Automerge doc of ID \(self.id, privacy: .public) from CBOR encoded data of \(wrappedDocument.data.count, privacy: .public) bytes" - ) - modelEncoder = AutomergeEncoder(doc: doc, strategy: .createWhenNeeded) - modelDecoder = AutomergeDecoder(doc: doc) + do { + doc = try Document(wrappedDocument.data) + Logger.document + .debug( + "Created Automerge doc of ID \(self.id, privacy: .public) from CBOR encoded data of \(wrappedDocument.data.count, privacy: .public) bytes" + ) + modelEncoder = AutomergeEncoder(doc: doc, strategy: .createWhenNeeded) + modelDecoder = AutomergeDecoder(doc: doc) + } catch { + Logger.document.error("Failed to construct Automerge doc from data: \(error.localizedDescription)") + throw error + } do { model = try modelDecoder.decode(MeetingNotesModel.self) } catch let DecodingError.dataCorrupted(context) { @@ -152,7 +157,7 @@ final class MeetingNotesDocument: ReferenceFileDocument { .throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true) .receive(on: RunLoop.main) .sink { - Logger.syncflow.trace("\(self.id) ** objectWillChange (1 sec delay) **") + Logger.syncflow.trace("APPSYNC: \(self.id) ** Automerge document objectWillChange (1 sec delay) **") do { try self.getModelUpdates() } catch { @@ -197,14 +202,14 @@ final class MeetingNotesDocument: ReferenceFileDocument { /// Updates the Automerge document with the current value from the model. func storeModelUpdates() throws { - Logger.syncflow.debug("Storing model updates") + Logger.syncflow.debug("APPSYNC: Storing model updates") try modelEncoder.encode(model) self.objectWillChange.send() } /// Updates the model document with any changed values in the Automerge document. func getModelUpdates() throws { - Logger.syncflow.debug("Loading model updates") + Logger.syncflow.debug("APPSYNC: Loading model updates") model = try modelDecoder.decode(MeetingNotesModel.self) } diff --git a/MeetingNotes/Views/PeerSyncView.swift b/MeetingNotes/Views/PeerSyncView.swift index 02963982..b738433c 100644 --- a/MeetingNotes/Views/PeerSyncView.swift +++ b/MeetingNotes/Views/PeerSyncView.swift @@ -134,12 +134,16 @@ struct PeerSyncView: View { listenerState = state }) .task { + // NOTE: this task gets invoked on _every_ re-appearance of the view - kind of the async + // equivalent of .onAppear() {} closure structure. + // + // The result is this bit if repeatedly redundant, but covers the case where the app is + // first coming online and a default "???" value should be set whatever the inline default + // from the library can provide. Since this is an @AppStorage() setup, if there's a configured + // setting, this won't get hit and we're just waiting cycles with the check. if nameToDisplay == "???" { // no user default is setup, so load a default value from the library nameToDisplay = await peerToPeer.peerName - } else { - // overrides the library default name - await peerToPeer.setName(nameToDisplay) } } } From e264808a078cf02b88d0605eb1af61b34ea7aff0 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Mon, 6 May 2024 16:44:14 -0700 Subject: [PATCH 19/21] doing some extra tracking of heads to double check change notifications --- MeetingNotes/Logger+extensions.swift | 2 +- MeetingNotes/MeetingNotesDocument.swift | 27 +++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/MeetingNotes/Logger+extensions.swift b/MeetingNotes/Logger+extensions.swift index 362901db..54ada887 100644 --- a/MeetingNotes/Logger+extensions.swift +++ b/MeetingNotes/Logger+extensions.swift @@ -11,7 +11,7 @@ extension Logger: @unchecked Sendable { /// Logs the Document interactions, such as saving and loading. static let document = Logger(subsystem: subsystem, category: "Document") - + /// Logs messages that might pertain to initiating, or receiving, sync updates static let syncflow = Logger(subsystem: subsystem, category: "SyncFlow") } diff --git a/MeetingNotes/MeetingNotesDocument.swift b/MeetingNotes/MeetingNotesDocument.swift index 5d18a5c2..16273458 100644 --- a/MeetingNotes/MeetingNotesDocument.swift +++ b/MeetingNotes/MeetingNotesDocument.swift @@ -62,6 +62,7 @@ final class MeetingNotesDocument: ReferenceFileDocument { let modelDecoder: AutomergeDecoder let id: DocumentId var doc: Document + var latestHeads: Set @Published var model: MeetingNotesModel @@ -74,6 +75,7 @@ final class MeetingNotesDocument: ReferenceFileDocument { Logger.document.debug("INITIALIZING NEW DOCUMENT") id = DocumentId() doc = Document() + latestHeads = doc.heads() let newModel = MeetingNotesModel(title: "Untitled") model = newModel modelEncoder = AutomergeEncoder(doc: doc, strategy: .createWhenNeeded) @@ -86,9 +88,14 @@ final class MeetingNotesDocument: ReferenceFileDocument { fatalError(error.localizedDescription) } - syncedDocumentTrigger = doc.objectWillChange.sink { - Logger.syncflow.trace("APPSYNC: \(self.id) ** objectWillChange **") - self.objectWillChange.send() + syncedDocumentTrigger = doc.objectWillChange.sink { [weak self] in + guard let self else { return } + let now = self.doc.heads() + if now != self.latestHeads { + self.latestHeads = now + Logger.syncflow.trace("APPSYNC: \(self.id) ** objectWillChange **") + self.objectWillChange.send() + } } } @@ -113,6 +120,7 @@ final class MeetingNotesDocument: ReferenceFileDocument { // Then deserialize the Automerge document from the wrappers data. do { doc = try Document(wrappedDocument.data) + latestHeads = doc.heads() Logger.document .debug( "Created Automerge doc of ID \(self.id, privacy: .public) from CBOR encoded data of \(wrappedDocument.data.count, privacy: .public) bytes" @@ -148,18 +156,25 @@ final class MeetingNotesDocument: ReferenceFileDocument { Logger.document.error("error: \(error, privacy: .public)") fatalError() } + Logger.document .debug("finished loading from \(String(describing: configuration.file.filename), privacy: .public)") + syncedDocumentTrigger = doc.objectWillChange // slow down the rate at which updates can appear so that the whole SwiftUI view // structure won't be reset too frequently, but IS updated when changes come in from // a syncing mechanism. .throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true) .receive(on: RunLoop.main) - .sink { - Logger.syncflow.trace("APPSYNC: \(self.id) ** Automerge document objectWillChange (1 sec delay) **") + .sink { [weak self] in + guard let self else { return } do { - try self.getModelUpdates() + Logger.syncflow.trace("APPSYNC: \(self.id) ** Automerge document objectWillChange (1 sec delay) **") + let now = self.doc.heads() + if now != self.latestHeads { + self.latestHeads = now + try self.getModelUpdates() + } } catch { fatalError("Error occurred while updating the model from the Automerge document: \(error)") } From e8c4c1ed44e70d6b5510eab21b840c8dcccf79a3 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Tue, 7 May 2024 15:12:44 -0700 Subject: [PATCH 20/21] sendable, set name properly --- MeetingNotes/MeetingNotesModel.swift | 4 ++-- MeetingNotes/Views/SyncStatusView.swift | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/MeetingNotes/MeetingNotesModel.swift b/MeetingNotes/MeetingNotesModel.swift index 2e12fc70..c3c04aaf 100644 --- a/MeetingNotes/MeetingNotesModel.swift +++ b/MeetingNotes/MeetingNotesModel.swift @@ -3,7 +3,7 @@ import Foundation /// An individual agenda item tracked by meeting notes. /// The `discussion` property is the type `Text` is from Automerge, and represents a collaboratively edited string. -struct AgendaItem: Identifiable, Codable, Hashable { +struct AgendaItem: Identifiable, Codable, Hashable, Sendable { let id: UUID var title: String var discussion: AutomergeText @@ -39,7 +39,7 @@ struct AgendaItem: Identifiable, Codable, Hashable { /// ] /// } /// ``` -struct MeetingNotesModel: Codable { +struct MeetingNotesModel: Codable, Sendable { var title: String var attendees: [String] var agendas: [AgendaItem] diff --git a/MeetingNotes/Views/SyncStatusView.swift b/MeetingNotes/Views/SyncStatusView.swift index c1d73d99..64c5b4b3 100644 --- a/MeetingNotes/Views/SyncStatusView.swift +++ b/MeetingNotes/Views/SyncStatusView.swift @@ -4,6 +4,7 @@ import SwiftUI /// A toolbar button for activating sync for a document. @MainActor struct SyncStatusView: View { + @AppStorage(UserDefaultKeys.publicPeerName) var nameToDisplay: String = "???" @State private var syncEnabledIndicator: Bool = false var body: some View { Button { @@ -11,9 +12,13 @@ struct SyncStatusView: View { if syncEnabledIndicator { // only enable listening if an identity has been chosen Task { - try await peerToPeer.startListening() + if self.nameToDisplay == "???" { + let nameToUse = await peerToPeer.peerName + try await peerToPeer.startListening(as: nameToUse) + } else { + try await peerToPeer.startListening(as: self.nameToDisplay) + } } - } else { Task { await peerToPeer.stopListening() From c805c44a7f4998d2a584e2af61fcd2a9bd750165 Mon Sep 17 00:00:00 2001 From: Joe Heck Date: Wed, 8 May 2024 12:12:03 -0700 Subject: [PATCH 21/21] updating to initial automerge-repo-swift release --- MeetingNotes.xcodeproj/project.pbxproj | 4 +- .../Documentation.docc/AppWalkthrough.md | 234 +++--------------- .../Documentation.docc/Documentation.md | 12 +- MeetingNotes/Logger+extensions.swift | 2 +- MeetingNotes/MeetingNotesApp.swift | 9 +- 5 files changed, 54 insertions(+), 207 deletions(-) diff --git a/MeetingNotes.xcodeproj/project.pbxproj b/MeetingNotes.xcodeproj/project.pbxproj index 9fd9473f..9a1beb17 100644 --- a/MeetingNotes.xcodeproj/project.pbxproj +++ b/MeetingNotes.xcodeproj/project.pbxproj @@ -799,8 +799,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/automerge/automerge-repo-swift"; requirement = { - branch = main; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/MeetingNotes/Documentation.docc/AppWalkthrough.md b/MeetingNotes/Documentation.docc/AppWalkthrough.md index 2cd35b4d..4d415ae8 100644 --- a/MeetingNotes/Documentation.docc/AppWalkthrough.md +++ b/MeetingNotes/Documentation.docc/AppWalkthrough.md @@ -329,213 +329,53 @@ With a document-based SwiftUI app, the SwiftUI app framework owns the lifetime o If the file saved from the document-based app is stored in iCloud, the operating system may destroy an existing instance and re-create it from the contents on device - most notably after having replicated the file with iCloud. There may be other instances of where the document can be rebuilt, but the important aspect to note is that SwiftUI is in control of that instance's lifecycle. -To provide peer to peer syncing, MeetingNotes handles the document being ephemeral by enabling an app-level sync coordinator: [DocumentSyncCoordinator.swift](https://github.com/automerge/MeetingNotes/blob/main/MeetingNotes/PeerNetworking/DocumentSyncCoordinator.swift) -This coordinator has properties for tracking Documents using identifiers that represent those documents and identifiers to represent the peers it syncs with. -The sync coordinator presents itself as an `Observable` object for more convenient use within SwiftUI views, to provide information about peers, connections, and exposing a control to establish a new connection. - -When MeetingNotes enables sync for a document, it registers a document with the SyncCoordinator, which builds a [NWTextRecord](https://developer.apple.com/documentation/network/nwtxtrecord) instance to use in advertising that the document. - -```swift -func registerDocument(_ document: MeetingNotesDocument) { - documents[document.id] = document - - var txtRecord = NWTXTRecord() - txtRecord[TXTRecordKeys.name] = name - txtRecord[TXTRecordKeys.peer_id] = peerId.uuidString - txtRecord[TXTRecordKeys.doc_id] = document.id.uuidString - txtRecords[document.id] = txtRecord -} -``` - -On activating sync, the coordinator activates both an [NWBrowser](https://developer.apple.com/documentation/network/nwbrowser) and [NWListener](https://developer.apple.com/documentation/network/nwlistener) instance. -In addition to activating the network services, the coordinator starts a timer to drive checks for document updates to determine if they should send a network sync message. -When a connection is established, it subscribes to the timer to drive checks to sync the Automerge document. - -#### Network Browser - -The browser looks for nearby peers that the app can sync with, while the listener provides the means to accept network connections. -The actual sync connection can be initiated by either peer, and only one needs to be initiated to support sync. - -The browser filters results by the type of network protocol it is initialized with: [AutomergeSyncProtocol](https://github.com/automerge/MeetingNotes/blob/main/MeetingNotes/PeerNetworking/AutomergeSyncProtocol.swift). -The `NWBrowser` instance sees all available listeners, including itself, when the listener is active. -The handler that processes browser updates filters the results to only show other peers on the network. +To provide peer to peer syncing, MeetingNotes uses the [automerge-repo-swift package](https://github.com/automerge/automerge-repo-swift). +It creates a single globally available instance of a repository to track documents that are loaded by the SwiftUI document-based app. +To provide the network connections, it also creates an instance of a `WebSocketprovider` and `PeerToPeerProvider`, and adds those to the repository at the end of app initialization: ```swift -// Only show broadcasting peers that doesn't have the name -// provided by this app. -let filtered = results.filter { result in - if case let .bonjour(txtrecord) = result.metadata, - txtrecord[TXTRecordKeys.peer_id] != self.peerId.uuidString - { - return true +public let repo = Repo(sharePolicy: SharePolicy.agreeable) +public let websocket = WebSocketProvider(.init(reconnectOnError: true)) +public let peerToPeer = PeerToPeerProvider( + PeerToPeerProviderConfiguration( + passcode: "AutomergeMeetingNotes", + reconnectOnError: true, + autoconnect: false + ) +) + +/// The document-based Meeting Notes application. +@main +struct MeetingNotesApp: App { + ... + init() { + Task { + // Enable network adapters + await repo.addNetworkAdapter(adapter: websocket) + await repo.addNetworkAdapter(adapter: peerToPeer) + } } - return false } -.sorted(by: { - $0.hashValue < $1.hashValue -}) -``` -MeetingNotes automatically connects to a new peer listed within [NWBrowser.Result](https://developer.apple.com/documentation/network/nwbrowser/result) when running on iOS. -The view that shows these results also provides a button to establish a connection manually. -The auto-connect waits for a short, random period of time before establishing an automatic connection. - -#### Network Listener - -To accept a connection, the coordinator activates a bonjour listener for the document being shared. -Within MeetingNotes, the listener is configured with the sync protocol, a `NWTxtRecord` that describes the document, and network parameters to configure TCP and TLS. -MeetingNotes uses the document identifier as a pre-shared TLS secret, which both enables encryption and constraints sync connections to other instances that use this same convention. - -> Warning: Using a pre-shared secret is _not_ a recommended security practice, and this example makes no attestations of being a secure means of encrypting the communications. - -While the browser receives the published TXTRecord of the peer with the Bonjour notifications, the Listener only knows that it has received a connection. -Because of this, at the start, who initiated the connection is unknown. -MeetingNotes accepts any full connections that get fully established with TLS, using the document identifier as a shared key. -A more fully developed application might also track and determine acceptability of connections using additional information - either embedded within the network sync protocol or passed as parameters within the protocol. - -Once MeetingNotes accepts a connection, it creates an instance of [SyncConnection](https://github.com/automerge/MeetingNotes/blob/main/MeetingNotes/PeerNetworking/SyncConnection.swift). - -#### Syncing over a connection - -`SyncConnection` tracks the state of a connection as well as the sync state with a peer. -It is initialized with a [NWConnection](https://developer.apple.com/documentation/network/nwconnection), the identifier for the document. -It maintains it's own identifier and establishes an instance of `SyncState` to track the state of the peer on the other side of the connection. - -Upon initialization, the connection wrapper subscribes to the timer provided by the sync coordinator. -The `SyncConnection` uses the timer signal to drive a check to determine if a sync message should be sent. - -```swift -syncTriggerCancellable = trigger.sink(receiveValue: { _ in - if let automergeDoc = sharedSyncCoordinator - .documents[self.documentId]?.doc, - let syncData = automergeDoc.generateSyncMessage( - state: self.syncState), - self.connectionState == .ready - { - Logger.syncConnection - .info( - "\(self.shortId, privacy: .public): Syncing \(syncData.count, privacy: .public) bytes to \(connection.endpoint.debugDescription, privacy: .public)" - ) - self.sendSyncMsg(syncData) - } -}) ``` -The underlying network protocol only sends an event if the call to `generateSyncMessage(state:)` returns non-nil data. -The heart of the synchronization happens when the connection receives a network protocol sync message. -This message is structured wrapper around the sync bytes from another Automerge document, along with a minimal type-of-message identifier, taking advantage of the [Network framework](https://developer.apple.com/documentation/network) to frame and establish the messages being transferred. -Once received, the connection uses [NWProtocolFramer](https://developer.apple.com/documentation/network/nwprotocolframer) to retrieve the message from the bytes sent over the network, and delegates receiving the message to be processed if complete, before waiting for the next message on the network. - -```swift -private func receiveNextMessage() { - guard let connection = connection else { - return - } +The SwiftUI document-based API is all synchronous, so loading an Automerge document it provides is down within the view when it first appears. - connection.receiveMessage { content, context, isComplete, error in - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): Received a \(isComplete ? "complete" : "incomplete", privacy: .public) msg on connection" - ) - if let content { - Logger.syncConnection.debug(" - received \(content.count) bytes") - } else { - Logger.syncConnection.debug(" - received no data with msg") - } - // Extract your message type from the received context. - if let syncMessage = context? - .protocolMetadata( - definition: AutomergeSyncProtocol.definition - ) as? NWProtocolFramer.Message, - let endpoint = self.connection?.endpoint - { - self.receivedMessage( - content: content, - message: syncMessage, - from: endpoint) - } - if error == nil { - // Continue to receive more messages until we receive - // an error. - self.receiveNextMessage() - } else { - Logger.syncConnection.error(" - error on received message: \(error)") - self.cancel() - } - } -} ``` - -The connection processes the received sync protocol message with the `receivedMessage` function, using the identifier of the document stored with the connection to retrieve a reference to the document instance. -Neither the connection, nor the sync coordinator object, can maintain a stable reference to the Automerge document instance because SwiftUI owns the life-cycle of the app's `ReferenceFileDocument` subclass. -To work around SwiftUI replacing this class, the coordinator maintains and updates references as `Document` subclasses register themselves, in order to provide a quick lookup by the document's identifier. - -With a reference to the document, the method invokes `receiveSyncMessageWithPatches(state:message:)` to receive any provided changes, and uses the returns array of `Patch` to log how many patches were returned. -Immediately after receiving an update, the function calls `generateSyncMessage(state:)` to determine if the additional sync messages are needed, and sends a return sync message if the function returns any data. - -```swift -func receivedMessage( - content data: Data?, - message: NWProtocolFramer.Message, - from endpoint: NWEndpoint) { - - guard let document = sharedSyncCoordinator.documents[self.documentId] else { - // ... - return - } - switch message.syncMessageType { - case .invalid: - // ... - case .sync: - guard let data else { - // ... - return - } - do { - // When we receive a complete sync message from the - // underlying transport, update our automerge document, - // and the associated SyncState. - let patches = try document.doc.receiveSyncMessageWithPatches( - state: syncState, - message: data - ) - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): Received \(patches.count, privacy: .public) patches in \(data.count, privacy: .public) bytes" - ) - try document.getModelUpdates() - - // Once the Automerge doc is updated, check (using the - // SyncState) to see if we believe we need to send additional - // messages to the peer to keep it in sync. - if let response = document.doc.generateSyncMessage(state: syncState) { - sendSyncMsg(response) - } else { - // When generateSyncMessage returns nil, the remote - // endpoint represented by SyncState should be up to date. - Logger.syncConnection - .debug( - "\(self.shortId, privacy: .public): Sync complete with \(endpoint.debugDescription, privacy: .public)" - ) - } - } catch { - Logger.syncConnection - .error("\(self.shortId, privacy: .public): Error applying sync message: \(error, privacy: .public)") - } - case .id: - Logger.syncConnection.info("\(self.shortId, privacy: .public): received request for document ID") - sendDocumentId(document.id.uuidString) +.task { + // SwiftUI controls the lifecycle of MeetingNoteDocument instances, + // including sometimes regenerating them when disk contents are updated + // in the background, so register the current instance with the + // sync coordinator as they become visible. + do { + _ = try await repo.create(doc: document.doc, id: document.id) + } catch { + fatalError("Crashed loading the document: \(error.localizedDescription)") } } ``` -With this pattern established on both sides of a Bonjour connection, once a sync process is initiated, the functions send messages back and forth until a sync is complete. -The timer, provided from the sync coordinator, is only needed to start to drive sync messages when changes have occurred locally. - -> Note: The messages that contain changes to sync generated by Automerge are _not_ guaranteed to have all the updates needed within a single round trip. -The underlying mechanism optimizes for sharing the state of heads initially, resulting in a small initial message, followed by sets of changes from either side. -The full sync process is iterative, which allows for efficient sync even when the two peers may be concurrently syncing with other, unseen or unknown, peers. - -The timer frequency in MeetingNotes is intentionally set to a short value to drive sync updates frequently enough to appear to "sync with each keystroke" to show off interactively collaboration. -Your own app may not need, or want, to drive a network sync this frequently. - +Once added to the repository, toolbar buttons on the `MeetingNotesDocumentView` toggle a WebSocket connection or activate the peer to peer networking. +`PeerSyncView` provides information about available peers on your local network, and allows you to explicitly connect to those peers. +The repository handles syncing automatically as the Automerge document is updated. +Both the WebSocket and peer-to-peer networking implement the Automerge sync protocol over their respective transports. diff --git a/MeetingNotes/Documentation.docc/Documentation.md b/MeetingNotes/Documentation.docc/Documentation.md index 4711c58c..d3fc2699 100644 --- a/MeetingNotes/Documentation.docc/Documentation.md +++ b/MeetingNotes/Documentation.docc/Documentation.md @@ -26,10 +26,18 @@ The source for the MeetingNotes app is [available on Github](https://github.com/ - ``MeetingNotesApp`` - ``MergeError`` +- ``UserDefaultKeys`` + +### Global Variables + +- ``repo`` +- ``websocket`` +- ``peerToPeer`` ### Logger extensions - ``MeetingNotes/os/Logger/document`` +- ``MeetingNotes/os/Logger/syncflow`` ### Views @@ -53,10 +61,6 @@ The source for the MeetingNotes app is [available on Github](https://github.com/ - ``ExportView_Previews`` - ``WebSocketView_Previews`` -### Legacy Sync Connection - -- ``WebsocketSyncConnection`` - ### Application Resources - ``ColorResource`` diff --git a/MeetingNotes/Logger+extensions.swift b/MeetingNotes/Logger+extensions.swift index 54ada887..2479a48b 100644 --- a/MeetingNotes/Logger+extensions.swift +++ b/MeetingNotes/Logger+extensions.swift @@ -12,6 +12,6 @@ extension Logger: @unchecked Sendable { /// Logs the Document interactions, such as saving and loading. static let document = Logger(subsystem: subsystem, category: "Document") - /// Logs messages that might pertain to initiating, or receiving, sync updates + /// Logs messages that pertain to sending or receiving sync updates. static let syncflow = Logger(subsystem: subsystem, category: "SyncFlow") } diff --git a/MeetingNotes/MeetingNotesApp.swift b/MeetingNotes/MeetingNotesApp.swift index d262c95b..78cd796d 100644 --- a/MeetingNotes/MeetingNotesApp.swift +++ b/MeetingNotes/MeetingNotesApp.swift @@ -1,9 +1,12 @@ import AutomergeRepo import SwiftUI -public let repo = Repo(sharePolicy: SharePolicy.agreeable) -public let websocket = WebSocketProvider(.init(reconnectOnError: true, loggingAt: .tracing)) -public let peerToPeer = PeerToPeerProvider( +/// A global repository for storing and synchronizing Automerge documents by ID. +let repo = Repo(sharePolicy: SharePolicy.agreeable) +/// A WebSocket network provider for the repository. +let websocket = WebSocketProvider(.init(reconnectOnError: true, loggingAt: .tracing)) +/// A peer-to-peer network provider for the repository. +let peerToPeer = PeerToPeerProvider( PeerToPeerProviderConfiguration( passcode: "AutomergeMeetingNotes", reconnectOnError: true,