511 lines
19 KiB
Swift
511 lines
19 KiB
Swift
// 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<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?
|
|
/// 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<Void, Never>?
|
|
/// 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<Void, Never>?
|
|
/// 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 <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]
|
|
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<Feed, any Error>
|
|
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)
|
|
}
|
|
}
|