diff options
| author | Christian Cleberg <[email protected]> | 2026-04-13 19:44:44 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-13 19:44:44 -0500 |
| commit | fe1ccc69661603d419a642a450e4ac9e1258adb0 (patch) | |
| tree | 386455b6b5468abdd9dfa46dd4802b10da8b77a2 | |
| parent | 6ec9754ce34f331641b000eddef3d44bca2631a5 (diff) | |
| download | hutch-3.1.3.tar.gz hutch-3.1.3.tar.bz2 hutch-3.1.3.zip | |
fix: centralize URLs, tighten models, and polish SwiftUI bindingsv3.1.3
- 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.
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 = "[email protected]:~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) == "[email protected]:~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")! +} |
