├── .swift-version ├── notes ├── .gitignore ├── notes.md ├── websocket_sync_closed.mmd ├── websocket_sync_initial.mmd ├── websocket_sync_peered.mmd ├── websocket_sync_handshake.mmd ├── websocket_strategy_sync.mmd ├── websocket_sync_states.mmd ├── websocket_strategy_request.mmd ├── README.md ├── generate.bash └── release-process.md ├── .spi.yml ├── Sources └── AutomergeRepo │ ├── Documentation.docc │ ├── Curation │ │ ├── ShareAuthorizing.md │ │ ├── NetworkEventReceiver.md │ │ ├── CBORCoder.md │ │ ├── EphemeralMessageReceiver.md │ │ ├── DocHandle.md │ │ ├── AvailablePeer.md │ │ ├── PeerDisconnectPayload.md │ │ ├── SharePolicy.md │ │ ├── PeerMetadata.md │ │ ├── DocumentId.md │ │ ├── Errors.md │ │ ├── NetworkAdapterEvents.md │ │ ├── WebSocketProviderConfiguration.md │ │ ├── PeerToPeerProviderConfiguration.md │ │ ├── NetworkProvider.md │ │ ├── StorageProvider.md │ │ ├── PeerConnectionInfo.md │ │ ├── SyncV1Msg.md │ │ ├── Repo.md │ │ ├── WebSocketProvider.md │ │ └── PeerToPeerProvider.md │ ├── Documentation.md │ └── Resources │ │ ├── wss_peered.svg │ │ └── wss_handshake.svg │ ├── Sync │ ├── CBORCoder.swift │ ├── ProtocolState.swift │ └── SyncV1Msg.swift │ ├── Networking │ ├── Providers │ │ ├── TXTRecordKeys.swift │ │ ├── WebSocketProviderState.swift │ │ ├── AvailablePeer.swift │ │ ├── WebSocketProviderConfiguration.swift │ │ ├── PeerToPeerProviderConfiguration.swift │ │ ├── PeerConnectionInfo.swift │ │ ├── NWParameters+peerSyncParameters.swift │ │ └── P2PAutomergeSyncProtocol.swift │ ├── NetworkEventReceiver.swift │ ├── Backoff.swift │ ├── NetworkAdapterEvents.swift │ └── NetworkProvider.swift │ ├── extensions │ ├── TimeInterval+milliseconds.swift │ ├── NWPathMonitor+paths.swift │ ├── Data+hexEncodedString.swift │ ├── UUID+bs58String.swift │ ├── OSLog+extensions.swift │ └── String+hexEncoding.swift │ ├── AutomergeRepo.swift │ ├── DocHandle.swift │ ├── EphemeralMessageReceiver.swift │ ├── Repo+LogComponent.swift │ ├── RepoTypes.swift │ ├── PeerMetadata.swift │ ├── DocumentId.swift │ ├── Storage │ └── StorageProvider.swift │ ├── ShareAuthorizing.swift │ ├── Errors.swift │ └── InternalDocHandle.swift ├── IntegrationTests ├── .gitignore ├── scripts │ └── serial_tests.bash ├── Tests │ └── IntegrationTestsTests │ │ ├── Logger+test.swift │ │ ├── RepoHelpers.swift │ │ ├── URLSessionWebSocketTask+sendPing.swift │ │ ├── P2P_base.swift │ │ ├── TestProviders │ │ └── InMemoryStorage.swift │ │ ├── P2P+AutoConnect.swift │ │ ├── P2P+peerVisibility.swift │ │ ├── P2P+connectAndFind.swift │ │ ├── Repo_OneClient_WebsocketIntegrationTests.swift │ │ ├── P2P+reconnect.swift │ │ └── P2P+explicitConnect.swift ├── Package.swift └── README.md ├── Tests └── AutomergeRepoTests │ ├── TestNetworkProviders │ ├── UnconfiguredTestNetwork.swift │ └── TestOutgoingNetworkProvider.swift │ ├── SharePolicyTests.swift │ ├── RepoHelpers.swift │ ├── BackoffTests.swift │ ├── RepoFindTest.swift │ ├── CBORExperiments.swift │ ├── BS58IdTests.swift │ ├── DocHandleTests.swift │ ├── TestStorageProviders │ └── InMemoryStorage.swift │ ├── DocumentIdTests.swift │ ├── BaseRepoTests.swift │ ├── StorageSubsystemTests.swift │ └── ObservingChangesTest.swift ├── .gitignore ├── .swiftformat ├── scripts ├── interop.sh ├── preview-docs.sh └── build-ghpages-docs.sh ├── docker ├── collector-config.yaml ├── docker-compose-jaeger.yml └── docker-compose-zipkin-jaeger.yml ├── privacy └── PrivacyInfo.xcprivacy ├── LICENSE ├── .github └── workflows │ └── ci.yaml ├── README.md ├── Package.swift ├── CONTRIBUTING.md └── Package.resolved /.swift-version: -------------------------------------------------------------------------------- 1 | 5.9 2 | -------------------------------------------------------------------------------- /notes/.gitignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | node_modules 4 | *.svg 5 | yarn.lock 6 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [AutomergeRepo] 5 | scheme: automerge-repo 6 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/ShareAuthorizing.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/ShareAuthorizing`` 2 | 3 | ## Topics 4 | 5 | ### Authorizing a document to be shared 6 | 7 | - ``ShareAuthorizing/share(peer:docId:)`` 8 | -------------------------------------------------------------------------------- /IntegrationTests/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | Package.resolved 10 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/NetworkEventReceiver.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/NetworkEventReceiver`` 2 | 3 | ## Topics 4 | 5 | ### A type that receives and processes network events 6 | 7 | - ``NetworkEventReceiver/receiveEvent(event:)`` 8 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/TestNetworkProviders/UnconfiguredTestNetwork.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct UnconfiguredTestNetwork: LocalizedError { 4 | public var errorDescription: String? { 5 | "The test network is not configured." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/ 7 | .swiftpm/configuration/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | .vscode/ 11 | automerge-repo-sync-server 12 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/CBORCoder.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/CBORCoder`` 2 | 3 | ## Topics 4 | 5 | ### Accessing a CBOR encoder 6 | 7 | - ``CBORCoder/encoder`` 8 | 9 | ### Accessing a CBOR decoder 10 | 11 | - ``CBORCoder/decoder`` 12 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --hexgrouping ignore 2 | --decimalgrouping ignore 3 | --enable isEmpty 4 | --ifdef no-indent 5 | --wraparguments before-first 6 | --wrapcollections before-first 7 | --closingparen balanced 8 | --maxwidth 120 9 | 10 | # rules 11 | --disable redundantSelf 12 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/EphemeralMessageReceiver.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/EphemeralMessageReceiver`` 2 | 3 | ## Topics 4 | 5 | ### Accepting ephemeral messages from peers connected to your repository 6 | 7 | - ``EphemeralMessageReceiver/receiveMessage(_:)`` 8 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/DocHandle.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/DocHandle`` 2 | 3 | ## Topics 4 | 5 | ### Creating a DocHandle 6 | 7 | - ``DocHandle/init(id:doc:)`` 8 | 9 | ### Inspecting a DocHandle 10 | 11 | - ``DocHandle/id`` 12 | - ``DocHandle/doc`` 13 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/AvailablePeer.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/AvailablePeer`` 2 | 3 | ## Topics 4 | 5 | ### Inspecting a Peer on the local network 6 | 7 | - ``AvailablePeer/id`` 8 | - ``AvailablePeer/peerId`` 9 | - ``AvailablePeer/name`` 10 | - ``AvailablePeer/endpoint`` 11 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/PeerDisconnectPayload.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/NetworkAdapterEvents/PeerDisconnectPayload`` 2 | 3 | ## Topics 4 | 5 | ### Creating a payload 6 | 7 | - ``init(peerId:)`` 8 | 9 | ### Inspecting a payload 10 | 11 | - ``peerId`` 12 | - ``description`` 13 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/SharePolicy.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/SharePolicy`` 2 | 3 | ## Topics 4 | 5 | ### Creating a share policy 6 | 7 | - ``agreeable`` 8 | - ``readonly`` 9 | - ``init(_:)`` 10 | 11 | ### Authorizing a document to be shared 12 | 13 | - ``share(peer:docId:)`` 14 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/PeerMetadata.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/PeerMetadata`` 2 | 3 | ## Topics 4 | 5 | ### Creating peer metadata 6 | 7 | - ``init(storageId:isEphemeral:)`` 8 | 9 | ### Inspecting peer metadata 10 | 11 | - ``storageId`` 12 | - ``isEphemeral`` 13 | - ``debugDescription`` 14 | -------------------------------------------------------------------------------- /notes/notes.md: -------------------------------------------------------------------------------- 1 | # using docker-compose 2 | 3 | `docker-compose -f someDockerComposefile up -d`, for example: 4 | 5 | ```bash 6 | docker-compose -f docker-compose.yml up -d 7 | ``` 8 | 9 | there's an equiv for Tempo, and another for sigNoz 10 | https://github.com/SigNoz/signoz/tree/develop/deploy/docker/clickhouse-setup 11 | -------------------------------------------------------------------------------- /notes/websocket_sync_closed.mmd: -------------------------------------------------------------------------------- 1 | stateDiagram-v2 2 | direction LR 3 | 4 | classDef currentState fill:#0CC,font-weight:bold,strike-width:2px 5 | 6 | [*] --> new 7 | new --> handshake 8 | handshake --> closed:::currentState 9 | handshake --> peered 10 | peered --> closed 11 | closed --> handshake 12 | -------------------------------------------------------------------------------- /notes/websocket_sync_initial.mmd: -------------------------------------------------------------------------------- 1 | stateDiagram-v2 2 | direction LR 3 | 4 | classDef currentState fill:#0CC,font-weight:bold,strike-width:2px 5 | 6 | [*] --> new:::currentState 7 | new --> handshake 8 | handshake --> closed 9 | handshake --> peered 10 | peered --> closed 11 | closed --> handshake 12 | -------------------------------------------------------------------------------- /notes/websocket_sync_peered.mmd: -------------------------------------------------------------------------------- 1 | stateDiagram-v2 2 | direction LR 3 | 4 | classDef currentState fill:#0CC,font-weight:bold,strike-width:2px 5 | 6 | [*] --> new 7 | new --> handshake 8 | handshake --> closed 9 | handshake --> peered:::currentState 10 | peered --> closed 11 | closed --> handshake 12 | -------------------------------------------------------------------------------- /notes/websocket_sync_handshake.mmd: -------------------------------------------------------------------------------- 1 | stateDiagram-v2 2 | direction LR 3 | 4 | classDef currentState fill:#0CC,font-weight:bold,strike-width:2px 5 | 6 | [*] --> new 7 | new --> handshake:::currentState 8 | handshake --> closed 9 | handshake --> peered 10 | peered --> closed 11 | closed --> handshake 12 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Sync/CBORCoder.swift: -------------------------------------------------------------------------------- 1 | public import PotentCBOR 2 | 3 | /// A type that provides concurrency-safe access to a CBOR encoder and decoder. 4 | public actor CBORCoder { 5 | /// A CBOR encoder 6 | public static let encoder = CBOREncoder() 7 | /// A CBOR decoder 8 | public static let decoder = CBORDecoder() 9 | } 10 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/Providers/TXTRecordKeys.swift: -------------------------------------------------------------------------------- 1 | /// A type that provides type-safe strings for TXTRecord publication of peers over Bonjour 2 | enum TXTRecordKeys: Sendable { 3 | /// The peer identifier. 4 | public static let peer_id = "peer_id" 5 | /// The human-readable name for the peer. 6 | public static let name = "name" 7 | } 8 | -------------------------------------------------------------------------------- /scripts/interop.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eou pipefail 3 | 4 | # see https://stackoverflow.com/questions/4774054/reliable-way-for-a-bash-script-to-get-the-full-path-to-itself 5 | THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" 6 | REPO_DIR=$THIS_SCRIPT_DIR/../ 7 | 8 | docker run -d --name syncserver -p 3030:3030 ghcr.io/heckj/automerge-repo-sync-server:main 9 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/extensions/TimeInterval+milliseconds.swift: -------------------------------------------------------------------------------- 1 | internal import struct Foundation.TimeInterval 2 | 3 | extension TimeInterval { 4 | /// Returns a time interval from the number of milliseconds you provide. 5 | /// - Parameter value: The number of milliseconds. 6 | static func milliseconds(_ value: Int) -> Self { 7 | 0.001 * Double(value) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/DocumentId.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/DocumentId`` 2 | 3 | ## Topics 4 | 5 | ### Creating and Document ID 6 | 7 | - ``DocumentId/init()`` 8 | - ``DocumentId/init(_:)-1i6v5`` 9 | - ``DocumentId/init(_:)-28eig`` 10 | - ``DocumentId/init(_:)-3hx6h`` 11 | 12 | ### Inspecting a Document ID 13 | 14 | - ``DocumentId/id`` 15 | - ``DocumentId/description`` 16 | 17 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/Errors.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/Errors`` 2 | 3 | ## Topics 4 | 5 | ### Network, Sync, and Repository Errors 6 | 7 | - ``ConnectionClosed`` 8 | - ``UnsupportedProtocolError`` 9 | - ``Unavailable`` 10 | - ``Timeout`` 11 | - ``UnexpectedMsg`` 12 | - ``DocDeleted`` 13 | 14 | ### Network Provider Errors 15 | 16 | - ``InvalidURL`` 17 | - ``NetworkProviderError`` 18 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/NetworkAdapterEvents.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/NetworkAdapterEvents`` 2 | 3 | ## Topics 4 | 5 | ### Inspecting events from a Network Provider 6 | 7 | - ``peerCandidate(payload:)`` 8 | - ``ready(payload:)`` 9 | - ``message(payload:)`` 10 | - ``peerDisconnect(payload:)`` 11 | - ``PeerDisconnectPayload`` 12 | - ``close`` 13 | 14 | ### Inspecting an event 15 | 16 | - ``debugDescription`` 17 | -------------------------------------------------------------------------------- /IntegrationTests/scripts/serial_tests.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # bash "strict" mode 4 | # https://gist.github.com/mohanpedala/1e2ff5661761d3abd0385e8223e16425 5 | set -euxo pipefail 6 | 7 | THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" 8 | cd ${THIS_SCRIPT_DIR}/.. 9 | 10 | TEST_LIST=$(swift test list) 11 | for TESTNAME in ${TEST_LIST} 12 | do 13 | swift test --filter ${TESTNAME} 14 | sleep 1 15 | done 16 | -------------------------------------------------------------------------------- /notes/websocket_strategy_sync.mmd: -------------------------------------------------------------------------------- 1 | sequenceDiagram 2 | participant local 3 | participant remote 4 | 5 | critical handshaking phase 6 | Note over local,remote: state = "new" or "closed" 7 | local->>remote: join 8 | Note over local,remote: state = "handshake" 9 | remote->>local: peer 10 | end 11 | 12 | Note over local,remote: state = "peered" 13 | local->>remote: sync 14 | remote-->>local: sync (if needed) -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/WebSocketProviderConfiguration.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/WebSocketProviderConfiguration`` 2 | 3 | ## Topics 4 | 5 | ### Built-in configurations 6 | 7 | - ``default`` 8 | 9 | ### Creating a configuration 10 | 11 | - ``init(reconnectOnError:loggingAt:maxNumberOfConnectRetries:)`` 12 | 13 | ### Inspecting a configuration 14 | 15 | - ``reconnectOnError`` 16 | - ``maxNumberOfConnectRetries`` 17 | - ``logLevel`` 18 | -------------------------------------------------------------------------------- /notes/websocket_sync_states.mmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: WebSocket Sync Protocol 3 | --- 4 | 5 | stateDiagram-v2 6 | [*] --> new : WebsocketSyncConnection.init() 7 | new --> handshake : registerDocument()\nawait connect() 8 | handshake --> closed : connect timeout expired\nconnection failed 9 | handshake --> peered : websocket peer response 10 | peered --> closed : await disconnect()\nwebsocket error 11 | closed --> handshake : await connect() 12 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/PeerToPeerProviderConfiguration.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/PeerToPeerProviderConfiguration`` 2 | 3 | ## Topics 4 | 5 | ### Creating a peer-to-peer provider configuration 6 | 7 | - ``init(passcode:reconnectOnError:autoconnect:logVerbosity:recurringNextMessageTimeout:waitForPeerTimeout:)`` 8 | 9 | ### Providing a default name for peer-to-peer network advertising 10 | 11 | - ``defaultSharingIdentity()`` 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/Providers/WebSocketProviderState.swift: -------------------------------------------------------------------------------- 1 | /// The state of a WebSocket connection 2 | public enum WebSocketProviderState: Sendable { 3 | /// WebSocket is connected, pending handshaking 4 | case connected 5 | /// WebSocket is ready to send and receive messages 6 | case ready 7 | /// WebSocket is disconnected and attempting to reconnect 8 | case reconnecting 9 | /// WebSocket is disconnected 10 | case disconnected 11 | } 12 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/NetworkProvider.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/NetworkProvider`` 2 | 3 | ## Topics 4 | 5 | ### Configuring the provider 6 | 7 | - ``setDelegate(_:as:with:)`` 8 | 9 | ### Establishing Connections 10 | 11 | - ``connect(to:)`` 12 | - ``NetworkConnectionEndpoint`` 13 | - ``disconnect()`` 14 | 15 | ### Sending messages 16 | 17 | - ``send(message:to:)`` 18 | 19 | ### Inspecting the provider 20 | 21 | - ``name`` 22 | - ``peeredConnections`` 23 | -------------------------------------------------------------------------------- /docker/collector-config.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | endpoint: otel-collector:4317 6 | 7 | exporters: 8 | logging: 9 | verbosity: detailed 10 | 11 | otlp: 12 | endpoint: jaeger:4317 13 | tls: 14 | insecure: true 15 | 16 | zipkin: 17 | endpoint: "http://zipkin:9411/api/v2/spans" 18 | 19 | 20 | service: 21 | pipelines: 22 | traces: 23 | receivers: otlp 24 | exporters: [logging, otlp, zipkin] 25 | -------------------------------------------------------------------------------- /docker/docker-compose-jaeger.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | jaeger: 4 | # Jaeger is one of many options we can choose from as our distributed tracing backend. 5 | # It supports OTLP out of the box so it's very easy to get started. 6 | # https://www.jaegertracing.io 7 | image: jaegertracing/all-in-one 8 | ports: 9 | - "4317:4317" # This is where the OTLPGRPCSpanExporter sends its spans 10 | - "16686:16686" # This is Jaeger's Web UI, visualizing recorded traces 11 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/StorageProvider.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/StorageProvider`` 2 | 3 | ## Topics 4 | 5 | ### Inspecting the storage provider 6 | 7 | - ``id`` 8 | 9 | ### Loading and storing data 10 | 11 | - ``load(id:)`` 12 | - ``save(id:data:)`` 13 | 14 | ### Removing documents 15 | 16 | - ``remove(id:)`` 17 | 18 | ### Incremental document updates 19 | 20 | - ``addToRange(id:prefix:data:)`` 21 | - ``loadRange(id:prefix:)`` 22 | - ``removeRange(id:prefix:data:)`` 23 | - ``CHUNK`` 24 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/SharePolicyTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AutomergeRepo 2 | import XCTest 3 | 4 | final class SharePolicyTests: XCTestCase { 5 | func testSharePolicy() async throws { 6 | let agreeableShareResult = await SharePolicy.agreeable.share(peer: "A", docId: DocumentId()) 7 | XCTAssertTrue(agreeableShareResult) 8 | 9 | let readOnlyShareResult = await SharePolicy.readonly.share(peer: "A", docId: DocumentId()) 10 | XCTAssertFalse(readOnlyShareResult) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/PeerConnectionInfo.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/PeerConnectionInfo`` 2 | 3 | ## Topics 4 | 5 | ### Inspecting information about a peer connection 6 | 7 | - ``peerId`` 8 | - ``peerMetadata`` 9 | - ``endpoint`` 10 | - ``initiated`` 11 | - ``peered`` 12 | 13 | ### Creating information about a peer connection 14 | 15 | - ``init(peerId:peerMetadata:endpoint:initiated:peered:)`` 16 | 17 | ### Inspecting information about the notification 18 | 19 | - ``id`` 20 | - ``description`` 21 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/extensions/NWPathMonitor+paths.swift: -------------------------------------------------------------------------------- 1 | internal import Network 2 | 3 | extension NWPathMonitor { 4 | func paths() -> AsyncStream { 5 | AsyncStream { continuation in 6 | pathUpdateHandler = { path in 7 | continuation.yield(path) 8 | } 9 | continuation.onTermination = { [weak self] _ in 10 | self?.cancel() 11 | } 12 | start(queue: DispatchQueue(label: "NSPathMonitor.paths")) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /notes/websocket_strategy_request.mmd: -------------------------------------------------------------------------------- 1 | sequenceDiagram 2 | participant local 3 | participant remote 4 | 5 | critical handshaking phase 6 | Note over local,remote: state = "new" or "closed" 7 | local->>remote: join 8 | Note over local,remote: state = "handshake" 9 | remote->>local: peer 10 | end 11 | 12 | Note over local,remote: state = "peered" 13 | local->>remote: request 14 | alt: if unavailable 15 | remote->>local: unavailable 16 | else 17 | remote-->>local: sync (if needed) 18 | end -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/NetworkEventReceiver.swift: -------------------------------------------------------------------------------- 1 | /// A type that accepts provides a method for a Network Provider to call with network events. 2 | /// 3 | /// Mostly commonly, this is a ``Repo`` instance, and describes the interface that a network provider 4 | /// uses for its delegate callbacks. 5 | @AutomergeRepo 6 | public protocol NetworkEventReceiver: Sendable { 7 | /// Receive and process an event from a Network Provider. 8 | /// - Parameter event: The event to process. 9 | func receiveEvent(event: NetworkAdapterEvents) async 10 | } 11 | -------------------------------------------------------------------------------- /scripts/preview-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eou pipefail 3 | 4 | # see https://stackoverflow.com/questions/4774054/reliable-way-for-a-bash-script-to-get-the-full-path-to-itself 5 | THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" 6 | PACKAGE_PATH=$THIS_SCRIPT_DIR/../ 7 | # BUILD_DIR=$PACKAGE_PATH/.build 8 | 9 | #echo "THIS_SCRIPT_DIR= ${THIS_SCRIPT_DIR}" 10 | #echo "PACKAGE_PATH = ${PACKAGE_PATH}" 11 | #echo "BUILD_DIR = ${BUILD_DIR}" 12 | pushd ${PACKAGE_PATH} 13 | 14 | $(xcrun --find swift) package --disable-sandbox plugin preview-documentation -------------------------------------------------------------------------------- /Sources/AutomergeRepo/AutomergeRepo.swift: -------------------------------------------------------------------------------- 1 | /// A global actor for coordinating data-race safety within Automerge-repo and its plugins. 2 | /// 3 | /// ``Repo``, ``StorageProvider``, and ``NetworkProvider`` use this global actor to provide an isolation domain for 4 | /// the repository and its plugins. 5 | /// You can conform to with your own types to provide additional network transports for Automerge-repo. 6 | @globalActor public actor AutomergeRepo { 7 | /// A shared instance of the AutomergeRepo global actor 8 | public static let shared = AutomergeRepo() 9 | 10 | private init() {} 11 | } 12 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/Logger+test.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger+test.swift 3 | // 4 | // 5 | // Created by Joseph Heck on 4/24/24. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | extension Logger { 12 | /// Using your bundle identifier is a great way to ensure a unique identifier. 13 | private static let subsystem = Bundle.main.bundleIdentifier! 14 | 15 | /// Logs updates and interaction related to watching for external peer systems. 16 | static let test = Logger(subsystem: subsystem, category: "IntegrationTest") 17 | } 18 | 19 | let expectationTimeOut = 10.0 // seconds 20 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/DocHandle.swift: -------------------------------------------------------------------------------- 1 | public import class Automerge.Document 2 | 3 | /// A type that represents an Automerge Document with its identifier. 4 | public struct DocHandle: Sendable { 5 | /// The ID of the document. 6 | public let id: DocumentId 7 | 8 | /// The Automerge document. 9 | public let doc: Document 10 | 11 | /// Creates a new DocHandle with the ID and document that you provide. 12 | /// - Parameters: 13 | /// - id: the ID of the Document 14 | /// - doc: the Automerge Document 15 | public init(id: DocumentId, doc: Document) { 16 | self.id = id 17 | self.doc = doc 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/extensions/Data+hexEncodedString.swift: -------------------------------------------------------------------------------- 1 | public import struct Foundation.Data 2 | 3 | public extension Data { 4 | /// Returns the data as a hex-encoded string. 5 | /// - Parameter uppercase: A Boolean value that indicates whether the hex encoded string uses uppercase letters. 6 | func hexEncodedString(uppercase: Bool = false) -> String { 7 | let format = uppercase ? "%02hhX" : "%02hhx" 8 | return map { String(format: format, $0) }.joined() 9 | } 10 | 11 | /// The data as an array of bytes. 12 | var bytes: [UInt8] { // fancy pretty call: myData.bytes -> [UInt8] 13 | [UInt8](self) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docker/docker-compose-zipkin-jaeger.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | otel-collector: 4 | image: otel/opentelemetry-collector-contrib:latest 5 | command: ["--config=/etc/config.yaml"] 6 | volumes: 7 | - ./collector-config.yaml:/etc/config.yaml 8 | ports: 9 | - "4317:4317" 10 | networks: [exporter] 11 | depends_on: [zipkin, jaeger] 12 | 13 | zipkin: 14 | image: openzipkin/zipkin:latest 15 | ports: 16 | - "9411:9411" 17 | networks: [exporter] 18 | 19 | jaeger: 20 | image: jaegertracing/all-in-one 21 | ports: 22 | - "16686:16686" 23 | networks: [exporter] 24 | 25 | networks: 26 | exporter: 27 | -------------------------------------------------------------------------------- /privacy/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | NSPrivacyTrackingDomains 8 | 9 | NSPrivacyCollectedDataTypes 10 | 11 | 12 | 13 | NSPrivacyAccessedAPITypes 14 | 15 | 16 | NSPrivacyAccessedAPIType 17 | NSPrivacyAccessedAPICategoryUserDefaults 18 | NSPrivacyAccessedAPITypeReasons 19 | 20 | CA92.1 21 | 1C8F.1 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/EphemeralMessageReceiver.swift: -------------------------------------------------------------------------------- 1 | /// A type that accepts ephemeral messages as they arrive from connected network peers. 2 | public protocol EphemeralMessageReceiver: Sendable { 3 | /// Receive and process an an ephemeral message from a repository. 4 | /// - Parameter msg: The ephemeral message. 5 | /// 6 | /// Conform a type to this protocol to be able to accept and process ``SyncV1Msg/ephemeral(_:)``, provided by other 7 | /// connected peers. 8 | /// This message type exists in the Automerge Repo Sync protocol to allow you to send and receive app-specific 9 | /// messages. 10 | /// Decode the ``SyncV1Msg/EphemeralMsg/data`` to receive and process the message. 11 | func receiveMessage(_ msg: SyncV1Msg.EphemeralMsg) async 12 | } 13 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/RepoHelpers.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | @testable import AutomergeRepo 3 | import AutomergeUtilities 4 | import Foundation 5 | 6 | public enum RepoHelpers { 7 | static func documentWithData() throws -> Document { 8 | let newDoc = Document() 9 | let txt = try newDoc.putObject(obj: .ROOT, key: "words", ty: .Text) 10 | try newDoc.updateText(obj: txt, value: "Hello World!") 11 | return newDoc 12 | } 13 | 14 | static func docHandleWithData() throws -> DocHandle { 15 | let newDoc = Document() 16 | let txt = try newDoc.putObject(obj: .ROOT, key: "words", ty: .Text) 17 | try newDoc.updateText(obj: txt, value: "Hello World!") 18 | return DocHandle(id: DocumentId(), doc: newDoc) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/RepoHelpers.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | import AutomergeRepo 3 | import AutomergeUtilities 4 | import Foundation 5 | 6 | public enum RepoHelpers { 7 | static func documentWithData() throws -> Document { 8 | let newDoc = Document() 9 | let txt = try newDoc.putObject(obj: .ROOT, key: "words", ty: .Text) 10 | try newDoc.updateText(obj: txt, value: "Hello World!") 11 | return newDoc 12 | } 13 | 14 | static func docHandleWithData() throws -> DocHandle { 15 | let newDoc = Document() 16 | let txt = try newDoc.putObject(obj: .ROOT, key: "words", ty: .Text) 17 | try newDoc.updateText(obj: txt, value: "Hello World!") 18 | return DocHandle(id: DocumentId(), doc: newDoc) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /IntegrationTests/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "IntegrationTests", 7 | platforms: [.iOS(.v16), .macOS(.v13)], 8 | products: [], 9 | dependencies: [ 10 | .package(path: "../"), 11 | // Testing Tracing 12 | .package(url: "https://github.com/heckj/DistributedTracer", branch: "main"), 13 | // this ^^ brings in a MASSIVE cascade of dependencies 14 | ], 15 | targets: [ 16 | .testTarget( 17 | name: "AutomergeRepoIntegrationTests", 18 | dependencies: [ 19 | .product(name: "AutomergeRepo", package: "automerge-repo-swift"), 20 | .product(name: "DistributedTracer", package: "DistributedTracer"), 21 | ] 22 | ), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /IntegrationTests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | The integration tests included in this Swift package can be run from either the command line or Xcode. 4 | They actively use network communications, and to fully execute all the tests, you need an operational local copy of `automerge-repo-sync-server`. 5 | To use Docker to provide an instance to test against, run the following command from this directory: 6 | 7 | ```bash 8 | ../scripts/interop.sh 9 | `` 10 | 11 | After it is operational, you can run the tests on the command line, also from this directory: 12 | 13 | ```bash 14 | swift test 15 | ``` 16 | 17 | The tests are written to accomodate running the tests in parallel, but there is also a script to run them serially, because Xcode (and `swift test`) default to running the tests in parallel. 18 | 19 | ```bash 20 | ./scripts/serial_tests.bash 21 | ``` -------------------------------------------------------------------------------- /notes/README.md: -------------------------------------------------------------------------------- 1 | # Technical Notes 2 | 3 | ## Generating SVG from Mermaid Diagrams 4 | 5 | To manually generate SVG files from the existing mermaid diagrams: 6 | 7 | npm i @mermaid-js/mermaid-cli 8 | ./node_modules/.bin/mmdc -i websocket_sync_states.mmd -o websocket_sync_states.svg 9 | 10 | Generate the compact left-to-right views with a single state highlighted, for annotating other bits of documentation: 11 | 12 | npm i @mermaid-js/mermaid-cli 13 | ./node_modules/.bin/mmdc -i websocket_sync_initial.mmd -o wss_initial.svg 14 | ./node_modules/.bin/mmdc -i websocket_sync_handshake.mmd -o wss_handshake.svg 15 | ./node_modules/.bin/mmdc -i websocket_sync_peered_waiting.mmd -o wss_peered_waiting.svg 16 | ./node_modules/.bin/mmdc -i websocket_sync_peered_syncing.mmd -o wss_peered_syncing.svg 17 | ./node_modules/.bin/mmdc -i websocket_sync_closed.mmd -o wss_closed.svg 18 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Repo+LogComponent.swift: -------------------------------------------------------------------------------- 1 | internal import Automerge 2 | internal import OSLog 3 | 4 | public extension Repo { 5 | /// Represents the primary internal components of a repository 6 | enum LogComponent: String, Hashable, Sendable { 7 | /// The storage subsystem 8 | case storage 9 | /// The network subsystem 10 | case network 11 | /// The top-level repository coordination 12 | case repo 13 | /// The document state resolution system 14 | case resolver 15 | 16 | func logger() -> Logger { 17 | switch self { 18 | case .storage: 19 | Logger.storage 20 | case .network: 21 | Logger.network 22 | case .repo: 23 | Logger.repo 24 | case .resolver: 25 | Logger.resolver 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/URLSessionWebSocketTask+sendPing.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URLSessionWebSocketTask { 4 | struct WebSocketPingError: LocalizedError { 5 | var errorDescription: String { 6 | "WebSocket ping() returned an error: \(wrappedError.localizedDescription)" 7 | } 8 | 9 | let wrappedError: any Error 10 | init(wrappedError: any Error) { 11 | self.wrappedError = wrappedError 12 | } 13 | } 14 | 15 | func sendPing() async throws { 16 | let _: Bool = try await withCheckedThrowingContinuation { continuation in 17 | self.sendPing { err in 18 | if let err { 19 | continuation.resume(throwing: WebSocketPingError(wrappedError: err)) 20 | } else { 21 | continuation.resume(returning: true) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/RepoTypes.swift: -------------------------------------------------------------------------------- 1 | public import struct Foundation.Data 2 | 3 | /// A type that represents a peer. 4 | /// 5 | /// Typically a UUID4 in string form. 6 | public typealias PEER_ID = String 7 | 8 | /// A type that represents an identity for the storage of a peer. 9 | /// 10 | /// Typically a UUID4 in string form. Receiving peers may tie cached sync state for documents to this identifier. 11 | public typealias STORAGE_ID = String 12 | 13 | /// The type that represents the external representation of a document ID. 14 | /// 15 | /// Typically a string that is 16 bytes of data encoded in bs58 format. 16 | public typealias MSG_DOCUMENT_ID = String 17 | // internally, DOCUMENT_ID is represented by the internal type DocumentId 18 | 19 | /// A type that represents the raw bytes of an Automerge sync message. 20 | public typealias SYNC_MESSAGE = Data 21 | 22 | /// A type that represents the raw bytes of a set of encoded changes to an Automerge document. 23 | public typealias CHUNK = Data 24 | -------------------------------------------------------------------------------- /notes/generate.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | set -eou pipefail 4 | 5 | # see https://stackoverflow.com/questions/4774054/reliable-way-for-a-bash-script-to-get-the-full-path-to-itself 6 | THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" 7 | 8 | pushd $THIS_SCRIPT_DIR 9 | 10 | if [ ! -d node_modules ]; then 11 | yarn install 12 | fi 13 | 14 | ./node_modules/.bin/mmdc -i websocket_sync_states.mmd -o websocket_sync_states.svg 15 | 16 | ./node_modules/.bin/mmdc -i websocket_sync_initial.mmd -o wss_initial.svg 17 | ./node_modules/.bin/mmdc -i websocket_sync_handshake.mmd -o wss_handshake.svg 18 | ./node_modules/.bin/mmdc -i websocket_sync_peered.mmd -o wss_peered.svg 19 | ./node_modules/.bin/mmdc -i websocket_sync_closed.mmd -o wss_closed.svg 20 | 21 | ./node_modules/.bin/mmdc -i websocket_strategy_sync.mmd -o websocket_strategy_sync.svg 22 | ./node_modules/.bin/mmdc -i websocket_strategy_request.mmd -o websocket_stragegy_request.svg 23 | 24 | mv *.svg ../Sources/AutomergeRepo/Documentation.docc/Resources/ 25 | 26 | popd 27 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/extensions/UUID+bs58String.swift: -------------------------------------------------------------------------------- 1 | internal import Base58Swift 2 | public import struct Foundation.Data 3 | public import struct Foundation.UUID 4 | 5 | public extension UUID { 6 | /// The contents of the UUID as data. 7 | var data: Data { 8 | var byteblob = Data(count: 16) 9 | byteblob[0] = uuid.0 10 | byteblob[1] = uuid.1 11 | byteblob[2] = uuid.2 12 | byteblob[3] = uuid.3 13 | byteblob[4] = uuid.4 14 | byteblob[5] = uuid.5 15 | byteblob[6] = uuid.6 16 | byteblob[7] = uuid.7 17 | byteblob[8] = uuid.8 18 | byteblob[9] = uuid.9 19 | byteblob[10] = uuid.10 20 | byteblob[11] = uuid.11 21 | byteblob[12] = uuid.12 22 | byteblob[13] = uuid.13 23 | byteblob[14] = uuid.14 24 | byteblob[15] = uuid.15 25 | return byteblob 26 | } 27 | 28 | /// The contents of UUID as a BS58 encoded string. 29 | var bs58String: String { 30 | Base58.base58CheckEncode(data.bytes) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/P2P_base.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | @testable import AutomergeRepo 3 | import AutomergeUtilities 4 | import OSLog 5 | import XCTest 6 | 7 | final class RepoPeer2PeerIntegrationTests: XCTestCase { 8 | override func setUp() async throws {} 9 | 10 | override func tearDown() async throws {} 11 | 12 | // document structure for test 13 | struct ExampleStruct: Identifiable, Codable, Hashable { 14 | let id: UUID 15 | var title: String 16 | var discussion: AutomergeText 17 | 18 | init(title: String, discussion: String) { 19 | id = UUID() 20 | self.title = title 21 | self.discussion = AutomergeText(discussion) 22 | } 23 | } 24 | 25 | func addContent(_ doc: Document) throws { 26 | // initial setup and encoding of Automerge doc to sync it 27 | let encoder = AutomergeEncoder(doc: doc) 28 | let model = ExampleStruct(title: "new item", discussion: "editable text") 29 | try encoder.encode(model) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/BackoffTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AutomergeRepo 2 | import XCTest 3 | 4 | final class BackoffTests: XCTestCase { 5 | func testSimpleBackoff() throws { 6 | XCTAssertEqual(0, Backoff.delay(0, withJitter: false)) 7 | XCTAssertEqual(1, Backoff.delay(1, withJitter: false)) 8 | XCTAssertEqual(1, Backoff.delay(2, withJitter: false)) 9 | XCTAssertEqual(2, Backoff.delay(3, withJitter: false)) 10 | XCTAssertEqual(3, Backoff.delay(4, withJitter: false)) 11 | XCTAssertEqual(610, Backoff.delay(15, withJitter: false)) 12 | XCTAssertEqual(610, Backoff.delay(16, withJitter: false)) 13 | XCTAssertEqual(610, Backoff.delay(100, withJitter: false)) 14 | } 15 | 16 | func testWithJitterBackoff() throws { 17 | XCTAssertEqual(0, Backoff.delay(0, withJitter: true)) 18 | XCTAssertEqual(1, Backoff.delay(1, withJitter: true)) 19 | for i: UInt in 2 ... 50 { 20 | XCTAssertTrue(Backoff.delay(i, withJitter: true) <= 987) 21 | // print(Backoff.delay(i, withJitter: true)) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-2024 the Automerge contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/SyncV1Msg.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/SyncV1Msg`` 2 | 3 | ## Topics 4 | 5 | ### Connection and handshaking messages 6 | 7 | - ``peer(_:)`` 8 | - ``PeerMsg`` 9 | 10 | - ``join(_:)`` 11 | - ``JoinMsg`` 12 | 13 | - ``leave(_:)`` 14 | - ``LeaveMsg`` 15 | 16 | ### Requesting and synchronizing documents 17 | 18 | - ``sync(_:)`` 19 | - ``SyncMsg`` 20 | 21 | - ``request(_:)`` 22 | - ``RequestMsg`` 23 | 24 | - ``unavailable(_:)`` 25 | - ``UnavailableMsg`` 26 | 27 | ### App-specific ephemeral messages 28 | 29 | - ``ephemeral(_:)`` 30 | - ``EphemeralMsg`` 31 | 32 | ### Error and unknown messages 33 | 34 | - ``error(_:)`` 35 | - ``ErrorMsg`` 36 | - ``Errors`` 37 | - ``unknown(_:)`` 38 | 39 | ### Peer information gossip messages 40 | 41 | - ``remoteHeadsChanged(_:)`` 42 | - ``RemoteHeadsChangedMsg`` 43 | 44 | - ``remoteSubscriptionChange(_:)`` 45 | - ``RemoteSubscriptionChangeMsg`` 46 | 47 | ### Updating ephemeral and gossip messages 48 | 49 | - ``setTarget(_:)`` 50 | 51 | ### Decoding Messages 52 | 53 | - ``decode(_:)`` 54 | - ``decodePeer(_:)`` 55 | 56 | ### Encoding Messages 57 | 58 | - ``encode(_:)`` 59 | 60 | ### Types of V1 Sync Messages 61 | 62 | - ``MsgTypes`` 63 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/Providers/AvailablePeer.swift: -------------------------------------------------------------------------------- 1 | public import Network 2 | 3 | /// A type that represents a peer available on the Peer to Peer (Bonjour) network. 4 | public struct AvailablePeer: Identifiable, Sendable { 5 | /// The ID of the peer 6 | public let peerId: String 7 | /// The ID endpoint where the peer is available 8 | public let endpoint: NWEndpoint 9 | /// The name broadcast for that peer 10 | public let name: String 11 | /// The stable identity of the peer 12 | public var id: String { 13 | peerId 14 | } 15 | } 16 | 17 | extension AvailablePeer: Hashable {} 18 | 19 | extension AvailablePeer: Comparable { 20 | /// Compares two available peers to provide consistent ordering by name. 21 | /// - Parameters: 22 | /// - lhs: The first available peer to compare 23 | /// - rhs: The second available peer to compare. 24 | public static func < (lhs: AvailablePeer, rhs: AvailablePeer) -> Bool { 25 | lhs.name < rhs.name 26 | } 27 | } 28 | 29 | extension AvailablePeer: CustomDebugStringConvertible { 30 | public var debugDescription: String { 31 | "\(name) [\(peerId)] at \(endpoint.debugDescription)" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/RepoFindTest.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | @testable import AutomergeRepo 3 | import AutomergeUtilities 4 | import XCTest 5 | 6 | final class RepoFindTest: XCTestCase { 7 | func testRepoFindWithoutNetworkingActive() async throws { 8 | // https://github.com/automerge/automerge-repo-swift/issues/84 9 | let repo = Repo(sharePolicy: SharePolicy.agreeable) 10 | await repo.setLogLevel(.resolver, to: .tracing) 11 | await repo.setLogLevel(.network, to: .tracing) 12 | let websocket = WebSocketProvider(.init(reconnectOnError: false, loggingAt: .tracing)) 13 | await repo.addNetworkAdapter(adapter: websocket) 14 | 15 | let unavailableExpectation = 16 | expectation(description: "Find should throw an Unavailable error if no peers are available to request from") 17 | Task { 18 | do { 19 | let handle = try await repo.find(id: DocumentId()) // never completes, never errors 20 | print(handle) 21 | } catch { 22 | unavailableExpectation.fulfill() 23 | } 24 | } 25 | await fulfillment(of: [unavailableExpectation], timeout: 5) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Sync/ProtocolState.swift: -------------------------------------------------------------------------------- 1 | /// Describes the state of an Automerge sync protocol connection. 2 | enum ProtocolState: String { 3 | /// The connection that has been created but not yet connected 4 | case setup 5 | 6 | /// The connection is established, waiting to successfully peer with the recipient. 7 | case preparing 8 | 9 | /// The connection successfully peered and is ready for use. 10 | case ready 11 | 12 | /// The connection is cancelled, failed, or terminated. 13 | case closed 14 | } 15 | 16 | #if canImport(Network) 17 | internal import class Network.NWConnection 18 | 19 | extension ProtocolState { 20 | /// Translates a Network connection state into a protocol state 21 | /// - Parameter connectState: The state of the network connection 22 | /// - Returns: The corresponding protocol state 23 | func from(_ connectState: NWConnection.State) -> Self { 24 | switch connectState { 25 | case .setup: 26 | .setup 27 | case .waiting: 28 | .preparing 29 | case .preparing: 30 | .preparing 31 | case .ready: 32 | .ready 33 | case .failed: 34 | .closed 35 | case .cancelled: 36 | .closed 37 | @unknown default: 38 | fatalError() 39 | } 40 | } 41 | } 42 | #endif 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | build-test: 10 | runs-on: macos-14 11 | # runner images reference info: 12 | # https://github.com/actions/runner-images/tree/main/images/macos 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | # default Xcode for macOS 14 image is v15.0.1 17 | - name: Select Xcode 15.3 18 | run: sudo xcode-select -s /Applications/Xcode_15.3.app 19 | 20 | - name: get xcode information 21 | run: | 22 | xcodebuild -version 23 | swift --version 24 | 25 | - name: Swift tests 26 | run: swift test 27 | 28 | #- name: Show Build Settings 29 | # run: xcodebuild -showBuildSettings 30 | 31 | - name: Show Build SDK 32 | run: xcodebuild -showsdks 33 | # iOS 17.0.1, iOS 17.2, macOS 13.3 on macOS-13 w/ Xcode 15.2 34 | # iOS 17.0.1, iOS 17.4 on macOS-14 w/ Xcode 15.3 35 | 36 | - name: Show Destinations 37 | run: xcodebuild -showdestinations -scheme 'automerge-repo' 38 | 39 | - name: iOS build for MeetingNotes 40 | # Xcode 15.3 settings 41 | run: xcodebuild clean build -scheme 'automerge-repo' -destination 'platform=iOS Simulator,OS=17.0.1,name=iPhone 14' -sdk iphonesimulator17.4 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/PeerMetadata.swift: -------------------------------------------------------------------------------- 1 | internal import Foundation 2 | 3 | // ; Metadata sent in either the join or peer message types 4 | // peer_metadata = { 5 | // ; The storage ID of this peer 6 | // ? storageId: storage_id, 7 | // ; Whether the sender expects to connect again with this storage ID 8 | // isEphemeral: bool 9 | // } 10 | 11 | /// A type that represents metadata associated with the storage capabilities of a remote peer. 12 | public struct PeerMetadata: Hashable, Sendable, Codable, CustomDebugStringConvertible { 13 | /// The ID of the peer's storage 14 | /// 15 | /// Multiple peers can technically share the same persistent storage. 16 | public var storageId: STORAGE_ID? 17 | 18 | /// A Boolean value that indicates any data sent to this peer is ephemeral. 19 | /// 20 | /// Typically, this means that the peer doesn't have any local persistent storage. 21 | public var isEphemeral: Bool 22 | 23 | /// Creates a new instance of peer metadata 24 | /// - Parameters: 25 | /// - storageId: An optional storage ID 26 | /// - isEphemeral: A Boolean value that indicates any data sent to this peer is ephemeral 27 | public init(storageId: STORAGE_ID? = nil, isEphemeral: Bool) { 28 | self.storageId = storageId 29 | self.isEphemeral = isEphemeral 30 | } 31 | 32 | /// A description of the metadata 33 | public var debugDescription: String { 34 | "[storageId: \(storageId ?? "nil"), ephemeral: \(isEphemeral)]" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /notes/release-process.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | Release process: 4 | 5 | Check out the latest code, or at the mark you want to release. 6 | While the CI system is solid, it's worthwhile to do a fresh repository clone, run a full build, and all the relevant tests before proceeding. 7 | 8 | This process adds a tag into the GitHub repository, but not until we've made a commit that explicitly sets up the downloadable packages from GitHub release artifacts. 9 | 10 | Steps: 11 | 12 | - Note the version you are intending to release. 13 | The version's tag number gets used in a number of places through this process. 14 | This example uses the version `0.1.0`. 15 | 16 | ```bash 17 | git tag 0.1.0 18 | git push origin --tags 19 | ``` 20 | 21 | - Open a browser and navigate to the URL that you can use to create a release on GitHub. 22 | - https://github.com/automerge/automerge-repo-swift/releases/new 23 | - choose the existing tag (`0.1.0` in this example) 24 | 25 | - Add a release title 26 | - Add in a description for the release 27 | - Select the checkout for a pre-release if relevant. 28 | 29 | - click `Publish release` 30 | 31 | ## Oops, I made a mistake - what do I do? 32 | 33 | If something in the process goes awry, don't worry - that happens. 34 | _Do not_ attempt to delete or move any tsgs that you've made. 35 | Instead, just move on to the next semantic version and call it a day. 36 | For example, when I was testing this process, I learned about the unsafe flags constraint at the last minute. 37 | To resolve this, I repeated the process with the next tag `0.1.1` even though it didn't have any meaningful changes in the code. 38 | 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AutomergeRepo, swift edition 2 | 3 | Extends the [Automerge-swift](https://github.com/automerge/automerge-swift) library, providing support for working with multiple Automerge documents at once, with pluggable network and storage providers. 4 | 5 | The library is a functional port/replica of the [automerge-repo](https://github.com/automerge/automerge-repo) javascript library. 6 | The goal of this project is to provide convenient storage and network synchronization for one or more Automerge documents, concurrently with multiple network peers. 7 | 8 | This library is being extracted from the Automerge-swift demo application [MeetingNotes](https://github.com/automerge/MeetingNotes). 9 | As such, the API is far from stable, and some not-swift6-compatible classes remain while we continue to evolve this library. 10 | 11 | ## Quickstart 12 | 13 | > WARNING: This package does NOT yet have a release tagged. Once the legacy elements from the MeetingNotes app are fully ported into Repo, we will cut an initial release for this package. In the meantime, if you want to explore or use this package, please do so as a local Package depdendency. 14 | 15 | 16 | **PENDING A RELEASE**, add a dependency in `Package.swift`, as the following example shows: 17 | 18 | ```swift 19 | let package = Package( 20 | ... 21 | dependencies: [ 22 | ... 23 | .package(url: "https://github.com/automerge/automerge-repo-swift.git", from: "0.1.0") 24 | ], 25 | targets: [ 26 | .executableTarget( 27 | ... 28 | dependencies: [.product(name: "AutomergeRepo", package: "automerge-repo-swift")], 29 | ... 30 | ) 31 | ] 32 | ) 33 | ``` 34 | 35 | For more details on using Automerge Documents, see the [Automerge-swift API documentation](https://automerge.org/automerge-swift/documentation/automerge/) and the articles within. 36 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/Backoff.swift: -------------------------------------------------------------------------------- 1 | internal import Foundation 2 | 3 | /// A type that provides a computation for a random back-off value based on an integer number of iterations. 4 | enum Backoff { 5 | // fibonacci numbers for 0...15 6 | static let fibonacci = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610] 7 | 8 | /// Returns an integer value that indicates a backoff value. 9 | /// 10 | /// The value can be interpreted as the caller desires - milliseconds, seconds, etc - 11 | /// as it is not a duration. 12 | /// 13 | /// - Parameters: 14 | /// - step: The number of previous tries 15 | /// - withJitter: A Boolean value that indicates whether additional randomness should be applied to the base 16 | /// backoff value for the step you provide. 17 | /// - Returns: An integer greater than 0 that represents a growing backoff time. 18 | public static func delay(_ step: UInt, withJitter: Bool) -> Int { 19 | let boundedStep = Int(min(15, step)) 20 | 21 | if withJitter { 22 | // pick a range of +/- values that's one fibonacci step lower 23 | let jitterStep = max(min(15, boundedStep - 1), 0) 24 | if jitterStep < 1 { 25 | // picking a random number between -0 and 0 is just silly, and kinda wrong 26 | // so just return the fibonacci number 27 | return Self.fibonacci[boundedStep] 28 | } 29 | let jitterRange = -1 * Self.fibonacci[jitterStep] ... Self.fibonacci[jitterStep] 30 | let selectedValue = Int.random(in: jitterRange) 31 | // no delay should be less than 0 32 | let adjustedValue = max(0, Self.fibonacci[boundedStep] + selectedValue) 33 | // max value is 987, min value is 0 34 | return adjustedValue 35 | } 36 | return Self.fibonacci[boundedStep] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/Providers/WebSocketProviderConfiguration.swift: -------------------------------------------------------------------------------- 1 | public import Automerge 2 | 3 | /// The configuration options for a WebSocket network provider. 4 | public struct WebSocketProviderConfiguration: Sendable { 5 | /// A Boolean value that indicates if the provider should attempt to reconnect when it fails with an error. 6 | public let reconnectOnError: Bool 7 | /// The maximum number of reconnections allowed before the WebSocket provider disconnects. 8 | /// 9 | /// If ``reconnectOnError`` is `false`, this value is ignored. 10 | /// If `nil`, the default, the WebSocketProvider does not enforce a maximum number of retries. 11 | public let maxNumberOfConnectRetries: Int? 12 | /// The verbosity of the logs sent to the unified logging system. 13 | public let logLevel: LogVerbosity 14 | 15 | /// The default configuration for the WebSocket network provider. 16 | /// 17 | /// In the default configuration: 18 | /// 19 | /// - `reconnectOnError` is `true` 20 | public static let `default` = WebSocketProviderConfiguration(reconnectOnError: true) 21 | 22 | /// Creates a new WebSocket network provider configuration instance. 23 | /// - Parameter reconnectOnError: A Boolean value that indicates if the provider should attempt to reconnect 24 | /// when it fails with an error. 25 | /// - Parameter loggingAt: The verbosity of the logs sent to the unified logging system. 26 | /// - Parameter maxNumberOfConnectRetries: The maximum number of reconnections allowed before the WebSocket provider disconnects. If `nil`, the default, retries continue forever. 27 | public init(reconnectOnError: Bool, loggingAt: LogVerbosity = .errorOnly, maxNumberOfConnectRetries: Int? = nil) { 28 | self.reconnectOnError = reconnectOnError 29 | self.maxNumberOfConnectRetries = maxNumberOfConnectRetries 30 | self.logLevel = loggingAt 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/Repo.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/Repo`` 2 | 3 | ## Overview 4 | 5 | Initialize a repository with a storage provider to enable automatic loading and saving of Automerge documents to persistent storage. 6 | Add one or more network adapters to support synchronization of updates between any connected peers. 7 | Documents are shared on request, or not, based on ``SharePolicy`` you provide when creating the repository. 8 | 9 | Enable network providers with async calls to ``addNetworkAdapter(adapter:)``. 10 | When network providers are active, the repository attempts to sync documents with connected peers on any update to the an Automerge document. 11 | 12 | ## Topics 13 | 14 | ### Creating a repository 15 | 16 | - ``init(sharePolicy:saveDebounce:maxResolveFetchIterations:resolveFetchIterationDelay:)-3j0z7`` 17 | - ``init(sharePolicy:saveDebounce:maxResolveFetchIterations:resolveFetchIterationDelay:)-18my9`` 18 | - ``init(sharePolicy:storage:saveDebounce:maxResolveFetchIterations:resolveFetchIterationDelay:)`` 19 | - ``init(sharePolicy:storage:networks:saveDebounce:maxResolveFetchIterations:resolveFetchIterationDelay:)`` 20 | 21 | ### Configuring a repository 22 | 23 | - ``addNetworkAdapter(adapter:)`` 24 | - ``setDelegate(_:)`` 25 | - ``setLogLevel(_:to:)`` 26 | - ``LogComponent`` 27 | 28 | ### Creating documents 29 | 30 | - ``create()`` 31 | - ``create(id:)`` 32 | 33 | ### Importing a document 34 | 35 | - ``import(handle:)`` 36 | 37 | ### Cloning a document 38 | 39 | - ``clone(id:)`` 40 | 41 | ### Requesting a document 42 | 43 | - ``find(id:)`` 44 | 45 | ### Deleting a document 46 | 47 | - ``delete(id:)`` 48 | 49 | ### Inspecting a repository 50 | 51 | - ``storageId()`` 52 | - ``peerId`` 53 | - ``localPeerMetadata`` 54 | 55 | - ``documentIds()`` 56 | - ``peers()`` 57 | 58 | ### Requesting ongoing updates from peers 59 | 60 | - ``subscribeToRemotes(remotes:)`` 61 | 62 | ### Sending app-specific messages 63 | 64 | - ``send(_:to:)`` 65 | - ``send(count:sessionId:documentId:data:to:)`` 66 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/CBORExperiments.swift: -------------------------------------------------------------------------------- 1 | @testable import AutomergeRepo 2 | import PotentCBOR 3 | import XCTest 4 | 5 | // public extension Data { 6 | // func hexEncodedString(uppercase: Bool = false) -> String { 7 | // let format = uppercase ? "%02hhX" : "%02hhx" 8 | // return map { String(format: format, $0) }.joined() 9 | // } 10 | // } 11 | 12 | struct AnotherType: Codable { 13 | var name: String 14 | var blah: Data 15 | } 16 | 17 | struct Message: Codable { 18 | var first: String 19 | var second: Int? 20 | var notexisting: AnotherType? 21 | } 22 | 23 | struct ExtendedMessage: Codable { 24 | var first: String 25 | var second: Int 26 | var third: String 27 | var fourth: AnotherType? 28 | } 29 | 30 | final class CBORExperiments: XCTestCase { 31 | static let encoder = CBOREncoder() 32 | static let decoder = CBORDecoder() 33 | 34 | func testCBORSerialization() throws { 35 | let peerMsg = SyncV1Msg.PeerMsg(senderId: "senderUUID", targetId: "targetUUID", storageId: "something") 36 | let encodedPeerMsg = try SyncV1Msg.encode(peerMsg) 37 | 38 | let x = try CBORSerialization.cbor(from: encodedPeerMsg) 39 | XCTAssertEqual(x.mapValue?["type"]?.utf8StringValue, SyncV1Msg.MsgTypes.peer) 40 | // print("CBOR data: \(x)") 41 | } 42 | 43 | func testDecodingWithAdditionalData() throws { 44 | let data = try Self.encoder.encode(ExtendedMessage( 45 | first: "one", 46 | second: 2, 47 | third: "three", 48 | fourth: AnotherType(name: "foo", blah: Data()) 49 | )) 50 | print("Encoded form: \(data.hexEncodedString())") 51 | // data format decoded with CBOR.me: 52 | // {"first": "one", "second": 2, "third": "three", "fourth": {"name": "foo", "blah": h''}} 53 | let decodedData = try Self.decoder.decode(Message.self, from: data) 54 | XCTAssertEqual(decodedData.first, "one") 55 | XCTAssertEqual(decodedData.second, 2) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/extensions/OSLog+extensions.swift: -------------------------------------------------------------------------------- 1 | internal import Automerge 2 | internal import OSLog 3 | 4 | extension Logger: @unchecked Sendable {} 5 | // https://forums.developer.apple.com/forums/thread/747816?answerId=781922022#781922022 6 | // Per Quinn: 7 | // `Logger` should be sendable. Under the covers, it’s an immutable struct with a single 8 | // OSLog property, and that in turn is just a wrapper around the C os_log_t which is 9 | // definitely thread safe. 10 | #if swift(>=6.0) 11 | #warning("Reevaluate whether this decoration is necessary.") 12 | #endif 13 | 14 | extension Logger { 15 | /// Using your bundle identifier is a great way to ensure a unique identifier. 16 | private static let subsystem = Bundle.main.bundleIdentifier! 17 | 18 | /// Logs updates and interaction related to watching for external peer systems. 19 | static let peer2peer = Logger(subsystem: subsystem, category: "SyncController") 20 | 21 | /// Logs updates and interaction related to the process of synchronization over the network. 22 | static let peerconnection = Logger(subsystem: subsystem, category: "SyncConnection") 23 | 24 | /// Logs updates and interations performed by the sync protocol encoder and decoder. 25 | static let coder = Logger(subsystem: subsystem, category: "SyncCoderDecoder") 26 | 27 | /// Logs updates and interaction related to the process of synchronization over the network. 28 | static let websocket = Logger(subsystem: subsystem, category: "WebSocket") 29 | 30 | /// Logs updates and interaction related to the process of synchronization over the network. 31 | static let storage = Logger(subsystem: subsystem, category: "storageSubsystem") 32 | 33 | static let repo = Logger(subsystem: subsystem, category: "automerge-repo") 34 | 35 | /// Logs updates related to tracing the resolution of docIDs within a repo 36 | static let resolver = Logger(subsystem: subsystem, category: "resolver") 37 | 38 | static let network = Logger(subsystem: subsystem, category: "networkSubsystem") 39 | } 40 | -------------------------------------------------------------------------------- /scripts/build-ghpages-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eou pipefail 3 | 4 | # see https://stackoverflow.com/questions/4774054/reliable-way-for-a-bash-script-to-get-the-full-path-to-itself 5 | THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" 6 | PACKAGE_PATH=$THIS_SCRIPT_DIR/../ 7 | BUILD_DIR=$PACKAGE_PATH/.build 8 | 9 | #echo "THIS_SCRIPT_DIR= ${THIS_SCRIPT_DIR}" 10 | #echo "PACKAGE_PATH = ${PACKAGE_PATH}" 11 | #echo "BUILD_DIR = ${BUILD_DIR}" 12 | pushd ${PACKAGE_PATH} 13 | 14 | # Enables deterministic output 15 | # - useful when you're committing the results to host on github pages 16 | export DOCC_JSON_PRETTYPRINT=YES 17 | 18 | # LEGACY WAY OF DOING THIS 19 | # mkdir -p "${BUILD_DIR}/symbol-graphs" 20 | 21 | # $(xcrun --find swift) build --target AutomergeRepo \ 22 | # -Xswiftc -emit-symbol-graph \ 23 | # -Xswiftc -emit-symbol-graph-dir -Xswiftc "${BUILD_DIR}/symbol-graphs" 24 | 25 | # $(xcrun --find docc) convert Sources/AutomergeRepo/Documentation.docc \ 26 | # --output-path ./docs \ 27 | # --fallback-display-name AutomergeRepo \ 28 | # --fallback-bundle-identifier com.github.automerge.automerge-repo-swift \ 29 | # --fallback-bundle-version 0.0.1 \ 30 | # --additional-symbol-graph-dir "${BUILD_DIR}/symbol-graphs" \ 31 | # --emit-digest \ 32 | # --transform-for-static-hosting \ 33 | # --hosting-base-path 'automerge-repo-swift' 34 | 35 | $(xcrun --find swift) package \ 36 | --allow-writing-to-directory ./docs \ 37 | generate-documentation \ 38 | --fallback-bundle-identifier com.github.automerge.automerge-repo-swift \ 39 | --target AutomergeRepo \ 40 | --output-path ${PACKAGE_PATH}/docs \ 41 | --emit-digest \ 42 | --hosting-base-path 'automerge-repo-swift' 43 | # --disable-indexing \ 44 | 45 | # The following options are Swift 5.8 *only* and add github reference 46 | # links to the hosted documentation. 47 | # --source-service github \ 48 | # --source-service-base-url https://github.com/automerge/automerge-repo-swift/blob/main \ 49 | # --checkout-path ${PACKAGE_PATH} 50 | 51 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/WebSocketProvider.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/WebSocketProvider`` 2 | 3 | ## Overview 4 | 5 | Create and retain a reference to an instance of `WebSocketProvider` to establish a point to point connection, using WebSockets, to another Automerge repository, for example a service built with the JavaScript library [automerge-repo](https://github.com/automerge/automerge-repo). 6 | 7 | After creating the network provider, add it to ``Repo`` using ``Repo/addNetworkAdapter(adapter:)``. 8 | For example, the [MeetingNotes demo application](https://github.com/automerge/MeetingNotes/) creates a single global instance and adds it to a repository in a Task that is invoked immediately after the app initializes: 9 | 10 | 11 | ```swift 12 | let repo = Repo(sharePolicy: SharePolicy.agreeable) 13 | let websocket = WebSocketProvider(.init(reconnectOnError: true, loggingAt: .tracing)) 14 | 15 | @main 16 | struct MeetingNotesApp: App { 17 | ... 18 | 19 | init() { 20 | Task { 21 | // Enable network adapters 22 | await repo.addNetworkAdapter(adapter: websocket) 23 | } 24 | } 25 | } 26 | ``` 27 | 28 | To connect the repository to a remote endpoint, make an async call to ``connect(to:)-32her``, passing the WebSocket URL: 29 | 30 | ```swift 31 | Button { 32 | Task { 33 | try await websocket.connect(to: repoDestination.url) 34 | } 35 | } label: { 36 | Text("Connect") 37 | } 38 | ``` 39 | 40 | ## Topics 41 | 42 | ### Creating a WebSocket network provider 43 | 44 | - ``init(_:)`` 45 | - ``WebSocketProviderConfiguration`` 46 | - ``ProviderConfiguration`` 47 | - ``NetworkConnectionEndpoint`` 48 | 49 | ### Configuring the provider 50 | 51 | - ``setDelegate(_:as:with:)`` 52 | 53 | ### Establishing Connections 54 | 55 | - ``connect(to:)-32her`` 56 | - ``connect(to:)-5y82b`` 57 | - ``disconnect()`` 58 | 59 | ### Sending messages 60 | 61 | - ``send(message:to:)`` 62 | 63 | ### Inspecting the provider 64 | 65 | - ``name`` 66 | - ``peeredConnections`` 67 | 68 | ### Receiving state updates 69 | 70 | - ``statePublisher`` 71 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/BS58IdTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AutomergeRepo 2 | import Base58Swift 3 | import XCTest 4 | 5 | final class BS58IdTests: XCTestCase { 6 | func testDataLengthUUIDandAutomergeID() throws { 7 | let exampleUUID = UUID() 8 | let bytes: Data = exampleUUID.data 9 | // example from AutomergeRepo docs/blog 10 | // https://automerge.org/blog/2023/11/06/automerge-repo/ 11 | // let full = "automerge:2j9knpCseyhnK8izDmLpGP5WMdZQ" 12 | let partial = "2j9knpCseyhnK8izDmLpGP5WMdZQ" 13 | XCTAssertEqual(Base58.base58Decode(partial)?.count, 20) 14 | if let decodedBytes = Base58.base58CheckDecode(partial) { 15 | // both are 16 bytes of data 16 | XCTAssertEqual(bytes.count, Data(decodedBytes).count) 17 | } 18 | } 19 | 20 | func testDisplayingUUIDWithBase58() throws { 21 | let exampleUUID = try XCTUnwrap(UUID(uuidString: "1654A0B5-43B9-48FF-B7FB-83F58F4D1D75")) 22 | // print("hexencoded: \(exampleUUID.data.hexEncodedString())") 23 | XCTAssertEqual("1654a0b543b948ffb7fb83f58f4d1d75", exampleUUID.data.hexEncodedString()) 24 | let bs58Converted = Base58.base58CheckEncode(exampleUUID.data.bytes) 25 | // print("Converted: \(bs58Converted)") 26 | XCTAssertEqual("K3YptshN5CcFZNpnnXcStizSNPU", bs58Converted) 27 | XCTAssertEqual(exampleUUID.bs58String, bs58Converted) 28 | } 29 | 30 | func testDataInAndOutWithBase58() throws { 31 | // let full = "automerge:2j9knpCseyhnK8izDmLpGP5WMdZQ" 32 | let partial = "2j9knpCseyhnK8izDmLpGP5WMdZQ" 33 | if let decodedBytes = Base58.base58CheckDecode(partial) { 34 | print(decodedBytes.count) 35 | // AutomergeID is 16 bytes of data 36 | XCTAssertEqual(16, Data(decodedBytes).count) 37 | XCTAssertEqual("7bf18580944c450ea740c1f23be047ca", Data(decodedBytes).hexEncodedString()) 38 | // print(Data(decodedBytes).hexEncodedString()) 39 | 40 | let reversed = Base58.base58CheckEncode(decodedBytes) 41 | XCTAssertEqual(reversed, partial) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/DocHandleTests.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | @testable import AutomergeRepo 3 | import XCTest 4 | 5 | final class DocHandleTests: XCTestCase { 6 | func testNewDocHandleData() async throws { 7 | let id = DocumentId() 8 | let new = InternalDocHandle(id: id, isNew: true) 9 | 10 | XCTAssertEqual(new.id, id) 11 | XCTAssertEqual(new.state, .idle) 12 | XCTAssertEqual(new.isDeleted, false) 13 | XCTAssertEqual(new.isReady, false) 14 | XCTAssertEqual(new.isUnavailable, false) 15 | XCTAssertEqual(new.remoteHeads.count, 0) 16 | XCTAssertNil(new.doc) 17 | } 18 | 19 | func testNewDocHandleDataWithDocument() async throws { 20 | let id = DocumentId() 21 | let new = InternalDocHandle(id: id, isNew: true, initialValue: Document()) 22 | 23 | XCTAssertEqual(new.id, id) 24 | XCTAssertEqual(new.state, .loading) 25 | XCTAssertEqual(new.isDeleted, false) 26 | XCTAssertEqual(new.isReady, false) 27 | XCTAssertEqual(new.isUnavailable, false) 28 | XCTAssertEqual(new.remoteHeads.count, 0) 29 | XCTAssertNotNil(new.doc) 30 | } 31 | 32 | func testDocHandleRequestData() async throws { 33 | let id = DocumentId() 34 | let new = InternalDocHandle(id: id, isNew: false) 35 | 36 | XCTAssertEqual(new.id, id) 37 | XCTAssertEqual(new.state, .idle) 38 | XCTAssertEqual(new.isDeleted, false) 39 | XCTAssertEqual(new.isReady, false) 40 | XCTAssertEqual(new.isUnavailable, false) 41 | XCTAssertEqual(new.remoteHeads.count, 0) 42 | XCTAssertNil(new.doc) 43 | } 44 | 45 | func testDocHandleRequestDataWithData() async throws { 46 | let id = DocumentId() 47 | let new = InternalDocHandle(id: id, isNew: false, initialValue: Document()) 48 | 49 | XCTAssertEqual(new.id, id) 50 | XCTAssertEqual(new.state, .ready) 51 | XCTAssertEqual(new.isDeleted, false) 52 | XCTAssertEqual(new.isReady, true) 53 | XCTAssertEqual(new.isUnavailable, false) 54 | XCTAssertEqual(new.remoteHeads.count, 0) 55 | XCTAssertNotNil(new.doc) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/DocumentId.swift: -------------------------------------------------------------------------------- 1 | internal import Base58Swift 2 | public import struct Foundation.Data 3 | public import struct Foundation.UUID 4 | 5 | /// A unique identifier for an Automerge document. 6 | public struct DocumentId: Sendable, Hashable, Comparable, Identifiable { 7 | /// A bs58 encoded string that represents the identifier 8 | public let id: String 9 | let data: Data 10 | 11 | /// Creates a random document identifier. 12 | public init() { 13 | let uuid = UUID() 14 | data = uuid.data 15 | id = uuid.bs58String 16 | } 17 | 18 | /// Creates a document identifier from a UUID v4. 19 | /// - Parameter id: the v4 UUID to use as a document identifier. 20 | public init(_ id: UUID) { 21 | self.data = id.data 22 | self.id = id.bs58String 23 | } 24 | 25 | /// Creates a document identifier or returns nil if the optional string you provide is not a valid DocumentID. 26 | /// - Parameter id: The string to use as a document identifier. 27 | public init?(_ id: String?) { 28 | guard let id else { 29 | return nil 30 | } 31 | guard let uint_array = Base58.base58CheckDecode(id) else { 32 | return nil 33 | } 34 | if uint_array.count != 16 { 35 | return nil 36 | } 37 | self.id = id 38 | self.data = Data(uint_array) 39 | } 40 | 41 | /// Creates a document identifier or returns nil if the string you provide is not a valid DocumentID. 42 | /// - Parameter id: The string to use as a document identifier. 43 | public init?(_ id: String) { 44 | guard let uint_array = Base58.base58CheckDecode(id) else { 45 | return nil 46 | } 47 | if uint_array.count != 16 { 48 | return nil 49 | } 50 | self.id = id 51 | self.data = Data(uint_array) 52 | } 53 | 54 | // Comparable conformance 55 | public static func < (lhs: DocumentId, rhs: DocumentId) -> Bool { 56 | lhs.id < rhs.id 57 | } 58 | } 59 | 60 | extension DocumentId: Codable {} 61 | 62 | extension DocumentId: CustomStringConvertible { 63 | /// The string representation of the Document identifier. 64 | public var description: String { 65 | id 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Storage/StorageProvider.swift: -------------------------------------------------------------------------------- 1 | public import struct Foundation.Data 2 | 3 | // loose adaptation from automerge-repo storage interface 4 | // https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo/src/storage/StorageAdapter.ts 5 | /// A type that provides an interface for persisting the changes to Automerge documents. 6 | @AutomergeRepo 7 | public protocol StorageProvider: Sendable { 8 | /// The identifier for the persistent storage location. 9 | nonisolated var id: STORAGE_ID { get } 10 | 11 | /// Return the data for an Automerge document 12 | /// - Parameter id: The ID of the document 13 | /// - Returns: The combined data that can be loaded into a Document 14 | func load(id: DocumentId) async throws -> Data? 15 | 16 | /// Save the data you provide as the ID you provide. 17 | /// - Parameters: 18 | /// - id: The ID of the document 19 | /// - data: The data from the Document 20 | func save(id: DocumentId, data: Data) async throws 21 | 22 | /// Remove the stored data for the ID you provide 23 | /// - Parameter id: The ID of the document 24 | func remove(id: DocumentId) async throws 25 | 26 | // MARK: Incremental Load Support 27 | 28 | /// Stores incremental data updates in parallel for the document and prefix you provide. 29 | /// - Parameters: 30 | /// - id: The ID of the document 31 | /// - prefix: The identifier for the parallel set of data to store. 32 | /// - data: The combined incremental updates from the document 33 | func addToRange(id: DocumentId, prefix: String, data: Data) async throws 34 | 35 | /// Retrieve the incremental data updates for the document and prefix you provide. 36 | /// - Parameters: 37 | /// - id: The ID of the document 38 | /// - prefix: The identifier for the parallel set of data to store. 39 | /// - Returns: The combined incremental updates from the document 40 | func loadRange(id: DocumentId, prefix: String) async throws -> [Data] 41 | 42 | /// Removes the list of data you provide for the document and prefix you provide. 43 | /// - Parameters: 44 | /// - id: The ID of the document 45 | /// - prefix: The identifier for the parallel set of data to store. 46 | /// - data: The list of incremental updates to remove. 47 | func removeRange(id: DocumentId, prefix: String, data: [Data]) async throws 48 | } 49 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/ShareAuthorizing.swift: -------------------------------------------------------------------------------- 1 | /// A type that determines if a document may be shared with a peer 2 | public protocol ShareAuthorizing: Sendable { 3 | /// Returns a Boolean value that indicates whether a document may be shared. 4 | /// - Parameters: 5 | /// - peer: The peer to potentially share with 6 | /// - docId: The document Id to share 7 | func share(peer: PEER_ID, docId: DocumentId) async -> Bool 8 | } 9 | 10 | /// A type that encapsulates the logic to choose if a repository shares a document. 11 | /// 12 | /// The built-in share policies include ``agreeable`` and ``readonly``. 13 | /// Provide your own closure that accepts a ``PEER_ID`` and ``DocumentId`` to return a Boolean value that indicates if 14 | /// the document should be shared on request. 15 | /// 16 | /// If you need a type that supports more state and logic to determine authorization to share, 17 | /// initialize a ``Repo`` with your own type that conforms to ``ShareAuthorizing`` with 18 | /// ``Repo/init(sharePolicy:saveDebounce:maxResolveFetchIterations:resolveFetchIterationDelay:)-3j0z7``. 19 | public struct SharePolicy: ShareAuthorizing, Sendable { 20 | /// Returns a Boolean value that indicates whether a document may be shared. 21 | /// - Parameters: 22 | /// - peer: The peer to potentially share with 23 | /// - docId: The document Id to share 24 | public func share(peer: PEER_ID, docId: DocumentId) async -> Bool { 25 | await shareCheck(peer, docId) 26 | } 27 | 28 | // let msgResponse: @Sendable (SyncV1Msg) async -> SyncV1Msg? 29 | let shareCheck: @Sendable (_ peer: PEER_ID, _ docId: DocumentId) async -> Bool 30 | 31 | /// Create a new share policy that determines a repo's share authorization logic with a closure that you provide. 32 | /// - Parameter closure: A closure that accepts a peer ID and a document ID and returns a Boolean value that 33 | /// indicates if the document may be shared with peers requesting it. 34 | public init( 35 | _ closure: @Sendable @escaping (_ peer: PEER_ID, _ docId: DocumentId) async -> Bool 36 | ) { 37 | self.shareCheck = closure 38 | } 39 | 40 | /// A policy that always shares documents. 41 | public static let agreeable = SharePolicy { _, _ in 42 | true 43 | } 44 | 45 | /// A policy that never shares documents. 46 | public static let readonly = SharePolicy { _, _ in 47 | false 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/extensions/String+hexEncoding.swift: -------------------------------------------------------------------------------- 1 | internal import struct Foundation.Data 2 | 3 | // https://stackoverflow.com/a/56870030/19477 4 | // Licensed: CC BY-SA 4.0 for [itMaxence](https://stackoverflow.com/users/3328736/itmaxence) 5 | extension String { 6 | enum ExtendedEncoding { 7 | case hexadecimal 8 | } 9 | 10 | func data(using _: ExtendedEncoding) -> Data? { 11 | let hexStr = dropFirst(hasPrefix("0x") ? 2 : 0) 12 | 13 | guard hexStr.count % 2 == 0 else { return nil } 14 | 15 | var newData = Data(capacity: hexStr.count / 2) 16 | 17 | var indexIsEven = true 18 | for i in hexStr.indices { 19 | if indexIsEven { 20 | let byteRange = i ... hexStr.index(after: i) 21 | guard let byte = UInt8(hexStr[byteRange], radix: 16) else { return nil } 22 | newData.append(byte) 23 | } 24 | indexIsEven.toggle() 25 | } 26 | return newData 27 | } 28 | } 29 | 30 | // usage: 31 | // "5413".data(using: .hexadecimal) 32 | // "0x1234FF".data(using: .hexadecimal) 33 | 34 | // extension Data { 35 | // Could make a more optimized one~ 36 | // func hexa(prefixed isPrefixed: Bool = true) -> String { 37 | // self.bytes.reduce(isPrefixed ? "0x" : "") { $0 + String(format: "%02X", $1) } 38 | // } 39 | // print("000204ff5400".data(using: .hexadecimal)?.hexa() ?? "failed") // OK 40 | // print("0x000204ff5400".data(using: .hexadecimal)?.hexa() ?? "failed") // OK 41 | // print("541".data(using: .hexadecimal)?.hexa() ?? "failed") // fails 42 | // print("5413".data(using: .hexadecimal)?.hexa() ?? "failed") // OK 43 | // } 44 | 45 | // https://stackoverflow.com/a/73731660/19477 46 | // Licensed: CC BY-SA 4.0 for [Nick](https://stackoverflow.com/users/392986/nick) 47 | extension Data { 48 | init(hexString: String) { 49 | self = hexString 50 | .dropFirst(hexString.hasPrefix("0x") ? 2 : 0) 51 | .compactMap { $0.hexDigitValue.map { UInt8($0) } } 52 | .reduce(into: (data: Data(capacity: hexString.count / 2), byte: nil as UInt8?)) { partialResult, nibble in 53 | if let p = partialResult.byte { 54 | partialResult.data.append(p + nibble) 55 | partialResult.byte = nil 56 | } else { 57 | partialResult.byte = nibble << 4 58 | } 59 | }.data 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/TestStorageProviders/InMemoryStorage.swift: -------------------------------------------------------------------------------- 1 | import AutomergeRepo 2 | import struct Foundation.Data 3 | import struct Foundation.UUID 4 | 5 | /// An in-memory only storage provider. 6 | @AutomergeRepo 7 | public final class InMemoryStorage: StorageProvider { 8 | public nonisolated let id: STORAGE_ID = UUID().uuidString 9 | 10 | var _storage: [DocumentId: Data] = [:] 11 | var _incrementalChunks: [CombinedKey: [Data]] = [:] 12 | 13 | public nonisolated init() {} 14 | 15 | public struct CombinedKey: Hashable, Comparable { 16 | public static func < (lhs: InMemoryStorage.CombinedKey, rhs: InMemoryStorage.CombinedKey) -> Bool { 17 | if lhs.prefix == rhs.prefix { 18 | return lhs.id < rhs.id 19 | } 20 | return lhs.prefix < rhs.prefix 21 | } 22 | 23 | public let id: DocumentId 24 | public let prefix: String 25 | } 26 | 27 | public func load(id: DocumentId) async -> Data? { 28 | _storage[id] 29 | } 30 | 31 | public func save(id: DocumentId, data: Data) async { 32 | _storage[id] = data 33 | } 34 | 35 | public func remove(id: DocumentId) async { 36 | _storage.removeValue(forKey: id) 37 | } 38 | 39 | // MARK: Incremental Load Support 40 | 41 | public func addToRange(id: DocumentId, prefix: String, data: Data) async { 42 | var dataArray: [Data] = _incrementalChunks[CombinedKey(id: id, prefix: prefix)] ?? [] 43 | dataArray.append(data) 44 | _incrementalChunks[CombinedKey(id: id, prefix: prefix)] = dataArray 45 | } 46 | 47 | public func loadRange(id: DocumentId, prefix: String) async -> [Data] { 48 | _incrementalChunks[CombinedKey(id: id, prefix: prefix)] ?? [] 49 | } 50 | 51 | public func removeRange(id: DocumentId, prefix: String, data: [Data]) async { 52 | var chunksForKey: [Data] = _incrementalChunks[CombinedKey(id: id, prefix: prefix)] ?? [] 53 | for d in data { 54 | if let indexToRemove = chunksForKey.firstIndex(of: d) { 55 | chunksForKey.remove(at: indexToRemove) 56 | } 57 | } 58 | _incrementalChunks[CombinedKey(id: id, prefix: prefix)] = chunksForKey 59 | } 60 | 61 | // MARK: Testing Spies/Support 62 | 63 | public func storageKeys() -> [DocumentId] { 64 | _storage.keys.sorted() 65 | } 66 | 67 | public func incrementalKeys() -> [CombinedKey] { 68 | _incrementalChunks.keys.sorted() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/TestProviders/InMemoryStorage.swift: -------------------------------------------------------------------------------- 1 | import AutomergeRepo 2 | import struct Foundation.Data 3 | import struct Foundation.UUID 4 | 5 | /// An in-memory only storage provider. 6 | @AutomergeRepo 7 | public final class InMemoryStorage: StorageProvider { 8 | public nonisolated let id: STORAGE_ID = UUID().uuidString 9 | 10 | var _storage: [DocumentId: Data] = [:] 11 | var _incrementalChunks: [CombinedKey: [Data]] = [:] 12 | 13 | public nonisolated init() {} 14 | 15 | public struct CombinedKey: Hashable, Comparable { 16 | public static func < (lhs: InMemoryStorage.CombinedKey, rhs: InMemoryStorage.CombinedKey) -> Bool { 17 | if lhs.prefix == rhs.prefix { 18 | return lhs.id < rhs.id 19 | } 20 | return lhs.prefix < rhs.prefix 21 | } 22 | 23 | public let id: DocumentId 24 | public let prefix: String 25 | } 26 | 27 | public func load(id: DocumentId) async -> Data? { 28 | _storage[id] 29 | } 30 | 31 | public func save(id: DocumentId, data: Data) async { 32 | _storage[id] = data 33 | } 34 | 35 | public func remove(id: DocumentId) async { 36 | _storage.removeValue(forKey: id) 37 | } 38 | 39 | // MARK: Incremental Load Support 40 | 41 | public func addToRange(id: DocumentId, prefix: String, data: Data) async { 42 | var dataArray: [Data] = _incrementalChunks[CombinedKey(id: id, prefix: prefix)] ?? [] 43 | dataArray.append(data) 44 | _incrementalChunks[CombinedKey(id: id, prefix: prefix)] = dataArray 45 | } 46 | 47 | public func loadRange(id: DocumentId, prefix: String) async -> [Data] { 48 | _incrementalChunks[CombinedKey(id: id, prefix: prefix)] ?? [] 49 | } 50 | 51 | public func removeRange(id: DocumentId, prefix: String, data: [Data]) async { 52 | var chunksForKey: [Data] = _incrementalChunks[CombinedKey(id: id, prefix: prefix)] ?? [] 53 | for d in data { 54 | if let indexToRemove = chunksForKey.firstIndex(of: d) { 55 | chunksForKey.remove(at: indexToRemove) 56 | } 57 | } 58 | _incrementalChunks[CombinedKey(id: id, prefix: prefix)] = chunksForKey 59 | } 60 | 61 | // MARK: Testing Spies/Support 62 | 63 | public func storageKeys() -> [DocumentId] { 64 | _storage.keys.sorted() 65 | } 66 | 67 | public func incrementalKeys() -> [CombinedKey] { 68 | _incrementalChunks.keys.sorted() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import Foundation 4 | import PackageDescription 5 | 6 | var globalSwiftSettings: [PackageDescription.SwiftSetting] = [] 7 | 8 | if ProcessInfo.processInfo.environment["LOCAL_BUILD"] != nil { 9 | globalSwiftSettings.append(.enableExperimentalFeature("StrictConcurrency")) 10 | } 11 | 12 | let package = Package( 13 | name: "automerge-repo", 14 | platforms: [.iOS(.v16), .macOS(.v13), .visionOS(.v1)], 15 | products: [ 16 | .library( 17 | name: "AutomergeRepo", 18 | targets: ["AutomergeRepo"] 19 | ), 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/automerge/automerge-swift", .upToNextMajor(from: "0.5.7")), 23 | .package(url: "https://github.com/outfoxx/PotentCodables", .upToNextMajor(from: "3.1.0")), 24 | .package(url: "https://github.com/heckj/Base58Swift", .upToNextMajor(from: "2.1.14")), 25 | .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), 26 | // Distributed Tracing support 27 | .package(url: "https://github.com/apple/swift-distributed-tracing", from: "1.0.0"), 28 | 29 | // Documentation plugin 30 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), 31 | ], 32 | targets: [ 33 | .target( 34 | name: "AutomergeRepo", 35 | dependencies: [ 36 | .product(name: "Automerge", package: "automerge-swift"), 37 | // CBOR encoding and decoding 38 | .product(name: "PotentCodables", package: "PotentCodables"), 39 | // BS58 representations of data 40 | .product(name: "Base58Swift", package: "Base58Swift"), 41 | // Async functional algorithms 42 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), 43 | // Support for distributed tracing 44 | .product(name: "Tracing", package: "swift-distributed-tracing"), 45 | ], 46 | // borrowing a set of Swift6 enabling features to double-check against 47 | // future proofing concurrency, safety, and exportable feature-creep. 48 | swiftSettings: [ 49 | .enableExperimentalFeature("StrictConcurrency"), 50 | .enableUpcomingFeature("ExistentialAny"), 51 | .enableExperimentalFeature("AccessLevelOnImport"), 52 | .enableUpcomingFeature("InternalImportsByDefault"), 53 | ] 54 | ), 55 | .testTarget( 56 | name: "AutomergeRepoTests", 57 | dependencies: [ 58 | "AutomergeRepo", 59 | ] 60 | ), 61 | ] 62 | ) 63 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Curation/PeerToPeerProvider.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo/PeerToPeerProvider`` 2 | 3 | ## Overview 4 | 5 | Create and retain a reference to an instance of `PeerToPeerProvider` to support multiple connections to other apps on your local network. 6 | 7 | After creating the network provider, add it to ``Repo`` using ``Repo/addNetworkAdapter(adapter:)``. 8 | For example, the [MeetingNotes demo application](https://github.com/automerge/MeetingNotes/) creates a single global instance and adds it to a repository in a Task that is invoked immediately after the app initializes: 9 | 10 | 11 | ```swift 12 | let repo = Repo(sharePolicy: SharePolicy.agreeable) 13 | public let repo = Repo(sharePolicy: SharePolicy.agreeable) 14 | public let peerToPeer = PeerToPeerProvider( 15 | PeerToPeerProviderConfiguration( 16 | passcode: "AutomergeMeetingNotes", 17 | reconnectOnError: true, 18 | autoconnect: false 19 | ) 20 | ) 21 | 22 | @main 23 | struct MeetingNotesApp: App { 24 | ... 25 | 26 | init() { 27 | Task { 28 | // Enable network adapters 29 | await repo.addNetworkAdapter(adapter: peerToPeer) 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | Use ``startListening(as:)`` to activate the peer to peer network provider. 36 | When active, ``availablePeerPublisher`` publishes updates from the Bonjour browser that shows all peers on the local network, including yourself. 37 | Filter the instances of ``AvailablePeer`` provided by the published by ``AvailablePeer/peerId`` to exclude yourself. 38 | If the provider is not set to auto-connect, you can explicitly connect to a peer's endpoint provided in ``AvailablePeer/endpoint``. 39 | The ``connectionPublisher`` publishes a list of ``PeerConnectionInfo`` to provide information about active connections. 40 | Two additional publishers, ``browserStatePublisher`` and ``listenerStatePublisher`` share status information about the Bonjour browser and listener. 41 | 42 | ## Topics 43 | 44 | ### Creating a peer-to-peer network provider 45 | 46 | - ``init(_:)`` 47 | - ``PeerToPeerProviderConfiguration`` 48 | 49 | ### Configuring the provider 50 | 51 | - ``setDelegate(_:as:with:)`` 52 | - ``startListening(as:)`` 53 | - ``stopListening()`` 54 | - ``setName(_:)`` 55 | 56 | ### Establishing Connections 57 | 58 | - ``connect(to:)`` 59 | - ``disconnect()`` 60 | - ``disconnect(peerId:)`` 61 | - ``NetworkConnectionEndpoint`` 62 | 63 | ### Sending messages 64 | 65 | - ``send(message:to:)`` 66 | 67 | ### Inspecting the provider 68 | 69 | - ``name`` 70 | - ``peerName`` 71 | - ``peeredConnections`` 72 | 73 | ### Receiving ongoing and updated information 74 | 75 | - ``availablePeerPublisher`` 76 | - ``browserStatePublisher`` 77 | - ``listenerStatePublisher`` 78 | - ``connectionPublisher`` 79 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/P2P+AutoConnect.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | import AutomergeRepo 3 | import AutomergeUtilities 4 | import Foundation 5 | import OSLog 6 | import XCTest 7 | 8 | extension RepoPeer2PeerIntegrationTests { 9 | func testAutoConnect() async throws { 10 | // set up repo (with a client-websocket) 11 | let repoAlice = Repo(sharePolicy: SharePolicy.agreeable) 12 | let p2pAlice = PeerToPeerProvider(.init(passcode: "1234")) 13 | await repoAlice.addNetworkAdapter(adapter: p2pAlice) 14 | try await p2pAlice.startListening(as: "Alice") 15 | 16 | // add the document to the Alice repo 17 | let handle: DocHandle = try await repoAlice.create(id: DocumentId()) 18 | try addContent(handle.doc) 19 | 20 | let repoBob = Repo(sharePolicy: SharePolicy.agreeable) 21 | let p2pBob = PeerToPeerProvider(.init(passcode: "1234", autoconnect: true)) 22 | await repoBob.addNetworkAdapter(adapter: p2pBob) 23 | try await p2pBob.startListening(as: "Bob") 24 | 25 | let aliceConnectionExpectation = expectation(description: "Repo 'Alice' sees a connection to Bob") 26 | let bobConnectionExpectation = expectation(description: "Repo 'Bob' sees a connection to Alice") 27 | 28 | let a_c = p2pAlice.connectionPublisher.receive(on: RunLoop.main).sink { connectList in 29 | // Logger.test.critical("TEST: CONNECT LIST FROM ALICE: \(String(describing: connectList))") 30 | if connectList.count == 1, 31 | connectList.contains(where: { connection in 32 | connection.initiated == false && 33 | connection.peered == true && 34 | connection.peerId == repoBob.peerId 35 | }) 36 | { 37 | aliceConnectionExpectation.fulfill() 38 | } 39 | } 40 | XCTAssertNotNil(a_c) 41 | 42 | let b_c = p2pBob.connectionPublisher.receive(on: RunLoop.main).sink { connectList in 43 | // Logger.test.critical("TEST: CONNECT LIST FROM BOB: \(String(describing: connectList))") 44 | if connectList.count == 1, 45 | connectList.contains(where: { connection in 46 | connection.initiated == true && 47 | connection.peered == true && 48 | connection.peerId == repoAlice.peerId 49 | }) 50 | { 51 | bobConnectionExpectation.fulfill() 52 | } 53 | } 54 | XCTAssertNotNil(b_c) 55 | 56 | await fulfillment( 57 | of: [aliceConnectionExpectation, bobConnectionExpectation], 58 | timeout: expectationTimeOut, 59 | enforceOrder: false 60 | ) 61 | 62 | // MARK: cleanup and teardown 63 | 64 | await p2pAlice.disconnect() 65 | await p2pAlice.stopListening() 66 | await p2pBob.disconnect() 67 | await p2pBob.stopListening() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/Providers/PeerToPeerProviderConfiguration.swift: -------------------------------------------------------------------------------- 1 | public import Automerge 2 | internal import Foundation 3 | #if os(iOS) || os(visionOS) 4 | internal import UIKit // for UIDevice.name access 5 | #endif 6 | 7 | /// A type that represents a configuration for a Peer to Peer Network Provider 8 | public struct PeerToPeerProviderConfiguration: Sendable { 9 | let passcode: String 10 | let reconnectOnError: Bool 11 | let autoconnect: Bool 12 | 13 | let recurringNextMessageTimeout: ContinuousClock.Instant.Duration 14 | let waitForPeerTimeout: ContinuousClock.Instant.Duration 15 | 16 | let logLevel: LogVerbosity 17 | /// Creates a new Peer to Peer Network Provider configuration 18 | /// - Parameters: 19 | /// - passcode: A passcode to use as a shared private key to enable TLS encryption 20 | /// - reconnectOnError: A Boolean value that indicates if outgoing connections should attempt to reconnect on 21 | /// - autoconnect: An option Boolean value that indicates wether the connection should automatically attempt to 22 | /// connect to found peers. The default if unset is `true` for iOS , `false` for macOS. 23 | /// - logVerbosity: The verbosity of the logs sent to the unified logging system. 24 | /// - recurringNextMessageTimeout: The timeout to wait for an additional Automerge sync protocol message. 25 | /// - waitForPeerTimeout: The timeout to wait for a peer to respond to a peer request for authorizing the 26 | /// connection. 27 | public init( 28 | passcode: String, 29 | reconnectOnError: Bool = true, 30 | autoconnect: Bool? = nil, 31 | logVerbosity: LogVerbosity = .errorOnly, 32 | recurringNextMessageTimeout: ContinuousClock.Instant.Duration = .seconds(30), 33 | waitForPeerTimeout: ContinuousClock.Instant.Duration = .seconds(5) 34 | ) { 35 | self.reconnectOnError = reconnectOnError 36 | if let auto = autoconnect { 37 | self.autoconnect = auto 38 | } else { 39 | #if os(iOS) || os(visionOS) 40 | self.autoconnect = true 41 | #elseif os(macOS) 42 | self.autoconnect = false 43 | #endif 44 | } 45 | self.passcode = passcode 46 | self.waitForPeerTimeout = waitForPeerTimeout 47 | self.recurringNextMessageTimeout = recurringNextMessageTimeout 48 | self.logLevel = logVerbosity 49 | } 50 | 51 | // MARK: default sharing identity 52 | 53 | /// Returns a default peer to peer sharing identity to broadcast as your human-readable peer name. 54 | public static func defaultSharingIdentity() async -> String { 55 | let defaultName: String 56 | #if os(iOS) || os(visionOS) 57 | defaultName = await MainActor.run(body: { 58 | UIDevice().name 59 | }) 60 | #elseif os(macOS) 61 | defaultName = Host.current().localizedName ?? "Automerge User" 62 | #endif 63 | return defaultName 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/DocumentIdTests.swift: -------------------------------------------------------------------------------- 1 | @testable import AutomergeRepo 2 | import Base58Swift 3 | import XCTest 4 | 5 | final class DocumentIdTests: XCTestCase { 6 | func testInvalidDocumentIdString() async throws { 7 | XCTAssertNil(DocumentId("some random string")) 8 | } 9 | 10 | func testDocumentId() async throws { 11 | let someUUID = UUID() 12 | let id = DocumentId(someUUID) 13 | XCTAssertEqual(id.description, someUUID.bs58String) 14 | } 15 | 16 | func testDocumentIdFromString() async throws { 17 | let someUUID = UUID() 18 | let bs58String = someUUID.bs58String 19 | let id = DocumentId(bs58String) 20 | XCTAssertEqual(id?.description, bs58String) 21 | 22 | let invalidOptionalString: String? = "SomeRandomNonBS58String" 23 | XCTAssertNil(DocumentId(invalidOptionalString)) 24 | 25 | let invalidString = "SomeRandomNonBS58String" 26 | XCTAssertNil(DocumentId(invalidString)) 27 | 28 | let optionalString: String? = bs58String 29 | XCTAssertEqual(DocumentId(optionalString)?.description, bs58String) 30 | 31 | XCTAssertNil(DocumentId(nil)) 32 | } 33 | 34 | func testInvalidTooMuchDataDocumentId() async throws { 35 | let tooBig = [UInt8](UUID().data + UUID().data) 36 | let bs58StringFromData = Base58.base58CheckEncode(tooBig) 37 | let tooLargeOptionalString: String? = bs58StringFromData 38 | XCTAssertNil(DocumentId(bs58StringFromData)) 39 | XCTAssertNil(DocumentId(tooLargeOptionalString)) 40 | 41 | let optionalString: String? = bs58StringFromData 42 | XCTAssertNil(DocumentId(optionalString)) 43 | } 44 | 45 | func testComparisonOnData() async throws { 46 | let first = DocumentId() 47 | let second = DocumentId() 48 | let compareFirstAndSecond = first < second 49 | let compareFirstAndSecondDescription = first.description < second.description 50 | XCTAssertEqual(compareFirstAndSecond, compareFirstAndSecondDescription) 51 | } 52 | 53 | func testStringConversionDocumentId() async throws { 54 | // roughly 2 in 1000 are failing the conversion down and back 55 | for i in 1 ... 1000 { 56 | let new = DocumentId() 57 | let stringOfDocumentId = new.id 58 | let converted = DocumentId(stringOfDocumentId) 59 | XCTAssertNotNil(converted, "id: \(new) [\(new.data.hexEncodedString())] doesn't back convert (try #\(i))") 60 | } 61 | } 62 | 63 | func testExploreFailedStringBackconvert() async throws { 64 | // illustrates a specific example from https://github.com/automerge/automerge-repo-swift/issues/108 65 | let data = "00cf851bc4f4441d86d127c26774145e".data(using: .hexadecimal) 66 | let output = Base58.base58CheckEncode([UInt8](data!)) 67 | XCTAssertEqual(output, "1ezULPhgshBPYi4H2MTBoMKwc3S") 68 | let checkDecode = Base58.base58CheckDecode(output) 69 | XCTAssertNotNil(checkDecode) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/Providers/PeerConnectionInfo.swift: -------------------------------------------------------------------------------- 1 | public import Foundation 2 | 3 | /// A type that represents a snapshot of the current state of a peer-to-peer network connection. 4 | public struct PeerConnectionInfo: Sendable, Identifiable, CustomStringConvertible { 5 | /// A single-line string representation of the connection information. 6 | public var description: String { 7 | var str = "" 8 | if initiated { 9 | str.append(" -> ") 10 | } else { 11 | str.append(" <- ") 12 | } 13 | if let meta = peerMetadata { 14 | str.append("\(peerId),\(meta)") 15 | } else { 16 | str.append("\(peerId),nil") 17 | } 18 | if peered { 19 | str.append(" [ready]") 20 | } else { 21 | str.append(" [pending]") 22 | } 23 | return str 24 | } 25 | 26 | /// The peer ID of the remote end of this connection. 27 | public let peerId: PEER_ID 28 | /// The peer metadata, if any, of the remote end of this connection. 29 | public let peerMetadata: PeerMetadata? 30 | 31 | // additional metadata about the connection that's useful for UI displays 32 | 33 | /// The endpoint of the remote end of the connection, represented as a string. 34 | public let endpoint: String 35 | /// A Boolean value that indicates if this provider initiated the connection. 36 | public let initiated: Bool 37 | /// A Boolean value that indicates the connection is fully established and ready to use. 38 | public let peered: Bool 39 | /// The stable identifier for this snapshot. 40 | public let id: UUID 41 | 42 | /// Create a new peer connection information snapshot. 43 | /// - Parameters: 44 | /// - peerId: The peer ID of the remote end of this connection. 45 | /// - peerMetadata: The peer metadata, if any, of the remote end of this connection. 46 | /// - endpoint: The endpoint of the remote end of the connection, represented as a string. 47 | /// - initiated: A Boolean value that indicates if this provider initiated the connection. 48 | /// - peered: A Boolean value that indicates the connection is fully established and ready to use. 49 | public init(peerId: PEER_ID, peerMetadata: PeerMetadata?, endpoint: String, initiated: Bool, peered: Bool) { 50 | self.peerId = peerId 51 | self.peerMetadata = peerMetadata 52 | self.endpoint = endpoint 53 | self.initiated = initiated 54 | self.peered = peered 55 | self.id = UUID() 56 | } 57 | } 58 | 59 | extension PeerConnectionInfo: Hashable {} 60 | 61 | extension PeerConnectionInfo: Comparable { 62 | /// Compares two snapshots to provide consistent ordering for snapshots. 63 | /// - Parameters: 64 | /// - lhs: The first network information snapshot to compare 65 | /// - rhs: The second network information snapshot to compare. 66 | public static func < (lhs: PeerConnectionInfo, rhs: PeerConnectionInfo) -> Bool { 67 | lhs.peerId < rhs.peerId 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/P2P+peerVisibility.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | import AutomergeRepo 3 | import AutomergeUtilities 4 | import Foundation 5 | import OSLog 6 | import XCTest 7 | 8 | extension RepoPeer2PeerIntegrationTests { 9 | func testPeerVisibility() async throws { 10 | // set up repo (with a client-websocket) 11 | let repoAlice = Repo(sharePolicy: SharePolicy.agreeable) 12 | let p2pAlice = PeerToPeerProvider(.init(passcode: "1234")) 13 | await repoAlice.addNetworkAdapter(adapter: p2pAlice) 14 | try await p2pAlice.startListening(as: "Alice") 15 | 16 | let repoBob = Repo(sharePolicy: SharePolicy.agreeable) 17 | let p2pBob = PeerToPeerProvider(.init(passcode: "1234")) 18 | await repoBob.addNetworkAdapter(adapter: p2pBob) 19 | try await p2pBob.startListening(as: "Bob") 20 | 21 | // add the document to the Alice repo 22 | let handle: DocHandle = try await repoAlice.create(id: DocumentId()) 23 | try addContent(handle.doc) 24 | 25 | // With the websocket protocol, we don't get confirmation of a sync being complete - 26 | // if the other side has everything and nothing new, they just won't send a response 27 | // back. In that case, we don't get any further responses - but we don't _know_ that 28 | // it's complete. In an initial sync there will always be at least one response, but 29 | // we can't quite count on this always being an initial sync... so I'm shimming in a 30 | // short "wait" here to leave the background tasks that receive WebSocket messages 31 | // running to catch any updates, and hoping that'll be enough time to complete it. 32 | 33 | let alicePeersExpectation = expectation(description: "Repo 'Alice' sees two peers") 34 | let bobPeersExpectation = expectation(description: "Repo 'Bob' sees two peers") 35 | 36 | let a = p2pAlice.availablePeerPublisher.receive(on: RunLoop.main).sink { peerList in 37 | if peerList.count >= 2, 38 | peerList.contains(where: { ap in 39 | ap.peerId == repoBob.peerId 40 | }), 41 | peerList.contains(where: { ap in 42 | ap.peerId == repoAlice.peerId 43 | }) 44 | { 45 | alicePeersExpectation.fulfill() 46 | } 47 | } 48 | XCTAssertNotNil(a) 49 | 50 | let b = p2pBob.availablePeerPublisher.receive(on: RunLoop.main).sink { peerList in 51 | if peerList.count >= 2, 52 | peerList.contains(where: { ap in 53 | ap.peerId == repoBob.peerId 54 | }), 55 | peerList.contains(where: { ap in 56 | ap.peerId == repoAlice.peerId 57 | }) 58 | { 59 | bobPeersExpectation.fulfill() 60 | } 61 | } 62 | XCTAssertNotNil(b) 63 | 64 | await fulfillment( 65 | of: [alicePeersExpectation, bobPeersExpectation], 66 | timeout: expectationTimeOut, 67 | enforceOrder: false 68 | ) 69 | 70 | // MARK: cleanup and teardown 71 | 72 | await p2pAlice.disconnect() 73 | await p2pAlice.stopListening() 74 | await p2pBob.disconnect() 75 | await p2pBob.stopListening() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Issues for this library are tracked on GitHub: [https://github.com/automerge/automerge-repo-swift/issues](https://github.com/automerge/automerge-repo-swift/issues) 4 | 5 | Feel free to [join the Automerge Discord Server](https://discord.gg/HrpnPAU5zx), which includes a channel for `#automerge-swift`, for conversation about this library, the [automerge-swift](https://github.com/automerge/automerge-swift) library, or Automerge in general. 6 | 7 | ## Building and Developing 8 | 9 | This is a standard Swift library package. 10 | Use Xcode to open Package.swift, or use swift on the command line: 11 | 12 | ```bash 13 | swift build 14 | swift test 15 | ``` 16 | 17 | The code is written to be fully Swift 6 concurrency compliant, and supports compilation back to Swift 5.9. 18 | 19 | ## Formatting 20 | 21 | This project uses [swiftformat](https://github.com/nicklockwood/SwiftFormat) to maintain consistency of the formatting. 22 | Before submitting any pull requests, please format the code: 23 | 24 | ```bash 25 | swiftformat . 26 | ``` 27 | 28 | ## Integration Tests 29 | 30 | The integration tests work the network adapters provided in the repository with real networking. 31 | They check for a local instance of the javascript automerge-repo sync server, and if available, run. 32 | If the local instance is not available, the tests report as skipped. 33 | 34 | To run a local instance, invoke the following command in another terminal window prior to running the tests: 35 | 36 | ```bash 37 | ./scripts/interop.sh 38 | ``` 39 | 40 | To run the integration test, you can either run them serially: 41 | 42 | ```bash 43 | ./IntegrationTests/scripts/serial_tests.bash 44 | ``` 45 | 46 | Or switch into the IntegrationTests directory and run them in parallel with swift test: 47 | 48 | ```bash 49 | cd IntegrationTests 50 | swift test 51 | ``` 52 | 53 | ## Pull Requests 54 | 55 | ### New Features/Extended Features 56 | 57 | New features should start with [a new issue](https://github.com/automerge/automerge-repo-swift/issues/new), not just a pull request. 58 | 59 | New features should include: 60 | 61 | - at least some coverage by tests 62 | - documentation for all public and internal methods and types 63 | - If the feature is purely internal, and changing how something operates, update the [FutureMe.md](notes/FutureMe.md) notes. 64 | Share what's been updated and how the new system both functions and is intended to function. 65 | 66 | ### Merging Pull Requests 67 | 68 | All of the projects tests must pass before we merge a pull request, with the exception of a pull request that ONLY contains a test update that illustrates an existing bug. 69 | Code should be formatted with `swiftformat` prior to submitting a pull request. 70 | 71 | Integration Tests are not required to pass, but a pull request may be reverted after the fact if the Integration Tests start failing due to the change. 72 | As a general pattern, if the change is related to a networking provider or that interface, verify the change by running the Integration Tests manually and verifying they pass before submitting a pull request. 73 | 74 | ## Building the docs 75 | 76 | The script `./scripts/preview-docs.sh` will run a web server previewing the docs. 77 | This does not pick up all source code changes, so if you are working on documentation, you may need to restart it occasionally. 78 | 79 | ## Releasing Updates 80 | 81 | The full process of releasing updates for the library is detailed in [Release Process](./notes/release-process.md) 82 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``AutomergeRepo`` 2 | 3 | Manage and share a collection of Automerge documents. 4 | 5 | ## Overview 6 | 7 | Automerge Repo provides a coordinator that tracks Automerge documents using a well-defined Document ID. 8 | Without any of the storage or network providers enabled, the repository acts as a collection location for Automerge documents, storing, and accessing them, by a unique Document ID. 9 | The library has a plugin architecture, so in addition to the storage and network providers offered in this library, you can create your own providers to support custom network transports or persistent storage needs. 10 | 11 | With an optional storage provider, the repository manages saving documents to persistent filesystems. 12 | When you use Automerge Repo with a Document-based app, you can use the repository without a storage adapter and let Apple's frameworks manage the saving and loading process to disk. 13 | The filesystem support allows for concurrent updates to a single location by multiple applications without loosing or overwriting changes. 14 | For example, when your app uses a shared location in iCloud drive or DropBox. 15 | 16 | With a network provider, you can connect to servers or other applications on a peer-to-peer network to share documents and synchronize them as changes are made by any collaborators. 17 | Automerge Repo supports multiple active providers, allowing you to both synchronize to a server and to collaborators on a local network. 18 | This library provides a WebSocket network provider for connecting to instances of a server app built using [automerge-repo](https://github.com/automerge/automerge-repo). 19 | This library also provides the types, encoding, and messages to interoperate with the cross-language and cross-platform Automerge sync protocol over any network transport. 20 | 21 | In addition to the WebSocket provider, the library offers a peer-to-peer network provider that both accepts and creates connections to other apps also supporting the peer to peer protocol. 22 | The protocol it uses is the same sync protocol as the WebSocket, using a shared-private-key TLS encrypted socket connection with other local apps. 23 | The peer-to-peer provider uses of Apple's Bonjour technology to connect to other local apps with a shared passcode. 24 | 25 | ## Topics 26 | 27 | ### Managing a collection of Automerge documents 28 | 29 | - ``AutomergeRepo/Repo`` 30 | - ``AutomergeRepo/DocHandle`` 31 | - ``AutomergeRepo/DocumentId`` 32 | - ``AutomergeRepo/EphemeralMessageReceiver`` 33 | - ``AutomergeRepo/AutomergeRepo`` 34 | - ``AutomergeRepo/Errors`` 35 | 36 | ### WebSocket Network Adapter 37 | 38 | - ``AutomergeRepo/WebSocketProvider`` 39 | - ``AutomergeRepo/WebSocketProviderState`` 40 | 41 | ### Peer to Peer Network Adapter 42 | 43 | - ``AutomergeRepo/PeerToPeerProvider`` 44 | - ``AutomergeRepo/PeerToPeerProviderConfiguration`` 45 | - ``AutomergeRepo/PeerConnectionInfo`` 46 | 47 | ### Network Adapters 48 | 49 | - ``AutomergeRepo/NetworkProvider`` 50 | - ``AutomergeRepo/NetworkEventReceiver`` 51 | - ``AutomergeRepo/NetworkAdapterEvents`` 52 | - ``AutomergeRepo/AvailablePeer`` 53 | 54 | ### Automerge-Repo Sync Protocol 55 | 56 | - ``AutomergeRepo/SyncV1Msg`` 57 | - ``PeerMetadata`` 58 | - ``AutomergeRepo/MSG_DOCUMENT_ID`` 59 | - ``AutomergeRepo/PEER_ID`` 60 | - ``AutomergeRepo/STORAGE_ID`` 61 | - ``AutomergeRepo/SYNC_MESSAGE`` 62 | - ``CBORCoder`` 63 | 64 | ### Share Policy 65 | 66 | - ``AutomergeRepo/SharePolicy`` 67 | - ``AutomergeRepo/ShareAuthorizing`` 68 | 69 | ### Storage Adapters 70 | 71 | - ``AutomergeRepo/StorageProvider`` 72 | - ``AutomergeRepo/CHUNK`` 73 | 74 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Errors.swift: -------------------------------------------------------------------------------- 1 | public import Foundation 2 | 3 | /// Describes errors from the repository and providers. 4 | public enum Errors: Sendable { 5 | /// An error with the network provider. 6 | public struct NetworkProviderError: Sendable, LocalizedError { 7 | public var msg: String 8 | public var errorDescription: String? { 9 | "NetworkProviderError: \(msg)" 10 | } 11 | 12 | public init(msg: String) { 13 | self.msg = msg 14 | } 15 | } 16 | 17 | /// The sync protocol requested is unsupported. 18 | public struct UnsupportedProtocolError: Sendable, LocalizedError { 19 | public var msg: String 20 | public var errorDescription: String? { 21 | "Unsupported protocol requested: \(msg)" 22 | } 23 | 24 | public init(msg: String) { 25 | self.msg = msg 26 | } 27 | } 28 | 29 | /// The document in unavailable. 30 | public struct Unavailable: Sendable, LocalizedError { 31 | public let id: DocumentId 32 | public var errorDescription: String? { 33 | "Unknown document Id: \(id)" 34 | } 35 | 36 | public init(id: DocumentId) { 37 | self.id = id 38 | } 39 | } 40 | 41 | /// The document is deleted. 42 | public struct DocDeleted: Sendable, LocalizedError { 43 | public let id: DocumentId 44 | public var errorDescription: String? { 45 | "Document with Id: \(id) has been deleted." 46 | } 47 | 48 | public init(id: DocumentId) { 49 | self.id = id 50 | } 51 | } 52 | 53 | /// A request timed out before completion. 54 | public struct Timeout: Sendable, LocalizedError { 55 | public var errorDescription: String = "Task timed out before completion" 56 | public init(errorDescription: String? = nil) { 57 | if let errorDescription { 58 | self.errorDescription = errorDescription 59 | } 60 | } 61 | } 62 | 63 | /// The connection closed or does not exist. 64 | public struct ConnectionClosed: Sendable, LocalizedError { 65 | public var errorDescription: String = "The connection closed or is nil" 66 | public init(errorDescription: String? = nil) { 67 | if let errorDescription { 68 | self.errorDescription = errorDescription 69 | } 70 | } 71 | } 72 | 73 | /// The URL provided is invalid. 74 | public struct InvalidURL: Sendable, LocalizedError { 75 | public var urlString: String 76 | public var errorDescription: String? { 77 | "Invalid URL: \(urlString)" 78 | } 79 | 80 | public init(urlString: String) { 81 | self.urlString = urlString 82 | } 83 | } 84 | 85 | /// Received an unexpected message. 86 | public struct UnexpectedMsg: Sendable, LocalizedError { 87 | public var msg: String 88 | public var errorDescription: String? { 89 | "Received an unexpected message: \(msg)" 90 | } 91 | 92 | public init(msg: String) { 93 | self.msg = msg 94 | } 95 | } 96 | 97 | /// The ID of the document already exists within the repository 98 | public struct DuplicateID: Sendable, LocalizedError { 99 | public var id: DocumentId 100 | public var errorDescription: String? { 101 | "The ID of the document \(id) already exists within the repository." 102 | } 103 | 104 | public init(id: DocumentId) { 105 | self.id = id 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/NetworkAdapterEvents.swift: -------------------------------------------------------------------------------- 1 | /// Describes the network events that a network provider sends to its delegate. 2 | /// 3 | /// The ``Repo`` and its internal systems are responsible for responding to these events. 4 | /// Send these events when new connections are established, 5 | /// peers are added or removed on a connection, of for ``SyncV1Msg`` messages that are not 6 | /// related to establishing the connection. 7 | public enum NetworkAdapterEvents: Sendable, CustomDebugStringConvertible { 8 | /// A description of the event. 9 | public var debugDescription: String { 10 | switch self { 11 | case let .ready(payload): 12 | "NetworkAdapterEvents.ready[\(payload)]" 13 | case .close: 14 | "NetworkAdapterEvents.close[]" 15 | case let .peerCandidate(payload): 16 | "NetworkAdapterEvents.peerCandidate[\(payload)]" 17 | case let .peerDisconnect(payload): 18 | "NetworkAdapterEvents.peerDisconnect[\(payload)]" 19 | case let .message(payload): 20 | "NetworkAdapterEvents.message[\(payload)]" 21 | } 22 | } 23 | 24 | /// The information associated with an individual peer being disconnected from a network provider. 25 | public struct PeerDisconnectPayload: Sendable, CustomStringConvertible { 26 | /// A string representation of the payload. 27 | public var description: String { 28 | "\(peerId)" 29 | } 30 | 31 | // handled by Repo, relevant to Sync 32 | /// The peer that disconnected. 33 | public let peerId: PEER_ID 34 | 35 | /// Creates a new payload identifying the peer that disconnected. 36 | /// - Parameter peerId: The peer that disconnected. 37 | public init(peerId: PEER_ID) { 38 | self.peerId = peerId 39 | } 40 | } 41 | 42 | /// A network event that indicates a network connection has been established and successfully handshaked. 43 | /// 44 | /// This message is sent by both listening (passive) and initiating (active) connections. 45 | case ready(payload: PeerConnectionInfo) // 46 | /// A network event that indicates a request to close a connection. 47 | case close // handled by Repo, relevant to sync 48 | /// A network event that indicates that a listening network has received a connection with a proposed peer, 49 | /// but the handshake and any authorization process is not yet complete. 50 | case peerCandidate(payload: PeerConnectionInfo) 51 | /// A network event that indicates a connection closed. 52 | case peerDisconnect(payload: PeerDisconnectPayload) // send when a peer connection terminates 53 | /// A network event that passes a protocol message into the repo. 54 | /// 55 | /// The messages sent should be a subset of ``SyncV1Msg``. The provider should accept any message, 56 | /// but the handshake protocol messages (``SyncV1Msg/join(_:)``, ``SyncV1Msg/peer(_:)``) and 57 | /// ``SyncV1Msg/unknown(_:)`` are unexpected. 58 | case message(payload: SyncV1Msg) // handled by Sync 59 | } 60 | 61 | // network connection overview: 62 | // - connection established 63 | // - initiating side sends "join" message 64 | // - receiving side send "peer" message 65 | // ONLY after peer message is received is the connection considered valid 66 | 67 | // for an outgoing connection: 68 | // - network is ready for action 69 | // - connect(to: SOMETHING) 70 | // - when it receives the "peer" message, it's ready for ongoing work 71 | 72 | // for an incoming connection: 73 | // - network is ready for action 74 | // - remove peer opens a connection, we receive a "join" message 75 | // - (peer candidate is known at that point) 76 | // - if all is good (version matches, etc) then we send "peer" message to acknowledge 77 | // - after that, we're ready to process protocol messages 78 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/BaseRepoTests.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | @testable import AutomergeRepo 3 | import AutomergeUtilities 4 | import XCTest 5 | 6 | final class BaseRepoTests: XCTestCase { 7 | var repo: Repo! 8 | 9 | override func setUp() async throws { 10 | repo = Repo(sharePolicy: SharePolicy.agreeable) 11 | } 12 | 13 | func testMostBasicRepoStartingPoints() async throws { 14 | let peers = await repo.peers() 15 | XCTAssertEqual(peers, []) 16 | 17 | let storageId = await repo.storageId() 18 | XCTAssertNil(storageId) 19 | 20 | let knownIds = await repo.documentIds() 21 | XCTAssertEqual(knownIds, []) 22 | } 23 | 24 | func testCreate() async throws { 25 | let newDoc = try await repo.create() 26 | XCTAssertNotNil(newDoc) 27 | let knownIds = await repo.documentIds() 28 | XCTAssertEqual(knownIds.count, 1) 29 | } 30 | 31 | func testCreateWithId() async throws { 32 | let myId = DocumentId() 33 | let handle = try await repo.create(id: myId) 34 | XCTAssertEqual(myId, handle.id) 35 | 36 | let knownIds = await repo.documentIds() 37 | XCTAssertEqual(knownIds.count, 1) 38 | XCTAssertEqual(knownIds[0], myId) 39 | } 40 | 41 | func testImportExistingDoc() async throws { 42 | let newHandleWithDoc = DocHandle(id: DocumentId(), doc: Document()) 43 | let handle = try await repo.import(handle: newHandleWithDoc) 44 | let knownIds = await repo.documentIds() 45 | XCTAssertEqual(knownIds.count, 1) 46 | XCTAssertEqual(knownIds[0], handle.id) 47 | } 48 | 49 | func testFind() async throws { 50 | let myId = DocumentId() 51 | let handle = try await repo.create(id: myId) 52 | XCTAssertEqual(myId, handle.id) 53 | 54 | let foundDoc = try await repo.find(id: myId) 55 | XCTAssertEqual(foundDoc.doc.actor, handle.doc.actor) 56 | } 57 | 58 | func testFindFailed() async throws { 59 | do { 60 | let _ = try await repo.find(id: DocumentId()) 61 | XCTFail() 62 | } catch {} 63 | } 64 | 65 | func testDelete() async throws { 66 | let myId = DocumentId() 67 | let _ = try await repo.create(id: myId) 68 | var knownIds = await repo.documentIds() 69 | XCTAssertEqual(knownIds.count, 1) 70 | 71 | try await repo.delete(id: myId) 72 | knownIds = await repo.documentIds() 73 | XCTAssertEqual(knownIds.count, 0) 74 | 75 | do { 76 | let _ = try await repo.find(id: DocumentId()) 77 | XCTFail() 78 | } catch {} 79 | } 80 | 81 | func testClone() async throws { 82 | let myId = DocumentId() 83 | let handle = try await repo.create(id: myId) 84 | XCTAssertEqual(myId, handle.id) 85 | 86 | let clonedHandle = try await repo.clone(id: myId) 87 | XCTAssertNotEqual(handle.id, clonedHandle.id) 88 | XCTAssertNotEqual(handle.doc.actor, clonedHandle.doc.actor) 89 | 90 | let knownIds = await repo.documentIds() 91 | XCTAssertEqual(knownIds.count, 2) 92 | } 93 | 94 | // TBD: 95 | // - func storageIdForPeer(peerId) -> StorageId 96 | // - func subscribeToRemotes([StorageId]) 97 | 98 | func testAsyncRepoSetup() async throws { 99 | let storage = InMemoryStorage() 100 | let repoA = Repo(sharePolicy: .agreeable, storage: storage) 101 | 102 | let storageId = await repoA.storageId() 103 | XCTAssertNotNil(storageId) 104 | } 105 | 106 | @AutomergeRepo 107 | func testSyncRepoSetup() throws { 108 | let storage = InMemoryStorage() 109 | let repoA = Repo(sharePolicy: .agreeable, storage: storage) 110 | 111 | let storageId = repoA.storageId() 112 | XCTAssertNotNil(storageId) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/P2P+connectAndFind.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | import AutomergeRepo 3 | import AutomergeUtilities 4 | import Foundation 5 | import OSLog 6 | import XCTest 7 | 8 | extension RepoPeer2PeerIntegrationTests { 9 | func testPeerExplicitConnectAndFind() async throws { 10 | // set up repo (with a client-websocket) 11 | let repoAlice = Repo(sharePolicy: SharePolicy.agreeable) 12 | let p2pAlice = PeerToPeerProvider(.init(passcode: "1234")) 13 | await repoAlice.addNetworkAdapter(adapter: p2pAlice) 14 | try await p2pAlice.startListening(as: "Alice") 15 | 16 | let repoBob = Repo(sharePolicy: SharePolicy.agreeable) 17 | let p2pBob = PeerToPeerProvider(.init(passcode: "1234")) 18 | await repoBob.addNetworkAdapter(adapter: p2pBob) 19 | try await p2pBob.startListening(as: "Bob") 20 | 21 | // add the document to the Alice repo 22 | let handle: DocHandle = try await repoAlice.create(id: DocumentId()) 23 | try addContent(handle.doc) 24 | 25 | // With the websocket protocol, we don't get confirmation of a sync being complete - 26 | // if the other side has everything and nothing new, they just won't send a response 27 | // back. In that case, we don't get any further responses - but we don't _know_ that 28 | // it's complete. In an initial sync there will always be at least one response, but 29 | // we can't quite count on this always being an initial sync... so I'm shimming in a 30 | // short "wait" here to leave the background tasks that receive WebSocket messages 31 | // running to catch any updates, and hoping that'll be enough time to complete it. 32 | 33 | let alicePeersExpectation = expectation(description: "Repo 'Alice' sees two peers") 34 | var peerToConnect: AvailablePeer? 35 | 36 | let a = p2pAlice.availablePeerPublisher.receive(on: RunLoop.main).sink { peerList in 37 | if peerList.count >= 2, 38 | peerList.contains(where: { ap in 39 | ap.peerId == repoBob.peerId 40 | }), 41 | peerList.contains(where: { ap in 42 | ap.peerId == repoAlice.peerId 43 | }) 44 | { 45 | // stash away the endpoint so that we can connect to it. 46 | peerToConnect = peerList.first { ap in 47 | ap.peerId != repoAlice.peerId 48 | } 49 | 50 | alicePeersExpectation.fulfill() 51 | } 52 | } 53 | XCTAssertNotNil(a) 54 | 55 | await fulfillment(of: [alicePeersExpectation], timeout: expectationTimeOut, enforceOrder: false) 56 | 57 | // verify the state of documents within each of the two peer repo BEFORE we connect 58 | 59 | var aliceDocs = await repoAlice.documentIds() 60 | XCTAssertEqual(aliceDocs.count, 1) 61 | XCTAssertEqual(aliceDocs[0], handle.id) 62 | var bobDocs = await repoBob.documentIds() 63 | XCTAssertEqual(bobDocs.count, 0) 64 | 65 | // MARK: CONNECT TO PEER 66 | 67 | let unwrappedPeerToConnect = try XCTUnwrap(peerToConnect) 68 | try await p2pAlice.connect(to: unwrappedPeerToConnect.endpoint) 69 | 70 | // verify the state of documents within each of the two peer repo AFTER we connect 71 | 72 | aliceDocs = await repoAlice.documentIds() 73 | XCTAssertEqual(aliceDocs.count, 1) 74 | XCTAssertEqual(aliceDocs[0], handle.id) 75 | bobDocs = await repoBob.documentIds() 76 | XCTAssertEqual(bobDocs.count, 0) 77 | 78 | // MARK: make the explicit request for a document across the peer network 79 | 80 | let requestedDoc = try await repoBob.find(id: handle.id) 81 | XCTAssertEqual(requestedDoc.id, handle.id) 82 | XCTAssertTrue(requestedDoc.doc.equivalentContents(handle.doc)) 83 | 84 | // MARK: cleanup and teardown 85 | 86 | await p2pAlice.disconnect() 87 | await p2pAlice.stopListening() 88 | await p2pBob.disconnect() 89 | await p2pBob.stopListening() 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "automerge-swift", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/automerge/automerge-swift", 7 | "state" : { 8 | "revision" : "c8daf47b5cb77ae5eec451a4e4b77ad6f6db8f5e", 9 | "version" : "0.5.19" 10 | } 11 | }, 12 | { 13 | "identity" : "base58swift", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/heckj/Base58Swift", 16 | "state" : { 17 | "revision" : "3ba16c02089401c817b631ffc33ca8f022a41538", 18 | "version" : "2.1.15" 19 | } 20 | }, 21 | { 22 | "identity" : "bigint", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/attaswift/BigInt.git", 25 | "state" : { 26 | "revision" : "0ed110f7555c34ff468e72e1686e59721f2b0da6", 27 | "version" : "5.3.0" 28 | } 29 | }, 30 | { 31 | "identity" : "float16", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/SusanDoggie/Float16.git", 34 | "state" : { 35 | "revision" : "936ae66adccf1c91bcaeeb9c0cddde78a13695c3", 36 | "version" : "1.1.1" 37 | } 38 | }, 39 | { 40 | "identity" : "potentcodables", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/outfoxx/PotentCodables", 43 | "state" : { 44 | "revision" : "660e33e84e00b9bf07bd41dd99ff800600e435e7", 45 | "version" : "3.5.0" 46 | } 47 | }, 48 | { 49 | "identity" : "regex", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/sharplet/Regex.git", 52 | "state" : { 53 | "revision" : "76c2b73d4281d77fc3118391877efd1bf972f515", 54 | "version" : "2.1.1" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-async-algorithms", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apple/swift-async-algorithms", 61 | "state" : { 62 | "revision" : "da4e36f86544cdf733a40d59b3a2267e3a7bbf36", 63 | "version" : "1.0.0" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-collections", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/apple/swift-collections.git", 70 | "state" : { 71 | "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", 72 | "version" : "1.0.6" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-distributed-tracing", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-distributed-tracing", 79 | "state" : { 80 | "revision" : "6483d340853a944c96dbcc28b27dd10b6c581703", 81 | "version" : "1.1.2" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-docc-plugin", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/apple/swift-docc-plugin", 88 | "state" : { 89 | "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", 90 | "version" : "1.4.3" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-docc-symbolkit", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/swiftlang/swift-docc-symbolkit", 97 | "state" : { 98 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 99 | "version" : "1.0.0" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-numerics", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/apple/swift-numerics", 106 | "state" : { 107 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 108 | "version" : "1.0.2" 109 | } 110 | }, 111 | { 112 | "identity" : "swift-service-context", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/apple/swift-service-context.git", 115 | "state" : { 116 | "revision" : "0c62c5b4601d6c125050b5c3a97f20cce881d32b", 117 | "version" : "1.1.0" 118 | } 119 | } 120 | ], 121 | "version" : 2 122 | } 123 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/Providers/NWParameters+peerSyncParameters.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Portions Copyright © 2022 Apple Inc. 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | 11 | WWDC Video references aligned with this code: 12 | - https://developer.apple.com/videos/play/wwdc2019/713/ 13 | - https://developer.apple.com/videos/play/wwdc2020/10110/ 14 | */ 15 | 16 | internal import CryptoKit 17 | internal import Network 18 | 19 | extension NWParameters { 20 | /// Returns listener and connection network parameters using default TLS for peer to peer connections. 21 | static func peerSyncParameters(passcode: String) -> NWParameters { 22 | let tcpOptions = NWProtocolTCP.Options() 23 | tcpOptions.enableKeepalive = true 24 | tcpOptions.keepaliveIdle = 2 25 | 26 | let params = NWParameters(tls: tlsOptions(passcode: passcode), tcp: tcpOptions) 27 | let syncOptions = NWProtocolFramer.Options(definition: P2PAutomergeSyncProtocol.definition) 28 | params.defaultProtocolStack.applicationProtocols.insert(syncOptions, at: 0) 29 | 30 | params.includePeerToPeer = true 31 | return params 32 | } 33 | 34 | // Create TLS options using a passcode to derive a pre-shared key. 35 | private static func tlsOptions(passcode: String) -> NWProtocolTLS.Options { 36 | let tlsOptions = NWProtocolTLS.Options() 37 | 38 | let authenticationKey = SymmetricKey(data: passcode.data(using: .utf8)!) 39 | let authenticationCode = HMAC.authenticationCode( 40 | for: "MeetingNotes".data(using: .utf8)!, 41 | using: authenticationKey 42 | ) 43 | 44 | let authenticationDispatchData = authenticationCode.withUnsafeBytes { 45 | DispatchData(bytes: $0) 46 | } 47 | 48 | // Private Shared Key (https://datatracker.ietf.org/doc/html/rfc4279) is *not* supported in 49 | // TLS 1.3 [https://tools.ietf.org/html/rfc8446], so this pins the TLS options to use version 1.2: 50 | // @constant tls_protocol_version_TLSv12 TLS 1.2 [https://tools.ietf.org/html/rfc5246] 51 | sec_protocol_options_set_max_tls_protocol_version(tlsOptions.securityProtocolOptions, .TLSv12) 52 | sec_protocol_options_set_min_tls_protocol_version(tlsOptions.securityProtocolOptions, .TLSv12) 53 | 54 | sec_protocol_options_add_pre_shared_key( 55 | tlsOptions.securityProtocolOptions, 56 | authenticationDispatchData as __DispatchData, 57 | stringToDispatchData("MeetingNotes")! as __DispatchData 58 | ) 59 | 60 | /* RFC 5487 - PSK with SHA-256/384 and AES GCM */ 61 | // Forcing non-standard cipher suite value to UInt16 because for 62 | // whatever reason, it can get returned as UInt32 - such as in 63 | // GitHub actions CI. 64 | let ciphersuiteValue = UInt16(TLS_PSK_WITH_AES_128_GCM_SHA256) 65 | sec_protocol_options_append_tls_ciphersuite( 66 | tlsOptions.securityProtocolOptions, 67 | tls_ciphersuite_t(rawValue: ciphersuiteValue)! 68 | ) 69 | return tlsOptions 70 | } 71 | 72 | // Create a utility function to encode strings as preshared key data. 73 | private static func stringToDispatchData(_ string: String) -> DispatchData? { 74 | guard let stringData = string.data(using: .utf8) else { 75 | return nil 76 | } 77 | let dispatchData = stringData.withUnsafeBytes { 78 | DispatchData(bytes: $0) 79 | } 80 | return dispatchData 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/NetworkProvider.swift: -------------------------------------------------------------------------------- 1 | internal import Automerge 2 | 3 | // https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo/src/network/NetworkAdapterInterface.ts 4 | 5 | /// A type that is responsible for establishing, and maintaining, one or more network connection for a repository. 6 | /// 7 | /// Types conforming to `NetworkProvider` are responsible for the setup and initial handshake of network 8 | /// connections to other peers. 9 | /// They provide a means to send messages to other peers, and through a delegate provides messages and updates 10 | /// when connections are made, disconnected, and messages are received from connected peers. 11 | /// The delegate is responsible for processing and responding to sync, gossip, and other messages appropriately. 12 | /// 13 | /// A NetworkProvider can initiate or listen for connections, or support both. 14 | /// 15 | /// The expected behavior when a network provider initiates a connection: 16 | /// 17 | /// - After the underlying transport connection is established due to a call to `connect`, the emit 18 | /// ``NetworkAdapterEvents/ready(payload:)``, which includes a payload that provides information about the peer that is 19 | /// now connected. 20 | /// - After the connection is established, send a ``SyncV1Msg/join(_:)`` message to request peering. 21 | /// - When the NetworkAdapter receives a ``SyncV1Msg/peer(_:)`` message, emit 22 | /// ``NetworkAdapterEvents/peerCandidate(payload:)``. 23 | /// - If the provider receives a message other than `peer`, terminate the connection and emit 24 | /// ``NetworkAdapterEvents/close``. 25 | /// - For any other message, emit it to the delegate using ``NetworkAdapterEvents/message(payload:)``. 26 | /// - When a transport connection is closed, emit ``NetworkAdapterEvents/peerDisconnect(payload:)``. 27 | /// - When `disconnect` is invoked on a network provider, send a ``SyncV1Msg/leave(_:)`` message then terminate 28 | /// the connection, and emit ``NetworkAdapterEvents/close``. 29 | /// 30 | /// A connecting transport may optionally enable automatic reconnection on connection failure. 31 | /// If the provider supports configurable reconnection logic, it should be configured with a `configure` 32 | /// call with the relevant configuration type for the network provider. 33 | /// 34 | /// The expected behavior when listening for, and responding to, an incoming connection: 35 | /// 36 | /// - When a connection is established, emit ``NetworkAdapterEvents/ready(payload:)``. 37 | /// - When the transport receives a `join` message, verify that the protocols being requested are compatible. 38 | /// If it is not, return an ``SyncV1Msg/error(_:)`` message, close the connection, and emit 39 | /// ``NetworkAdapterEvents/close``. 40 | /// - When any other message is received, emit it using ``NetworkAdapterEvents/message(payload:)``. 41 | /// - When the transport receives a `leave` message, close the connection and emit ``NetworkAdapterEvents/close``. 42 | /// 43 | @AutomergeRepo 44 | public protocol NetworkProvider: Sendable { 45 | /// A string that represents the name of the network provider 46 | var name: String { get } 47 | 48 | /// A list of all active, peered connections that the provider is maintaining. 49 | /// 50 | /// For an outgoing connection, this is typically a single connection. 51 | /// For a listening connection, this could be quite a few. 52 | var peeredConnections: [PeerConnectionInfo] { get } 53 | 54 | /// A type that represents the endpoint that the provider can connect with. 55 | /// 56 | /// For example, it could be `URL`, `NWEndpoint` for a Bonjour network, or a custom type. 57 | associatedtype NetworkConnectionEndpoint: Sendable 58 | 59 | /// Initiate an outgoing connection. 60 | func connect(to: NetworkConnectionEndpoint) async throws // aka "activate" 61 | 62 | /// Disconnect and terminate any existing connection. 63 | func disconnect() async // aka "deactivate" 64 | 65 | /// Requests the network transport to send a message. 66 | /// - Parameter message: The message to send. 67 | /// - Parameter to: An option peerId to identify the recipient for the message. If nil, the message is sent to all 68 | /// connected peers. 69 | func send(message: SyncV1Msg, to: PEER_ID?) async 70 | 71 | /// Set the delegate for the peer to peer provider. 72 | /// - Parameters: 73 | /// - delegate: The delegate instance. 74 | /// - peer: The peer ID to use for the peer to peer provider. 75 | /// - metadata: The peer metadata, if any, to use for the peer to peer provider. 76 | /// 77 | /// This is typically called when the delegate adds the provider, and provides this network 78 | /// provider with a peer ID and associated metadata, as well as an endpoint that receives 79 | /// Automerge sync protocol sync message and network events. 80 | func setDelegate(_ delegate: any NetworkEventReceiver, as peer: PEER_ID, with metadata: PeerMetadata?) 81 | } 82 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/InternalDocHandle.swift: -------------------------------------------------------------------------------- 1 | internal import struct Automerge.ChangeHash 2 | internal import class Automerge.Document 3 | internal import struct Automerge.SyncState 4 | internal import struct Foundation.Data 5 | 6 | final class InternalDocHandle { 7 | enum DocHandleState { 8 | case idle 9 | case loading 10 | case requesting 11 | case ready 12 | case unavailable 13 | case deleted 14 | } 15 | 16 | // NOTE: heckj - what I was originally researching how this all goes together, I 17 | // wondered if there wasn't the concept of unloading/reloading the bytes from memory and 18 | // onto disk when there was a storage system available - in that case, we'd need a few 19 | // more states to this diagram (originally from `automerge-repo`) - one for 'purged' and 20 | // an associated action PURGE - the idea being that might be invoked when an app is coming 21 | // under memory pressure. 22 | // 23 | // The state itself is driven from Repo, in the `resolveDocHandle(id:)` method 24 | 25 | /** 26 | * Internally we use a state machine to orchestrate document loading and/or syncing, in order to 27 | * avoid requesting data we already have, or surfacing intermediate values to the consumer. 28 | * 29 | * ┌─────────────────────┬─────────TIMEOUT────►┌─────────────┐ 30 | * ┌───┴─────┐ ┌───┴────────┐ │ unavailable │ 31 | * ┌───────┐ ┌──FIND──┤ loading ├─REQUEST──►│ requesting ├─UPDATE──┐ └─────────────┘ 32 | * │ idle ├──┤ └───┬─────┘ └────────────┘ │ 33 | * └───────┘ │ │ └─►┌────────┐ 34 | * │ └───────LOAD───────────────────────────────►│ ready │ 35 | * └──CREATE───────────────────────────────────────────────►└────────┘ 36 | */ 37 | 38 | let id: DocumentId 39 | var doc: Automerge.Document? 40 | var state: DocHandleState 41 | // Uncomment for a trace/debugging point to see what's updating the state of a DocHandle, useful for setting 42 | // and capturing with a breakpoint... 43 | // { 44 | // willSet { 45 | // Logger.repo.trace("updating state of \(self.id) to \(String(describing: newValue))") 46 | // } 47 | // } 48 | 49 | /// A Boolean value that indicates the document was added to the repository by way of syncing with a remote peer, 50 | /// and hasn't been explicitly asked for by the app using this repository. 51 | var remote: Bool 52 | var remoteHeads: [STORAGE_ID: Set] 53 | var syncStates: [PEER_ID: SyncState] 54 | 55 | // TODO: verify that we want a timeout delay per Document, as opposed to per-Repo 56 | var timeoutDelay: Double 57 | 58 | init( 59 | id: DocumentId, 60 | isNew: Bool, 61 | initialValue: Automerge.Document? = nil, 62 | timeoutDelay: Double = 1.0, 63 | remote: Bool = false 64 | ) { 65 | self.id = id 66 | self.timeoutDelay = timeoutDelay 67 | self.remote = remote 68 | remoteHeads = [:] 69 | syncStates = [:] 70 | // isNew is when we're creating content and it needs to get stored locally in a storage 71 | // provider, if available. 72 | if isNew { 73 | if let newDoc = initialValue { 74 | doc = newDoc 75 | state = .loading 76 | } else { 77 | doc = nil 78 | state = .idle 79 | } 80 | } else if let newDoc = initialValue { 81 | doc = newDoc 82 | state = .ready 83 | } else { 84 | doc = nil 85 | state = .idle 86 | } 87 | } 88 | 89 | var isReady: Bool { 90 | state == .ready 91 | } 92 | 93 | var isDeleted: Bool { 94 | state == .deleted 95 | } 96 | 97 | var isUnavailable: Bool { 98 | state == .unavailable 99 | } 100 | 101 | // not entirely sure why this is holding data about remote heads... convenience? 102 | // why not track within Repo? 103 | func getRemoteHeads(id: STORAGE_ID) async -> Set? { 104 | remoteHeads[id] 105 | } 106 | 107 | func setRemoteHeads(id: STORAGE_ID, heads: Set) { 108 | remoteHeads[id] = heads 109 | } 110 | 111 | // For testing only - snapshot of current state of a DocHandle 112 | struct DocHandleSnapshot { 113 | let docExists: Bool 114 | let id: DocumentId 115 | let state: DocHandleState 116 | let remote: Bool 117 | let remoteHeads: [STORAGE_ID: Set] 118 | let syncStates: [PEER_ID: SyncState] 119 | } 120 | 121 | func snapshot() -> DocHandleSnapshot { 122 | DocHandleSnapshot( 123 | docExists: self.doc != nil, 124 | id: id, 125 | state: state, 126 | remote: remote, 127 | remoteHeads: remoteHeads, 128 | syncStates: syncStates 129 | ) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/StorageSubsystemTests.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | @testable import AutomergeRepo 3 | import AutomergeUtilities 4 | import XCTest 5 | 6 | final class StorageSubsystemTests: XCTestCase { 7 | var subsystem: DocumentStorage! 8 | var testStorageProvider: InMemoryStorage! 9 | 10 | override func setUp() async throws { 11 | let storageProvider = InMemoryStorage() 12 | let incrementalKeys = await storageProvider.incrementalKeys() 13 | let docIds = await storageProvider.storageKeys() 14 | XCTAssertEqual(docIds.count, 0) 15 | XCTAssertEqual(incrementalKeys.count, 0) 16 | 17 | subsystem = DocumentStorage(storageProvider, verbosity: .tracing) 18 | testStorageProvider = storageProvider 19 | } 20 | 21 | func assertCounts(docIds: Int, incrementals: Int) async { 22 | let countOfIncrementalKeys = await testStorageProvider?.incrementalKeys().count 23 | let countOfDocumentIdKeys = await testStorageProvider?.storageKeys().count 24 | XCTAssertEqual(countOfDocumentIdKeys, docIds) 25 | XCTAssertEqual(countOfIncrementalKeys, incrementals) 26 | } 27 | 28 | func docDataSize(id: DocumentId) async -> Int { 29 | await testStorageProvider?.load(id: id)?.count ?? 0 30 | } 31 | 32 | func combinedIncData(id: DocumentId) async -> Int { 33 | if let inc = await testStorageProvider?.loadRange(id: id, prefix: subsystem.chunkNamespace) { 34 | return inc.reduce(0) { partialResult, data in 35 | partialResult + data.count 36 | } 37 | } 38 | return 0 39 | } 40 | 41 | func testSubsystemSetup() async throws { 42 | XCTAssertNotNil(subsystem) 43 | let newDoc = Document() 44 | let newDocId = DocumentId() 45 | 46 | try await subsystem.saveDoc(id: newDocId, doc: newDoc) 47 | await assertCounts(docIds: 0, incrementals: 1) 48 | 49 | let combinedKeys = await testStorageProvider?.incrementalKeys() 50 | XCTAssertEqual(combinedKeys?.count, 1) 51 | XCTAssertEqual(combinedKeys?[0].id, newDocId) 52 | XCTAssertEqual(combinedKeys?[0].prefix, "incrChanges") 53 | let incData: [Data]? = await testStorageProvider?.loadRange(id: newDocId, prefix: "incrChanges") 54 | let incDataUnwrapped = try XCTUnwrap(incData) 55 | XCTAssertEqual(incDataUnwrapped.count, 1) 56 | XCTAssertEqual(incDataUnwrapped[0].count, 0) 57 | 58 | let txt = try newDoc.putObject(obj: .ROOT, key: "words", ty: .Text) 59 | try await subsystem.saveDoc(id: newDocId, doc: newDoc) 60 | 61 | await assertCounts(docIds: 0, incrementals: 1) 62 | var incSize = await combinedIncData(id: newDocId) 63 | XCTAssertEqual(incSize, 58) 64 | 65 | try newDoc.updateText(obj: txt, value: "Hello World!") 66 | try await subsystem.saveDoc(id: newDocId, doc: newDoc) 67 | 68 | await assertCounts(docIds: 1, incrementals: 1) 69 | incSize = await combinedIncData(id: newDocId) 70 | var docSize = await docDataSize(id: newDocId) 71 | XCTAssertEqual(docSize, 176) 72 | XCTAssertEqual(incSize, 0) 73 | 74 | try await subsystem.compact(id: newDocId, doc: newDoc) 75 | 76 | await assertCounts(docIds: 1, incrementals: 1) 77 | incSize = await combinedIncData(id: newDocId) 78 | docSize = await docDataSize(id: newDocId) 79 | XCTAssertEqual(docSize, 176) 80 | XCTAssertEqual(incSize, 0) 81 | // if let incrementals = await testStorageProvider?.loadRange(id: newDocId, prefix: subsystem.chunkNamespace) { 82 | // print(incrementals) 83 | // } 84 | } 85 | 86 | func testSubsystemLoadDoc() async throws { 87 | let newDoc = try RepoHelpers.documentWithData() 88 | let newDocId = DocumentId() 89 | try await subsystem.saveDoc(id: newDocId, doc: newDoc) 90 | 91 | let loadedDoc = try await subsystem.loadDoc(id: newDocId) 92 | let unwrappedDoc = try XCTUnwrap(loadedDoc) 93 | XCTAssertTrue(newDoc.equivalentContents(unwrappedDoc)) 94 | } 95 | 96 | func testSubsystemPurgeDoc() async throws { 97 | let newDoc = try RepoHelpers.documentWithData() 98 | let newDocId = DocumentId() 99 | try await subsystem.saveDoc(id: newDocId, doc: newDoc) 100 | 101 | await assertCounts(docIds: 0, incrementals: 1) 102 | let incSize = await combinedIncData(id: newDocId) 103 | let docSize = await docDataSize(id: newDocId) 104 | XCTAssertEqual(docSize, 0) 105 | XCTAssertEqual(incSize, 106) 106 | 107 | try await subsystem.compact(id: newDocId, doc: newDoc) 108 | await assertCounts(docIds: 1, incrementals: 1) 109 | let compactedIncSize = await combinedIncData(id: newDocId) 110 | let compactedDocSize = await docDataSize(id: newDocId) 111 | XCTAssertEqual(compactedDocSize, 170) 112 | XCTAssertEqual(compactedIncSize, 0) 113 | 114 | try await subsystem.purgeDoc(id: newDocId) 115 | await assertCounts(docIds: 0, incrementals: 1) 116 | let purgedIncSize = await combinedIncData(id: newDocId) 117 | let purgedDocSize = await docDataSize(id: newDocId) 118 | XCTAssertEqual(purgedDocSize, 0) 119 | XCTAssertEqual(purgedIncSize, 0) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Sync/SyncV1Msg.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncV1Msg.swift 3 | // MeetingNotes 4 | // 5 | // Created by Joseph Heck on 1/24/24. 6 | // 7 | 8 | public import Foundation 9 | internal import PotentCBOR 10 | 11 | // Automerge Repo WebSocket sync details: 12 | // https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo-network-websocket/README.md 13 | // explicitly using a protocol version "1" here - make sure to specify (and verify?) that 14 | 15 | // related source for the automerge-repo sync code: 16 | // https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo-network-websocket/src/BrowserWebSocketClientAdapter.ts 17 | // All the WebSocket messages are CBOR encoded and sent as data streams 18 | 19 | /// Describes the possible V1 Automerge sync protocol messages. 20 | public indirect enum SyncV1Msg: Sendable { 21 | // CDDL pre-amble 22 | // ; The base64 encoded bytes of a Peer ID 23 | // peer_id = str 24 | // ; The base64 encoded bytes of a Storage ID 25 | // storage_id = str 26 | // ; The possible protocol versions (currently always the string "1") 27 | // protocol_version = "1" 28 | // ; The bytes of an automerge sync message 29 | // sync_message = bstr 30 | // ; The base58check encoded bytes of a document ID 31 | // document_id = str 32 | 33 | /// The collection of value "type" strings for the V1 automerge-repo protocol. 34 | public enum MsgTypes: Sendable { 35 | public static let peer = "peer" 36 | public static let join = "join" 37 | public static let leave = "leave" 38 | public static let request = "request" 39 | public static let sync = "sync" 40 | public static let ephemeral = "ephemeral" 41 | public static let error = "error" 42 | public static let unavailable = "doc-unavailable" 43 | public static let remoteHeadsChanged = "remote-heads-changed" 44 | public static let remoteSubscriptionChange = "remote-subscription-change" 45 | } 46 | 47 | /// A request for a peer connection. 48 | case peer(PeerMsg) 49 | /// Acknowledging the request for a peer connection. 50 | case join(JoinMsg) 51 | /// A request to terminate a peer connection. 52 | case leave(LeaveMsg) 53 | /// An error response. 54 | case error(ErrorMsg) 55 | /// A request to find an Automerge document. 56 | case request(RequestMsg) 57 | /// A request to synchronize an Automerge document. 58 | case sync(SyncMsg) 59 | /// A response to a request for a document that indicates the document is not available. 60 | case unavailable(UnavailableMsg) 61 | /// An app-specific message for other network connected peers. 62 | case ephemeral(EphemeralMsg) 63 | // gossip additions 64 | /// A message that indicate an update to a subscription to remote document changes. 65 | case remoteSubscriptionChange(RemoteSubscriptionChangeMsg) 66 | /// A notification that updates occurred on a network peer. 67 | case remoteHeadsChanged(RemoteHeadsChangedMsg) 68 | 69 | // fall-through scenario - unknown message 70 | /// An unknown message. 71 | /// 72 | /// These are typically ignored, and are not guaranteed to be processed. 73 | case unknown(Data) 74 | 75 | /// Copies a message and returns an updated version with the targetId of message set to a specific target. 76 | /// - Parameter peer: The peer to set as the targetId for the message. 77 | /// - Returns: The updated message. 78 | public func setTarget(_ peer: PEER_ID) -> Self { 79 | switch self { 80 | case .peer(_), .join(_), .leave(_), .request(_), .sync(_), .unavailable(_), .error(_), .unknown: 81 | return self 82 | case let .ephemeral(msg): 83 | var copy = msg 84 | copy.targetId = peer 85 | return .ephemeral(copy) 86 | case let .remoteSubscriptionChange(msg): 87 | var copy = msg 88 | copy.targetId = peer 89 | return .remoteSubscriptionChange(copy) 90 | case let .remoteHeadsChanged(msg): 91 | var copy = msg 92 | copy.targetId = peer 93 | return .remoteHeadsChanged(copy) 94 | } 95 | } 96 | } 97 | 98 | extension SyncV1Msg: CustomDebugStringConvertible { 99 | public var debugDescription: String { 100 | switch self { 101 | case let .peer(interior_msg): 102 | interior_msg.debugDescription 103 | case let .join(interior_msg): 104 | interior_msg.debugDescription 105 | case let .leave(interior_msg): 106 | interior_msg.debugDescription 107 | case let .error(interior_msg): 108 | interior_msg.debugDescription 109 | case let .request(interior_msg): 110 | interior_msg.debugDescription 111 | case let .sync(interior_msg): 112 | interior_msg.debugDescription 113 | case let .unavailable(interior_msg): 114 | interior_msg.debugDescription 115 | case let .ephemeral(interior_msg): 116 | interior_msg.debugDescription 117 | case let .remoteSubscriptionChange(interior_msg): 118 | interior_msg.debugDescription 119 | case let .remoteHeadsChanged(interior_msg): 120 | interior_msg.debugDescription 121 | case let .unknown(data): 122 | "UNKNOWN[data: \(data.hexEncodedString(uppercase: false))]" 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/Repo_OneClient_WebsocketIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | import AutomergeRepo 3 | import AutomergeUtilities 4 | import OSLog 5 | import XCTest 6 | 7 | // NOTE(heckj): This integration test expects that you have a websocket server with the 8 | // Automerge-repo sync protocol running at localhost:3030. If you're testing from the local 9 | // repository, run the `./scripts/interop.sh` script to start up a local instance to 10 | // respond. 11 | 12 | final class Repo_OneClient_WebsocketIntegrationTests: XCTestCase { 13 | private static let subsystem = Bundle.main.bundleIdentifier! 14 | 15 | static let test = Logger(subsystem: subsystem, category: "WebSocketSyncIntegrationTests") 16 | let syncDestination = "ws://localhost:3030/" 17 | // Switch to the following line to run a test against the public hosted automerge-repo instance 18 | // let syncDestination = "wss://sync.automerge.org/" 19 | 20 | override func setUp() async throws { 21 | let isWebSocketConnectable = await webSocketAvailable(destination: syncDestination) 22 | try XCTSkipUnless(isWebSocketConnectable, "websocket unavailable for integration test") 23 | } 24 | 25 | override func tearDown() async throws { 26 | // teardown 27 | } 28 | 29 | func webSocketAvailable(destination: String) async -> Bool { 30 | guard let url = URL(string: destination) else { 31 | Self.test.error("invalid URL: \(destination, privacy: .public) - endpoint unavailable") 32 | return false 33 | } 34 | // establishes the websocket 35 | let request = URLRequest(url: url) 36 | let ws: URLSessionWebSocketTask = URLSession.shared.webSocketTask(with: request) 37 | ws.resume() 38 | Self.test.info("websocket to \(destination, privacy: .public) prepped, sending ping") 39 | do { 40 | try await ws.sendPing() 41 | Self.test.info("PING OK - returning true") 42 | ws.cancel(with: .normalClosure, reason: nil) 43 | return true 44 | } catch { 45 | Self.test.error("PING FAILED: \(error.localizedDescription, privacy: .public) - returning false") 46 | ws.cancel(with: .abnormalClosure, reason: nil) 47 | return false 48 | } 49 | } 50 | 51 | func testSyncAndFind() async throws { 52 | // document structure for test 53 | struct ExampleStruct: Identifiable, Codable, Hashable { 54 | let id: UUID 55 | var title: String 56 | var discussion: AutomergeText 57 | 58 | init(title: String, discussion: String) { 59 | id = UUID() 60 | self.title = title 61 | self.discussion = AutomergeText(discussion) 62 | } 63 | } 64 | 65 | // set up repo (with a client-websocket) 66 | let repo = Repo(sharePolicy: SharePolicy.agreeable) 67 | let websocket = WebSocketProvider() 68 | await repo.addNetworkAdapter(adapter: websocket) 69 | 70 | // add the document to the repo 71 | let handle: DocHandle = try await repo.create(id: DocumentId()) 72 | 73 | // initial setup and encoding of Automerge doc to sync it 74 | let encoder = AutomergeEncoder(doc: handle.doc) 75 | let model = ExampleStruct(title: "new item", discussion: "editable text") 76 | try encoder.encode(model) 77 | 78 | let url = try XCTUnwrap(URL(string: syncDestination)) 79 | try await websocket.connect(to: url) 80 | 81 | // With the websocket protocol, we don't get confirmation of a sync being complete - 82 | // if the other side has everything and nothing new, they just won't send a response 83 | // back. In that case, we don't get any further responses - but we don't _know_ that 84 | // it's complete. In an initial sync there will always be at least one response, but 85 | // we can't quite count on this always being an initial sync... so I'm shimming in a 86 | // short "wait" here to leave the background tasks that receive WebSocket messages 87 | // running to catch any updates, and hoping that'll be enough time to complete it. 88 | try await Task.sleep(for: .seconds(5)) 89 | await websocket.disconnect() 90 | 91 | // Create a second, empty repo that doesn't have the document and request it 92 | 93 | // set up repo (with a client-websocket) 94 | let repoTwo = Repo(sharePolicy: SharePolicy.agreeable) 95 | let websocketTwo = WebSocketProvider() 96 | await repoTwo.addNetworkAdapter(adapter: websocketTwo) 97 | 98 | // connect the repo to the external automerge-repo 99 | try await websocketTwo.connect(to: url) 100 | 101 | let foundDocHandle = try await repoTwo.find(id: handle.id) 102 | XCTAssertEqual(foundDocHandle.id, handle.id) 103 | XCTAssertTrue(foundDocHandle.doc.equivalentContents(handle.doc)) 104 | } 105 | 106 | func testFindWithRandomId() async throws { 107 | let repo = Repo(sharePolicy: SharePolicy.agreeable) 108 | let websocket = WebSocketProvider(.init(reconnectOnError: false, loggingAt: .tracing)) 109 | await repo.addNetworkAdapter(adapter: websocket) 110 | 111 | let url = try XCTUnwrap(URL(string: syncDestination)) 112 | try await websocket.connect(to: url) 113 | 114 | let randomId = DocumentId() 115 | do { 116 | let _ = try await repo.find(id: randomId) 117 | XCTFail("Repo shouldn't return a new, empty document for a random Document ID") 118 | } catch let error as Errors.Unavailable { 119 | XCTAssertEqual(error.id, randomId) 120 | } catch { 121 | XCTFail("Unknown error returned") 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/ObservingChangesTest.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | @testable import AutomergeRepo 3 | import OSLog 4 | import XCTest 5 | 6 | final class ObservingChangesTest: XCTestCase { 7 | let network = InMemoryNetwork.shared 8 | var repoOne: Repo! 9 | var repoTwo: Repo! 10 | 11 | var adapterOne: InMemoryNetworkEndpoint! 12 | var adapterTwo: InMemoryNetworkEndpoint! 13 | 14 | override func setUp() async throws { 15 | await network.resetTestNetwork() 16 | repoOne = Repo(sharePolicy: SharePolicy.readonly) 17 | adapterOne = await network.createNetworkEndpoint( 18 | config: .init( 19 | listeningNetwork: false, 20 | name: "EndpointOne" 21 | ) 22 | ) 23 | await repoOne.addNetworkAdapter(adapter: adapterOne) 24 | 25 | repoTwo = Repo(sharePolicy: SharePolicy.agreeable) 26 | adapterTwo = await network.createNetworkEndpoint( 27 | config: .init( 28 | listeningNetwork: true, 29 | name: "EndpointTwo" 30 | ) 31 | ) 32 | await repoTwo.addNetworkAdapter(adapter: adapterTwo) 33 | 34 | let connections = await network.connections() 35 | XCTAssertEqual(connections.count, 0) 36 | 37 | let endpointRecount = await network.endpoints 38 | XCTAssertEqual(endpointRecount.count, 2) 39 | } 40 | 41 | override func tearDown() async throws {} 42 | 43 | // func testCheckForFlake() async throws { 44 | // for i in 1...1000 { 45 | // Logger.testNetwork.error("\(i)") 46 | // try await self.setUp() 47 | // try await flakeCheck_testCreateAndObserveChange() 48 | // } 49 | // } 50 | 51 | func testCreateAndObserveChange() async throws { 52 | // initial conditions 53 | var knownOnTwo = await repoTwo.documentIds() 54 | var knownOnOne = await repoOne.documentIds() 55 | XCTAssertEqual(knownOnOne.count, 0) 56 | XCTAssertEqual(knownOnTwo.count, 0) 57 | 58 | // Create and add some doc content to the "server" repo - RepoTwo 59 | let newDocId = DocumentId() 60 | let newDoc = try await repoTwo.create(id: newDocId) 61 | 62 | // add some content to the new document 63 | try newDoc.doc.put(obj: .ROOT, key: "title", value: .String("INITIAL VALUE")) 64 | 65 | XCTAssertNotNil(newDoc) 66 | knownOnTwo = await repoTwo.documentIds() 67 | XCTAssertEqual(knownOnTwo.count, 1) 68 | XCTAssertEqual(knownOnTwo[0], newDocId) 69 | 70 | knownOnOne = await repoOne.documentIds() 71 | XCTAssertEqual(knownOnOne.count, 0) 72 | 73 | // "GO ONLINE" 74 | // await network.traceConnections(true) 75 | // await adapterTwo.logReceivedMessages(true) 76 | 77 | var sendExpectationTriggered = false 78 | let twoSendSyncExpectation = expectation(description: "Repo Two should attempt to sync when repo one connects") 79 | let two_sink = repoTwo.syncRequestPublisher.sink { syncRequest in 80 | // Logger.testNetwork.error("SYNC PUB: \(syncRequest.id) peer: \(syncRequest.peer)") 81 | if syncRequest.id == newDocId, syncRequest.peer == self.repoOne.peerId { 82 | if !sendExpectationTriggered { 83 | sendExpectationTriggered = true 84 | twoSendSyncExpectation.fulfill() 85 | } 86 | } 87 | } 88 | XCTAssertNotNil(twoSendSyncExpectation) 89 | 90 | var recvExpectationTriggered = false 91 | let oneReceiveSyncExpectation = 92 | expectation(description: "Repo One should receive a sync request when repo one connects") 93 | let one_sink = repoOne.syncRequestPublisher.sink { syncRequest in 94 | // Logger.testNetwork.error("SYNC PUB: \(syncRequest.id) peer: \(syncRequest.peer)") 95 | if syncRequest.id == newDocId, syncRequest.peer == self.repoTwo.peerId { 96 | if !recvExpectationTriggered { 97 | recvExpectationTriggered = true 98 | oneReceiveSyncExpectation.fulfill() 99 | } 100 | } 101 | } 102 | XCTAssertNotNil(oneReceiveSyncExpectation) 103 | 104 | try await adapterOne.connect(to: "EndpointTwo") 105 | 106 | await fulfillment(of: [twoSendSyncExpectation, oneReceiveSyncExpectation], timeout: 10) 107 | two_sink.cancel() 108 | one_sink.cancel() 109 | 110 | knownOnOne = await repoOne.documentIds() 111 | if knownOnOne.count >= 1 { 112 | XCTAssertEqual(knownOnOne.count, 1) 113 | XCTAssertEqual(knownOnOne[0], newDocId) 114 | } else { 115 | XCTFail("Repo 1 doesn't have any known document Ids yet") 116 | } 117 | 118 | // Now verify that Two will attempt to sync AGAIN when the content of the document has changed 119 | var contentChangeSyncTriggered = false 120 | let twoSyncOnContentExpectation = 121 | expectation(description: "Repo Two should attempt to sync when the content changes") 122 | let two_sink_content = repoTwo.syncRequestPublisher.sink { syncRequest in 123 | if syncRequest.id == newDocId, syncRequest.peer == self.repoOne.peerId { 124 | // Logger.testNetwork.error("SYNC PUB: \(syncRequest.id) peer: \(syncRequest.peer)") 125 | if !contentChangeSyncTriggered { 126 | contentChangeSyncTriggered = true 127 | twoSyncOnContentExpectation.fulfill() 128 | } 129 | } 130 | } 131 | XCTAssertNotNil(twoSyncOnContentExpectation) 132 | // making this change _should_ trigger the initial repo to sync 133 | try newDoc.doc.put(obj: .ROOT, key: "title", value: .String("UPDATED VALUE")) 134 | 135 | await fulfillment(of: [twoSyncOnContentExpectation], timeout: 10) 136 | two_sink_content.cancel() 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/P2P+reconnect.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | import AutomergeRepo 3 | import AutomergeUtilities 4 | import Foundation 5 | import OSLog 6 | import XCTest 7 | 8 | extension RepoPeer2PeerIntegrationTests { 9 | func testPeerExplicitConnectAndReconnect() async throws { 10 | // set up repo (with a client-websocket) 11 | let repoAlice = Repo(sharePolicy: SharePolicy.agreeable) 12 | let p2pAlice = PeerToPeerProvider(.init(passcode: "1234")) 13 | await repoAlice.addNetworkAdapter(adapter: p2pAlice) 14 | try await p2pAlice.startListening(as: "Alice") 15 | 16 | let repoBob = Repo(sharePolicy: SharePolicy.agreeable) 17 | let p2pBob = PeerToPeerProvider(.init(passcode: "1234")) 18 | await repoBob.addNetworkAdapter(adapter: p2pBob) 19 | try await p2pBob.startListening(as: "Bob") 20 | 21 | // add the document to the Alice repo 22 | let handle: DocHandle = try await repoAlice.create(id: DocumentId()) 23 | try addContent(handle.doc) 24 | 25 | // With the websocket protocol, we don't get confirmation of a sync being complete - 26 | // if the other side has everything and nothing new, they just won't send a response 27 | // back. In that case, we don't get any further responses - but we don't _know_ that 28 | // it's complete. In an initial sync there will always be at least one response, but 29 | // we can't quite count on this always being an initial sync... so I'm shimming in a 30 | // short "wait" here to leave the background tasks that receive WebSocket messages 31 | // running to catch any updates, and hoping that'll be enough time to complete it. 32 | 33 | let alicePeersExpectation = expectation(description: "Repo 'Alice' sees two peers") 34 | let aliceConnectionExpectation = expectation(description: "Repo 'Alice' sees a connection to Bob") 35 | let bobConnectionExpectation = expectation(description: "Repo 'Bob' sees a connection to Alice") 36 | var peerToConnect: AvailablePeer? 37 | 38 | let a = p2pAlice.availablePeerPublisher.receive(on: RunLoop.main).sink { peerList in 39 | if peerList.count >= 2, 40 | peerList.contains(where: { ap in 41 | ap.peerId == repoBob.peerId 42 | }), 43 | peerList.contains(where: { ap in 44 | ap.peerId == repoAlice.peerId 45 | }) 46 | { 47 | // stash away the endpoint so that we can connect to it. 48 | peerToConnect = peerList.first { ap in 49 | ap.peerId != repoAlice.peerId 50 | } 51 | 52 | alicePeersExpectation.fulfill() 53 | } 54 | } 55 | XCTAssertNotNil(a) 56 | 57 | await fulfillment(of: [alicePeersExpectation], timeout: expectationTimeOut, enforceOrder: false) 58 | 59 | let a_c = p2pAlice.connectionPublisher.receive(on: RunLoop.main).sink { connectList in 60 | if connectList.count == 1, 61 | connectList.contains(where: { connection in 62 | connection.initiated == true && 63 | connection.peered == true && 64 | connection.peerId == repoBob.peerId 65 | }) 66 | { 67 | aliceConnectionExpectation.fulfill() 68 | } 69 | } 70 | XCTAssertNotNil(a_c) 71 | 72 | let b_c = p2pBob.connectionPublisher.receive(on: RunLoop.main).sink { connectList in 73 | if connectList.count == 1, 74 | connectList.contains(where: { connection in 75 | connection.initiated == false && 76 | connection.peered == true && 77 | connection.peerId == repoAlice.peerId 78 | }) 79 | { 80 | bobConnectionExpectation.fulfill() 81 | } 82 | } 83 | XCTAssertNotNil(b_c) 84 | 85 | // verify the state of documents within each of the two peer repo BEFORE we connect 86 | 87 | let aliceDocs = await repoAlice.documentIds() 88 | XCTAssertEqual(aliceDocs.count, 1) 89 | XCTAssertEqual(aliceDocs[0], handle.id) 90 | let bobDocs = await repoBob.documentIds() 91 | XCTAssertEqual(bobDocs.count, 0) 92 | 93 | // MARK: CONNECT TO PEER 94 | 95 | let unwrappedPeerToConnect = try XCTUnwrap(peerToConnect) 96 | try await p2pAlice.connect(to: unwrappedPeerToConnect.endpoint) 97 | 98 | await fulfillment( 99 | of: [aliceConnectionExpectation, bobConnectionExpectation], 100 | timeout: expectationTimeOut, 101 | enforceOrder: false 102 | ) 103 | 104 | // Terminate the sinks to avoid a second invocation of fulfill on the earlier expectations 105 | a_c.cancel() 106 | b_c.cancel() 107 | 108 | // MARK: FORCE DISCONNECT 109 | 110 | await p2pBob.disconnect() 111 | 112 | let aliceReConnectionExpectation = expectation(description: "Repo 'Alice' sees a connection to Bob") 113 | let bobReConnectionExpectation = expectation(description: "Repo 'Bob' sees a connection to Alice") 114 | 115 | let a_c2 = p2pAlice.connectionPublisher.receive(on: RunLoop.main).sink { connectList in 116 | if connectList.count == 1, 117 | connectList.contains(where: { connection in 118 | connection.initiated == true && 119 | connection.peered == true && 120 | connection.peerId == repoBob.peerId 121 | }) 122 | { 123 | aliceReConnectionExpectation.fulfill() 124 | } 125 | } 126 | XCTAssertNotNil(a_c2) 127 | 128 | let b_c2 = p2pBob.connectionPublisher.receive(on: RunLoop.main).sink { connectList in 129 | if connectList.count == 1, 130 | connectList.contains(where: { connection in 131 | connection.initiated == false && 132 | connection.peered == true && 133 | connection.peerId == repoAlice.peerId 134 | }) 135 | { 136 | bobReConnectionExpectation.fulfill() 137 | } 138 | } 139 | XCTAssertNotNil(b_c2) 140 | 141 | await fulfillment( 142 | of: [aliceReConnectionExpectation, bobReConnectionExpectation], 143 | timeout: expectationTimeOut, 144 | enforceOrder: false 145 | ) 146 | 147 | // MARK: cleanup and teardown 148 | 149 | await p2pAlice.disconnect() 150 | await p2pAlice.stopListening() 151 | await p2pBob.disconnect() 152 | await p2pBob.stopListening() 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /IntegrationTests/Tests/IntegrationTestsTests/P2P+explicitConnect.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | @testable import AutomergeRepo 3 | import AutomergeUtilities 4 | import Foundation 5 | import OSLog 6 | import XCTest 7 | 8 | extension RepoPeer2PeerIntegrationTests { 9 | func testPeerExplicitConnect() async throws { 10 | // set up repo (with a client-websocket) 11 | let repoAlice = Repo(sharePolicy: SharePolicy.agreeable) 12 | let p2pAlice = PeerToPeerProvider(.init(passcode: "1234")) 13 | await repoAlice.addNetworkAdapter(adapter: p2pAlice) 14 | try await p2pAlice.startListening(as: "Alice") 15 | 16 | let repoBob = Repo(sharePolicy: SharePolicy.agreeable) 17 | let p2pBob = PeerToPeerProvider(.init(passcode: "1234")) 18 | await repoBob.addNetworkAdapter(adapter: p2pBob) 19 | try await p2pBob.startListening(as: "Bob") 20 | 21 | // add the document to the Alice repo 22 | let handle: DocHandle = try await repoAlice.create(id: DocumentId()) 23 | try addContent(handle.doc) 24 | 25 | // With the websocket protocol, we don't get confirmation of a sync being complete - 26 | // if the other side has everything and nothing new, they just won't send a response 27 | // back. In that case, we don't get any further responses - but we don't _know_ that 28 | // it's complete. In an initial sync there will always be at least one response, but 29 | // we can't quite count on this always being an initial sync... so I'm shimming in a 30 | // short "wait" here to leave the background tasks that receive WebSocket messages 31 | // running to catch any updates, and hoping that'll be enough time to complete it. 32 | 33 | let alicePeersExpectation = expectation(description: "Repo 'Alice' sees two peers") 34 | let aliceConnectionExpectation = expectation(description: "Repo 'Alice' sees a connection to Bob") 35 | let bobConnectionExpectation = expectation(description: "Repo 'Bob' sees a connection to Alice") 36 | let bobDocHandleExpectation = expectation(description: "Repo 'Bob' sees the now available dochandle") 37 | 38 | var peerToConnect: AvailablePeer? 39 | 40 | let a = p2pAlice.availablePeerPublisher.receive(on: RunLoop.main).sink { peerList in 41 | if peerList.count >= 2, 42 | peerList.contains(where: { ap in 43 | ap.peerId == repoBob.peerId 44 | }), 45 | peerList.contains(where: { ap in 46 | ap.peerId == repoAlice.peerId 47 | }) 48 | { 49 | // stash away the endpoint so that we can connect to it. 50 | peerToConnect = peerList.first { ap in 51 | ap.peerId != repoAlice.peerId 52 | } 53 | 54 | alicePeersExpectation.fulfill() 55 | } 56 | } 57 | XCTAssertNotNil(a) 58 | 59 | await fulfillment(of: [alicePeersExpectation], timeout: expectationTimeOut, enforceOrder: false) 60 | 61 | let a_c = p2pAlice.connectionPublisher.receive(on: RunLoop.main).sink { connectList in 62 | if connectList.count == 1, 63 | connectList.contains(where: { connection in 64 | connection.initiated == true && 65 | connection.peered == true && 66 | connection.peerId == repoBob.peerId 67 | }) 68 | { 69 | aliceConnectionExpectation.fulfill() 70 | } 71 | } 72 | XCTAssertNotNil(a_c) 73 | 74 | let b_c = p2pBob.connectionPublisher.receive(on: RunLoop.main).sink { connectList in 75 | if connectList.count == 1, 76 | connectList.contains(where: { connection in 77 | connection.initiated == false && 78 | connection.peered == true && 79 | connection.peerId == repoAlice.peerId 80 | }) 81 | { 82 | bobConnectionExpectation.fulfill() 83 | } 84 | } 85 | XCTAssertNotNil(b_c) 86 | 87 | // verify the state of documents within each of the two peer repo BEFORE we connect 88 | 89 | var aliceDocs = await repoAlice.documentIds() 90 | XCTAssertEqual(aliceDocs.count, 1) 91 | XCTAssertEqual(aliceDocs[0], handle.id) 92 | var bobDocs = await repoBob.documentIds() 93 | XCTAssertEqual(bobDocs.count, 0) 94 | 95 | // MARK: CONNECT TO PEER 96 | 97 | let unwrappedPeerToConnect = try XCTUnwrap(peerToConnect) 98 | try await p2pAlice.connect(to: unwrappedPeerToConnect.endpoint) 99 | 100 | await fulfillment( 101 | of: [aliceConnectionExpectation, bobConnectionExpectation], 102 | timeout: expectationTimeOut, 103 | enforceOrder: false 104 | ) 105 | 106 | // MARK: waiting for the sync, happening in the background, to fully replicate the doc from Alice to Bob 107 | 108 | // This is sort of stupid one-time latch fix to the fact that the test publisher generates a LOT of updates, 109 | // but we're supposed to call "fulfill()" on the expectation only once. So we latch it - and the first time 110 | // we match it, we'll call fulfull(), otherwise we'll just happily pass it by... 111 | var foundDocAtBobYet = false 112 | 113 | let b_d = repoBob.docHandlePublisher.receive(on: RunLoop.main).sink { docHandleSnap in 114 | if docHandleSnap.id == handle.id { 115 | Logger.test 116 | .info( 117 | "TEST: \(docHandleSnap.id) docExists:\(docHandleSnap.docExists) state:\(String(describing: docHandleSnap.state))" 118 | ) 119 | } 120 | if docHandleSnap.docExists, docHandleSnap.id == handle.id, docHandleSnap.state == .ready { 121 | if !foundDocAtBobYet { 122 | bobDocHandleExpectation.fulfill() 123 | foundDocAtBobYet = true 124 | } 125 | } 126 | } 127 | XCTAssertNotNil(b_d) 128 | 129 | await fulfillment( 130 | of: [bobDocHandleExpectation], 131 | timeout: expectationTimeOut, 132 | enforceOrder: false 133 | ) 134 | 135 | b_d.cancel() 136 | 137 | // verify the state of documents within each of the two peer repo AFTER we connect 138 | 139 | aliceDocs = await repoAlice.documentIds() 140 | XCTAssertEqual(aliceDocs.count, 1) 141 | XCTAssertEqual(aliceDocs[0], handle.id) 142 | 143 | bobDocs = await repoBob.documentIds() 144 | XCTAssertEqual(bobDocs.count, 1) 145 | XCTAssertEqual(bobDocs[0], handle.id) 146 | 147 | // MARK: cleanup and teardown 148 | 149 | await p2pAlice.disconnect() 150 | await p2pAlice.stopListening() 151 | await p2pBob.disconnect() 152 | await p2pBob.stopListening() 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /Tests/AutomergeRepoTests/TestNetworkProviders/TestOutgoingNetworkProvider.swift: -------------------------------------------------------------------------------- 1 | import Automerge 2 | import AutomergeRepo 3 | import Foundation 4 | 5 | public struct TestOutgoingNetworkConfiguration: Sendable, CustomDebugStringConvertible { 6 | let remotePeer: PEER_ID 7 | let remotePeerMetadata: PeerMetadata? 8 | let msgResponse: @Sendable (SyncV1Msg) async -> SyncV1Msg? 9 | 10 | public var debugDescription: String { 11 | "peer: \(remotePeer), metadata: \(remotePeerMetadata?.debugDescription ?? "none")" 12 | } 13 | 14 | init( 15 | remotePeer: PEER_ID, 16 | remotePeerMetadata: PeerMetadata?, 17 | msgResponse: @Sendable @escaping (SyncV1Msg) async -> SyncV1Msg 18 | ) { 19 | self.remotePeer = remotePeer 20 | self.remotePeerMetadata = remotePeerMetadata 21 | self.msgResponse = msgResponse 22 | } 23 | 24 | public static let simple: @Sendable (SyncV1Msg) async -> SyncV1Msg? = { msg in 25 | var doc = Document() 26 | var syncState = SyncState() 27 | let peerId: PEER_ID = "SIMPLE REMOTE TEST" 28 | let peerMetadata: PeerMetadata? = PeerMetadata(storageId: "SIMPLE STORAGE", isEphemeral: true) 29 | switch msg { 30 | case let .join(msg): 31 | return .peer(.init( 32 | senderId: peerId, 33 | targetId: msg.senderId, 34 | storageId: peerMetadata?.storageId, 35 | ephemeral: peerMetadata?.isEphemeral ?? false 36 | )) 37 | case .peer: 38 | return nil 39 | case .leave: 40 | return nil 41 | case .error: 42 | return nil 43 | case let .request(msg): 44 | // everything is always unavailable 45 | return .unavailable(.init(documentId: msg.documentId, senderId: peerId, targetId: msg.senderId)) 46 | case let .sync(msg): 47 | do { 48 | try doc.receiveSyncMessage(state: syncState, message: msg.data) 49 | if let returnData = doc.generateSyncMessage(state: syncState) { 50 | return .sync(.init( 51 | documentId: msg.documentId, 52 | senderId: peerId, 53 | targetId: msg.senderId, 54 | sync_message: returnData 55 | )) 56 | } 57 | } catch { 58 | return .error(.init(message: error.localizedDescription)) 59 | } 60 | return nil 61 | case .unavailable: 62 | return nil 63 | case .ephemeral: 64 | return nil // TODO: RESPONSE EXAMPLE 65 | case .remoteSubscriptionChange: 66 | return nil 67 | case .remoteHeadsChanged: 68 | return nil 69 | case .unknown: 70 | return nil 71 | } 72 | } 73 | } 74 | 75 | /// A Test network that operates in memory 76 | /// 77 | /// Acts akin to an outbound connection - doesn't "connect" and trigger messages until you explicitly ask 78 | @AutomergeRepo 79 | public final class TestOutgoingNetworkProvider: NetworkProvider { 80 | public let name = "MockProvider" 81 | public var peeredConnections: [PeerConnectionInfo] = [] 82 | 83 | public typealias NetworkConnectionEndpoint = String 84 | 85 | public nonisolated var debugDescription: String { 86 | "TestOutgoingNetworkProvider" 87 | } 88 | 89 | public nonisolated var description: String { 90 | "TestNetwork" 91 | } 92 | 93 | var delegate: (any NetworkEventReceiver)? 94 | 95 | var config: TestOutgoingNetworkConfiguration? 96 | var connected: Bool 97 | var messages: [SyncV1Msg] = [] 98 | 99 | public typealias ProviderConfiguration = TestOutgoingNetworkConfiguration 100 | 101 | init() { 102 | connected = false 103 | delegate = nil 104 | } 105 | 106 | public func configure(_ config: TestOutgoingNetworkConfiguration) async { 107 | self.config = config 108 | } 109 | 110 | public var connectedPeer: PEER_ID? { 111 | get async { 112 | if let config, connected == true { 113 | return config.remotePeer 114 | } 115 | return nil 116 | } 117 | } 118 | 119 | public func connect(to somewhere: String) async throws { 120 | do { 121 | guard let config else { 122 | throw UnconfiguredTestNetwork() 123 | } 124 | let initialPeerConnection = PeerConnectionInfo( 125 | peerId: config.remotePeer, 126 | peerMetadata: config.remotePeerMetadata, 127 | endpoint: somewhere, 128 | initiated: true, 129 | peered: false 130 | ) 131 | 132 | peeredConnections.append(initialPeerConnection) 133 | await delegate?.receiveEvent( 134 | event: .peerCandidate( 135 | payload: initialPeerConnection 136 | ) 137 | ) 138 | try await Task.sleep(for: .milliseconds(250)) 139 | let finalPeerConnection = PeerConnectionInfo( 140 | peerId: config.remotePeer, 141 | peerMetadata: config.remotePeerMetadata, 142 | endpoint: somewhere, 143 | initiated: true, 144 | peered: true 145 | ) 146 | await delegate?.receiveEvent( 147 | event: .ready( 148 | payload: finalPeerConnection 149 | ) 150 | ) 151 | connected = true 152 | 153 | } catch { 154 | connected = false 155 | } 156 | } 157 | 158 | public func disconnect() async { 159 | connected = false 160 | } 161 | 162 | public func ready() async -> Bool { 163 | connected 164 | } 165 | 166 | public func send(message: SyncV1Msg, to _: PEER_ID?) async { 167 | messages.append(message) 168 | if let response = await config?.msgResponse(message) { 169 | await delegate?.receiveEvent(event: .message(payload: response)) 170 | } 171 | } 172 | 173 | public func receiveMessage(msg _: SyncV1Msg) async { 174 | // no-op on the receive, as all "responses" are generated by a closure provided 175 | // by the configuration of this test network provider. 176 | } 177 | 178 | public func setDelegate( 179 | _ delegate: any NetworkEventReceiver, 180 | as _: PEER_ID, 181 | with _: PeerMetadata? 182 | ) { 183 | self.delegate = delegate 184 | } 185 | 186 | // MARK: TESTING SPECIFIC API 187 | 188 | public func disconnectNow() async { 189 | guard let config else { 190 | fatalError("Attempting to disconnect an unconfigured testing network") 191 | } 192 | if connected { 193 | connected = false 194 | await delegate?.receiveEvent(event: .peerDisconnect(payload: .init(peerId: config.remotePeer))) 195 | } 196 | } 197 | 198 | public func messagesReceivedByRemotePeer() async -> [SyncV1Msg] { 199 | messages 200 | } 201 | 202 | /// WIPES TEST NETWORK AND ERASES DELEGATE SETTING 203 | public func resetTestNetwork() async { 204 | guard config != nil else { 205 | fatalError("Attempting to reset an unconfigured testing network") 206 | } 207 | connected = false 208 | messages = [] 209 | delegate = nil 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Networking/Providers/P2PAutomergeSyncProtocol.swift: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Copyright © 2022 Apple Inc. 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | 11 | WWDC Video references aligned with this code: 12 | - https://developer.apple.com/videos/play/wwdc2019/713/ 13 | - https://developer.apple.com/videos/play/wwdc2020/10110/ 14 | */ 15 | 16 | /* 17 | * 18 | * PENDING PORT TO REPO/NETWORK PROVIDER FOR PEER NETWORKING 19 | * 20 | */ 21 | 22 | internal import Foundation 23 | internal import Network 24 | internal import OSLog 25 | 26 | /// The type of sync message for the Automerge network sync protocol. 27 | enum P2PSyncMessageType: UInt32 { 28 | case unknown = 0 // msg isn't a recognized type 29 | case syncV1data = 1 // msg is generated sync data to merge into an Automerge document 30 | } 31 | 32 | /// The definition of the Automerge network sync protocol. 33 | class P2PAutomergeSyncProtocol: NWProtocolFramerImplementation { 34 | // Create a global definition of your game protocol to add to connections. 35 | static let definition = NWProtocolFramer.Definition(implementation: P2PAutomergeSyncProtocol.self) 36 | 37 | // Set a name for your protocol for use in debugging. 38 | static var label: String { "AutomergeSync" } 39 | 40 | static var bonjourType: String { "_automergesync._tcp" } 41 | static var applicationService: String { "AutomergeSync" } 42 | 43 | // Set the default behavior for most framing protocol functions. 44 | required init(framer _: NWProtocolFramer.Instance) {} 45 | func start(framer _: NWProtocolFramer.Instance) -> NWProtocolFramer.StartResult { .ready } 46 | func wakeup(framer _: NWProtocolFramer.Instance) {} 47 | func stop(framer _: NWProtocolFramer.Instance) -> Bool { true } 48 | func cleanup(framer _: NWProtocolFramer.Instance) {} 49 | 50 | // Whenever the application sends a message, add your protocol header and forward the bytes. 51 | func handleOutput( 52 | framer: NWProtocolFramer.Instance, 53 | message: NWProtocolFramer.Message, 54 | messageLength: Int, 55 | isComplete _: Bool 56 | ) { 57 | // Extract the type of message. 58 | let type = message.syncMessageType 59 | 60 | // Create a header using the type and length. 61 | let header = P2PAutomergeSyncProtocolHeader(type: type.rawValue, length: UInt32(messageLength)) 62 | 63 | // Write the header. 64 | framer.writeOutput(data: header.encodedData) 65 | 66 | // Ask the connection to insert the content of the app message after your header. 67 | do { 68 | try framer.writeOutputNoCopy(length: messageLength) 69 | } catch { 70 | Logger.peer2peer.error("Error writing protocol data into frame: \(error, privacy: .public)") 71 | } 72 | } 73 | 74 | // Whenever new bytes are available to read, try to parse out your message format. 75 | func handleInput(framer: NWProtocolFramer.Instance) -> Int { 76 | while true { 77 | // Try to read out a single header. 78 | var tempHeader: P2PAutomergeSyncProtocolHeader? = nil 79 | let headerSize = P2PAutomergeSyncProtocolHeader.encodedSize 80 | let parsed = framer.parseInput( 81 | minimumIncompleteLength: headerSize, 82 | maximumLength: headerSize 83 | ) { buffer, _ -> Int in 84 | guard let buffer else { 85 | return 0 86 | } 87 | if buffer.count < headerSize { 88 | return 0 89 | } 90 | tempHeader = P2PAutomergeSyncProtocolHeader(buffer) 91 | return headerSize 92 | } 93 | 94 | // If you can't parse out a complete header, stop parsing and return headerSize, 95 | // which asks for that many more bytes. 96 | guard parsed, let header = tempHeader else { 97 | return headerSize 98 | } 99 | 100 | // Create an object to deliver the message. 101 | var messageType = P2PSyncMessageType.unknown 102 | if let parsedMessageType = P2PSyncMessageType(rawValue: header.type) { 103 | messageType = parsedMessageType 104 | } 105 | let message = NWProtocolFramer.Message(syncMessageType: messageType) 106 | 107 | // Deliver the body of the message, along with the message object. 108 | if !framer.deliverInputNoCopy(length: Int(header.length), message: message, isComplete: true) { 109 | return 0 110 | } 111 | } 112 | } 113 | } 114 | 115 | // Extend framer messages to handle storing your command types in the message metadata. 116 | extension NWProtocolFramer.Message { 117 | /// Create a new protocol-framed message for the Automerge network sync protocol. 118 | /// - Parameter syncMessageType: The type of sync message for this Automerge peer to peer sync protocol 119 | convenience init(syncMessageType: P2PSyncMessageType) { 120 | self.init(definition: P2PAutomergeSyncProtocol.definition) 121 | self["SyncMessageType"] = syncMessageType 122 | } 123 | 124 | /// The type of sync message. 125 | var syncMessageType: P2PSyncMessageType { 126 | if let type = self["SyncMessageType"] as? P2PSyncMessageType { 127 | type 128 | } else { 129 | .unknown 130 | } 131 | } 132 | } 133 | 134 | // Define a protocol header structure to help encode and decode bytes. 135 | 136 | /// The Automerge network sync protocol header structure. 137 | struct P2PAutomergeSyncProtocolHeader: Codable { 138 | let type: UInt32 139 | let length: UInt32 140 | 141 | init(type: UInt32, length: UInt32) { 142 | self.type = type 143 | self.length = length 144 | } 145 | 146 | init(_ buffer: UnsafeMutableRawBufferPointer) { 147 | var tempType: UInt32 = 0 148 | var tempLength: UInt32 = 0 149 | withUnsafeMutableBytes(of: &tempType) { typePtr in 150 | typePtr.copyMemory(from: UnsafeRawBufferPointer( 151 | start: buffer.baseAddress!.advanced(by: 0), 152 | count: MemoryLayout.size 153 | )) 154 | } 155 | withUnsafeMutableBytes(of: &tempLength) { lengthPtr in 156 | lengthPtr 157 | .copyMemory(from: UnsafeRawBufferPointer( 158 | start: buffer.baseAddress! 159 | .advanced(by: MemoryLayout.size), 160 | count: MemoryLayout.size 161 | )) 162 | } 163 | type = tempType 164 | length = tempLength 165 | } 166 | 167 | var encodedData: Data { 168 | var tempType = type 169 | var tempLength = length 170 | var data = Data(bytes: &tempType, count: MemoryLayout.size) 171 | data.append(Data(bytes: &tempLength, count: MemoryLayout.size)) 172 | return data 173 | } 174 | 175 | static var encodedSize: Int { 176 | MemoryLayout.size * 2 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Resources/wss_peered.svg: -------------------------------------------------------------------------------- 1 |
new
handshake
closed
peered
-------------------------------------------------------------------------------- /Sources/AutomergeRepo/Documentation.docc/Resources/wss_handshake.svg: -------------------------------------------------------------------------------- 1 |
new
handshake
closed
peered
--------------------------------------------------------------------------------