Commit 8d5cd9e707

8d5cd9e7077a059dadce089a255e8bdb8e62a8d3

parent: f27a5c4fa5

Unsigned

cmc <hello@cleberg.net> · 2026-03-24 23:28 UTC

v2.4.0: widgets

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +172 −4
@@ -10,6 +10,7 @@
1010 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; };
1111 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; };
1212 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; };
13 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1314/* End PBXBuildFile section */
1415
1516/* Begin PBXContainerItemProxy section */
@@ -20,6 +21,13 @@
2021 remoteGlobalIDString = 8B4B28D02F6704280045FA19;
2122 remoteInfo = Hutch;
2223 };
24 8BE082022F80000100000001 /* PBXContainerItemProxy */ = {
25 isa = PBXContainerItemProxy;
26 containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
27 proxyType = 1;
28 remoteGlobalIDString = 8BE081F72F80000100000001;
29 remoteInfo = HutchWidgetExtension;
30 };
2331/* End PBXContainerItemProxy section */
2432
2533/* Begin PBXFileReference section */
@@ -30,6 +38,7 @@
3038 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3139 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; };
3240 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
41 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HutchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
3342/* End PBXFileReference section */
3443
3544/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -40,6 +49,13 @@
4049 );
4150 target = 8B4B28D02F6704280045FA19 /* Hutch */;
4251 };
52 8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */ = {
53 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
54 membershipExceptions = (
55 Info.plist,
56 );
57 target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
58 };
4359/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
4460
4561/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -56,8 +72,35 @@
5672 path = HutchTests;
5773 sourceTree = "<group>";
5874 };
75 8BE081F42F80000100000001 /* Shared */ = {
76 isa = PBXFileSystemSynchronizedRootGroup;
77 path = Shared;
78 sourceTree = "<group>";
79 };
80 8BE081F52F80000100000001 /* HutchWidgetExtension */ = {
81 isa = PBXFileSystemSynchronizedRootGroup;
82 exceptions = (
83 8BE082032F80000100000001 /* Exceptions for "HutchWidgetExtension" folder in "HutchWidgetExtension" target */,
84 );
85 path = HutchWidgetExtension;
86 sourceTree = "<group>";
87 };
5988/* End PBXFileSystemSynchronizedRootGroup section */
6089
90/* Begin PBXCopyFilesBuildPhase section */
91 8BE081FA2F80000100000001 /* Embed App Extensions */ = {
92 isa = PBXCopyFilesBuildPhase;
93 buildActionMask = 2147483647;
94 dstPath = "";
95 dstSubfolderSpec = 13;
96 files = (
97 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed App Extensions */,
98 );
99 name = "Embed App Extensions";
100 runOnlyForDeploymentPostprocessing = 0;
101 };
102/* End PBXCopyFilesBuildPhase section */
103
61104/* Begin PBXFrameworksBuildPhase section */
62105 8B4B28CE2F6704280045FA19 /* Frameworks */ = {
63106 isa = PBXFrameworksBuildPhase;
@@ -73,6 +116,13 @@
73116 );
74117 runOnlyForDeploymentPostprocessing = 0;
75118 };
119 8BE081F92F80000100000001 /* Frameworks */ = {
120 isa = PBXFrameworksBuildPhase;
121 buildActionMask = 2147483647;
122 files = (
123 );
124 runOnlyForDeploymentPostprocessing = 0;
125 };
76126/* End PBXFrameworksBuildPhase section */
77127
78128/* Begin PBXGroup section */
@@ -85,6 +135,8 @@
85135 8B2F89642F69DEB900FC0253 /* README.md */,
86136 8B2F89652F69DEB900FC0253 /* SECURITY.md */,
87137 8B4B28D32F6704280045FA19 /* Hutch */,
138 8BE081F42F80000100000001 /* Shared */,
139 8BE081F52F80000100000001 /* HutchWidgetExtension */,
88140 8B8182B82F6B73F3000AE049 /* HutchTests */,
89141 8B4B28D22F6704280045FA19 /* Products */,
90142 );
@@ -95,6 +147,7 @@
95147 children = (
96148 8B4B28D12F6704280045FA19 /* Hutch.app */,
97149 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */,
150 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */,
98151 );
99152 name = Products;
100153 sourceTree = "<group>";
@@ -109,13 +162,16 @@
109162 8B4B28CD2F6704280045FA19 /* Sources */,
110163 8B4B28CE2F6704280045FA19 /* Frameworks */,
111164 8B4B28CF2F6704280045FA19 /* Resources */,
165 8BE081FA2F80000100000001 /* Embed App Extensions */,
112166 );
113167 buildRules = (
114168 );
115169 dependencies = (
170 8BE082042F80000100000001 /* PBXTargetDependency */,
116171 );
117172 fileSystemSynchronizedGroups = (
118173 8B4B28D32F6704280045FA19 /* Hutch */,
174 8BE081F42F80000100000001 /* Shared */,
119175 );
120176 name = Hutch;
121177 packageProductDependencies = (
@@ -147,6 +203,29 @@
147203 productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */;
148204 productType = "com.apple.product-type.bundle.unit-test";
149205 };
206 8BE081F72F80000100000001 /* HutchWidgetExtension */ = {
207 isa = PBXNativeTarget;
208 buildConfigurationList = 8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */;
209 buildPhases = (
210 8BE081F82F80000100000001 /* Sources */,
211 8BE081F92F80000100000001 /* Frameworks */,
212 8BE081FB2F80000100000001 /* Resources */,
213 );
214 buildRules = (
215 );
216 dependencies = (
217 );
218 fileSystemSynchronizedGroups = (
219 8BE081F42F80000100000001 /* Shared */,
220 8BE081F52F80000100000001 /* HutchWidgetExtension */,
221 );
222 name = HutchWidgetExtension;
223 packageProductDependencies = (
224 );
225 productName = HutchWidgetExtension;
226 productReference = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */;
227 productType = "com.apple.product-type.app-extension";
228 };
150229/* End PBXNativeTarget section */
151230
152231/* Begin PBXProject section */
@@ -182,6 +261,7 @@
182261 targets = (
183262 8B4B28D02F6704280045FA19 /* Hutch */,
184263 8B8182B62F6B73F3000AE049 /* HutchTests */,
264 8BE081F72F80000100000001 /* HutchWidgetExtension */,
185265 );
186266 };
187267/* End PBXProject section */
@@ -204,6 +284,13 @@
204284 );
205285 runOnlyForDeploymentPostprocessing = 0;
206286 };
287 8BE081FB2F80000100000001 /* Resources */ = {
288 isa = PBXResourcesBuildPhase;
289 buildActionMask = 2147483647;
290 files = (
291 );
292 runOnlyForDeploymentPostprocessing = 0;
293 };
207294/* End PBXResourcesBuildPhase section */
208295
209296/* Begin PBXSourcesBuildPhase section */
@@ -221,6 +308,13 @@
221308 );
222309 runOnlyForDeploymentPostprocessing = 0;
223310 };
311 8BE081F82F80000100000001 /* Sources */ = {
312 isa = PBXSourcesBuildPhase;
313 buildActionMask = 2147483647;
314 files = (
315 );
316 runOnlyForDeploymentPostprocessing = 0;
317 };
224318/* End PBXSourcesBuildPhase section */
225319
226320/* Begin PBXTargetDependency section */
@@ -229,6 +323,11 @@
229323 target = 8B4B28D02F6704280045FA19 /* Hutch */;
230324 targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */;
231325 };
326 8BE082042F80000100000001 /* PBXTargetDependency */ = {
327 isa = PBXTargetDependency;
328 target = 8BE081F72F80000100000001 /* HutchWidgetExtension */;
329 targetProxy = 8BE082022F80000100000001 /* PBXContainerItemProxy */;
330 };
232331/* End PBXTargetDependency section */
233332
234333/* Begin XCBuildConfiguration section */
@@ -358,8 +457,9 @@
358457 buildSettings = {
359458 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
360459 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
460 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
361461 CODE_SIGN_STYLE = Automatic;
362 CURRENT_PROJECT_VERSION = 9;
462 CURRENT_PROJECT_VERSION = 10;
363463 DEVELOPMENT_TEAM = ZCNAX3VL9D;
364464 ENABLE_PREVIEWS = YES;
365465 GENERATE_INFOPLIST_FILE = YES;
@@ -376,7 +476,7 @@
376476 "$(inherited)",
377477 "@executable_path/Frameworks",
378478 );
379 MARKETING_VERSION = 2.3.1;
479 MARKETING_VERSION = 2.4.0;
380480 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
381481 PRODUCT_NAME = "$(TARGET_NAME)";
382482 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -394,8 +494,9 @@
394494 buildSettings = {
395495 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
396496 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
497 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
397498 CODE_SIGN_STYLE = Automatic;
398 CURRENT_PROJECT_VERSION = 9;
499 CURRENT_PROJECT_VERSION = 10;
399500 DEVELOPMENT_TEAM = ZCNAX3VL9D;
400501 ENABLE_PREVIEWS = YES;
401502 GENERATE_INFOPLIST_FILE = YES;
@@ -412,7 +513,7 @@
412513 "$(inherited)",
413514 "@executable_path/Frameworks",
414515 );
415 MARKETING_VERSION = 2.3.1;
516 MARKETING_VERSION = 2.4.0;
416517 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
417518 PRODUCT_NAME = "$(TARGET_NAME)";
418519 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -469,6 +570,64 @@
469570 };
470571 name = Release;
471572 };
573 8BE081FC2F80000100000001 /* Debug */ = {
574 isa = XCBuildConfiguration;
575 buildSettings = {
576 APPLICATION_EXTENSION_API_ONLY = YES;
577 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
578 CODE_SIGN_STYLE = Automatic;
579 CURRENT_PROJECT_VERSION = 10;
580 DEVELOPMENT_TEAM = ZCNAX3VL9D;
581 GENERATE_INFOPLIST_FILE = NO;
582 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
583 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
584 LD_RUNPATH_SEARCH_PATHS = (
585 "$(inherited)",
586 "@executable_path/Frameworks",
587 "@executable_path/../../Frameworks",
588 );
589 MARKETING_VERSION = 2.4.0;
590 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
591 PRODUCT_NAME = "$(TARGET_NAME)";
592 SKIP_INSTALL = YES;
593 STRING_CATALOG_GENERATE_SYMBOLS = YES;
594 SWIFT_APPROACHABLE_CONCURRENCY = YES;
595 SWIFT_EMIT_LOC_STRINGS = YES;
596 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
597 SWIFT_VERSION = 5.0;
598 TARGETED_DEVICE_FAMILY = "1,2";
599 };
600 name = Debug;
601 };
602 8BE081FD2F80000100000001 /* Release */ = {
603 isa = XCBuildConfiguration;
604 buildSettings = {
605 APPLICATION_EXTENSION_API_ONLY = YES;
606 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
607 CODE_SIGN_STYLE = Automatic;
608 CURRENT_PROJECT_VERSION = 10;
609 DEVELOPMENT_TEAM = ZCNAX3VL9D;
610 GENERATE_INFOPLIST_FILE = NO;
611 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
612 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
613 LD_RUNPATH_SEARCH_PATHS = (
614 "$(inherited)",
615 "@executable_path/Frameworks",
616 "@executable_path/../../Frameworks",
617 );
618 MARKETING_VERSION = 2.4.0;
619 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
620 PRODUCT_NAME = "$(TARGET_NAME)";
621 SKIP_INSTALL = YES;
622 STRING_CATALOG_GENERATE_SYMBOLS = YES;
623 SWIFT_APPROACHABLE_CONCURRENCY = YES;
624 SWIFT_EMIT_LOC_STRINGS = YES;
625 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
626 SWIFT_VERSION = 5.0;
627 TARGETED_DEVICE_FAMILY = "1,2";
628 };
629 name = Release;
630 };
472631/* End XCBuildConfiguration section */
473632
474633/* Begin XCConfigurationList section */
@@ -499,6 +658,15 @@
499658 defaultConfigurationIsVisible = 0;
500659 defaultConfigurationName = Release;
501660 };
661 8BE081FE2F80000100000001 /* Build configuration list for PBXNativeTarget "HutchWidgetExtension" */ = {
662 isa = XCConfigurationList;
663 buildConfigurations = (
664 8BE081FC2F80000100000001 /* Debug */,
665 8BE081FD2F80000100000001 /* Release */,
666 );
667 defaultConfigurationIsVisible = 0;
668 defaultConfigurationName = Release;
669 };
502670/* End XCConfigurationList section */
503671 };
504672 rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata +8
@@ -4,4 +4,12 @@
44 <FileRef
55 location = "self:">
66 </FileRef>
7 <Group
8 location = "group:Shared"
9 name = "Shared">
10 </Group>
11 <Group
12 location = "group:HutchWidgetExtension"
13 name = "HutchWidgetExtension">
14 </Group>
715</Workspace>
Hutch/App/AppState.swift +15
@@ -77,11 +77,13 @@ final class AppState {
7777 let user = try await fetchMe()
7878 currentUser = user
7979 authPhase = .authenticated
80 await refreshNeedsAttentionSnapshot()
8081 } catch {
8182 try? KeychainHelper.deleteToken()
8283 client.setToken(nil)
8384 currentUser = nil
8485 authPhase = .unauthenticated
86 NeedsAttentionSnapshotStore.clear()
8587 }
8688 }
8789
@@ -98,6 +100,7 @@ final class AppState {
98100 try KeychainHelper.saveToken(token)
99101 currentUser = user
100102 authPhase = .authenticated
103 await refreshNeedsAttentionSnapshot()
101104 } catch {
102105 // Roll back — don't leave an invalid token in the client.
103106 client.setToken(nil)
@@ -111,6 +114,7 @@ final class AppState {
111114 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
112115 await clearWebData()
113116 clearWebContentRenderCaches()
117 NeedsAttentionSnapshotStore.clear()
114118 authPhase = .unauthenticated
115119 selectedTab = .home
116120 }
@@ -125,6 +129,7 @@ final class AppState {
125129 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
126130 await clearWebData()
127131 clearWebContentRenderCaches()
132 NeedsAttentionSnapshotStore.clear()
128133
129134 authPhase = .unauthenticated
130135 selectedTab = .home
@@ -270,6 +275,16 @@ final class AppState {
270275 selectedTab = .home
271276 }
272277
278 private func refreshNeedsAttentionSnapshot() async {
279 guard let currentUser else {
280 NeedsAttentionSnapshotStore.clear()
281 return
282 }
283
284 let viewModel = HomeViewModel(currentUser: currentUser, client: client)
285 await viewModel.loadDashboard()
286 }
287
273288 private func clearWebData() async {
274289 await withCheckedContinuation { continuation in
275290 let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
Hutch/App/DeepLink.swift +4
@@ -2,6 +2,7 @@ import Foundation
22
33/// Represents a parsed `hutch://` deep link.
44enum DeepLink: Equatable {
5 case home
56 /// hutch://git/<owner>/<repo>
67 case repository(owner: String, repo: String)
78 /// hutch://todo/<owner>/<tracker>/<ticketId>
@@ -19,6 +20,9 @@ enum DeepLink: Equatable {
1920 let components = url.pathComponents(fromScheme: "hutch")
2021
2122 switch components.first {
23 case "home", nil:
24 self = .home
25
2226 case "git" where components.count >= 3:
2327 let owner = components[1]
2428 let repo = components[2]
Hutch/App/RootView.swift +7
@@ -161,6 +161,10 @@ struct RootView: View {
161161 guard appState.isAuthenticated else { return }
162162
163163 switch link {
164 case .home:
165 homePath = NavigationPath()
166 appState.selectedTab = .home
167
164168 case .repository(let owner, let repo):
165169 resolveRepositoryLink(owner: owner, repo: repo)
166170
@@ -284,12 +288,15 @@ private struct MoreNavigationRoot: View {
284288 thread: thread,
285289 onViewed: {
286290 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
291 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
287292 },
288293 onMarkRead: {
289294 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
295 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
290296 },
291297 onMarkUnread: {
292298 InboxReadStateStore.markUnread(for: thread.id)
299 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
293300 }
294301 )
295302 }
Hutch/Hutch.entitlements added +10
@@ -0,0 +1,10 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>com.apple.security.application-groups</key>
6 <array>
7 <string>group.net.cleberg.Hutch</string>
8 </array>
9</dict>
10</plist>
Hutch/Views/Home/HomeViewModel.swift +92 −35
@@ -59,6 +59,7 @@ private struct HomeInboxMailingListThreads: Decodable, Sendable {
5959
6060private struct HomeInboxThreadPage: Decodable, Sendable {
6161 let results: [HomeInboxThreadPayload]
62 let cursor: String?
6263}
6364
6465private struct HomeInboxThreadPayload: Decodable, Sendable {
@@ -143,6 +144,7 @@ final class HomeViewModel {
143144 var assignedTickets: [HomeAssignedTicket] = []
144145 var recentBuilds: [HomeBuildItem] = []
145146 private(set) var hasUnreadInboxThreads = false
147 private(set) var unreadInboxThreadCount: Int?
146148 private(set) var isLoadingProjects = false
147149 private(set) var isLoadingAssignedTickets = false
148150 private(set) var isLoadingRecentBuilds = false
@@ -154,7 +156,6 @@ final class HomeViewModel {
154156 private let projectService: ProjectService
155157 private let ticketFetchConcurrencyLimit = 6
156158 private let inboxUnreadConcurrencyLimit = 4
157 private let inboxUnreadThreadPreviewLimit = 10
158159
159160 private static let jobsQuery = """
160161 query jobs {
@@ -232,13 +233,14 @@ final class HomeViewModel {
232233 """
233234
234235 private static let inboxListThreadsQuery = """
235 query inboxListThreads($rid: ID!) {
236 query inboxListThreads($rid: ID!, $cursor: Cursor) {
236237 list(rid: $rid) {
237 threads {
238 threads(cursor: $cursor) {
238239 results {
239240 updated
240241 subject
241242 }
243 cursor
242244 }
243245 }
244246 }
@@ -280,7 +282,7 @@ final class HomeViewModel {
280282 async let projectsTask = loadProjects()
281283 async let jobsTask = loadRecentJobs()
282284 async let assignedTicketsTask = loadAssignedTickets()
283 async let inboxUnreadTask = loadInboxUnreadState()
285 async let inboxUnreadTask = loadInboxUnreadCount()
284286
285287 let projectsResult = await projectsTask
286288 switch projectsResult {
@@ -316,7 +318,9 @@ final class HomeViewModel {
316318 }
317319 isLoadingAssignedTickets = false
318320
319 hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
321 unreadInboxThreadCount = await inboxUnreadTask
322 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
323 persistNeedsAttentionSnapshot()
320324 }
321325
322326 func resolveTicket(_ ticket: HomeAssignedTicket) async {
@@ -347,6 +351,7 @@ final class HomeViewModel {
347351 responseType: UnassignResponse.self
348352 )
349353 assignedTickets.removeAll { $0.id == ticket.id }
354 persistNeedsAttentionSnapshot()
350355 } catch {
351356 homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
352357 }
@@ -380,6 +385,7 @@ final class HomeViewModel {
380385 repositoryOwner: build.repositoryOwner
381386 )
382387 }
388 persistNeedsAttentionSnapshot()
383389 } catch {
384390 homeLogger.error("Cancel build failed: \(error, privacy: .public)")
385391 }
@@ -406,19 +412,21 @@ final class HomeViewModel {
406412 }
407413 }
408414
409 private func loadInboxUnreadState() async -> Bool? {
415 private func loadInboxUnreadCount() async -> Int? {
410416 do {
411 return try await fetchHasUnreadInboxThreads()
417 return try await fetchUnreadInboxThreadCount()
412418 } catch {
413419 return nil
414420 }
415421 }
416422
417 private func fetchHasUnreadInboxThreads() async throws -> Bool {
423 private func fetchUnreadInboxThreadCount() async throws -> Int {
418424 let mailingLists = try await fetchInboxMailingLists()
419 guard !mailingLists.isEmpty else { return false }
425 guard !mailingLists.isEmpty else { return 0 }
420426
421427 var startIndex = mailingLists.startIndex
428 var unreadCount = 0
429 var successfulFetchCount = 0
422430 while startIndex < mailingLists.endIndex {
423431 let endIndex = mailingLists.index(
424432 startIndex,
@@ -427,30 +435,41 @@ final class HomeViewModel {
427435 ) ?? mailingLists.endIndex
428436 let batch = Array(mailingLists[startIndex..<endIndex])
429437
430 let batchHasUnread = await withTaskGroup(of: Bool.self) { group in
438 let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in
431439 for mailingList in batch {
432440 group.addTask {
433 (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false
441 do {
442 return .success(try await self.fetchUnreadThreadCount(for: mailingList))
443 } catch {
444 return .failure(error)
445 }
434446 }
435447 }
436448
437 for await hasUnread in group {
438 if hasUnread {
439 group.cancelAll()
440 return true
449 var counts: [Int] = []
450 var errors: [Error] = []
451 for await result in group {
452 switch result {
453 case .success(let count):
454 counts.append(count)
455 case .failure(let error):
456 errors.append(error)
441457 }
442458 }
443 return false
459 return (counts, errors)
444460 }
445461
446 if batchHasUnread {
447 return true
448 }
462 unreadCount += batchResult.0.reduce(0, +)
463 successfulFetchCount += batchResult.0.count
449464
450465 startIndex = endIndex
451466 }
452467
453 return false
468 guard successfulFetchCount > 0 else {
469 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
470 }
471
472 return unreadCount
454473 }
455474
456475 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
@@ -481,23 +500,40 @@ final class HomeViewModel {
481500 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
482501 }
483502
484 private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool {
485 let response = try await client.execute(
486 service: .lists,
487 query: Self.inboxListThreadsQuery,
488 variables: ["rid": mailingList.rid],
489 responseType: HomeInboxListThreadsResponse.self
490 )
503 private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int {
504 var unreadCount = 0
505 var cursor: String?
506
507 while true {
508 var variables: [String: any Sendable] = ["rid": mailingList.rid]
509 if let cursor {
510 variables["cursor"] = cursor
511 }
491512
492 return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in
493 let normalizedSubject = thread.subject
494 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
495 .trimmingCharacters(in: .whitespacesAndNewlines)
496 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
497 .lowercased()
498 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
499 return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
513 let response = try await client.execute(
514 service: .lists,
515 query: Self.inboxListThreadsQuery,
516 variables: variables,
517 responseType: HomeInboxListThreadsResponse.self
518 )
519
520 unreadCount += response.list.threads.results.filter { thread in
521 let normalizedSubject = thread.subject
522 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
523 .trimmingCharacters(in: .whitespacesAndNewlines)
524 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
525 .lowercased()
526 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
527 return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
528 }.count
529
530 guard let nextCursor = response.list.threads.cursor else {
531 break
532 }
533 cursor = nextCursor
500534 }
535
536 return unreadCount
501537 }
502538
503539 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
@@ -616,11 +652,32 @@ final class HomeViewModel {
616652 responseType: StatusEventResponse.self
617653 )
618654 assignedTickets.removeAll { $0.id == ticket.id }
655 persistNeedsAttentionSnapshot()
619656 } catch {
620657 homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
621658 }
622659 }
623660
661 private func persistNeedsAttentionSnapshot() {
662 NeedsAttentionSnapshotStore.save(
663 NeedsAttentionSnapshot(
664 unreadInboxThreads: unreadInboxThreadCount,
665 assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
666 failedBuilds: recentBuildsError == nil
667 ? recentBuilds.filter {
668 switch $0.job.status {
669 case .failed, .timeout:
670 true
671 default:
672 false
673 }
674 }.count
675 : nil,
676 updatedAt: .now
677 )
678 )
679 }
680
624681 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
625682 jobs.map { job in
626683 let repository = primaryRepositoryReference(in: job.manifest)
Hutch/Views/Inbox/InboxViewModel.swift +3
@@ -136,6 +136,7 @@ final class InboxViewModel {
136136 }
137137 return lhs.lastActivityAt > rhs.lastActivityAt
138138 }
139 NeedsAttentionSnapshotStore.update(unreadInboxThreads: threads.count)
139140 } catch {
140141 inboxListLogger.error("Inbox request failed")
141142 self.error = "Failed to load inbox"
@@ -146,11 +147,13 @@ final class InboxViewModel {
146147 let viewedAt = max(Date(), thread.lastActivityAt)
147148 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
148149 threads.removeAll { $0.id == thread.id }
150 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
149151 }
150152
151153 func markThreadUnread(_ thread: InboxThreadSummary) {
152154 InboxReadStateStore.markUnread(for: thread.id)
153155 updateThread(thread, isUnread: true)
156 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
154157 }
155158
156159 func toggleThreadReadState(_ thread: InboxThreadSummary) {
Hutch/Views/Projects/ProjectMailingListView.swift +2
@@ -92,11 +92,13 @@ final class MailingListDetailViewModel {
9292 let viewedAt = max(Date(), thread.lastActivityAt)
9393 InboxReadStateStore.markViewed(viewedAt, for: thread.id)
9494 updateThread(thread, isUnread: false)
95 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
9596 }
9697
9798 func markThreadUnread(_ thread: InboxThreadSummary) {
9899 InboxReadStateStore.markUnread(for: thread.id)
99100 updateThread(thread, isUnread: true)
101 NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
100102 }
101103
102104 private func makeSummary(from thread: ProjectMailingListThreadPayload) -> InboxThreadSummary {
HutchWidgetExtension/HutchWidgetExtension.entitlements added +10
@@ -0,0 +1,10 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>com.apple.security.application-groups</key>
6 <array>
7 <string>group.net.cleberg.Hutch</string>
8 </array>
9</dict>
10</plist>
HutchWidgetExtension/Info.plist added +29
@@ -0,0 +1,29 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleDevelopmentRegion</key>
6 <string>$(DEVELOPMENT_LANGUAGE)</string>
7 <key>CFBundleDisplayName</key>
8 <string>Needs Attention</string>
9 <key>CFBundleExecutable</key>
10 <string>$(EXECUTABLE_NAME)</string>
11 <key>CFBundleIdentifier</key>
12 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 <key>CFBundleInfoDictionaryVersion</key>
14 <string>6.0</string>
15 <key>CFBundleName</key>
16 <string>$(PRODUCT_NAME)</string>
17 <key>CFBundlePackageType</key>
18 <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
19 <key>CFBundleShortVersionString</key>
20 <string>$(MARKETING_VERSION)</string>
21 <key>CFBundleVersion</key>
22 <string>$(CURRENT_PROJECT_VERSION)</string>
23 <key>NSExtension</key>
24 <dict>
25 <key>NSExtensionPointIdentifier</key>
26 <string>com.apple.widgetkit-extension</string>
27 </dict>
28</dict>
29</plist>
HutchWidgetExtension/NeedsAttentionWidget.swift added +226
@@ -0,0 +1,226 @@
1import SwiftUI
2import WidgetKit
3
4struct NeedsAttentionEntry: TimelineEntry {
5 let date: Date
6 let snapshot: NeedsAttentionSnapshot?
7}
8
9struct NeedsAttentionTimelineProvider: TimelineProvider {
10 func placeholder(in context: Context) -> NeedsAttentionEntry {
11 NeedsAttentionEntry(
12 date: .now,
13 snapshot: NeedsAttentionSnapshot(
14 unreadInboxThreads: 3,
15 assignedOpenTickets: 2,
16 failedBuilds: 1,
17 updatedAt: .now
18 )
19 )
20 }
21
22 func getSnapshot(in context: Context, completion: @escaping (NeedsAttentionEntry) -> Void) {
23 completion(
24 NeedsAttentionEntry(
25 date: .now,
26 snapshot: NeedsAttentionSnapshotStore.load()
27 )
28 )
29 }
30
31 func getTimeline(in context: Context, completion: @escaping (Timeline<NeedsAttentionEntry>) -> Void) {
32 let refreshDate = Calendar.current.date(byAdding: .minute, value: 20, to: .now) ?? .now.addingTimeInterval(1200)
33 let entry = NeedsAttentionEntry(date: .now, snapshot: NeedsAttentionSnapshotStore.load())
34 completion(Timeline(entries: [entry], policy: .after(refreshDate)))
35 }
36}
37
38struct NeedsAttentionWidget: Widget {
39 static let kind = NeedsAttentionWidgetConfiguration.kind
40
41 var body: some WidgetConfiguration {
42 StaticConfiguration(kind: Self.kind, provider: NeedsAttentionTimelineProvider()) { entry in
43 NeedsAttentionWidgetView(entry: entry)
44 }
45 .configurationDisplayName("Needs Attention")
46 .description("Unread inbox threads, assigned tickets, and failed builds.")
47 .supportedFamilies([.systemSmall, .systemMedium])
48 }
49}
50
51private struct NeedsAttentionWidgetView: View {
52 let entry: NeedsAttentionEntry
53 @Environment(\.widgetFamily) private var family
54
55 var body: some View {
56 Group {
57 if let snapshot = entry.snapshot, snapshot.hasAnyData {
58 if snapshot.allCountsAreZero {
59 clearState
60 } else {
61 switch family {
62 case .systemMedium:
63 mediumView(snapshot: snapshot)
64 default:
65 smallView(snapshot: snapshot)
66 }
67 }
68 } else {
69 fallbackState
70 }
71 }
72 .widgetURL(URL(string: "hutch://home"))
73 .containerBackground(for: .widget) {
74 Color(.systemBackground)
75 }
76 }
77
78 private var header: some View {
79 VStack(alignment: .leading, spacing: 1) {
80 Text("Hutch")
81 .font(.caption.weight(.semibold))
82 .foregroundStyle(.secondary)
83 Text("Needs Attention")
84 .font(.headline)
85 .lineLimit(1)
86 }
87 }
88
89 private func smallView(snapshot: NeedsAttentionSnapshot) -> some View {
90 VStack(alignment: .leading, spacing: 12) {
91 Spacer(minLength: 0)
92 VStack(alignment: .leading, spacing: 8) {
93 compactMetric(
94 count: snapshot.unreadInboxThreads,
95 label: "Unread",
96 tint: unreadTint(for: snapshot.unreadInboxThreads)
97 )
98 compactMetric(count: snapshot.assignedOpenTickets, label: "Assigned")
99 compactMetric(
100 count: snapshot.failedBuilds,
101 label: "Failed",
102 tint: failedTint(for: snapshot.failedBuilds)
103 )
104 }
105 Spacer(minLength: 0)
106 }
107 .padding()
108 }
109
110 private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
111 VStack(alignment: .leading, spacing: 18) {
112 header
113 .padding(.top, 6)
114 HStack(alignment: .top, spacing: 12) {
115 metricColumn(
116 count: snapshot.unreadInboxThreads,
117 label: "Unread",
118 systemImage: "tray",
119 tint: unreadTint(for: snapshot.unreadInboxThreads)
120 )
121 metricColumn(
122 count: snapshot.assignedOpenTickets,
123 label: "Assigned",
124 systemImage: "ticket"
125 )
126 metricColumn(
127 count: snapshot.failedBuilds,
128 label: "Failed",
129 systemImage: "hammer",
130 tint: failedTint(for: snapshot.failedBuilds)
131 )
132 }
133 Spacer(minLength: 0)
134 }
135 .padding(.horizontal, 16)
136 .padding(.top, 18)
137 .padding(.bottom, 16)
138 }
139
140 private var clearState: some View {
141 VStack(alignment: .leading, spacing: 12) {
142 header
143 Spacer(minLength: 0)
144 Text("All clear")
145 .font(.title3.weight(.semibold))
146 Text("No unread threads, assigned tickets, or failed builds.")
147 .font(.caption)
148 .foregroundStyle(.secondary)
149 .fixedSize(horizontal: false, vertical: true)
150 }
151 .padding()
152 }
153
154 private var fallbackState: some View {
155 VStack(alignment: .leading, spacing: 12) {
156 header
157 Spacer(minLength: 0)
158 Text("Open Hutch")
159 .font(.title3.weight(.semibold))
160 Text("Unable to load recent counts.")
161 .font(.caption)
162 .foregroundStyle(.secondary)
163 }
164 .padding()
165 }
166
167 private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View {
168 HStack(alignment: .center, spacing: 6) {
169 Text(displayCount(count))
170 .font(.title2.weight(.semibold))
171 .monospacedDigit()
172 .foregroundStyle(tint)
173 Text(label)
174 .font(.subheadline)
175 .foregroundStyle(.secondary)
176 Spacer(minLength: 0)
177 }
178 }
179
180 private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View {
181 VStack(alignment: .leading, spacing: 8) {
182 HStack(alignment: .center, spacing: 10) {
183 Image(systemName: systemImage)
184 .font(.title3.weight(.semibold))
185 .foregroundStyle(tint)
186 .frame(width: 24, alignment: .leading)
187 .padding(.trailing, 4)
188 Text(displayCount(count))
189 .font(.system(size: 28, weight: .semibold, design: .rounded))
190 .monospacedDigit()
191 .lineLimit(1)
192 .minimumScaleFactor(0.8)
193 .foregroundStyle(tint)
194 }
195 Text(label)
196 .font(.caption)
197 .foregroundStyle(.secondary)
198 .lineLimit(1)
199 .minimumScaleFactor(0.85)
200 .frame(height: 16, alignment: .topLeading)
201 }
202 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
203 }
204
205 private func displayCount(_ count: Int?) -> String {
206 guard let count else { return "—" }
207 return "\(count)"
208 }
209
210 private func unreadTint(for count: Int?) -> Color {
211 guard let count, count > 0 else { return .primary }
212 return .blue
213 }
214
215 private func failedTint(for count: Int?) -> Color {
216 guard let count else { return .primary }
217 return count > 0 ? .red : .green
218 }
219}
220
221@main
222struct HutchWidgets: WidgetBundle {
223 var body: some Widget {
224 NeedsAttentionWidget()
225 }
226}
Shared/NeedsAttentionSnapshot.swift added +109
@@ -0,0 +1,109 @@
1import Foundation
2#if canImport(WidgetKit)
3import WidgetKit
4#endif
5
6enum HutchAppGroup {
7 static let identifier = "group.net.cleberg.Hutch"
8}
9
10enum NeedsAttentionWidgetConfiguration {
11 static let kind = "NeedsAttentionWidget"
12}
13
14struct NeedsAttentionSnapshot: Codable, Sendable {
15 let unreadInboxThreads: Int?
16 let assignedOpenTickets: Int?
17 let failedBuilds: Int?
18 let updatedAt: Date
19
20 static let unavailable = NeedsAttentionSnapshot(
21 unreadInboxThreads: nil,
22 assignedOpenTickets: nil,
23 failedBuilds: nil,
24 updatedAt: .now
25 )
26
27 var hasAnyData: Bool {
28 unreadInboxThreads != nil || assignedOpenTickets != nil || failedBuilds != nil
29 }
30
31 var allCountsAreZero: Bool {
32 let counts = [unreadInboxThreads, assignedOpenTickets, failedBuilds].compactMap { $0 }
33 return !counts.isEmpty && counts.allSatisfy { $0 == 0 }
34 }
35}
36
37enum NeedsAttentionSnapshotStore {
38 private static let snapshotKey = "needsAttention.snapshot"
39
40 static func load(defaults: UserDefaults? = sharedDefaults()) -> NeedsAttentionSnapshot? {
41 guard let defaults,
42 let data = defaults.data(forKey: snapshotKey) else {
43 return nil
44 }
45
46 return try? JSONDecoder().decode(NeedsAttentionSnapshot.self, from: data)
47 }
48
49 static func save(_ snapshot: NeedsAttentionSnapshot, defaults: UserDefaults? = sharedDefaults()) {
50 guard let defaults,
51 let data = try? JSONEncoder().encode(snapshot) else {
52 return
53 }
54
55 defaults.set(data, forKey: snapshotKey)
56 reloadWidgetTimelines()
57 }
58
59 static func update(
60 unreadInboxThreads: Int? = nil,
61 assignedOpenTickets: Int? = nil,
62 failedBuilds: Int? = nil,
63 defaults: UserDefaults? = sharedDefaults()
64 ) {
65 let existing = load(defaults: defaults)
66 let snapshot = NeedsAttentionSnapshot(
67 unreadInboxThreads: unreadInboxThreads ?? existing?.unreadInboxThreads,
68 assignedOpenTickets: assignedOpenTickets ?? existing?.assignedOpenTickets,
69 failedBuilds: failedBuilds ?? existing?.failedBuilds,
70 updatedAt: .now
71 )
72 save(snapshot, defaults: defaults)
73 }
74
75 static func adjustUnreadInboxThreads(
76 by delta: Int,
77 defaults: UserDefaults? = sharedDefaults()
78 ) {
79 guard let existing = load(defaults: defaults),
80 let unreadInboxThreads = existing.unreadInboxThreads else {
81 return
82 }
83
84 save(
85 NeedsAttentionSnapshot(
86 unreadInboxThreads: max(0, unreadInboxThreads + delta),
87 assignedOpenTickets: existing.assignedOpenTickets,
88 failedBuilds: existing.failedBuilds,
89 updatedAt: .now
90 ),
91 defaults: defaults
92 )
93 }
94
95 static func clear(defaults: UserDefaults? = sharedDefaults()) {
96 defaults?.removeObject(forKey: snapshotKey)
97 reloadWidgetTimelines()
98 }
99
100 private static func sharedDefaults() -> UserDefaults? {
101 UserDefaults(suiteName: HutchAppGroup.identifier)
102 }
103
104 private static func reloadWidgetTimelines() {
105 #if canImport(WidgetKit)
106 WidgetCenter.shared.reloadTimelines(ofKind: NeedsAttentionWidgetConfiguration.kind)
107 #endif
108 }
109}