summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch/Networking/SRHTClient.swift37
-rw-r--r--Hutch/Views/Repositories/ArtifactsView.swift21
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift31
3 files changed, 87 insertions, 2 deletions
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
index 94531f4..8aaa4aa 100644
--- a/Hutch/Networking/SRHTClient.swift
+++ b/Hutch/Networking/SRHTClient.swift
@@ -276,6 +276,43 @@ final class SRHTClient: Sendable {
// MARK: - Plain-text fetch
+ /// Fetch the bytes at a URL using the same authorization header.
+ ///
+ /// sr.ht serves some resources from the API origin rather than the web one —
+ /// `Artifact.url` is `https://git.sr.ht/query/artifact/<checksum>/<filename>`
+ /// — and those return an auth error to anything without a bearer token. They
+ /// cannot be handed to a browser; they have to be fetched here.
+ func fetchData(url: URL) async throws -> Data {
+ guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+ guard Self.isTrustedAuthenticatedTextURL(url) else {
+ throw SRHTError.invalidAuthenticatedURL(url)
+ }
+
+ var request = URLRequest(url: url)
+ request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ return data
+ }
+
/// Fetch the contents of a URL as plain text, using the same authorization header.
/// Used for build logs and other non-GraphQL resources.
func fetchText(url: URL) async throws -> String {
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
index 8843c6f..1d9fc6c 100644
--- a/Hutch/Views/Repositories/ArtifactsView.swift
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -12,11 +12,11 @@ struct ArtifactsView: View {
/// Passed in rather than recomputed: RepositoryDetailView already owns this
/// check and gates its other management surfaces on it.
var canManage: Bool = false
- @Environment(\.openURL) private var openURL
@State private var uploadTargetRef: String?
@State private var isImporting = false
@State private var pendingDeletion: ArtifactInfo?
+ @State private var downloadedFile: DownloadedArtifact?
private var isOwnedByCurrentUser: Bool { canManage }
@@ -63,7 +63,14 @@ struct ArtifactsView: View {
Section {
ForEach(refArtifacts.artifacts) { artifact in
ArtifactRow(artifact: artifact) {
- openURL(artifact.url)
+ Task {
+ // Artifact.url is on the API origin and 401s
+ // without a bearer token, so it cannot be handed
+ // to a browser. Fetch it and share the file.
+ if let fileURL = await viewModel.downloadArtifact(artifact) {
+ downloadedFile = DownloadedArtifact(url: fileURL)
+ }
+ }
}
// See MailingListListView: a full-swipe destructive
// action animates the row out before the confirmation.
@@ -131,6 +138,9 @@ struct ArtifactsView: View {
.themedList()
.listStyle(.insetGrouped)
.srhtErrorBanner(error: $vm.error)
+ .sheet(item: $downloadedFile) { download in
+ FileContentShareSheet(activityItems: [download.url])
+ }
.task {
// Tags drive the picker above and are not otherwise needed by this tab.
if isOwnedByCurrentUser, viewModel.tags.isEmpty {
@@ -184,6 +194,13 @@ struct ArtifactsView: View {
}
}
+/// Wraps the downloaded file for `.sheet(item:)`. URL is not Identifiable, and
+/// conforming a stdlib type retroactively is worse than a four-line struct.
+private struct DownloadedArtifact: Identifiable {
+ let id = UUID()
+ let url: URL
+}
+
private struct ArtifactRow: View {
let artifact: ArtifactInfo
let onDownload: () -> Void
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index dce39c1..9b6b942 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -542,6 +542,14 @@ final class RepositoryDetailViewModel {
return false
}
+ // sr.ht streams the upload into S3, which rejects a zero-part multipart
+ // completion with "MalformedXML" — an error that says nothing about the
+ // actual problem. Catch it here where we can name it.
+ guard !fileData.isEmpty else {
+ self.error = "\(fileURL.lastPathComponent) is empty. SourceHut rejects zero-byte artifacts."
+ return false
+ }
+
do {
_ = try await client.executeMultipart(
service: service,
@@ -567,6 +575,29 @@ final class RepositoryDetailViewModel {
}
}
+ /// Downloads an artifact and returns a local file URL to share.
+ ///
+ /// `Artifact.url` points at the API origin, not the web one, and returns an
+ /// auth error to anything without a bearer token — so it cannot be opened in
+ /// a browser. Fetch it here and hand the user the file instead.
+ func downloadArtifact(_ artifact: ArtifactInfo) async -> URL? {
+ guard !isMutatingArtifact else { return nil }
+ isMutatingArtifact = true
+ error = nil
+ defer { isMutatingArtifact = false }
+
+ do {
+ let data = try await client.fetchData(url: artifact.url)
+ let destination = FileManager.default.temporaryDirectory
+ .appendingPathComponent(artifact.filename)
+ try data.write(to: destination, options: .atomic)
+ return destination
+ } catch {
+ self.error = "Couldn't download \(artifact.filename). \(error.userFacingMessage)"
+ return nil
+ }
+ }
+
@discardableResult
func deleteArtifact(id: Int) async -> Bool {
guard !isMutatingArtifact else { return false }