// App-wide state: whether the watch is paired, the navigation path, and the latest feed. import Foundation import Observation import UserNotifications import WatchKit enum Route: Hashable, Sendable { case thread(hostId: String, threadId: String) case newThread } @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 var path: [Route] = [] private(set) var feed: Feed? private(set) var feedError: String? private var apnsToken: String? private var refreshing = false 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 /` opens that /// screen, which is how the screenshots in watch/screenshots are taken. convenience init(launchArguments args: [String]) { 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) } 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 { feed = try await api.feed() feedError = nil } catch { feedError = message(for: error) } } func open(_ route: Route) { path = [route] } // 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() { 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) } }