// 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] = [:] /// Every thread in Recent, for the full Recent screen, which loads it when it appears. private(set) var allRecent: [FeedItem]? private(set) var allRecentError: String? /// True while Settle all is out. Completed Recent threads are hidden meanwhile. private(set) var settlingAll = false /// Why Settle all failed, or how many threads it could not settle. The Recent header shows it. private(set) var settleAllError: 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, allRecent: [FeedItem]? = nil, path: [Route] = []) { self.mode = mode self.feed = feed self.allRecent = allRecent self.path = path } /// `-demo` starts in demo mode. `-screen new|recent|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 "recent": path = [.recent] 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) } let settled = ids.filter { failures[$0] == nil } removeFromRecent(settled.count) { settled.contains($0.id) } settling.subtract(ids) await refreshFeed() } /// Recent threads to show: those not being settled. func visibleRecent(_ items: [FeedItem]) -> [FeedItem] { items.filter { !settling.contains($0.id) && !(settlingAll && $0.phase == .completed) } } /// `GET /v1/recent`, for the full Recent screen. func loadAllRecent() async { guard mode != .unpaired else { return } do { allRecent = try await api.recent().items allRecentError = nil } catch { allRecentError = message(for: error) } } /// Settles every completed thread in Recent with one call, including those past the feed's first /// 10. They leave at once. Then the feed, and the full list when it was loaded, refresh. func settleAllRecent() async { settlingAll = true settleAllError = nil do { let result = try await api.settleRecent() removeFromRecent(result.settled) { $0.phase == .completed } if result.failed > 0 { settleAllError = result.failed == 1 ? "1 thread didn't settle." : "\(result.failed) threads didn't settle." } } catch { settleAllError = message(for: error) } settlingAll = false await refreshFeed() if allRecent != nil { await loadAllRecent() } } /// Drops settled threads from the feed and the full list until the next refresh confirms it. private func removeFromRecent(_ count: Int, where settled: (FeedItem) -> Bool) { allRecent?.removeAll(where: settled) guard var feed else { return } feed.recent.removeAll(where: settled) feed.recentTotal = max(feed.recent.count, feed.recentTotal - count) self.feed = feed } 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(), allRecent: DemoFixtures.previewRecent(), path: path) } }