krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

fe1ccc69661603d419a642a450e4ac9e1258adb0

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-14T00:44:44Z

fix: centralize URLs, tighten models, and polish SwiftUI bindings

- Add shared HutchDeepLinkURL constants for app, widgets, and tests.
- Bump marketing version to 3.1.2 and build to 70 (app + widget extension).
- Point Hutch stats default base URL at HutchStatsAPI; add SRHTWebURL status
  incident feed and reuse it from SystemStatusService.
- Group Project into Metadata and Resources; split ContributionStatsResponse
  into StatsWindow and StatsTotals with updated decoding and tests.
- Replace @Bindable usage with explicit Bindings in Profile, Projects list,
  and repository ACL flows; simplify Home pinned-item helper; add no-op
  bodies on cancel alert buttons where the compiler requires a statement.
- Move repository row build-status indicator next to the relative-updated
  caption and reserve a fixed 8×8 slot so the row does not jump when status
  loads.
- Use NSString.lastPathComponent for build artifact filenames; collapse
  duplicate ACL error branches; minor HutchStats HTTP and XMLParserDelegate
  cleanups.
- Point widgets at HutchDeepLinkURL helpers; align tests with the new
  response and URL types.
 Hutch.xcodeproj/project.pbxproj                    |  16 +--
 Hutch/App/AppConfiguration.swift                   |   2 +-
 Hutch/App/HutchIntents.swift                       |   4 +-
 Hutch/Extensions/SRHTWebURL.swift                  |   2 +
 Hutch/Models/Builds.swift                          |   3 +-
 Hutch/Models/ContributionCalendar.swift            |  92 ++++++++------
 Hutch/Models/Project.swift                         |  54 +++++----
 Hutch/Networking/HutchStatsService.swift           |   7 +-
 Hutch/Networking/ProjectService.swift              |  47 +++----
 Hutch/Networking/SystemStatusService.swift         |  14 +--
 Hutch/Views/Home/HomeView.swift                    |   4 +-
 Hutch/Views/More/AccountSwitcherView.swift         |   4 +-
 Hutch/Views/More/ProfileView.swift                 |  47 ++++---
 Hutch/Views/Projects/ProjectsListView.swift        |  14 ++-
 .../HgRepositorySettingsViewModel.swift            |   6 +-
 Hutch/Views/Repositories/RepositoryACLView.swift   |  13 +-
 Hutch/Views/Repositories/RepositoryRowView.swift   |  19 ++-
 Hutch/Views/Settings/SettingsView.swift            |   4 +-
 HutchTests/ContributionCalendarTests.swift         | 108 ++++++++++-------
 HutchTests/DeepLinkTests.swift                     |  26 ++--
 HutchTests/ProjectPinStoreTests.swift              |   4 +-
 HutchTests/ProjectTests.swift                      | 135 +++++++++++----------
 HutchTests/SRHTWebURLTests.swift                   |  26 ++--
 HutchTests/SystemStatusServiceTests.swift          |   5 +-
 HutchWidgetExtension/ContributionGraphWidget.swift |   4 +-
 HutchWidgetExtension/NeedsAttentionWidget.swift    |  14 +--
 HutchWidgetExtension/SystemStatusWidget.swift      |   7 +-
 Shared/HutchDeepLinkURLs.swift                     |  23 ++++
 28 files changed, 414 insertions(+), 290 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 8f98e1b..bbdb9e3 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 69;
