| @@ -75,6 +75,20 @@ private struct PathObject: Decodable, Sendable { |
| 75 | 75 | let text: String? |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | private struct UploadArtifactResponse: Decodable, Sendable { |
| 79 | let uploadArtifact: ArtifactInfo |
| 80 | } |
| 81 | |
| 82 | private struct DeleteArtifactResponse: Decodable, Sendable { |
| 83 | /// Nullable in the schema: sr.ht returns null when there was no artifact to |
| 84 | /// remove, which is still a success from the caller's point of view. |
| 85 | let deleteArtifact: ArtifactIDPayload? |
| 86 | } |
| 87 | |
| 88 | private struct ArtifactIDPayload: Decodable, Sendable { |
| 89 | let id: Int |
| 90 | } |
| 91 | |
| 78 | 92 | private struct ArtifactsResponse: Decodable, Sendable { |
| 79 | 93 | let repository: ArtifactsRepository? |
| 80 | 94 | } |
| @@ -144,6 +158,7 @@ final class RepositoryDetailViewModel { |
| 144 | 158 | |
| 145 | 159 | private(set) var referenceArtifacts: [ReferenceWithArtifacts] = [] |
| 146 | 160 | private(set) var isLoadingArtifacts = false |
| 161 | private(set) var isMutatingArtifact = false |
| 147 | 162 | |
| 148 | 163 | // MARK: - Error |
| 149 | 164 | |
| @@ -457,6 +472,26 @@ final class RepositoryDetailViewModel { |
| 457 | 472 | |
| 458 | 473 | // MARK: - Artifacts |
| 459 | 474 | |
| 475 | /// `file` is a top-level Upload variable here, unlike meta's avatar upload |
| 476 | /// where it is nested inside an input object. |
| 477 | private static let uploadArtifactMutation = """ |
| 478 | mutation uploadArtifact($repoId: Int!, $revspec: String!, $file: Upload!) { |
| 479 | uploadArtifact(repoId: $repoId, revspec: $revspec, file: $file) { |
| 480 | id |
| 481 | filename |
| 482 | checksum |
| 483 | size |
| 484 | url |
| 485 | } |
| 486 | } |
| 487 | """ |
| 488 | |
| 489 | private static let deleteArtifactMutation = """ |
| 490 | mutation deleteArtifact($id: Int!) { |
| 491 | deleteArtifact(id: $id) { id } |
| 492 | } |
| 493 | """ |
| 494 | |
| 460 | 495 | private static let artifactsQuery = """ |
| 461 | 496 | query artifacts($rid: ID!) { |
| 462 | 497 | repository(rid: $rid) { |
| @@ -480,6 +515,91 @@ final class RepositoryDetailViewModel { |
| 480 | 515 | } |
| 481 | 516 | """ |
| 482 | 517 | |
| 518 | /// Attaches a file to the tag named by `revspec`. |
| 519 | /// |
| 520 | /// sr.ht requires the filename to be unique among the repository's artifacts, |
| 521 | /// and rejects a duplicate rather than replacing it, so the error is surfaced |
| 522 | /// as-is rather than being retried. |
| 523 | @discardableResult |
| 524 | func uploadArtifact(revspec: String, fileURL: URL) async -> Bool { |
| 525 | guard !isMutatingArtifact else { return false } |
| 526 | isMutatingArtifact = true |
| 527 | error = nil |
| 528 | defer { isMutatingArtifact = false } |
| 529 | |
| 530 | let needsScopedAccess = fileURL.startAccessingSecurityScopedResource() |
| 531 | defer { |
| 532 | if needsScopedAccess { |
| 533 | fileURL.stopAccessingSecurityScopedResource() |
| 534 | } |
| 535 | } |
| 536 | |
| 537 | let fileData: Data |
| 538 | do { |
| 539 | fileData = try Data(contentsOf: fileURL) |
| 540 | } catch { |
| 541 | self.error = "Couldn't read \(fileURL.lastPathComponent)." |
| 542 | return false |
| 543 | } |
| 544 | |
| 545 | do { |
| 546 | _ = try await client.executeMultipart( |
| 547 | service: service, |
| 548 | query: Self.uploadArtifactMutation, |
| 549 | variables: [ |
| 550 | "repoId": repository.id, |
| 551 | "revspec": revspec, |
| 552 | "file": nil as String? as Any |
| 553 | ], |
| 554 | file: MultipartUploadFile( |
| 555 | variablePath: "file", |
| 556 | fileData: fileData, |
| 557 | fileName: fileURL.lastPathComponent, |
| 558 | mimeType: Self.mimeType(for: fileURL) |
| 559 | ), |
| 560 | responseType: UploadArtifactResponse.self |
| 561 | ) |
| 562 | await reloadArtifacts() |
| 563 | return true |
| 564 | } catch { |
| 565 | self.error = "Couldn't upload \(fileURL.lastPathComponent). \(error.userFacingMessage)" |
| 566 | return false |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | @discardableResult |
| 571 | func deleteArtifact(id: Int) async -> Bool { |
| 572 | guard !isMutatingArtifact else { return false } |
| 573 | isMutatingArtifact = true |
| 574 | error = nil |
| 575 | defer { isMutatingArtifact = false } |
| 576 | |
| 577 | do { |
| 578 | _ = try await client.execute( |
| 579 | service: service, |
| 580 | query: Self.deleteArtifactMutation, |
| 581 | variables: ["id": id], |
| 582 | responseType: DeleteArtifactResponse.self |
| 583 | ) |
| 584 | await reloadArtifacts() |
| 585 | return true |
| 586 | } catch { |
| 587 | self.error = "Couldn't delete the artifact. \(error.userFacingMessage)" |
| 588 | return false |
| 589 | } |
| 590 | } |
| 591 | |
| 592 | private func reloadArtifacts() async { |
| 593 | isLoadingArtifacts = false |
| 594 | await loadArtifacts() |
| 595 | } |
| 596 | |
| 597 | /// Artifacts are release tarballs and signatures rather than media, so a |
| 598 | /// generic binary type is honest more often than guessing from the extension. |
| 599 | private nonisolated static func mimeType(for url: URL) -> String { |
| 600 | "application/octet-stream" |
| 601 | } |
| 602 | |
| 483 | 603 | func loadArtifacts() async { |
| 484 | 604 | guard !isLoadingArtifacts else { return } |
| 485 | 605 | isLoadingArtifacts = true |