Docs PlatformSync, README
Summary: - Docs: PlatformSync, README - Added symbols: class WatchConnectivityService, func session, func sessionWatchStateDidChange, func sessionReachabilityDidChange, func requestSyncIfNeeded Stats: - 2 files changed, 190 insertions(+)
This commit is contained in:
parent
358e7a0ffc
commit
fb90270d9f
168
Documentation/PlatformSync.md
Normal file
168
Documentation/PlatformSync.md
Normal file
@ -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`
|
||||||
|
|
||||||
22
README.md
22
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.
|
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
|
## Platforms
|
||||||
- iOS 17+
|
- iOS 17+
|
||||||
- watchOS 10+
|
- watchOS 10+
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user