Commit 258ce2b73b
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 31; | |
| 518 | CURRENT_PROJECT_VERSION = 32; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 2.14.0; | |
| 535 | MARKETING_VERSION = 2.15.0; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 31; | |
| 555 | CURRENT_PROJECT_VERSION = 32; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 2.14.0; | |
| 572 | MARKETING_VERSION = 2.15.0; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 31; | |
| 635 | CURRENT_PROJECT_VERSION = 32; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 2.14.0; | |
| 645 | MARKETING_VERSION = 2.15.0; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 31; | |
| 664 | CURRENT_PROJECT_VERSION = 32; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 2.14.0; | |
| 674 | MARKETING_VERSION = 2.15.0; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/App/AppState.swift +2
| @@ -203,6 +203,7 @@ final class AppState { | ||
| 203 | 203 | await clearWebData() |
| 204 | 204 | clearWebContentRenderCaches() |
| 205 | 205 | NeedsAttentionSnapshotStore.clear() |
| 206 | SystemStatusWidgetSnapshotStore.clear() | |
| 206 | 207 | authPhase = .unauthenticated |
| 207 | 208 | selectedTab = .home |
| 208 | 209 | } |
| @@ -218,6 +219,7 @@ final class AppState { | ||
| 218 | 219 | await clearWebData() |
| 219 | 220 | clearWebContentRenderCaches() |
| 220 | 221 | NeedsAttentionSnapshotStore.clear() |
| 222 | SystemStatusWidgetSnapshotStore.clear() | |
| 221 | 223 | |
| 222 | 224 | authPhase = .unauthenticated |
| 223 | 225 | selectedTab = .home |
Hutch/App/DeepLink.swift +25
| @@ -9,6 +9,16 @@ enum DeepLink: Equatable { | ||
| 9 | 9 | case ticket(owner: String, tracker: String, ticketId: Int) |
| 10 | 10 | /// hutch://builds/<jobId> |
| 11 | 11 | case build(jobId: Int) |
| 12 | /// hutch://builds (tab-level) | |
| 13 | case buildsTab | |
| 14 | /// hutch://repositories (tab-level) | |
| 15 | case repositoriesTab | |
| 16 | /// hutch://trackers (tab-level) | |
| 17 | case trackersTab | |
| 18 | /// hutch://status | |
| 19 | case systemStatus | |
| 20 | /// hutch://lookup | |
| 21 | case lookup | |
| 12 | 22 | |
| 13 | 23 | /// Attempt to parse a URL into a DeepLink. |
| 14 | 24 | /// Expected format: hutch://<path> |
| @@ -38,6 +48,21 @@ enum DeepLink: Equatable { | ||
| 38 | 48 | guard let jobId = Int(components[1]) else { return nil } |
| 39 | 49 | self = .build(jobId: jobId) |
| 40 | 50 | |
| 51 | case "builds": | |
| 52 | self = .buildsTab | |
| 53 | ||
| 54 | case "repositories": | |
| 55 | self = .repositoriesTab | |
| 56 | ||
| 57 | case "trackers": | |
| 58 | self = .trackersTab | |
| 59 | ||
| 60 | case "status": | |
| 61 | self = .systemStatus | |
| 62 | ||
| 63 | case "lookup": | |
| 64 | self = .lookup | |
| 65 | ||
| 41 | 66 | default: |
| 42 | 67 | return nil |
| 43 | 68 | } |
Hutch/App/HutchApp.swift +15
| @@ -15,6 +15,21 @@ struct HutchApp: App { | ||
| 15 | 15 | appState.pendingDeepLink = link |
| 16 | 16 | } |
| 17 | 17 | } |
| 18 | .onChange(of: HutchIntentNavigator.shared.pendingDestination) { _, destination in | |
| 19 | guard let destination else { return } | |
| 20 | HutchIntentNavigator.shared.pendingDestination = nil | |
| 21 | let link: DeepLink | |
| 22 | switch destination { | |
| 23 | case .home: link = .home | |
| 24 | case .inbox: link = .home | |
| 25 | case .builds: link = .buildsTab | |
| 26 | case .repositories: link = .repositoriesTab | |
| 27 | case .trackers: link = .trackersTab | |
| 28 | case .systemStatus: link = .systemStatus | |
| 29 | case .lookup: link = .lookup | |
| 30 | } | |
| 31 | appState.pendingDeepLink = link | |
| 32 | } | |
| 18 | 33 | } |
| 19 | 34 | } |
| 20 | 35 | } |
Hutch/App/HutchIntents.swift added +154
| @@ -0,0 +1,154 @@ | ||
| 1 | import AppIntents | |
| 2 | import Foundation | |
| 3 | ||
| 4 | // MARK: - Open Hutch Intent | |
| 5 | ||
| 6 | enum HutchDestination: String, AppEnum { | |
| 7 | case home | |
| 8 | case inbox | |
| 9 | case builds | |
| 10 | case repositories | |
| 11 | case trackers | |
| 12 | case systemStatus | |
| 13 | case lookup | |
| 14 | ||
| 15 | static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Hutch Section") | |
| 16 | ||
| 17 | static var caseDisplayRepresentations: [HutchDestination: DisplayRepresentation] = [ | |
| 18 | .home: "Home", | |
| 19 | .inbox: "Inbox", | |
| 20 | .builds: "Builds", | |
| 21 | .repositories: "Repositories", | |
| 22 | .trackers: "Trackers", | |
| 23 | .systemStatus: "System Status", | |
| 24 | .lookup: "Look Up" | |
| 25 | ] | |
| 26 | } | |
| 27 | ||
| 28 | struct OpenHutchIntent: AppIntent { | |
| 29 | static var title: LocalizedStringResource = "Open Hutch" | |
| 30 | static var description = IntentDescription("Opens Hutch to a specific section.") | |
| 31 | static var openAppWhenRun = true | |
| 32 | ||
| 33 | @Parameter(title: "Section", default: .home) | |
| 34 | var destination: HutchDestination | |
| 35 | ||
| 36 | @MainActor | |
| 37 | func perform() async throws -> some IntentResult { | |
| 38 | HutchIntentNavigator.shared.pendingDestination = destination | |
| 39 | return .result() | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | // MARK: - Check System Status Intent | |
| 44 | ||
| 45 | struct CheckSystemStatusIntent: AppIntent { | |
| 46 | static var title: LocalizedStringResource = "Check SourceHut Status" | |
| 47 | static var description = IntentDescription("Returns the current SourceHut system status.") | |
| 48 | ||
| 49 | @MainActor | |
| 50 | func perform() async throws -> some IntentResult & ReturnsValue<String> { | |
| 51 | guard let snapshot = SystemStatusWidgetSnapshotStore.load() else { | |
| 52 | return .result(value: "System status is unavailable. Open Hutch to refresh.") | |
| 53 | } | |
| 54 | ||
| 55 | if snapshot.hasDisruption { | |
| 56 | let disrupted = snapshot.services | |
| 57 | .filter { $0.requiresAttention } | |
| 58 | .map { "\($0.name): \($0.status)" } | |
| 59 | .joined(separator: ", ") | |
| 60 | return .result(value: "SourceHut disruption detected: \(disrupted)") | |
| 61 | } | |
| 62 | ||
| 63 | return .result(value: "All SourceHut services operational.") | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | // MARK: - Check Builds Intent | |
| 68 | ||
| 69 | struct CheckBuildsIntent: AppIntent { | |
| 70 | static var title: LocalizedStringResource = "Check Hutch Builds" | |
| 71 | static var description = IntentDescription("Returns a summary of your recent build status.") | |
| 72 | ||
| 73 | @MainActor | |
| 74 | func perform() async throws -> some IntentResult & ReturnsValue<String> { | |
| 75 | guard let snapshot = NeedsAttentionSnapshotStore.load() else { | |
| 76 | return .result(value: "Build status unavailable. Open Hutch to refresh.") | |
| 77 | } | |
| 78 | ||
| 79 | var parts: [String] = [] | |
| 80 | ||
| 81 | if let failed = snapshot.failedBuilds { | |
| 82 | if failed > 0 { | |
| 83 | parts.append("\(failed) failed build\(failed == 1 ? "" : "s")") | |
| 84 | } else { | |
| 85 | parts.append("No failed builds") | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | if let unread = snapshot.unreadInboxThreads, unread > 0 { | |
| 90 | parts.append("\(unread) unread thread\(unread == 1 ? "" : "s")") | |
| 91 | } | |
| 92 | ||
| 93 | if let assigned = snapshot.assignedOpenTickets, assigned > 0 { | |
| 94 | parts.append("\(assigned) assigned ticket\(assigned == 1 ? "" : "s")") | |
| 95 | } | |
| 96 | ||
| 97 | if parts.isEmpty { | |
| 98 | return .result(value: "No recent data. Open Hutch to refresh.") | |
| 99 | } | |
| 100 | ||
| 101 | return .result(value: parts.joined(separator: ". ") + ".") | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | // MARK: - Shortcuts Provider | |
| 106 | ||
| 107 | struct HutchShortcuts: AppShortcutsProvider { | |
| 108 | static var appShortcuts: [AppShortcut] { | |
| 109 | AppShortcut( | |
| 110 | intent: OpenHutchIntent(), | |
| 111 | phrases: [ | |
| 112 | "Open \(.applicationName)", | |
| 113 | "Open \(.applicationName) \(\.$destination)", | |
| 114 | "Show my \(.applicationName) \(\.$destination)", | |
| 115 | "Go to \(\.$destination) in \(.applicationName)" | |
| 116 | ], | |
| 117 | shortTitle: "Open Hutch", | |
| 118 | systemImageName: "house" | |
| 119 | ) | |
| 120 | ||
| 121 | AppShortcut( | |
| 122 | intent: CheckSystemStatusIntent(), | |
| 123 | phrases: [ | |
| 124 | "Check \(.applicationName) status", | |
| 125 | "Is SourceHut up in \(.applicationName)", | |
| 126 | "SourceHut status in \(.applicationName)" | |
| 127 | ], | |
| 128 | shortTitle: "Check SourceHut Status", | |
| 129 | systemImageName: "server.rack" | |
| 130 | ) | |
| 131 | ||
| 132 | AppShortcut( | |
| 133 | intent: CheckBuildsIntent(), | |
| 134 | phrases: [ | |
| 135 | "Check my \(.applicationName) builds", | |
| 136 | "How are my builds in \(.applicationName)", | |
| 137 | "Build status in \(.applicationName)" | |
| 138 | ], | |
| 139 | shortTitle: "Check Builds", | |
| 140 | systemImageName: "hammer" | |
| 141 | ) | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | // MARK: - Intent Navigator | |
| 146 | ||
| 147 | @MainActor | |
| 148 | @Observable | |
| 149 | final class HutchIntentNavigator { | |
| 150 | static let shared = HutchIntentNavigator() | |
| 151 | var pendingDestination: HutchDestination? | |
| 152 | ||
| 153 | private init() {} | |
| 154 | } | |
Hutch/App/RootView.swift +71
| @@ -113,6 +113,11 @@ struct RootView: View { | ||
| 113 | 113 | Label("More", systemImage: "ellipsis.circle") |
| 114 | 114 | } |
| 115 | 115 | } |
| 116 | .modifier(SidebarAdaptableTabStyle()) | |
| 117 | .modifier(TabKeyboardShortcuts(selectedTab: Binding( | |
| 118 | get: { appState.selectedTab }, | |
| 119 | set: { appState.selectedTab = $0 } | |
| 120 | ))) | |
| 116 | 121 | .overlay { |
| 117 | 122 | if isResolvingDeepLink { |
| 118 | 123 | ZStack { |
| @@ -178,6 +183,29 @@ struct RootView: View { | ||
| 178 | 183 | |
| 179 | 184 | case .ticket(let owner, let tracker, let ticketId): |
| 180 | 185 | resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) |
| 186 | ||
| 187 | case .buildsTab: | |
| 188 | buildsPath = NavigationPath() | |
| 189 | appState.selectedTab = .builds | |
| 190 | ||
| 191 | case .repositoriesTab: | |
| 192 | repoPath = NavigationPath() | |
| 193 | appState.selectedTab = .repositories | |
| 194 | ||
| 195 | case .trackersTab: | |
| 196 | ticketsPath = NavigationPath() | |
| 197 | appState.selectedTab = .tickets | |
| 198 | ||
| 199 | case .systemStatus: | |
| 200 | appState.navigateToSystemStatus() | |
| 201 | ||
| 202 | case .lookup: | |
| 203 | morePath = NavigationPath() | |
| 204 | appState.selectedTab = .more | |
| 205 | Task { | |
| 206 | await settleNavigationTransition() | |
| 207 | morePath.append(MoreRoute.lookup) | |
| 208 | } | |
| 181 | 209 | } |
| 182 | 210 | } |
| 183 | 211 | |
| @@ -336,3 +364,46 @@ struct TicketDeepLinkTarget: Hashable { | ||
| 336 | 364 | let trackerRid: String |
| 337 | 365 | let ticketId: Int |
| 338 | 366 | } |
| 367 | ||
| 368 | // MARK: - Keyboard Shortcuts for iPad + Hardware Keyboard | |
| 369 | ||
| 370 | /// Adds Cmd+1 through Cmd+5 keyboard shortcuts for tab switching on iPad. | |
| 371 | private struct TabKeyboardShortcuts: ViewModifier { | |
| 372 | @Binding var selectedTab: AppState.Tab | |
| 373 | ||
| 374 | private static let tabMap: [String: AppState.Tab] = [ | |
| 375 | "1": .home, | |
| 376 | "2": .repositories, | |
| 377 | "3": .tickets, | |
| 378 | "4": .builds, | |
| 379 | "5": .more, | |
| 380 | ] | |
| 381 | ||
| 382 | func body(content: Content) -> some View { | |
| 383 | content | |
| 384 | .onKeyPress(characters: .decimalDigits, phases: .down) { press in | |
| 385 | guard press.modifiers == .command else { return .ignored } | |
| 386 | let key = String(press.characters) | |
| 387 | if let tab = Self.tabMap[key] { | |
| 388 | selectedTab = tab | |
| 389 | return .handled | |
| 390 | } | |
| 391 | return .ignored | |
| 392 | } | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | // MARK: - iPad Sidebar Adaptable | |
| 397 | ||
| 398 | /// Applies `.tabViewStyle(.sidebarAdaptable)` on iOS 18+ so the tab bar | |
| 399 | /// becomes a full sidebar on iPad, while falling back to the standard tab | |
| 400 | /// bar on earlier releases. | |
| 401 | private struct SidebarAdaptableTabStyle: ViewModifier { | |
| 402 | func body(content: Content) -> some View { | |
| 403 | if #available(iOS 18.0, *) { | |
| 404 | content.tabViewStyle(.sidebarAdaptable) | |
| 405 | } else { | |
| 406 | content | |
| 407 | } | |
| 408 | } | |
| 409 | } | |
Hutch/Views/Home/HomeViewModel.swift +22
| @@ -388,6 +388,7 @@ final class HomeViewModel { | ||
| 388 | 388 | } |
| 389 | 389 | isLoadingSystemStatus = false |
| 390 | 390 | persistNeedsAttentionSnapshot() |
| 391 | persistSystemStatusWidgetSnapshot() | |
| 391 | 392 | } |
| 392 | 393 | |
| 393 | 394 | var hasDashboardContent: Bool { |
| @@ -928,6 +929,27 @@ final class HomeViewModel { | ||
| 928 | 929 | ) |
| 929 | 930 | } |
| 930 | 931 | |
| 932 | private func persistSystemStatusWidgetSnapshot() { | |
| 933 | guard let snapshot = systemStatusSnapshot else { | |
| 934 | return | |
| 935 | } | |
| 936 | let widgetSnapshot = SystemStatusWidgetSnapshot( | |
| 937 | services: snapshot.services.map { service in | |
| 938 | SystemStatusWidgetSnapshot.ServiceEntry( | |
| 939 | id: service.id, | |
| 940 | name: service.name, | |
| 941 | status: service.status.displayName, | |
| 942 | requiresAttention: service.status.requiresAttention | |
| 943 | ) | |
| 944 | }, | |
| 945 | hasDisruption: snapshot.hasDisruption, | |
| 946 | overallStatusText: snapshot.overallStatusText, | |
| 947 | bannerSummary: snapshot.bannerSummary, | |
| 948 | updatedAt: .now | |
| 949 | ) | |
| 950 | SystemStatusWidgetSnapshotStore.save(widgetSnapshot) | |
| 951 | } | |
| 952 | ||
| 931 | 953 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { |
| 932 | 954 | jobs.map { job in |
| 933 | 955 | let repository = primaryRepositoryReference(in: job.manifest) |
HutchTests/DeepLinkTests.swift added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | struct DeepLinkTests { | |
| 6 | ||
| 7 | @Test | |
| 8 | func parsesHomeLink() { | |
| 9 | let link = DeepLink(url: URL(string: "hutch://home")!) | |
| 10 | #expect(link == .home) | |
| 11 | } | |
| 12 | ||
| 13 | @Test | |
| 14 | func parsesNilPathAsHome() { | |
| 15 | let link = DeepLink(url: URL(string: "hutch://")!) | |
| 16 | #expect(link == .home) | |
| 17 | } | |
| 18 | ||
| 19 | @Test | |
| 20 | func parsesRepositoryLink() { | |
| 21 | let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!) | |
| 22 | #expect(link == .repository(owner: "~user", repo: "repo")) | |
| 23 | } | |
| 24 | ||
| 25 | @Test | |
| 26 | func parsesTicketLink() { | |
| 27 | let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!) | |
| 28 | #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42)) | |
| 29 | } | |
| 30 | ||
| 31 | @Test | |
| 32 | func parsesBuildJobLink() { | |
| 33 | let link = DeepLink(url: URL(string: "hutch://builds/12345")!) | |
| 34 | #expect(link == .build(jobId: 12345)) | |
| 35 | } | |
| 36 | ||
| 37 | @Test | |
| 38 | func parsesBuildsTabLink() { | |
| 39 | let link = DeepLink(url: URL(string: "hutch://builds")!) | |
| 40 | #expect(link == .buildsTab) | |
| 41 | } | |
| 42 | ||
| 43 | @Test | |
| 44 | func parsesRepositoriesTabLink() { | |
| 45 | let link = DeepLink(url: URL(string: "hutch://repositories")!) | |
| 46 | #expect(link == .repositoriesTab) | |
| 47 | } | |
| 48 | ||
| 49 | @Test | |
| 50 | func parsesTrackersTabLink() { | |
| 51 | let link = DeepLink(url: URL(string: "hutch://trackers")!) | |
| 52 | #expect(link == .trackersTab) | |
| 53 | } | |
| 54 | ||
| 55 | @Test | |
| 56 | func parsesSystemStatusLink() { | |
| 57 | let link = DeepLink(url: URL(string: "hutch://status")!) | |
| 58 | #expect(link == .systemStatus) | |
| 59 | } | |
| 60 | ||
| 61 | @Test | |
| 62 | func parsesLookupLink() { | |
| 63 | let link = DeepLink(url: URL(string: "hutch://lookup")!) | |
| 64 | #expect(link == .lookup) | |
| 65 | } | |
| 66 | ||
| 67 | @Test | |
| 68 | func rejectsNonHutchScheme() { | |
| 69 | let link = DeepLink(url: URL(string: "https://example.com/home")!) | |
| 70 | #expect(link == nil) | |
| 71 | } | |
| 72 | ||
| 73 | @Test | |
| 74 | func rejectsUnknownPath() { | |
| 75 | let link = DeepLink(url: URL(string: "hutch://unknown")!) | |
| 76 | #expect(link == nil) | |
| 77 | } | |
| 78 | ||
| 79 | @Test | |
| 80 | func rejectsTicketLinkWithNonNumericId() { | |
| 81 | let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!) | |
| 82 | #expect(link == nil) | |
| 83 | } | |
| 84 | ||
| 85 | @Test | |
| 86 | func rejectsBuildLinkWithNonNumericId() { | |
| 87 | let link = DeepLink(url: URL(string: "hutch://builds/abc")!) | |
| 88 | #expect(link == nil) | |
| 89 | } | |
| 90 | } | |
HutchTests/HutchIntentsTests.swift added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | @MainActor | |
| 6 | struct HutchIntentsTests { | |
| 7 | ||
| 8 | @Test | |
| 9 | func checkSystemStatusReturnsOperationalWhenNoDisruption() async throws { | |
| 10 | let defaultsName = "HutchIntentsTests-status-\(UUID().uuidString)" | |
| 11 | let defaults = UserDefaults(suiteName: defaultsName)! | |
| 12 | defer { defaults.removePersistentDomain(forName: defaultsName) } | |
| 13 | ||
| 14 | let snapshot = SystemStatusWidgetSnapshot( | |
| 15 | services: [ | |
| 16 | .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false), | |
| 17 | ], | |
| 18 | hasDisruption: false, | |
| 19 | overallStatusText: "All monitored services operational", | |
| 20 | bannerSummary: "", | |
| 21 | updatedAt: .now | |
| 22 | ) | |
| 23 | SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) | |
| 24 | ||
| 25 | let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) | |
| 26 | #expect(loaded != nil) | |
| 27 | #expect(loaded?.hasDisruption == false) | |
| 28 | } | |
| 29 | ||
| 30 | @Test | |
| 31 | func checkSystemStatusReturnsDisruptionDetails() async throws { | |
| 32 | let defaultsName = "HutchIntentsTests-disruption-\(UUID().uuidString)" | |
| 33 | let defaults = UserDefaults(suiteName: defaultsName)! | |
| 34 | defer { defaults.removePersistentDomain(forName: defaultsName) } | |
| 35 | ||
| 36 | let snapshot = SystemStatusWidgetSnapshot( | |
| 37 | services: [ | |
| 38 | .init(id: "git", name: "git.sr.ht", status: "Degraded", requiresAttention: true), | |
| 39 | .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false), | |
| 40 | ], | |
| 41 | hasDisruption: true, | |
| 42 | overallStatusText: "Experiencing disruptions", | |
| 43 | bannerSummary: "git.sr.ht disrupted", | |
| 44 | updatedAt: .now | |
| 45 | ) | |
| 46 | SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) | |
| 47 | ||
| 48 | let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) | |
| 49 | #expect(loaded?.hasDisruption == true) | |
| 50 | ||
| 51 | let disrupted = loaded!.services.filter(\.requiresAttention) | |
| 52 | #expect(disrupted.count == 1) | |
| 53 | #expect(disrupted.first?.name == "git.sr.ht") | |
| 54 | } | |
| 55 | ||
| 56 | @Test | |
| 57 | func needsAttentionSnapshotSupportsBuildsCheck() { | |
| 58 | let defaultsName = "HutchIntentsTests-builds-\(UUID().uuidString)" | |
| 59 | let defaults = UserDefaults(suiteName: defaultsName)! | |
| 60 | defer { defaults.removePersistentDomain(forName: defaultsName) } | |
| 61 | ||
| 62 | let snapshot = NeedsAttentionSnapshot( | |
| 63 | unreadInboxThreads: 5, | |
| 64 | assignedOpenTickets: 3, | |
| 65 | failedBuilds: 2, | |
| 66 | updatedAt: .now | |
| 67 | ) | |
| 68 | NeedsAttentionSnapshotStore.save(snapshot, defaults: defaults) | |
| 69 | ||
| 70 | let loaded = NeedsAttentionSnapshotStore.load(defaults: defaults) | |
| 71 | #expect(loaded?.failedBuilds == 2) | |
| 72 | #expect(loaded?.unreadInboxThreads == 5) | |
| 73 | #expect(loaded?.assignedOpenTickets == 3) | |
| 74 | } | |
| 75 | } | |
HutchTests/SystemStatusWidgetSnapshotTests.swift added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | @MainActor | |
| 6 | struct SystemStatusWidgetSnapshotTests { | |
| 7 | ||
| 8 | @Test | |
| 9 | func saveAndLoadRoundTrips() { | |
| 10 | let defaultsName = "SystemStatusWidgetSnapshotTests-\(UUID().uuidString)" | |
| 11 | let defaults = UserDefaults(suiteName: defaultsName)! | |
| 12 | defer { defaults.removePersistentDomain(forName: defaultsName) } | |
| 13 | ||
| 14 | let snapshot = SystemStatusWidgetSnapshot( | |
| 15 | services: [ | |
| 16 | .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false), | |
| 17 | .init(id: "builds", name: "builds.sr.ht", status: "Degraded", requiresAttention: true), | |
| 18 | ], | |
| 19 | hasDisruption: true, | |
| 20 | overallStatusText: "Experiencing disruptions", | |
| 21 | bannerSummary: "builds.sr.ht disrupted", | |
| 22 | updatedAt: Date(timeIntervalSince1970: 1000) | |
| 23 | ) | |
| 24 | ||
| 25 | SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) | |
| 26 | let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) | |
| 27 | ||
| 28 | #expect(loaded != nil) | |
| 29 | #expect(loaded?.services.count == 2) | |
| 30 | #expect(loaded?.hasDisruption == true) | |
| 31 | #expect(loaded?.bannerSummary == "builds.sr.ht disrupted") | |
| 32 | #expect(loaded?.services[0].name == "git.sr.ht") | |
| 33 | #expect(loaded?.services[1].requiresAttention == true) | |
| 34 | } | |
| 35 | ||
| 36 | @Test | |
| 37 | func loadReturnsNilWhenEmpty() { | |
| 38 | let defaultsName = "SystemStatusWidgetSnapshotTests-empty-\(UUID().uuidString)" | |
| 39 | let defaults = UserDefaults(suiteName: defaultsName)! | |
| 40 | defer { defaults.removePersistentDomain(forName: defaultsName) } | |
| 41 | ||
| 42 | let loaded = SystemStatusWidgetSnapshotStore.load(defaults: defaults) | |
| 43 | #expect(loaded == nil) | |
| 44 | } | |
| 45 | ||
| 46 | @Test | |
| 47 | func clearRemovesSnapshot() { | |
| 48 | let defaultsName = "SystemStatusWidgetSnapshotTests-clear-\(UUID().uuidString)" | |
| 49 | let defaults = UserDefaults(suiteName: defaultsName)! | |
| 50 | defer { defaults.removePersistentDomain(forName: defaultsName) } | |
| 51 | ||
| 52 | let snapshot = SystemStatusWidgetSnapshot( | |
| 53 | services: [.init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false)], | |
| 54 | hasDisruption: false, | |
| 55 | overallStatusText: "All monitored services operational", | |
| 56 | bannerSummary: "", | |
| 57 | updatedAt: .now | |
| 58 | ) | |
| 59 | ||
| 60 | SystemStatusWidgetSnapshotStore.save(snapshot, defaults: defaults) | |
| 61 | #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) != nil) | |
| 62 | ||
| 63 | SystemStatusWidgetSnapshotStore.clear(defaults: defaults) | |
| 64 | #expect(SystemStatusWidgetSnapshotStore.load(defaults: defaults) == nil) | |
| 65 | } | |
| 66 | ||
| 67 | @Test | |
| 68 | func unavailableSnapshotHasEmptyServices() { | |
| 69 | let snapshot = SystemStatusWidgetSnapshot.unavailable | |
| 70 | #expect(snapshot.services.isEmpty) | |
| 71 | #expect(snapshot.hasDisruption == false) | |
| 72 | } | |
| 73 | } | |
HutchWidgetExtension/NeedsAttentionWidget.swift +46 −33
| @@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View { | ||
| 71 | 71 | fallbackState |
| 72 | 72 | } |
| 73 | 73 | } |
| 74 | .widgetURL(URL(string: "hutch://home")) | |
| 74 | .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil) | |
| 75 | 75 | .containerBackground(for: .widget) { |
| 76 | 76 | Color(.systemBackground) |
| 77 | 77 | } |
| @@ -101,52 +101,64 @@ private struct NeedsAttentionWidgetView: View { | ||
| 101 | 101 | private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { |
| 102 | 102 | VStack(alignment: .leading, spacing: 0) { |
| 103 | 103 | HStack(alignment: .top, spacing: 12) { |
| 104 | metricColumn( | |
| 104 | Link(destination: URL(string: "hutch://home")!) { | |
| 105 | metricColumn( | |
| 106 | count: snapshot.unreadInboxThreads, | |
| 107 | label: "Unread", | |
| 108 | systemImage: "tray", | |
| 109 | tint: unreadTint(for: snapshot.unreadInboxThreads) | |
| 110 | ) | |
| 111 | } | |
| 112 | Link(destination: URL(string: "hutch://trackers")!) { | |
| 113 | metricColumn( | |
| 114 | count: snapshot.assignedOpenTickets, | |
| 115 | label: "Assigned", | |
| 116 | systemImage: "ticket" | |
| 117 | ) | |
| 118 | } | |
| 119 | Link(destination: URL(string: "hutch://builds")!) { | |
| 120 | metricColumn( | |
| 121 | count: snapshot.failedBuilds, | |
| 122 | label: "Failed", | |
| 123 | systemImage: "hammer", | |
| 124 | tint: failedTint(for: snapshot.failedBuilds) | |
| 125 | ) | |
| 126 | } | |
| 127 | } | |
| 128 | Spacer(minLength: 0) | |
| 129 | } | |
| 130 | .padding(.horizontal, 16) | |
| 131 | .padding(.top, 18) | |
| 132 | .padding(.bottom, 16) | |
| 133 | } | |
| 134 | ||
| 135 | private func largeView(snapshot: NeedsAttentionSnapshot) -> some View { | |
| 136 | VStack(alignment: .leading, spacing: 18) { | |
| 137 | Link(destination: URL(string: "hutch://home")!) { | |
| 138 | metricRow( | |
| 105 | 139 | count: snapshot.unreadInboxThreads, |
| 106 | label: "Unread", | |
| 140 | label: "Unread threads", | |
| 107 | 141 | systemImage: "tray", |
| 108 | 142 | tint: unreadTint(for: snapshot.unreadInboxThreads) |
| 109 | 143 | ) |
| 110 | metricColumn( | |
| 144 | } | |
| 145 | Link(destination: URL(string: "hutch://trackers")!) { | |
| 146 | metricRow( | |
| 111 | 147 | count: snapshot.assignedOpenTickets, |
| 112 | label: "Assigned", | |
| 148 | label: "Assigned tickets", | |
| 113 | 149 | systemImage: "ticket" |
| 114 | 150 | ) |
| 115 | metricColumn( | |
| 151 | } | |
| 152 | Link(destination: URL(string: "hutch://builds")!) { | |
| 153 | metricRow( | |
| 116 | 154 | count: snapshot.failedBuilds, |
| 117 | label: "Failed", | |
| 155 | label: "Failed builds", | |
| 118 | 156 | systemImage: "hammer", |
| 119 | 157 | tint: failedTint(for: snapshot.failedBuilds) |
| 120 | 158 | ) |
| 121 | 159 | } |
| 122 | 160 | Spacer(minLength: 0) |
| 123 | 161 | } |
| 124 | .padding(.horizontal, 16) | |
| 125 | .padding(.top, 18) | |
| 126 | .padding(.bottom, 16) | |
| 127 | } | |
| 128 | ||
| 129 | private func largeView(snapshot: NeedsAttentionSnapshot) -> some View { | |
| 130 | VStack(alignment: .leading, spacing: 18) { | |
| 131 | metricRow( | |
| 132 | count: snapshot.unreadInboxThreads, | |
| 133 | label: "Unread threads", | |
| 134 | systemImage: "tray", | |
| 135 | tint: unreadTint(for: snapshot.unreadInboxThreads) | |
| 136 | ) | |
| 137 | metricRow( | |
| 138 | count: snapshot.assignedOpenTickets, | |
| 139 | label: "Assigned tickets", | |
| 140 | systemImage: "ticket" | |
| 141 | ) | |
| 142 | metricRow( | |
| 143 | count: snapshot.failedBuilds, | |
| 144 | label: "Failed builds", | |
| 145 | systemImage: "hammer", | |
| 146 | tint: failedTint(for: snapshot.failedBuilds) | |
| 147 | ) | |
| 148 | Spacer(minLength: 0) | |
| 149 | } | |
| 150 | 162 | .padding(20) |
| 151 | 163 | } |
| 152 | 164 | |
| @@ -303,5 +315,6 @@ struct HutchWidgets: WidgetBundle { | ||
| 303 | 315 | var body: some Widget { |
| 304 | 316 | NeedsAttentionWidget() |
| 305 | 317 | ContributionGraphWidget() |
| 318 | SystemStatusWidget() | |
| 306 | 319 | } |
| 307 | 320 | } |
HutchWidgetExtension/SystemStatusWidget.swift added +177
| @@ -0,0 +1,177 @@ | ||
| 1 | import SwiftUI | |
| 2 | import WidgetKit | |
| 3 | ||
| 4 | struct SystemStatusEntry: TimelineEntry { | |
| 5 | let date: Date | |
| 6 | let snapshot: SystemStatusWidgetSnapshot? | |
| 7 | } | |
| 8 | ||
| 9 | struct SystemStatusTimelineProvider: TimelineProvider { | |
| 10 | func placeholder(in _: Context) -> SystemStatusEntry { | |
| 11 | SystemStatusEntry( | |
| 12 | date: .now, | |
| 13 | snapshot: SystemStatusWidgetSnapshot( | |
| 14 | services: [ | |
| 15 | .init(id: "git", name: "git.sr.ht", status: "Operational", requiresAttention: false), | |
| 16 | .init(id: "builds", name: "builds.sr.ht", status: "Operational", requiresAttention: false), | |
| 17 | .init(id: "lists", name: "lists.sr.ht", status: "Operational", requiresAttention: false), | |
| 18 | .init(id: "todo", name: "todo.sr.ht", status: "Operational", requiresAttention: false), | |
| 19 | .init(id: "meta", name: "meta.sr.ht", status: "Operational", requiresAttention: false), | |
| 20 | .init(id: "hg", name: "hg.sr.ht", status: "Operational", requiresAttention: false), | |
| 21 | ], | |
| 22 | hasDisruption: false, | |
| 23 | overallStatusText: "All monitored services operational", | |
| 24 | bannerSummary: "", | |
| 25 | updatedAt: .now | |
| 26 | ) | |
| 27 | ) | |
| 28 | } | |
| 29 | ||
| 30 | func getSnapshot(in _: Context, completion: @escaping (SystemStatusEntry) -> Void) { | |
| 31 | completion( | |
| 32 | SystemStatusEntry( | |
| 33 | date: .now, | |
| 34 | snapshot: SystemStatusWidgetSnapshotStore.load() | |
| 35 | ) | |
| 36 | ) | |
| 37 | } | |
| 38 | ||
| 39 | func getTimeline(in _: Context, completion: @escaping (Timeline<SystemStatusEntry>) -> Void) { | |
| 40 | let refreshDate = Calendar.current.date(byAdding: .minute, value: 15, to: .now) ?? .now.addingTimeInterval(900) | |
| 41 | let entry = SystemStatusEntry(date: .now, snapshot: SystemStatusWidgetSnapshotStore.load()) | |
| 42 | completion(Timeline(entries: [entry], policy: .after(refreshDate))) | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | struct SystemStatusWidget: Widget { | |
| 47 | static let kind = SystemStatusWidgetConfiguration.kind | |
| 48 | ||
| 49 | var body: some WidgetConfiguration { | |
| 50 | StaticConfiguration(kind: Self.kind, provider: SystemStatusTimelineProvider()) { entry in | |
| 51 | SystemStatusWidgetView(entry: entry) | |
| 52 | } | |
| 53 | .configurationDisplayName("SourceHut Status") | |
| 54 | .description("Current status of SourceHut services at a glance.") | |
| 55 | .supportedFamilies([.systemSmall, .systemMedium]) | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | private struct SystemStatusWidgetView: View { | |
| 60 | let entry: SystemStatusEntry | |
| 61 | @Environment(\.widgetFamily) private var family | |
| 62 | ||
| 63 | var body: some View { | |
| 64 | Group { | |
| 65 | if let snapshot = entry.snapshot, !snapshot.services.isEmpty { | |
| 66 | switch family { | |
| 67 | case .systemMedium: | |
| 68 | mediumView(snapshot: snapshot) | |
| 69 | default: | |
| 70 | smallView(snapshot: snapshot) | |
| 71 | } | |
| 72 | } else { | |
| 73 | fallbackView | |
| 74 | } | |
| 75 | } | |
| 76 | .widgetURL(URL(string: "hutch://status")) | |
| 77 | .containerBackground(for: .widget) { | |
| 78 | Color(.systemBackground) | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | private func smallView(snapshot: SystemStatusWidgetSnapshot) -> some View { | |
| 83 | VStack(alignment: .leading, spacing: 8) { | |
| 84 | HStack(spacing: 6) { | |
| 85 | Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") | |
| 86 | .font(.title3.weight(.semibold)) | |
| 87 | .foregroundStyle(snapshot.hasDisruption ? .orange : .green) | |
| 88 | Spacer() | |
| 89 | } | |
| 90 | ||
| 91 | Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Clear") | |
| 92 | .font(.subheadline.weight(.semibold)) | |
| 93 | .lineLimit(2) | |
| 94 | ||
| 95 | Spacer(minLength: 0) | |
| 96 | ||
| 97 | if snapshot.hasDisruption { | |
| 98 | let disrupted = snapshot.services.filter(\.requiresAttention) | |
| 99 | Text(disrupted.map(\.name).joined(separator: ", ")) | |
| 100 | .font(.caption2) | |
| 101 | .foregroundStyle(.secondary) | |
| 102 | .lineLimit(2) | |
| 103 | } else { | |
| 104 | Text("\(snapshot.services.count) services monitored") | |
| 105 | .font(.caption2) | |
| 106 | .foregroundStyle(.secondary) | |
| 107 | } | |
| 108 | } | |
| 109 | .padding() | |
| 110 | } | |
| 111 | ||
| 112 | private func mediumView(snapshot: SystemStatusWidgetSnapshot) -> some View { | |
| 113 | VStack(alignment: .leading, spacing: 6) { | |
| 114 | HStack(spacing: 8) { | |
| 115 | Image(systemName: snapshot.hasDisruption ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") | |
| 116 | .font(.title3.weight(.semibold)) | |
| 117 | .foregroundStyle(snapshot.hasDisruption ? .orange : .green) | |
| 118 | ||
| 119 | Text(snapshot.hasDisruption ? snapshot.bannerSummary : "All Services Operational") | |
| 120 | .font(.subheadline.weight(.semibold)) | |
| 121 | .lineLimit(1) | |
| 122 | ||
| 123 | Spacer() | |
| 124 | } | |
| 125 | ||
| 126 | Divider() | |
| 127 | ||
| 128 | let columns = Array(snapshot.services.prefix(8)) | |
| 129 | let half = (columns.count + 1) / 2 | |
| 130 | let leftColumn = Array(columns.prefix(half)) | |
| 131 | let rightColumn = Array(columns.suffix(from: half)) | |
| 132 | ||
| 133 | HStack(alignment: .top, spacing: 16) { | |
| 134 | VStack(alignment: .leading, spacing: 4) { | |
| 135 | ForEach(leftColumn) { service in | |
| 136 | serviceRow(service) | |
| 137 | } | |
| 138 | } | |
| 139 | VStack(alignment: .leading, spacing: 4) { | |
| 140 | ForEach(rightColumn) { service in | |
| 141 | serviceRow(service) | |
| 142 | } | |
| 143 | } | |
| 144 | Spacer(minLength: 0) | |
| 145 | } | |
| 146 | ||
| 147 | Spacer(minLength: 0) | |
| 148 | } | |
| 149 | .padding(.horizontal, 16) | |
| 150 | .padding(.top, 14) | |
| 151 | .padding(.bottom, 12) | |
| 152 | } | |
| 153 | ||
| 154 | private func serviceRow(_ service: SystemStatusWidgetSnapshot.ServiceEntry) -> some View { | |
| 155 | HStack(spacing: 6) { | |
| 156 | Circle() | |
| 157 | .fill(service.requiresAttention ? Color.orange : Color.green) | |
| 158 | .frame(width: 6, height: 6) | |
| 159 | Text(service.name) | |
| 160 | .font(.caption2) | |
| 161 | .foregroundStyle(service.requiresAttention ? .primary : .secondary) | |
| 162 | .lineLimit(1) | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | private var fallbackView: some View { | |
| 167 | VStack(alignment: .leading, spacing: 8) { | |
| 168 | Spacer(minLength: 0) | |
| 169 | Text("Open Hutch") | |
| 170 | .font(.title3.weight(.semibold)) | |
| 171 | Text("Sign in to load system status.") | |
| 172 | .font(.caption) | |
| 173 | .foregroundStyle(.secondary) | |
| 174 | } | |
| 175 | .padding() | |
| 176 | } | |
| 177 | } | |
Shared/SystemStatusWidgetSnapshot.swift added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | import Foundation | |
| 2 | #if canImport(WidgetKit) | |
| 3 | import WidgetKit | |
| 4 | #endif | |
| 5 | ||
| 6 | enum SystemStatusWidgetConfiguration { | |
| 7 | static let kind = "SystemStatusWidget" | |
| 8 | } | |
| 9 | ||
| 10 | struct SystemStatusWidgetSnapshot: Codable, Sendable { | |
| 11 | let services: [ServiceEntry] | |
| 12 | let hasDisruption: Bool | |
| 13 | let overallStatusText: String | |
| 14 | let bannerSummary: String | |
| 15 | let updatedAt: Date | |
| 16 | ||
| 17 | struct ServiceEntry: Codable, Sendable, Identifiable { | |
| 18 | let id: String | |
| 19 | let name: String | |
| 20 | let status: String | |
| 21 | let requiresAttention: Bool | |
| 22 | } | |
| 23 | ||
| 24 | static let unavailable = SystemStatusWidgetSnapshot( | |
| 25 | services: [], | |
| 26 | hasDisruption: false, | |
| 27 | overallStatusText: "Unavailable", | |
| 28 | bannerSummary: "", | |
| 29 | updatedAt: .now | |
| 30 | ) | |
| 31 | } | |
| 32 | ||
| 33 | enum SystemStatusWidgetSnapshotStore { | |
| 34 | private static let snapshotKey = "systemStatus.widgetSnapshot" | |
| 35 | ||
| 36 | static func load(defaults: UserDefaults? = sharedDefaults()) -> SystemStatusWidgetSnapshot? { | |
| 37 | guard let defaults, | |
| 38 | let data = defaults.data(forKey: snapshotKey) else { | |
| 39 | return nil | |
| 40 | } | |
| 41 | return try? JSONDecoder().decode(SystemStatusWidgetSnapshot.self, from: data) | |
| 42 | } | |
| 43 | ||
| 44 | static func save(_ snapshot: SystemStatusWidgetSnapshot, defaults: UserDefaults? = sharedDefaults()) { | |
| 45 | guard let defaults, | |
| 46 | let data = try? JSONEncoder().encode(snapshot) else { | |
| 47 | return | |
| 48 | } | |
| 49 | defaults.set(data, forKey: snapshotKey) | |
| 50 | reloadWidgetTimelines() | |
| 51 | } | |
| 52 | ||
| 53 | static func clear(defaults: UserDefaults? = sharedDefaults()) { | |
| 54 | defaults?.removeObject(forKey: snapshotKey) | |
| 55 | reloadWidgetTimelines() | |
| 56 | } | |
| 57 | ||
| 58 | private static func sharedDefaults() -> UserDefaults? { | |
| 59 | UserDefaults(suiteName: HutchAppGroup.identifier) | |
| 60 | } | |
| 61 | ||
| 62 | private static func reloadWidgetTimelines() { | |
| 63 | #if canImport(WidgetKit) | |
| 64 | WidgetCenter.shared.reloadTimelines(ofKind: SystemStatusWidgetConfiguration.kind) | |
| 65 | #endif | |
| 66 | } | |
| 67 | } | |