Repository: nerdsupremacist/Sync Branch: main Commit: 037435e554eb Files: 40 Total size: 78.1 KB Directory structure: gitextract_5xfba8u5/ ├── .gitignore ├── .swiftpm/ │ └── xcode/ │ └── package.xcworkspace/ │ └── contents.xcworkspacedata ├── Changelog.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources/ │ └── Sync/ │ ├── Connection/ │ │ └── Connection.swift │ ├── EventBus.swift │ ├── Events/ │ │ ├── EventCodingContext.swift │ │ ├── InternalEvent.swift │ │ ├── JSONEventCodingContext.swift │ │ ├── MessagePackEventCodingContext.swift │ │ └── PathComponent.swift │ ├── Extensions.swift │ ├── Strategies/ │ │ ├── Implementation/ │ │ │ ├── Basics/ │ │ │ │ ├── Array.swift │ │ │ │ ├── Codable.swift │ │ │ │ ├── Optional.swift │ │ │ │ ├── String.swift │ │ │ │ └── Utils/ │ │ │ │ ├── Equivalence/ │ │ │ │ │ ├── AnyEquivalenceDetector.swift │ │ │ │ │ ├── EquatableEquivalenceDetector.swift │ │ │ │ │ ├── EquivalenceDetector.swift │ │ │ │ │ ├── ErasedEquivalenceDetector.swift │ │ │ │ │ ├── OptionalEquivalenceDetector.swift │ │ │ │ │ ├── ReferenceEquivalenceDetector.swift │ │ │ │ │ └── extractEquivalenceDetector.swift │ │ │ │ └── extractStrategy.swift │ │ │ ├── Synced Objects/ │ │ │ │ └── SyncableObjectStrategy.swift │ │ │ └── Type Erasure/ │ │ │ ├── AnySyncStrategy.swift │ │ │ └── ErasedSyncStrategy.swift │ │ ├── SyncStrategy.swift │ │ └── SyncableType.swift │ ├── SwiftUI/ │ │ ├── Reconnection/ │ │ │ ├── ReconnectionStrategy.swift │ │ │ └── TryAgainReconnectionStrategy.swift │ │ ├── Sync.swift │ │ └── SyncedObject.swift │ ├── SyncManager.swift │ ├── SyncableObject.swift │ └── Synced.swift └── Tests/ └── SyncTests/ └── SyncTests.swift ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## User settings xcuserdata/ ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) *.xcscmblueprint *.xccheckout ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) build/ DerivedData/ *.moved-aside *.pbxuser !default.pbxuser *.mode1v3 !default.mode1v3 *.mode2v3 !default.mode2v3 *.perspectivev3 !default.perspectivev3 ## Obj-C/Swift specific *.hmap ## App packaging *.ipa *.dSYM.zip *.dSYM ## Playgrounds timeline.xctimeline playground.xcworkspace # Swift Package Manager # # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins # Package.resolved # *.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project # .swiftpm .build/ # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However # you should judge for yourself, the pros and cons are mentioned at: # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control # # Pods/ # # Add this line if you want to avoid checking in source code from the Xcode workspace # *.xcworkspace # Carthage # # Add this line if you want to avoid checking in source code from Carthage dependencies. # Carthage/Checkouts Carthage/Build/ # Accio dependency management Dependencies/ .accio/ # fastlane # # It is recommended to not store the screenshots in the git repo. # Instead, use fastlane to re-generate the screenshots whenever they are needed. # For more information about the recommended setup visit: # https://docs.fastlane.tools/best-practices/source-control/#source-control fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output # Code Injection # # After new code Injection tools there's a generated folder /iOSInjectionProject # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ ================================================ FILE: .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: Changelog.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [2.0.0] - Unreleased ### Added - Added default Message Pack based event coding context. - Connections no longer need to specify a coding context. It will use the Message Pack Context by default. - Added `EventBus`, which acts like `Synced`, except it only allows to send messages, and won't persist any values. - Added `SyncedWriteRights`, which will ### Changed - **Breaking Compatibility with 1.1.0:** Changes to strings are now encoded into smaller changes, reducing the size of transmission. But this change makes it incompatible with 1.1.0 since the payloads transmitted have now changed. - **Breaking Compatibility with 1.1.0:** Other changes in internals event encoding/decoding which make this version not compatible with 1.1.0. Please make sure that you upgrade all usages at the same time. ## [1.1.0] - 2022-02-27 ### Added - Added projected value to `Synced` property wrapper, to access value change publisher - Exporting imports of Combine/OpenCombine ## [1.0.1] - 2022-02-21 ### Fixed - Fixed compiler errors on non Apple Platforms ## [1.0.1] - 2022-02-20 ### Fixed - Fixed UI update issues when using `Sync` - Fixed projected value of `SyncedObject` not being visible ## [1.0.0] - 2022-02-20 ### Added - Added `valueChange` publisher to `Synced`, to listen for changes to the value - Added getter for `connection` to `SyncedObject` - Added support for getting a `SyncedObbject` from a parent `SyncedObject` via dynamic member lookup - Added `SyncManager.reconnect` method to restard connection - Added `ReconnectionStrategy` in order to attempt to resume the session after being disconnected ## Changed - **Breaking:** Renamed `SyncedObject` protocol to `SyncableObject`. To be consistent with `ObservableObject` - **Breaking:** Renamed `SyncedObservedObject` to `SyncedObject`. To be consistent with `ObservedObject` - **Breaking:** Projected Value of `SyncedObject` is of type now `SyncedObject` - **Breaking:** Renamed `SyncableObject.manager` to `sync` - **Breaking:** Renamed `SyncableObject.managerWithoutRetainingInMemory` to `syncWithoutRetainingInMemory` ## [0.1.0] - 2022-02-21 ### Added - Support for OpenCombine ### Changed - Improved handling of updates to array ## [0.1.0] - 2022-02-20 ### Added - Initial Release ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022 Mathias Quintero Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Package.resolved ================================================ { "object": { "pins": [ { "package": "AssociatedTypeRequirementsKit", "repositoryURL": "https://github.com/nerdsupremacist/AssociatedTypeRequirementsKit.git", "state": { "branch": null, "revision": "2e4c49c21ffb2135f1c99fbfcf2119c9d24f5e8c", "version": "0.3.2" } }, { "package": "MessagePack", "repositoryURL": "https://github.com/Flight-School/MessagePack.git", "state": { "branch": null, "revision": "bbc5ab6362db234f2051e73e67296ebf5c3d2042", "version": "1.2.4" } }, { "package": "OpenCombine", "repositoryURL": "https://github.com/OpenCombine/OpenCombine.git", "state": { "branch": null, "revision": "9cf67e363738dbab61b47fb5eaed78d3db31e5ee", "version": "0.13.0" } } ] }, "version": 1 } ================================================ FILE: Package.swift ================================================ // swift-tools-version:5.5 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Sync", platforms: [.macOS(.v11), .iOS(.v14), .watchOS(.v6), .tvOS(.v14)], products: [ .library( name: "Sync", targets: ["Sync"]), ], dependencies: [ .package(url: "https://github.com/OpenCombine/OpenCombine.git", from: "0.13.0"), .package(url: "https://github.com/nerdsupremacist/AssociatedTypeRequirementsKit.git", .upToNextMinor(from: "0.3.2")), .package(url: "https://github.com/Flight-School/MessagePack.git", from: "1.2.4"), ], targets: [ .target( name: "Sync", dependencies: [ .product(name: "OpenCombineShim", package: "OpenCombine"), .product(name: "AssociatedTypeRequirementsKit", package: "AssociatedTypeRequirementsKit"), .product(name: "MessagePack", package: "MessagePack"), ]), .testTarget( name: "SyncTests", dependencies: ["Sync"]), ] ) ================================================ FILE: README.md ================================================

