Files
wristcode/watch/WristCode/AppModel.swift
T

322 lines
11 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?
/// Ids of feed items whose settle call is still out. The feed hides them meanwhile.
private(set) var settling: Set<String> = []
/// 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 <hostId>` 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
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)
}
}