├── .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
├── demo.gif
└── logo.png
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Changelog.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [2.0.0] - Unreleased
8 | ### Added
9 | - Added default Message Pack based event coding context.
10 | - Connections no longer need to specify a coding context. It will use the Message Pack Context by default.
11 | - Added `EventBus`, which acts like `Synced`, except it only allows to send messages, and won't persist any values.
12 | - Added `SyncedWriteRights`, which will
13 |
14 | ### Changed
15 | - **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.
16 | - **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.
17 |
18 | ## [1.1.0] - 2022-02-27
19 | ### Added
20 | - Added projected value to `Synced` property wrapper, to access value change publisher
21 | - Exporting imports of Combine/OpenCombine
22 |
23 | ## [1.0.1] - 2022-02-21
24 | ### Fixed
25 | - Fixed compiler errors on non Apple Platforms
26 |
27 | ## [1.0.1] - 2022-02-20
28 | ### Fixed
29 | - Fixed UI update issues when using `Sync`
30 | - Fixed projected value of `SyncedObject` not being visible
31 |
32 |
33 | ## [1.0.0] - 2022-02-20
34 | ### Added
35 | - Added `valueChange` publisher to `Synced`, to listen for changes to the value
36 | - Added getter for `connection` to `SyncedObject`
37 | - Added support for getting a `SyncedObbject` from a parent `SyncedObject` via dynamic member lookup
38 | - Added `SyncManager.reconnect` method to restard connection
39 | - Added `ReconnectionStrategy` in order to attempt to resume the session after being disconnected
40 |
41 | ## Changed
42 | - **Breaking:** Renamed `SyncedObject` protocol to `SyncableObject`. To be consistent with `ObservableObject`
43 | - **Breaking:** Renamed `SyncedObservedObject` to `SyncedObject`. To be consistent with `ObservedObject`
44 | - **Breaking:** Projected Value of `SyncedObject` is of type now `SyncedObject`
45 | - **Breaking:** Renamed `SyncableObject.manager` to `sync`
46 | - **Breaking:** Renamed `SyncableObject.managerWithoutRetainingInMemory` to `syncWithoutRetainingInMemory`
47 |
48 | ## [0.1.0] - 2022-02-21
49 | ### Added
50 | - Support for OpenCombine
51 |
52 | ### Changed
53 | - Improved handling of updates to array
54 |
55 |
56 | ## [0.1.0] - 2022-02-20
57 | ### Added
58 | - Initial Release
59 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Mathias Quintero
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "AssociatedTypeRequirementsKit",
6 | "repositoryURL": "https://github.com/nerdsupremacist/AssociatedTypeRequirementsKit.git",
7 | "state": {
8 | "branch": null,
9 | "revision": "2e4c49c21ffb2135f1c99fbfcf2119c9d24f5e8c",
10 | "version": "0.3.2"
11 | }
12 | },
13 | {
14 | "package": "MessagePack",
15 | "repositoryURL": "https://github.com/Flight-School/MessagePack.git",
16 | "state": {
17 | "branch": null,
18 | "revision": "bbc5ab6362db234f2051e73e67296ebf5c3d2042",
19 | "version": "1.2.4"
20 | }
21 | },
22 | {
23 | "package": "OpenCombine",
24 | "repositoryURL": "https://github.com/OpenCombine/OpenCombine.git",
25 | "state": {
26 | "branch": null,
27 | "revision": "9cf67e363738dbab61b47fb5eaed78d3db31e5ee",
28 | "version": "0.13.0"
29 | }
30 | }
31 | ]
32 | },
33 | "version": 1
34 | }
35 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Sync",
8 | platforms: [.macOS(.v11), .iOS(.v14), .watchOS(.v6), .tvOS(.v14)],
9 | products: [
10 | .library(
11 | name: "Sync",
12 | targets: ["Sync"]),
13 | ],
14 | dependencies: [
15 | .package(url: "https://github.com/OpenCombine/OpenCombine.git", from: "0.13.0"),
16 | .package(url: "https://github.com/nerdsupremacist/AssociatedTypeRequirementsKit.git", .upToNextMinor(from: "0.3.2")),
17 | .package(url: "https://github.com/Flight-School/MessagePack.git", from: "1.2.4"),
18 | ],
19 | targets: [
20 | .target(
21 | name: "Sync",
22 | dependencies: [
23 | .product(name: "OpenCombineShim", package: "OpenCombine"),
24 | .product(name: "AssociatedTypeRequirementsKit", package: "AssociatedTypeRequirementsKit"),
25 | .product(name: "MessagePack", package: "MessagePack"),
26 | ]),
27 | .testTarget(
28 | name: "SyncTests",
29 | dependencies: ["Sync"]),
30 | ]
31 | )
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | # Sync
16 | Sync is a proof of concept for expanding on the Magic of ObservableObject, and making it work over the network.
17 | This let's you create real-time Apps in which your Observable Objects are in sync across multiple devices.
18 | This has a lot of applications just as:
19 | - IoT Apps
20 | - Multi-Player Mini Games
21 | - etc.
22 |
23 | 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:
24 | - Bluetooth
25 | - Multi-Peer
26 | - MQTT
27 | - etc.
28 |
29 | The sky is the limit!
30 |
31 | **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...
32 |
33 | ## Installation
34 | ### Swift Package Manager
35 |
36 | You can install Sync via [Swift Package Manager](https://swift.org/package-manager/) by adding the following line to your `Package.swift`:
37 |
38 | ```swift
39 | import PackageDescription
40 |
41 | let package = Package(
42 | [...]
43 | dependencies: [
44 | .package(url: "https://github.com/nerdsupremacist/Sync.git", from: "1.0.0")
45 | ]
46 | )
47 | ```
48 |
49 | ## Usage
50 |
51 | If you have ever used Observable Object, then Sync will be extremely easy to use.
52 | 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).
53 |
54 |
55 |
56 |
57 |
58 | For this we will need a few additional packages:
59 | - [Vapor](https://vapor.codes): To create a Web Server that will sync our devices
60 | - [SyncWebSocketClient](https://github.com/nerdsupremacist/SyncWebSocketClient): The client code for using WebSockets
61 | - [SyncWebSocketVapor](https://github.com/nerdsupremacist/SyncWebSocketVapor): A utility to make it easy to serve our object via a WebSocket
62 |
63 | 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`:
64 | ```swift
65 | class ViewModel: SyncableObject {
66 | @Synced
67 | var toggle: Bool = false
68 |
69 | init() { }
70 | }
71 | ```
72 |
73 | 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.
74 |
75 | 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:
76 |
77 | ```swift
78 | import Vapor
79 | import SyncWebSocketVapor
80 |
81 | let app = Application(try .detect())
82 |
83 | let viewModel = ViewModel()
84 | app.syncObjectOverWebSocket("view_model") { _ in
85 | return viewModel
86 | }
87 |
88 | try app.run()
89 | ```
90 |
91 | For our SwiftUI App, we need to use two things:
92 | - @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
93 | - Sync: A little wrapper view to start the remote session
94 |
95 | Our actual view then uses SyncedObservedObject with our ViewModel
96 | ```swift
97 | struct ContentView: View {
98 | @SyncedObject
99 | var viewModel: ViewModel
100 |
101 | var body: some View {
102 | Toggle("A toggle", isOn: $viewModel.toggle)
103 | .animation(.easeIn, value: viewModel.toggle)
104 | .padding(64)
105 | }
106 | }
107 | ```
108 |
109 | And in order to display it we use Sync, and pass along the Web Socket Connection:
110 | ```swift
111 | struct RootView: View {
112 | var body: some View {
113 | Sync(ViewModel.self, using: .webSocket(url: url)) { viewModel in
114 | ContentView(viewModel: viewModel)
115 | }
116 | }
117 | }
118 | ```
119 |
120 | ### Developing for Web?
121 |
122 | 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.
123 | Here are the Web Assembly specific packages for Sync:
124 | - [SyncTokamak](https://github.com/nerdsupremacist/SyncTokamak): Compatibility Layer so that Tokamak reacts to updates
125 | - [SyncWebSocketWebAssemblyClient](https://github.com/nerdsupremacist/SyncWebSocketWebAssemblyClient): Web Assembly compatible version of [SyncWebSocketClient](https://github.com/nerdsupremacist/SyncWebSocketClient)
126 |
127 | Here's a small demo of that working:
128 |
129 |
130 |
131 |
132 | ## Contributions
133 | Contributions are welcome and encouraged!
134 |
135 | ## License
136 | Sync is available under the MIT license. See the LICENSE file for more info.
137 |
--------------------------------------------------------------------------------
/Sources/Sync/Connection/Connection.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | public protocol Connection {
6 | var isConnected: Bool { get }
7 | var isConnectedPublisher: AnyPublisher { get }
8 |
9 | var codingContext: EventCodingContext { get }
10 |
11 | func disconnect()
12 |
13 | func send(data: Data)
14 | func receive() -> AnyPublisher
15 | }
16 |
17 | extension Connection {
18 |
19 | public var codingContext: EventCodingContext {
20 | return .default
21 | }
22 |
23 | }
24 |
25 | public protocol ConsumerConnection: Connection {
26 | func connect() async throws -> Data
27 | }
28 |
29 | public protocol ProducerConnection: Connection { }
30 |
--------------------------------------------------------------------------------
/Sources/Sync/EventBus.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | import OpenCombineShim
4 |
5 | public enum EventBusWriteOwnership: Int8, Codable {
6 | case shared
7 | case producer
8 | case consumer
9 | }
10 |
11 | public final class EventBus: Codable {
12 | enum EventBusEventHandlingError: Error {
13 | case writeAtSubpathNotAllowed
14 | case deleteNotAllowed
15 | case insertNotAllowed
16 | }
17 |
18 | private let ownership: EventBusWriteOwnership
19 | private let canWrite: Bool
20 | private let outgoingEventSubject = PassthroughSubject()
21 | private let incomingEventSubject = PassthroughSubject()
22 |
23 | public var events: AnyPublisher {
24 | return incomingEventSubject.eraseToAnyPublisher()
25 | }
26 |
27 | private init(ownership: EventBusWriteOwnership, canWrite: Bool) {
28 | self.ownership = ownership
29 | self.canWrite = canWrite
30 | }
31 |
32 | public convenience init(for ownership: EventBusWriteOwnership = .shared) {
33 | self.init(ownership: ownership, canWrite: ownership != .consumer)
34 | }
35 |
36 | public convenience init(from decoder: Decoder) throws {
37 | let container = try decoder.singleValueContainer()
38 | let ownership = try container.decode(EventBusWriteOwnership.self)
39 | self.init(ownership: ownership, canWrite: ownership != .producer)
40 | }
41 |
42 | public func encode(to encoder: Encoder) throws {
43 | var container = encoder.singleValueContainer()
44 | try container.encode(ownership)
45 | }
46 |
47 | public func send(_ event: Event) {
48 | outgoingEventSubject.send(event)
49 | }
50 | }
51 |
52 | extension EventBus: SelfContainedStrategy {
53 | func handle(event: InternalEvent, from context: ConnectionContext) throws {
54 | switch event {
55 | case .write(let path, let data) where path.isEmpty:
56 | let event = try context.codingContext.decode(data: data, as: Event.self)
57 | incomingEventSubject.send(event)
58 | case .write:
59 | throw EventBusEventHandlingError.writeAtSubpathNotAllowed
60 | case .delete:
61 | throw EventBusEventHandlingError.deleteNotAllowed
62 | case .insert:
63 | throw EventBusEventHandlingError.insertNotAllowed
64 | }
65 | }
66 |
67 | func events(for context: ConnectionContext) -> AnyPublisher {
68 | return outgoingEventSubject
69 | .compactMap { event in
70 | guard let data = try? context.codingContext.encode(event) else { return nil }
71 | return .write([], data)
72 | }
73 | .eraseToAnyPublisher()
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/Sync/Events/EventCodingContext.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | public protocol EventCodingContext {
5 | func decode(data: Data, as type: T.Type) throws -> T
6 | func encode(_ value: T) throws -> Data
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/Sync/Events/InternalEvent.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | enum InternalEvent {
5 | case delete([PathComponent], width: Int = 1)
6 | case write([PathComponent], Data)
7 | case insert([PathComponent], index: Int, Data)
8 |
9 | func oneLevelLower() -> InternalEvent {
10 | switch self {
11 | case .write(let path, let data):
12 | return .write(Array(path.dropFirst()), data)
13 | case .delete(let path, let width):
14 | return .delete(Array(path.dropFirst()), width: width)
15 | case .insert(let path, let index, let data):
16 | return .insert(Array(path.dropFirst()), index: index, data)
17 | }
18 | }
19 |
20 | func prefix(by index: Int) -> InternalEvent {
21 | switch self {
22 | case .write(let path, let data):
23 | return .write([.index(index)] + path, data)
24 | case .delete(let path, let width):
25 | return .delete([.index(index)] + path, width: width)
26 | case .insert(let path, let insertionIndex, let data):
27 | return .insert([.index(index)] + path, index: insertionIndex, data)
28 | }
29 | }
30 |
31 | func prefix(by label: String) -> InternalEvent {
32 | switch self {
33 | case .write(let path, let data):
34 | return .write([.name(label)] + path, data)
35 | case .delete(let path, let width):
36 | return .delete([.name(label)] + path, width: width)
37 | case .insert(let path, let insertionIndex, let data):
38 | return .insert([.name(label)] + path, index: insertionIndex, data)
39 | }
40 | }
41 | }
42 |
43 | extension PathComponent: Codable {
44 | private enum Kind: Int8, Codable {
45 | case label
46 | case index
47 | }
48 |
49 | init(from decoder: Decoder) throws {
50 | var container = try decoder.unkeyedContainer()
51 | switch try container.decode(Kind.self) {
52 | case .label:
53 | self = .name(try container.decode(String.self))
54 | case .index:
55 | self = .index(try container.decode(Int.self))
56 | }
57 | assert(container.isAtEnd)
58 | }
59 |
60 | func encode(to encoder: Encoder) throws {
61 | var container = encoder.unkeyedContainer()
62 | switch self {
63 | case .name(let name):
64 | try container.encode(Kind.label)
65 | try container.encode(name)
66 | case .index(let index):
67 | try container.encode(Kind.index)
68 | try container.encode(index)
69 | }
70 | }
71 | }
72 |
73 | extension InternalEvent: Codable {
74 | private enum Kind: Int8, Codable {
75 | case write
76 | case delete
77 | case insert
78 | }
79 |
80 | init(from decoder: Decoder) throws {
81 | var container = try decoder.unkeyedContainer()
82 | switch try container.decode(Kind.self) {
83 | case .write:
84 | self = .write(try container.decode([PathComponent].self), try container.decode(Data.self))
85 | case .delete:
86 | let path = try container.decode([PathComponent].self)
87 | let width = container.isAtEnd ? 1 : try container.decode(Int.self)
88 | self = .delete(path, width: width)
89 | case .insert:
90 | self = .insert(try container.decode([PathComponent].self), index: try container.decode(Int.self), try container.decode(Data.self))
91 | }
92 | assert(container.isAtEnd)
93 | }
94 |
95 | func encode(to encoder: Encoder) throws {
96 | var container = encoder.unkeyedContainer()
97 | switch self {
98 | case .write(let path, let data):
99 | try container.encode(Kind.write)
100 | try container.encode(path)
101 | try container.encode(data)
102 | case .delete(let path, let width):
103 | try container.encode(Kind.delete)
104 | try container.encode(path)
105 | if width != 1 {
106 | try container.encode(width)
107 | }
108 | case .insert(let path, let index, let data):
109 | try container.encode(Kind.insert)
110 | try container.encode(path)
111 | try container.encode(index)
112 | try container.encode(data)
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/Sources/Sync/Events/JSONEventCodingContext.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | extension EventCodingContext where Self == JSONEventCodingContext {
5 |
6 | public static var json: EventCodingContext {
7 | return JSONEventCodingContext()
8 | }
9 |
10 | }
11 |
12 | public struct JSONEventCodingContext: EventCodingContext {
13 | private let encoder = JSONEncoder()
14 | private let decoder = JSONDecoder()
15 |
16 | public init() { }
17 |
18 | public func decode(data: Data, as type: T.Type) throws -> T where T : Decodable {
19 | return try decoder.decode(type, from: data)
20 | }
21 |
22 | public func encode(_ value: T) throws -> Data where T : Encodable {
23 | return try encoder.encode(value)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/Sync/Events/MessagePackEventCodingContext.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_implementationOnly import MessagePack
4 |
5 | extension EventCodingContext where Self == MessagePackEventCodingContext {
6 |
7 | public static var messagePack: EventCodingContext {
8 | return MessagePackEventCodingContext()
9 | }
10 |
11 | public static var `default`: EventCodingContext {
12 | return .messagePack
13 | }
14 |
15 | }
16 |
17 | public struct MessagePackEventCodingContext: EventCodingContext {
18 | private let encoder = MessagePackEncoder()
19 | private let decoder = MessagePackDecoder()
20 |
21 | public init() { }
22 |
23 | public func decode(data: Data, as type: T.Type) throws -> T where T : Decodable {
24 | return try decoder.decode(type, from: data)
25 | }
26 |
27 | public func encode(_ value: T) throws -> Data where T : Encodable {
28 | return try encoder.encode(value)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Sources/Sync/Events/PathComponent.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | enum PathComponent {
5 | case name(String)
6 | case index(Int)
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/Sync/Extensions.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | extension Sequence where Element: Publisher {
6 |
7 | func mergeMany() -> AnyPublisher {
8 | #if canImport(Combine)
9 | return Publishers.MergeMany(self).eraseToAnyPublisher()
10 | #else
11 | return MergeMany(publishers: Array(self)).eraseToAnyPublisher()
12 | #endif
13 | }
14 |
15 | }
16 |
17 | #if !canImport(Combine)
18 | extension Publisher {
19 |
20 | func merge(with other: P) -> Merge where P : Publisher, Self.Failure == P.Failure, Self.Output == P.Output {
21 | return Merge(a: self, b: other)
22 | }
23 |
24 | }
25 |
26 | struct Merge: Publisher where A.Output == B.Output, A.Failure == B.Failure {
27 | typealias Output = A.Output
28 | typealias Failure = B.Failure
29 |
30 | let a: A
31 | let b: B
32 |
33 | func merge(with c: C) -> Merge3 where C : Publisher, Self.Failure == C.Failure, Self.Output == C.Output {
34 | return Merge3(a: a, b: b, c: c)
35 | }
36 |
37 | func receive(subscriber: S) where S : Subscriber, B.Failure == S.Failure, A.Output == S.Input {
38 | a.receive(subscriber: subscriber)
39 | b.receive(subscriber: subscriber)
40 | }
41 | }
42 |
43 | struct Merge3: Publisher where A.Output == B.Output, A.Output == C.Output, A.Failure == B.Failure, A.Failure == C.Failure {
44 | typealias Output = A.Output
45 | typealias Failure = B.Failure
46 |
47 | let a: A
48 | let b: B
49 | let c: C
50 |
51 | func receive(subscriber: S) where S : Subscriber, B.Failure == S.Failure, A.Output == S.Input {
52 | a.receive(subscriber: subscriber)
53 | b.receive(subscriber: subscriber)
54 | c.receive(subscriber: subscriber)
55 | }
56 | }
57 |
58 | private struct MergeMany: Publisher {
59 | typealias Output = T.Output
60 | typealias Failure = T.Failure
61 |
62 | let publishers: [T]
63 |
64 | func receive(subscriber: S) where S : Subscriber, T.Failure == S.Failure, T.Output == S.Input {
65 | for publisher in publishers {
66 | publisher.receive(subscriber: subscriber)
67 | }
68 | }
69 | }
70 | #endif
71 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Array.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | class ArrayStrategy: SyncStrategy {
6 | enum ArrayEventHandlingError: Error {
7 | case expectedIntIndexInPathButReceivedSomethingElse
8 | case intIndexReceivedOutOfBounds(Int)
9 | case deletionOfWholeArrayNotAllowed
10 | }
11 |
12 | typealias Value = [Element]
13 |
14 | let elementStrategy: AnySyncStrategy
15 | let equivalenceDetector: AnyEquivalenceDetector?
16 |
17 | init(_ elementStrategy: AnySyncStrategy, equivalenceDetector: AnyEquivalenceDetector?) {
18 | self.elementStrategy = elementStrategy
19 | self.equivalenceDetector = equivalenceDetector
20 | }
21 |
22 | func handle(event: InternalEvent, from context: ConnectionContext, for value: inout [Element]) throws -> EventSyncHandlingResult {
23 | switch event {
24 | case .insert(let path, let index, let data) where path.isEmpty:
25 | guard value.indices.contains(index) || value.endIndex == index else {
26 | throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index)
27 | }
28 | let element = try context.codingContext.decode(data: data, as: Element.self)
29 | value.insert(element, at: index)
30 | return .alertRemainingConnections
31 | case .write(let path, let data) where path.isEmpty:
32 | value = try context.codingContext.decode(data: data, as: Array.self)
33 | return .alertRemainingConnections
34 | case .insert(let path, _, _):
35 | guard case .some(.index(let index)) = path.first else {
36 | throw ArrayEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse
37 | }
38 | if value.indices.contains(index) {
39 | return try elementStrategy.handle(event: event.oneLevelLower(), from: context, for: &value[index])
40 | } else {
41 | throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index)
42 | }
43 | case .write(let path, let data):
44 | guard case .some(.index(let index)) = path.first else {
45 | throw ArrayEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse
46 | }
47 | if value.indices.contains(index) {
48 | return try elementStrategy.handle(event: event.oneLevelLower(), from: context, for: &value[index])
49 | } else if value.endIndex == index && path.count == 1 {
50 | value.append(try context.codingContext.decode(data: data, as: Element.self))
51 | return .alertRemainingConnections
52 | } else {
53 | throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index)
54 | }
55 | case .delete(let path, _) where path.isEmpty:
56 | throw ArrayEventHandlingError.deletionOfWholeArrayNotAllowed
57 | case .delete(let path, _) where path.count == 1:
58 | guard case .some(.index(let index)) = path.first else {
59 | throw ArrayEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse
60 | }
61 | guard value.indices.contains(index) else {
62 | throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index)
63 | }
64 | value.remove(at: index)
65 | return .alertRemainingConnections
66 | case .delete(let path, _):
67 | guard case .some(.index(let index)) = path.first else {
68 | throw ArrayEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse
69 | }
70 | guard value.indices.contains(index) else {
71 | throw ArrayEventHandlingError.intIndexReceivedOutOfBounds(index)
72 | }
73 | return try elementStrategy.handle(event: event.oneLevelLower(), from: context, for: &value[index])
74 | }
75 | }
76 |
77 | func events(from previous: [Element], to next: [Element], for context: ConnectionContext) -> [InternalEvent] {
78 | guard let equivalenceDetector = equivalenceDetector else {
79 | guard let data = try? context.codingContext.encode(next) else { return [] }
80 | return [.write([], data)]
81 | }
82 |
83 | let differences = next.difference(from: previous) { equivalenceDetector.areEquivalent(lhs: $0, rhs: $1) }
84 | guard differences.count < next.count else {
85 | guard let data = try? context.codingContext.encode(next) else { return [] }
86 | return [.write([], data)]
87 | }
88 |
89 | return differences.compactMap { operation in
90 | switch operation {
91 | case .insert(let offset, let element, _):
92 | guard let data = try? context.codingContext.encode(element) else { return nil }
93 | return .insert([], index: offset, data)
94 | case .remove(offset: let offset, _, _):
95 | return .delete([.index(offset)])
96 | }
97 | }
98 | }
99 |
100 | func subEvents(for value: [Element], for context: ConnectionContext) -> AnyPublisher {
101 | return value
102 | .enumerated()
103 | .map { item -> AnyPublisher in
104 | let (offset, element) = item
105 | return self.elementStrategy.subEvents(for: element, for: context)
106 | .map { $0.prefix(by: offset) }.eraseToAnyPublisher()
107 | }
108 | .mergeMany()
109 | }
110 | }
111 |
112 | extension Array: HasErasedSyncStrategy where Element: Codable {}
113 |
114 | extension Array: SyncableType where Element: Codable {
115 | static var strategy: ArrayStrategy {
116 | let equivalenceDetector = extractEquivalenceDetector(for: Element.self)
117 | return ArrayStrategy(extractStrategy(for: Element.self), equivalenceDetector: equivalenceDetector)
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Codable.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | class CodableStrategy: SyncStrategy {
6 | enum CodableEventHandlingError: Error {
7 | case codableCannotBeDeleted
8 | case codableDoesNotAcceptSubPaths
9 | }
10 |
11 | init() { }
12 |
13 | func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult {
14 | guard case .write(let path, let data) = event else {
15 | throw CodableEventHandlingError.codableCannotBeDeleted
16 | }
17 | guard path.isEmpty else {
18 | throw CodableEventHandlingError.codableDoesNotAcceptSubPaths
19 | }
20 | value = try context.codingContext.decode(data: data, as: Value.self)
21 | return .alertRemainingConnections
22 | }
23 |
24 | func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] {
25 | guard let data = try? context.codingContext.encode(next) else { return [] }
26 | return [.write([], data)]
27 | }
28 |
29 | func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher {
30 | return Empty(completeImmediately: false).eraseToAnyPublisher()
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Optional.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | class OptionalStrategy: SyncStrategy {
6 | enum OptionalEventHandlingError: Error {
7 | case cannotHandleInsertion
8 | case cannotPropagateInsertionToSubPathOfNil
9 | case cannotPropagateDeletionToSubPathOfNil
10 | case cannotPropagateWriteToSubPathOfNil
11 | }
12 | typealias Value = Wrapped?
13 |
14 | let wrappedStrategy: AnySyncStrategy
15 |
16 | init(_ wrappedStrategy: AnySyncStrategy) {
17 | self.wrappedStrategy = wrappedStrategy
18 | }
19 |
20 | func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Wrapped?) throws -> EventSyncHandlingResult {
21 | switch event {
22 | case .insert(let path, _, _) where path.isEmpty:
23 | throw OptionalEventHandlingError.cannotHandleInsertion
24 | case .delete(let path, _) where path.isEmpty:
25 | value = nil
26 | return .alertRemainingConnections
27 | case .delete:
28 | guard case .some(var wrapped) = value else {
29 | throw OptionalEventHandlingError.cannotPropagateDeletionToSubPathOfNil
30 | }
31 | let result = try wrappedStrategy.handle(event: event, from: context, for: &wrapped)
32 | value = wrapped
33 | return result
34 | case .write(let path, let data):
35 | switch value {
36 | case .none where path.isEmpty:
37 | value = try context.codingContext.decode(data: data, as: Wrapped.self)
38 | return .alertRemainingConnections
39 | case .none:
40 | throw OptionalEventHandlingError.cannotPropagateWriteToSubPathOfNil
41 | case .some(var wrapped):
42 | let result = try wrappedStrategy.handle(event: event, from: context, for: &wrapped)
43 | value = wrapped
44 | return result
45 | }
46 | case .insert(_, _, _):
47 | switch value {
48 | case .none:
49 | throw OptionalEventHandlingError.cannotPropagateInsertionToSubPathOfNil
50 | case .some(var wrapped):
51 | let result = try wrappedStrategy.handle(event: event, from: context, for: &wrapped)
52 | value = wrapped
53 | return result
54 | }
55 | }
56 | }
57 |
58 | func events(from previous: Wrapped?, to next: Wrapped?, for context: ConnectionContext) -> [InternalEvent] {
59 | switch (previous, next) {
60 | case (.some, .none):
61 | return [.delete([])]
62 | default:
63 | guard let data = try? context.codingContext.encode(next) else { return [] }
64 | return [.write([], data)]
65 | }
66 | }
67 |
68 | func subEvents(for value: Wrapped?, for context: ConnectionContext) -> AnyPublisher {
69 | guard let value = value else {
70 | return Empty(completeImmediately: false).eraseToAnyPublisher()
71 | }
72 | return wrappedStrategy.subEvents(for: value, for: context)
73 | }
74 | }
75 |
76 | extension Optional: HasErasedSyncStrategy where Wrapped: Codable {}
77 |
78 | extension Optional: SyncableType where Wrapped: Codable {
79 | static var strategy: OptionalStrategy {
80 | return OptionalStrategy(extractStrategy(for: Wrapped.self))
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/String.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | class StringStrategy: SyncStrategy {
6 | enum StringEventHandlingError: Error {
7 | case expectedIntIndexInPathButReceivedSomethingElse
8 | case intIndexReceivedOutOfBounds(Int)
9 | case deletionOfEntireStringNotAllowed
10 | case deletionOfSubIndexNotAllowed
11 | case writingToSubIndexNotAllowed
12 | case insertingToSubIndexNotAllowed
13 | case deletionOfNegativeWidthNotAllowed
14 | }
15 |
16 | typealias Value = String
17 |
18 | init() { }
19 |
20 | func handle(event: InternalEvent, from context: ConnectionContext, for value: inout String) throws -> EventSyncHandlingResult {
21 | switch event {
22 | case .insert(let path, let offset, let data) where path.isEmpty:
23 | let index = value.index(value.startIndex, offsetBy: offset)
24 | guard value.indices.contains(index) || value.endIndex == index else {
25 | throw StringEventHandlingError.intIndexReceivedOutOfBounds(offset)
26 | }
27 | let string = try context.codingContext.decode(data: data, as: String.self)
28 | value.insert(contentsOf: string, at: index)
29 | case .write(let path, let data) where path.isEmpty:
30 | value = try context.codingContext.decode(data: data, as: String.self)
31 | return .alertRemainingConnections
32 | case .write(let path, let data) where path.count == 1:
33 | guard case .some(.index(let offset)) = path.first else {
34 | throw StringEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse
35 | }
36 | let index = value.index(value.startIndex, offsetBy: offset)
37 | if value.indices.contains(index) {
38 |
39 | } else if value.endIndex == index && path.count == 1 {
40 | value.append(try context.codingContext.decode(data: data, as: String.self))
41 | } else {
42 | throw StringEventHandlingError.intIndexReceivedOutOfBounds(offset)
43 | }
44 | case .delete(let path, let width) where path.count == 1:
45 | guard case .some(.index(let offset)) = path.first else {
46 | throw StringEventHandlingError.expectedIntIndexInPathButReceivedSomethingElse
47 | }
48 | guard width != 0 else { return .done }
49 | guard width > 0 else { throw StringEventHandlingError.deletionOfNegativeWidthNotAllowed }
50 |
51 | let start = value.index(value.startIndex, offsetBy: offset)
52 | let end = value.index(start, offsetBy: width)
53 | let range = start.. [InternalEvent] {
72 | let differences = next.stringDifference(from: previous)
73 | let isWritingWholeValue = differences.contains { change in
74 | switch change {
75 | case .insert(_, let element, _):
76 | return element == next
77 | case .remove(_, let element, _):
78 | return element == previous
79 | }
80 | }
81 | guard differences.count < next.count, !isWritingWholeValue else {
82 | guard let data = try? context.codingContext.encode(next) else { return [] }
83 | return [.write([], data)]
84 | }
85 |
86 | return differences.compactMap { operation in
87 | switch operation {
88 | case .insert(let offset, let element, _):
89 | guard let data = try? context.codingContext.encode(element) else { return nil }
90 | return .insert([], index: offset, data)
91 | case .remove(offset: let offset, let element, _):
92 | return .delete([.index(offset)], width: element.count)
93 | }
94 | }
95 | }
96 |
97 | func subEvents(for value: String, for context: ConnectionContext) -> AnyPublisher {
98 | return Empty(completeImmediately: false).eraseToAnyPublisher()
99 | }
100 | }
101 |
102 | extension String: SyncableType {
103 | static let strategy: StringStrategy = StringStrategy()
104 | }
105 |
106 | extension String {
107 |
108 | fileprivate func stringDifference(from previous: String) -> CollectionDifference {
109 | var changes: [CollectionDifference.Change] = []
110 | var current: CollectionDifference.Change?
111 |
112 | for change in difference(from: previous) {
113 | switch (current, change) {
114 | case (.some(.insert(let lhsOffset, let lhsElement, let lhsWidth)), .insert(let rhsOffset, let rhsElement, let rhsWidth)) where (lhsOffset + lhsElement.count) == rhsOffset:
115 | current = .insert(offset: lhsOffset, element: lhsElement + String(rhsElement), associatedWith: lhsWidth + rhsWidth)
116 | case (.some(.remove(let lhsOffset, let lhsElement, let lhsWidth)), .remove(let rhsOffset, let rhsElement, let rhsWidth)) where (lhsOffset + lhsElement.count) == rhsOffset:
117 | current = .remove(offset: lhsOffset, element: lhsElement + String(rhsElement), associatedWith: lhsWidth + rhsWidth)
118 | case (.some(.insert(let lhsOffset, let lhsElement, let lhsWidth)), .insert(let rhsOffset, let rhsElement, let rhsWidth)) where (rhsOffset + 1) == lhsOffset:
119 | current = .insert(offset: rhsOffset, element: String(rhsElement) + lhsElement, associatedWith: lhsWidth + rhsWidth)
120 | case (.some(.remove(let lhsOffset, let lhsElement, let lhsWidth)), .remove(let rhsOffset, let rhsElement, let rhsWidth)) where (rhsOffset + 1) == lhsOffset:
121 | current = .remove(offset: rhsOffset, element: String(rhsElement) + lhsElement, associatedWith: lhsWidth + rhsWidth)
122 | case (_, .remove(let offset, let element, let width)):
123 | if let current = current {
124 | changes.append(current)
125 | }
126 | current = .remove(offset: offset, element: String(element), associatedWith: width)
127 | case (_, .insert(let offset, let element, let width)):
128 | if let current = current {
129 | changes.append(current)
130 | }
131 | current = .insert(offset: offset, element: String(element), associatedWith: width)
132 | }
133 | }
134 |
135 | if let current = current {
136 | changes.append(current)
137 | }
138 |
139 | return CollectionDifference(changes)!
140 | }
141 | }
142 |
143 | fileprivate func + (lhs: Int?, rhs: Int?) -> Int? {
144 | switch (lhs, rhs) {
145 | case (.some(let lhs), .some(let rhs)):
146 | return lhs + rhs
147 | case (_, .none):
148 | return lhs
149 | case (.none, _):
150 | return rhs
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/AnyEquivalenceDetector.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | struct AnyEquivalenceDetector: EquivalenceDetector {
5 | private class BaseStorage {
6 | func areEquivalent(lhs: Value, rhs: Value) -> Bool {
7 | fatalError()
8 | }
9 | }
10 |
11 | private final class Storage: BaseStorage where Detector.Value == Value {
12 | let detector: Detector
13 |
14 | init(_ detector: Detector) {
15 | self.detector = detector
16 | }
17 |
18 | override func areEquivalent(lhs: Value, rhs: Value) -> Bool {
19 | return detector.areEquivalent(lhs: lhs, rhs: rhs)
20 | }
21 | }
22 |
23 | private let storage: BaseStorage
24 |
25 | init(_ detector: Detector) where Detector.Value == Value {
26 | self.storage = Storage(detector)
27 | }
28 |
29 | func areEquivalent(lhs: Value, rhs: Value) -> Bool {
30 | return storage.areEquivalent(lhs: lhs, rhs: rhs)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/EquatableEquivalenceDetector.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | struct EquatableEquivalenceDetector: EquivalenceDetector {
5 | func areEquivalent(lhs: Value, rhs: Value) -> Bool {
6 | return lhs == rhs
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/EquivalenceDetector.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | protocol EquivalenceDetector {
5 | associatedtype Value
6 |
7 | func areEquivalent(lhs: Value, rhs: Value) -> Bool
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/ErasedEquivalenceDetector.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | protocol HasErasedErasedEquivalenceDetector {
5 | static var erasedEquivalenceDetector: ErasedEquivalenceDetector? { get }
6 | }
7 |
8 | class ErasedEquivalenceDetector {
9 | private let detector: Any
10 |
11 | init(_ detector: T) {
12 | self.detector = AnyEquivalenceDetector(detector)
13 | }
14 |
15 | func read() -> AnyEquivalenceDetector {
16 | guard let detector = detector as? AnyEquivalenceDetector else { fatalError() }
17 | return detector
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/OptionalEquivalenceDetector.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | struct OptionalEquivalenceDetector: EquivalenceDetector {
5 | typealias Value = Wrapped?
6 |
7 | let detector: AnyEquivalenceDetector
8 |
9 | func areEquivalent(lhs: Wrapped?, rhs: Wrapped?) -> Bool {
10 | switch (lhs, rhs) {
11 | case (.none, .none):
12 | return true
13 | case (.some(let lhs), .some(let rhs)):
14 | return detector.areEquivalent(lhs: lhs, rhs: rhs)
15 | default:
16 | return false
17 | }
18 | }
19 | }
20 |
21 | extension Optional: HasErasedErasedEquivalenceDetector {
22 | static var erasedEquivalenceDetector: ErasedEquivalenceDetector? {
23 | guard let detector = extractEquivalenceDetector(for: Wrapped.self) else { return nil }
24 | return ErasedEquivalenceDetector(OptionalEquivalenceDetector(detector: detector))
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/ReferenceEquivalenceDetector.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | struct ReferenceEquivalenceDetector: EquivalenceDetector {
5 | func areEquivalent(lhs: Value, rhs: Value) -> Bool {
6 | return lhs === rhs
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Utils/Equivalence/extractEquivalenceDetector.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | #if canImport(AssociatedTypeRequirementsVisitor)
4 | @_implementationOnly import AssociatedTypeRequirementsVisitor
5 | #endif
6 |
7 | func extractEquivalenceDetector(for type: T.Type) -> AnyEquivalenceDetector? {
8 | #if canImport(AssociatedTypeRequirementsVisitor)
9 | if let equatableDetector = EquivalenceDetectorForEquatableFatory.detector(for: type) {
10 | return equatableDetector.read()
11 | }
12 | #endif
13 | if let type = type as? HasErasedErasedEquivalenceDetector.Type {
14 | return type.erasedEquivalenceDetector?.read()
15 | }
16 | if let type = type as? SyncableObject.Type {
17 | return type.erasedEquivalenceDetector.read()
18 | }
19 | return nil
20 | }
21 |
22 | extension SyncableObject {
23 | static var erasedEquivalenceDetector: ErasedEquivalenceDetector {
24 | return ErasedEquivalenceDetector(ReferenceEquivalenceDetector())
25 | }
26 | }
27 |
28 | #if canImport(AssociatedTypeRequirementsVisitor)
29 | private struct EquivalenceDetectorForEquatableFatory: EquatableTypeVisitor {
30 | typealias Output = ErasedEquivalenceDetector
31 |
32 | private static let shared = EquivalenceDetectorForEquatableFatory()
33 |
34 | private init() {}
35 |
36 | func callAsFunction(_ type: T.Type) -> ErasedEquivalenceDetector where T : Equatable {
37 | return ErasedEquivalenceDetector(EquatableEquivalenceDetector())
38 | }
39 |
40 | public static func detector(for type: Any.Type) -> ErasedEquivalenceDetector? {
41 | return shared(type)
42 | }
43 | }
44 | #endif
45 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Basics/Utils/extractStrategy.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | func extractStrategy(for type: T.Type) -> AnySyncStrategy {
5 | if let type = type as? HasErasedSyncStrategy.Type {
6 | return type.erasedStrategy.read()
7 | }
8 |
9 | if let type = type as? SyncableObject.Type {
10 | return type.erasedStrategy.read()
11 | }
12 |
13 | return AnySyncStrategy(CodableStrategy())
14 | }
15 |
16 | extension SyncableObject {
17 |
18 | static var erasedStrategy: ErasedSyncStrategy {
19 | return ErasedSyncStrategy(SyncableObjectStrategy())
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Synced Objects/SyncableObjectStrategy.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | class SyncableObjectStrategy: SyncStrategy {
6 | enum ObjectEventHandlingError: Error {
7 | case cannotHandleInsertion
8 | case cannotDeleteSyncedObject
9 | case pathForObjectWasNotAStringLabel
10 | case syncedPropertyForLabelNotFound(String)
11 | }
12 |
13 | var identifier: ObjectIdentifier?
14 | var strategiesPerPath: [String : SelfContainedStrategy]? = nil
15 |
16 | init() {}
17 |
18 | private func computeStrategies(for value: Value) -> [String : SelfContainedStrategy] {
19 | if let strategiesPerPath = strategiesPerPath, let identifier = identifier, identifier == ObjectIdentifier(value) {
20 | return strategiesPerPath
21 | }
22 |
23 | var strategiesPerPath = [String : SelfContainedStrategy]()
24 | let mirror = Mirror(reflecting: value)
25 | for child in mirror.children {
26 | guard let label = child.label, let value = child.value as? SelfContainedStrategy else { continue }
27 | strategiesPerPath[label] = value
28 | }
29 |
30 | self.strategiesPerPath = strategiesPerPath
31 | return strategiesPerPath
32 | }
33 |
34 | func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult {
35 | switch event {
36 | case .insert(let path, _, _) where path.isEmpty:
37 | throw ObjectEventHandlingError.cannotHandleInsertion
38 | case .delete(let path, _) where path.isEmpty:
39 | throw ObjectEventHandlingError.cannotDeleteSyncedObject
40 | case .delete(let path, _), .write(let path, _), .insert(let path, _, _):
41 | let strategiesPerPath = computeStrategies(for: value)
42 | guard case .some(.name(let label)) = path.first else {
43 | throw ObjectEventHandlingError.pathForObjectWasNotAStringLabel
44 | }
45 | guard let strategy = strategiesPerPath[label] else {
46 | throw ObjectEventHandlingError.syncedPropertyForLabelNotFound(label)
47 | }
48 | try strategy.handle(event: event.oneLevelLower(), from: context)
49 | return .done
50 | }
51 | }
52 |
53 | func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] {
54 | guard let data = try? context.codingContext.encode(next) else { return [] }
55 | return [.write([], data)]
56 | }
57 |
58 | func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher {
59 | return computeStrategies(for: value)
60 | .map { item -> AnyPublisher in
61 | let (label, strategy) = item
62 | return strategy.events(for: context).map { $0.prefix(by: label) }.eraseToAnyPublisher()
63 | }
64 | .mergeMany()
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Type Erasure/AnySyncStrategy.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | class AnySyncStrategy: SyncStrategy {
6 | private class BaseStorage {
7 | func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult {
8 | fatalError()
9 | }
10 |
11 | func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] {
12 | fatalError()
13 | }
14 |
15 | func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher {
16 | fatalError()
17 | }
18 | }
19 |
20 | private class Storage: BaseStorage where Strategy.Value == Value {
21 | private let strategy: Strategy
22 |
23 | init(_ strategy: Strategy) {
24 | self.strategy = strategy
25 | }
26 |
27 | override func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult {
28 | return try strategy.handle(event: event, from: context, for: &value)
29 | }
30 |
31 | override func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] {
32 | return strategy.events(from: previous, to: next, for: context)
33 | }
34 |
35 | override func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher {
36 | return strategy.subEvents(for: value, for: context)
37 | }
38 | }
39 |
40 | private let storage: BaseStorage
41 |
42 | init(_ strategy: Strategy) where Strategy.Value == Value {
43 | self.storage = Storage(strategy)
44 | }
45 |
46 | func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult {
47 | return try storage.handle(event: event, from: context, for: &value)
48 | }
49 |
50 | func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent] {
51 | return storage.events(from: previous, to: next, for: context)
52 | }
53 |
54 | func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher {
55 | return storage.subEvents(for: value, for: context)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/Implementation/Type Erasure/ErasedSyncStrategy.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | protocol HasErasedSyncStrategy: Codable {
5 | static var erasedStrategy: ErasedSyncStrategy { get }
6 | }
7 |
8 | class ErasedSyncStrategy {
9 | private let strategy: Any
10 |
11 | init(_ strategy: Strategy) {
12 | self.strategy = AnySyncStrategy(strategy)
13 | }
14 |
15 | func read() -> AnySyncStrategy {
16 | guard let strategy = strategy as? AnySyncStrategy else { fatalError() }
17 | return strategy
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/SyncStrategy.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | enum EventSyncHandlingResult {
6 | case done
7 | case alertRemainingConnections
8 | }
9 |
10 | protocol ConnectionContext {
11 | var id: UUID { get }
12 | var type: ConnectionType { get }
13 | var connection: Connection { get }
14 | }
15 |
16 | extension ConnectionContext {
17 |
18 | var codingContext: EventCodingContext {
19 | return connection.codingContext
20 | }
21 |
22 | }
23 |
24 | protocol SyncStrategy {
25 | associatedtype Value
26 |
27 | func handle(event: InternalEvent, from context: ConnectionContext, for value: inout Value) throws -> EventSyncHandlingResult
28 |
29 | func events(from previous: Value, to next: Value, for context: ConnectionContext) -> [InternalEvent]
30 | func subEvents(for value: Value, for context: ConnectionContext) -> AnyPublisher
31 | }
32 |
33 | protocol SelfContainedStrategy {
34 | func handle(event: InternalEvent, from context: ConnectionContext) throws
35 | func events(for context: ConnectionContext) -> AnyPublisher
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/Sync/Strategies/SyncableType.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | protocol SyncableType: HasErasedSyncStrategy {
5 | associatedtype Strategy: SyncStrategy where Strategy.Value == Self
6 |
7 | static var strategy: Strategy { get }
8 | }
9 |
10 | extension SyncableType {
11 | static var erasedStrategy: ErasedSyncStrategy {
12 | return ErasedSyncStrategy(strategy)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/Sync/SwiftUI/Reconnection/ReconnectionStrategy.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | public enum ReconnectionDecision {
5 | case attemptToReconnect
6 | case stop
7 | }
8 |
9 | public protocol ReconnectionStrategy {
10 | func maybeReconnect() async -> ReconnectionDecision
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/Sync/SwiftUI/Reconnection/TryAgainReconnectionStrategy.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 |
4 | extension ReconnectionStrategy where Self == TryAgainReconnectionStrategy {
5 | public static func tryAgain(delay: TimeInterval) -> ReconnectionStrategy {
6 | return TryAgainReconnectionStrategy(delay: delay)
7 | }
8 | }
9 |
10 | public struct TryAgainReconnectionStrategy: ReconnectionStrategy {
11 | private let delay: TimeInterval
12 |
13 | public init(delay: TimeInterval) {
14 | self.delay = delay
15 | }
16 |
17 | public func maybeReconnect() async -> ReconnectionDecision {
18 | do {
19 | try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
20 | return .attemptToReconnect
21 | } catch {
22 | return .attemptToReconnect
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/Sync/SwiftUI/Sync.swift:
--------------------------------------------------------------------------------
1 |
2 | #if canImport(SwiftUI)
3 | import SwiftUI
4 | @_exported import OpenCombineShim
5 |
6 | public struct Sync: View {
7 | @StateObject
8 | private var viewModel: SyncViewModel
9 | private let loading: LoadingView
10 | private let error: (Error) -> ErrorView
11 | private let content: (SyncedObject) -> Content
12 |
13 | public init(_ type: Value.Type = Value.self,
14 | using connection: ConsumerConnection,
15 | reconnectionStrategy: ReconnectionStrategy? = nil,
16 | @ViewBuilder content: @escaping (SyncedObject) -> Content,
17 | @ViewBuilder loading: () -> LoadingView,
18 | @ViewBuilder error: @escaping (Error) -> ErrorView) {
19 |
20 | self._viewModel = StateObject(wrappedValue: SyncViewModel(connection: connection, reconnectionStrategy: reconnectionStrategy))
21 | self.loading = loading()
22 | self.error = error
23 | self.content = content
24 | }
25 |
26 | public var body: some View {
27 | if let synced = viewModel.synced {
28 | content(synced)
29 | } else if let error = viewModel.error {
30 | self.error(error)
31 | } else {
32 | loading
33 | .onAppear {
34 | Task {
35 | await viewModel.loadIfNeeded()
36 | }
37 | }
38 | }
39 | }
40 | }
41 |
42 | extension Sync where LoadingView == AnyView {
43 |
44 | public init(_ type: Value.Type = Value.self,
45 | using connection: ConsumerConnection,
46 | reconnectionStrategy: ReconnectionStrategy? = nil,
47 | @ViewBuilder content: @escaping (SyncedObject) -> Content,
48 | @ViewBuilder error: @escaping (Error) -> ErrorView) {
49 |
50 | self.init(type,
51 | using: connection,
52 | reconnectionStrategy: reconnectionStrategy,
53 | content: content,
54 | loading: { AnyView(BasicLoadingView()) },
55 | error: error)
56 | }
57 |
58 | }
59 |
60 | extension Sync where ErrorView == AnyView {
61 |
62 |
63 | public init(_ type: Value.Type = Value.self,
64 | using connection: ConsumerConnection,
65 | reconnectionStrategy: ReconnectionStrategy? = nil,
66 | @ViewBuilder content: @escaping (SyncedObject) -> Content,
67 | @ViewBuilder loading: () -> LoadingView) {
68 |
69 | self.init(type,
70 | using: connection,
71 | reconnectionStrategy: reconnectionStrategy,
72 | content: content,
73 | loading: loading,
74 | error: { AnyView(BasicErrorView(error: $0)) })
75 | }
76 |
77 | }
78 |
79 | extension Sync where LoadingView == AnyView, ErrorView == AnyView {
80 |
81 | public init(_ type: Value.Type = Value.self,
82 | using connection: ConsumerConnection,
83 | reconnectionStrategy: ReconnectionStrategy? = nil,
84 | @ViewBuilder content: @escaping (SyncedObject) -> Content) {
85 |
86 | self.init(type,
87 | using: connection,
88 | reconnectionStrategy: reconnectionStrategy,
89 | content: content,
90 | loading: { AnyView(BasicLoadingView()) },
91 | error: { AnyView(BasicErrorView(error: $0)) })
92 | }
93 |
94 | public init(_ type: Value.Type = Value.self,
95 | using syncManager: SyncManager,
96 | @ViewBuilder content: @escaping (SyncedObject) -> Content) {
97 |
98 | self._viewModel = StateObject(wrappedValue: SyncViewModel(syncManager: syncManager))
99 | self.content = content
100 | self.loading = AnyView(BasicLoadingView())
101 | self.error = { AnyView(BasicErrorView(error: $0)) }
102 | }
103 |
104 | }
105 |
106 | private struct BasicLoadingView: View {
107 | var body: some View {
108 | Text("Loading")
109 | }
110 | }
111 |
112 | private struct BasicErrorView: View {
113 | let error: Error
114 |
115 | var body: some View {
116 | Text("Error: \(error.localizedDescription)")
117 | }
118 | }
119 |
120 | fileprivate class SyncViewModel: ObservableObject {
121 | private enum State {
122 | case loading(ConsumerConnection)
123 | case synced(SyncedObject)
124 | }
125 |
126 | @Published
127 | private var state: State
128 |
129 | @Published
130 | private var isLoading: Bool = false
131 |
132 | @Published
133 | private(set) var error: Error?
134 |
135 | private var cancellables: Set = []
136 | private let reconnectionStrategy: ReconnectionStrategy?
137 | private var reconnectionTask: Task? = nil
138 |
139 | var synced: SyncedObject? {
140 | switch state {
141 | case .synced(let object):
142 | return object
143 | case .loading:
144 | return nil
145 | }
146 | }
147 |
148 | init(connection: ConsumerConnection, reconnectionStrategy: ReconnectionStrategy?) {
149 | self.state = .loading(connection)
150 | self.reconnectionStrategy = reconnectionStrategy
151 | }
152 |
153 | init(syncManager: SyncManager) {
154 | self.state = .synced(try! SyncedObject(syncManager: syncManager))
155 | self.reconnectionStrategy = nil
156 | }
157 |
158 | deinit {
159 | reconnectionTask?.cancel()
160 | }
161 |
162 | func loadIfNeeded() async {
163 | switch state {
164 | case .synced:
165 | return
166 | case .loading(let connection):
167 | guard !isLoading else { return }
168 | isLoading = true
169 | do {
170 | reconnectionTask?.cancel()
171 | cancellables = []
172 | let manager = try await Value.sync(with: connection)
173 | // For some reason Swift says this needs an await ¯\_(ツ)_/¯
174 | let object = try await SyncedObject(syncManager: manager)
175 |
176 | if let reconnectionStrategy = reconnectionStrategy {
177 | connection
178 | .isConnectedPublisher
179 | .removeDuplicates()
180 | .filter { !$0 }
181 | .receive(on: DispatchQueue.global())
182 | .sink { [unowned self] _ in
183 | self.reconnectionTask?.cancel()
184 | self.reconnectionTask = Task { [unowned self] in
185 | while case .attemptToReconnect = await reconnectionStrategy.maybeReconnect() {
186 | do {
187 | guard try await manager.reconnect() else { return }
188 | let updateTask = Task { @MainActor in
189 | object.forceUpdate(value: try manager.value())
190 | }
191 | try await updateTask.value
192 | return
193 | } catch {
194 | DispatchQueue.main.async { [weak self] in
195 | self?.error = error
196 | }
197 | }
198 | }
199 | }
200 | }
201 | .store(in: &cancellables)
202 | }
203 | let state: State = .synced(object)
204 | DispatchQueue.main.async { [weak self] in
205 | self?.state = state
206 | }
207 | } catch {
208 | DispatchQueue.main.async { [weak self] in
209 | self?.isLoading = false
210 | self?.error = error
211 | }
212 | }
213 | }
214 | }
215 | }
216 | #endif
217 |
--------------------------------------------------------------------------------
/Sources/Sync/SwiftUI/SyncedObject.swift:
--------------------------------------------------------------------------------
1 |
2 | #if canImport(SwiftUI)
3 | import SwiftUI
4 | @_exported import OpenCombineShim
5 |
6 | @dynamicMemberLookup
7 | @propertyWrapper
8 | public struct SyncedObject: DynamicProperty {
9 | private class Storage {
10 | var value: Value
11 |
12 | init(value: Value) {
13 | self.value = value
14 | }
15 | }
16 |
17 | @ObservedObject
18 | private var fakeObservable: FakeObservableObject
19 |
20 | private let manager: AnyManager
21 | private let storage: Storage
22 |
23 | public var wrappedValue: Value {
24 | get {
25 | return storage.value
26 | }
27 | }
28 |
29 | public var projectedValue: SyncedObject {
30 | return self
31 | }
32 |
33 | private init(value: Value, manager: AnyManager) {
34 | self.fakeObservable = FakeObservableObject(manager: manager)
35 | self.storage = Storage(value: value)
36 | self.manager = manager
37 | }
38 |
39 | init(syncManager: SyncManager) throws {
40 | self.init(value: try syncManager.value(), manager: Manager(manager: syncManager))
41 | }
42 |
43 | func forceUpdate(value: Value) {
44 | self.storage.value = value
45 | fakeObservable.forceUpdate()
46 | }
47 | }
48 |
49 | extension SyncedObject {
50 |
51 | public var connection: Connection {
52 | return manager.connection
53 | }
54 |
55 | }
56 |
57 | extension SyncedObject {
58 |
59 | public subscript(dynamicMember keyPath: KeyPath) -> SyncedObject {
60 | return SyncedObject(value: storage.value[keyPath: keyPath], manager: manager)
61 | }
62 |
63 | public subscript(dynamicMember keyPath: WritableKeyPath) -> Binding {
64 | return Binding(get: { self.storage.value[keyPath: keyPath] }, set: { self.storage.value[keyPath: keyPath] = $0 })
65 | }
66 |
67 | }
68 |
69 | private final class FakeObservableObject: ObservableObject {
70 | private let manualUpdate = PassthroughSubject()
71 | private let manager: AnyManager
72 |
73 | let objectWillChange = ObservableObjectPublisher()
74 | private var cancellables: Set = []
75 |
76 | init(manager: AnyManager) {
77 | self.manager = manager
78 | let changeEvents = manager.eventHasChanged
79 | let connectionChange = manager.connection.isConnectedPublisher.removeDuplicates().map { _ in () }
80 |
81 | changeEvents
82 | .merge(with: connectionChange)
83 | .merge(with: manualUpdate)
84 | #if canImport(SwiftUI)
85 | .receive(on: DispatchQueue.main)
86 | #endif
87 | .sink { [unowned self] in objectWillChange.send() }
88 | .store(in: &cancellables)
89 | }
90 |
91 | func forceUpdate() {
92 | manualUpdate.send()
93 | }
94 | }
95 |
96 | private class AnyManager {
97 | var connection: Connection {
98 | fatalError()
99 | }
100 |
101 | var eventHasChanged: AnyPublisher {
102 | fatalError()
103 | }
104 | }
105 |
106 | private final class Manager: AnyManager {
107 | let manager: SyncManager
108 |
109 | init(manager: SyncManager) {
110 | self.manager = manager
111 | }
112 |
113 | override var eventHasChanged: AnyPublisher {
114 | return manager.eventHasChanged
115 | }
116 |
117 | override var connection: Connection {
118 | return manager.connection
119 | }
120 | }
121 | #endif
122 |
--------------------------------------------------------------------------------
/Sources/Sync/SyncManager.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | enum ConnectionType {
6 | case consumer
7 | case producer
8 | }
9 |
10 | struct BasicConnectionContext: ConnectionContext {
11 | let id: UUID
12 | let connection: Connection
13 | let type: ConnectionType
14 | }
15 |
16 | public class SyncManager {
17 | enum SyncManagerError: Error {
18 | case unretainedValueWasReleased
19 | }
20 |
21 | private class BaseStorage {
22 | var object: Value? {
23 | return nil
24 | }
25 |
26 | func set(value: Value) {
27 | fatalError()
28 | }
29 | }
30 |
31 | private final class RetainedStorage: BaseStorage {
32 | private var value: Value
33 |
34 | init(value: Value) {
35 | self.value = value
36 | }
37 |
38 | override var object: Value? {
39 | return value
40 | }
41 |
42 | override func set(value: Value) {
43 | self.value = value
44 | }
45 | }
46 |
47 | private final class WeakStorage: BaseStorage {
48 | private weak var value: Value?
49 |
50 | init(value: Value) {
51 | self.value = value
52 | }
53 |
54 | override var object: Value? {
55 | return value
56 | }
57 |
58 | override func set(value: Value) {
59 | self.value = value
60 | }
61 | }
62 |
63 | private let id = UUID()
64 | private let connectionType: ConnectionType
65 | private let strategy: AnySyncStrategy
66 | private let storage: BaseStorage
67 | public let connection: Connection
68 | private var cancellables: Set = []
69 | private let errorsSubject = PassthroughSubject()
70 | private let hasChangedSubject = PassthroughSubject()
71 |
72 | public var isConnected: Bool {
73 | return connection.isConnected
74 | }
75 |
76 | public var eventHasChanged: AnyPublisher {
77 | return hasChangedSubject.eraseToAnyPublisher()
78 | }
79 |
80 | init(_ value: Value, connection: Connection, connectionType: ConnectionType) {
81 | self.strategy = extractStrategy(for: Value.self)
82 | self.storage = RetainedStorage(value: value)
83 | self.connection = connection
84 | self.connectionType = connectionType
85 | setUpConnection()
86 | }
87 |
88 | init(weak value: Value, connection: Connection, connectionType: ConnectionType) {
89 | self.strategy = extractStrategy(for: Value.self)
90 | self.storage = WeakStorage(value: value)
91 | self.connection = connection
92 | self.connectionType = connectionType
93 | setUpConnection()
94 | }
95 |
96 | public func value() throws -> Value {
97 | guard let value = storage.object else {
98 | throw SyncManagerError.unretainedValueWasReleased
99 | }
100 | return value
101 | }
102 |
103 | public func data() throws -> Data {
104 | return try connection.codingContext.encode(try value())
105 | }
106 |
107 | @discardableResult
108 | public func reconnect() async throws -> Bool {
109 | guard let connection = connection as? ConsumerConnection else {
110 | return false
111 | }
112 |
113 | if connection.isConnected {
114 | connection.disconnect()
115 | }
116 | let data = try await connection.connect()
117 | do {
118 | let value = try connection.codingContext.decode(data: data, as: Value.self)
119 | storage.set(value: value)
120 | setUpConnection()
121 | hasChangedSubject.send()
122 | return true
123 | } catch {
124 | connection.disconnect()
125 | throw error
126 | }
127 | }
128 |
129 | private func setUpConnection() {
130 | let context = BasicConnectionContext(id: id, connection: connection, type: connectionType)
131 | cancellables = []
132 | connection
133 | .receive()
134 | .sink { [unowned self] data in
135 | do {
136 | var value = try self.value()
137 | let event = try self.connection.codingContext.decode(data: data, as: InternalEvent.self)
138 | _ = try self.strategy.handle(event: event, from: context, for: &value)
139 | self.hasChangedSubject.send()
140 | } catch {
141 | self.connection.disconnect()
142 | self.errorsSubject.send(error)
143 | }
144 | }
145 | .store(in: &cancellables)
146 |
147 | guard let value = storage.object else { return }
148 | strategy
149 | .subEvents(for: value,
150 | for: context)
151 | .sink { [unowned self] event in
152 | do {
153 | self.hasChangedSubject.send()
154 | let data = try self.connection.codingContext.encode(event)
155 | self.connection.send(data: data)
156 | } catch {
157 | self.connection.disconnect()
158 | self.errorsSubject.send(error)
159 | }
160 | }
161 | .store(in: &cancellables)
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/Sources/Sync/SyncableObject.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 |
5 | public protocol SyncableObject: AnyObject, Codable { }
6 |
7 | extension SyncableObject {
8 | public func sync(with connection: ProducerConnection) -> SyncManager {
9 | return SyncManager(self, connection: connection, connectionType: .producer)
10 | }
11 |
12 | public func syncWithoutRetainingInMemory(with connection: ProducerConnection) -> SyncManager {
13 | return SyncManager(weak: self, connection: connection, connectionType: .producer)
14 | }
15 |
16 | public static func sync(with connection: ConsumerConnection) async throws -> SyncManager {
17 | let data = try await connection.connect()
18 | do {
19 | let value = try connection.codingContext.decode(data: data, as: Self.self)
20 | return SyncManager(value, connection: connection, connectionType: .consumer)
21 | } catch {
22 | connection.disconnect()
23 | throw error
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/Sync/Synced.swift:
--------------------------------------------------------------------------------
1 |
2 | import Foundation
3 | @_exported import OpenCombineShim
4 | import Accessibility
5 |
6 | public enum SyncedWriteRights: UInt8, Codable {
7 | case shared
8 | case protected
9 | }
10 |
11 | @propertyWrapper
12 | public final class Synced: Codable {
13 | enum SyncedEventHandlingError: Error {
14 | case receivedIllegalEventForProtectedProperty
15 | }
16 |
17 | private struct ConnectionState {
18 | let value: Value?
19 | let events: AnyPublisher
20 | }
21 |
22 | private enum ValueChange {
23 | case local(Value)
24 | case remote(Value, connectionId: UUID)
25 |
26 | var value: Value {
27 | switch self {
28 | case .local(let value):
29 | return value
30 | case .remote(let value, _):
31 | return value
32 | }
33 | }
34 |
35 | func handle(previous: ConnectionState, using strategy: AnySyncStrategy, for context: ConnectionContext) -> ConnectionState {
36 | switch self {
37 | case .local(let value):
38 | return ConnectionState(value: value, events: events(from: previous.value, to: value, using: strategy, for: context))
39 | case .remote(let value, let connectionId):
40 | guard connectionId != context.id else {
41 | return ConnectionState(value: value, events: events(from: nil, to: value, using: strategy, for: context))
42 | }
43 | return ConnectionState(value: value, events: events(from: previous.value, to: value, using: strategy, for: context))
44 | }
45 | }
46 |
47 | private func events(from previous: Value?,
48 | to current: Value,
49 | using strategy: AnySyncStrategy,
50 | for context: ConnectionContext) -> AnyPublisher {
51 |
52 | let future = strategy.subEvents(for: current, for: context)
53 | guard let previous = previous else { return future }
54 |
55 | let events = strategy.events(from: previous, to: current, for: context)
56 | let immediate = Publishers.Sequence<[InternalEvent], Never>(sequence: events)
57 | return immediate.merge(with: future).eraseToAnyPublisher()
58 | }
59 | }
60 |
61 | private let publisher: CurrentValueSubject
62 | private var value: Value
63 | private let rights: SyncedWriteRights
64 | private let strategy: AnySyncStrategy
65 | private let isAllowedToWrite: Bool
66 |
67 | public var wrappedValue: Value {
68 | get {
69 | return value
70 | }
71 | set {
72 | guard isAllowedToWrite else { return }
73 | value = newValue
74 | publisher.send(.local(newValue))
75 | }
76 | }
77 |
78 | public var values: AnyPublisher {
79 | return publisher.map(\.value).eraseToAnyPublisher()
80 | }
81 |
82 | public var valueChange: AnyPublisher {
83 | return publisher.map(\.value).dropFirst().eraseToAnyPublisher()
84 | }
85 |
86 | public var projectedValue: Synced {
87 | return self
88 | }
89 |
90 | init(value: Value, rights: SyncedWriteRights, isAllowedToWrite: Bool) {
91 | self.value = value
92 | self.publisher = CurrentValueSubject(.local(value))
93 | self.strategy = extractStrategy(for: Value.self)
94 | self.rights = rights
95 | self.isAllowedToWrite = isAllowedToWrite
96 | }
97 |
98 | public convenience init(wrappedValue value: Value, _ rights: SyncedWriteRights = .shared) {
99 | self.init(value: value, rights: rights, isAllowedToWrite: true)
100 | }
101 |
102 | public convenience init(from decoder: Decoder) throws {
103 | var container = try decoder.unkeyedContainer()
104 | let value = try container.decode(Value.self)
105 | let rights = try container.decode(SyncedWriteRights.self)
106 | self.init(value: value, rights: rights, isAllowedToWrite: rights != .protected)
107 | }
108 |
109 | public func encode(to encoder: Encoder) throws {
110 | var container = encoder.unkeyedContainer()
111 | try container.encode(value)
112 | try container.encode(rights)
113 | }
114 | }
115 |
116 | extension Synced: SelfContainedStrategy {
117 | func handle(event: InternalEvent, from context: ConnectionContext) throws {
118 | if case .producer = context.type, case .protected = rights {
119 | throw SyncedEventHandlingError.receivedIllegalEventForProtectedProperty
120 | }
121 | switch try strategy.handle(event: event, from: context, for: &value) {
122 | case .alertRemainingConnections:
123 | guard case .producer = context.type else { break }
124 | publisher.send(.remote(value, connectionId: context.id))
125 | case .done:
126 | break
127 | }
128 | }
129 |
130 | func events(for context: ConnectionContext) -> AnyPublisher {
131 | let strategy = strategy
132 | let initialState = ConnectionState(value: nil, events: Empty(completeImmediately: false).eraseToAnyPublisher())
133 | return publisher
134 | .scan(initialState) { [strategy, context] state, change in
135 | return change.handle(previous: state, using: strategy, for: context)
136 | }
137 | .flatMap { $0.events }
138 | .eraseToAnyPublisher()
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/Tests/SyncTests/SyncTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import Sync
3 |
4 | class MockServerConnection: ProducerConnection {
5 | var isConnected: Bool {
6 | return true
7 | }
8 |
9 | var isConnectedPublisher: AnyPublisher {
10 | return Empty().eraseToAnyPublisher()
11 | }
12 |
13 | private let inputSubject = PassthroughSubject()
14 | private let outputSubject = PassthroughSubject()
15 |
16 | func disconnect() {
17 | fatalError()
18 | }
19 |
20 | func send(data: Data) {
21 | outputSubject.send(data)
22 | }
23 |
24 | func receive() -> AnyPublisher {
25 | return inputSubject.eraseToAnyPublisher()
26 | }
27 |
28 | func dataForClient() -> AnyPublisher {
29 | return outputSubject.eraseToAnyPublisher()
30 | }
31 |
32 | func clientSent(data: Data) {
33 | inputSubject.send(data)
34 | }
35 | }
36 |
37 | class MockClientConnection: ConsumerConnection {
38 | let service: MockRemoteService
39 | var serverConnection: MockServerConnection? = nil
40 |
41 | init(service: MockRemoteService) {
42 | self.service = service
43 | }
44 |
45 | var isConnected: Bool {
46 | return serverConnection != nil
47 | }
48 |
49 | var isConnectedPublisher: AnyPublisher {
50 | return Empty().eraseToAnyPublisher()
51 | }
52 |
53 | func connect() async throws -> Data {
54 | let response = try await service.createConnection()
55 | self.serverConnection = response.connection
56 | return response.data
57 | }
58 |
59 | func disconnect() {
60 | serverConnection?.disconnect()
61 | }
62 |
63 | func receive() -> AnyPublisher {
64 | return serverConnection?.dataForClient() ?? Just(Data()).eraseToAnyPublisher()
65 | }
66 |
67 | func send(data: Data) {
68 | serverConnection?.clientSent(data: data)
69 | }
70 | }
71 |
72 | struct MockResponse {
73 | let data: Data
74 | let connection: MockServerConnection
75 | }
76 |
77 | class MockRemoteService {
78 | var managers: [SyncManager] = []
79 | let viewModel: ViewModel
80 |
81 | init(viewModel: ViewModel) {
82 | self.viewModel = viewModel
83 | }
84 |
85 | func createConnection() async throws -> MockResponse {
86 | let connection = MockServerConnection()
87 | let manager = viewModel.sync(with: connection)
88 | managers.append(manager)
89 | return MockResponse(data: try manager.data(), connection: connection)
90 | }
91 | }
92 |
93 | class ViewModel: SyncableObject, Codable {
94 | class SubViewModel: SyncableObject, Codable {
95 | @Synced
96 | var toggle = false
97 | }
98 |
99 | @Synced
100 | var name = "Hello World!"
101 |
102 | @Synced
103 | var names = ["A", "B"]
104 |
105 | @Synced
106 | var subViewModels = [SubViewModel(), SubViewModel()]
107 |
108 | @Synced(.protected)
109 | var protectedString = "This string is protected"
110 | }
111 |
112 | final class SyncTests: XCTestCase {
113 | func testExample() async throws {
114 | let serverViewModel = ViewModel()
115 | let service = MockRemoteService(viewModel: serverViewModel)
116 | let clientConnection = MockClientConnection(service: service)
117 | let clientManager = try await ViewModel.sync(with: clientConnection)
118 | let clientViewModel = try clientManager.value()
119 |
120 | XCTAssertEqual(clientViewModel.name, "Hello World!")
121 |
122 | clientViewModel.name = "hello, World!!"
123 | XCTAssertEqual(serverViewModel.name, "hello, World!!")
124 |
125 | XCTAssertEqual(clientViewModel.protectedString, "This string is protected")
126 | clientViewModel.protectedString = "test"
127 | XCTAssertEqual(clientViewModel.protectedString, "This string is protected")
128 | XCTAssertEqual(serverViewModel.protectedString, "This string is protected")
129 |
130 | serverViewModel.protectedString = "still protected"
131 | XCTAssertEqual(clientViewModel.protectedString, "still protected")
132 | XCTAssertEqual(serverViewModel.protectedString, "still protected")
133 |
134 | clientViewModel.name = "Foo"
135 | XCTAssertEqual(serverViewModel.name, "Foo")
136 |
137 | serverViewModel.name = "Bar"
138 | XCTAssertEqual(clientViewModel.name, "Bar")
139 |
140 | serverViewModel.subViewModels[0].toggle = true
141 | XCTAssertEqual(clientViewModel.subViewModels[0].toggle, true)
142 | XCTAssertEqual(clientViewModel.subViewModels[1].toggle, false)
143 |
144 | serverViewModel.names.insert("C", at: 1)
145 | XCTAssertEqual(clientViewModel.names[0], "A")
146 | XCTAssertEqual(clientViewModel.names[1], "C")
147 | XCTAssertEqual(clientViewModel.names[2], "B")
148 | }
149 |
150 | func testMultipleClients() async throws {
151 | let serverViewModel = ViewModel()
152 | let service1 = MockRemoteService(viewModel: serverViewModel)
153 | let service2 = MockRemoteService(viewModel: serverViewModel)
154 | let clientConnection1 = MockClientConnection(service: service1)
155 | let clientConnection2 = MockClientConnection(service: service2)
156 | let clientManager1 = try await ViewModel.sync(with: clientConnection1)
157 | let clientManager2 = try await ViewModel.sync(with: clientConnection2)
158 | let clientViewModel1 = try clientManager1.value()
159 | let clientViewModel2 = try clientManager2.value()
160 |
161 | XCTAssertEqual(clientViewModel1.name, "Hello World!")
162 | XCTAssertEqual(clientViewModel2.name, "Hello World!")
163 | clientViewModel1.name = "Foo"
164 | XCTAssertEqual(serverViewModel.name, "Foo")
165 | XCTAssertEqual(clientViewModel2.name, "Foo")
166 |
167 | clientViewModel2.names.insert("C", at: 1)
168 | XCTAssertEqual(clientViewModel1.names[0], "A")
169 | XCTAssertEqual(clientViewModel1.names[1], "C")
170 | XCTAssertEqual(clientViewModel1.names[2], "B")
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nerdsupremacist/Sync/037435e554eb64a2dff0034fd1bb6f066d0c91b5/demo.gif
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nerdsupremacist/Sync/037435e554eb64a2dff0034fd1bb6f066d0c91b5/logo.png
--------------------------------------------------------------------------------