Skip to content

Commit

Permalink
Add basic Bitrise extension (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
lfroms authored Nov 29, 2024
1 parent 737997b commit 3f074f0
Show file tree
Hide file tree
Showing 25 changed files with 782 additions and 8 deletions.
189 changes: 185 additions & 4 deletions Tophat.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
"version" : "1.0.2"
}
},
{
"identity" : "simplekeychain",
"kind" : "remoteSourceControl",
"location" : "https://github.com/auth0/SimpleKeychain.git",
"state" : {
"revision" : "b694f155907b189bc82e93586695a26f558c742f",
"version" : "1.2.0"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
Expand Down
13 changes: 10 additions & 3 deletions Tophat/Utilities/ArtifactContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ final class ArtifactContainer: Identifiable {
artifacts.append(.rawDownload(destinationURL))

case .application(let application):
let baseURL = application.url.deletingLastPathComponent()

guard baseURL == url else {
guard application.url.isDescendant(of: url) else {
throw ArtifactContainerError.applicationNotCoLocated
}

Expand All @@ -84,3 +82,12 @@ extension ArtifactContainer: Deletable {
try FileManager.default.removeItem(at: url)
}
}

private extension URL {
func isDescendant(of url: URL) -> Bool {
let ancestorPathComponents = url.pathComponents
let childPathComponents = self.pathComponents

return ancestorPathComponents.count < childPathComponents.count && !zip(ancestorPathComponents, childPathComponents).contains(where: !=)
}
}
4 changes: 3 additions & 1 deletion Tophat/Views/Settings/ExtensionsTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ struct ExtensionsTab: View {
} label: {
Label {
Text(availableExtension.specification.title)
Text(availableExtension.specification.description ?? "")
if let description = availableExtension.specification.description {
Text(description)
}
} icon: {
SymbolChip(systemName: "puzzlepiece.extension.fill", color: .gray)
.imageScale(.medium)
Expand Down
5 changes: 5 additions & 0 deletions Tophat/Views/Settings/QuickLaunchEntryRecipeSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ struct QuickLaunchEntryRecipeSheet: View {
artifactProviderID = artifactProviders.first?.id
}
}
.onChange(of: artifactProviderID, initial: false) { oldValue, newValue in
if oldValue != nil, newValue != nil, newValue != oldValue {
artifactProviderParameters.removeAll()
}
}
}

private var artifactProviders: [ArtifactProviderSpecification] {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// ArtifactListElementResponseModel.swift
// Tophat
//
// Created by Lukas Romsicki on 2024-11-28.
// Copyright © 2024 Shopify. All rights reserved.
//

import Foundation

struct ArtifactListElementResponseModel: Codable {
var slug: String
var title: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// ArtifactListResponseModel.swift
// Tophat
//
// Created by Lukas Romsicki on 2024-11-28.
// Copyright © 2024 Shopify. All rights reserved.
//

import Foundation

struct ArtifactListResponseModel: Codable {
var data: [ArtifactListElementResponseModel]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// ArtifactResponseItemModel.swift
// Tophat
//
// Created by Lukas Romsicki on 2024-11-28.
// Copyright © 2024 Shopify. All rights reserved.
//

import Foundation

struct ArtifactResponseItemModel: Codable {
var artifactType: String
var expiringDownloadURL: URL
var fileSizeBytes: Int
var isPublicPageEnabled: Bool
var publicInstallPageURL: String
var slug: String
var title: String

enum CodingKeys: String, CodingKey {
case artifactType = "artifact_type"
case expiringDownloadURL = "expiring_download_url"
case fileSizeBytes = "file_size_bytes"
case isPublicPageEnabled = "is_public_page_enabled"
case publicInstallPageURL = "public_install_page_url"
case slug
case title
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// ArtifactShowResponseModel.swift
// Tophat
//
// Created by Lukas Romsicki on 2024-11-28.
// Copyright © 2024 Shopify. All rights reserved.
//

import Foundation

struct ArtifactShowResponseModel: Codable {
var data: ArtifactResponseItemModel
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// BuildListResponseModel.swift
// Tophat
//
// Created by Lukas Romsicki on 2024-11-28.
// Copyright © 2024 Shopify. All rights reserved.
//

import Foundation

struct BuildListResponseModel: Codable {
var data: [BuildResponseItemModel]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// BuildResponseItemModel.swift
// Tophat
//
// Created by Lukas Romsicki on 2024-11-28.
// Copyright © 2024 Shopify. All rights reserved.
//

import Foundation

struct BuildResponseItemModel: Codable {
var slug: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// ArtifactProvider+ValidateBitriseResponse.swift
// Tophat
//
// Created by Lukas Romsicki on 2024-11-28.
// Copyright © 2024 Shopify. All rights reserved.
//

import Foundation
import TophatKit

extension ArtifactProvider {
func makeAuthenticatedURLRequest(url: URL, token: String) -> URLRequest {
var request = URLRequest(url: url)
request.setValue(token, forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
return request
}

func validateBitriseResponse(response: URLResponse) throws {
guard let artifactHTTPResponse = response as? HTTPURLResponse else {
throw BitriseArtifactProviderError.unexpected
}

guard artifactHTTPResponse.statusCode == 200 else {
switch artifactHTTPResponse.statusCode {
case 401:
throw BitriseArtifactProviderError.unauthorized
case 404:
throw BitriseArtifactProviderError.notFound
default:
throw BitriseArtifactProviderError.unexpected
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// BasicArtifactProvider.swift
// Tophat
//
// Created by Lukas Romsicki on 2024-11-28.
// Copyright © 2024 Shopify. All rights reserved.
//

import Foundation
import TophatKit

struct BasicArtifactProvider: ArtifactProvider {
@SecureStorage(Constants.keychainPersonalAccessTokenKey) var personalAccessToken: String?

static let id = "bitrise"
static let title: LocalizedStringResource = "Bitrise"

@Parameter(key: "app_slug", title: "App Slug")
var appSlug: String

@Parameter(key: "build_slug", title: "Build Slug")
var buildSlug: String

@Parameter(key: "artifact_slug", title: "Artifact Slug")
var artifactSlug: String

private var url: URL {
URL(string: "https://api.bitrise.io/v0.1")!
.appending(path: "apps")
.appending(path: appSlug)
.appending(path: "builds")
.appending(path: buildSlug)
.appending(path: "artifacts")
.appending(path: artifactSlug)
}

func retrieve() async throws -> some ArtifactProviderResult {
guard let personalAccessToken, !personalAccessToken.isEmpty else {
throw BitriseArtifactProviderError.accessTokenNotSet
}

// Fetch artifact details.

let artifactRequest = makeAuthenticatedURLRequest(url: url, token: personalAccessToken)
let (artifactResponseData, artifactResponse) = try await URLSession.shared.data(for: artifactRequest)
try validateBitriseResponse(response: artifactResponse)

let artifactShowResponse = try JSONDecoder().decode(ArtifactShowResponseModel.self, from: artifactResponseData)

// Download artifact.

let destinationDirectoryURL: URL = .temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true)

let (downloadedFileURL, nextResponse) = try await URLSession.shared.download(
from: artifactShowResponse.data.expiringDownloadURL
)

let destinationURL = destinationDirectoryURL
.appending(component: nextResponse.suggestedFilename ?? downloadedFileURL.lastPathComponent)

try FileManager.default.moveItem(at: downloadedFileURL, to: destinationURL)

return .result(localURL: destinationURL)
}

func cleanUp(localURL: URL) async throws {
try FileManager.default.removeItem(at: localURL)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// BitriseArtifactProviderError.swift
// Tophat
//
// Created by Lukas Romsicki on 2024-11-28.
// Copyright © 2024 Shopify. All rights reserved.
//

import Foundation

enum BitriseArtifactProviderError: Error {
case accessTokenNotSet
case unauthorized
case notFound
case unexpected
}

extension BitriseArtifactProviderError: LocalizedError {
var errorDescription: String? {
"Failed to download artifact"
}

var failureReason: String? {
switch self {
case .accessTokenNotSet:
"A Bitrise personal access token has not been specified."
case .unauthorized:
"The access token used to authenticate with Bitrise is invalid."
case .notFound:
"The requested artifact was not found. It may have expired."
case .unexpected:
"An unexpected error has occurred."
}
}

var recoverySuggestion: String? {
switch self {
case .accessTokenNotSet:
"Go to Tophat Settings → Extensions → Bitrise to add a token."
case .unauthorized:
"Go to Tophat Settings → Extensions → Bitrise to update the token."
case .notFound:
nil
case .unexpected:
"Try again later."
}
}
}
Loading

0 comments on commit 3f074f0

Please sign in to comment.