// App-wide state: whether the watch is paired, the navigation path, the latest feed, the hosts // and projects All threads browses, and the thread being snoozed. The last good feed is saved to // disk, so launch shows it at once while the first refresh runs. 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? /// True while the feed on screen came from the cache and the first refresh has not answered. private(set) var feedIsCached = false /// 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? /// Hosts and their projects for All threads, loaded when its screens appear. private(set) var projects: ProjectsResponse? private(set) var projectsError: String? /// Snoozed threads, for the Snoozed screen, which loads them when it appears. private(set) var snoozed: [FeedItem]? private(set) var snoozedError: String? /// The thread the Snooze until picker is open for. var snoozing: FeedItem? /// Where the feed and thread details are saved. Only used while paired, never in demo mode. private let cache: FeedCache? /// Answers every call in place of the mode's gateway, for tests. private let gateway: (any GatewayAPI)? private var apnsToken: String? /// The feed refresh in flight. A refresh asked for meanwhile waits on it, and it runs once more. private var refreshTask: Task? /// Bumped when a change to threads starts, when a refresh is asked for during another, and on /// reset. A feed answer to a request sent before the bump is dropped, and the refresh runs again. private var feedGeneration = 0 /// What was last written to disk, so an unchanged feed or thread is not written again. private var savedFeed: Feed? private var savedThread: ThreadDetail? /// The latest disk write. Writes run off the main actor, one after another. private(set) var cacheWrite: Task? /// The counts the complication was last told about. private var sharedSummary: Summary? /// With a `cache` and no `feed`, a paired model starts on the saved feed. init( mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, snoozed: [FeedItem]? = nil, projects: ProjectsResponse? = nil, path: [Route] = [], cache: FeedCache? = nil, gateway: (any GatewayAPI)? = nil ) { self.mode = mode self.cache = cache self.gateway = gateway self.allRecent = allRecent self.snoozed = snoozed self.projects = projects self.path = path if feed == nil, case .paired = mode, let saved = cache?.feed() { self.feed = saved feedIsCached = true } else { self.feed = feed } } /// `-demo` starts in demo mode. `-screen new|recent|status|notifications|threads`, `-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] case "threads": path = [.allThreads] 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, cache: .standard) shareMode() } var isPaired: Bool { if case .paired = mode { return true } return false } /// The cache while paired. Demo mode keeps off it. private var pairedCache: FeedCache? { isPaired ? cache : nil } var api: any GatewayAPI { if let gateway { return gateway } return 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) resetContent() await enableNotifications() } func startDemo() { mode = .demo resetContent() } /// Back to the pairing screen. A real token and the saved feed and threads are forgotten; the /// gateway URL is kept. func signOut() { if isPaired { DeviceStore.clearToken() } // After any write still queued, so none puts a file back. if let cache { write { cache.clear() } } mode = .unpaired resetContent() } /// Forgets what the screens loaded, for a new pairing, demo mode or signing out. A refresh still /// in flight drops its answer. private func resetContent() { feedGeneration += 1 feed = nil feedError = nil feedIsCached = false settling = [] settleErrors = [:] settlingAll = false settleAllError = nil allRecent = nil allRecentError = nil snoozed = nil snoozedError = nil snoozing = nil projects = nil projectsError = nil savedFeed = nil savedThread = 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. /// A cancelled call, as when a screen's `.task` ends or a refresh restarts, returns nil too. func message(for error: any Error) -> String? { if error is CancellationError || (error as? URLError)?.code == .cancelled { return nil } if let error = error as? GatewayError, error == .unauthorized { signOut() return nil } return error.localizedDescription } // MARK: - Feed /// Loads the feed. Asked for while a refresh is in flight, it waits for that one, which then /// runs again, since its answer may predate the caller's change. func refreshFeed() async { guard mode != .unpaired else { return } if let refreshTask { feedGeneration += 1 await refreshTask.value return } let task = Task { await runRefresh() } refreshTask = task await task.value } /// Call as a change to threads goes out, such as a settle or an answer. A feed answer to a /// request sent before it is dropped, so a poll cannot put back what the change removed. func beginChange() { feedGeneration += 1 } /// Fetches the feed until an answer arrives with no change, reset or new request since it was /// sent, then applies it. An answer from before signing out or switching mode is never applied. private func runRefresh() async { defer { refreshTask = nil } while mode != .unpaired { let generation = feedGeneration let result: Result do { result = .success(try await api.feed()) } catch { result = .failure(error) } guard generation == feedGeneration else { continue } switch result { case let .success(feed): self.feed = feed feedError = nil feedIsCached = false let listed = Set((feed.needsYou + feed.working + feed.recent + (allRecent ?? [])).map(\.id)) settleErrors = settleErrors.filter { listed.contains($0.key) } share(Summary(feed)) saveFeed(feed) case let .failure(error): // A cancelled refresh changes nothing; the next one answers. guard let message = message(for: error) else { return } feedError = message feedIsCached = false } return } } /// The detail saved when the thread last loaded, to show while it loads again. Nil in demo mode. func cachedThread(hostId: String, threadId: String) -> ThreadDetail? { pairedCache?.thread(hostId: hostId, threadId: threadId) } /// Saves a thread's detail for the next time it opens, unless it is what was saved last. func saveThread(_ detail: ThreadDetail) { guard let cache = pairedCache, detail != savedThread else { return } savedThread = detail write { cache.save(detail) } } /// Saves the feed for the next launch, unless it is what was saved last. private func saveFeed(_ feed: Feed) { guard let cache = pairedCache, feed != savedFeed else { return } savedFeed = feed write { cache.save(feed) } } /// Runs a disk write off the main actor, after the writes before it. private func write(_ body: @escaping @Sendable () -> Void) { let previous = cacheWrite cacheWrite = Task.detached(priority: .utility) { await previous?.value body() } } /// 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)) beginChange() 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 { if let message = message(for: error) { allRecentError = message } } } /// 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 { beginChange() 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() } } // MARK: - Snooze /// Snoozes a finished thread until `until`. Once the gateway agrees, it leaves Recent, the picker /// closes, and its screen does too if it is on top. Then the feed refreshes. func snooze(_ item: FeedItem, until: Date) async throws { beginChange() try await api.snooze(hostId: item.hostId, threadId: item.threadId, until: until) snoozing = nil removeFromRecent(1) { $0.id == item.id } feed?.snoozedTotal += 1 if path.last == item.route { path.removeLast() } await refreshFeed() } /// `GET /v1/snoozed`, for the Snoozed screen. func loadSnoozed() async { guard mode != .unpaired else { return } do { snoozed = try await api.snoozed().items snoozedError = nil } catch { if let message = message(for: error) { snoozedError = message } } } /// Puts a snoozed thread back in Recent. It leaves the Snoozed list at once, then the feed and /// the list refresh. On failure the list shows why, once the reload has put the thread back. func unsnooze(_ item: FeedItem) async { beginChange() snoozed?.removeAll { $0.id == item.id } var failure: String? do { try await api.unsnooze(hostId: item.hostId, threadId: item.threadId) } catch { failure = message(for: error) } await refreshFeed() await loadSnoozed() if let failure { snoozedError = failure } } /// 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 } // MARK: - All threads /// `GET /v1/projects`, for the hosts and projects All threads lists. func loadProjects() async { guard mode != .unpaired else { return } do { projects = try await api.projects() projectsError = nil } catch { if let message = message(for: error) { projectsError = message } } } 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(), snoozed: DemoFixtures.previewSnoozed(), projects: ProjectsResponse(hosts: DemoFixtures(now: .now).projectHosts), path: path) } }