diff --git a/Documentation/PlatformSync.md b/Documentation/PlatformSync.md new file mode 100644 index 0000000..cca6d4e --- /dev/null +++ b/Documentation/PlatformSync.md @@ -0,0 +1,168 @@ +# Platform Sync (iOS + watchOS) + +This document describes the end-to-end WatchConnectivity workflow used with LocalData. It covers the responsibilities of the LocalData package versus app-level wiring, and shows how to build a launch-order-safe sync handshake. + +## Goals + +- Sync selected keys between iOS and watchOS using `WCSession.updateApplicationContext`. +- Avoid launch-order dependencies (either app can start first). +- Keep business data stored in LocalData; the watch app displays data in memory. + +## What LocalData Provides + +LocalData does **not** activate `WCSession` for you. It provides the core sync utilities: + +- `SyncHelper.syncIfNeeded(...)` sends `applicationContext` for eligible keys. +- `StorageRouter.syncRegisteredKeysIfNeeded()` re-sends stored values for all registered keys that are eligible for sync. +- `StorageRouter.syncSnapshot()` builds a payload snapshot (key → Data) to reply to watch requests. + +These APIs are intentionally low-level so apps can decide when to sync (launch, reachability, user actions, etc.). + +## What the App Must Provide + +Your app owns the WatchConnectivity lifecycle: + +1. **Activate WCSession** on iOS and watch. +2. **Bootstrap sync** at app launch and on watch availability changes. +3. **Handle watch-initiated requests** and reply with a snapshot. +4. **Decode payloads** on watch and update UI state. + +## Recommended Architecture + +### iOS App + +- Create a `WatchConnectivityService` (or similar) that: + - Activates `WCSession`. + - Calls `StorageRouter.syncRegisteredKeysIfNeeded()` on: + - `activationDidComplete` + - `sessionWatchStateDidChange` + - `sessionReachabilityDidChange` + - Handles watch messages: + - `didReceiveMessage(_:replyHandler:)` → reply with `StorageRouter.syncSnapshot()` + - `didReceiveUserInfo` → trigger `syncRegisteredKeysIfNeeded()` for queued requests + +### watchOS App + +- Create a `WatchConnectivityService` that: + - Activates `WCSession`. + - Requests sync on launch or reachability changes: + - If `reachable`: `sendMessage` with a `request_sync` key and use the reply payload. + - If not reachable: `transferUserInfo` with the same request key (queued until iOS launches). + - Loads `applicationContext` on activation and decodes known keys. + +- Create lightweight handlers (e.g., `UserProfileWatchHandler`) to decode payloads into in-memory state. + +## Launch-Order-Safe Handshake + +This prevents the "watch first" or "phone first" problem. + +1. **Watch launches first** + - Sends `request_sync` via `transferUserInfo` if iPhone unreachable. + - When iPhone launches, it receives the queued request and replies with a snapshot. +2. **iPhone launches first** + - Bootstraps by calling `syncRegisteredKeysIfNeeded()`. + - When the watch launches, it requests a sync and receives a snapshot immediately. + +Either order works with this handshake. + +## Data Eligibility Rules + +A key is eligible for sync when: + +- `availability` is `.all` or `.phoneWithWatchSync` +- `syncPolicy` is `.manual` or `.automaticSmall` +- For `.automaticSmall`, the payload must be <= `SyncConfiguration.maxAutoSyncSize` + +Keys with `.never` will not sync unless your app explicitly overrides behavior. + +## Example: iOS Service + +```swift +@MainActor +final class WatchConnectivityService: NSObject, WCSessionDelegate { + static let shared = WatchConnectivityService() + + private override init() { + super.init() + let session = WCSession.default + session.delegate = self + session.activate() + } + + func session(_ session: WCSession, activationDidCompleteWith: WCSessionActivationState, error: Error?) { + Task { await StorageRouter.shared.syncRegisteredKeysIfNeeded() } + } + + func sessionWatchStateDidChange(_ session: WCSession) { + Task { await StorageRouter.shared.syncRegisteredKeysIfNeeded() } + } + + func sessionReachabilityDidChange(_ session: WCSession) { + Task { await StorageRouter.shared.syncRegisteredKeysIfNeeded() } + } + + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + Task { + let snapshot = await StorageRouter.shared.syncSnapshot() + replyHandler(snapshot.isEmpty ? ["ack": true] : snapshot) + await StorageRouter.shared.syncRegisteredKeysIfNeeded() + } + } +} +``` + +## Example: watch Service + +```swift +@MainActor +final class WatchConnectivityService: NSObject, WCSessionDelegate { + static let shared = WatchConnectivityService() + + private override init() { + super.init() + let session = WCSession.default + session.delegate = self + session.activate() + } + + func session(_ session: WCSession, activationDidCompleteWith: WCSessionActivationState, error: Error?) { + requestSyncIfNeeded() + } + + func sessionReachabilityDidChange(_ session: WCSession) { + requestSyncIfNeeded() + } + + private func requestSyncIfNeeded() { + let session = WCSession.default + if session.isReachable { + session.sendMessage(["request_sync": true]) { reply in + // Apply payload + } + } else { + session.transferUserInfo(["request_sync": true]) + } + } +} +``` + +## Common Pitfalls + +- **Simulator unreliability**: WatchConnectivity is inconsistent in simulators. Test on real devices. +- **Missing entitlements**: Watch app must be embedded and signed correctly. +- **Launch order assumptions**: Always use the handshake; do not assume which app starts first. +- **Large payloads**: Automatic sync will fail if the payload exceeds the configured max size. + +## SecureStorageSample Reference + +For a working implementation, see: + +- iOS: `SecureStorageSample/SecureStorageSample/Services/WatchConnectivityService.swift` +- Watch: `SecureStorageSample/SecureStorageSample Watch App/Services/WatchConnectivityService.swift` +- Watch handlers: `SecureStorageSample/SecureStorageSample Watch App/Services/Handlers/` +- Bootstrap: `SecureStorageSample/SecureStorageSample/SecureStorageSampleApp.swift` + diff --git a/README.md b/README.md index 5c83a1c..7b364d4 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,28 @@ StorageRouter can sync data to Apple Watch via WCSession when: The app owns WCSession activation and handling incoming updates. +### Bootstrapping on Launch +To ensure the watch receives the latest values after app relaunch or reconnection, call: + +```swift +await StorageRouter.shared.syncRegisteredKeysIfNeeded() +``` + +This re-sends stored values for any registered keys that are eligible for sync. Apps typically call this on launch and when WatchConnectivity becomes reachable. + +### Responding to Watch-Initiated Sync Requests +If your watch app asks for an explicit refresh, you can build a snapshot of syncable data and reply via WCSession messaging: + +```swift +let snapshot = await StorageRouter.shared.syncSnapshot() +// Reply with snapshot (key: Data) via WCSession.sendMessage +``` + +### Platform Sync Guide +For end-to-end iOS + watchOS setup (including a launch-order-safe handshake), see: + +`Documentation/PlatformSync.md` + ## Platforms - iOS 17+ - watchOS 10+