diff options
77 files changed, 920 insertions, 257 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c002514..62592d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,10 +3,12 @@ name: Tests # builds.sr.ht is the canonical CI for this project, but it has no macOS images # and will not add them, so xcodebuild cannot run there. This job exists to run # the test plan on the GitHub mirror; see builds/swift-ci.yml for the rest. -# Prose only: the root docs and Docs/. Both patterns are single-star, so they -# match those two levels and nothing deeper — a .md that ever lands inside a -# source directory still runs the suite. A skip rule that is too broad is how a -# real change slips through ungated. +# Prose only: the root docs (*.md, *.txt, README.nfo, LICENSE) and Docs/*.md. +# The globs are single-star, so they match the repo root and Docs/ and nothing +# deeper — a .md or .txt that ever lands inside a source directory still runs +# the suite. A skip rule that is too broad is how a real change slips through +# ungated. paths-ignore skips the run only when *all* changed files match, so a +# doc edit alongside code still tests. # # pull_request only, by design. GitHub runs the suite against the merge result # (PR merged into main), so a green PR already validates exactly what lands on @@ -23,7 +25,7 @@ name: Tests # docs-only PR waiting forever. on: pull_request: - paths-ignore: ['*.md', 'Docs/*.md'] + paths-ignore: ['*.md', '*.txt', 'README.nfo', 'LICENSE', 'Docs/*.md'] workflow_dispatch: # Without this, GITHUB_TOKEN inherits the repository default, which is diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index efff26f..355157b 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -597,7 +597,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 94; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -614,7 +614,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.11.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -622,7 +622,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -634,7 +634,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 94; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -651,7 +651,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.11.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -659,7 +659,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -680,7 +680,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch"; }; @@ -702,7 +702,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch"; }; @@ -714,7 +714,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 94; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -724,7 +724,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.11.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -732,7 +732,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -743,7 +743,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 94; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -753,7 +753,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.11.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -761,7 +761,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -772,7 +772,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 94; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchSafariExtension/Info.plist; @@ -782,7 +782,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.11.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -790,7 +790,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -801,7 +801,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 94; + CURRENT_PROJECT_VERSION = 96; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchSafariExtension/Info.plist; @@ -811,7 +811,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.9.0; + MARKETING_VERSION = 3.11.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -819,7 +819,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift index fe4ebdc..3f4cd83 100644 --- a/Hutch/App/HutchIntents.swift +++ b/Hutch/App/HutchIntents.swift @@ -4,9 +4,9 @@ import Foundation // MARK: - Navigation Intents struct OpenWorkQueueIntent: AppIntent { - static var title: LocalizedStringResource = "Open Work Queue" - static var description = IntentDescription("Opens Hutch to your Work Queue.") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Open Work Queue" + static let description = IntentDescription("Opens Hutch to your Work Queue.") + static let openAppWhenRun = true var route: HutchRoute { .workQueue(scope: .all) } @@ -18,9 +18,9 @@ struct OpenWorkQueueIntent: AppIntent { } struct OpenRecentActivityIntent: AppIntent { - static var title: LocalizedStringResource = "Open Recent Activity" - static var description = IntentDescription("Opens Hutch to recent activity.") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Open Recent Activity" + static let description = IntentDescription("Opens Hutch to recent activity.") + static let openAppWhenRun = true var route: HutchRoute { .recentActivity } @@ -32,9 +32,9 @@ struct OpenRecentActivityIntent: AppIntent { } struct OpenSystemStatusIntent: AppIntent { - static var title: LocalizedStringResource = "Open System Status" - static var description = IntentDescription("Opens Hutch to SourceHut system status.") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Open System Status" + static let description = IntentDescription("Opens Hutch to SourceHut system status.") + static let openAppWhenRun = true var route: HutchRoute { .systemStatus } @@ -46,9 +46,9 @@ struct OpenSystemStatusIntent: AppIntent { } struct OpenPinnedResourceIntent: AppIntent { - static var title: LocalizedStringResource = "Open Pinned Resource" - static var description = IntentDescription("Opens a pinned Hutch resource.") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Open Pinned Resource" + static let description = IntentDescription("Opens a pinned Hutch resource.") + static let openAppWhenRun = true @Parameter(title: "Pinned Resource") var pinnedResource: PinnedResourceEntity @@ -63,9 +63,9 @@ struct OpenPinnedResourceIntent: AppIntent { } struct OpenProjectDashboardIntent: AppIntent { - static var title: LocalizedStringResource = "Open Project Dashboard" - static var description = IntentDescription("Opens a pinned project dashboard in Hutch.") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Open Project Dashboard" + static let description = IntentDescription("Opens a pinned project dashboard in Hutch.") + static let openAppWhenRun = true @Parameter(title: "Project") var project: ProjectEntity @@ -84,16 +84,16 @@ struct OpenProjectDashboardIntent: AppIntent { enum HutchShortcutScope: String, AppEnum { case all - static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scope") - static var caseDisplayRepresentations: [HutchShortcutScope: DisplayRepresentation] = [ + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scope") + static let caseDisplayRepresentations: [HutchShortcutScope: DisplayRepresentation] = [ .all: "All" ] } struct OpenFailedBuildsIntent: AppIntent { - static var title: LocalizedStringResource = "Open Failed Builds" - static var description = IntentDescription("Opens Hutch to failed builds.") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Open Failed Builds" + static let description = IntentDescription("Opens Hutch to failed builds.") + static let openAppWhenRun = true @Parameter(title: "Scope", default: .all) var scope: HutchShortcutScope @@ -108,9 +108,9 @@ struct OpenFailedBuildsIntent: AppIntent { } struct OpenAssignedTicketsIntent: AppIntent { - static var title: LocalizedStringResource = "Open Assigned Tickets" - static var description = IntentDescription("Opens Hutch to tickets assigned to you.") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Open Assigned Tickets" + static let description = IntentDescription("Opens Hutch to tickets assigned to you.") + static let openAppWhenRun = true @Parameter(title: "Scope", default: .all) var scope: HutchShortcutScope @@ -125,9 +125,9 @@ struct OpenAssignedTicketsIntent: AppIntent { } struct SearchHutchIntent: AppIntent { - static var title: LocalizedStringResource = "Search Hutch" - static var description = IntentDescription("Opens Hutch lookup with a search query.") - static var openAppWhenRun = true + static let title: LocalizedStringResource = "Search Hutch" + static let description = IntentDescription("Opens Hutch lookup with a search query.") + static let openAppWhenRun = true @Parameter(title: "Query") var query: String @@ -155,8 +155,8 @@ struct SearchHutchIntent: AppIntent { // MARK: - App Entities struct PinnedResourceEntity: AppEntity, Identifiable { - static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Pinned Resource") - static var defaultQuery = PinnedResourceQuery() + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Pinned Resource") + static let defaultQuery = PinnedResourceQuery() let id: String let name: String @@ -181,8 +181,8 @@ struct PinnedResourceQuery: EntityQuery { } struct ProjectEntity: AppEntity, Identifiable { - static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project") - static var defaultQuery = ProjectEntityQuery() + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project") + static let defaultQuery = ProjectEntityQuery() let id: String let name: String @@ -278,8 +278,8 @@ enum HutchIntentEntityStore { // MARK: - Existing Read-Only Summary Intents struct CheckSystemStatusIntent: AppIntent { - static var title: LocalizedStringResource = "Check SourceHut Status" - static var description = IntentDescription("Returns the current SourceHut system status.") + static let title: LocalizedStringResource = "Check SourceHut Status" + static let description = IntentDescription("Returns the current SourceHut system status.") @MainActor func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog { @@ -302,8 +302,8 @@ struct CheckSystemStatusIntent: AppIntent { } struct CheckBuildsIntent: AppIntent { - static var title: LocalizedStringResource = "Check Hutch Builds" - static var description = IntentDescription("Returns a summary of your recent build status.") + static let title: LocalizedStringResource = "Check Hutch Builds" + static let description = IntentDescription("Returns a summary of your recent build status.") @MainActor func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog { @@ -337,7 +337,7 @@ struct CheckBuildsIntent: AppIntent { // MARK: - Search Type -extension LookupType: @retroactive AppEnum { +extension LookupType: AppEnum { public nonisolated static var typeDisplayRepresentation: TypeDisplayRepresentation { TypeDisplayRepresentation(name: "Search Type") } @@ -357,8 +357,8 @@ extension LookupType: @retroactive AppEnum { // MARK: - Mutating Intents struct ClearRecentActivityIntent: AppIntent { - static var title: LocalizedStringResource = "Clear Recent Activity" - static var description = IntentDescription("Clears the Recent list on the Hutch Home tab.") + static let title: LocalizedStringResource = "Clear Recent Activity" + static let description = IntentDescription("Clears the Recent list on the Hutch Home tab.") @MainActor func perform() async throws -> some IntentResult & ProvidesDialog { @@ -368,8 +368,8 @@ struct ClearRecentActivityIntent: AppIntent { } struct UnpinResourceIntent: AppIntent { - static var title: LocalizedStringResource = "Unpin Resource" - static var description = IntentDescription("Removes a pinned resource from the Hutch Home tab.") + static let title: LocalizedStringResource = "Unpin Resource" + static let description = IntentDescription("Removes a pinned resource from the Hutch Home tab.") @Parameter(title: "Pinned Resource") var pinnedResource: PinnedResourceEntity diff --git a/Hutch/Extensions/Date+Relative.swift b/Hutch/Extensions/Date+Relative.swift index f06ba59..b6c28ed 100644 --- a/Hutch/Extensions/Date+Relative.swift +++ b/Hutch/Extensions/Date+Relative.swift @@ -1,6 +1,6 @@ import Foundation -extension Date { +nonisolated extension Date { /// A short relative description like "2h ago", "3d ago", or "Jan 5, 2025". var relativeDescription: String { let formatter = RelativeDateTimeFormatter() diff --git a/Hutch/Extensions/DateFormatter+SRHT.swift b/Hutch/Extensions/DateFormatter+SRHT.swift index c017b11..a06b58a 100644 --- a/Hutch/Extensions/DateFormatter+SRHT.swift +++ b/Hutch/Extensions/DateFormatter+SRHT.swift @@ -1,6 +1,6 @@ import Foundation -extension DateFormatter { +nonisolated extension DateFormatter { /// Formatter for the sr.ht `Time` scalar: `%Y-%m-%dT%H:%M:%SZ` (UTC). static let srht: DateFormatter = { let formatter = DateFormatter() diff --git a/Hutch/Extensions/Foundation+Sendable.swift b/Hutch/Extensions/Foundation+Sendable.swift new file mode 100644 index 0000000..9a12aa6 --- /dev/null +++ b/Hutch/Extensions/Foundation+Sendable.swift @@ -0,0 +1,7 @@ +import Foundation + +// `UserDefaults` is documented as thread-safe but is not marked `Sendable` by +// Foundation. Hutch passes it into account sessions and the various per-account +// stores, all of which need to cross concurrency boundaries. The retroactive +// unchecked conformance reflects the documented thread-safety. +extension UserDefaults: @retroactive @unchecked Sendable {} diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift index fc5a1d5..5715ce4 100644 --- a/Hutch/Extensions/SRHTWebURL.swift +++ b/Hutch/Extensions/SRHTWebURL.swift @@ -1,6 +1,6 @@ import Foundation -enum SRHTWebURL { +nonisolated 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`). diff --git a/Hutch/Models/AccountEntry.swift b/Hutch/Models/AccountEntry.swift index 551cd19..cfc2f62 100644 --- a/Hutch/Models/AccountEntry.swift +++ b/Hutch/Models/AccountEntry.swift @@ -1,7 +1,7 @@ import Foundation /// A stored sr.ht account (token + resolved username). -struct AccountEntry: Codable, Identifiable, Equatable, Sendable { +nonisolated struct AccountEntry: Codable, Identifiable, Equatable, Sendable { let id: String var username: String var token: String diff --git a/Hutch/Models/ArtifactInfo.swift b/Hutch/Models/ArtifactInfo.swift index dbe9d96..6418ba8 100644 --- a/Hutch/Models/ArtifactInfo.swift +++ b/Hutch/Models/ArtifactInfo.swift @@ -1,7 +1,7 @@ import Foundation /// An artifact with its parent reference name, used in the artifacts tab. -struct ArtifactInfo: Codable, Sendable, Identifiable { +nonisolated struct ArtifactInfo: Codable, Sendable, Identifiable { let id: Int let filename: String let checksum: String @@ -9,13 +9,13 @@ struct ArtifactInfo: Codable, Sendable, Identifiable { let url: URL } -struct ArtifactPage: Codable, Sendable { +nonisolated struct ArtifactPage: Codable, Sendable { let results: [ArtifactInfo] let cursor: String? } /// A reference (tag) that has associated artifacts. -struct ReferenceWithArtifacts: Codable, Sendable, Identifiable { +nonisolated struct ReferenceWithArtifacts: Codable, Sendable, Identifiable { var id: String { name } let name: String let artifacts: [ArtifactInfo] diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift index 3926888..48a9c91 100644 --- a/Hutch/Models/Builds.swift +++ b/Hutch/Models/Builds.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - Enums /// Status of a build job. -enum JobStatus: String, Codable, Sendable { +nonisolated enum JobStatus: String, Codable, Sendable { case pending = "PENDING" case queued = "QUEUED" case running = "RUNNING" @@ -30,7 +30,7 @@ enum JobStatus: String, Codable, Sendable { } /// Status of a single build task within a job. -enum TaskStatus: String, Codable, Sendable { +nonisolated enum TaskStatus: String, Codable, Sendable { case pending = "PENDING" case running = "RUNNING" case success = "SUCCESS" @@ -41,7 +41,7 @@ enum TaskStatus: String, Codable, Sendable { // MARK: - Build Task /// A single task within a build job. -struct BuildTask: Codable, Sendable, Identifiable, Equatable { +nonisolated struct BuildTask: Codable, Sendable, Identifiable, Equatable { private(set) var ordinal: Int? let name: String let status: TaskStatus @@ -74,7 +74,7 @@ struct BuildTask: Codable, Sendable, Identifiable, Equatable { // MARK: - Job Summary (for list view) /// Lightweight job model matching the fields returned by the jobs list query. -struct JobSummary: Codable, Sendable, Identifiable, Hashable { +nonisolated struct JobSummary: Codable, Sendable, Identifiable, Hashable { let id: Int let created: Date let updated: Date @@ -103,7 +103,7 @@ struct JobSummary: Codable, Sendable, Identifiable, Hashable { } /// Minimal task info for the list query. -struct JobTaskSummary: Codable, Sendable, Hashable { +nonisolated struct JobTaskSummary: Codable, Sendable, Hashable { let name: String let status: TaskStatus } @@ -111,7 +111,7 @@ struct JobTaskSummary: Codable, Sendable, Hashable { // MARK: - Job Detail (for detail view) /// Full job model with all fields for the detail view. -struct JobDetail: Codable, Sendable, Equatable { +nonisolated struct JobDetail: Codable, Sendable, Equatable { let id: Int let created: Date let updated: Date @@ -128,7 +128,7 @@ struct JobDetail: Codable, Sendable, Equatable { } /// A downloadable artifact emitted by a build job. -struct BuildArtifact: Codable, Sendable, Identifiable, Equatable { +nonisolated struct BuildArtifact: Codable, Sendable, Identifiable, Equatable { let id: Int let created: Date let path: String @@ -145,14 +145,14 @@ struct BuildArtifact: Codable, Sendable, Identifiable, Equatable { } /// The log associated with a build job. -struct BuildLog: Codable, Sendable, Equatable { +nonisolated struct BuildLog: Codable, Sendable, Equatable { let fullURL: String } // MARK: - Job Group /// A group of related build jobs. -struct JobGroup: Codable, Sendable, Identifiable { +nonisolated struct JobGroup: Codable, Sendable, Identifiable { let id: Int let created: Date let note: String? diff --git a/Hutch/Models/CommitDetail.swift b/Hutch/Models/CommitDetail.swift index c95199d..962d2a4 100644 --- a/Hutch/Models/CommitDetail.swift +++ b/Hutch/Models/CommitDetail.swift @@ -1,7 +1,7 @@ import Foundation /// Full commit detail returned by the revparse_single query. -struct CommitDetail: Codable, Sendable, Identifiable { +nonisolated struct CommitDetail: Codable, Sendable, Identifiable { let id: String let shortId: String let author: CommitAuthor @@ -26,39 +26,39 @@ struct CommitDetail: Codable, Sendable, Identifiable { } } -struct CommitTrailer: Codable, Sendable, Identifiable { +nonisolated struct CommitTrailer: Codable, Sendable, Identifiable { var id: String { "\(name):\(value)" } let name: String let value: String } -struct ParentCommit: Codable, Sendable, Identifiable, Hashable { +nonisolated struct ParentCommit: Codable, Sendable, Identifiable, Hashable { let id: String let shortId: String let author: ParentAuthor } -struct ParentAuthor: Codable, Sendable, Hashable { +nonisolated struct ParentAuthor: Codable, Sendable, Hashable { let name: String } -struct CommitTree: Codable, Sendable { +nonisolated struct CommitTree: Codable, Sendable { let entries: CommitTreeEntries } -struct CommitTreeEntries: Codable, Sendable { +nonisolated struct CommitTreeEntries: Codable, Sendable { let results: [CommitTreeEntry] let cursor: String? } -struct CommitTreeEntry: Codable, Sendable, Identifiable { +nonisolated struct CommitTreeEntry: Codable, Sendable, Identifiable { let id: String let name: String let mode: Int let object: CommitTreeObject? } -struct CommitTreeObject: Codable, Sendable { +nonisolated struct CommitTreeObject: Codable, Sendable { let type: String? let id: String? let shortId: String? diff --git a/Hutch/Models/CommitSummary.swift b/Hutch/Models/CommitSummary.swift index 88c3e7d..99ed233 100644 --- a/Hutch/Models/CommitSummary.swift +++ b/Hutch/Models/CommitSummary.swift @@ -2,7 +2,7 @@ import Foundation /// Lightweight commit model for list views. Matches the subset of fields /// returned by the repository log query. -struct CommitSummary: Codable, Sendable, Identifiable, Hashable { +nonisolated struct CommitSummary: Codable, Sendable, Identifiable, Hashable { let id: String let shortId: String let author: CommitAuthor @@ -15,7 +15,7 @@ struct CommitSummary: Codable, Sendable, Identifiable, Hashable { } /// A compact author representation used in commit list responses. -struct CommitAuthor: Codable, Sendable, Hashable { +nonisolated struct CommitAuthor: Codable, Sendable, Hashable { let name: String let email: String? let time: Date diff --git a/Hutch/Models/ContributionCalendar.swift b/Hutch/Models/ContributionCalendar.swift index a429fa2..cf87ca7 100644 --- a/Hutch/Models/ContributionCalendar.swift +++ b/Hutch/Models/ContributionCalendar.swift @@ -1,6 +1,6 @@ import Foundation -struct ContributionCalendarResponse: Decodable, Sendable, Hashable { +nonisolated struct ContributionCalendarResponse: Decodable, Sendable, Hashable { let actor: String let from: Date let to: Date @@ -9,7 +9,7 @@ struct ContributionCalendarResponse: Decodable, Sendable, Hashable { let indexingState: ContributionIndexingState let days: [ContributionDay] - enum CodingKeys: String, CodingKey { + nonisolated enum CodingKeys: String, CodingKey { case actor case from case to @@ -59,14 +59,14 @@ struct ContributionCalendarResponse: Decodable, Sendable, Hashable { } } -struct ContributionDay: Decodable, Sendable, Hashable, Identifiable { +nonisolated struct ContributionDay: Decodable, Sendable, Hashable, Identifiable { var id: Date { date } let date: Date let count: Int let score: Double - enum CodingKeys: String, CodingKey { + nonisolated enum CodingKeys: String, CodingKey { case date case count case score @@ -90,7 +90,7 @@ struct ContributionDay: Decodable, Sendable, Hashable, Identifiable { } } -struct ContributionStatsResponse: Decodable, Sendable, Hashable { +nonisolated struct ContributionStatsResponse: Decodable, Sendable, Hashable { let actor: String let from: Date let to: Date @@ -103,7 +103,7 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable { let longestStreak: Int let currentStreak: Int - enum CodingKeys: String, CodingKey { + nonisolated enum CodingKeys: String, CodingKey { case actor case from case to @@ -117,7 +117,7 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable { case currentStreak = "current_streak" } - struct StatsWindow: Sendable, Hashable { + nonisolated struct StatsWindow: Sendable, Hashable { let actor: String let from: Date let to: Date @@ -126,7 +126,7 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable { let indexingState: ContributionIndexingState } - struct StatsTotals: Sendable, Hashable { + nonisolated struct StatsTotals: Sendable, Hashable { let totalEvents: Int let totalScore: Double let activeDays: Int @@ -181,13 +181,13 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable { } } -enum ContributionIndexingState: String, Codable, Sendable, Hashable { +nonisolated enum ContributionIndexingState: String, Codable, Sendable, Hashable { case pending case indexed case error } -enum ContributionIntensity: Int, Sendable, CaseIterable { +nonisolated enum ContributionIntensity: Int, Sendable, CaseIterable { case empty = 0 case level1 = 1 case level2 = 2 @@ -210,12 +210,12 @@ enum ContributionIntensity: Int, Sendable, CaseIterable { } } -struct ContributionWeek: Sendable, Hashable { +nonisolated struct ContributionWeek: Sendable, Hashable { let startDate: Date let days: [ContributionDay] } -enum ContributionCalendarLayout { +nonisolated enum ContributionCalendarLayout { static func weekColumns( from days: [ContributionDay], calendar: Calendar = .contributionCalendar @@ -241,7 +241,7 @@ enum ContributionCalendarLayout { } } -enum ContributionDateParser { +nonisolated enum ContributionDateParser { static func parse(_ rawValue: String) -> Date? { let parts = rawValue.split(separator: "-", omittingEmptySubsequences: false) guard @@ -348,7 +348,7 @@ enum ContributionDateParser { } } -extension Calendar { +nonisolated extension Calendar { static var contributionCalendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.firstWeekday = 1 diff --git a/Hutch/Models/Git.swift b/Hutch/Models/Git.swift index 26a5e2d..ae74f19 100644 --- a/Hutch/Models/Git.swift +++ b/Hutch/Models/Git.swift @@ -3,14 +3,14 @@ import Foundation // MARK: - Enums /// Repository visibility level. -enum Visibility: String, Codable, Sendable { +nonisolated enum Visibility: String, Codable, Sendable { case publicVisibility = "PUBLIC" case unlisted = "UNLISTED" case privateVisibility = "PRIVATE" } /// Repository access mode. -enum AccessMode: String, Codable, Sendable, CaseIterable { +nonisolated enum AccessMode: String, Codable, Sendable, CaseIterable { case ro = "RO" case rw = "RW" } @@ -19,14 +19,14 @@ enum AccessMode: String, Codable, Sendable, CaseIterable { /// The `Entity` GraphQL interface from git.sr.ht. Represents the owner of a /// resource (typically a user). -struct Entity: Codable, Sendable, Hashable { +nonisolated struct Entity: Codable, Sendable, Hashable { let canonicalName: String } // MARK: - Repository /// A git repository from git.sr.ht. -struct Repository: Codable, Sendable, Identifiable { +nonisolated struct Repository: Codable, Sendable, Identifiable { let id: Int let created: Date let updated: Date @@ -37,7 +37,7 @@ struct Repository: Codable, Sendable, Identifiable { let accessMode: AccessMode let owner: Entity - enum CodingKeys: String, CodingKey { + nonisolated enum CodingKeys: String, CodingKey { case id, created, updated, name, description, visibility, readme case accessMode = "access" case owner @@ -47,7 +47,7 @@ struct Repository: Codable, Sendable, Identifiable { // MARK: - Signature /// A Git commit/tag signature (author or committer). -struct Signature: Codable, Sendable { +nonisolated struct Signature: Codable, Sendable { let name: String let email: String let time: Date @@ -56,7 +56,7 @@ struct Signature: Codable, Sendable { // MARK: - Trailer /// A Git commit trailer (e.g. "Signed-off-by", "Co-authored-by"). -struct Trailer: Codable, Sendable { +nonisolated struct Trailer: Codable, Sendable { let name: String let value: String } @@ -64,7 +64,7 @@ struct Trailer: Codable, Sendable { // MARK: - Commit /// A Git commit from git.sr.ht. -struct Commit: Codable, Sendable, Identifiable { +nonisolated struct Commit: Codable, Sendable, Identifiable { let id: String let shortId: String let author: Signature @@ -77,13 +77,13 @@ struct Commit: Codable, Sendable, Identifiable { // MARK: - Reference /// A Git reference (branch or tag name). -struct Reference: Codable, Sendable, Hashable { +nonisolated struct Reference: Codable, Sendable, Hashable { let name: String let target: String? } /// A Git reference enriched with the date of its tip commit or tag, for display in the Refs tab. -struct ReferenceDetail: Sendable, Hashable { +nonisolated struct ReferenceDetail: Sendable, Hashable { let name: String let target: String? let date: Date? @@ -92,7 +92,7 @@ struct ReferenceDetail: Sendable, Hashable { // MARK: - TreeEntry /// An entry in a Git tree (file or directory). -struct TreeEntry: Codable, Sendable, Identifiable { +nonisolated struct TreeEntry: Codable, Sendable, Identifiable { let id: String let name: String let mode: Int? @@ -104,41 +104,41 @@ struct TreeEntry: Codable, Sendable, Identifiable { /// Both TextBlob and BinaryBlob have type == "BLOB"; they are /// distinguished by the presence of the "text" key (TextBlob) vs /// the "content" key (BinaryBlob). -enum GitObject: Sendable { +nonisolated enum GitObject: Sendable { case tree(GitTree) case textBlob(GitTextBlob) case binaryBlob(GitBinaryBlob) case unknown } -struct GitTree: Codable, Sendable { +nonisolated struct GitTree: Codable, Sendable { let id: String? let shortId: String? let entries: GitTreeEntryPage? } -struct GitTextBlob: Codable, Sendable { +nonisolated struct GitTextBlob: Codable, Sendable { let id: String? let shortId: String? let text: String? let size: Int? } -struct GitBinaryBlob: Codable, Sendable { +nonisolated struct GitBinaryBlob: Codable, Sendable { let id: String? let shortId: String? let size: Int? let content: String? } -struct GitTreeEntryPage: Codable, Sendable { +nonisolated struct GitTreeEntryPage: Codable, Sendable { let results: [TreeEntry] let cursor: String? } // MARK: - GitObject Codable -extension GitObject: Codable { +nonisolated extension GitObject: Codable { private enum CodingKeys: String, CodingKey { case type, id, shortId, entries, text, size, content case typename = "__typename" @@ -211,7 +211,7 @@ extension GitObject: Codable { } /// Convenience helpers for checking object type. -extension GitObject { +nonisolated extension GitObject { var isTree: Bool { if case .tree = self { return true } return false @@ -230,7 +230,7 @@ extension GitObject { // MARK: - Tag /// An annotated Git tag from git.sr.ht. -struct Tag: Codable, Sendable, Identifiable { +nonisolated struct Tag: Codable, Sendable, Identifiable { let id: String let shortId: String let name: String @@ -241,7 +241,7 @@ struct Tag: Codable, Sendable, Identifiable { // MARK: - Artifact /// A release artifact attached to a Git tag. -struct Artifact: Codable, Sendable, Identifiable { +nonisolated struct Artifact: Codable, Sendable, Identifiable { let id: Int let created: Date let filename: String diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift index c8e41bb..dfed719 100644 --- a/Hutch/Models/Inbox.swift +++ b/Hutch/Models/Inbox.swift @@ -1,6 +1,6 @@ import Foundation -struct InboxThreadSummary: Identifiable, Hashable, Sendable { +nonisolated struct InboxThreadSummary: Identifiable, Hashable, Sendable { let rootEmailID: Int let rootMessageID: String let threadRootEmailIDs: [Int] @@ -76,7 +76,7 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable { } } -struct InboxMessage: Identifiable, Hashable, Sendable { +nonisolated struct InboxMessage: Identifiable, Hashable, Sendable { let id: Int let author: Entity let date: Date @@ -89,12 +89,12 @@ struct InboxMessage: Identifiable, Hashable, Sendable { let rawMessageURL: URL? } -enum InboxMessageContentBlock: Hashable, Sendable { +nonisolated enum InboxMessageContentBlock: Hashable, Sendable { case plainText(String) case diff(String) } -struct InboxThreadDetail: Sendable { +nonisolated struct InboxThreadDetail: Sendable { let id: String let rootEmailID: Int let rootMessageID: String @@ -114,7 +114,7 @@ struct InboxThreadDetail: Sendable { } } -extension InboxThreadDetail { +nonisolated extension InboxThreadDetail { var replyRecipient: String { "\(listOwner.canonicalName)/\(listName)@lists.sr.ht" } @@ -144,7 +144,7 @@ extension InboxThreadDetail { } } -struct MailComposeDraft: Sendable { +nonisolated struct MailComposeDraft: Sendable { let recipients: [String] let ccRecipients: [String] let subject: String @@ -155,20 +155,20 @@ struct MailComposeDraft: Sendable { } } -extension MailComposeDraft: Identifiable {} +nonisolated extension MailComposeDraft: Identifiable {} -struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable { +nonisolated struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable { let id: Int let rid: String let name: String let owner: Entity } -struct InboxPatchPreview: Decodable, Sendable, Hashable { +nonisolated struct InboxPatchPreview: Decodable, Sendable, Hashable { let subject: String? } -enum InboxReadStateStore { +nonisolated enum InboxReadStateStore { private static let key = "InboxThreadLastViewed" private static let baselineKey = "InboxUnreadBaseline" diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift index f52fc76..e0a09de 100644 --- a/Hutch/Models/Meta.swift +++ b/Hutch/Models/Meta.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - User Profile (full) /// Extended user profile from meta.sr.ht with all fields from the `me` query. -struct UserProfile: Codable, Sendable { +nonisolated struct UserProfile: Codable, Sendable { let username: String let canonicalName: String let email: String @@ -20,7 +20,7 @@ struct UserProfile: Codable, Sendable { // MARK: - SSH Key -struct SSHKey: Codable, Sendable, Identifiable { +nonisolated struct SSHKey: Codable, Sendable, Identifiable { let id: Int let comment: String? let created: Date @@ -34,27 +34,27 @@ struct SSHKey: Codable, Sendable, Identifiable { } } -struct SSHKeyPage: Codable, Sendable { +nonisolated struct SSHKeyPage: Codable, Sendable { let results: [SSHKey] let cursor: String? } // MARK: - PGP Key -struct PGPKey: Codable, Sendable, Identifiable { +nonisolated struct PGPKey: Codable, Sendable, Identifiable { let id: Int let fingerprint: String let created: Date } -struct PGPKeyPage: Codable, Sendable { +nonisolated struct PGPKeyPage: Codable, Sendable { let results: [PGPKey] let cursor: String? } // MARK: - Subscription -struct Subscription: Codable, Sendable { +nonisolated struct Subscription: Codable, Sendable { let status: String? let autorenew: Bool? let interval: String? @@ -62,7 +62,7 @@ struct Subscription: Codable, Sendable { // MARK: - Personal Access Token -struct PersonalAccessToken: Codable, Sendable, Identifiable { +nonisolated struct PersonalAccessToken: Codable, Sendable, Identifiable { let id: Int let issued: Date let expires: Date? @@ -72,7 +72,7 @@ struct PersonalAccessToken: Codable, Sendable, Identifiable { /// One entry in meta.sr.ht's audit log: a security-relevant action on the /// account, with the address it came from. -struct AuditLogEntry: Codable, Sendable, Identifiable { +nonisolated struct AuditLogEntry: Codable, Sendable, Identifiable { let id: Int let created: Date let ipAddress: String diff --git a/Hutch/Models/Paste.swift b/Hutch/Models/Paste.swift index b4c8f18..a8bf926 100644 --- a/Hutch/Models/Paste.swift +++ b/Hutch/Models/Paste.swift @@ -1,6 +1,6 @@ import Foundation -struct PasteFile: Codable, Sendable, Hashable, Identifiable { +nonisolated struct PasteFile: Codable, Sendable, Hashable, Identifiable { let filename: String? let hash: String let contents: URL? @@ -8,7 +8,7 @@ struct PasteFile: Codable, Sendable, Hashable, Identifiable { var id: String { hash } } -struct Paste: Codable, Sendable, Identifiable, Hashable { +nonisolated struct Paste: Codable, Sendable, Identifiable, Hashable { let id: String let created: Date let visibility: Visibility @@ -16,7 +16,7 @@ struct Paste: Codable, Sendable, Identifiable, Hashable { let user: Entity } -struct PasteUploadDraft: Identifiable, Equatable, Sendable { +nonisolated struct PasteUploadDraft: Identifiable, Equatable, Sendable { let id: UUID var filename: String var contents: String diff --git a/Hutch/Models/Patchset.swift b/Hutch/Models/Patchset.swift index 330bbbc..cc10b35 100644 --- a/Hutch/Models/Patchset.swift +++ b/Hutch/Models/Patchset.swift @@ -1,7 +1,7 @@ import Foundation /// Review state of a patchset on lists.sr.ht. -enum PatchsetStatus: String, Codable, Sendable, CaseIterable { +nonisolated enum PatchsetStatus: String, Codable, Sendable, CaseIterable { case unknown = "UNKNOWN" case proposed = "PROPOSED" case needsRevision = "NEEDS_REVISION" @@ -55,7 +55,7 @@ enum PatchsetStatus: String, Codable, Sendable, CaseIterable { /// A patchset as it appears in a mailing list listing, derived from the thread's /// root email rather than a dedicated patchsets query — `MailingList` exposes no /// such field. -struct PatchsetSummary: Identifiable, Hashable, Sendable { +nonisolated struct PatchsetSummary: Identifiable, Hashable, Sendable { let id: Int let subject: String let version: Int @@ -70,7 +70,7 @@ struct PatchsetSummary: Identifiable, Hashable, Sendable { } /// One email within a patchset: either the cover letter or a single patch. -struct PatchsetEmail: Identifiable, Hashable, Sendable { +nonisolated struct PatchsetEmail: Identifiable, Hashable, Sendable { let id: Int let subject: String let date: Date? @@ -88,13 +88,13 @@ struct PatchsetEmail: Identifiable, Hashable, Sendable { } /// A build or check reported against a patchset. -struct PatchsetToolResult: Identifiable, Hashable, Sendable { +nonisolated struct PatchsetToolResult: Identifiable, Hashable, Sendable { let id: Int let icon: PatchsetToolIcon let details: String } -enum PatchsetToolIcon: String, Codable, Sendable { +nonisolated enum PatchsetToolIcon: String, Codable, Sendable { case pending = "PENDING" case waiting = "WAITING" case success = "SUCCESS" @@ -112,7 +112,7 @@ enum PatchsetToolIcon: String, Codable, Sendable { } /// A patchset with its cover letter, patches, and review context. -struct PatchsetDetail: Sendable { +nonisolated struct PatchsetDetail: Sendable { let id: Int let created: Date let updated: Date diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift index 8f5aa68..bd1a688 100644 --- a/Hutch/Models/Project.swift +++ b/Hutch/Models/Project.swift @@ -1,7 +1,7 @@ import Foundation -struct Project: Identifiable, Hashable, Sendable { - struct MailingList: Identifiable, Hashable, Sendable { +nonisolated struct Project: Identifiable, Hashable, Sendable { + nonisolated struct MailingList: Identifiable, Hashable, Sendable { let id: String let name: String let description: String? @@ -22,8 +22,8 @@ struct Project: Identifiable, Hashable, Sendable { } } - struct SourceRepo: Identifiable, Hashable, Sendable { - enum RepoType: String, Decodable, Sendable { + nonisolated struct SourceRepo: Identifiable, Hashable, Sendable { + nonisolated enum RepoType: String, Decodable, Sendable { case git = "GIT" case hg = "HG" @@ -47,7 +47,7 @@ struct Project: Identifiable, Hashable, Sendable { } } - struct Tracker: Identifiable, Hashable, Sendable { + nonisolated struct Tracker: Identifiable, Hashable, Sendable { let id: String let name: String let description: String? @@ -72,7 +72,7 @@ struct Project: Identifiable, Hashable, Sendable { let isFullyLoaded: Bool /// Identity and display fields for a project. - struct Metadata: Sendable, Hashable { + nonisolated struct Metadata: Sendable, Hashable { let id: String let name: String let description: String? @@ -82,7 +82,7 @@ struct Project: Identifiable, Hashable, Sendable { let updated: Date } - struct Resources: Sendable, Hashable { + nonisolated struct Resources: Sendable, Hashable { let mailingLists: [MailingList] let sources: [SourceRepo] let trackers: [Tracker] @@ -171,7 +171,7 @@ struct Project: Identifiable, Hashable, Sendable { } } -extension Project.MailingList { +nonisolated extension Project.MailingList { var displayName: String { Project.normalizedText(name) ?? "Untitled Mailing List" } @@ -185,7 +185,7 @@ extension Project.MailingList { } } -extension Project.SourceRepo { +nonisolated extension Project.SourceRepo { var displayName: String { Project.normalizedText(name) ?? "Untitled Repository" } @@ -203,7 +203,7 @@ extension Project.SourceRepo { } } -extension Project.Tracker { +nonisolated extension Project.Tracker { var displayName: String { Project.normalizedText(name) ?? "Untitled Tracker" } @@ -221,13 +221,13 @@ extension Project.Tracker { } } -extension String { +nonisolated extension String { var srhtUsername: String { hasPrefix("~") ? String(dropFirst()) : self } } -extension Visibility { +nonisolated extension Visibility { var displayName: String { switch self { case .publicVisibility: diff --git a/Hutch/Models/RepositoryACL.swift b/Hutch/Models/RepositoryACL.swift index e5f5eb1..e5d93f6 100644 --- a/Hutch/Models/RepositoryACL.swift +++ b/Hutch/Models/RepositoryACL.swift @@ -1,12 +1,12 @@ import Foundation -struct RepositoryACLEntry: Codable, Sendable, Identifiable, Hashable { +nonisolated struct RepositoryACLEntry: Codable, Sendable, Identifiable, Hashable { let id: Int let mode: AccessMode let entity: Entity } -extension AccessMode { +nonisolated extension AccessMode { var shortLabel: String { rawValue } var displayName: String { diff --git a/Hutch/Models/RepositorySummary.swift b/Hutch/Models/RepositorySummary.swift index b41589b..40ec7a4 100644 --- a/Hutch/Models/RepositorySummary.swift +++ b/Hutch/Models/RepositorySummary.swift @@ -3,7 +3,7 @@ import Foundation /// Lightweight repository model matching the fields returned by the /// repositories list query. Avoids optionalizing all fields on the full /// `Repository` model. -struct RepositorySummary: Codable, Sendable, Identifiable, Hashable { +nonisolated struct RepositorySummary: Codable, Sendable, Identifiable, Hashable { let id: Int /// GraphQL resource identifier used by `repository(rid:)` queries. let rid: String @@ -15,13 +15,13 @@ struct RepositorySummary: Codable, Sendable, Identifiable, Hashable { let owner: Entity let head: Reference? - enum CodingKeys: String, CodingKey { + nonisolated enum CodingKeys: String, CodingKey { case id, rid, service, name, description, visibility, updated, owner case head = "HEAD" } /// Grouped initializer fields (single parameter keeps APIs explicit without exceeding parameter-count limits). - struct Fields: Sendable, Hashable { + nonisolated struct Fields: Sendable, Hashable { let id: Int let rid: String let service: SRHTService @@ -59,7 +59,7 @@ struct RepositorySummary: Codable, Sendable, Identifiable, Hashable { } } -extension RepositorySummary { +nonisolated extension RepositorySummary { var defaultBranchName: String? { head?.name.replacingOccurrences(of: "refs/heads/", with: "") } diff --git a/Hutch/Models/SystemStatusModels.swift b/Hutch/Models/SystemStatusModels.swift index 9a664b0..ddd659c 100644 --- a/Hutch/Models/SystemStatusModels.swift +++ b/Hutch/Models/SystemStatusModels.swift @@ -1,6 +1,6 @@ import Foundation -enum StatusLevel: String, Codable, Sendable { +nonisolated enum StatusLevel: String, Codable, Sendable { case operational case degraded case majorOutage @@ -32,7 +32,7 @@ enum StatusLevel: String, Codable, Sendable { } } -struct StatusServiceState: Identifiable, Hashable, Codable, Sendable { +nonisolated struct StatusServiceState: Identifiable, Hashable, Codable, Sendable { let id: String let name: String let slug: String? @@ -40,7 +40,7 @@ struct StatusServiceState: Identifiable, Hashable, Codable, Sendable { let description: String? } -struct StatusIncident: Identifiable, Hashable, Codable, Sendable { +nonisolated struct StatusIncident: Identifiable, Hashable, Codable, Sendable { let id: String let title: String let summary: String? @@ -50,7 +50,7 @@ struct StatusIncident: Identifiable, Hashable, Codable, Sendable { let isActive: Bool? } -struct SystemStatusSnapshot: Hashable, Codable, Sendable { +nonisolated struct SystemStatusSnapshot: Hashable, Codable, Sendable { let services: [StatusServiceState] let activeIncidents: [StatusIncident] let lastUpdated: Date @@ -78,7 +78,7 @@ struct SystemStatusSnapshot: Hashable, Codable, Sendable { } } -struct SystemStatusPageData: Sendable { +nonisolated struct SystemStatusPageData: Sendable { let snapshot: SystemStatusSnapshot let recentIncidents: [StatusIncident] } diff --git a/Hutch/Models/TicketBulkAction.swift b/Hutch/Models/TicketBulkAction.swift index dcf7676..ea9cc01 100644 --- a/Hutch/Models/TicketBulkAction.swift +++ b/Hutch/Models/TicketBulkAction.swift @@ -1,6 +1,6 @@ import Foundation -enum TicketBulkActionKind: String, Sendable { +nonisolated enum TicketBulkActionKind: String, Sendable { case close case assign @@ -23,7 +23,7 @@ enum TicketBulkActionKind: String, Sendable { } } -struct TicketBulkActionResult: Identifiable, Sendable { +nonisolated struct TicketBulkActionResult: Identifiable, Sendable { let id = UUID() let action: TicketBulkActionKind let totalCount: Int @@ -82,7 +82,7 @@ struct TicketBulkActionResult: Identifiable, Sendable { } } -struct TicketBulkActionFailure: Sendable { +nonisolated struct TicketBulkActionFailure: Sendable { let ticketID: Int let message: String } diff --git a/Hutch/Models/Todo.swift b/Hutch/Models/Todo.swift index 129d4bf..d2ae889 100644 --- a/Hutch/Models/Todo.swift +++ b/Hutch/Models/Todo.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - Enums /// Status of a ticket. -enum TicketStatus: String, Codable, Sendable, CaseIterable { +nonisolated enum TicketStatus: String, Codable, Sendable, CaseIterable { case reported = "REPORTED" case confirmed = "CONFIRMED" case inProgress = "IN_PROGRESS" @@ -30,7 +30,7 @@ enum TicketStatus: String, Codable, Sendable, CaseIterable { } /// Resolution of a ticket. -enum TicketResolution: String, Codable, Sendable { +nonisolated enum TicketResolution: String, Codable, Sendable { case unresolved = "UNRESOLVED" case fixed = "FIXED" case implemented = "IMPLEMENTED" @@ -64,7 +64,7 @@ enum TicketResolution: String, Codable, Sendable { } /// Authenticity of a ticket or comment. -enum Authenticity: String, Codable, Sendable { +nonisolated enum Authenticity: String, Codable, Sendable { case authentic = "AUTHENTIC" case tampered = "TAMPERED" case unauthenticated = "UNAUTHENTICATED" @@ -74,7 +74,7 @@ enum Authenticity: String, Codable, Sendable { /// A label that can be applied to tickets. /// Named `TicketLabel` to avoid collision with `SwiftUI.Label`. -struct TicketLabel: Codable, Sendable, Identifiable, Hashable { +nonisolated struct TicketLabel: Codable, Sendable, Identifiable, Hashable { let id: Int let name: String let backgroundColor: String @@ -83,7 +83,7 @@ struct TicketLabel: Codable, Sendable, Identifiable, Hashable { // MARK: - Tracker ACL -struct TrackerACLPermissions: Codable, Sendable, Hashable { +nonisolated struct TrackerACLPermissions: Codable, Sendable, Hashable { let browse: Bool let submit: Bool let comment: Bool @@ -91,7 +91,7 @@ struct TrackerACLPermissions: Codable, Sendable, Hashable { let triage: Bool } -struct TrackerACL: Codable, Sendable, Identifiable, Hashable { +nonisolated struct TrackerACL: Codable, Sendable, Identifiable, Hashable { let id: Int let created: Date let entity: Entity @@ -112,7 +112,7 @@ struct TrackerACL: Codable, Sendable, Identifiable, Hashable { } } -struct DefaultTrackerACL: Codable, Sendable, Hashable { +nonisolated struct DefaultTrackerACL: Codable, Sendable, Hashable { let browse: Bool let submit: Bool let comment: Bool @@ -133,7 +133,7 @@ struct DefaultTrackerACL: Codable, Sendable, Hashable { // MARK: - Tracker /// A bug tracker from todo.sr.ht. -struct Tracker: Codable, Sendable, Identifiable, Hashable { +nonisolated struct Tracker: Codable, Sendable, Identifiable, Hashable { let id: Int let created: Date let updated: Date @@ -154,7 +154,7 @@ struct Tracker: Codable, Sendable, Identifiable, Hashable { // MARK: - Tracker Summary (for list view) /// Lightweight tracker model matching the fields returned by the trackers list query. -struct TrackerSummary: Codable, Sendable, Identifiable, Hashable { +nonisolated struct TrackerSummary: Codable, Sendable, Identifiable, Hashable { let id: Int /// GraphQL resource identifier used by `tracker(id:)` queries. let rid: String @@ -168,7 +168,7 @@ struct TrackerSummary: Codable, Sendable, Identifiable, Hashable { // MARK: - Ticket Summary (for list view) /// Lightweight ticket model for the list query. -struct TicketSummary: Codable, Sendable, Identifiable, Hashable { +nonisolated struct TicketSummary: Codable, Sendable, Identifiable, Hashable { let id: Int let title: String let status: TicketStatus @@ -190,7 +190,7 @@ struct TicketSummary: Codable, Sendable, Identifiable, Hashable { // MARK: - Ticket Detail /// Full ticket model for the detail view. -struct TicketDetail: Codable, Sendable { +nonisolated struct TicketDetail: Codable, Sendable { let id: Int let created: Date let updated: Date @@ -207,7 +207,7 @@ struct TicketDetail: Codable, Sendable { // MARK: - Event /// A timeline event on a ticket. Each event contains one or more changes. -struct TicketEvent: Codable, Sendable, Identifiable { +nonisolated struct TicketEvent: Codable, Sendable, Identifiable { let id: Int let created: Date var changes: [EventChange] @@ -215,7 +215,7 @@ struct TicketEvent: Codable, Sendable, Identifiable { /// A single change within an event, decoded from the polymorphic EventDetail /// interface using inline fragments. -struct EventChange: Codable, Sendable, Identifiable { +nonisolated struct EventChange: Codable, Sendable, Identifiable { let id: UUID let eventType: String @@ -272,12 +272,12 @@ struct EventChange: Codable, Sendable, Identifiable { /// Decoded from the `mentioned` field which may be a Ticket (with `id`) or /// an Entity (with `canonicalName`) depending on the event type. -struct MentionTarget: Codable, Sendable { +nonisolated struct MentionTarget: Codable, Sendable { let id: Int? let canonicalName: String? } /// Label info as returned within a LabelUpdate event change. -struct EventLabel: Codable, Sendable { +nonisolated struct EventLabel: Codable, Sendable { let name: String } diff --git a/Hutch/Models/User.swift b/Hutch/Models/User.swift index 4ab7315..10576ad 100644 --- a/Hutch/Models/User.swift +++ b/Hutch/Models/User.swift @@ -1,7 +1,7 @@ import Foundation /// A Sourcehut user from meta.sr.ht. -struct User: Decodable, Sendable, Hashable { +nonisolated struct User: Decodable, Sendable, Hashable { let id: Int let created: String? let updated: String? diff --git a/Hutch/Networking/RepositoryDeployKeyService.swift b/Hutch/Networking/RepositoryDeployKeyService.swift new file mode 100644 index 0000000..39e2da1 --- /dev/null +++ b/Hutch/Networking/RepositoryDeployKeyService.swift @@ -0,0 +1,100 @@ +import Foundation + +/// A per-repository deploy key (git.sr.ht `SSHKey` under `Repository.deployKeys`). +struct RepositoryDeployKey: Decodable, Sendable, Identifiable, Hashable { + let rid: String + let keyType: String + let fingerprintSHA256: String + let comment: String? + let access: AccessMode + + var id: String { rid } +} + +private struct DeployKeysQueryResponse: Decodable, Sendable { + let repository: DeployKeysRepository? +} + +private struct DeployKeysRepository: Decodable, Sendable { + let deployKeys: DeployKeysPage +} + +private struct DeployKeysPage: Decodable, Sendable { + let results: [RepositoryDeployKey] +} + +/// `createDeployKey`'s response returns an empty `access` (the stored value is +/// correct — the list query reports it), so we select only `rid` here and let +/// callers reload rather than decode the partial key. +private struct CreateDeployKeyResponse: Decodable, Sendable {} + +/// Delete returns the removed key; only success matters here. +private struct DeleteDeployKeyResponse: Decodable, Sendable {} + +/// Deploy keys are a git.sr.ht capability (`createDeployKey` / `deleteDeployKey`), +/// owner-only, alongside repository ACLs. +struct RepositoryDeployKeyService: Sendable { + private let client: SRHTClient + + init(client: SRHTClient) { + self.client = client + } + + func fetchDeployKeys(repositoryRid: String) async throws -> [RepositoryDeployKey] { + let response = try await client.execute( + service: .git, + query: Self.deployKeysQuery, + variables: ["rid": repositoryRid], + responseType: DeployKeysQueryResponse.self + ) + return response.repository?.deployKeys.results ?? [] + } + + func createDeployKey(repositoryRid: String, mode: AccessMode, key: String) async throws { + _ = try await client.execute( + service: .git, + query: Self.createDeployKeyMutation, + variables: ["repo": repositoryRid, "mode": mode.rawValue, "key": key], + responseType: CreateDeployKeyResponse.self + ) + } + + func deleteDeployKey(rid: String) async throws { + _ = try await client.execute( + service: .git, + query: Self.deleteDeployKeyMutation, + variables: ["rid": rid], + responseType: DeleteDeployKeyResponse.self + ) + } +} + +private extension RepositoryDeployKeyService { + static let deployKeysQuery = """ + query repositoryDeployKeys($rid: ID!) { + repository(rid: $rid) { + deployKeys { + results { + rid + keyType + fingerprintSHA256 + comment + access + } + } + } + } + """ + + static let createDeployKeyMutation = """ + mutation createDeployKey($repo: ID!, $mode: AccessMode!, $key: String!) { + createDeployKey(repo: $repo, mode: $mode, key: $key) { rid } + } + """ + + static let deleteDeployKeyMutation = """ + mutation deleteDeployKey($rid: ID!) { + deleteDeployKey(rid: $rid) { rid } + } + """ +} diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift index 1b159bf..b6ed2ff 100644 --- a/Hutch/Views/Lists/MailingListListView.swift +++ b/Hutch/Views/Lists/MailingListListView.swift @@ -100,7 +100,7 @@ final class MailingListListViewModel { query: Self.createMailingListMutation, variables: [ "name": trimmedName, - "description": trimmedDescription.isEmpty ? nil as String? as Any : trimmedDescription, + "description": trimmedDescription.isEmpty ? nil as String? as any Sendable : trimmedDescription, "visibility": visibility.rawValue ], responseType: Response.self diff --git a/Hutch/Views/More/TipStoreViewModel.swift b/Hutch/Views/More/TipStoreViewModel.swift index bc909c1..043b218 100644 --- a/Hutch/Views/More/TipStoreViewModel.swift +++ b/Hutch/Views/More/TipStoreViewModel.swift @@ -32,7 +32,9 @@ final class TipStoreViewModel { var errorMessage: String? var statusMessage: String? - private var transactionUpdatesTask: Task<Void, Never>? + // Internal task handle, not observable state; assigned only on the main + // actor and read once from the nonisolated deinit. + @ObservationIgnored nonisolated(unsafe) private var transactionUpdatesTask: Task<Void, Never>? init() { transactionUpdatesTask = Task.detached(priority: .background) { diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift index 10bb19e..a4decbb 100644 --- a/Hutch/Views/Projects/ProjectMailingListView.swift +++ b/Hutch/Views/Projects/ProjectMailingListView.swift @@ -37,6 +37,36 @@ private struct PatchsetSummaryPayload: Decodable, Sendable { let status: PatchsetStatus } +private struct ListMetaResponse: Decodable, Sendable { + let list: ListMetaPayload? +} + +private struct ListMetaPayload: Decodable, Sendable { + let id: Int + let owner: Entity +} + +private struct SubscriptionRidsResponse: Decodable, Sendable { + let subscriptions: SubscriptionRidsPage +} + +private struct SubscriptionRidsPage: Decodable, Sendable { + let results: [SubscriptionRidEntry] + let cursor: String? +} + +private struct SubscriptionRidEntry: Decodable, Sendable { + let list: SubscriptionRidList? +} + +private struct SubscriptionRidList: Decodable, Sendable { + let rid: String +} + +/// Toggle mutations return the subscription (nullable on unsubscribe); only +/// success matters. +private struct SubscriptionToggleResponse: Decodable, Sendable {} + @Observable @MainActor final class MailingListDetailViewModel { @@ -47,6 +77,13 @@ final class MailingListDetailViewModel { var error: String? var searchText = "" + /// Subscription state. `isSubscribed` is `nil` while unknown or unavailable + /// (the toggle stays hidden); `isOwnList` hides it for lists you own. + private(set) var listNumericID: Int? + private(set) var isSubscribed: Bool? + private(set) var isOwnList = false + private(set) var isTogglingSubscription = false + private let mailingList: InboxMailingListReference private let client: SRHTClient private let defaults: UserDefaults @@ -86,6 +123,96 @@ final class MailingListDetailViewModel { self.accountID = accountID } + // MARK: - Subscription + + private static let listMetaQuery = """ + query listMeta($rid: ID!) { + list(rid: $rid) { id owner { canonicalName } } + } + """ + + // `MailingList.subscription` is unreliable (see the API-traps note), so + // subscribe state comes from the authoritative `subscriptions` query. + private static let subscriptionRidsQuery = """ + query subscriptionRids($cursor: Cursor) { + subscriptions(cursor: $cursor) { + results { + ... on MailingListSubscription { list { rid } } + } + cursor + } + } + """ + + private static let subscribeMutation = """ + mutation mailingListSubscribe($id: Int!) { + mailingListSubscribe(listID: $id) { id } + } + """ + + private static let unsubscribeMutation = """ + mutation mailingListUnsubscribe($id: Int!) { + mailingListUnsubscribe(listID: $id) { id } + } + """ + + /// Resolves the list's numeric id, whether the viewer owns it, and — for + /// lists they don't own — whether they're subscribed. + func loadSubscriptionState(currentUserCanonicalName: String?) async { + do { + let meta = try await client.execute( + service: .lists, + query: Self.listMetaQuery, + variables: ["rid": mailingList.rid], + responseType: ListMetaResponse.self + ) + guard let list = meta.list else { return } + listNumericID = list.id + + if let currentUserCanonicalName, list.owner.canonicalName == currentUserCanonicalName { + isOwnList = true + return + } + isSubscribed = try await isSubscribed(toRid: mailingList.rid) + } catch { + // Leave state unknown; the toggle stays hidden rather than lying. + } + } + + private func isSubscribed(toRid rid: String) async throws -> Bool { + var cursor: String? + repeat { + let response = try await client.execute( + service: .lists, + query: Self.subscriptionRidsQuery, + variables: cursor.map { ["cursor": $0] }, + responseType: SubscriptionRidsResponse.self + ) + if response.subscriptions.results.contains(where: { $0.list?.rid == rid }) { + return true + } + cursor = response.subscriptions.cursor + } while cursor != nil + return false + } + + func toggleSubscription() async { + guard let id = listNumericID, let subscribed = isSubscribed, !isTogglingSubscription else { return } + isTogglingSubscription = true + defer { isTogglingSubscription = false } + do { + _ = try await client.execute( + service: .lists, + query: subscribed ? Self.unsubscribeMutation : Self.subscribeMutation, + variables: ["id": id], + responseType: SubscriptionToggleResponse.self + ) + isSubscribed = !subscribed + } catch { + self.error = error.userFacingMessage + } + } + var filteredThreads: [InboxThreadSummary] { Self.filterThreads(threads, matching: searchText) } @@ -386,6 +513,21 @@ struct MailingListDetailView: View { .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") } } + if let viewModel, !viewModel.isOwnList, let subscribed = viewModel.isSubscribed { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await viewModel.toggleSubscription() } + } label: { + if viewModel.isTogglingSubscription { + ProgressView().controlSize(.small) + } else { + Image(systemName: subscribed ? "bell.fill" : "bell") + } + } + .disabled(viewModel.isTogglingSubscription) + .accessibilityLabel(subscribed ? "Unsubscribe from list" : "Subscribe to list") + } + } } .task { if viewModel == nil { @@ -397,6 +539,7 @@ struct MailingListDetailView: View { ) self.viewModel = viewModel await viewModel.loadThreads() + await viewModel.loadSubscriptionState(currentUserCanonicalName: currentUserKey) } } .onAppear { diff --git a/Hutch/Views/Repositories/RepositoryDeployKeysView.swift b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift new file mode 100644 index 0000000..71e252b --- /dev/null +++ b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +@Observable +@MainActor +final class RepositoryDeployKeysViewModel { + private(set) var keys: [RepositoryDeployKey] = [] + private(set) var isLoading = false + private(set) var isSaving = false + private(set) var deletingRID: String? + var loadError: String? + var error: String? + var saveError: String? + + let repositoryRid: String + private let service: RepositoryDeployKeyService + + init(repositoryRid: String, service: RepositoryDeployKeyService) { + self.repositoryRid = repositoryRid + self.service = service + } + + func load() async { + guard !isLoading else { return } + isLoading = true + loadError = nil + defer { isLoading = false } + do { + keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid) + } catch { + if keys.isEmpty { + loadError = error.userFacingMessage + } else { + self.error = error.userFacingMessage + } + } + } + + func addKey(publicKey: String, mode: AccessMode) async -> Bool { + let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !isSaving else { return false } + isSaving = true + saveError = nil + defer { isSaving = false } + do { + try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed) + // The create response omits the key's fields, so reload the list. + keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid) + return true + } catch { + saveError = error.userFacingMessage + return false + } + } + + func deleteKey(_ key: RepositoryDeployKey) async { + guard deletingRID == nil else { return } + deletingRID = key.rid + error = nil + defer { deletingRID = nil } + do { + try await service.deleteDeployKey(rid: key.rid) + keys.removeAll { $0.rid == key.rid } + } catch { + self.error = error.userFacingMessage + } + } +} + +struct RepositoryDeployKeysView: View { + let repository: RepositorySummary + let client: SRHTClient + var showsDoneButton = false + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: RepositoryDeployKeysViewModel? + @State private var showAddSheet = false + @State private var pendingDeletion: RepositoryDeployKey? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading deploy keys…") + } + } + .navigationTitle("Deploy Keys") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + if viewModel != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAddSheet = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add deploy key") + } + } + } + .task { + if viewModel == nil { + let vm = RepositoryDeployKeysViewModel( + repositoryRid: repository.rid, + service: RepositoryDeployKeyService(client: client) + ) + viewModel = vm + await vm.load() + } + } + } + + @ViewBuilder + private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View { + Group { + if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil { + SRHTLoadingStateView(message: "Loading deploy keys…") + } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Deploy Keys", + message: loadError, + retryAction: { await viewModel.load() } + ) + } else { + List { + if viewModel.keys.isEmpty { + Section { + ContentUnavailableView( + "No Deploy Keys", + systemImage: "key", + description: Text("Add an SSH public key to grant this repository read or read/write access for automation.") + ) + .themedRow() + } + } else { + Section { + ForEach(viewModel.keys) { key in + DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid) + .themedRow() + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + pendingDeletion = key + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } footer: { + Text("Deploy keys are SSH keys scoped to this repository only.") + } + } + } + .themedList() + .refreshable { await viewModel.load() } + } + } + .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 })) + .confirmationDialog( + "Delete this deploy key?", + isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }), + titleVisibility: .visible + ) { + Button("Cancel", role: .cancel) { pendingDeletion = nil } + Button("Delete", role: .destructive) { + if let key = pendingDeletion { + pendingDeletion = nil + Task { await viewModel.deleteKey(key) } + } + } + } message: { + Text("This revokes the key's access to \(repository.name). This cannot be undone.") + } + .sheet(isPresented: $showAddSheet) { + AddDeployKeyView(viewModel: viewModel) + } + } +} + +private struct DeployKeyRow: View { + let key: RepositoryDeployKey + let isDeleting: Bool + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(key.comment?.isEmpty == false ? key.comment! : key.keyType) + .font(.body) + .lineLimit(1) + Text(key.fingerprintSHA256) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + if isDeleting { + ProgressView().controlSize(.small) + } else { + Text(key.access.displayName) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } +} + +private struct AddDeployKeyView: View { + let viewModel: RepositoryDeployKeysViewModel + + @Environment(\.dismiss) private var dismiss + @State private var publicKey = "" + @State private var mode: AccessMode = .ro + + var body: some View { + NavigationStack { + Form { + Section("SSH Public Key") { + TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical) + .lineLimit(3...8) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.body.monospaced()) + .themedRow() + } + Section { + Picker("Access", selection: $mode) { + Text("Read Only").tag(AccessMode.ro) + Text("Read/Write").tag(AccessMode.rw) + } + .themedRow() + } footer: { + Text("Read/Write lets the key push to this repository.") + } + if let saveError = viewModel.saveError, !saveError.isEmpty { + Section { + Text(saveError).foregroundStyle(.red).themedRow() + } + } + } + .themedList() + .navigationTitle("Add Deploy Key") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if await viewModel.addKey(publicKey: publicKey, mode: mode) { + dismiss() + } + } + } label: { + if viewModel.isSaving { + ProgressView().controlSize(.small) + } else { + Text("Add") + } + } + .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving) + } + } + } + } +} diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift index 8f466ba..a4ac25e 100644 --- a/Hutch/Views/Repositories/RepositoryDetailView.swift +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -12,6 +12,7 @@ struct RepositoryDetailView: View { @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary @State private var showSettings = false @State private var showACLs = false + @State private var showDeployKeys = false @State private var currentRepository: RepositorySummary @State private var pinChangeCount = 0 @@ -82,6 +83,15 @@ struct RepositoryDetailView: View { ) } } + .sheet(isPresented: $showDeployKeys) { + NavigationStack { + RepositoryDeployKeysView( + repository: currentRepository, + client: appState.client, + showsDoneButton: true + ) + } + } .task { if viewModel == nil { viewModel = RepositoryDetailViewModel( @@ -201,6 +211,12 @@ struct RepositoryDetailView: View { } Button { + showDeployKeys = true + } label: { + Label("Deploy Keys", systemImage: "key") + } + + Button { showSettings = true } label: { Label("Repository Settings", systemImage: "gear") diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index 82e1592..62a8b12 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -557,7 +557,7 @@ final class RepositoryDetailViewModel { variables: [ "repoId": repository.id, "revspec": revspec, - "file": nil as String? as Any + "file": nil as String? as any Sendable ], file: MultipartUploadFile( variablePath: "file", diff --git a/Hutch/Views/Repositories/SyntaxHighlighter.swift b/Hutch/Views/Repositories/SyntaxHighlighter.swift index 5ae8f58..dfab443 100644 --- a/Hutch/Views/Repositories/SyntaxHighlighter.swift +++ b/Hutch/Views/Repositories/SyntaxHighlighter.swift @@ -3,7 +3,7 @@ import Highlightr import SwiftUI import UIKit -enum SyntaxHighlightTheme { +nonisolated enum SyntaxHighlightTheme { case light case dark @@ -31,7 +31,7 @@ enum SyntaxHighlightTheme { /// instance must stay on the thread/task that created it. Callers that fail to /// resolve a language — or hit an unavailable engine — get `nil` and should /// fall back to plain, escaped text. -final class SyntaxHighlighter { +nonisolated final class SyntaxHighlighter { private let highlightr: Highlightr? private let supportedLanguages: Set<String> @@ -208,7 +208,7 @@ final class SyntaxHighlighter { ] } -private extension UIColor { +nonisolated private extension UIColor { /// `#rrggbb` for HTML inline styles, or `nil` if the color isn't RGB-convertible. var hexRGBString: String? { var red: CGFloat = 0 diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift index 8536e60..2d57ba5 100644 --- a/Hutch/Views/Settings/SettingsViewModel.swift +++ b/Hutch/Views/Settings/SettingsViewModel.swift @@ -198,9 +198,9 @@ final class SettingsViewModel { do { let input: [String: any Sendable] = [ "email": email, - "url": url.isEmpty ? nil as String? as Any : url, - "location": location.isEmpty ? nil as String? as Any : location, - "bio": bio.isEmpty ? nil as String? as Any : bio + "url": url.isEmpty ? nil as String? as any Sendable : url, + "location": location.isEmpty ? nil as String? as any Sendable : location, + "bio": bio.isEmpty ? nil as String? as any Sendable : bio ] let result = try await client.execute( service: .meta, @@ -243,7 +243,7 @@ final class SettingsViewModel { do { // The input variable has avatar set to null; the actual file // is sent as a separate multipart part per graphql-multipart-request-spec. - let input: [String: any Sendable] = ["avatar": nil as String? as Any] + let input: [String: any Sendable] = ["avatar": nil as String? as any Sendable] let result = try await client.executeMultipart( service: .meta, query: Self.updateUserMutation, @@ -286,7 +286,7 @@ final class SettingsViewModel { error = nil do { - let input: [String: any Sendable] = ["avatar": nil as String? as Any] + let input: [String: any Sendable] = ["avatar": nil as String? as any Sendable] let result = try await client.execute( service: .meta, query: Self.updateUserMutation, diff --git a/HutchTests/APICacheTests.swift b/HutchTests/APICacheTests.swift index 24db870..9a93783 100644 --- a/HutchTests/APICacheTests.swift +++ b/HutchTests/APICacheTests.swift @@ -3,6 +3,7 @@ import Testing @testable import Hutch @Suite(.serialized) +@MainActor struct APICacheTests { private struct Payload: Codable, Sendable, Equatable { let value: String diff --git a/HutchTests/AppStateTests.swift b/HutchTests/AppStateTests.swift index b7e1755..49baa17 100644 --- a/HutchTests/AppStateTests.swift +++ b/HutchTests/AppStateTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct AppStateTests { @Test diff --git a/HutchTests/BuildListViewModelTests.swift b/HutchTests/BuildListViewModelTests.swift index 8ee5b81..9b64621 100644 --- a/HutchTests/BuildListViewModelTests.swift +++ b/HutchTests/BuildListViewModelTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct BuildListViewModelTests { @Test diff --git a/HutchTests/BuildTaskLogSearchTests.swift b/HutchTests/BuildTaskLogSearchTests.swift index 4cb9984..8c7ddc2 100644 --- a/HutchTests/BuildTaskLogSearchTests.swift +++ b/HutchTests/BuildTaskLogSearchTests.swift @@ -1,6 +1,7 @@ import Testing @testable import Hutch +@MainActor struct BuildTaskLogSearchTests { @Test diff --git a/HutchTests/BuildTaskTests.swift b/HutchTests/BuildTaskTests.swift index 2e2a1e0..c7baae7 100644 --- a/HutchTests/BuildTaskTests.swift +++ b/HutchTests/BuildTaskTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct BuildTaskTests { @Test diff --git a/HutchTests/BundleUserAgentTests.swift b/HutchTests/BundleUserAgentTests.swift index c1a20b8..a4bda55 100644 --- a/HutchTests/BundleUserAgentTests.swift +++ b/HutchTests/BundleUserAgentTests.swift @@ -40,6 +40,7 @@ private final class CapturingURLProtocol: URLProtocol, @unchecked Sendable { /// Tests are serialized because CapturingURLProtocol uses shared static state. @Suite(.serialized) +@MainActor struct BundleUserAgentTests { // MARK: Bundle extension diff --git a/HutchTests/ContributionCalendarTests.swift b/HutchTests/ContributionCalendarTests.swift index 0328ad5..e5d625a 100644 --- a/HutchTests/ContributionCalendarTests.swift +++ b/HutchTests/ContributionCalendarTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct ContributionCalendarTests { @Test diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift index cdc5bab..dc4f605 100644 --- a/HutchTests/DeepLinkTests.swift +++ b/HutchTests/DeepLinkTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct DeepLinkTests { @Test diff --git a/HutchTests/GitObjectTests.swift b/HutchTests/GitObjectTests.swift index 547c6f3..3546d11 100644 --- a/HutchTests/GitObjectTests.swift +++ b/HutchTests/GitObjectTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct GitObjectTests { @Test diff --git a/HutchTests/HomeViewModelTests.swift b/HutchTests/HomeViewModelTests.swift index 851b520..a050931 100644 --- a/HutchTests/HomeViewModelTests.swift +++ b/HutchTests/HomeViewModelTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct HomeViewModelTests { @Test diff --git a/HutchTests/HutchTests.swift b/HutchTests/HutchTests.swift index c681efa..98c68d3 100644 --- a/HutchTests/HutchTests.swift +++ b/HutchTests/HutchTests.swift @@ -7,6 +7,7 @@ import Testing +@MainActor struct HutchTests { @Test func example() async throws { diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift index 5780b6a..9c35001 100644 --- a/HutchTests/InboxViewModelTests.swift +++ b/HutchTests/InboxViewModelTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct InboxViewModelTests { @Test diff --git a/HutchTests/LookupHistoryStoreTests.swift b/HutchTests/LookupHistoryStoreTests.swift index 414d016..af7c69b 100644 --- a/HutchTests/LookupHistoryStoreTests.swift +++ b/HutchTests/LookupHistoryStoreTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct LookupHistoryStoreTests { @Test diff --git a/HutchTests/MailingListActivityTests.swift b/HutchTests/MailingListActivityTests.swift index 46208cb..61a7e77 100644 --- a/HutchTests/MailingListActivityTests.swift +++ b/HutchTests/MailingListActivityTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct MailingListActivityTests { @Test diff --git a/HutchTests/ManPageCatalogTests.swift b/HutchTests/ManPageCatalogTests.swift index df995f8..e6bd2fc 100644 --- a/HutchTests/ManPageCatalogTests.swift +++ b/HutchTests/ManPageCatalogTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct ManPageCatalogTests { @Test func loadsBundledCatalog() { diff --git a/HutchTests/PatchsetTests.swift b/HutchTests/PatchsetTests.swift index 945131f..e181f14 100644 --- a/HutchTests/PatchsetTests.swift +++ b/HutchTests/PatchsetTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct PatchsetStatusTests { @Test @@ -46,6 +47,7 @@ struct PatchsetStatusTests { } } +@MainActor struct PatchsetSummaryTests { @Test diff --git a/HutchTests/ProjectFormTests.swift b/HutchTests/ProjectFormTests.swift index a4b4dfa..3e1fffc 100644 --- a/HutchTests/ProjectFormTests.swift +++ b/HutchTests/ProjectFormTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct ProjectFormTests { @Test func parsesTagsSplittingAndTrimming() { diff --git a/HutchTests/ProjectPinStoreTests.swift b/HutchTests/ProjectPinStoreTests.swift index 13df539..c040868 100644 --- a/HutchTests/ProjectPinStoreTests.swift +++ b/HutchTests/ProjectPinStoreTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct ProjectPinStoreTests { @Test func storesPinnedProjectsPerUser() { diff --git a/HutchTests/ProjectTests.swift b/HutchTests/ProjectTests.swift index 23faa0d..785c98f 100644 --- a/HutchTests/ProjectTests.swift +++ b/HutchTests/ProjectTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct ProjectTests { private enum Fixture { static let exampleWebsite = "https://example.com" diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift index 9e053b8..96eb8c0 100644 --- a/HutchTests/ReadmeViewTests.swift +++ b/HutchTests/ReadmeViewTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct ReadmeViewTests { @Test @@ -28,6 +29,7 @@ struct ReadmeViewTests { } } +@MainActor struct MarkdownRenderingTests { @Test @@ -219,6 +221,7 @@ struct MarkdownRenderingTests { } } +@MainActor struct OrgRenderingTests { @Test @@ -376,6 +379,7 @@ struct OrgRenderingTests { } } +@MainActor struct RepositoryAssetURLTests { @Test @@ -391,6 +395,7 @@ struct RepositoryAssetURLTests { } } +@MainActor struct RepositoryLinkURLTests { @Test diff --git a/HutchTests/RecentActivityStoreTests.swift b/HutchTests/RecentActivityStoreTests.swift index e6fcc04..7713e6d 100644 --- a/HutchTests/RecentActivityStoreTests.swift +++ b/HutchTests/RecentActivityStoreTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct RecentActivityStoreTests { @Test func removeDropsMatchingEntryKeepingOthers() { diff --git a/HutchTests/RepositoryACLViewModelTests.swift b/HutchTests/RepositoryACLViewModelTests.swift index 9640b93..501074c 100644 --- a/HutchTests/RepositoryACLViewModelTests.swift +++ b/HutchTests/RepositoryACLViewModelTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct RepositoryACLViewModelTests { @Test diff --git a/HutchTests/RepositoryDeployKeyTests.swift b/HutchTests/RepositoryDeployKeyTests.swift new file mode 100644 index 0000000..d7a0dad --- /dev/null +++ b/HutchTests/RepositoryDeployKeyTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import Hutch + +@MainActor +struct RepositoryDeployKeyTests { + @Test + func decodesDeployKey() throws { + let json = """ + { "rid": "k1", "keyType": "ssh-ed25519", "fingerprintSHA256": "SHA256:abc", "comment": "laptop", "access": "RW" } + """ + let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8)) + #expect(key.rid == "k1") + #expect(key.id == "k1") + #expect(key.access == .rw) + #expect(key.comment == "laptop") + } + + @Test + func decodesDeployKeyWithNullComment() throws { + let json = """ + { "rid": "k2", "keyType": "ssh-rsa", "fingerprintSHA256": "SHA256:def", "comment": null, "access": "RO" } + """ + let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8)) + #expect(key.comment == nil) + #expect(key.access == .ro) + } +} diff --git a/HutchTests/RepositoryListViewModelTests.swift b/HutchTests/RepositoryListViewModelTests.swift index 5fe3974..7787fca 100644 --- a/HutchTests/RepositoryListViewModelTests.swift +++ b/HutchTests/RepositoryListViewModelTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct RepositoryListViewModelTests { @Test diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift index 13d0b21..25e46c9 100644 --- a/HutchTests/RepositorySettingsViewModelTests.swift +++ b/HutchTests/RepositorySettingsViewModelTests.swift @@ -10,6 +10,7 @@ private struct UpdateRepositoryInfoPayload: Decodable { let id: Int } +@MainActor struct RepositorySettingsViewModelTests { @Test diff --git a/HutchTests/SRHTClientTests.swift b/HutchTests/SRHTClientTests.swift index 12d6d0f..51b6a68 100644 --- a/HutchTests/SRHTClientTests.swift +++ b/HutchTests/SRHTClientTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct SRHTClientTests { @Test diff --git a/HutchTests/SRHTWebURLTests.swift b/HutchTests/SRHTWebURLTests.swift index d1053e6..f20f964 100644 --- a/HutchTests/SRHTWebURLTests.swift +++ b/HutchTests/SRHTWebURLTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct SRHTWebURLTests { private enum Expected { static let chatOrigin = "https://chat.sr.ht" diff --git a/HutchTests/ScopedSearchHistoryStoreTests.swift b/HutchTests/ScopedSearchHistoryStoreTests.swift index 856d69f..8effa06 100644 --- a/HutchTests/ScopedSearchHistoryStoreTests.swift +++ b/HutchTests/ScopedSearchHistoryStoreTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct ScopedSearchHistoryStoreTests { @Test diff --git a/HutchTests/SettingsViewModelTests.swift b/HutchTests/SettingsViewModelTests.swift index 906f33f..e88d064 100644 --- a/HutchTests/SettingsViewModelTests.swift +++ b/HutchTests/SettingsViewModelTests.swift @@ -64,6 +64,7 @@ private struct DeleteResultPayload: Decodable { } @Suite(.serialized) +@MainActor struct SettingsViewModelTests { @Test diff --git a/HutchTests/SettingsViewTests.swift b/HutchTests/SettingsViewTests.swift index 950c3dc..f0f7fc1 100644 --- a/HutchTests/SettingsViewTests.swift +++ b/HutchTests/SettingsViewTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct SettingsViewTests { @Test diff --git a/HutchTests/SourceHutWebDeepLinkMapperTests.swift b/HutchTests/SourceHutWebDeepLinkMapperTests.swift index 0fa3aaa..2c638c2 100644 --- a/HutchTests/SourceHutWebDeepLinkMapperTests.swift +++ b/HutchTests/SourceHutWebDeepLinkMapperTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct SourceHutWebDeepLinkMapperTests { @Test func mapsRepositoryURLs() { diff --git a/HutchTests/SyntaxHighlighterTests.swift b/HutchTests/SyntaxHighlighterTests.swift index 258e81e..3150814 100644 --- a/HutchTests/SyntaxHighlighterTests.swift +++ b/HutchTests/SyntaxHighlighterTests.swift @@ -3,6 +3,7 @@ import Testing import UIKit @testable import Hutch +@MainActor struct SyntaxHighlighterTests { @Test func mapsFileNamesToLanguages() { @@ -24,9 +25,8 @@ struct SyntaxHighlighterTests { let highlighter = SyntaxHighlighter(theme: .light) let html = highlighter.highlightedHTML(for: "let answer = 42", language: "swift") - let unwrapped = try? #require(html) - #expect(unwrapped?.contains("<span style=\"color:") == true) - #expect(unwrapped?.contains("answer") == true) + #expect(html?.contains("<span style=\"color:") == true) + #expect(html?.contains("answer") == true) } @Test @@ -42,7 +42,6 @@ struct SyntaxHighlighterTests { let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) let attributed = highlighter.attributedText(for: "print(\"hi\")", language: "python", font: font) - let unwrapped = try? #require(attributed) - #expect(unwrapped?.string.contains("print") == true) + #expect(attributed?.string.contains("print") == true) } } diff --git a/HutchTests/SystemStatusRepositoryTests.swift b/HutchTests/SystemStatusRepositoryTests.swift index 56fa7c0..45d32b9 100644 --- a/HutchTests/SystemStatusRepositoryTests.swift +++ b/HutchTests/SystemStatusRepositoryTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct SystemStatusRepositoryTests { @Test @@ -111,21 +112,21 @@ private struct TestSystemStatusService: SystemStatusServing { } private extension SystemStatusRepositoryTests { - static let cachedSnapshotHTML = #""" + nonisolated static let cachedSnapshotHTML = #""" <div class="component" data-status="disrupted"> <a href="/affected/git.sr.ht/">git.sr.ht</a> <span class="component-status">Disrupted</span> </div> """# - static let cachedOperationalHTML = #""" + nonisolated static let cachedOperationalHTML = #""" <div class="component" data-status="ok"> <a href="/affected/meta.sr.ht/">meta.sr.ht</a> <span class="component-status">Operational</span> </div> """# - static let cachedIncidentRSS = #""" + nonisolated static let cachedIncidentRSS = #""" <rss version="2.0"> <channel> <item> @@ -139,7 +140,7 @@ private extension SystemStatusRepositoryTests { </rss> """# - static let emptyRSS = #""" + nonisolated static let emptyRSS = #""" <rss version="2.0"> <channel></channel> </rss> diff --git a/HutchTests/SystemStatusServiceTests.swift b/HutchTests/SystemStatusServiceTests.swift index d963fa4..9a7af1f 100644 --- a/HutchTests/SystemStatusServiceTests.swift +++ b/HutchTests/SystemStatusServiceTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct SystemStatusServiceTests { private enum Fixture { static let ddosIssueURL = "https://status.sr.ht/issues/2026-04-06-ddos-attack/" diff --git a/HutchTests/TicketBulkActionTests.swift b/HutchTests/TicketBulkActionTests.swift index 2f4d1fd..2c1d738 100644 --- a/HutchTests/TicketBulkActionTests.swift +++ b/HutchTests/TicketBulkActionTests.swift @@ -1,6 +1,7 @@ import Testing @testable import Hutch +@MainActor struct TicketBulkActionTests { @Test diff --git a/HutchTests/TicketDetailViewModelTests.swift b/HutchTests/TicketDetailViewModelTests.swift index ba4a476..d2c0c8e 100644 --- a/HutchTests/TicketDetailViewModelTests.swift +++ b/HutchTests/TicketDetailViewModelTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct TicketDetailViewModelTests { @Test diff --git a/HutchTests/TicketSavedFilterStoreTests.swift b/HutchTests/TicketSavedFilterStoreTests.swift index 2ff64e6..cfdcba6 100644 --- a/HutchTests/TicketSavedFilterStoreTests.swift +++ b/HutchTests/TicketSavedFilterStoreTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct TicketSavedFilterStoreTests { @Test diff --git a/HutchTests/TipStoreViewModelTests.swift b/HutchTests/TipStoreViewModelTests.swift index ed58d1e..734a7cc 100644 --- a/HutchTests/TipStoreViewModelTests.swift +++ b/HutchTests/TipStoreViewModelTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct TipStoreViewModelTests { @Test diff --git a/HutchTests/TrackerListViewModelTests.swift b/HutchTests/TrackerListViewModelTests.swift index 5c57e04..85b051f 100644 --- a/HutchTests/TrackerListViewModelTests.swift +++ b/HutchTests/TrackerListViewModelTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct TrackerListViewModelTests { @Test diff --git a/HutchTests/TrackerManagementViewModelTests.swift b/HutchTests/TrackerManagementViewModelTests.swift index 531608c..211c838 100644 --- a/HutchTests/TrackerManagementViewModelTests.swift +++ b/HutchTests/TrackerManagementViewModelTests.swift @@ -1,6 +1,7 @@ import Testing @testable import Hutch +@MainActor struct TrackerManagementViewModelTests { @Test diff --git a/HutchWidgetExtension/ContributionGraphWidget.swift b/HutchWidgetExtension/ContributionGraphWidget.swift index 2fc07f2..bc56a03 100644 --- a/HutchWidgetExtension/ContributionGraphWidget.swift +++ b/HutchWidgetExtension/ContributionGraphWidget.swift @@ -28,12 +28,16 @@ struct ContributionGraphTimelineProvider: TimelineProvider { } func getSnapshot(in _: Context, completion: @escaping (ContributionGraphEntry) -> Void) { + // WidgetKit's completion is safe to call from the task; rebind as + // nonisolated(unsafe) so it can cross into the `sending` Task closure. + nonisolated(unsafe) let completion = completion Task { completion(await loadEntry()) } } func getTimeline(in _: Context, completion: @escaping (Timeline<ContributionGraphEntry>) -> Void) { + nonisolated(unsafe) let completion = completion Task { let entry = await loadEntry() let refreshDate = Calendar.contributionCalendar.date(byAdding: .hour, value: 1, to: Date()) ?? Date().addingTimeInterval(3600) diff --git a/ROADMAP.txt b/ROADMAP.txt index 6c4765e..4352b9e 100644 --- a/ROADMAP.txt +++ b/ROADMAP.txt @@ -27,6 +27,15 @@ Things the schema does not tell you, each of which has already cost real time. `git clone --depth 1 https://git.sr.ht/~sircmpwn/<service>.sr.ht` and look at `api/graph/schema.graphqls`. Regenerating the dumps with a full introspection query would remove the trap. +- **`MailingList.subscription` does not report your subscription.** The field + exists and is typed `MailingListSubscription`, but it returns null even + immediately after a `mailingListSubscribe` that hands you back a real + subscription id — verified live against `~hutch`, for both owned and + non-owned lists. Do not gate subscribe-state on it. The authoritative source + is membership in the `subscriptions` query (correct: true after subscribe, + false after unsubscribe); the mutations take `listID: Int!`, read from + `list(rid:){ id }`. Cost the v3.11.0 subscribe toggle a full afternoon of the + "looks right, isn't" variety. ## Phase 0: Unblock CI — done (v3.5.0) @@ -79,13 +88,14 @@ were never called. Each removes a "why can't I do this here?" moment. - ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and `copySelf`, surfaced as an Email section in Settings. -`mailingListSubscribe` is deliberately not wired up. `MailingList` has no -`subscription` field, unlike `Ticket` and `Tracker`, so per-list state is only -knowable from the `subscriptions` query — which by definition lists what the -user is already subscribed to. Subscribing needs a list the user is *not* -subscribed to, and sr.ht exposes no discovery API to find one (see -[SCOPE.txt](SCOPE.txt) on hub.sr.ht). Revisit if hub.sr.ht ever gains an API, or -alongside Phase 2, which surfaces lists through patchsets. +`mailingListSubscribe` was left unwired here on the view that per-list state was +only knowable from the `subscriptions` query, and subscribing needs a list you +are *not* subscribed to. **Shipped in v3.11.0** once live testing clarified two +things: a specific list is reachable without a discovery API (Lookup, a project's +lists, patchsets), and the `subscriptions` query *is* the reliable state source — +membership in it answers "am I subscribed to this rid?". `MailingList.subscription` +looked like a shortcut but is a trap (see API traps); it is not used. See +"mailing list subscribe" below. ### Refactors folded in @@ -144,10 +154,11 @@ so "breaking change" does not apply. These buckets track *user-visible scale*. | v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all | | v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface | | v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session | -| v3.10.0 | "What's cooking" ingest; doc truth-up; deploy keys; revisit `mailingListSubscribe` | Ships one feature, corrects the map | -| v3.11.0 | Accessibility | Independent, device-verified | +| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map | +| v3.11.0 | ~~Mailing list subscribe/unsubscribe toggle~~ (shipped) | Ingest-surfaced; state via the `subscriptions` query (the `subscription` field is a trap) | +| v3.12.0 | Accessibility | Independent, device-verified | | v4.0.0 | Localization *with* translations | The only true re-presentation | -| — | Swift 6 language mode; cache reads | Internal; ride along, no tag | +| — | ~~Swift 6 language mode~~ (done); cache reads | Internal; ride along, no tag | 3.9.0 was cut this session, bundling the hub.sr.ht writes with the other features listed. That reorders the original plan: the "What's cooking" ingest @@ -276,7 +287,7 @@ exception earns the release its own line: the `forceRefresh` fix changes what pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships, not just a green suite. -### Ingest "What's cooking on SourceHut?" — v3.9.0 +### Ingest "What's cooking on SourceHut?" — v3.10.0 sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at <https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and @@ -294,19 +305,42 @@ flags two openings: - **hub.sr.ht gained a writable GraphQL API** for managing projects and project resources. ~~Rechecked and shipped~~: project create/edit, resource link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes" - below. `SCOPE.txt` still needs its "hub has no public API / no discovery" - claim corrected. `mailingListSubscribe` was *not* unblocked — that needs a - per-list subscription field lists.sr.ht still lacks. -- **git.sr.ht deploy keys are complete** (`createDeployKey` / `deleteDeployKey` - are in the SDL). Hutch never calls them. + below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since + been corrected. +- ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0). + `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired + into the repository actions menu, owner-only, alongside ACLs. Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps were captured. -Research does not ship, so v3.9.0 pairs the ingest with **deploy keys** — the one -self-contained feature it has already surfaced and that the SDL confirms exists. -That gives the release something a user can see. Everything else the ingest turns -up gets filed, not built, and hub.sr.ht gets its own bucket below. +Deploy keys — the one self-contained feature the ingest had already surfaced and +that the SDL confirmed — shipped in v3.10.0. + +**Ingest run (2026-08, Q1–Q2 posts + live schema introspection with a test +token):** everything else the posts flagged is already in Hutch — RIDs (used +throughout), pronouns and avatars (queried and displayed, avatar upload/delete +in Settings), hub project writes and discovery (shipped), deploy keys (shipped). +Planned-but-not-yet-shipped upstream, so nothing to build: anonymous API access +and "standardized / connections-spec" GraphQL (Q2 named both as future work). +The one *new* opening the introspection turned up is below. + +### Mailing list subscribe — done (v3.11.0) + +A subscribe / unsubscribe toggle now sits in the mailing-list detail toolbar +(`MailingListDetailView`, which backs both Lookup results and +`ProjectMailingListView`). It is hidden for lists you own and while state is +unknown. + +Live testing rewrote the plan. `MailingList.subscription` looked like the state +source but is a trap — it returns null even right after a successful +`mailingListSubscribe` that hands back a subscription id (see API traps). So +state comes from membership in the `subscriptions` query, which *is* reliable, +and the numeric `listID` the mutations require comes from `list(rid:){ id }`. +The mutations themselves (`mailingListSubscribe` / `mailingListUnsubscribe`, +`listID: Int!`) work as expected. Phase 1's "no discovery API" worry was moot: +a specific list is reachable via Lookup, a project's lists, or patchsets, and +that is all subscribing needs. ### hub.sr.ht writes — projects and discovery done @@ -325,11 +359,10 @@ confirmed without a token (introspection there is auth-gated), so the mutations degrade to a visible error rather than a crash if a field is not yet deployed. Verify on a signed-in device. -`mailingListSubscribe` stays out. `MailingList` still has no `subscription` -field, and public *project* discovery does not help find a mailing list the user -is *not* subscribed to — the reason Phase 1 declined it. Revisit only if -lists.sr.ht gains per-list subscription state. `SCOPE.txt` still needs its -"hub has no public API / no discovery" claim corrected. +`mailingListSubscribe` is now buildable: the ingest found `MailingList` gained a +`subscription` field, so per-list state is readable and the subscribe/unsubscribe +toggle can reflect it — see the "mailing list subscribe" bucket below. `SCOPE.txt` +has since had its "hub has no public API / no discovery" claim corrected. ### App Intent gaps — unscheduled @@ -351,21 +384,29 @@ rather than a bare `TODO`.) saved-search store for an intent to open. Add the intent once global saved-search persistence exists. -### Swift 6 language mode — no release of its own - -The project builds in Swift 5 language mode with -`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. Moving to Swift 6 is blocked on -concurrency diagnostics that are warnings today and errors there: - -- `APICacheTests` and `BundleUserAgentTests` call main-actor-isolated - initialisers and properties from nonisolated contexts, and `await` a few - expressions without marking them. Roughly 20 warnings, all in tests. -- Response types are implicitly `@MainActor` under the default isolation, so - their `Decodable` conformances are too. Decoding one from a nonisolated - context — an `async let` over a raw `client.execute`, say — warns now and - fails then. The pattern that avoids it is `async let` over `@MainActor` - methods, as in `HomeViewModel.loadDashboard` and - `NotificationPreferencesViewModel.load`. +### Swift 6 language mode — done + +`SWIFT_VERSION` is now `6.0` (keeping `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`). +The migration was mostly mechanical, in a few buckets: + +- **Models are `nonisolated`.** Under MainActor-default, every value-type model + was implicitly `@MainActor`; the pure data types in `Models/` (and utility + extensions like `Date.relativeDescription`, `DateFormatter+SRHT`, `SRHTWebURL`) + are now `nonisolated`, so the nonisolated networking layer can use them. +- **App Intents statics.** `AppIntent`/`AppEntity`/`AppEnum` `static var`s were + "global shared mutable state"; the stored ones are now `static let`. +- **Sendable dictionaries.** `nil as String? as Any` in `[String: any Sendable]` + GraphQL variable dicts became `nil as String? as any Sendable`. +- **`UserDefaults`** gets a retroactive `@unchecked Sendable` (documented + thread-safe) since it threads through account sessions and stores. +- **WidgetKit** completion handlers are rebound `nonisolated(unsafe)` to cross + into their `Task {}`; the `@Observable` `TipStoreViewModel` task handle is + `@ObservationIgnored nonisolated(unsafe)` for its nonisolated `deinit`. +- **Tests** run on `@MainActor` (they exercise MainActor app code), with a few + constant fixtures marked `nonisolated` for use inside `@Sendable` stub + closures. + +Builds and the full suite are clean in Swift 6 mode with no behaviour change. ### Cache reads that bypass the client — no release of its own @@ -4,7 +4,11 @@ file) - Push notifications for builds and tickets (requires a backend relay server) - ref: https://git.sr.ht/~ccleberg/hutch-notify -- Explore / search (hub.sr.ht) (no public discovery API) +- ~~Explore / search (hub.sr.ht) (no public discovery API)~~ — **stale**. + hub.sr.ht added a public `projects` query ("List all public projects"), and + Hutch ships it as project discovery (#12, `DiscoverProjectsView`). Left struck + through as more evidence for the ingestion task in ROADMAP.txt: this entry ruled + out a feature the API had come to support. - ~~Pronouns on profile (not in GraphQL schema)~~ — **stale**. sr.ht added pronouns (see the Q1 2026 "What's cooking"), and Hutch already queries them in `AppState` and shows them in `UserProfileView`. Left here struck through as @@ -27,9 +31,11 @@ unaffected because it filters on `ev.ticket_id`, which is why ticket timelines work. Nothing a client can do fixes this; revisit only if sr.ht changes the resolver. -- Subscribe to a mailing list (`mailingListSubscribe` exists, but `MailingList` - has no `subscription` field and sr.ht has no discovery API, so there is no way - to find a list you are not already subscribed to — see hub.sr.ht above) +- ~~Subscribe to a mailing list~~ — **shipped** (v3.11.0). A subscribe/unsubscribe + toggle on the mailing-list detail view. State comes from the `subscriptions` + query (`MailingList.subscription` is a trap — it never reports your state; see + ROADMAP.txt API traps), and a list is reachable to subscribe to via Lookup or a + project's lists — no discovery API needed. - Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch reviews patchsets but cannot send them) |
