LocalData/Documentation/PlatformSync.md

169 lines
6.0 KiB
Markdown

# 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`