// 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? /// Ids of feed items whose settle call is still out. The feed hides them meanwhile. private(set) var settling: Set = [] /// Why settling an item failed, by item id. Its row shows the message. private(set) var settleErrors: [String: 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|status|notifications`, `-host ` or /// `-thread /` 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 func argument(_ flag: String) -> String? { guard demo, let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil } return args[i + 1] } var path: [Route] = [] switch argument("-screen") { case "new": path = [.newThread] case "status": path = [.status] case "notifications": path = [.status, .notifications] default: break } if let hostId = argument("-host") { path = [.status, .host(id: hostId)] } if let thread = argument("-thread") { let parts = thread.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 = [] } /// Revokes the device token on the gateway, then signs out. Signs out even when the gateway /// cannot be reached, since the token is forgotten either way. func unpair() async { if isPaired { try? await api.unpair() } signOut() } /// The gateway's host name, such as `wristcode.gbrown.org`. Nil in demo mode. var gatewayHost: String? { if case let .paired(credentials) = mode { return credentials.gatewayURL.host() } return nil } /// 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) } } /// Settles items concurrently. Each one leaves the feed at once and comes back with its error /// if its call fails. Refreshes once every call has answered. func settle(_ items: [FeedItem]) async { let ids = Set(items.map(\.id)) settling.formUnion(ids) for id in ids { settleErrors[id] = nil } let api = api let failures = await withTaskGroup(of: (String, (any Error)?).self) { group in for item in items { group.addTask { do { try await api.settle(hostId: item.hostId, threadId: item.threadId) return (item.id, nil) } catch { return (item.id, error) } } } var failures: [String: any Error] = [:] for await (id, error) in group { if let error { failures[id] = error } } return failures } for (id, error) in failures { settleErrors[id] = message(for: error) } feed?.recent.removeAll { ids.contains($0.id) && failures[$0.id] == nil } settling.subtract(ids) await refreshFeed() } 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) } }