Files
wristcode/watch/WristCode/AppModel.swift
T
gibandClaude Opus 5.5 27469dbc77 Watch: share with the complication through a Keychain group, not an App Group
Automatic signing with an App Store Connect API key registers bundle IDs and
profiles but cannot create or assign App Groups, so the archive failed. A
keychain-access-groups entry needs no portal setup. The demo flag and last
summary move into the same group; tests cover the round trip and the
migration of a pre-complication token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:15:31 -05:00

208 lines
6.7 KiB
Swift

// App-wide state: whether the watch is paired, the navigation path, and the latest feed. It also
// keeps the complication current: it shares the mode and latest counts through the shared Keychain
// group and reloads the complication's timeline when they change.
import Foundation
import Observation
import UserNotifications
import WatchKit
import WidgetKit
@MainActor @Observable
final class AppModel {
enum Mode: Equatable {
case unpaired
case paired(Credentials)
/// Serves fixtures from `DemoGateway`. Nothing leaves the watch.
case demo
}
/// The one instance the app delegate and notification handler share with the views.
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
var mode: Mode {
didSet { if mode != oldValue { shareMode() } }
}
var path: [Route] = []
private(set) var feed: Feed?
private(set) var feedError: String?
private var apnsToken: String?
private var refreshing = false
/// The counts the complication was last told about.
private var sharedSummary: Summary?
init(mode: Mode, feed: Feed? = nil, path: [Route] = []) {
self.mode = mode
self.feed = feed
self.path = path
}
/// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that
/// screen, which is how the screenshots in watch/screenshots are taken.
convenience init(launchArguments args: [String]) {
DeviceStore.migrateToSharedGroup()
let demo = args.contains("-demo")
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
var path: [Route] = []
if demo, let i = args.firstIndex(of: "-screen"), i + 1 < args.count, args[i + 1] == "new" {
path = [.newThread]
}
if demo, let i = args.firstIndex(of: "-thread"), i + 1 < args.count {
let parts = args[i + 1].split(separator: "/", maxSplits: 1).map(String.init)
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
}
self.init(mode: mode, path: path)
shareMode()
}
var isPaired: Bool {
if case .paired = mode { return true }
return false
}
var api: any GatewayAPI {
switch mode {
case let .paired(credentials):
GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
case .demo:
DemoGateway.shared
case .unpaired:
GatewayClient(baseURL: URL(string: DeviceStore.defaultGatewayURL)!, token: nil)
}
}
// MARK: - Pairing
func pair(gatewayURL: URL, code: String) async throws {
let deviceName = WKInterfaceDevice.current().name
let response = try await GatewayClient.pair(baseURL: gatewayURL, code: code, deviceName: deviceName)
let credentials = Credentials(gatewayURL: gatewayURL, token: response.deviceToken)
DeviceStore.save(credentials)
mode = .paired(credentials)
feed = nil
feedError = nil
path = []
await enableNotifications()
}
func startDemo() {
mode = .demo
feed = nil
feedError = nil
path = []
}
/// Back to the pairing screen. A real token is forgotten; the gateway URL is kept.
func signOut() {
if isPaired { DeviceStore.clearToken() }
mode = .unpaired
feed = nil
feedError = nil
path = []
}
/// The message to show for a failed call. A 401 sends the app back to pairing and returns nil.
func message(for error: any Error) -> String? {
if error is CancellationError { return nil }
if let error = error as? GatewayError, error == .unauthorized {
signOut()
return nil
}
return error.localizedDescription
}
// MARK: - Feed
func refreshFeed() async {
guard mode != .unpaired, !refreshing else { return }
refreshing = true
defer { refreshing = false }
do {
let feed = try await api.feed()
self.feed = feed
feedError = nil
share(Summary(feed))
} catch {
feedError = message(for: error)
}
}
func open(_ route: Route) {
path = [route]
}
/// Opens a `wristcode://` link from the complication.
func open(url: URL) {
guard mode != .unpaired, let route = Route(url: url) else { return }
open(route)
}
// MARK: - Complication
/// Tells the complication about the mode. Counts from another mode, or from before signing
/// out, are forgotten.
private func shareMode() {
let demo = mode == .demo
if SharedState.demo != demo || mode == .unpaired { SharedState.lastSummary = nil }
SharedState.demo = demo
sharedSummary = nil
reloadComplication()
}
/// Hands the complication fresh counts. The feed refreshes every 10 s while on screen, so the
/// timeline only reloads when the counts or the next thread change.
private func share(_ summary: Summary) {
guard summary != sharedSummary else { return }
sharedSummary = summary
SharedState.lastSummary = summary
reloadComplication()
}
/// Asks the complication to fetch the summary again, as after a push or a notification action.
func reloadComplication() {
WidgetCenter.shared.reloadAllTimelines()
}
// MARK: - Notifications
func enableNotifications() async {
guard isPaired else { return }
let center = UNUserNotificationCenter.current()
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
if granted {
WKApplication.shared().registerForRemoteNotifications()
}
}
func didRegister(apnsToken data: Data) {
apnsToken = data.map { String(format: "%02x", $0) }.joined()
Task { await sendPushToken() }
}
private func sendPushToken() async {
guard isPaired, let apnsToken else { return }
#if DEBUG
let environment = PushRegistration.Environment.sandbox
#else
let environment = PushRegistration.Environment.production
#endif
do {
try await api.registerPush(PushRegistration(apnsToken: apnsToken, apnsEnvironment: environment))
} catch {
_ = message(for: error)
}
}
func notificationArrived() {
reloadComplication()
Task { await refreshFeed() }
}
}
extension AppModel {
/// A demo model with the fixture feed already loaded, for previews.
static func preview(path: [Route] = []) -> AppModel {
AppModel(mode: .demo, feed: DemoFixtures.previewFeed(), path: path)
}
}