+				CURRENT_PROJECT_VERSION = 71;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.1.1;
+				MARKETING_VERSION = 3.1.3;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 69;
+				CURRENT_PROJECT_VERSION = 71;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 3.1.1;
+				MARKETING_VERSION = 3.1.3;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 69;
+				CURRENT_PROJECT_VERSION = 71;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.1.1;
+				MARKETING_VERSION = 3.1.3;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 69;
+				CURRENT_PROJECT_VERSION = 71;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 3.1.1;
+				MARKETING_VERSION = 3.1.3;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/App/AppConfiguration.swift b/Hutch/App/AppConfiguration.swift
index 7824371..c733799 100644
--- a/Hutch/App/AppConfiguration.swift
+++ b/Hutch/App/AppConfiguration.swift
@@ -1,7 +1,7 @@
 import Foundation
 
 struct AppConfiguration: Sendable {
-    static let defaultHutchStatsBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
+    static let defaultHutchStatsBaseURL = HutchStatsAPI.defaultBaseURL
     static let hutchStatsBaseURLEnvironmentKey = "HUTCH_STATS_BASE_URL"
 
     let hutchStatsBaseURL: URL
diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift
index a7a33b3..5751ef5 100644
--- a/Hutch/App/HutchIntents.swift
+++ b/Hutch/App/HutchIntents.swift
@@ -150,5 +150,7 @@ final class HutchIntentNavigator {
     static let shared = HutchIntentNavigator()
     var pendingDestination: HutchDestination?
 
-    private init() {}
+    private init() {
+        /* Singleton; external code uses `shared`. */
+    }
 }
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index bfed48b..fc5a1d5 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -3,6 +3,8 @@ import Foundation
 enum SRHTWebURL {
     static let chat = URL(string: "https://chat.sr.ht")!
     static let status = URL(string: "https://status.sr.ht")!
+    /// RSS/Atom feed for status incidents (HTML snapshot uses `status`).
+    static let statusIncidentFeed = URL(string: "https://status.sr.ht/index.xml")!
 
     static func repository(_ repository: RepositorySummary) -> URL? {
         userScopedURL(
diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift
index d666398..3926888 100644
--- a/Hutch/Models/Builds.swift
+++ b/Hutch/Models/Builds.swift
@@ -136,8 +136,7 @@ struct BuildArtifact: Codable, Sendable, Identifiable, Equatable {
     let url: URL?
 
     var filename: String {
-        let pathComponents = path.split(separator: "/")
-        return pathComponents.last.map(String.init) ?? path
+        (path as NSString).lastPathComponent
     }
 
     var isDownloadable: Bool {
diff --git a/Hutch/Models/ContributionCalendar.swift b/Hutch/Models/ContributionCalendar.swift
index e2942dd..a429fa2 100644
--- a/Hutch/Models/ContributionCalendar.swift
+++ b/Hutch/Models/ContributionCalendar.swift
@@ -117,45 +117,67 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable {
         case currentStreak = "current_streak"
     }
 
-    init(
-        actor: String,
-        from: Date,
-        to: Date,
-        isIndexed: Bool,
-        lastPolledAt: Date?,
-        indexingState: ContributionIndexingState,
-        totalEvents: Int,
-        totalScore: Double,
-        activeDays: Int,
-        longestStreak: Int,
-        currentStreak: Int
-    ) {
-        self.actor = actor
-        self.from = from
-        self.to = to
-        self.isIndexed = isIndexed
-        self.lastPolledAt = lastPolledAt
-        self.indexingState = indexingState
-        self.totalEvents = totalEvents
-        self.totalScore = totalScore
-        self.activeDays = activeDays
-        self.longestStreak = longestStreak
-        self.currentStreak = currentStreak
+    struct StatsWindow: Sendable, Hashable {
+        let actor: String
+        let from: Date
+        let to: Date
+        let isIndexed: Bool
+        let lastPolledAt: Date?
+        let indexingState: ContributionIndexingState
+    }
+
+    struct StatsTotals: Sendable, Hashable {
+        let totalEvents: Int
+        let totalScore: Double
+        let activeDays: Int
+        let longestStreak: Int
+        let currentStreak: Int
+    }
+
+    init(window: StatsWindow, totals: StatsTotals) {
+        actor = window.actor
+        from = window.from
+        to = window.to
+        isIndexed = window.isIndexed
+        lastPolledAt = window.lastPolledAt
+        indexingState = window.indexingState
+        totalEvents = totals.totalEvents
+        totalScore = totals.totalScore
+        activeDays = totals.activeDays
+        longestStreak = totals.longestStreak
+        currentStreak = totals.currentStreak
     }
 
     init(from decoder: any Decoder) throws {
         let container = try decoder.container(keyedBy: CodingKeys.self)
-        actor = try container.decode(String.self, forKey: .actor)
-        from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
-        to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
-        isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
-        lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
-        indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
-        totalEvents = try container.decode(Int.self, forKey: .totalEvents)
-        totalScore = try container.decode(Double.self, forKey: .totalScore)
-        activeDays = try container.decode(Int.self, forKey: .activeDays)
-        longestStreak = try container.decode(Int.self, forKey: .longestStreak)
-        currentStreak = try container.decode(Int.self, forKey: .currentStreak)
+        let actor = try container.decode(String.self, forKey: .actor)
+        let from = try ContributionDateParser.decodeDateString(from: container, forKey: .from)
+        let to = try ContributionDateParser.decodeDateString(from: container, forKey: .to)
+        let isIndexed = try container.decodeIfPresent(Bool.self, forKey: .isIndexed) ?? false
+        let lastPolledAt = try ContributionDateParser.decodeOptionalTimestamp(from: container, forKey: .lastPolledAt)
+        let indexingState = try container.decodeIfPresent(ContributionIndexingState.self, forKey: .indexingState) ?? .indexed
+        let totalEvents = try container.decode(Int.self, forKey: .totalEvents)
+        let totalScore = try container.decode(Double.self, forKey: .totalScore)
+        let activeDays = try container.decode(Int.self, forKey: .activeDays)
+        let longestStreak = try container.decode(Int.self, forKey: .longestStreak)
+        let currentStreak = try container.decode(Int.self, forKey: .currentStreak)
+        self.init(
+            window: .init(
+                actor: actor,
+                from: from,
+                to: to,
+                isIndexed: isIndexed,
+                lastPolledAt: lastPolledAt,
+                indexingState: indexingState
+            ),
+            totals: .init(
+                totalEvents: totalEvents,
+                totalScore: totalScore,
+                activeDays: activeDays,
+                longestStreak: longestStreak,
+                currentStreak: currentStreak
+            )
+        )
     }
 }
 
diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift
index 678d52e..bbca7d6 100644
--- a/Hutch/Models/Project.swift
+++ b/Hutch/Models/Project.swift
@@ -71,30 +71,36 @@ struct Project: Identifiable, Hashable, Sendable {
     let trackers: [Tracker]
     let isFullyLoaded: Bool
 
-    init(
-        id: String,
-        name: String,
-        description: String?,
-        website: String?,
-        visibility: Visibility,
-        tags: [String],
-        updated: Date,
-        mailingLists: [MailingList],
-        sources: [SourceRepo],
-        trackers: [Tracker],
-        isFullyLoaded: Bool = true
-    ) {
-        self.id = id
-        self.name = name
-        self.description = description
-        self.website = website
-        self.visibility = visibility
-        self.tags = tags
-        self.updated = updated
-        self.mailingLists = mailingLists
-        self.sources = sources
-        self.trackers = trackers
-        self.isFullyLoaded = isFullyLoaded
+    /// Identity and display fields for a project.
+    struct Metadata: Sendable, Hashable {
+        let id: String
+        let name: String
+        let description: String?
+        let website: String?
+        let visibility: Visibility
+        let tags: [String]
+        let updated: Date
+    }
+
+    struct Resources: Sendable, Hashable {
+        let mailingLists: [MailingList]
+        let sources: [SourceRepo]
+        let trackers: [Tracker]
+        let isFullyLoaded: Bool
+    }
+
+    init(metadata: Metadata, resources: Resources) {
+        id = metadata.id
+        name = metadata.name
+        description = metadata.description
+        website = metadata.website
+        visibility = metadata.visibility
+        tags = metadata.tags
+        updated = metadata.updated
+        mailingLists = resources.mailingLists
+        sources = resources.sources
+        trackers = resources.trackers
+        isFullyLoaded = resources.isFullyLoaded
     }
 
     var resourceSummary: String? {
diff --git a/Hutch/Networking/HutchStatsService.swift b/Hutch/Networking/HutchStatsService.swift
index 52d86b9..aead9c6 100644
--- a/Hutch/Networking/HutchStatsService.swift
+++ b/Hutch/Networking/HutchStatsService.swift
@@ -61,10 +61,9 @@ struct HutchStatsService: ContributionCalendarServing {
             throw SRHTError.networkError(error)
         }
 
-        if let httpResponse = response as? HTTPURLResponse {
-            if !(200...299).contains(httpResponse.statusCode) {
-                throw SRHTError.httpError(httpResponse.statusCode)
-            }
+        if let httpResponse = response as? HTTPURLResponse,
+           !(200...299).contains(httpResponse.statusCode) {
+            throw SRHTError.httpError(httpResponse.statusCode)
         }
 
         do {
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
index 93ce71f..bd8ccdc 100644
--- a/Hutch/Networking/ProjectService.swift
+++ b/Hutch/Networking/ProjectService.swift
@@ -372,17 +372,21 @@ struct ProjectService: Sendable {
 
             if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
                 return Project(
-                    id: project.rid,
-                    name: project.name,
-                    description: project.description,
-                    website: project.website,
-                    visibility: project.visibility,
-                    tags: project.tags,
-                    updated: project.updated,
-                    mailingLists: deduplicate(mailingLists),
-                    sources: deduplicate(sources),
-                    trackers: deduplicate(trackers),
-                    isFullyLoaded: true
+                    metadata: .init(
+                        id: project.rid,
+                        name: project.name,
+                        description: project.description,
+                        website: project.website,
+                        visibility: project.visibility,
+                        tags: project.tags,
+                        updated: project.updated
+                    ),
+                    resources: .init(
+                        mailingLists: deduplicate(mailingLists),
+                        sources: deduplicate(sources),
+                        trackers: deduplicate(trackers),
+                        isFullyLoaded: true
+                    )
                 )
             }
         }
@@ -390,17 +394,16 @@ struct ProjectService: Sendable {
 
     private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
         Project(
-            id: summary.rid,
-            name: summary.name,
-            description: summary.description,
-            website: summary.website,
-            visibility: summary.visibility,
-            tags: summary.tags,
-            updated: summary.updated,
-            mailingLists: [],
-            sources: [],
-            trackers: [],
-            isFullyLoaded: false
+            metadata: .init(
+                id: summary.rid,
+                name: summary.name,
+                description: summary.description,
+                website: summary.website,
+                visibility: summary.visibility,
+                tags: summary.tags,
+                updated: summary.updated
+            ),
+            resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
         )
     }
 
diff --git a/Hutch/Networking/SystemStatusService.swift b/Hutch/Networking/SystemStatusService.swift
index 5dd27b5..ec2d233 100644
--- a/Hutch/Networking/SystemStatusService.swift
+++ b/Hutch/Networking/SystemStatusService.swift
@@ -1,8 +1,8 @@
 import Foundation
 
 struct SystemStatusService: Sendable {
-    nonisolated static let statusURL = URL(string: "https://status.sr.ht/")!
-    nonisolated static let feedURL = URL(string: "https://status.sr.ht/index.xml")!
+    nonisolated static let statusURL = SRHTWebURL.status
+    nonisolated static let feedURL = SRHTWebURL.statusIncidentFeed
 
     private let session: URLSession
     private let now: @Sendable () -> Date
@@ -340,24 +340,24 @@ private final class SystemStatusFeedParser: NSObject, XMLParserDelegate, @unchec
         return incidents.sorted { $0.publishedAt > $1.publishedAt }
     }
 
-    func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
+    func parser(_: XMLParser, didStartElement elementName: String, namespaceURI _: String?, qualifiedName _: String?, attributes _: [String: String] = [:]) {
         textBuffer = ""
         if elementName == "item" {
             currentItem = FeedItem()
         }
     }
 
-    func parser(_ parser: XMLParser, foundCharacters string: String) {
+    func parser(_: XMLParser, foundCharacters string: String) {
         textBuffer += string
     }
 
-    func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
-        if let string = String(data: CDATABlock, encoding: .utf8) {
+    func parser(_: XMLParser, foundCDATA cdata: Data) {
+        if let string = String(data: cdata, encoding: .utf8) {
             textBuffer += string
         }
     }
 
-    func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
+    func parser(_: XMLParser, didEndElement elementName: String, namespaceURI _: String?, qualifiedName _: String?) {
         guard var currentItem else {
             textBuffer = ""
             return
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index b10926a..b4b809d 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -171,7 +171,7 @@ struct HomeView: View {
                 ) {
                     ForEach(items) { item in
                         Button {
-                            openPinnedItem(item, viewModel: viewModel)
+                            openPinnedItem(item)
                         } label: {
                             HomePinnedCard(item: item)
                         }
@@ -313,7 +313,7 @@ struct HomeView: View {
         }
     }
 
-    private func openPinnedItem(_ item: HomePinnedItem, viewModel: HomeViewModel) {
+    private func openPinnedItem(_ item: HomePinnedItem) {
         switch item.pin.kind {
         case .project:
             guard let project = item.project else { return }
diff --git a/Hutch/Views/More/AccountSwitcherView.swift b/Hutch/Views/More/AccountSwitcherView.swift
index ce69279..83871a8 100644
--- a/Hutch/Views/More/AccountSwitcherView.swift
+++ b/Hutch/Views/More/AccountSwitcherView.swift
@@ -98,7 +98,9 @@ struct AccountSwitcherView: View {
                     }
                 )
             ) {
-                Button("Cancel", role: .cancel) {}
+                Button("Cancel", role: .cancel) {
+                    /* Dismiss only; removal uses the destructive button. */
+                }
                 Button("Remove", role: .destructive) {
                     guard let pendingRemoval else { return }
                     Task { await appState.removeAccount(id: pendingRemoval.id) }
diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift
index c53a89d..8f353ee 100644
--- a/Hutch/Views/More/ProfileView.swift
+++ b/Hutch/Views/More/ProfileView.swift
@@ -61,8 +61,6 @@ struct ProfileView: View {
 
     @ViewBuilder
     private func profileContent(_ viewModel: SettingsViewModel) -> some View {
-        @Bindable var vm = viewModel
-
         Form {
             if let profile = viewModel.profile {
                 profileSection(profile, viewModel: viewModel)
@@ -100,7 +98,10 @@ struct ProfileView: View {
                 )
             }
         }
-        .sheet(isPresented: $vm.isEditingProfile) {
+        .sheet(isPresented: Binding(
+            get: { viewModel.isEditingProfile },
+            set: { viewModel.isEditingProfile = $0 }
+        )) {
             if let profile = viewModel.profile {
                 EditProfileSheet(profile: profile, viewModel: viewModel)
             }
@@ -130,7 +131,9 @@ struct ProfileView: View {
                 }
             )
         ) {
-            Button("Cancel", role: .cancel) {}
+            Button("Cancel", role: .cancel) {
+                /* Dismiss only; destructive action is separate. */
+            }
             Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
                 guard let action = pendingDestructiveAction else { return }
                 pendingDestructiveAction = nil
@@ -158,12 +161,11 @@ struct ProfileView: View {
         Section("Profile") {
             HStack(spacing: 12) {
                 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
-                    switch phase {
-                    case .success(let image):
+                    if case .success(let image) = phase {
                         image
                             .resizable()
                             .scaledToFill()
-                    default:
+                    } else {
                         Image(systemName: "person.crop.circle.fill")
                             .resizable()
                             .foregroundStyle(.secondary)
@@ -238,8 +240,6 @@ struct ProfileView: View {
 
     @ViewBuilder
     private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
-        @Bindable var vm = viewModel
-
         Section {
             ForEach(viewModel.sshKeys) { key in
                 VStack(alignment: .leading, spacing: 2) {
@@ -275,7 +275,14 @@ struct ProfileView: View {
             .themedRow()
 
             if viewModel.isAddingSSHKey {
-                TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
+                TextField(
+                    "Paste SSH public key",
+                    text: Binding(
+                        get: { viewModel.newSSHKey },
+                        set: { viewModel.newSSHKey = $0 }
+                    ),
+                    axis: .vertical
+                )
                     .font(.caption.monospaced())
                     .lineLimit(3...6)
                     .themedRow()
@@ -310,8 +317,6 @@ struct ProfileView: View {
 
     @ViewBuilder
     private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
-        @Bindable var vm = viewModel
-
         Section {
             ForEach(viewModel.pgpKeys) { key in
                 VStack(alignment: .leading, spacing: 2) {
@@ -333,7 +338,14 @@ struct ProfileView: View {
             .themedRow()
 
             if viewModel.isAddingPGPKey {
-                TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
+                TextField(
+                    "Paste PGP public key",
+                    text: Binding(
+                        get: { viewModel.newPGPKey },
+                        set: { viewModel.newPGPKey = $0 }
+                    ),
+                    axis: .vertical
+                )
                     .font(.caption.monospaced())
                     .lineLimit(3...6)
                     .themedRow()
@@ -482,12 +494,11 @@ private struct EditProfileSheet: View {
                                             .scaledToFill()
                                     } else {
                                         AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
-                                            switch phase {
-                                            case .success(let image):
+                                            if case .success(let image) = phase {
                                                 image
                                                     .resizable()
                                                     .scaledToFill()
-                                            default:
+                                            } else {
                                                 Image(systemName: "person.crop.circle.fill")
                                                     .resizable()
                                                     .foregroundStyle(.secondary)
@@ -592,7 +603,9 @@ private struct EditProfileSheet: View {
                 }
             }
             .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
-                Button("Cancel", role: .cancel) {}
+                Button("Cancel", role: .cancel) {
+                    /* Dismiss only; removal uses the destructive button. */
+                }
                 Button("Remove Avatar", role: .destructive) {
                     Task {
                         await viewModel.removeAvatar()
diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift
index 12a5149..f6d5766 100644
--- a/Hutch/Views/Projects/ProjectsListView.swift
+++ b/Hutch/Views/Projects/ProjectsListView.swift
@@ -67,8 +67,6 @@ struct ProjectsListView: View {
 
     @ViewBuilder
     private func content(_ viewModel: ProjectsListViewModel) -> some View {
-        @Bindable var vm = viewModel
-
         List {
             ForEach(viewModel.filteredProjects) { project in
                 NavigationLink {
@@ -84,7 +82,10 @@ struct ProjectsListView: View {
         .themedList()
         .listStyle(.plain)
         .searchable(
-            text: $vm.searchText,
+            text: Binding(
+                get: { viewModel.searchText },
+                set: { viewModel.searchText = $0 }
+            ),
             placement: .navigationBarDrawer(displayMode: .always),
             prompt: "Search projects"
         )
@@ -107,7 +108,12 @@ struct ProjectsListView: View {
                 )
             }
         }
-        .srhtErrorBanner(error: $vm.error)
+        .srhtErrorBanner(
+            error: Binding(
+                get: { viewModel.error },
+                set: { viewModel.error = $0 }
+            )
+        )
         .refreshable {
             await viewModel.loadProjects()
         }
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
index 7b8d7f7..6df40db 100644
--- a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
@@ -261,11 +261,7 @@ final class HgRepositorySettingsViewModel {
             }
             newACLEntity = ""
         } catch {
-            if error.matchesGraphQLErrorClassification(.serviceNotProvisioned) {
-                self.error = error.userFacingMessage
-            } else {
-                self.error = error.userFacingMessage
-            }
+            self.error = error.userFacingMessage
         }
     }
 
diff --git a/Hutch/Views/Repositories/RepositoryACLView.swift b/Hutch/Views/Repositories/RepositoryACLView.swift
index 29ded84..ede770c 100644
--- a/Hutch/Views/Repositories/RepositoryACLView.swift
+++ b/Hutch/Views/Repositories/RepositoryACLView.swift
@@ -51,8 +51,6 @@ struct RepositoryACLView: View {
 
     @ViewBuilder
     private func content(_ viewModel: RepositoryACLViewModel) -> some View {
-        @Bindable var vm = viewModel
-
         Group {
             if viewModel.isLoading && !viewModel.hasEntries && viewModel.loadError == nil {
                 SRHTLoadingStateView(message: "Loading access…")
@@ -98,7 +96,12 @@ struct RepositoryACLView: View {
                 }
             }
         }
-        .srhtErrorBanner(error: $vm.error)
+        .srhtErrorBanner(
+            error: Binding(
+                get: { viewModel.error },
+                set: { viewModel.error = $0 }
+            )
+        )
         .alert("Remove Access?", isPresented: Binding(
             get: { pendingDeletion != nil },
             set: { isPresented in
@@ -107,7 +110,9 @@ struct RepositoryACLView: View {
                 }
             }
         )) {
-            Button("Cancel", role: .cancel) {}
+            Button("Cancel", role: .cancel) {
+                /* Dismiss only; removal is confirmed separately. */
+            }
             Button("Remove Access", role: .destructive) {
                 guard let entry = pendingDeletion else { return }
                 Task {
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
index afbd740..a2d120b 100644
--- a/Hutch/Views/Repositories/RepositoryRowView.swift
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -17,9 +17,6 @@ struct RepositoryRowView: View {
 
                 RepositoryForgeBadge(service: repository.service)
 
-                if buildStatus != .none {
-                    RepositoryBuildStatusIndicator(status: buildStatus)
-                }
                 VisibilityBadge(visibility: repository.visibility)
             }
 
@@ -44,9 +41,19 @@ struct RepositoryRowView: View {
 
                 Spacer()
 
-                Text(repository.updated.relativeDescription)
-                    .font(.caption)
-                    .foregroundStyle(.tertiary)
+                HStack(spacing: 6) {
+                    ZStack {
+                        if buildStatus != .none {
+                            RepositoryBuildStatusIndicator(status: buildStatus)
+                        }
+                    }
+                    .frame(width: 8, height: 8)
+                    .accessibilityHidden(buildStatus == .none)
+
+                    Text(repository.updated.relativeDescription)
+                        .font(.caption)
+                        .foregroundStyle(.tertiary)
+                }
             }
         }
         .padding(.vertical, 2)
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 4ed9216..577de8a 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -32,7 +32,9 @@ struct SettingsView: View {
                 }
             )
         ) {
-            Button("Cancel", role: .cancel) {}
+            Button("Cancel", role: .cancel) {
+                /* Dismiss only; destructive action is separate. */
+            }
             Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
                 guard let action = pendingDestructiveAction else { return }
                 pendingDestructiveAction = nil
diff --git a/HutchTests/ContributionCalendarTests.swift b/HutchTests/ContributionCalendarTests.swift
index 9e76e42..0328ad5 100644
--- a/HutchTests/ContributionCalendarTests.swift
+++ b/HutchTests/ContributionCalendarTests.swift
@@ -259,12 +259,12 @@ private final class MockContributionCalendarService: ContributionCalendarServing
         self.statsResponses = statsResponses
     }
 
-    func fetchContributionCalendar(actor: String, endingOn endDate: Date) async throws -> ContributionCalendarResponse {
+    func fetchContributionCalendar(actor _: String, endingOn _: Date) async throws -> ContributionCalendarResponse {
         fetchCalendarCallCount += 1
         return calendarResponses[min(fetchCalendarCallCount - 1, calendarResponses.count - 1)]
     }
 
-    func fetchContributionStats(actor: String, endingOn endDate: Date) async throws -> ContributionStatsResponse {
+    func fetchContributionStats(actor _: String, endingOn _: Date) async throws -> ContributionStatsResponse {
         fetchStatsCallCount += 1
         return statsResponses[min(fetchStatsCallCount - 1, statsResponses.count - 1)]
     }
@@ -348,65 +348,81 @@ private extension ContributionCalendarResponse {
 private extension ContributionStatsResponse {
     static func empty(actor: String, year: Int) -> Self {
         ContributionStatsResponse(
-            actor: actor,
-            from: ContributionDateParser.parse("\(year)-01-01")!,
-            to: ContributionDateParser.parse("\(year)-01-07")!,
-            isIndexed: true,
-            lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
-            indexingState: .indexed,
-            totalEvents: 0,
-            totalScore: 0,
-            activeDays: 0,
-            longestStreak: 0,
-            currentStreak: 0
+            window: .init(
+                actor: actor,
+                from: ContributionDateParser.parse("\(year)-01-01")!,
+                to: ContributionDateParser.parse("\(year)-01-07")!,
+                isIndexed: true,
+                lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
+                indexingState: .indexed
+            ),
+            totals: .init(
+                totalEvents: 0,
+                totalScore: 0,
+                activeDays: 0,
+                longestStreak: 0,
+                currentStreak: 0
+            )
         )
     }
 
     static func active(actor: String, year: Int, totalEvents: Int, activeDays: Int, longestStreak: Int) -> Self {
         ContributionStatsResponse(
-            actor: actor,
-            from: ContributionDateParser.parse("\(year)-01-01")!,
-            to: ContributionDateParser.parse("\(year)-01-07")!,
-            isIndexed: true,
-            lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
-            indexingState: .indexed,
-            totalEvents: totalEvents,
-            totalScore: Double(totalEvents),
-            activeDays: activeDays,
-            longestStreak: longestStreak,
-            currentStreak: 0
+            window: .init(
+                actor: actor,
+                from: ContributionDateParser.parse("\(year)-01-01")!,
+                to: ContributionDateParser.parse("\(year)-01-07")!,
+                isIndexed: true,
+                lastPolledAt: ContributionDateParser.parseTimestamp("\(year)-01-07T12:00:00Z"),
+                indexingState: .indexed
+            ),
+            totals: .init(
+                totalEvents: totalEvents,
+                totalScore: Double(totalEvents),
+                activeDays: activeDays,
+                longestStreak: longestStreak,
+                currentStreak: 0
+            )
         )
     }
 
     static func pending(actor: String, year: Int) -> Self {
         ContributionStatsResponse(
-            actor: actor,
-            from: ContributionDateParser.parse("\(year)-01-01")!,
-            to: ContributionDateParser.parse("\(year)-01-07")!,
-            isIndexed: false,
-            lastPolledAt: nil,
-            indexingState: .pending,
-            totalEvents: 0,
-            totalScore: 0,
-            activeDays: 0,
-            longestStreak: 0,
-            currentStreak: 0
+            window: .init(
+                actor: actor,
+                from: ContributionDateParser.parse("\(year)-01-01")!,
+                to: ContributionDateParser.parse("\(year)-01-07")!,
+                isIndexed: false,
+                lastPolledAt: nil,
+                indexingState: .pending
+            ),
+            totals: .init(
+                totalEvents: 0,
+                totalScore: 0,
+                activeDays: 0,
+                longestStreak: 0,
+                currentStreak: 0
+            )
         )
     }
 
     static func error(actor: String, year: Int) -> Self {
         ContributionStatsResponse(
-            actor: actor,
-            from: ContributionDateParser.parse("\(year)-01-01")!,
-            to: ContributionDateParser.parse("\(year)-01-07")!,
-            isIndexed: false,
-            lastPolledAt: nil,
-            indexingState: .error,
-            totalEvents: 0,
-            totalScore: 0,
-            activeDays: 0,
-            longestStreak: 0,
-            currentStreak: 0
+            window: .init(
+                actor: actor,
+                from: ContributionDateParser.parse("\(year)-01-01")!,
+                to: ContributionDateParser.parse("\(year)-01-07")!,
+                isIndexed: false,
+                lastPolledAt: nil,
+                indexingState: .error
+            ),
+            totals: .init(
+                totalEvents: 0,
+                totalScore: 0,
+                activeDays: 0,
+                longestStreak: 0,
+                currentStreak: 0
+            )
         )
     }
 }
diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift
index 12f759b..c57cfce 100644
--- a/HutchTests/DeepLinkTests.swift
+++ b/HutchTests/DeepLinkTests.swift
@@ -6,61 +6,61 @@ struct DeepLinkTests {
 
     @Test
     func parsesHomeLink() {
-        let link = DeepLink(url: URL(string: "hutch://home")!)
+        let link = DeepLink(url: HutchDeepLinkURL.home)
         #expect(link == .home)
     }
 
     @Test
     func parsesNilPathAsHome() {
-        let link = DeepLink(url: URL(string: "hutch://")!)
+        let link = DeepLink(url: HutchDeepLinkURL.emptyHost)
         #expect(link == .home)
     }
 
     @Test
     func parsesRepositoryLink() {
-        let link = DeepLink(url: URL(string: "hutch://git/~user/repo")!)
+        let link = DeepLink(url: HutchDeepLinkURL.repositoryGit)
         #expect(link == .repository(owner: "~user", repo: "repo"))
     }
 
     @Test
     func parsesTicketLink() {
-        let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/42")!)
+        let link = DeepLink(url: HutchDeepLinkURL.ticket)
         #expect(link == .ticket(owner: "~owner", tracker: "tracker", ticketId: 42))
     }
 
     @Test
     func parsesBuildJobLink() {
-        let link = DeepLink(url: URL(string: "hutch://builds/12345")!)
+        let link = DeepLink(url: HutchDeepLinkURL.buildJob)
         #expect(link == .build(jobId: 12345))
     }
 
     @Test
     func parsesBuildsTabLink() {
-        let link = DeepLink(url: URL(string: "hutch://builds")!)
+        let link = DeepLink(url: HutchDeepLinkURL.builds)
         #expect(link == .buildsTab)
     }
 
     @Test
     func parsesRepositoriesTabLink() {
-        let link = DeepLink(url: URL(string: "hutch://repositories")!)
+        let link = DeepLink(url: HutchDeepLinkURL.repositories)
         #expect(link == .repositoriesTab)
     }
 
     @Test
     func parsesTrackersTabLink() {
-        let link = DeepLink(url: URL(string: "hutch://trackers")!)
+        let link = DeepLink(url: HutchDeepLinkURL.trackers)
         #expect(link == .trackersTab)
     }
 
     @Test
     func parsesSystemStatusLink() {
-        let link = DeepLink(url: URL(string: "hutch://status")!)
+        let link = DeepLink(url: HutchDeepLinkURL.status)
         #expect(link == .systemStatus)
     }
 
     @Test
     func parsesLookupLink() {
-        let link = DeepLink(url: URL(string: "hutch://lookup")!)
+        let link = DeepLink(url: HutchDeepLinkURL.lookup)
         #expect(link == .lookup)
     }
 
@@ -72,19 +72,19 @@ struct DeepLinkTests {
 
     @Test
     func rejectsUnknownPath() {
-        let link = DeepLink(url: URL(string: "hutch://unknown")!)
+        let link = DeepLink(url: HutchDeepLinkURL.unknown)
         #expect(link == nil)
     }
 
     @Test
     func rejectsTicketLinkWithNonNumericId() {
-        let link = DeepLink(url: URL(string: "hutch://todo/~owner/tracker/abc")!)
+        let link = DeepLink(url: HutchDeepLinkURL.invalidTicketId)
         #expect(link == nil)
     }
 
     @Test
     func rejectsBuildLinkWithNonNumericId() {
-        let link = DeepLink(url: URL(string: "hutch://builds/abc")!)
+        let link = DeepLink(url: HutchDeepLinkURL.invalidBuildId)
         #expect(link == nil)
     }
 }
diff --git a/HutchTests/ProjectPinStoreTests.swift b/HutchTests/ProjectPinStoreTests.swift
index 17b4397..13df539 100644
--- a/HutchTests/ProjectPinStoreTests.swift
+++ b/HutchTests/ProjectPinStoreTests.swift
@@ -27,11 +27,11 @@ struct ProjectPinStoreTests {
     }
 
     @Test
-    func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() {
+    func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() throws {
         let defaults = UserDefaults(suiteName: #function)!
         defaults.removePersistentDomain(forName: #function)
 
-        let encoded = try! JSONEncoder().encode([
+        let encoded = try JSONEncoder().encode([
             "~alice": [" project-1 ", "", "project-1", "project-2"]
         ])
         defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects)
diff --git a/HutchTests/ProjectTests.swift b/HutchTests/ProjectTests.swift
index eff800f..0323f25 100644
--- a/HutchTests/ProjectTests.swift
+++ b/HutchTests/ProjectTests.swift
@@ -3,52 +3,61 @@ import Testing
 @testable import Hutch
 
 struct ProjectTests {
+    private enum Fixture {
+        static let exampleWebsite = "https://example.com"
+    }
+
     @Test
     func resourceSummaryIncludesCounts() {
         let project = Project(
-            id: "project-1",
-            name: "Hutch",
-            description: nil,
-            website: nil,
-            visibility: .public,
-            tags: [],
-            updated: Date(timeIntervalSince1970: 0),
-            mailingLists: [
-                Project.MailingList(
-                    id: "list-1",
-                    name: "hutch-devel",
-                    description: nil,
-                    visibility: .public,
-                    owner: Entity(canonicalName: "~owner")
-                )
-            ],
-            sources: [
-                Project.SourceRepo(
-                    id: "repo-1",
-                    name: "hutch",
-                    description: nil,
-                    visibility: .public,
-                    owner: Entity(canonicalName: "~owner"),
-                    repoType: .git
-                ),
-                Project.SourceRepo(
-                    id: "repo-2",
-                    name: "hutch-web",
-                    description: nil,
-                    visibility: .public,
-                    owner: Entity(canonicalName: "~owner"),
-                    repoType: .git
-                )
-            ],
-            trackers: [
-                Project.Tracker(
-                    id: "tracker-1",
-                    name: "bugs",
-                    description: nil,
-                    visibility: .public,
-                    owner: Entity(canonicalName: "~owner")
-                )
-            ]
+            metadata: .init(
+                id: "project-1",
+                name: "Hutch",
+                description: nil,
+                website: nil,
+                visibility: .public,
+                tags: [],
+                updated: Date(timeIntervalSince1970: 0)
+            ),
+            resources: .init(
+                mailingLists: [
+                    Project.MailingList(
+                        id: "list-1",
+                        name: "hutch-devel",
+                        description: nil,
+                        visibility: .public,
+                        owner: Entity(canonicalName: "~owner")
+                    )
+                ],
+                sources: [
+                    Project.SourceRepo(
+                        id: "repo-1",
+                        name: "hutch",
+                        description: nil,
+                        visibility: .public,
+                        owner: Entity(canonicalName: "~owner"),
+                        repoType: .git
+                    ),
+                    Project.SourceRepo(
+                        id: "repo-2",
+                        name: "hutch-web",
+                        description: nil,
+                        visibility: .public,
+                        owner: Entity(canonicalName: "~owner"),
+                        repoType: .git
+                    )
+                ],
+                trackers: [
+                    Project.Tracker(
+                        id: "tracker-1",
+                        name: "bugs",
+                        description: nil,
+                        visibility: .public,
+                        owner: Entity(canonicalName: "~owner")
+                    )
+                ],
+                isFullyLoaded: true
+            )
         )
 
         #expect(project.resourceSummary == "2 repos • 1 tracker • 1 list")
@@ -57,16 +66,16 @@ struct ProjectTests {
     @Test
     func resourceSummaryFallsBackToWebsite() {
         let project = Project(
-            id: "project-1",
-            name: "Docs",
-            description: nil,
-            website: "https://example.com",
-            visibility: .public,
-            tags: [],
-            updated: Date(timeIntervalSince1970: 0),
-            mailingLists: [],
-            sources: [],
-            trackers: []
+            metadata: .init(
+                id: "project-1",
+                name: "Docs",
+                description: nil,
+                website: Fixture.exampleWebsite,
+                visibility: .public,
+                tags: [],
+                updated: Date(timeIntervalSince1970: 0)
+            ),
+            resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true)
         )
 
         #expect(project.resourceSummary == "Website linked")
@@ -75,16 +84,16 @@ struct ProjectTests {
     @Test
     func displayHelpersNormalizeBlankValues() {
         let project = Project(
-            id: "project-1",
-            name: "  ",
-            description: "\n",
-            website: "https://example.com",
-            visibility: .unlisted,
-            tags: [" docs ", "", "Docs", "ios"],
-            updated: Date(timeIntervalSince1970: 0),
-            mailingLists: [],
-            sources: [],
-            trackers: []
+            metadata: .init(
+                id: "project-1",
+                name: "  ",
+                description: "\n",
+                website: Fixture.exampleWebsite,
+                visibility: .unlisted,
+                tags: [" docs ", "", "Docs", "ios"],
+                updated: Date(timeIntervalSince1970: 0)
+            ),
+            resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: true)
         )
 
         #expect(project.displayName == "Untitled Project")
diff --git a/HutchTests/SRHTWebURLTests.swift b/HutchTests/SRHTWebURLTests.swift
index a183f00..c1f0dc0 100644
--- a/HutchTests/SRHTWebURLTests.swift
+++ b/HutchTests/SRHTWebURLTests.swift
@@ -3,6 +3,16 @@ import Testing
 @testable import Hutch
 
 struct SRHTWebURLTests {
+    private enum Expected {
+        static let chatOrigin = "https://chat.sr.ht"
+        static let statusOrigin = "https://status.sr.ht"
+        static let gitRepoHTTPS = "https://git.sr.ht/~ccleberg/hutch"
+        static let gitSSH = "git@git.sr.ht:~ccleberg/hutch"
+        static let tracker = "https://todo.sr.ht/~ccleberg/todo"
+        static let ticket = "https://todo.sr.ht/~ccleberg/todo/42"
+        static let buildJob = "https://builds.sr.ht/~ccleberg/job/12"
+    }
+
     private let repository = RepositorySummary(
         id: 1,
         rid: "repo-1",
@@ -26,21 +36,21 @@ struct SRHTWebURLTests {
 
     @Test
     func browserOnlyServiceURLsUseCanonicalHosts() {
-        #expect(SRHTWebURL.chat.absoluteString == "https://chat.sr.ht")
-        #expect(SRHTWebURL.status.absoluteString == "https://status.sr.ht")
+        #expect(SRHTWebURL.chat.absoluteString == Expected.chatOrigin)
+        #expect(SRHTWebURL.status.absoluteString == Expected.statusOrigin)
     }
 
     @Test
     func repositoryAndCloneURLsUseStableUserScopedPaths() {
-        #expect(SRHTWebURL.repository(repository)?.absoluteString == "https://git.sr.ht/~ccleberg/hutch")
-        #expect(SRHTWebURL.httpsCloneURL(repository) == "https://git.sr.ht/~ccleberg/hutch")
-        #expect(SRHTWebURL.sshCloneURL(repository) == "git@git.sr.ht:~ccleberg/hutch")
+        #expect(SRHTWebURL.repository(repository)?.absoluteString == Expected.gitRepoHTTPS)
+        #expect(SRHTWebURL.httpsCloneURL(repository) == Expected.gitRepoHTTPS)
+        #expect(SRHTWebURL.sshCloneURL(repository) == Expected.gitSSH)
     }
 
     @Test
     func trackerTicketAndBuildURLsUseStableUserScopedPaths() {
-        #expect(SRHTWebURL.tracker(tracker)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo")
-        #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == "https://todo.sr.ht/~ccleberg/todo/42")
-        #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == "https://builds.sr.ht/~ccleberg/job/12")
+        #expect(SRHTWebURL.tracker(tracker)?.absoluteString == Expected.tracker)
+        #expect(SRHTWebURL.ticket(ownerUsername: "ccleberg", trackerName: "todo", ticketId: 42)?.absoluteString == Expected.ticket)
+        #expect(SRHTWebURL.build(jobId: 12, ownerCanonicalName: "~ccleberg")?.absoluteString == Expected.buildJob)
     }
 }
diff --git a/HutchTests/SystemStatusServiceTests.swift b/HutchTests/SystemStatusServiceTests.swift
index d1577b6..d963fa4 100644
--- a/HutchTests/SystemStatusServiceTests.swift
+++ b/HutchTests/SystemStatusServiceTests.swift
@@ -3,6 +3,9 @@ import Testing
 @testable import Hutch
 
 struct SystemStatusServiceTests {
+    private enum Fixture {
+        static let ddosIssueURL = "https://status.sr.ht/issues/2026-04-06-ddos-attack/"
+    }
 
     @Test
     func parsesCurrentStatusHTMLIntoServicesAndActiveIncidents() throws {
@@ -16,7 +19,7 @@ struct SystemStatusServiceTests {
         #expect(snapshot.activeIncidents.count == 1)
         #expect(snapshot.activeIncidents[0].title == "SourceHut disrupted due to DDoS attack")
         #expect(snapshot.activeIncidents[0].summary == "SourceHut was disrupted by a DDoS attack.")
-        #expect(snapshot.activeIncidents[0].url?.absoluteString == "https://status.sr.ht/issues/2026-04-06-ddos-attack/")
+        #expect(snapshot.activeIncidents[0].url?.absoluteString == Fixture.ddosIssueURL)
     }
 
     @Test
diff --git a/HutchWidgetExtension/ContributionGraphWidget.swift b/HutchWidgetExtension/ContributionGraphWidget.swift
index 4245230..2fc07f2 100644
--- a/HutchWidgetExtension/ContributionGraphWidget.swift
+++ b/HutchWidgetExtension/ContributionGraphWidget.swift
@@ -112,7 +112,7 @@ private struct ContributionGraphWidgetView: View {
                 graphView
             }
         }
-        .widgetURL(URL(string: "hutch://home"))
+        .widgetURL(HutchDeepLinkURL.home)
         .containerBackground(for: .widget) {
             Color(.systemBackground)
         }
@@ -235,7 +235,7 @@ private enum ContributionGraphSizing {
 }
 
 private struct ContributionGraphWidgetService {
-    private let baseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
+    private let baseURL = HutchStatsAPI.defaultBaseURL
 
     func fetchCalendar(actor: String) async throws -> ContributionGraphResponse {
         guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift
index 574e9eb..016f1c9 100644
--- a/HutchWidgetExtension/NeedsAttentionWidget.swift
+++ b/HutchWidgetExtension/NeedsAttentionWidget.swift
@@ -71,7 +71,7 @@ private struct NeedsAttentionWidgetView: View {
                 fallbackState
             }
         }
-        .widgetURL(family == .systemSmall ? URL(string: "hutch://home") : nil)
+        .widgetURL(family == .systemSmall ? HutchDeepLinkURL.home : nil)
         .containerBackground(for: .widget) {
             Color(.systemBackground)
         }
@@ -101,7 +101,7 @@ private struct NeedsAttentionWidgetView: View {
     private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View {
         VStack(alignment: .leading, spacing: 0) {
             HStack(alignment: .top, spacing: 12) {
-                Link(destination: URL(string: "hutch://home")!) {
+                Link(destination: HutchDeepLinkURL.home) {
                     metricColumn(
                         count: snapshot.unreadInboxThreads,
                         label: "Unread",
@@ -109,14 +109,14 @@ private struct NeedsAttentionWidgetView: View {
                         tint: unreadTint(for: snapshot.unreadInboxThreads)
                     )
                 }
-                Link(destination: URL(string: "hutch://trackers")!) {
+                Link(destination: HutchDeepLinkURL.trackers) {
                     metricColumn(
                         count: snapshot.assignedOpenTickets,
                         label: "Assigned",
                         systemImage: "ticket"
                     )
                 }
-                Link(destination: URL(string: "hutch://builds")!) {
+                Link(destination: HutchDeepLinkURL.builds) {
                     metricColumn(
                         count: snapshot.failedBuilds,
                         label: "Failed",
@@ -134,7 +134,7 @@ private struct NeedsAttentionWidgetView: View {
 
     private func largeView(snapshot: NeedsAttentionSnapshot) -> some View {
         VStack(alignment: .leading, spacing: 18) {
-            Link(destination: URL(string: "hutch://home")!) {
+            Link(destination: HutchDeepLinkURL.home) {
                 metricRow(
                     count: snapshot.unreadInboxThreads,
                     label: "Unread threads",
@@ -142,14 +142,14 @@ private struct NeedsAttentionWidgetView: View {
                     tint: unreadTint(for: snapshot.unreadInboxThreads)
                 )
             }
-            Link(destination: URL(string: "hutch://trackers")!) {
+            Link(destination: HutchDeepLinkURL.trackers) {
                 metricRow(
                     count: snapshot.assignedOpenTickets,
                     label: "Assigned tickets",
                     systemImage: "ticket"
                 )
             }
-            Link(destination: URL(string: "hutch://builds")!) {
+            Link(destination: HutchDeepLinkURL.builds) {
                 metricRow(
                     count: snapshot.failedBuilds,
                     label: "Failed builds",
diff --git a/HutchWidgetExtension/SystemStatusWidget.swift b/HutchWidgetExtension/SystemStatusWidget.swift
index 4a3c1e5..a0908bb 100644
--- a/HutchWidgetExtension/SystemStatusWidget.swift
+++ b/HutchWidgetExtension/SystemStatusWidget.swift
@@ -63,17 +63,16 @@ private struct SystemStatusWidgetView: View {
     var body: some View {
         Group {
             if let snapshot = entry.snapshot, !snapshot.services.isEmpty {
-                switch family {
-                case .systemMedium:
+                if family == .systemMedium {
                     mediumView(snapshot: snapshot)
-                default:
+                } else {
                     smallView(snapshot: snapshot)
                 }
             } else {
                 fallbackView
             }
         }
-        .widgetURL(URL(string: "hutch://status"))
+        .widgetURL(HutchDeepLinkURL.status)
         .containerBackground(for: .widget) {
             Color(.systemBackground)
         }
diff --git a/Shared/HutchDeepLinkURLs.swift b/Shared/HutchDeepLinkURLs.swift
new file mode 100644
index 0000000..9b60086
--- /dev/null
+++ b/Shared/HutchDeepLinkURLs.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+/// Canonical `hutch://` URLs for widgets, tests, and in-app links.
+enum HutchDeepLinkURL {
+    static let home = URL(string: "hutch://home")!
+    static let emptyHost = URL(string: "hutch://")!
+    static let repositoryGit = URL(string: "hutch://git/~user/repo")!
+    static let ticket = URL(string: "hutch://todo/~owner/tracker/42")!
+    static let buildJob = URL(string: "hutch://builds/12345")!
+    static let trackers = URL(string: "hutch://trackers")!
+    static let builds = URL(string: "hutch://builds")!
+    static let repositories = URL(string: "hutch://repositories")!
+    static let status = URL(string: "hutch://status")!
+    static let lookup = URL(string: "hutch://lookup")!
+    static let unknown = URL(string: "hutch://unknown")!
+    static let invalidTicketId = URL(string: "hutch://todo/~owner/tracker/abc")!
+    static let invalidBuildId = URL(string: "hutch://builds/abc")!
+}
+
+/// Default Hutch Stats API base URL (mirrors `AppConfiguration` fallback).
+enum HutchStatsAPI {
+    static let defaultBaseURL = URL(string: "https://hutch-stats.zerolabs.sh")!
+}