├── .gitignore ├── Sources └── WatchSync │ ├── Device.swift │ ├── SyncedWatchObject.swift │ ├── WCSession+SyncedSession.swift │ ├── SessionDelegator.swift │ └── SyncedWatchState.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Tests └── WatchSyncTests │ └── WatchSyncTests.swift ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /Sources/WatchSync/Device.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Device.swift 3 | // 4 | // 5 | // Created by Chris Gaafary on 5/1/21. 6 | // 7 | 8 | import WatchConnectivity 9 | 10 | public enum Device { case thisDevice, otherDevice } 11 | -------------------------------------------------------------------------------- /Sources/WatchSync/SyncedWatchObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataPacket.swift 3 | // SwiftUIWatchConnectivity 4 | // 5 | // Created by Chris Gaafary on 4/29/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SyncedWatchObject: Codable { 11 | let dateModified: Date 12 | let object: T 13 | } 14 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/WatchSyncTests/WatchSyncTests.swift: -------------------------------------------------------------------------------- 1 | // import XCTest 2 | // @testable import WatchSync 3 | // 4 | // final class WatchSyncTests: XCTestCase { 5 | // func testExample() { 6 | // // This is an example of a functional test case. 7 | // // Use XCTAssert and related functions to verify your tests produce the correct 8 | // // results. 9 | // XCTAssertEqual(WatchSync().text, "Hello, World!") 10 | // } 11 | // } 12 | -------------------------------------------------------------------------------- /Sources/WatchSync/WCSession+SyncedSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WCSession+SyncedSession.swift 3 | // 4 | // 5 | // Created by Chris Gaafary on 5/4/21. 6 | // 7 | 8 | import Foundation 9 | import WatchConnectivity 10 | import SwiftUI 11 | import Combine 12 | 13 | public extension WCSession { 14 | static let syncedStateSession: WCSession = { 15 | let session = WCSession.default 16 | session.delegate = SessionDelegater.syncedStateDelegate 17 | session.activate() 18 | return session 19 | }() 20 | } 21 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 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: "WatchSync", 8 | platforms: [.iOS(.v14), .watchOS(.v7)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "WatchSync", 13 | targets: ["WatchSync"]), 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "WatchSync", 24 | dependencies: []), 25 | .testTarget( 26 | name: "WatchSyncTests", 27 | dependencies: ["WatchSync"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /Sources/WatchSync/SessionDelegator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SessionDelegator.swift 3 | // WatchConnectivityPrototype 4 | // 5 | // Created by Chris Gaafary on 4/15/21. 6 | // 7 | 8 | import Combine 9 | import WatchConnectivity 10 | 11 | class SessionDelegater: NSObject, WCSessionDelegate { 12 | static let syncedStateDelegate = SessionDelegater() 13 | 14 | let dataSubject = PassthroughSubject() 15 | 16 | func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { 17 | // Protocol comformance only 18 | // Not needed for this demo 19 | } 20 | 21 | func session(_ session: WCSession, didReceiveMessageData messageData: Data, replyHandler: @escaping (Data) -> Void) { 22 | print("Received data from other device") 23 | self.dataSubject.send(messageData) 24 | 25 | // Empty data sent back to other device 26 | // to show evidence that data was received 27 | // TODO: Look into a better way to do this 28 | replyHandler(Data()) 29 | } 30 | 31 | // iOS Protocol comformance 32 | // Not needed for this demo otherwise 33 | #if os(iOS) 34 | func sessionDidBecomeInactive(_ session: WCSession) { 35 | print("\(#function): activationState = \(session.activationState.rawValue)") 36 | } 37 | 38 | func sessionDidDeactivate(_ session: WCSession) { 39 | // Activate the new session after having switched to a new watch. 40 | session.activate() 41 | } 42 | 43 | func sessionWatchStateDidChange(_ session: WCSession) { 44 | print("\(#function): activationState = \(session.activationState.rawValue)") 45 | } 46 | #endif 47 | 48 | } 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WatchSync 2 | WatchSync provides a property wrapper to encapsulate basic sharing of a codable object between an iPhone and AppleWatch using Apple's WatchConnectivity framework. 3 | 4 | ## How to use 5 | WatchSync exposes the property wrapper @SyncedWatchState which will use Apple's WatchConnectivity framework to pass updates automatically to each device when the value is changed. 6 | 7 | Currently, @SyncedWatchState must be used within a class that comforms to the ObservableObject protocol. Consider this as a version of Apple's @Published property wrapper that also publishes changes to the connected device. 8 | 9 | In the example below, we have a a Counter object that contains a syncedCount parameter. This class targets both iOS and watchOS and is used by each to update the shared syncedCount variable. SwiftUI views will automatically update when this value is changed (just like @Published). 10 | 11 | ```swift 12 | import WatchSync 13 | 14 | class Counter: ObservableObject { 15 | @SyncedWatchState var syncedCount: Int = 0 16 | 17 | func increment() { 18 | syncedCount += 1 19 | } 20 | 21 | func decrement() { 22 | syncedCount -= 1 23 | } 24 | } 25 | ``` 26 | 27 | The following SwiftUI View can use this object by accessing the Counter object 28 | 29 | ```swift 30 | import SwiftUI 31 | import WatchSync 32 | 33 | struct ContentView: View { 34 | @StateObject var counter = Counter() 35 | 36 | var labelStyle: some LabelStyle { 37 | #if os(watchOS) 38 | return IconOnlyLabelStyle() 39 | #else 40 | return DefaultLabelStyle() 41 | #endif 42 | } 43 | 44 | var body: some View { 45 | VStack { 46 | Text("\(counter.syncedCount)") 47 | .font(.largeTitle) 48 | 49 | HStack { 50 | Button(action: counter.decrement) { 51 | Label("Decrement", systemImage: "minus.circle") 52 | } 53 | .padding() 54 | 55 | Button(action: counter.increment) { 56 | Label("Increment", systemImage: "plus.circle.fill") 57 | } 58 | .padding() 59 | } 60 | .font(.headline) 61 | .labelStyle(IconOnlyLabelStyle()) 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | ## Limitations 68 | Currently this has been built only with the intent to have one instance per project. It's possible creating multiple instances of the @SyncedWatchState property wrapper but this has not been tested. 69 | 70 | Use at your own risk if you intend to use in production. This project is brand new so reliability is unclear. 71 | -------------------------------------------------------------------------------- /Sources/WatchSync/SyncedWatchState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WatchSync.swift 3 | // SwiftUIWatchConnectivity 4 | // 5 | // Created by Chris Gaafary on 5/1/21. 6 | // 7 | 8 | import SwiftUI 9 | import WatchConnectivity 10 | import Combine 11 | 12 | @propertyWrapper public class SyncedWatchState { 13 | public static subscript( 14 | _enclosingInstance instance: T, 15 | wrapped wrappedKeyPath: ReferenceWritableKeyPath, 16 | storage storageKeyPath: ReferenceWritableKeyPath 17 | ) -> Value { 18 | get { 19 | let publisher = instance.objectWillChange as! ObservableObjectPublisher 20 | // This assumption is definitely not safe to make in 21 | // production code, but it's fine for this demo purpose: 22 | 23 | let enclosingInstance = instance[keyPath: storageKeyPath] 24 | enclosingInstance.observableObjectPublisher = publisher 25 | return enclosingInstance.valueSubject.value 26 | } 27 | set { 28 | let publisher = instance.objectWillChange as! ObservableObjectPublisher 29 | // This assumption is definitely not safe to make in 30 | // production code, but it's fine for this demo purpose: 31 | 32 | let enclosingInstance = instance[keyPath: storageKeyPath] 33 | enclosingInstance.observableObjectPublisher = publisher 34 | 35 | instance[keyPath: storageKeyPath].send(newValue) 36 | instance[keyPath: storageKeyPath].valueSubject.value = newValue 37 | enclosingInstance.observableObjectPublisher?.send() 38 | } 39 | } 40 | 41 | private var session: WCSession 42 | private var subscriptions = Set() 43 | private var observableObjectPublisher: ObservableObjectPublisher? 44 | 45 | // SUBJECTS 46 | private let dataSubject: PassthroughSubject 47 | private let deviceSubject = PassthroughSubject() 48 | private let valueSubject: CurrentValueSubject 49 | 50 | // SYNC TIMER RELATED 51 | private let timer: Timer.TimerPublisher 52 | private var timerSubscription: AnyCancellable? 53 | 54 | // INTERNAL RECORD KEEPING 55 | // THIS HELPS PREVENT UNNECCESARY AND DUPLICATE NETWORK REQUESTS 56 | private var cacheDate: Date? 57 | private var cachedEncodedObjectData: Data? 58 | 59 | private var receivedData: AnyPublisher { 60 | dataSubject 61 | .removeDuplicates() 62 | .decode(type: SyncedWatchObject.self, decoder: JSONDecoder()) 63 | .handleEvents(receiveOutput: { syncedObject in 64 | if let cacheDate = self.cacheDate { 65 | if cacheDate < syncedObject.dateModified { 66 | print("Updating deviceSubject to other device") 67 | self.deviceSubject.send(.otherDevice) 68 | } 69 | } 70 | }) 71 | .filter({ dataPacket in 72 | guard let cacheDate = self.cacheDate else { 73 | print("No previous data cache. Update with incoming data") 74 | return true 75 | } 76 | let filtered = cacheDate < dataPacket.dateModified 77 | print("Incoming more recent than cached data: \(filtered)") 78 | return filtered 79 | }) 80 | .map(\.object) 81 | .receive(on: DispatchQueue.main) 82 | .eraseToAnyPublisher() 83 | } 84 | 85 | @available(*, unavailable, 86 | message: "@SyncedWatchState can only be applied to classes" 87 | ) 88 | public var wrappedValue: Value { 89 | get { fatalError() } 90 | set { fatalError() } 91 | } 92 | 93 | public var projectedValue: (value: AnyPublisher, device: AnyPublisher) { 94 | get {( 95 | value: valueSubject.eraseToAnyPublisher(), 96 | device: deviceSubject.eraseToAnyPublisher() 97 | )} 98 | } 99 | 100 | public init(wrappedValue: Value, session: WCSession = .syncedStateSession, autoRetryEvery timeInterval: TimeInterval = 2) { 101 | self.session = session 102 | self.dataSubject = SessionDelegater.syncedStateDelegate.dataSubject 103 | self.timer = Timer.publish(every: timeInterval, on: .main, in: .default) 104 | self.valueSubject = CurrentValueSubject(wrappedValue) 105 | 106 | receivedData 107 | .sink(receiveCompletion: valueSubject.send, receiveValue: { newValue in 108 | self.valueSubject.send(newValue) 109 | self.observableObjectPublisher?.send() 110 | }) 111 | .store(in: &subscriptions) 112 | } 113 | 114 | private func send(_ object: Value) { 115 | let now = Date() 116 | let syncedObject = SyncedWatchObject(dateModified: now, object: object) 117 | let encodedObject = try! JSONEncoder().encode(syncedObject) 118 | 119 | cacheObject(encodedData: encodedObject, cacheDate: now) 120 | deviceSubject.send(.thisDevice) 121 | 122 | if session.isReachable { 123 | transmit(encodedObject) 124 | } else { 125 | print("Session not currently reachable, retrying transmission") 126 | timerSubscription = timer 127 | .autoconnect() 128 | .sink { _ in 129 | if let latestPacketSent = self.cachedEncodedObjectData { 130 | self.transmit(latestPacketSent) 131 | } 132 | } 133 | } 134 | } 135 | 136 | private func transmit(_ data: Data) { 137 | print("Transmitting data to other device...") 138 | session.sendMessageData(data) { _ in 139 | print("Succesul data transfer") 140 | self.timerSubscription?.cancel() 141 | } errorHandler: { error in 142 | print(error.localizedDescription) 143 | } 144 | } 145 | 146 | private func cacheObject(encodedData: Data, cacheDate: Date) { 147 | cachedEncodedObjectData = encodedData 148 | self.cacheDate = cacheDate 149 | } 150 | } 151 | 152 | public extension CurrentValueSubject { 153 | func syncWithObject(_ object: Observable) -> AnyCancellable { 154 | let syncedObject: ObservableObjectPublisher = object.objectWillChange as! ObservableObjectPublisher 155 | return self 156 | .replaceError(with: self.value) 157 | .sink { _ in 158 | syncedObject.send() 159 | } 160 | } 161 | } 162 | --------------------------------------------------------------------------------