Sync

Swift Package Manager Twitter: @nerdsupremacist

# Sync Sync is a proof of concept for expanding on the Magic of ObservableObject, and making it work over the network. This let's you create real-time Apps in which your Observable Objects are in sync across multiple devices. This has a lot of applications just as: - IoT Apps - Multi-Player Mini Games - etc. As of right now Sync works out of the box using WebSockets, however, it's not limited to web sockets and it allows for multiple kinds of connections. Some possible connections could be: - Bluetooth - Multi-Peer - MQTT - etc. The sky is the limit! **Warning:** This is only a proof of concept that I'm using for experimentation. I assume there's lots and lots of bugs in there... ## Installation ### Swift Package Manager You can install Sync via [Swift Package Manager](https://swift.org/package-manager/) by adding the following line to your `Package.swift`: ```swift import PackageDescription let package = Package( [...] dependencies: [ .package(url: "https://github.com/nerdsupremacist/Sync.git", from: "1.0.0") ] ) ``` ## Usage If you have ever used Observable Object, then Sync will be extremely easy to use. For this example we will create an app with a Switch that everyone can flip on or off as they like. We will build this using SwiftUI, WebSockets and a Vapor Server. Final code available [here](https://github.com/nerdsupremacist/SyncExampleApp).

Sync

