Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-16 15:53:15 -06:00
parent b96b42b726
commit 9cd1652727
11 changed files with 481 additions and 77 deletions

View File

@ -12,8 +12,53 @@ struct ContentView: View {
@State private var store = WatchProfileStore.shared
var body: some View {
VStack {
if let profile = store.profile {
VStack(alignment: .leading) {
PhoneConnectivityBadge(isReachable: store.isPhoneReachable)
ProfileSectionView(profile: store.profile)
Divider()
SyncableSettingSectionView(
value: store.syncValue,
updatedAt: store.syncUpdatedAt
)
if !store.statusMessage.isEmpty {
StatusMessageView(message: store.statusMessage)
}
}
.padding()
}
}
#Preview {
ContentView()
}
private struct PhoneConnectivityBadge: View {
let isReachable: Bool
var body: some View {
if !isReachable {
Text("Open iPhone App to Sync")
.font(.caption2)
.foregroundStyle(Color.Status.warning)
.padding(.horizontal, WatchDesign.Spacing.small)
.padding(.vertical, WatchDesign.Spacing.xSmall)
.background(Color.Status.warning.opacity(WatchDesign.Opacity.subtle))
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.pill))
}
}
}
private struct ProfileSectionView: View {
let profile: UserProfile?
var body: some View {
VStack(alignment: .leading) {
Text("User Profile")
.font(.caption)
.foregroundStyle(.secondary)
if let profile {
Text(profile.name)
.bold()
Text(profile.email)
@ -30,17 +75,43 @@ struct ContentView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
if !store.statusMessage.isEmpty {
Text(store.statusMessage)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding()
}
}
#Preview {
ContentView()
private struct SyncableSettingSectionView: View {
let value: String?
let updatedAt: Date?
var body: some View {
VStack(alignment: .leading) {
Text("Syncable Setting")
.font(.caption)
.foregroundStyle(.secondary)
if let value {
Text(value)
.font(.system(.caption, design: .monospaced))
if let updatedAt {
Text(updatedAt, format: .dateTime)
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Text("No sync value received")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private struct StatusMessageView: View {
let message: String
var body: some View {
Text(message)
.font(.caption2)
.foregroundStyle(.secondary)
}
}

View File

@ -0,0 +1,22 @@
import SwiftUI
enum WatchDesign {
enum Spacing {
static let xSmall: CGFloat = 4
static let small: CGFloat = 8
}
enum CornerRadius {
static let pill: CGFloat = 12
}
enum Opacity {
static let subtle: Double = 0.2
}
}
extension Color {
enum Status {
static let warning = Color.orange
}
}

View File

@ -0,0 +1,29 @@
import Foundation
import SharedKit
@MainActor
final class SyncableSettingWatchHandler: WatchDataHandling {
let key = StorageKeyNames.syncableSetting
private let store: WatchProfileStore
private let decoder = JSONDecoder()
init(store: WatchProfileStore) {
self.store = store
}
convenience init() {
self.init(store: .shared)
}
func handle(data: Data) {
do {
let value = try decoder.decode(String.self, from: data)
store.setSyncValue(value)
Logger.debug("Watch synced syncable setting")
} catch {
store.setStatus("Failed to decode sync value")
Logger.error("Watch failed to decode syncable setting", error: error)
}
}
}

View File

@ -20,8 +20,10 @@ final class UserProfileWatchHandler: WatchDataHandling {
do {
let profile = try decoder.decode(UserProfile.self, from: data)
store.setProfile(profile)
Logger.debug("Watch synced user profile")
} catch {
store.setStatus("Failed to decode profile")
Logger.error("Watch failed to decode user profile", error: error)
}
}
}

View File

@ -1,4 +1,5 @@
import Foundation
import SharedKit
import WatchConnectivity
@MainActor
@ -19,6 +20,9 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
private func registerDefaultHandlers() {
let profileHandler = UserProfileWatchHandler(store: store)
registerHandler(profileHandler)
let syncableSettingHandler = SyncableSettingWatchHandler(store: store)
registerHandler(syncableSettingHandler)
}
func registerHandler(_ handler: WatchDataHandling) {
@ -37,10 +41,15 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
private func loadCurrentContext() {
guard WCSession.isSupported() else { return }
handleContext(WCSession.default.applicationContext)
let context = WCSession.default.applicationContext
let keys = context.keys.sorted().joined(separator: ", ")
Logger.debug("Watch loaded current context keys: [\(keys)]")
handleContext(context)
}
private func handleContext(_ context: [String: Any]) {
let keys = context.keys.sorted().joined(separator: ", ")
Logger.debug("Watch handling context keys: [\(keys)]")
for (key, handler) in handlers {
guard let data = context[key] as? Data else { continue }
handler.handle(data: data)
@ -52,10 +61,49 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
if let error {
Logger.error("Watch WCSession activation failed", error: error)
} else {
Logger.debug("Watch WCSession activated with state: \(activationState.rawValue)")
}
updateReachability(using: session)
loadCurrentContext()
requestSyncIfNeeded()
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
Logger.debug("Watch received application context with \(applicationContext.count) keys")
handleContext(applicationContext)
}
func sessionReachabilityDidChange(_ session: WCSession) {
Logger.debug("Watch reachability changed: reachable=\(session.isReachable)")
updateReachability(using: session)
requestSyncIfNeeded()
}
private func updateReachability(using session: WCSession) {
store.setPhoneReachable(session.isReachable)
}
private func requestSyncIfNeeded() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
if session.isReachable {
store.setPhoneReachable(true)
store.setStatus("Requesting sync from iPhone...")
session.sendMessage([WatchSyncMessageKeys.requestSync: true]) { reply in
Logger.debug("Watch received sync reply with \(reply.count) keys")
self.handleContext(reply)
} errorHandler: { error in
Logger.error("Watch sync request failed", error: error)
}
Logger.debug("Watch requested sync from iPhone (reachable)")
} else {
store.setPhoneReachable(false)
store.setStatus("Open the iPhone app to sync.")
session.transferUserInfo([WatchSyncMessageKeys.requestSync: true])
Logger.debug("Watch queued sync request for iPhone launch")
}
}
}

View File

@ -8,6 +8,9 @@ final class WatchProfileStore {
static let shared = WatchProfileStore()
private(set) var profile: UserProfile?
private(set) var syncValue: String?
private(set) var syncUpdatedAt: Date?
private(set) var isPhoneReachable = false
private(set) var statusMessage: String = ""
private init() {}
@ -17,6 +20,16 @@ final class WatchProfileStore {
statusMessage = "Profile synced"
}
func setSyncValue(_ value: String) {
syncValue = value
syncUpdatedAt = Date()
statusMessage = "Syncable setting updated"
}
func setPhoneReachable(_ isReachable: Bool) {
isPhoneReachable = isReachable
}
func setStatus(_ message: String) {
statusMessage = message
}

View File

@ -56,10 +56,12 @@ struct SecureStorageSampleApp: App {
ExternalKeyMaterialProvider(),
for: SampleKeyMaterialSources.external
)
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
#if DEBUG
let report = StorageAuditReport.renderText(AppStorageCatalog())
print(report)
// Disabled to keep console focused on sync logs.
// let report = StorageAuditReport.renderText(AppStorageCatalog())
// print(report)
#endif
}

View File

@ -1,4 +1,6 @@
import Foundation
import LocalData
import SharedKit
import WatchConnectivity
@MainActor
@ -22,7 +24,14 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
// Intentionally empty: activation state is handled by WCSession.
if let error {
Logger.error("iOS WCSession activation failed", error: error)
} else {
Logger.debug("iOS WCSession activated with state: \(activationState.rawValue)")
}
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
func sessionDidBecomeInactive(_ session: WCSession) {
@ -32,4 +41,74 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
func sessionWatchStateDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession watch state changed: paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)")
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession reachability changed: reachable=\(session.isReachable)")
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request")
Task {
let snapshot = await StorageRouter.shared.syncSnapshot()
if snapshot.isEmpty {
Logger.debug("iOS sync snapshot empty; falling back to application context")
}
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request (reply)")
Task {
let payload = await buildSyncReplyPayload()
replyHandler(payload)
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
private func buildSyncReplyPayload() async -> [String: Any] {
let maxAttempts = 3
for attempt in 1...maxAttempts {
let snapshot = await StorageRouter.shared.syncSnapshot()
if !snapshot.isEmpty {
Logger.debug("iOS sync reply snapshot ready on attempt \(attempt)")
return snapshot
}
if attempt < maxAttempts {
Logger.debug("iOS sync reply snapshot empty; retrying (\(attempt))")
try? await Task.sleep(for: .milliseconds(300))
}
}
Logger.debug("iOS sync reply snapshot empty after retries; replying with ack only")
return ["ack": true]
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
if let request = userInfo[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received queued watch sync request")
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
}

View File

@ -7,6 +7,7 @@
import SwiftUI
import LocalData
import WatchConnectivity
@MainActor
struct PlatformSyncDemo: View {
@ -16,8 +17,9 @@ struct PlatformSyncDemo: View {
@State private var isLoading = false
@State private var selectedPlatform: PlatformAvailability = .all
@State private var selectedSync: SyncPolicy = .never
@State private var watchStatus = WatchStatus.current()
@FocusState private var isFieldFocused: Bool
var body: some View {
Form {
Section {
@ -25,7 +27,11 @@ struct PlatformSyncDemo: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Watch Status") {
WatchStatusView(status: watchStatus)
}
Section("Platform Availability") {
Picker("Available On", selection: $selectedPlatform) {
Text("All (iPhone + Watch)").tag(PlatformAvailability.all)
@ -34,10 +40,10 @@ struct PlatformSyncDemo: View {
Text("Phone w/ Watch Sync").tag(PlatformAvailability.phoneWithWatchSync)
}
.pickerStyle(.menu)
platformDescription
PlatformAvailabilityDescriptionView(availability: selectedPlatform)
}
Section("Sync Policy") {
Picker("Sync Behavior", selection: $selectedSync) {
Text("Never").tag(SyncPolicy.never)
@ -45,15 +51,15 @@ struct PlatformSyncDemo: View {
Text("Automatic (Small Data)").tag(SyncPolicy.automaticSmall)
}
.pickerStyle(.menu)
syncDescription
SyncPolicyDescriptionView(syncPolicy: selectedSync)
}
Section("Test Data") {
TextField("Enter a value to store", text: $settingValue)
.focused($isFieldFocused)
}
Section("Actions") {
Button(action: saveValue) {
HStack {
@ -62,7 +68,7 @@ struct PlatformSyncDemo: View {
}
}
.disabled(settingValue.isEmpty || isLoading)
Button(action: loadValue) {
HStack {
Image(systemName: "icloud.and.arrow.down")
@ -70,7 +76,7 @@ struct PlatformSyncDemo: View {
}
}
.disabled(isLoading)
Button(action: testPlatformError) {
HStack {
Image(systemName: "exclamationmark.triangle")
@ -80,30 +86,46 @@ struct PlatformSyncDemo: View {
.foregroundStyle(.orange)
.disabled(isLoading)
}
if !storedValue.isEmpty {
Section("Retrieved Value") {
Text(storedValue)
.font(.system(.body, design: .monospaced))
}
}
if !statusMessage.isEmpty {
Section {
Text(statusMessage)
.font(.caption)
.foregroundStyle(statusMessage.contains("Error") ? .red :
.foregroundStyle(statusMessage.contains("Error") ? .red :
statusMessage.contains("") ? .orange : .green)
}
}
Section("Expected Result") {
ExpectedOutcomeView(
availability: selectedPlatform,
syncPolicy: selectedSync
)
}
Section("Current Configuration") {
LabeledContent("Platform", value: selectedPlatform.displayName)
LabeledContent("Sync", value: selectedSync.displayName)
LabeledContent("Max Auto-Sync Size", value: "100 KB")
LabeledContent("Max Auto-Sync Size", value: "50 KB")
}
Section("Watch Notes") {
Text("The watch app only shows data it is configured to handle. For this demo, the watch displays the syncable setting and user profile.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.navigationBarTitleDisplayMode(.inline)
.task {
refreshWatchStatus()
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
@ -113,47 +135,7 @@ struct PlatformSyncDemo: View {
}
}
}
@ViewBuilder
private var platformDescription: some View {
switch selectedPlatform {
case .all:
Text("Data accessible on both iPhone and Apple Watch")
.font(.caption)
.foregroundStyle(.secondary)
case .phoneOnly:
Text("Data only accessible on iPhone. Watch access throws error.")
.font(.caption)
.foregroundStyle(.secondary)
case .watchOnly:
Text("Data only accessible on Watch. iPhone access throws error.")
.font(.caption)
.foregroundStyle(.secondary)
case .phoneWithWatchSync:
Text("Stored on iPhone, synced to Watch via WatchConnectivity")
.font(.caption)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var syncDescription: some View {
switch selectedSync {
case .never:
Text("Data stays local, never synced")
.font(.caption)
.foregroundStyle(.secondary)
case .manual:
Text("Sync triggered explicitly by app code")
.font(.caption)
.foregroundStyle(.secondary)
case .automaticSmall:
Text("Auto-sync if data ≤ 100KB, otherwise throws error")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func saveValue() {
isLoading = true
Task {
@ -165,14 +147,15 @@ struct PlatformSyncDemo: View {
try await StorageRouter.shared.set(settingValue, for: key)
statusMessage = "✓ Saved with \(selectedPlatform.displayName) availability and \(selectedSync.displayName) sync"
} catch StorageError.dataTooLargeForSync {
statusMessage = "Error: Data too large for automatic sync (max 100KB)"
statusMessage = "Error: Data too large for automatic sync (max 50KB)"
} catch {
statusMessage = "Error: \(error.localizedDescription)"
}
isLoading = false
refreshWatchStatus()
}
}
private func loadValue() {
isLoading = true
Task {
@ -183,7 +166,7 @@ struct PlatformSyncDemo: View {
)
let value = try await StorageRouter.shared.get(key)
storedValue = value
settingValue = value // Sync to field
settingValue = value
statusMessage = "✓ Loaded value"
} catch StorageError.notFound {
storedValue = ""
@ -192,13 +175,13 @@ struct PlatformSyncDemo: View {
statusMessage = "Error: \(error.localizedDescription)"
}
isLoading = false
refreshWatchStatus()
}
}
private func testPlatformError() {
isLoading = true
Task {
// Try to access a watchOnly key from iPhone
let key = StorageKeys.WatchVibrationKey()
do {
_ = try await StorageRouter.shared.get(key)
@ -209,8 +192,13 @@ struct PlatformSyncDemo: View {
statusMessage = "Error: \(error.localizedDescription)"
}
isLoading = false
refreshWatchStatus()
}
}
private func refreshWatchStatus() {
watchStatus = WatchStatus.current()
}
}
// MARK: - Display Names
@ -241,3 +229,147 @@ extension SyncPolicy {
PlatformSyncDemo()
}
}
private struct WatchStatus: Equatable {
let isSupported: Bool
let isPaired: Bool
let isWatchAppInstalled: Bool
let isReachable: Bool
let activationState: WCSessionActivationState?
init(
isSupported: Bool = false,
isPaired: Bool = false,
isWatchAppInstalled: Bool = false,
isReachable: Bool = false,
activationState: WCSessionActivationState? = nil
) {
self.isSupported = isSupported
self.isPaired = isPaired
self.isWatchAppInstalled = isWatchAppInstalled
self.isReachable = isReachable
self.activationState = activationState
}
static func current() -> WatchStatus {
guard WCSession.isSupported() else {
return WatchStatus(isSupported: false)
}
let session = WCSession.default
return WatchStatus(
isSupported: true,
isPaired: session.isPaired,
isWatchAppInstalled: session.isWatchAppInstalled,
isReachable: session.isReachable,
activationState: session.activationState
)
}
}
private struct WatchStatusView: View {
let status: WatchStatus
var body: some View {
if status.isSupported {
LabeledContent("Paired", value: status.isPaired ? "Yes" : "No")
LabeledContent("Watch App Installed", value: status.isWatchAppInstalled ? "Yes" : "No")
LabeledContent("Reachable", value: status.isReachable ? "Yes" : "No")
LabeledContent("WCSession", value: activationLabel(for: status.activationState))
} else {
Text("WatchConnectivity not supported on this device.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func activationLabel(for state: WCSessionActivationState?) -> String {
switch state {
case .activated:
return "Activated"
case .inactive:
return "Inactive"
case .notActivated:
return "Not Activated"
case .none:
return "Unavailable"
@unknown default:
return "Unknown"
}
}
}
private struct PlatformAvailabilityDescriptionView: View {
let availability: PlatformAvailability
var body: some View {
Text(descriptionText)
.font(.caption)
.foregroundStyle(.secondary)
}
private var descriptionText: String {
switch availability {
case .all:
return "Data accessible on both iPhone and Apple Watch"
case .phoneOnly:
return "Data only accessible on iPhone. Watch access throws error."
case .watchOnly:
return "Data only accessible on Watch. iPhone access throws error."
case .phoneWithWatchSync:
return "Stored on iPhone, synced to Watch via WatchConnectivity"
}
}
}
private struct SyncPolicyDescriptionView: View {
let syncPolicy: SyncPolicy
var body: some View {
Text(descriptionText)
.font(.caption)
.foregroundStyle(.secondary)
}
private var descriptionText: String {
switch syncPolicy {
case .never:
return "Data stays local, never synced"
case .manual:
return "Sync triggered by an explicit save call"
case .automaticSmall:
return "Auto-sync if data ≤ 50KB, otherwise throws error"
}
}
}
private struct ExpectedOutcomeView: View {
let availability: PlatformAvailability
let syncPolicy: SyncPolicy
var body: some View {
Text(expectedText)
.font(.caption)
.foregroundStyle(Color.Status.info)
}
private var expectedText: String {
if availability == .phoneOnly {
return "Expected: Stored only on iPhone. Watch cannot read this key."
}
if availability == .watchOnly {
return "Expected: iPhone access fails. Watch can read this key."
}
if availability == .phoneWithWatchSync {
return syncPolicy == .never
? "Expected: Stored on iPhone only. Sync disabled."
: "Expected: Stored on iPhone and synced to Watch."
}
return syncPolicy == .never
? "Expected: Stored on both devices when written locally."
: "Expected: Stored and synced between iPhone and Watch."
}
}

View File

@ -2,4 +2,5 @@ import Foundation
public enum StorageKeyNames {
public static let userProfile = "user_profile.json"
public static let syncableSetting = "syncable_setting"
}

View File

@ -0,0 +1,5 @@
import Foundation
public enum WatchSyncMessageKeys {
public static let requestSync = "request_sync"
}