diff options
33 files changed, 1165 insertions, 108 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 8aab8bb..6368778 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -13,12 +13,25 @@ 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B4B28C92F6704280045FA19 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8B4B28D02F6704280045FA19; + remoteInfo = Hutch; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 8B2F89612F69DEA900FC0253 /* TODO.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TODO.md; sourceTree = "<group>"; }; 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; }; 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; }; 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; }; 8B4B28D12F6704280045FA19 /* Hutch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hutch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; }; + 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -40,6 +53,11 @@ path = Hutch; sourceTree = "<group>"; }; + 8B8182B82F6B73F3000AE049 /* HutchTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = HutchTests; + sourceTree = "<group>"; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -50,17 +68,27 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8B8182B42F6B73F3000AE049 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 8B4B28C82F6704280045FA19 = { isa = PBXGroup; children = ( + 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */, + 8B8182C22F6B742B000AE049 /* HutchTests */, 8B2F89632F69DEB900FC0253 /* LICENSE */, 8B2F89642F69DEB900FC0253 /* README.md */, 8B2F89652F69DEB900FC0253 /* SECURITY.md */, 8B2F89612F69DEA900FC0253 /* TODO.md */, 8B4B28D32F6704280045FA19 /* Hutch */, + 8B8182B82F6B73F3000AE049 /* HutchTests */, 8B4B28D22F6704280045FA19 /* Products */, ); sourceTree = "<group>"; @@ -69,6 +97,7 @@ isa = PBXGroup; children = ( 8B4B28D12F6704280045FA19 /* Hutch.app */, + 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */, ); name = Products; sourceTree = "<group>"; @@ -98,6 +127,29 @@ productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; productType = "com.apple.product-type.application"; }; + 8B8182B62F6B73F3000AE049 /* HutchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B8182BF2F6B73F3000AE049 /* Build configuration list for PBXNativeTarget "HutchTests" */; + buildPhases = ( + 8B8182B32F6B73F3000AE049 /* Sources */, + 8B8182B42F6B73F3000AE049 /* Frameworks */, + 8B8182B52F6B73F3000AE049 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 8B8182B82F6B73F3000AE049 /* HutchTests */, + ); + name = HutchTests; + packageProductDependencies = ( + ); + productName = HutchTests; + productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -111,6 +163,10 @@ 8B4B28D02F6704280045FA19 = { CreatedOnToolsVersion = 26.3; }; + 8B8182B62F6B73F3000AE049 = { + CreatedOnToolsVersion = 26.3; + TestTargetID = 8B4B28D02F6704280045FA19; + }; }; }; buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */; @@ -128,6 +184,7 @@ projectRoot = ""; targets = ( 8B4B28D02F6704280045FA19 /* Hutch */, + 8B8182B62F6B73F3000AE049 /* HutchTests */, ); }; /* End PBXProject section */ @@ -144,6 +201,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8B8182B52F6B73F3000AE049 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -154,8 +218,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8B8182B32F6B73F3000AE049 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8B4B28D02F6704280045FA19 /* Hutch */; + targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 8B4B28DA2F6704290045FA19 /* Debug */ = { isa = XCBuildConfiguration; @@ -284,7 +363,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -301,7 +380,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -320,7 +399,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -337,7 +416,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -350,6 +429,48 @@ }; name = Release; }; + 8B8182BD2F6B73F3000AE049 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch"; + }; + name = Debug; + }; + 8B8182BE2F6B73F3000AE049 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -371,6 +492,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8B8182BF2F6B73F3000AE049 /* Build configuration list for PBXNativeTarget "HutchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B8182BD2F6B73F3000AE049 /* Debug */, + 8B8182BE2F6B73F3000AE049 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 8B4B28C92F6704280045FA19 /* Project object */; diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme new file mode 100644 index 0000000..4bb83be --- /dev/null +++ b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme @@ -0,0 +1,96 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "2630" + version = "1.7"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "8B4B28D02F6704280045FA19" + BuildableName = "Hutch.app" + BlueprintName = "Hutch" + ReferencedContainer = "container:Hutch.xcodeproj"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + <TestPlans> + <TestPlanReference + reference = "container:HutchTests" + default = "YES"> + </TestPlanReference> + </TestPlans> + <Testables> + <TestableReference + skipped = "NO" + parallelizable = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "8B8182B62F6B73F3000AE049" + BuildableName = "HutchTests.xctest" + BlueprintName = "HutchTests" + ReferencedContainer = "container:Hutch.xcodeproj"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "8B4B28D02F6704280045FA19" + BuildableName = "Hutch.app" + BlueprintName = "Hutch" + ReferencedContainer = "container:Hutch.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "8B4B28D02F6704280045FA19" + BuildableName = "Hutch.app" + BlueprintName = "Hutch" + ReferencedContainer = "container:Hutch.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme new file mode 100644 index 0000000..2cbf63c --- /dev/null +++ b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "2630" + version = "1.7"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + <TestPlans> + <TestPlanReference + reference = "container:HutchTests.xctestplan" + default = "YES"> + </TestPlanReference> + </TestPlans> + <Testables> + <TestableReference + skipped = "NO" + parallelizable = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "8B8182B62F6B73F3000AE049" + BuildableName = "HutchTests.xctest" + BlueprintName = "HutchTests" + ReferencedContainer = "container:Hutch.xcodeproj"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + <BuildableProductRunnable + runnableDebuggingMode = "0"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "8B4B28D02F6704280045FA19" + BuildableName = "Hutch.app" + BlueprintName = "Hutch" + ReferencedContainer = "container:Hutch.xcodeproj"> + </BuildableReference> + </BuildableProductRunnable> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "8B4B28D02F6704280045FA19" + BuildableName = "Hutch.app" + BlueprintName = "Hutch" + ReferencedContainer = "container:Hutch.xcodeproj"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift index 6694c35..b0c0e8d 100644 --- a/Hutch/Models/Builds.swift +++ b/Hutch/Models/Builds.swift @@ -34,10 +34,33 @@ enum TaskStatus: String, Codable, Sendable { /// A single task within a build job. struct BuildTask: Codable, Sendable, Identifiable { - var id: String { name } + private(set) var ordinal: Int? let name: String let status: TaskStatus let log: BuildLog? + + var id: String { + if let ordinal { + return "\(ordinal):\(name)" + } + return [name, log?.fullURL, status.rawValue] + .compactMap { $0 } + .joined(separator: "::") + } + + var logCacheKey: String { + id + } + + func withOrdinal(_ ordinal: Int) -> BuildTask { + var task = self + task.ordinal = ordinal + return task + } + + private enum CodingKeys: String, CodingKey { + case name, status, log + } } // MARK: - Job Summary (for list view) @@ -90,7 +113,7 @@ struct JobDetail: Codable, Sendable { let visibility: Visibility? let image: String? let manifest: String? - let tasks: [BuildTask] + var tasks: [BuildTask] let log: BuildLog? let owner: Entity } diff --git a/Hutch/Models/Git.swift b/Hutch/Models/Git.swift index 19ec31b..905822e 100644 --- a/Hutch/Models/Git.swift +++ b/Hutch/Models/Git.swift @@ -113,7 +113,7 @@ struct GitTree: Codable, Sendable { struct GitTextBlob: Codable, Sendable { let id: String? let shortId: String? - let text: String + let text: String? let size: Int? } @@ -134,14 +134,16 @@ struct GitTreeEntryPage: Codable, Sendable { extension GitObject: Codable { private enum CodingKeys: String, CodingKey { case type, id, shortId, entries, text, size, content + case typename = "__typename" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decodeIfPresent(String.self, forKey: .type) + let typename = try container.decodeIfPresent(String.self, forKey: .typename) - switch type { - case "TREE": + switch type ?? typename { + case "TREE", "Tree": let tree = GitTree( id: try container.decodeIfPresent(String.self, forKey: .id), shortId: try container.decodeIfPresent(String.self, forKey: .shortId), @@ -149,13 +151,12 @@ extension GitObject: Codable { ) self = .tree(tree) - case "BLOB": - // TextBlob has a "text" key; BinaryBlob does not - if container.contains(.text) { + case "BLOB", "TextBlob", "BinaryBlob": + if typename == "TextBlob" || container.contains(.text) { let blob = GitTextBlob( id: try container.decodeIfPresent(String.self, forKey: .id), shortId: try container.decodeIfPresent(String.self, forKey: .shortId), - text: try container.decode(String.self, forKey: .text), + text: try container.decodeIfPresent(String.self, forKey: .text), size: try container.decodeIfPresent(Int.self, forKey: .size) ) self = .textBlob(blob) @@ -184,12 +185,14 @@ extension GitObject: Codable { try container.encodeIfPresent(tree.entries, forKey: .entries) case .textBlob(let blob): try container.encode("BLOB", forKey: .type) + try container.encode("TextBlob", forKey: .typename) try container.encodeIfPresent(blob.id, forKey: .id) try container.encodeIfPresent(blob.shortId, forKey: .shortId) - try container.encode(blob.text, forKey: .text) + try container.encodeIfPresent(blob.text, forKey: .text) try container.encodeIfPresent(blob.size, forKey: .size) case .binaryBlob(let blob): try container.encode("BLOB", forKey: .type) + try container.encode("BinaryBlob", forKey: .typename) try container.encodeIfPresent(blob.id, forKey: .id) try container.encodeIfPresent(blob.shortId, forKey: .shortId) try container.encodeIfPresent(blob.size, forKey: .size) diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index a24fe24..6032cab 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -435,6 +435,9 @@ final class SRHTClient: Sendable { guard let token = _token.withLock({ $0 }), !token.isEmpty else { throw SRHTError.unauthorized } + guard Self.isTrustedAuthenticatedTextURL(url) else { + throw SRHTError.invalidAuthenticatedURL(url) + } var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -512,6 +515,17 @@ final class SRHTClient: Sendable { // MARK: - Data Helper +private extension SRHTClient { + static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool { + guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame, + let host = url.host?.lowercased() else { + return false + } + + return host.hasSuffix(".sr.ht") + } +} + private extension Data { mutating func append(_ string: String) { if let data = string.data(using: .utf8) { diff --git a/Hutch/Networking/SRHTError.swift b/Hutch/Networking/SRHTError.swift index f148c00..f2da408 100644 --- a/Hutch/Networking/SRHTError.swift +++ b/Hutch/Networking/SRHTError.swift @@ -6,6 +6,8 @@ enum SRHTError: LocalizedError, Sendable { case graphQLErrors([GraphQLError]) /// The HTTP response had a non-2xx status code. case httpError(Int) + /// The client refused to send credentials to an unexpected URL. + case invalidAuthenticatedURL(URL) /// The response data could not be decoded. case decodingError(any Error) /// A networking error from URLSession (timeout, DNS, connectivity, etc.). @@ -20,6 +22,8 @@ enum SRHTError: LocalizedError, Sendable { return "GraphQL error: \(messages)" case .httpError(let code): return "Server returned HTTP \(code)." + case .invalidAuthenticatedURL(let url): + return "Refused to authenticate request to unexpected URL: \(url.absoluteString)" case .decodingError(let error): return "Failed to decode response: \(error.localizedDescription)" case .networkError(let error): diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift index 977cd87..0479f76 100644 --- a/Hutch/Views/Builds/BuildDetailView.swift +++ b/Hutch/Views/Builds/BuildDetailView.swift @@ -312,13 +312,13 @@ private struct TaskLogSection: View { var body: some View { DisclosureGroup(isExpanded: $isExpanded) { - if viewModel.loadingTaskLogs.contains(task.name) { + if viewModel.loadingTaskLogs.contains(task.logCacheKey) { HStack { Spacer() ProgressView("Loading log…") Spacer() } - } else if let logText = viewModel.taskLogs[task.name] { + } else if let logText = viewModel.taskLogs[task.logCacheKey] { ScrollView(.horizontal, showsIndicators: false) { Text(logText) .font(.caption2.monospaced()) diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift index c14e47b..1768e47 100644 --- a/Hutch/Views/Builds/BuildDetailViewModel.swift +++ b/Hutch/Views/Builds/BuildDetailViewModel.swift @@ -104,7 +104,11 @@ final class BuildDetailViewModel { variables: ["id": jobId], responseType: JobDetailResponse.self ) - job = result.job + var loadedJob = result.job + loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in + task.withOrdinal(index) + } + job = loadedJob } catch { self.error = error.localizedDescription } @@ -113,19 +117,20 @@ final class BuildDetailViewModel { } func loadTaskLog(task: BuildTask) async { + let cacheKey = task.logCacheKey guard let log = task.log, let logURL = URL(string: log.fullURL), - !loadingTaskLogs.contains(task.name), - taskLogs[task.name] == nil else { return } - loadingTaskLogs.insert(task.name) + !loadingTaskLogs.contains(cacheKey), + taskLogs[cacheKey] == nil else { return } + loadingTaskLogs.insert(cacheKey) do { - taskLogs[task.name] = try await client.fetchText(url: logURL) + taskLogs[cacheKey] = try await client.fetchText(url: logURL) } catch { self.error = error.localizedDescription } - loadingTaskLogs.remove(task.name) + loadingTaskLogs.remove(cacheKey) } func cancelJob() async { diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift index 3750adb..45fe5b6 100644 --- a/Hutch/Views/Repositories/FileTreeView.swift +++ b/Hutch/Views/Repositories/FileTreeView.swift @@ -235,7 +235,7 @@ private struct FileTreeContentView: View { GeometryReader { geometry in ScrollView([.vertical, .horizontal]) { - Text(blob.text) + Text(blob.text ?? "") .font(.system(.body, design: .monospaced)) .multilineTextAlignment(.leading) .fixedSize(horizontal: true, vertical: false) diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift index f270779..a9c86b4 100644 --- a/Hutch/Views/Repositories/FileTreeViewModel.swift +++ b/Hutch/Views/Repositories/FileTreeViewModel.swift @@ -96,6 +96,7 @@ final class FileTreeViewModel { mode object { type + __typename id shortId ... on Tree { @@ -110,12 +111,10 @@ final class FileTreeViewModel { } } ... on TextBlob { - text size } ... on BinaryBlob { size - content } } } @@ -142,6 +141,7 @@ final class FileTreeViewModel { mode object { type + __typename id shortId ... on Tree { @@ -156,12 +156,10 @@ final class FileTreeViewModel { } } ... on TextBlob { - text size } ... on BinaryBlob { size - content } } } @@ -187,6 +185,7 @@ final class FileTreeViewModel { mode object { type + __typename id shortId ... on Tree { @@ -201,12 +200,10 @@ final class FileTreeViewModel { } } ... on TextBlob { - text size } ... on BinaryBlob { size - content } } } @@ -321,16 +318,23 @@ final class FileTreeViewModel { // Otherwise fetch the subtree (handles pagination) await loadSubtree(name: entry.name, treeId: objectSHA) - case .textBlob: - viewingEntry = entry - viewingObject = object + case .textBlob(let blob): + if blob.text != nil { + viewingEntry = entry + viewingObject = object + } else if let blobId = blob.id { + await loadBlob(entry: entry, blobId: blobId) + } case .binaryBlob(let blob): - if blob.content != nil || blob.size != nil { + if blob.content != nil { viewingEntry = entry viewingObject = object } else if let blobId = blob.id { await loadBlob(entry: entry, blobId: blobId) + } else { + viewingEntry = entry + viewingObject = object } case .unknown: diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift index b53885e..576adfc 100644 --- a/Hutch/Views/Repositories/ReadmeView.swift +++ b/Hutch/Views/Repositories/ReadmeView.swift @@ -387,14 +387,20 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St let alt = nsText.substring(with: match.range(at: 1)) let source = nsText.substring(with: match.range(at: 2)) let resolvedSource = imageURLResolver?(source) ?? source - return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"# + guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { + return escapeHTML(alt) + } + return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"# } // Links: [text](url) - result = result.replacingOccurrences( - of: #"\[([^\]]+)\]\(([^)]+)\)"#, - with: #"<a href="$2">$1</a>"#, - options: .regularExpression - ) + result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in + let label = nsText.substring(with: match.range(at: 1)) + let rawURL = nsText.substring(with: match.range(at: 2)) + guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else { + return label + } + return #"<a href="\#(sanitizedURL)">\#(label)</a>"# + } // Bold: **text** result = result.replacingOccurrences( of: #"\*\*(.+?)\*\*"#, @@ -676,7 +682,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St ) { return imageHTML } - return #"<a href="\#(url)">\#(label)</a>"# + guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { + return label + } + return #"<a href="\#(sanitizedURL)">\#(label)</a>"# } result = protectMatches( in: result, @@ -691,7 +700,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St ) { return imageHTML } - return #"<a href="\#(url)">\#(url)</a>"# + guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { + return url + } + return #"<a href="\#(sanitizedURL)">\#(url)</a>"# } result = protectMatches( in: result, @@ -742,6 +754,57 @@ nonisolated private func escapeHTMLAttribute(_ text: String) -> String { escapeHTML(text).replacingOccurrences(of: "'", with: "'") } +nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { + sanitizeReadmeURLString( + rawURL, + allowedSchemes: ["http", "https", "mailto"], + allowsFragmentOnly: true + ) +} + +nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { + sanitizeReadmeURLString( + rawURL, + allowedSchemes: ["http", "https"], + allowsFragmentOnly: false + ) +} + +nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { + return false + } + if scheme == "about" || scheme == "data" { + return true + } + guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else { + return false + } + return sanitizedURL == escapeHTMLAttribute(url.absoluteString) +} + +nonisolated private func sanitizeReadmeURLString( + _ rawURL: String, + allowedSchemes: Set<String>, + allowsFragmentOnly: Bool +) -> String? { + let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { return nil } + + if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { + return escapeHTMLAttribute(trimmedURL) + } + + guard let components = URLComponents(string: trimmedURL), + let scheme = components.scheme?.lowercased(), + allowedSchemes.contains(scheme), + let sanitizedURL = components.url?.absoluteString else { + return nil + } + + return escapeHTMLAttribute(sanitizedURL) +} + nonisolated private func isOrgTableLine(_ line: String) -> Bool { line.hasPrefix("|") && line.hasSuffix("|") } @@ -899,6 +962,7 @@ struct HTMLWebView: View { let html: String let colorScheme: ColorScheme var style: HTMLWebViewStyle = .readme + @Environment(\.openURL) private var openURL @State private var contentHeight: CGFloat = 1 @State private var loadError: String? @State private var reloadToken = 0 @@ -921,6 +985,7 @@ struct HTMLWebView: View { html: html, colorScheme: colorScheme, style: style, + openURL: openURL, dynamicHeight: $contentHeight, loadError: $loadError, reloadToken: reloadToken @@ -956,6 +1021,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { let html: String let colorScheme: ColorScheme let style: HTMLWebViewStyle + let openURL: OpenURLAction @Binding var dynamicHeight: CGFloat @Binding var loadError: String? let reloadToken: Int @@ -966,12 +1032,13 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() - config.defaultWebpagePreferences.allowsContentJavaScript = true + config.defaultWebpagePreferences.allowsContentJavaScript = false config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore let webView = WKWebView(frame: .zero, configuration: config) webView.isOpaque = false webView.backgroundColor = .clear webView.clipsToBounds = false + webView.allowsLinkPreview = false webView.scrollView.isScrollEnabled = false webView.scrollView.contentInsetAdjustmentBehavior = .never webView.scrollView.clipsToBounds = false @@ -1088,6 +1155,31 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc handleLoadFailure(error) } + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void + ) { + guard let requestURL = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + if navigationAction.navigationType == .linkActivated { + if isAllowedReadmeNavigationURL(requestURL) { + parent.openURL(requestURL) + } + decisionHandler(.cancel) + return + } + + if isAllowedReadmeNavigationURL(requestURL) { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + } + } + private func handleLoadFailure(_ error: Error) { let nsError = error as NSError guard nsError.code != NSURLErrorCancelled else { return } @@ -1097,28 +1189,15 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc } private func updateHeight(for webView: WKWebView) { - let script = """ - Math.max( - document.body.scrollHeight, - document.body.offsetHeight, - document.documentElement.scrollHeight, - document.documentElement.offsetHeight, - Math.ceil(document.body.getBoundingClientRect().height), - Math.ceil(document.documentElement.getBoundingClientRect().height) - ) - """ - - webView.evaluateJavaScript(script) { [weak self] result, _ in - guard let value = result as? Double, value > 0 else { return } - let height = ceil(value) + 4 - DispatchQueue.main.async { - guard let self else { return } - if let html = self.lastHTML { - Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) - } - if abs(self.parent.dynamicHeight - height) > 0.5 { - self.parent.dynamicHeight = height - } + webView.layoutIfNeeded() + let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4 + guard height > 0 else { return } + DispatchQueue.main.async { + if let html = self.lastHTML { + Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) + } + if abs(self.parent.dynamicHeight - height) > 0.5 { + self.parent.dynamicHeight = height } } } diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift index bba0108..6b63203 100644 --- a/Hutch/Views/Repositories/RepositoryListViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -38,10 +38,13 @@ final class RepositoryListViewModel { private(set) var hasMore = false private(set) var isSearching = false private(set) var isCreatingRepository = false + private(set) var hasLoadedSearchIndex = false + private var searchIndex: [RepositorySummary] = [] private let client: SRHTClient private static let gitCacheKey = "git.repositories" private static let hgCacheKey = "hg.repositories" + private static let minimumRemoteSearchLength = 3 init(client: SRHTClient) { self.client = client @@ -147,22 +150,23 @@ final class RepositoryListViewModel { do { var filteredResults: [RepositorySummary] - + if isSearch { - // For search queries, fetch all repositories from both services. - filteredResults = try await fetchAllRepositories() - - // Perform client-side filtering - let lowercasedQuery = query.lowercased() - filteredResults = filteredResults.filter { repo in - repo.name.lowercased().contains(lowercasedQuery) || - repo.description?.lowercased().contains(lowercasedQuery) ?? false + if hasLoadedSearchIndex || repositories.isEmpty == false { + filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query) + } else if Self.shouldRefreshSearchIndex(for: query) { + let repositories = try await fetchAllRepositories(useCache: true) + updateSearchIndex(with: repositories) + filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query) + } else { + filteredResults = [] } } else { let repositories = try await fetchAllRepositories(useCache: true) + updateSearchIndex(with: repositories) filteredResults = repositories } - + repositories = filteredResults.sorted(by: repositorySortOrder) } catch { // Only show error if we have no cached data to fall back on @@ -240,6 +244,7 @@ final class RepositoryListViewModel { repository = result.createRepository.repositorySummary(service: .hg) } repositories.insert(repository, at: 0) + insertIntoSearchIndex(repository) return repository } catch { self.error = repositoryCreationErrorMessage(for: error) @@ -371,6 +376,10 @@ final class RepositoryListViewModel { let branch: String } + private var repositoriesForSearchIndex: [RepositorySummary] { + searchIndex + } + private func fetchPage( service: SRHTService, cursor: String?, @@ -502,7 +511,9 @@ final class RepositoryListViewModel { return [] } if !cachedRepositories.isEmpty { - repositories = cachedRepositories.sorted(by: repositorySortOrder) + let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) + repositories = sortedRepositories + updateSearchIndex(with: sortedRepositories) } } @@ -545,4 +556,37 @@ final class RepositoryListViewModel { } return lhs.updated > rhs.updated } + + private func updateSearchIndex(with repositories: [RepositorySummary]) { + searchIndex = repositories.sorted(by: repositorySortOrder) + hasLoadedSearchIndex = !searchIndex.isEmpty + } + + private func insertIntoSearchIndex(_ repository: RepositorySummary) { + let updatedRepositories = (repositoriesForSearchIndex + [repository]) + .uniqued(on: \.id) + .sorted(by: repositorySortOrder) + updateSearchIndex(with: updatedRepositories) + } + + static func shouldRefreshSearchIndex(for query: String) -> Bool { + query.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumRemoteSearchLength + } + + static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] { + let lowercasedQuery = query.lowercased() + return repositories.filter { repo in + repo.name.lowercased().contains(lowercasedQuery) || + repo.description?.lowercased().contains(lowercasedQuery) ?? false + } + } +} + +private extension Array { + func uniqued<ID: Hashable>(on keyPath: KeyPath<Element, ID>) -> [Element] { + var seenIDs: Set<ID> = [] + return filter { element in + seenIDs.insert(element[keyPath: keyPath]).inserted + } + } } diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift index d6f3300..71d6ab6 100644 --- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift +++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift @@ -6,12 +6,20 @@ private struct UpdateRepoResponse: Decodable, Sendable { let updateRepository: UpdatedRepo } +private struct UpdateRepoInfoResponse: Decodable, Sendable { + let updateRepository: UpdatedRepoInfo +} + private struct UpdatedRepo: Decodable, Sendable { - let id: Int - let rid: String + let id: Int? + let rid: String? let name: String let description: String? - let visibility: Visibility + let visibility: Visibility? +} + +private struct UpdatedRepoInfo: Decodable, Sendable { + let id: Int } private struct ACLResponse: Decodable, Sendable { @@ -71,6 +79,7 @@ final class RepositorySettingsViewModel { var editedDescription: String var editedVisibility: Visibility var editedHead: String + private let originalEditedHead: String var isSavingInfo = false // MARK: - Rename fields @@ -116,11 +125,14 @@ final class RepositorySettingsViewModel { self.branches = branches // Extract branch name from HEAD reference + let initialEditedHead: String if let head = repository.head?.name { - self.editedHead = head.replacingOccurrences(of: "refs/heads/", with: "") + initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "") } else { - self.editedHead = "main" + initialEditedHead = "main" } + self.editedHead = initialEditedHead + self.originalEditedHead = initialEditedHead } // MARK: - Update Repository Info @@ -133,22 +145,32 @@ final class RepositorySettingsViewModel { } """ + private static let updateRepoInfoMutation = """ + mutation updateRepository($id: Int!, $input: RepoInput!) { + updateRepository(id: $id, input: $input) { + id + } + } + """ + func saveInfo() async { isSavingInfo = true defer { isSavingInfo = false } error = nil do { - let input: [String: any Sendable] = [ + var input: [String: any Sendable] = [ "description": editedDescription, - "visibility": editedVisibility.rawValue, - "HEAD": editedHead + "visibility": editedVisibility.rawValue ] + if let headReference = selectedHeadReferenceForSave() { + input["HEAD"] = headReference + } _ = try await client.execute( service: service, - query: Self.updateRepoMutation, + query: Self.updateRepoInfoMutation, variables: ["id": repositoryId, "input": input], - responseType: UpdateRepoResponse.self + responseType: UpdateRepoInfoResponse.self ) } catch { self.error = error.localizedDescription @@ -238,8 +260,9 @@ final class RepositorySettingsViewModel { } func addACL() async { - let entity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines) - guard !entity.isEmpty else { return } + let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawEntity.isEmpty else { return } + let entity = Self.gitCanonicalEntity(from: rawEntity) isAddingACL = true defer { isAddingACL = false } error = nil @@ -267,6 +290,33 @@ final class RepositorySettingsViewModel { } } + static func gitCanonicalEntity(from input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + return "~\(username)" + } + + static func gitHeadReference(from input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + if trimmed.hasPrefix("refs/") { + return trimmed + } + return "refs/heads/\(trimmed)" + } + + func selectedHeadReferenceForSave() -> String? { + let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedEditedHead != originalEditedHead else { + return nil + } + + return branches.first { + $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead + }?.name + } + func deleteACL(_ entry: ACLEntry) async { isDeletingACL = true defer { isDeletingACL = false } diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift index 5bbded8..9b7d41f 100644 --- a/Hutch/Views/Settings/SettingsView.swift +++ b/Hutch/Views/Settings/SettingsView.swift @@ -1,6 +1,10 @@ import PhotosUI import SwiftUI +private let settingsBioMarkdownOptions = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace +) + struct SettingsView: View { @Environment(AppState.self) private var appState @Environment(\.colorScheme) private var colorScheme @@ -163,13 +167,7 @@ struct SettingsView: View { Text("Bio") .font(.caption) .foregroundStyle(.secondary) - RenderedMarkupContentView( - content: .markdown(bio), - readmePath: nil, - colorScheme: colorScheme, - ownerCanonicalName: "", - repositoryName: "" - ) + SettingsBioView(markdown: bio) } } @@ -424,6 +422,27 @@ struct SettingsView: View { } } +private struct SettingsBioView: View { + let markdown: String + + var body: some View { + Text(settingsBioAttributedString(markdown)) + .frame(maxWidth: .infinity, alignment: .leading) + .tint(.accentColor) + .textSelection(.enabled) + } +} + +func settingsBioAttributedString(_ markdown: String) -> AttributedString { + guard let attributed = try? AttributedString( + markdown: markdown, + options: settingsBioMarkdownOptions + ) else { + return AttributedString(markdown) + } + return attributed +} + // MARK: - Edit Profile Sheet private struct EditProfileSheet: View { @@ -695,7 +714,7 @@ private struct AboutView: View { Link(destination: URL(string: "https://man.sr.ht")!) { SwiftUI.Label("SourceHut Manuals", systemImage: "book") } - Link(destination: URL(string: "https://git.sr.ht/~ccleberg/Hutch")!) { + Link(destination: URL(string: "https://sr.ht/~ccleberg/Hutch")!) { SwiftUI.Label("Project Repository", systemImage: "folder") } } diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift index 204afbf..1cc5426 100644 --- a/Hutch/Views/Settings/SettingsViewModel.swift +++ b/Hutch/Views/Settings/SettingsViewModel.swift @@ -24,7 +24,7 @@ private struct CreateSSHKeyResponse: Decodable, Sendable { } private struct DeleteSSHKeyResponse: Decodable, Sendable { - let deleteSSHKey: DeleteResult + let deleteSSHKey: DeleteResult? } private struct CreatePGPKeyResponse: Decodable, Sendable { @@ -32,7 +32,7 @@ private struct CreatePGPKeyResponse: Decodable, Sendable { } private struct DeletePGPKeyResponse: Decodable, Sendable { - let deletePGPKey: DeleteResult + let deletePGPKey: DeleteResult? } private struct DeleteResult: Decodable, Sendable { diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index 6b2be4f..f553bd8 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -71,10 +71,7 @@ struct TicketDetailView: View { if ticket.status == .resolved { Button { Task { - await viewModel.updateStatus( - status: .reported, - resolution: .unresolved - ) + await viewModel.updateStatus(status: .reported) } } label: { SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward") diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift index 70eaf0a..90e33aa 100644 --- a/Hutch/Views/Tickets/TicketDetailViewModel.swift +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -130,6 +130,19 @@ final class TicketDetailViewModel { return lhs.created < rhs.created } + static func statusUpdateInput( + status: TicketStatus, + resolution: TicketResolution? + ) -> [String: any Sendable] { + var input: [String: any Sendable] = [ + "status": status.rawValue + ] + if status == .resolved, let resolution { + input["resolution"] = resolution.rawValue + } + return input + } + init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) { self.ownerUsername = ownerUsername self.trackerName = trackerName @@ -360,16 +373,13 @@ final class TicketDetailViewModel { // MARK: - Ticket Actions - func updateStatus(status: TicketStatus, resolution: TicketResolution) async { + func updateStatus(status: TicketStatus, resolution: TicketResolution? = nil) async { guard !isPerformingAction else { return } isPerformingAction = true error = nil do { - let input: [String: any Sendable] = [ - "status": status.rawValue, - "resolution": resolution.rawValue - ] + let input = Self.statusUpdateInput(status: status, resolution: resolution) _ = try await client.execute( service: .todo, query: Self.updateStatusMutation, diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift index d16246b..ee253ec 100644 --- a/Hutch/Views/Tickets/TrackerListView.swift +++ b/Hutch/Views/Tickets/TrackerListView.swift @@ -124,10 +124,17 @@ private struct CreateTrackerSheet: View { let onCreated: (TrackerSummary) -> Void @Environment(\.dismiss) private var dismiss + @Bindable var viewModelBindable: TrackerListViewModel @State private var name = "" @State private var description = "" @State private var visibility: Visibility = .public + init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) { + self.viewModel = viewModel + self._viewModelBindable = Bindable(viewModel) + self.onCreated = onCreated + } + var body: some View { NavigationStack { Form { @@ -143,12 +150,30 @@ private struct CreateTrackerSheet: View { Text("Private").tag(Visibility.private) } } + + if let error = viewModel.error { + Section { + Label { + Text(error) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + .foregroundStyle(.red) + } + } } .navigationTitle("New Tracker") .navigationBarTitleDisplayMode(.inline) + .onDisappear { + viewModelBindable.error = nil + } .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } + Button("Cancel") { + viewModelBindable.error = nil + dismiss() + } } ToolbarItem(placement: .confirmationAction) { Button { diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift index 9704071..978a610 100644 --- a/Hutch/Views/Tickets/TrackerListViewModel.swift +++ b/Hutch/Views/Tickets/TrackerListViewModel.swift @@ -139,7 +139,7 @@ final class TrackerListViewModel { trackers.insert(tracker, at: 0) return tracker } catch { - self.error = "Couldn’t create the tracker. \(error.localizedDescription)" + self.error = trackerCreationErrorMessage(for: error) return nil } } @@ -163,4 +163,21 @@ final class TrackerListViewModel { private struct CreateTrackerResponse: Decodable, Sendable { let createTracker: TrackerSummary } + + private func trackerCreationErrorMessage(for error: Error) -> String { + let message: String + + if let srhtError = error as? SRHTError { + switch srhtError { + case .graphQLErrors(let errors): + message = errors.map(\.message).joined(separator: "\n") + default: + message = srhtError.localizedDescription + } + } else { + message = error.localizedDescription + } + + return "Couldn’t create the tracker. \(message)" + } } diff --git a/HutchTests.xctestplan b/HutchTests.xctestplan new file mode 100644 index 0000000..8af3d1b --- /dev/null +++ b/HutchTests.xctestplan @@ -0,0 +1,30 @@ +{ + "configurations" : [ + { + "id" : "06A589A5-80C7-41FE-911F-03518252CBDD", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "performanceAntipatternCheckerEnabled" : true, + "targetForVariableExpansion" : { + "containerPath" : "container:Hutch.xcodeproj", + "identifier" : "8B4B28D02F6704280045FA19", + "name" : "Hutch" + } + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:Hutch.xcodeproj", + "identifier" : "8B8182B62F6B73F3000AE049", + "name" : "HutchTests" + } + } + ], + "version" : 1 +} diff --git a/HutchTests/BuildTaskTests.swift b/HutchTests/BuildTaskTests.swift new file mode 100644 index 0000000..2e2a1e0 --- /dev/null +++ b/HutchTests/BuildTaskTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import Hutch + +struct BuildTaskTests { + + @Test + @MainActor + func duplicateTaskNamesProduceDistinctIDsAndLogCacheKeys() { + let firstTask = BuildTask( + name: "test", + status: .failed, + log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/1") + ).withOrdinal(0) + + let secondTask = BuildTask( + name: "test", + status: .failed, + log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/2") + ).withOrdinal(1) + + #expect(firstTask.id == "0:test") + #expect(secondTask.id == "1:test") + #expect(firstTask.id != secondTask.id) + #expect(firstTask.logCacheKey != secondTask.logCacheKey) + } +} diff --git a/HutchTests/GitObjectTests.swift b/HutchTests/GitObjectTests.swift new file mode 100644 index 0000000..547c6f3 --- /dev/null +++ b/HutchTests/GitObjectTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import Hutch + +struct GitObjectTests { + + @Test + @MainActor + func decodesMetadataOnlyTextBlobUsingTypename() throws { + let data = Data(#"{"type":"BLOB","__typename":"TextBlob","id":"blob123","shortId":"blob123","size":42}"#.utf8) + + let blob = try JSONDecoder().decode(GitObject.self, from: data) + + guard case .textBlob(let textBlob) = blob else { + Issue.record("Expected metadata-only blob to decode as text blob.") + return + } + + #expect(textBlob.id == "blob123") + #expect(textBlob.size == 42) + #expect(textBlob.text == nil) + } + + @Test + @MainActor + func decodesMetadataOnlyBinaryBlobUsingTypename() throws { + let data = Data(#"{"type":"BLOB","__typename":"BinaryBlob","id":"blob456","shortId":"blob456","size":64}"#.utf8) + + let blob = try JSONDecoder().decode(GitObject.self, from: data) + + guard case .binaryBlob(let binaryBlob) = blob else { + Issue.record("Expected metadata-only blob to decode as binary blob.") + return + } + + #expect(binaryBlob.id == "blob456") + #expect(binaryBlob.size == 64) + #expect(binaryBlob.content == nil) + } +} diff --git a/HutchTests/HutchTests.swift b/HutchTests/HutchTests.swift new file mode 100644 index 0000000..c681efa --- /dev/null +++ b/HutchTests/HutchTests.swift @@ -0,0 +1,16 @@ +// +// HutchTests.swift +// HutchTests +// +// Created by cmc on 2026-03-18. +// + +import Testing + +struct HutchTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift new file mode 100644 index 0000000..9c3c019 --- /dev/null +++ b/HutchTests/ReadmeViewTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import Hutch + +struct ReadmeViewTests { + + @Test + func sanitizedReadmeLinkURLStringRejectsUnexpectedSchemes() { + #expect(sanitizedReadmeLinkURLString("javascript:alert(1)") == nil) + #expect(sanitizedReadmeLinkURLString("file:///tmp/readme") == nil) + #expect(sanitizedReadmeLinkURLString("data:text/html;base64,SGVsbG8=") == nil) + } + + @Test + func processInlineDropsUnsafeMarkdownLinks() { + let rendered = processInline("[click me](javascript:alert)") + + #expect(rendered == "click me") + #expect(!rendered.contains("href=")) + #expect(!rendered.contains("javascript:")) + } + + @Test + func sanitizedReadmeLinkURLStringAllowsExpectedDestinations() { + #expect(sanitizedReadmeLinkURLString("https://example.com/docs?q=1") == "https://example.com/docs?q=1") + #expect(sanitizedReadmeLinkURLString("mailto:[email protected]") == "mailto:[email protected]") + #expect(sanitizedReadmeLinkURLString("#readme") == "#readme") + } +} diff --git a/HutchTests/RepositoryListViewModelTests.swift b/HutchTests/RepositoryListViewModelTests.swift new file mode 100644 index 0000000..d6e76bf --- /dev/null +++ b/HutchTests/RepositoryListViewModelTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import Hutch + +struct RepositoryListViewModelTests { + + @Test + @MainActor + func searchIndexRefreshRequiresMinimumQueryLength() { + #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: "ab") == false) + #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: "abc") == true) + #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: " abc ") == true) + } + + @Test + @MainActor + func filterRepositoriesMatchesNameAndDescriptionLocally() { + let repositories = [ + makeRepository(id: 1, service: .git, name: "Hutch", description: "SourceHut client"), + makeRepository(id: 2, service: .hg, name: "Mail", description: "patch queue"), + makeRepository(id: 3, service: .git, name: "Tree", description: nil) + ] + + let nameMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "hut") + let descriptionMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "patch") + + #expect(nameMatches.map(\.id) == [1]) + #expect(descriptionMatches.map(\.id) == [2]) + } + + @MainActor + private func makeRepository( + id: Int, + service: SRHTService, + name: String, + description: String? + ) -> RepositorySummary { + RepositorySummary( + id: id, + rid: "rid-\(id)", + service: service, + name: name, + description: description, + visibility: .public, + updated: Date(timeIntervalSince1970: TimeInterval(id)), + owner: Entity(canonicalName: "~owner"), + head: Reference(name: "main", target: nil) + ) + } +} diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift new file mode 100644 index 0000000..a2c88ce --- /dev/null +++ b/HutchTests/RepositorySettingsViewModelTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing +@testable import Hutch + +private struct UpdateRepositoryInfoEnvelope: Decodable { + let updateRepository: UpdateRepositoryInfoPayload +} + +private struct UpdateRepositoryInfoPayload: Decodable { + let id: Int +} + +struct RepositorySettingsViewModelTests { + + @Test + func saveInfoResponseDecodesMinimalRepositoryPayload() throws { + let json = """ + { + "data": { + "updateRepository": { + "id": 42 + } + } + } + """ + + let decoded = try JSONDecoder().decode( + GraphQLResponse<UpdateRepositoryInfoEnvelope>.self, + from: Data(json.utf8) + ) + + #expect(decoded.data?.updateRepository.id == 42) + } + + @Test + func gitCanonicalEntityAddsMissingTilde() { + #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "alice") == "~alice") + #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "~alice") == "~alice") + #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: " alice ") == "~alice") + } + + @Test + func gitHeadReferenceUsesFullBranchRef() { + #expect(RepositorySettingsViewModel.gitHeadReference(from: "main") == "refs/heads/main") + #expect(RepositorySettingsViewModel.gitHeadReference(from: " refs/heads/dev ") == "refs/heads/dev") + } + + @Test + @MainActor + func unchangedHeadIsOmittedFromSaveInput() { + let viewModel = RepositorySettingsViewModel( + repository: makeRepository(headName: "refs/heads/main"), + branches: [Reference(name: "refs/heads/main", target: nil)], + client: SRHTClient(token: "test-token") + ) + + #expect(viewModel.selectedHeadReferenceForSave() == nil) + } + + @Test + @MainActor + func changedHeadUsesSelectedBranchReference() { + let viewModel = RepositorySettingsViewModel( + repository: makeRepository(headName: "refs/heads/main"), + branches: [ + Reference(name: "refs/heads/main", target: nil), + Reference(name: "refs/heads/dev", target: nil) + ], + client: SRHTClient(token: "test-token") + ) + viewModel.editedHead = "dev" + + #expect(viewModel.selectedHeadReferenceForSave() == "refs/heads/dev") + } + + @Test + @MainActor + func bareRepositoryOmitsHeadWhenNoBranchExists() { + let viewModel = RepositorySettingsViewModel( + repository: makeRepository(headName: nil), + branches: [], + client: SRHTClient(token: "test-token") + ) + + #expect(viewModel.selectedHeadReferenceForSave() == nil) + viewModel.editedHead = "main" + #expect(viewModel.selectedHeadReferenceForSave() == nil) + } + + @MainActor + private func makeRepository(headName: String?) -> RepositorySummary { + RepositorySummary( + id: 1, + rid: "rid-1", + service: .git, + name: "repo", + description: "desc", + visibility: .public, + updated: .now, + owner: Entity(canonicalName: "~owner"), + head: headName.map { Reference(name: $0, target: nil) } + ) + } +} diff --git a/HutchTests/SRHTClientTests.swift b/HutchTests/SRHTClientTests.swift new file mode 100644 index 0000000..2ce2529 --- /dev/null +++ b/HutchTests/SRHTClientTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import Hutch + +struct SRHTClientTests { + + @Test + @MainActor + func fetchTextRejectsUnexpectedAuthenticatedURL() async throws { + let client = SRHTClient(token: "test-token") + let url = try #require(URL(string: "https://example.com/build-log")) + + do { + _ = try await client.fetchText(url: url) + Issue.record("Expected fetchText(url:) to reject non-sr.ht URLs.") + } catch let error as SRHTError { + guard case .invalidAuthenticatedURL(let rejectedURL) = error else { + Issue.record("Expected invalidAuthenticatedURL error, got \(error).") + return + } + + #expect(rejectedURL == url) + } catch { + Issue.record("Expected SRHTError.invalidAuthenticatedURL, got \(error).") + } + } +} diff --git a/HutchTests/SettingsViewModelTests.swift b/HutchTests/SettingsViewModelTests.swift new file mode 100644 index 0000000..fc18162 --- /dev/null +++ b/HutchTests/SettingsViewModelTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import Hutch + +private struct DeletePGPKeyEnvelope: Decodable { + let deletePGPKey: DeleteResultPayload? +} + +private struct DeleteResultPayload: Decodable { + let id: Int? +} + +struct SettingsViewModelTests { + + @Test + @MainActor + func deletePGPKeyResponseDecodesNullPayloadWithGraphQLErrors() throws { + let json = """ + { + "errors": [ + { + "message": "PGP key ID 13629 is set as the user's preferred PGP key - it must be unset before removing the key" + } + ], + "data": { + "deletePGPKey": null + } + } + """ + + let decoded = try JSONDecoder().decode( + GraphQLResponse<DeletePGPKeyEnvelope>.self, + from: Data(json.utf8) + ) + + #expect(decoded.data?.deletePGPKey == nil) + #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true) + } +} diff --git a/HutchTests/SettingsViewTests.swift b/HutchTests/SettingsViewTests.swift new file mode 100644 index 0000000..950c3dc --- /dev/null +++ b/HutchTests/SettingsViewTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import Hutch + +struct SettingsViewTests { + + @Test + @MainActor + func settingsBioAttributedStringPreservesInlineMarkdown() { + let attributed = settingsBioAttributedString("Hello **world** and [link](https://example.com)") + + #expect(String(attributed.characters).contains("Hello world and link")) + } + + @Test + @MainActor + func settingsBioAttributedStringFallsBackForInvalidMarkdown() { + let attributed = settingsBioAttributedString("[broken") + + #expect(String(attributed.characters) == "[broken") + } +} diff --git a/HutchTests/TicketDetailViewModelTests.swift b/HutchTests/TicketDetailViewModelTests.swift new file mode 100644 index 0000000..bf0bc60 --- /dev/null +++ b/HutchTests/TicketDetailViewModelTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import Hutch + +struct TicketDetailViewModelTests { + + @Test + @MainActor + func reopenStatusInputOmitsResolution() { + let input = TicketDetailViewModel.statusUpdateInput( + status: .reported, + resolution: .unresolved + ) + + #expect(input["status"] as? String == TicketStatus.reported.rawValue) + #expect(input["resolution"] == nil) + } + + @Test + @MainActor + func resolveStatusInputIncludesResolution() { + let input = TicketDetailViewModel.statusUpdateInput( + status: .resolved, + resolution: .fixed + ) + + #expect(input["status"] as? String == TicketStatus.resolved.rawValue) + #expect(input["resolution"] as? String == TicketResolution.fixed.rawValue) + } +} diff --git a/HutchTests/TrackerListViewModelTests.swift b/HutchTests/TrackerListViewModelTests.swift new file mode 100644 index 0000000..1caedea --- /dev/null +++ b/HutchTests/TrackerListViewModelTests.swift @@ -0,0 +1,16 @@ +import Foundation +import Testing +@testable import Hutch + +struct TrackerListViewModelTests { + + @Test + @MainActor + func graphQLErrorDescriptionIsPreservedForTrackerCreationFailures() { + let error = SRHTError.graphQLErrors([ + GraphQLError(message: "A tracker named bugs already exists", locations: nil) + ]) + + #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists") + } +} diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..29d6aeb --- /dev/null +++ b/TESTING.md @@ -0,0 +1,28 @@ +# Testing Plan for Hutch + +--- + +## **Test Coverage Goals** + +Ensure stability and reliability for the following core workflows: + + +| Area | Description | +| ------------------------ | ----------------------------------------------- | +| Authentication Flow | PAT login, token refresh, sign-out | +| Repository Management | Clone, push, browse (Git/Hg), creation | +| Ticket/Tracker Workflows | Create, view, filter, update | +| Builds | Submission, retry, editing, status updates | +| Sharing & Navigation | Deep links, sharing sheets, external navigation | +| Offline Support | Caching, sync on reconnect | +| UI/UX Consistency | Dark mode, accessibility, error states | + + +--- + +## **Test Environments** + + +| Platform | Versions | Devices/Testers | +| -------- | -------- | --------------- | +| iOS | v1.0+ | iOS 17+ | |