For this we will need a few additional packages: - [Vapor](https://vapor.codes): To create a Web Server that will sync our devices - [SyncWebSocketClient](https://github.com/nerdsupremacist/SyncWebSocketClient): The client code for using WebSockets - [SyncWebSocketVapor](https://github.com/nerdsupremacist/SyncWebSocketVapor): A utility to make it easy to serve our object via a WebSocket Let's start by building our shared ViewModel. This is easy, instead of using `ObservableObject` we use `SyncableObject`. And instead of `Published` we use `Synced`: ```swift class ViewModel: SyncableObject { @Synced var toggle: Bool = false init() { } } ``` This ViewModel needs to be both on your App codebase as well as on the Server codebase. I recommend putting it in a shared Swift Package, if you're feeling fancy. Next stop is to create our server. In this example every client will be using the exact same ViewModel. So we're creating a Vapor application, and using `syncObjectOverWebSocket` to provide the object: ```swift import Vapor import SyncWebSocketVapor let app = Application(try .detect()) let viewModel = ViewModel() app.syncObjectOverWebSocket("view_model") { _ in return viewModel } try app.run() ``` For our SwiftUI App, we need to use two things: - @SyncedObject: Like [ObservedObject](https://developer.apple.com/documentation/swiftui/observedobject), but for Syncable Objects. It's a property wrapper that will dynamically tell SwiftUI when to update the UI - Sync: A little wrapper view to start the remote session Our actual view then uses SyncedObservedObject with our ViewModel ```swift struct ContentView: View { @SyncedObject var viewModel: ViewModel var body: some View { Toggle("A toggle", isOn: $viewModel.toggle) .animation(.easeIn, value: viewModel.toggle) .padding(64) } } ``` And in order to display it we use Sync, and pass along the Web Socket Connection: ```swift struct RootView: View { var body: some View { Sync(ViewModel.self, using: .webSocket(url: url)) { viewModel in ContentView(viewModel: viewModel) } } } ``` ### Developing for Web? No problem. You can scale this solution to the web using [Tokamak](https://github.com/TokamakUI/Tokamak), and use the same UI on the Web thanks to Web Assembly. Here are the Web Assembly specific packages for Sync: - [SyncTokamak](https://github.com/nerdsupremacist/SyncTokamak): Compatibility Layer so that Tokamak reacts to updates - [SyncWebSocketWebAssemblyClient](https://github.com/nerdsupremacist/SyncWebSocketWebAssemblyClient): Web Assembly compatible version of [SyncWebSocketClient](https://github.com/nerdsupremacist/SyncWebSocketClient) Here's a small demo of that working:

Sync

## Contributions Contributions are welcome and encouraged! ## License Sync is available under the MIT license. See the LICENSE file for more info. ================================================ FILE: Sources/Sync/Connection/Connection.swift ================================================ import Foundation @_exported import OpenCombineShim public protocol Connection { var isConnected: Bool { get } var isConnectedPublisher: AnyPublisher { get } var codingContext: EventCodingContext { get } func disconnect() func send(data: Data) func receive() -> AnyPublisher } extension Connection { public var codingContext: EventCodingContext { return .default } } public protocol ConsumerConnection: Connection { func connect() async throws -> Data } public protocol ProducerConnection: Connection { } ================================================ FILE: Sources/Sync/EventBus.swift ================================================ import Foundation import OpenCombineShim public enum EventBusWriteOwnership: Int8, Codable { case shared case producer case consumer } public final class EventBus: Codable { enum EventBusEventHandlingError: Error { case writeAtSubpathNotAllowed case deleteNotAllowed case insertNotAllowed } private let ownership: EventBusWriteOwnership private let canWrite: Bool private let outgoingEventSubject = PassthroughSubject() private let incomingEventSubject = PassthroughSubject() public var events: AnyPublisher { return incomingEventSubject.eraseToAnyPublisher() } private init(ownership: EventBusWriteOwnership, canWrite: Bool) { self.ownership = ownership self.canWrite = canWrite } public convenience init(for ownership: EventBusWriteOwnership = .shared) { self.init(ownership: ownership, canWrite: ownership != .consumer) } public convenience init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let ownership = try container.decode(EventBusWriteOwnership.self) self.init(ownership: ownership, canWrite: ownership != .producer) } public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(ownership) } public func send(_ event: Event) { outgoingEventSubject.send(event) } } extension EventBus: SelfContainedStrategy { func handle(event: InternalEvent, from context: ConnectionContext) throws { switch event { case .write(let path, let data) where path.isEmpty: let event = try context.codingContext.decode(data: data, as: Event.self) incomingEventSubject.send(event) case .write: throw EventBusEventHandlingError.writeAtSubpathNotAllowed case .delete: throw EventBusEventHandlingError.deleteNotAllowed case .insert: throw EventBusEventHandlingError.insertNotAllowed } } func events(for context: ConnectionContext) -> AnyPublisher { return outgoingEventSubject .compactMap { event in guard let data = try? context.codingContext.encode(event) else { return nil } return .write([], data) } .eraseToAnyPublisher() } } ================================================ FILE: Sources/Sync/Events/EventCodingContext.swift ================================================ import Foundation public protocol EventCodingContext { func decode(data: Data, as type: T.Type) throws -> T func encode(_ value: T) throws -> Data } ================================================ FILE: Sources/Sync/Events/InternalEvent.swift ================================================ import Foundation enum InternalEvent { case delete([PathComponent], width: Int = 1) case write([PathComponent], Data) case insert([PathComponent], index: Int, Data) func oneLevelLower() -> InternalEvent { switch self { case .write(let path, let data): return .write(Array(path.dropFirst()), data) case .delete(let path, let width): return .delete(Array(path.dropFirst()), width: width) case .insert(let path, let index, let data): return .insert(Array(path.dropFirst()), index: index, data) } } func prefix(by index: Int) -> InternalEvent { switch self { case .write(let path, let data): return .write([.index(index)] + path, data) case .delete(let path, let width): return .delete([.index(index)] + path, width: width) case .insert(let path, let insertionIndex, let data): return .insert([.index(index)] + path, index: insertionIndex, data) } } func prefix(by label: String) -> InternalEvent { switch self { case .write(let path, let data): return .write([.name(label)] + path, data) case .delete(let path, let width): return .delete([.name(label)] + path, width: width) case .insert(let path, let insertionIndex, let data): return .insert([.name(label)] + path, index: insertionIndex, data) } } } extension PathComponent: Codable { private enum Kind: Int8, Codable { case label case index } init(from decoder: Decoder) throws { var container = try decoder.unkeyedContainer() switch try container.decode(Kind.self) { case .label: self = .name(try container.decode(String.self)) case .index: self = .index(try container.decode(Int.self)) } assert(container.isAtEnd) } func encode(to encoder: Encoder) throws { var container = encoder.unkeyedContainer() switch self { case .name(let name): try container.encode(Kind.label) try container.encode(name) case .index(let index): try container.encode(Kind.index) try container.encode(index) } } } extension InternalEvent: Codable { private enum Kind: Int8, Codable { case write case delete case insert } init(from decoder: Decoder) throws { var container = try decoder.unkeyedContainer() switch try container.decode(Kind.self) { case .write: self = .write(try container.decode([PathComponent].self), try container.decode(Data.self)) case .delete: let path = try container.decode([PathComponent].self) let width = container.isAtEnd ? 1 : try container.decode(Int.self) self = .delete(path, width: width) case .insert: self = .insert(try container.decode([PathComponent].self), index: try container.decode(Int.self), try container.decode(Data.self)) } assert(container.isAtEnd) } func encode(to encoder: Encoder) throws { var container = encoder.unkeyedContainer() switch self { case .write(let path, let data): try container.encode(Kind.write) try container.encode(path) try container.encode(data) case .delete(let path, let width): try container.encode(Kind.delete) try container.encode(path) if width != 1 { try container.encode(width) } case .insert(let path, let index, let data): try container.encode(Kind.insert) try container.encode(path) try container.encode(index) try container.encode(data) } } } ================================================ FILE: Sources/Sync/Events/JSONEventCodingContext.swift ================================================ import Foundation extension EventCodingContext where Self == JSONEventCodingContext { public static var json: EventCodingContext { return JSONEventCodingContext() } } public struct JSONEventCodingContext: EventCodingContext { private let encoder = JSONEncoder() private let decoder = JSONDecoder() public init() { } public func decode(data: Data, as type: T.Type) throws -> T where T : Decodable { return try decoder.decode(type, from: data) } public func encode(_ value: T) throws -> Data where T : Encodable { return try encoder.encode(value) } } ================================================ FILE: Sources/Sync/Events/MessagePackEventCodingContext.swift ================================================ import Foundation @_implementationOnly import MessagePack extension EventCodingContext where Self == MessagePackEventCodingContext { public static var messagePack: EventCodingContext { return MessagePackEventCodingContext() } public static var `default`: EventCodingContext { return .messagePack } } public struct MessagePackEventCodingContext: EventCodingContext { private let encoder = MessagePackEncoder() private let decoder = MessagePackDecoder() public init() { } public func decode(data: Data, as type: T.Type) throws -> T where T : Decodable { return try decoder.decode(type, from: data) } public func encode(_ value: T) throws -> Data where T : Encodable { return try encoder.encode(value) } } ================================================ FILE: Sources/Sync/Events/PathComponent.swift ================================================ import Foundation enum PathComponent { case name(String) case index(Int) } ================================================ FILE: Sources/Sync/Extensions.swift ================================================ import Foundation @_exported import OpenCombineShim extension Sequence where Element: Publisher { func mergeMany() -> AnyPublisher { #if canImport(Combine) return Publishers.MergeMany(self).eraseToAnyPublisher() #else return MergeMany(publishers: Array(self)).eraseToAnyPublisher() #endif } } #if !canImport(Combine) extension Publisher { func merge

(with other: P) -> Merge where P : Publisher, Self.Failure == P.Failure, Self.Output == P.Output { return Merge(a: self, b: other) } } struct Merge: Publisher where A.Output == B.Output, A.Failure == B.Failure { typealias Output = A.Output typealias Failure = B.Failure let a: A let b: B func merge(with c: C) -> Merge3 where C : Publisher, Self.Failure == C.Failure, Self.Output == C.Output { return Merge3(a: a, b: b, c: c) } func receive(subscriber: S) where S : Subscriber, B.Failure == S.Failure, A.Output == S.Input { a.receive(subscriber: subscriber) b.receive(subscriber: subscriber) } } struct Merge3: Publisher where A.Output == B.Output, A.Output == C.Output, A.Failure == B.Failure, A.Failure == C.Failure { typealias Output = A.Output typealias Failure = B.Failure let a: A let b: B let c: C func receive(subscriber: S) where S : Subscriber, B.Failure == S.Failure, A.Output == S.Input { a.receive(subscriber: subscriber) b.receive(subscriber: subscriber) c.receive(subscriber: subscriber) } } private struct MergeMany: Publisher { typealias Output = T.Output typealias Failure = T.Failure let publishers: [T] func receive(subscriber: S) where S : Subscriber, T.Failure == S.Failure, T.Output == S.Input { for publisher in publishers { publisher.receive(subscriber: subscriber) } } } #endif ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Array.swift ================================================ import Foundation @_exported import OpenCombineShim class ArrayStrategy: SyncStrategy { enum ArrayEventHandlingError: Error { case expectedIntIndexInPathButReceivedSomethingElse case intIndexReceivedOutOfBounds(Int) case deletionOfWholeArrayNotAllowed } typealias Value = [Element] let elementStrategy: AnySyncStrategy let equivalenceDetector: AnyEquivalenceDetector? init(_ elementStrategy: AnySyncStrategy, equivalenceDetector: AnyEquivalenceDetector?) { self.elementStrategy = elementStrategy self.equivalenceDetector = equivalenceDetector } func handle(event: InternalEvent, from context: ConnectionContext, for value: inout [Element]) throws -> EventSyncHandlingResult { switch event { case .insert(let path, let index, let data) where path.isEmpty: guard value.indices.contains(index) || value.endIndex == index else { throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index) } let element = try context.codingContext.decode(data: data, as: Element.self) value.insert(element, at: index) return .alertRemainingConnections case .write(let path, let data) where path.isEmpty: value = try context.codingContext.decode(data: data, as: Array.self) return .alertRemainingConnections case .insert(let path, _, _): guard case .some(.index(let index)) = path.first else { throw ArrayEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse } if value.indices.contains(index) { return try elementStrategy.handle(event: event.oneLevelLower(), from: context, for: &value[index]) } else { throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index) } case .write(let path, let data): guard case .some(.index(let index)) = path.first else { throw ArrayEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse } if value.indices.contains(index) { return try elementStrategy.handle(event: event.oneLevelLower(), from: context, for: &value[index]) } else if value.endIndex == index && path.count == 1 { value.append(try context.codingContext.decode(data: data, as: Element.self)) return .alertRemainingConnections } else { throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index) } case .delete(let path, _) where path.isEmpty: throw ArrayEventHandlingError.deletionOfWholeArrayNotAllowed case .delete(let path, _) where path.count == 1: guard case .some(.index(let index)) = path.first else { throw ArrayEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse } guard value.indices.contains(index) else { throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index) } value.remove(at: index) return .alertRemainingConnections case .delete(let path, _): guard case .some(.index(let index)) = path.first else { throw ArrayEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse } guard value.indices.contains(index) else { throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index) } return try elementStrategy.handle(event: event.oneLevelLower(), from: context, for: &value[index]) } } func events(from previous: [Element], to next: [Element], for context: ConnectionContext) -> [InternalEvent] { guard let equivalenceDetector = equivalenceDetector else { guard let data = try? context.codingContext.encode(next) else { return [] } return [.write([], data)] } let differences = next.difference(from: previous) { equivalenceDetector.areEquivalent(lhs: $0, rhs: $1) } guard differences.count < next.count else { guard let data = try? context.codingContext.encode(next) else { return [] } return [.write([], data)] } return differences.compactMap { operation in switch operation { case .insert(let offset, let element, _): guard let data = try? context.codingContext.encode(element) else { return nil } return .insert([], index: offset, data) case .remove(offset: let offset, _, _): return .delete([.index(offset)]) } } } func subEvents(for value: [Element], for context: ConnectionContext) -> AnyPublisher { return value .enumerated() .map { item -> AnyPublisher in let (offset, element) = item return self.elementStrategy.subEvents(for: element, for: context) .map { $0.prefix(by: offset) }.eraseToAnyPublisher() } .mergeMany() } } extension Array: HasErasedSyncStrategy where Element: Codable {} extension Array: SyncableType where Element: Codable { static var strategy: ArrayStrategy { let equivalenceDetector = extractEquivalenceDetector(for: Element.self) return ArrayStrategy(extractStrategy(for: Element.self), equivalenceDetector: equivalenceDetector) } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Codable.swift ================================================ import Foundation @_exported import OpenCombineShim class CodableStrategy: SyncStrategy { enum CodableEventHandlingError: Error { case codableCannotBeDeleted case codableDoesNotAcceptSubPaths } init() { } func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult { guard case .write(let path, let data) = event else { throw CodableEventHandlingError.codableCannotBeDeleted } guard path.isEmpty else { throw CodableEventHandlingError.codableDoesNotAcceptSubPaths } value = try context.codingContext.decode(data: data, as: Value.self) return .alertRemainingConnections } func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] { guard let data = try? context.codingContext.encode(next) else { return [] } return [.write([], data)] } func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher { return Empty(completeImmediately: false).eraseToAnyPublisher() } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Optional.swift ================================================ import Foundation @_exported import OpenCombineShim class OptionalStrategy: SyncStrategy { enum OptionalEventHandlingError: Error { case cannotHandleInsertion case cannotPropagateInsertionToSubPathOfNil case cannotPropagateDeletionToSubPathOfNil case cannotPropagateWriteToSubPathOfNil } typealias Value = Wrapped? let wrappedStrategy: AnySyncStrategy init(_ wrappedStrategy: AnySyncStrategy) { self.wrappedStrategy = wrappedStrategy } func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Wrapped?) throws -> EventSyncHandlingResult { switch event { case .insert(let path, _, _) where path.isEmpty: throw OptionalEventHandlingError.cannotHandleInsertion case .delete(let path, _) where path.isEmpty: value = nil return .alertRemainingConnections case .delete: guard case .some(var wrapped) = value else { throw OptionalEventHandlingError.cannotPropagateDeletionToSubPathOfNil } let result = try wrappedStrategy.handle(event: event, from: context, for: &wrapped) value = wrapped return result case .write(let path, let data): switch value { case .none where path.isEmpty: value = try context.codingContext.decode(data: data, as: Wrapped.self) return .alertRemainingConnections case .none: throw OptionalEventHandlingError.cannotPropagateWriteToSubPathOfNil case .some(var wrapped): let result = try wrappedStrategy.handle(event: event, from: context, for: &wrapped) value = wrapped return result } case .insert(_, _, _): switch value { case .none: throw OptionalEventHandlingError.cannotPropagateInsertionToSubPathOfNil case .some(var wrapped): let result = try wrappedStrategy.handle(event: event, from: context, for: &wrapped) value = wrapped return result } } } func events(from previous: Wrapped?, to next: Wrapped?, for context: ConnectionContext) -> [InternalEvent] { switch (previous, next) { case (.some, .none): return [.delete([])] default: guard let data = try? context.codingContext.encode(next) else { return [] } return [.write([], data)] } } func subEvents(for value: Wrapped?, for context: ConnectionContext) -> AnyPublisher { guard let value = value else { return Empty(completeImmediately: false).eraseToAnyPublisher() } return wrappedStrategy.subEvents(for: value, for: context) } } extension Optional: HasErasedSyncStrategy where Wrapped: Codable {} extension Optional: SyncableType where Wrapped: Codable { static var strategy: OptionalStrategy { return OptionalStrategy(extractStrategy(for: Wrapped.self)) } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/String.swift ================================================ import Foundation @_exported import OpenCombineShim class StringStrategy: SyncStrategy { enum StringEventHandlingError: Error { case expectedIntIndexInPathButReceivedSomethingElse case intIndexReceivedOutOfBounds(Int) case deletionOfEntireStringNotAllowed case deletionOfSubIndexNotAllowed case writingToSubIndexNotAllowed case insertingToSubIndexNotAllowed case deletionOfNegativeWidthNotAllowed } typealias Value = String init() { } func handle(event: InternalEvent, from context: ConnectionContext, for value: inout String) throws -> EventSyncHandlingResult { switch event { case .insert(let path, let offset, let data) where path.isEmpty: let index = value.index(value.startIndex, offsetBy: offset) guard value.indices.contains(index) || value.endIndex == index else { throw StringEventHandlingError.intIndexReceivedOutOfBounds(offset) } let string = try context.codingContext.decode(data: data, as: String.self) value.insert(contentsOf: string, at: index) case .write(let path, let data) where path.isEmpty: value = try context.codingContext.decode(data: data, as: String.self) return .alertRemainingConnections case .write(let path, let data) where path.count == 1: guard case .some(.index(let offset)) = path.first else { throw StringEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse } let index = value.index(value.startIndex, offsetBy: offset) if value.indices.contains(index) { } else if value.endIndex == index && path.count == 1 { value.append(try context.codingContext.decode(data: data, as: String.self)) } else { throw StringEventHandlingError.intIndexReceivedOutOfBounds(offset) } case .delete(let path, let width) where path.count == 1: guard case .some(.index(let offset)) = path.first else { throw StringEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse } guard width != 0 else { return .done } guard width > 0 else { throw StringEventHandlingError.deletionOfNegativeWidthNotAllowed } let start = value.index(value.startIndex, offsetBy: offset) let end = value.index(start, offsetBy: width) let range = start.. [InternalEvent] { let differences = next.stringDifference(from: previous) let isWritingWholeValue = differences.contains { change in switch change { case .insert(_, let element, _): return element == next case .remove(_, let element, _): return element == previous } } guard differences.count < next.count, !isWritingWholeValue else { guard let data = try? context.codingContext.encode(next) else { return [] } return [.write([], data)] } return differences.compactMap { operation in switch operation { case .insert(let offset, let element, _): guard let data = try? context.codingContext.encode(element) else { return nil } return .insert([], index: offset, data) case .remove(offset: let offset, let element, _): return .delete([.index(offset)], width: element.count) } } } func subEvents(for value: String, for context: ConnectionContext) -> AnyPublisher { return Empty(completeImmediately: false).eraseToAnyPublisher() } } extension String: SyncableType { static let strategy: StringStrategy = StringStrategy() } extension String { fileprivate func stringDifference(from previous: String) -> CollectionDifference { var changes: [CollectionDifference.Change] = [] var current: CollectionDifference.Change? for change in difference(from: previous) { switch (current, change) { case (.some(.insert(let lhsOffset, let lhsElement, let lhsWidth)), .insert(let rhsOffset, let rhsElement, let rhsWidth)) where (lhsOffset + lhsElement.count) == rhsOffset: current = .insert(offset: lhsOffset, element: lhsElement + String(rhsElement), associatedWith: lhsWidth + rhsWidth) case (.some(.remove(let lhsOffset, let lhsElement, let lhsWidth)), .remove(let rhsOffset, let rhsElement, let rhsWidth)) where (lhsOffset + lhsElement.count) == rhsOffset: current = .remove(offset: lhsOffset, element: lhsElement + String(rhsElement), associatedWith: lhsWidth + rhsWidth) case (.some(.insert(let lhsOffset, let lhsElement, let lhsWidth)), .insert(let rhsOffset, let rhsElement, let rhsWidth)) where (rhsOffset + 1) == lhsOffset: current = .insert(offset: rhsOffset, element: String(rhsElement) + lhsElement, associatedWith: lhsWidth + rhsWidth) case (.some(.remove(let lhsOffset, let lhsElement, let lhsWidth)), .remove(let rhsOffset, let rhsElement, let rhsWidth)) where (rhsOffset + 1) == lhsOffset: current = .remove(offset: rhsOffset, element: String(rhsElement) + lhsElement, associatedWith: lhsWidth + rhsWidth) case (_, .remove(let offset, let element, let width)): if let current = current { changes.append(current) } current = .remove(offset: offset, element: String(element), associatedWith: width) case (_, .insert(let offset, let element, let width)): if let current = current { changes.append(current) } current = .insert(offset: offset, element: String(element), associatedWith: width) } } if let current = current { changes.append(current) } return CollectionDifference(changes)! } } fileprivate func + (lhs: Int?, rhs: Int?) -> Int? { switch (lhs, rhs) { case (.some(let lhs), .some(let rhs)): return lhs + rhs case (_, .none): return lhs case (.none, _): return rhs } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/AnyEquivalenceDetector.swift ================================================ import Foundation struct AnyEquivalenceDetector: EquivalenceDetector { private class BaseStorage { func areEquivalent(lhs: Value, rhs: Value) -> Bool { fatalError() } } private final class Storage: BaseStorage where Detector.Value == Value { let detector: Detector init(_ detector: Detector) { self.detector = detector } override func areEquivalent(lhs: Value, rhs: Value) -> Bool { return detector.areEquivalent(lhs: lhs, rhs: rhs) } } private let storage: BaseStorage init(_ detector: Detector) where Detector.Value == Value { self.storage = Storage(detector) } func areEquivalent(lhs: Value, rhs: Value) -> Bool { return storage.areEquivalent(lhs: lhs, rhs: rhs) } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/EquatableEquivalenceDetector.swift ================================================ import Foundation struct EquatableEquivalenceDetector: EquivalenceDetector { func areEquivalent(lhs: Value, rhs: Value) -> Bool { return lhs == rhs } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/EquivalenceDetector.swift ================================================ import Foundation protocol EquivalenceDetector { associatedtype Value func areEquivalent(lhs: Value, rhs: Value) -> Bool } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/ErasedEquivalenceDetector.swift ================================================ import Foundation protocol HasErasedErasedEquivalenceDetector { static var erasedEquivalenceDetector: ErasedEquivalenceDetector? { get } } class ErasedEquivalenceDetector { private let detector: Any init(_ detector: T) { self.detector = AnyEquivalenceDetector(detector) } func read() -> AnyEquivalenceDetector { guard let detector = detector as? AnyEquivalenceDetector else { fatalError() } return detector } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/OptionalEquivalenceDetector.swift ================================================ import Foundation struct OptionalEquivalenceDetector: EquivalenceDetector { typealias Value = Wrapped? let detector: AnyEquivalenceDetector func areEquivalent(lhs: Wrapped?, rhs: Wrapped?) -> Bool { switch (lhs, rhs) { case (.none, .none): return true case (.some(let lhs), .some(let rhs)): return detector.areEquivalent(lhs: lhs, rhs: rhs) default: return false } } } extension Optional: HasErasedErasedEquivalenceDetector { static var erasedEquivalenceDetector: ErasedEquivalenceDetector? { guard let detector = extractEquivalenceDetector(for: Wrapped.self) else { return nil } return ErasedEquivalenceDetector(OptionalEquivalenceDetector(detector: detector)) } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/ReferenceEquivalenceDetector.swift ================================================ import Foundation struct ReferenceEquivalenceDetector: EquivalenceDetector { func areEquivalent(lhs: Value, rhs: Value) -> Bool { return lhs === rhs } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/extractEquivalenceDetector.swift ================================================ import Foundation #if canImport(AssociatedTypeRequirementsVisitor) @_implementationOnly import AssociatedTypeRequirementsVisitor #endif func extractEquivalenceDetector(for type: T.Type) -> AnyEquivalenceDetector? { #if canImport(AssociatedTypeRequirementsVisitor) if let equatableDetector = EquivalenceDetectorForEquatableFatory.detector(for: type) { return equatableDetector.read() } #endif if let type = type as? HasErasedErasedEquivalenceDetector.Type { return type.erasedEquivalenceDetector?.read() } if let type = type as? SyncableObject.Type { return type.erasedEquivalenceDetector.read() } return nil } extension SyncableObject { static var erasedEquivalenceDetector: ErasedEquivalenceDetector { return ErasedEquivalenceDetector(ReferenceEquivalenceDetector()) } } #if canImport(AssociatedTypeRequirementsVisitor) private struct EquivalenceDetectorForEquatableFatory: EquatableTypeVisitor { typealias Output = ErasedEquivalenceDetector private static let shared = EquivalenceDetectorForEquatableFatory() private init() {} func callAsFunction(_ type: T.Type) -> ErasedEquivalenceDetector where T : Equatable { return ErasedEquivalenceDetector(EquatableEquivalenceDetector()) } public static func detector(for type: Any.Type) -> ErasedEquivalenceDetector? { return shared(type) } } #endif ================================================ FILE: Sources/Sync/Strategies/Implementation/Basics/Utils/extractStrategy.swift ================================================ import Foundation func extractStrategy(for type: T.Type) -> AnySyncStrategy { if let type = type as? HasErasedSyncStrategy.Type { return type.erasedStrategy.read() } if let type = type as? SyncableObject.Type { return type.erasedStrategy.read() } return AnySyncStrategy(CodableStrategy()) } extension SyncableObject { static var erasedStrategy: ErasedSyncStrategy { return ErasedSyncStrategy(SyncableObjectStrategy()) } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Synced Objects/SyncableObjectStrategy.swift ================================================ import Foundation @_exported import OpenCombineShim class SyncableObjectStrategy: SyncStrategy { enum ObjectEventHandlingError: Error { case cannotHandleInsertion case cannotDeleteSyncedObject case pathForObjectWasNotAStringLabel case syncedPropertyForLabelNotFound(String) } var identifier: ObjectIdentifier? var strategiesPerPath: [String : SelfContainedStrategy]? = nil init() {} private func computeStrategies(for value: Value) -> [String : SelfContainedStrategy] { if let strategiesPerPath = strategiesPerPath, let identifier = identifier, identifier == ObjectIdentifier(value) { return strategiesPerPath } var strategiesPerPath = [String : SelfContainedStrategy]() let mirror = Mirror(reflecting: value) for child in mirror.children { guard let label = child.label, let value = child.value as? SelfContainedStrategy else { continue } strategiesPerPath[label] = value } self.strategiesPerPath = strategiesPerPath return strategiesPerPath } func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult { switch event { case .insert(let path, _, _) where path.isEmpty: throw ObjectEventHandlingError.cannotHandleInsertion case .delete(let path, _) where path.isEmpty: throw ObjectEventHandlingError.cannotDeleteSyncedObject case .delete(let path, _), .write(let path, _), .insert(let path, _, _): let strategiesPerPath = computeStrategies(for: value) guard case .some(.name(let label)) = path.first else { throw ObjectEventHandlingError.pathForObjectWasNotAStringLabel } guard let strategy = strategiesPerPath[label] else { throw ObjectEventHandlingError.syncedPropertyForLabelNotFound(label) } try strategy.handle(event: event.oneLevelLower(), from: context) return .done } } func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] { guard let data = try? context.codingContext.encode(next) else { return [] } return [.write([], data)] } func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher { return computeStrategies(for: value) .map { item -> AnyPublisher in let (label, strategy) = item return strategy.events(for: context).map { $0.prefix(by: label) }.eraseToAnyPublisher() } .mergeMany() } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Type Erasure/AnySyncStrategy.swift ================================================ import Foundation @_exported import OpenCombineShim class AnySyncStrategy: SyncStrategy { private class BaseStorage { func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult { fatalError() } func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] { fatalError() } func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher { fatalError() } } private class Storage: BaseStorage where Strategy.Value == Value { private let strategy: Strategy init(_ strategy: Strategy) { self.strategy = strategy } override func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult { return try strategy.handle(event: event, from: context, for: &value) } override func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] { return strategy.events(from: previous, to: next, for: context) } override func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher { return strategy.subEvents(for: value, for: context) } } private let storage: BaseStorage init(_ strategy: Strategy) where Strategy.Value == Value { self.storage = Storage(strategy) } func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult { return try storage.handle(event: event, from: context, for: &value) } func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] { return storage.events(from: previous, to: next, for: context) } func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher { return storage.subEvents(for: value, for: context) } } ================================================ FILE: Sources/Sync/Strategies/Implementation/Type Erasure/ErasedSyncStrategy.swift ================================================ import Foundation protocol HasErasedSyncStrategy: Codable { static var erasedStrategy: ErasedSyncStrategy { get } } class ErasedSyncStrategy { private let strategy: Any init(_ strategy: Strategy) { self.strategy = AnySyncStrategy(strategy) } func read() -> AnySyncStrategy { guard let strategy = strategy as? AnySyncStrategy else { fatalError() } return strategy } } ================================================ FILE: Sources/Sync/Strategies/SyncStrategy.swift ================================================ import Foundation @_exported import OpenCombineShim enum EventSyncHandlingResult { case done case alertRemainingConnections } protocol ConnectionContext { var id: UUID { get } var type: ConnectionType { get } var connection: Connection { get } } extension ConnectionContext { var codingContext: EventCodingContext { return connection.codingContext } } protocol SyncStrategy { associatedtype Value func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher } protocol SelfContainedStrategy { func handle(event: InternalEvent, from context: ConnectionContext) throws func events(for context: ConnectionContext) -> AnyPublisher } ================================================ FILE: Sources/Sync/Strategies/SyncableType.swift ================================================ import Foundation protocol SyncableType: HasErasedSyncStrategy { associatedtype Strategy: SyncStrategy where Strategy.Value == Self static var strategy: Strategy { get } } extension SyncableType { static var erasedStrategy: ErasedSyncStrategy { return ErasedSyncStrategy(strategy) } } ================================================ FILE: Sources/Sync/SwiftUI/Reconnection/ReconnectionStrategy.swift ================================================ import Foundation public enum ReconnectionDecision { case attemptToReconnect case stop } public protocol ReconnectionStrategy { func maybeReconnect() async -> ReconnectionDecision } ================================================ FILE: Sources/Sync/SwiftUI/Reconnection/TryAgainReconnectionStrategy.swift ================================================ import Foundation extension ReconnectionStrategy where Self == TryAgainReconnectionStrategy { public static func tryAgain(delay: TimeInterval) -> ReconnectionStrategy { return TryAgainReconnectionStrategy(delay: delay) } } public struct TryAgainReconnectionStrategy: ReconnectionStrategy { private let delay: TimeInterval public init(delay: TimeInterval) { self.delay = delay } public func maybeReconnect() async -> ReconnectionDecision { do { try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) return .attemptToReconnect } catch { return .attemptToReconnect } } } ================================================ FILE: Sources/Sync/SwiftUI/Sync.swift ================================================ #if canImport(SwiftUI) import SwiftUI @_exported import OpenCombineShim public struct Sync: View { @StateObject private var viewModel: SyncViewModel private let loading: LoadingView private let error: (Error) -> ErrorView private let content: (SyncedObject) -> Content public init(_ type: Value.Type = Value.self, using connection: ConsumerConnection, reconnectionStrategy: ReconnectionStrategy? = nil, @ViewBuilder content: @escaping (SyncedObject) -> Content, @ViewBuilder loading: () -> LoadingView, @ViewBuilder error: @escaping (Error) -> ErrorView) { self._viewModel = StateObject(wrappedValue: SyncViewModel(connection: connection, reconnectionStrategy: reconnectionStrategy)) self.loading = loading() self.error = error self.content = content } public var body: some View { if let synced = viewModel.synced { content(synced) } else if let error = viewModel.error { self.error(error) } else { loading .onAppear { Task { await viewModel.loadIfNeeded() } } } } } extension Sync where LoadingView == AnyView { public init(_ type: Value.Type = Value.self, using connection: ConsumerConnection, reconnectionStrategy: ReconnectionStrategy? = nil, @ViewBuilder content: @escaping (SyncedObject) -> Content, @ViewBuilder error: @escaping (Error) -> ErrorView) { self.init(type, using: connection, reconnectionStrategy: reconnectionStrategy, content: content, loading: { AnyView(BasicLoadingView()) }, error: error) } } extension Sync where ErrorView == AnyView { public init(_ type: Value.Type = Value.self, using connection: ConsumerConnection, reconnectionStrategy: ReconnectionStrategy? = nil, @ViewBuilder content: @escaping (SyncedObject) -> Content, @ViewBuilder loading: () -> LoadingView) { self.init(type, using: connection, reconnectionStrategy: reconnectionStrategy, content: content, loading: loading, error: { AnyView(BasicErrorView(error: $0)) }) } } extension Sync where LoadingView == AnyView, ErrorView == AnyView { public init(_ type: Value.Type = Value.self, using connection: ConsumerConnection, reconnectionStrategy: ReconnectionStrategy? = nil, @ViewBuilder content: @escaping (SyncedObject) -> Content) { self.init(type, using: connection, reconnectionStrategy: reconnectionStrategy, content: content, loading: { AnyView(BasicLoadingView()) }, error: { AnyView(BasicErrorView(error: $0)) }) } public init(_ type: Value.Type = Value.self, using syncManager: SyncManager, @ViewBuilder content: @escaping (SyncedObject) -> Content) { self._viewModel = StateObject(wrappedValue: SyncViewModel(syncManager: syncManager)) self.content = content self.loading = AnyView(BasicLoadingView()) self.error = { AnyView(BasicErrorView(error: $0)) } } } private struct BasicLoadingView: View { var body: some View { Text("Loading") } } private struct BasicErrorView: View { let error: Error var body: some View { Text("Error: \(error.localizedDescription)") } } fileprivate class SyncViewModel: ObservableObject { private enum State { case loading(ConsumerConnection) case synced(SyncedObject) } @Published private var state: State @Published private var isLoading: Bool = false @Published private(set) var error: Error? private var cancellables: Set = [] private let reconnectionStrategy: ReconnectionStrategy? private var reconnectionTask: Task? = nil var synced: SyncedObject? { switch state { case .synced(let object): return object case .loading: return nil } } init(connection: ConsumerConnection, reconnectionStrategy: ReconnectionStrategy?) { self.state = .loading(connection) self.reconnectionStrategy = reconnectionStrategy } init(syncManager: SyncManager) { self.state = .synced(try! SyncedObject(syncManager: syncManager)) self.reconnectionStrategy = nil } deinit { reconnectionTask?.cancel() } func loadIfNeeded() async { switch state { case .synced: return case .loading(let connection): guard !isLoading else { return } isLoading = true do { reconnectionTask?.cancel() cancellables = [] let manager = try await Value.sync(with: connection) // For some reason Swift says this needs an await ¯\_(ツ)_/¯ let object = try await SyncedObject(syncManager: manager) if let reconnectionStrategy = reconnectionStrategy { connection .isConnectedPublisher .removeDuplicates() .filter { !$0 } .receive(on: DispatchQueue.global()) .sink { [unowned self] _ in self.reconnectionTask?.cancel() self.reconnectionTask = Task { [unowned self] in while case .attemptToReconnect = await reconnectionStrategy.maybeReconnect() { do { guard try await manager.reconnect() else { return } let updateTask = Task { @MainActor in object.forceUpdate(value: try manager.value()) } try await updateTask.value return } catch { DispatchQueue.main.async { [weak self] in self?.error = error } } } } } .store(in: &cancellables) } let state: State = .synced(object) DispatchQueue.main.async { [weak self] in self?.state = state } } catch { DispatchQueue.main.async { [weak self] in self?.isLoading = false self?.error = error } } } } } #endif ================================================ FILE: Sources/Sync/SwiftUI/SyncedObject.swift ================================================ #if canImport(SwiftUI) import SwiftUI @_exported import OpenCombineShim @dynamicMemberLookup @propertyWrapper public struct SyncedObject: DynamicProperty { private class Storage { var value: Value init(value: Value) { self.value = value } } @ObservedObject private var fakeObservable: FakeObservableObject private let manager: AnyManager private let storage: Storage public var wrappedValue: Value { get { return storage.value } } public var projectedValue: SyncedObject { return self } private init(value: Value, manager: AnyManager) { self.fakeObservable = FakeObservableObject(manager: manager) self.storage = Storage(value: value) self.manager = manager } init(syncManager: SyncManager) throws { self.init(value: try syncManager.value(), manager: Manager(manager: syncManager)) } func forceUpdate(value: Value) { self.storage.value = value fakeObservable.forceUpdate() } } extension SyncedObject { public var connection: Connection { return manager.connection } } extension SyncedObject { public subscript(dynamicMember keyPath: KeyPath) -> SyncedObject { return SyncedObject(value: storage.value[keyPath: keyPath], manager: manager) } public subscript(dynamicMember keyPath: WritableKeyPath) -> Binding { return Binding(get: { self.storage.value[keyPath: keyPath] }, set: { self.storage.value[keyPath: keyPath] = $0 }) } } private final class FakeObservableObject: ObservableObject { private let manualUpdate = PassthroughSubject() private let manager: AnyManager let objectWillChange = ObservableObjectPublisher() private var cancellables: Set = [] init(manager: AnyManager) { self.manager = manager let changeEvents = manager.eventHasChanged let connectionChange = manager.connection.isConnectedPublisher.removeDuplicates().map { _ in () } changeEvents .merge(with: connectionChange) .merge(with: manualUpdate) #if canImport(SwiftUI) .receive(on: DispatchQueue.main) #endif .sink { [unowned self] in objectWillChange.send() } .store(in: &cancellables) } func forceUpdate() { manualUpdate.send() } } private class AnyManager { var connection: Connection { fatalError() } var eventHasChanged: AnyPublisher { fatalError() } } private final class Manager: AnyManager { let manager: SyncManager init(manager: SyncManager) { self.manager = manager } override var eventHasChanged: AnyPublisher { return manager.eventHasChanged } override var connection: Connection { return manager.connection } } #endif ================================================ FILE: Sources/Sync/SyncManager.swift ================================================ import Foundation @_exported import OpenCombineShim enum ConnectionType { case consumer case producer } struct BasicConnectionContext: ConnectionContext { let id: UUID let connection: Connection let type: ConnectionType } public class SyncManager { enum SyncManagerError: Error { case unretainedValueWasReleased } private class BaseStorage { var object: Value? { return nil } func set(value: Value) { fatalError() } } private final class RetainedStorage: BaseStorage { private var value: Value init(value: Value) { self.value = value } override var object: Value? { return value } override func set(value: Value) { self.value = value } } private final class WeakStorage: BaseStorage { private weak var value: Value? init(value: Value) { self.value = value } override var object: Value? { return value } override func set(value: Value) { self.value = value } } private let id = UUID() private let connectionType: ConnectionType private let strategy: AnySyncStrategy private let storage: BaseStorage public let connection: Connection private var cancellables: Set = [] private let errorsSubject = PassthroughSubject() private let hasChangedSubject = PassthroughSubject() public var isConnected: Bool { return connection.isConnected } public var eventHasChanged: AnyPublisher { return hasChangedSubject.eraseToAnyPublisher() } init(_ value: Value, connection: Connection, connectionType: ConnectionType) { self.strategy = extractStrategy(for: Value.self) self.storage = RetainedStorage(value: value) self.connection = connection self.connectionType = connectionType setUpConnection() } init(weak value: Value, connection: Connection, connectionType: ConnectionType) { self.strategy = extractStrategy(for: Value.self) self.storage = WeakStorage(value: value) self.connection = connection self.connectionType = connectionType setUpConnection() } public func value() throws -> Value { guard let value = storage.object else { throw SyncManagerError.unretainedValueWasReleased } return value } public func data() throws -> Data { return try connection.codingContext.encode(try value()) } @discardableResult public func reconnect() async throws -> Bool { guard let connection = connection as? ConsumerConnection else { return false } if connection.isConnected { connection.disconnect() } let data = try await connection.connect() do { let value = try connection.codingContext.decode(data: data, as: Value.self) storage.set(value: value) setUpConnection() hasChangedSubject.send() return true } catch { connection.disconnect() throw error } } private func setUpConnection() { let context = BasicConnectionContext(id: id, connection: connection, type: connectionType) cancellables = [] connection .receive() .sink { [unowned self] data in do { var value = try self.value() let event = try self.connection.codingContext.decode(data: data, as: InternalEvent.self) _ = try self.strategy.handle(event: event, from: context, for: &value) self.hasChangedSubject.send() } catch { self.connection.disconnect() self.errorsSubject.send(error) } } .store(in: &cancellables) guard let value = storage.object else { return } strategy .subEvents(for: value, for: context) .sink { [unowned self] event in do { self.hasChangedSubject.send() let data = try self.connection.codingContext.encode(event) self.connection.send(data: data) } catch { self.connection.disconnect() self.errorsSubject.send(error) } } .store(in: &cancellables) } } ================================================ FILE: Sources/Sync/SyncableObject.swift ================================================ import Foundation @_exported import OpenCombineShim public protocol SyncableObject: AnyObject, Codable { } extension SyncableObject { public func sync(with connection: ProducerConnection) -> SyncManager { return SyncManager(self, connection: connection, connectionType: .producer) } public func syncWithoutRetainingInMemory(with connection: ProducerConnection) -> SyncManager { return SyncManager(weak: self, connection: connection, connectionType: .producer) } public static func sync(with connection: ConsumerConnection) async throws -> SyncManager { let data = try await connection.connect() do { let value = try connection.codingContext.decode(data: data, as: Self.self) return SyncManager(value, connection: connection, connectionType: .consumer) } catch { connection.disconnect() throw error } } } ================================================ FILE: Sources/Sync/Synced.swift ================================================ import Foundation @_exported import OpenCombineShim import Accessibility public enum SyncedWriteRights: UInt8, Codable { case shared case protected } @propertyWrapper public final class Synced: Codable { enum SyncedEventHandlingError: Error { case receivedIllegalEventForProtectedProperty } private struct ConnectionState { let value: Value? let events: AnyPublisher } private enum ValueChange { case local(Value) case remote(Value, connectionId: UUID) var value: Value { switch self { case .local(let value): return value case .remote(let value, _): return value } } func handle(previous: ConnectionState, using strategy: AnySyncStrategy, for context: ConnectionContext) -> ConnectionState { switch self { case .local(let value): return ConnectionState(value: value, events: events(from: previous.value, to: value, using: strategy, for: context)) case .remote(let value, let connectionId): guard connectionId != context.id else { return ConnectionState(value: value, events: events(from: nil, to: value, using: strategy, for: context)) } return ConnectionState(value: value, events: events(from: previous.value, to: value, using: strategy, for: context)) } } private func events(from previous: Value?, to current: Value, using strategy: AnySyncStrategy, for context: ConnectionContext) -> AnyPublisher { let future = strategy.subEvents(for: current, for: context) guard let previous = previous else { return future } let events = strategy.events(from: previous, to: current, for: context) let immediate = Publishers.Sequence<[InternalEvent], Never>(sequence: events) return immediate.merge(with: future).eraseToAnyPublisher() } } private let publisher: CurrentValueSubject private var value: Value private let rights: SyncedWriteRights private let strategy: AnySyncStrategy private let isAllowedToWrite: Bool public var wrappedValue: Value { get { return value } set { guard isAllowedToWrite else { return } value = newValue publisher.send(.local(newValue)) } } public var values: AnyPublisher { return publisher.map(\.value).eraseToAnyPublisher() } public var valueChange: AnyPublisher { return publisher.map(\.value).dropFirst().eraseToAnyPublisher() } public var projectedValue: Synced { return self } init(value: Value, rights: SyncedWriteRights, isAllowedToWrite: Bool) { self.value = value self.publisher = CurrentValueSubject(.local(value)) self.strategy = extractStrategy(for: Value.self) self.rights = rights self.isAllowedToWrite = isAllowedToWrite } public convenience init(wrappedValue value: Value, _ rights: SyncedWriteRights = .shared) { self.init(value: value, rights: rights, isAllowedToWrite: true) } public convenience init(from decoder: Decoder) throws { var container = try decoder.unkeyedContainer() let value = try container.decode(Value.self) let rights = try container.decode(SyncedWriteRights.self) self.init(value: value, rights: rights, isAllowedToWrite: rights != .protected) } public func encode(to encoder: Encoder) throws { var container = encoder.unkeyedContainer() try container.encode(value) try container.encode(rights) } } extension Synced: SelfContainedStrategy { func handle(event: InternalEvent, from context: ConnectionContext) throws { if case .producer = context.type, case .protected = rights { throw SyncedEventHandlingError.receivedIllegalEventForProtectedProperty } switch try strategy.handle(event: event, from: context, for: &value) { case .alertRemainingConnections: guard case .producer = context.type else { break } publisher.send(.remote(value, connectionId: context.id)) case .done: break } } func events(for context: ConnectionContext) -> AnyPublisher { let strategy = strategy let initialState = ConnectionState(value: nil, events: Empty(completeImmediately: false).eraseToAnyPublisher()) return publisher .scan(initialState) { [strategy, context] state, change in return change.handle(previous: state, using: strategy, for: context) } .flatMap { $0.events } .eraseToAnyPublisher() } } ================================================ FILE: Tests/SyncTests/SyncTests.swift ================================================ import XCTest import Sync class MockServerConnection: ProducerConnection { var isConnected: Bool { return true } var isConnectedPublisher: AnyPublisher { return Empty().eraseToAnyPublisher() } private let inputSubject = PassthroughSubject() private let outputSubject = PassthroughSubject() func disconnect() { fatalError() } func send(data: Data) { outputSubject.send(data) } func receive() -> AnyPublisher { return inputSubject.eraseToAnyPublisher() } func dataForClient() -> AnyPublisher { return outputSubject.eraseToAnyPublisher() } func clientSent(data: Data) { inputSubject.send(data) } } class MockClientConnection: ConsumerConnection { let service: MockRemoteService var serverConnection: MockServerConnection? = nil init(service: MockRemoteService) { self.service = service } var isConnected: Bool { return serverConnection != nil } var isConnectedPublisher: AnyPublisher { return Empty().eraseToAnyPublisher() } func connect() async throws -> Data { let response = try await service.createConnection() self.serverConnection = response.connection return response.data } func disconnect() { serverConnection?.disconnect() } func receive() -> AnyPublisher { return serverConnection?.dataForClient() ?? Just(Data()).eraseToAnyPublisher() } func send(data: Data) { serverConnection?.clientSent(data: data) } } struct MockResponse { let data: Data let connection: MockServerConnection } class MockRemoteService { var managers: [SyncManager] = [] let viewModel: ViewModel init(viewModel: ViewModel) { self.viewModel = viewModel } func createConnection() async throws -> MockResponse { let connection = MockServerConnection() let manager = viewModel.sync(with: connection) managers.append(manager) return MockResponse(data: try manager.data(), connection: connection) } } class ViewModel: SyncableObject, Codable { class SubViewModel: SyncableObject, Codable { @Synced var toggle = false } @Synced var name = "Hello World!" @Synced var names = ["A", "B"] @Synced var subViewModels = [SubViewModel(), SubViewModel()] @Synced(.protected) var protectedString = "This string is protected" } final class SyncTests: XCTestCase { func testExample() async throws { let serverViewModel = ViewModel() let service = MockRemoteService(viewModel: serverViewModel) let clientConnection = MockClientConnection(service: service) let clientManager = try await ViewModel.sync(with: clientConnection) let clientViewModel = try clientManager.value() XCTAssertEqual(clientViewModel.name, "Hello World!") clientViewModel.name = "hello, World!!" XCTAssertEqual(serverViewModel.name, "hello, World!!") XCTAssertEqual(clientViewModel.protectedString, "This string is protected") clientViewModel.protectedString = "test" XCTAssertEqual(clientViewModel.protectedString, "This string is protected") XCTAssertEqual(serverViewModel.protectedString, "This string is protected") serverViewModel.protectedString = "still protected" XCTAssertEqual(clientViewModel.protectedString, "still protected") XCTAssertEqual(serverViewModel.protectedString, "still protected") clientViewModel.name = "Foo" XCTAssertEqual(serverViewModel.name, "Foo") serverViewModel.name = "Bar" XCTAssertEqual(clientViewModel.name, "Bar") serverViewModel.subViewModels[0].toggle = true XCTAssertEqual(clientViewModel.subViewModels[0].toggle, true) XCTAssertEqual(clientViewModel.subViewModels[1].toggle, false) serverViewModel.names.insert("C", at: 1) XCTAssertEqual(clientViewModel.names[0], "A") XCTAssertEqual(clientViewModel.names[1], "C") XCTAssertEqual(clientViewModel.names[2], "B") } func testMultipleClients() async throws { let serverViewModel = ViewModel() let service1 = MockRemoteService(viewModel: serverViewModel) let service2 = MockRemoteService(viewModel: serverViewModel) let clientConnection1 = MockClientConnection(service: service1) let clientConnection2 = MockClientConnection(service: service2) let clientManager1 = try await ViewModel.sync(with: clientConnection1) let clientManager2 = try await ViewModel.sync(with: clientConnection2) let clientViewModel1 = try clientManager1.value() let clientViewModel2 = try clientManager2.value() XCTAssertEqual(clientViewModel1.name, "Hello World!") XCTAssertEqual(clientViewModel2.name, "Hello World!") clientViewModel1.name = "Foo" XCTAssertEqual(serverViewModel.name, "Foo") XCTAssertEqual(clientViewModel2.name, "Foo") clientViewModel2.names.insert("C", at: 1) XCTAssertEqual(clientViewModel1.names[0], "A") XCTAssertEqual(clientViewModel1.names[1], "C") XCTAssertEqual(clientViewModel1.names[2], "B") } }