A gear on the feed opens a status overview that leads with what is wrong: offline hosts, hosts on a fallback route, tokens expiring within 7 days, and push problems, with healthy hosts folded into one row. Each host opens its route ladder with Reconnect. The notifications page shows registration, the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN fallback, and snapshot tests render the three screens.
232 lines
7.5 KiB
Swift
232 lines
7.5 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?
|
|
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 <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 "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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|