From c424cdaf96df36be7bfc943c38c7337b15f2e48b Mon Sep 17 00:00:00 2001 From: Lukas Romsicki <3951690+lfroms@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:10:49 -0500 Subject: [PATCH] Restructure download and install mechanisms to cache artifacts (#35) --- Tophat.xcodeproj/project.pbxproj | 24 ++- ...chContext.swift => OperationContext.swift} | 4 +- Tophat/TophatApp.swift | 12 +- Tophat/Utilities/ArtifactContainer.swift | 86 +++++++++ Tophat/Utilities/ArtifactDownloader.swift | 62 ++----- Tophat/Utilities/ArtifactUnpacker.swift | 23 ++- .../CachingApplicationDownloader.swift | 115 ++++++++++++ Tophat/Utilities/InstallCoordinator.swift | 166 +++++++----------- Tophat/Utilities/InstallSession.swift | 127 ++++++++++++++ .../Utilities/InstallationTicketMachine.swift | 47 ++--- Tophat/Utilities/LaunchAppAction.swift | 19 +- .../Utilities/Tasks/FetchArtifactTask.swift | 65 ------- .../Tasks/InstallApplicationTask.swift | 18 +- Tophat/Utilities/Tasks/UpdateIconTask.swift | 2 +- .../Views/Quick Launch/QuickLaunchPanel.swift | 5 +- .../TophatFoundation/Application.swift | 2 +- .../TophatFoundation/InstallRecipe.swift | 2 +- .../Sources/TophatFoundation/Launchable.swift | 13 -- 18 files changed, 500 insertions(+), 292 deletions(-) rename Tophat/Models/{LaunchContext.swift => OperationContext.swift} (86%) create mode 100644 Tophat/Utilities/ArtifactContainer.swift create mode 100644 Tophat/Utilities/CachingApplicationDownloader.swift create mode 100644 Tophat/Utilities/InstallSession.swift delete mode 100644 Tophat/Utilities/Tasks/FetchArtifactTask.swift delete mode 100644 TophatModules/Sources/TophatFoundation/Launchable.swift diff --git a/Tophat.xcodeproj/project.pbxproj b/Tophat.xcodeproj/project.pbxproj index b467f1a..0f6a87c 100644 --- a/Tophat.xcodeproj/project.pbxproj +++ b/Tophat.xcodeproj/project.pbxproj @@ -61,6 +61,9 @@ 80564B542983414D002DC136 /* AlertOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80564B532983414D002DC136 /* AlertOptions.swift */; }; 80564B5629834203002DC136 /* FileTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80564B5529834203002DC136 /* FileTypes.swift */; }; 8058B48C2CA630620075D38D /* URLReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8058B48B2CA630620075D38D /* URLReaderTests.swift */; }; + 805AFDAA2CF67D4C00B3E227 /* ArtifactContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805AFDA92CF67D4900B3E227 /* ArtifactContainer.swift */; }; + 805AFDAC2CF6BB8D00B3E227 /* CachingApplicationDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805AFDAB2CF6BB8900B3E227 /* CachingApplicationDownloader.swift */; }; + 805AFDAE2CF6C22700B3E227 /* InstallSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805AFDAD2CF6C21F00B3E227 /* InstallSession.swift */; }; 805FC43229E9BE0A00A78208 /* BuildDownloaderError+LocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805FC43129E9BE0A00A78208 /* BuildDownloaderError+LocalizedError.swift */; }; 80629BF12939818C0077960E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BE92939818C0077960E /* SettingsView.swift */; }; 80629BF22939818C0077960E /* QuickLaunchEntryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BEC2939818C0077960E /* QuickLaunchEntryRow.swift */; }; @@ -141,13 +144,12 @@ 80D71F2E2985D11A0006E1BF /* CustomizeLocationsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D71F2D2985D11A0006E1BF /* CustomizeLocationsButton.swift */; }; 80DC0FD82C82225600E5C9EE /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 80DC0FD72C82225600E5C9EE /* Sparkle */; }; 80DC0FDA2C822E7F00E5C9EE /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DC0FD92C822E7F00E5C9EE /* UpdateController.swift */; }; - 80EB5D45296F59100011DE5F /* FetchArtifactTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D44296F59100011DE5F /* FetchArtifactTask.swift */; }; 80EB5D47296F59270011DE5F /* PrepareDeviceTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D46296F59270011DE5F /* PrepareDeviceTask.swift */; }; 80EB5D49296F5AAF0011DE5F /* InstallApplicationTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D48296F5AAF0011DE5F /* InstallApplicationTask.swift */; }; 80EB5D4B296F64D70011DE5F /* DeviceError+LocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D4A296F64D70011DE5F /* DeviceError+LocalizedError.swift */; }; 80EB5D4D296F64F50011DE5F /* ApplicationError+LocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D4C296F64F50011DE5F /* ApplicationError+LocalizedError.swift */; }; 80EB5D4F296F658E0011DE5F /* InstallationTicketMachineError+LocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D4E296F658E0011DE5F /* InstallationTicketMachineError+LocalizedError.swift */; }; - 80EB5D51296F68CD0011DE5F /* LaunchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D50296F68CD0011DE5F /* LaunchContext.swift */; }; + 80EB5D51296F68CD0011DE5F /* OperationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D50296F68CD0011DE5F /* OperationContext.swift */; }; 80EB5D53296F6A380011DE5F /* Array+JoinedWithSpaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D52296F6A380011DE5F /* Array+JoinedWithSpaces.swift */; }; 80EB5D55297095890011DE5F /* TaskStatusMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D54297095890011DE5F /* TaskStatusMetadata.swift */; }; 80EB5D57297096FB0011DE5F /* InstallStatusMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D56297096FB0011DE5F /* InstallStatusMetadata.swift */; }; @@ -287,6 +289,9 @@ 80564B532983414D002DC136 /* AlertOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertOptions.swift; sourceTree = ""; }; 80564B5529834203002DC136 /* FileTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTypes.swift; sourceTree = ""; }; 8058B48B2CA630620075D38D /* URLReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLReaderTests.swift; sourceTree = ""; }; + 805AFDA92CF67D4900B3E227 /* ArtifactContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactContainer.swift; sourceTree = ""; }; + 805AFDAB2CF6BB8900B3E227 /* CachingApplicationDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachingApplicationDownloader.swift; sourceTree = ""; }; + 805AFDAD2CF6C21F00B3E227 /* InstallSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallSession.swift; sourceTree = ""; }; 805FC43129E9BE0A00A78208 /* BuildDownloaderError+LocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BuildDownloaderError+LocalizedError.swift"; sourceTree = ""; }; 80629BE92939818C0077960E /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 80629BEC2939818C0077960E /* QuickLaunchEntryRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickLaunchEntryRow.swift; sourceTree = ""; }; @@ -361,13 +366,12 @@ 80D71F2D2985D11A0006E1BF /* CustomizeLocationsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeLocationsButton.swift; sourceTree = ""; }; 80DC0FD52C82202600E5C9EE /* TophatCtl.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TophatCtl.entitlements; sourceTree = ""; }; 80DC0FD92C822E7F00E5C9EE /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateController.swift; sourceTree = ""; }; - 80EB5D44296F59100011DE5F /* FetchArtifactTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchArtifactTask.swift; sourceTree = ""; }; 80EB5D46296F59270011DE5F /* PrepareDeviceTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrepareDeviceTask.swift; sourceTree = ""; }; 80EB5D48296F5AAF0011DE5F /* InstallApplicationTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallApplicationTask.swift; sourceTree = ""; }; 80EB5D4A296F64D70011DE5F /* DeviceError+LocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceError+LocalizedError.swift"; sourceTree = ""; }; 80EB5D4C296F64F50011DE5F /* ApplicationError+LocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationError+LocalizedError.swift"; sourceTree = ""; }; 80EB5D4E296F658E0011DE5F /* InstallationTicketMachineError+LocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InstallationTicketMachineError+LocalizedError.swift"; sourceTree = ""; }; - 80EB5D50296F68CD0011DE5F /* LaunchContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchContext.swift; sourceTree = ""; }; + 80EB5D50296F68CD0011DE5F /* OperationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationContext.swift; sourceTree = ""; }; 80EB5D52296F6A380011DE5F /* Array+JoinedWithSpaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+JoinedWithSpaces.swift"; sourceTree = ""; }; 80EB5D54297095890011DE5F /* TaskStatusMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStatusMetadata.swift; sourceTree = ""; }; 80EB5D56297096FB0011DE5F /* InstallStatusMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallStatusMetadata.swift; sourceTree = ""; }; @@ -511,7 +515,7 @@ 8030162B292C1B490016F25E /* ApplicationError.swift */, 8029A080298AF1E90002C579 /* ApplicationIcon.swift */, 80EB5D56297096FB0011DE5F /* InstallStatusMetadata.swift */, - 80EB5D50296F68CD0011DE5F /* LaunchContext.swift */, + 80EB5D50296F68CD0011DE5F /* OperationContext.swift */, 80629C21293A8D270077960E /* ProvisioningProfile.swift */, 8046321E2CF106C9002F6E8F /* QuickLaunchEntry.swift */, 80AE75E32CF4F459000923E3 /* QuickLaunchEntrySource.swift */, @@ -702,7 +706,6 @@ 80EB5D43296F59000011DE5F /* Tasks */ = { isa = PBXGroup; children = ( - 80EB5D44296F59100011DE5F /* FetchArtifactTask.swift */, 80EB5D48296F5AAF0011DE5F /* InstallApplicationTask.swift */, 80ED55452971CB3200B3AEBA /* MirrorDeviceDisplayTask.swift */, 80EB5D46296F59270011DE5F /* PrepareDeviceTask.swift */, @@ -764,6 +767,7 @@ 80462F952CEFEF3C002F6E8F /* Extensions */, 8090E2552967741F003106B9 /* Status Reporting */, 80EB5D43296F59000011DE5F /* Tasks */, + 805AFDA92CF67D4900B3E227 /* ArtifactContainer.swift */, 80691D272CDA9ADE006572CD /* ArtifactDownloader.swift */, 8030161F292874B70016F25E /* ArtifactUnpacker.swift */, 80629BFB293981B10077960E /* CodableAppStorage.swift */, @@ -773,6 +777,8 @@ 80564B5529834203002DC136 /* FileTypes.swift */, 804F37F82C7CE46F0005A869 /* HostTrustResult.swift */, 80FF03EE29087473008509E0 /* InstallCoordinator.swift */, + 805AFDAD2CF6C21F00B3E227 /* InstallSession.swift */, + 805AFDAB2CF6BB8900B3E227 /* CachingApplicationDownloader.swift */, 80D648432CAE254000135729 /* InstallationTicketMachine.swift */, 809C8570297B056F004CE6A2 /* LaunchAppAction.swift */, 80CBACED298988B700F778DD /* LaunchAtLoginController.swift */, @@ -1008,6 +1014,7 @@ 809C8573297B0625004CE6A2 /* LaunchFromLocationMenuItem.swift in Sources */, 80F74E2A2909FAC80040F026 /* MainMenu.swift in Sources */, 80A66D742981BD2200ECBCB6 /* UtilityPathPreferences.swift in Sources */, + 805AFDAC2CF6BB8D00B3E227 /* CachingApplicationDownloader.swift in Sources */, 80FAC08A2AB29665004A8DB8 /* DeviceError+StyledAlertError.swift in Sources */, 80518F682984A6BF00FB8803 /* OnboardingView.swift in Sources */, 80629BF82939819F0077960E /* String+IsValidURL.swift in Sources */, @@ -1018,7 +1025,7 @@ 804ECB7E2975C18300DE78F4 /* DeviceMenu.swift in Sources */, 80CBACFF2989B8B100F778DD /* ShowOnboardingWindowAction.swift in Sources */, 80F74E272909FA1B0040F026 /* TophatApp.swift in Sources */, - 80EB5D51296F68CD0011DE5F /* LaunchContext.swift in Sources */, + 80EB5D51296F68CD0011DE5F /* OperationContext.swift in Sources */, 80518F572984804C00FB8803 /* TophatCtlSymbolicLinkManager.swift in Sources */, 80EB5D49296F5AAF0011DE5F /* InstallApplicationTask.swift in Sources */, 8006E7E32943C95D0089805E /* MenuItemButtonStyle.swift in Sources */, @@ -1028,6 +1035,7 @@ 80CBACF229898FFE00F778DD /* AboutView.swift in Sources */, 80629BF52939818C0077960E /* AppsTab.swift in Sources */, 8046321F2CF106D5002F6E8F /* QuickLaunchEntry.swift in Sources */, + 805AFDAA2CF67D4C00B3E227 /* ArtifactContainer.swift in Sources */, 80AE75E62CF4F660000923E3 /* QuickLaunchEntrySourceSheet.swift in Sources */, 80D71F262984CF100006E1BF /* OnboardingItemLayout.swift in Sources */, 8090E25B2967741F003106B9 /* TaskState.swift in Sources */, @@ -1043,6 +1051,7 @@ 80629BFC293981B10077960E /* CodableAppStorage.swift in Sources */, 8026714B2947C770001A804D /* QuickLaunchPanel.swift in Sources */, 8090E263296774C1003106B9 /* StatusView.swift in Sources */, + 805AFDAE2CF6C22700B3E227 /* InstallSession.swift in Sources */, 8090E2132950E01E003106B9 /* DeviceList.swift in Sources */, 809C8571297B056F004CE6A2 /* LaunchAppAction.swift in Sources */, 809BD03E290CA40900FD4043 /* DeviceSelectionManager.swift in Sources */, @@ -1124,7 +1133,6 @@ 80462F8F2CEFA780002F6E8F /* InfoButton.swift in Sources */, 80301626292C17560016F25E /* AppleApplication.swift in Sources */, 802671492947C72C001A804D /* QuickLaunchEntryView.swift in Sources */, - 80EB5D45296F59100011DE5F /* FetchArtifactTask.swift in Sources */, 8090E25C2967741F003106B9 /* TaskStatusReporter.swift in Sources */, 804FF6592914239800147652 /* Collection+Filter.swift in Sources */, 802671472947C33C001A804D /* Bundle+Extensions.swift in Sources */, diff --git a/Tophat/Models/LaunchContext.swift b/Tophat/Models/OperationContext.swift similarity index 86% rename from Tophat/Models/LaunchContext.swift rename to Tophat/Models/OperationContext.swift index 2647c6c..7f54a56 100644 --- a/Tophat/Models/LaunchContext.swift +++ b/Tophat/Models/OperationContext.swift @@ -1,12 +1,12 @@ // -// LaunchContext.swift +// OperationContext.swift // Tophat // // Created by Lukas Romsicki on 2023-01-11. // Copyright © 2023 Shopify. All rights reserved. // -struct LaunchContext { +struct OperationContext { let appName: String? let quickLaunchEntryID: QuickLaunchEntry.ID? diff --git a/Tophat/TophatApp.swift b/Tophat/TophatApp.swift index e6218ce..4c4c1bc 100644 --- a/Tophat/TophatApp.swift +++ b/Tophat/TophatApp.swift @@ -102,11 +102,15 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { self.deviceSelectionManager = DeviceSelectionManager(deviceManager: deviceManager) self.taskStatusReporter = TaskStatusReporter() + let artifactDownloader = ArtifactDownloader( + artifactRetrievalCoordinator: ArtifactRetrievalCoordinator(appExtensionIdentityResolver: extensionHost) + ) + self.installCoordinator = InstallCoordinator( - deviceManager: deviceManager, - deviceSelectionManager: deviceSelectionManager, - taskStatusReporter: taskStatusReporter, - extensionHost: extensionHost + artifactDownloader: artifactDownloader, + deviceListLoader: deviceManager, + deviceSelector: deviceSelectionManager, + taskStatusReporter: taskStatusReporter ) self.utilityPathPreferences = UtilityPathPreferences() diff --git a/Tophat/Utilities/ArtifactContainer.swift b/Tophat/Utilities/ArtifactContainer.swift new file mode 100644 index 0000000..f2e061b --- /dev/null +++ b/Tophat/Utilities/ArtifactContainer.swift @@ -0,0 +1,86 @@ +// +// ArtifactContainer.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-26. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation + +final class ArtifactContainer: Identifiable { + let id: UUID + private(set) var artifacts: [Artifact] + + var url: URL { + .cachesDirectory + .appending(path: Bundle.main.bundleIdentifier!, directoryHint: .isDirectory) + .appending(path: "ArtifactContainers", directoryHint: .isDirectory) + .appending(path: id.uuidString, directoryHint: .isDirectory) + } + + var rawDownloads: [URL] { + artifacts.compactMap { artifact in + if case .rawDownload(let url) = artifact { + return url + } else { + return nil + } + } + } + + var applications: [Application] { + artifacts.compactMap { artifact in + if case .application(let application) = artifact { + return application + } else { + return nil + } + } + } + + init() { + self.id = UUID() + self.artifacts = [] + } + + func addCopy(of artifact: Artifact) throws { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + + switch artifact { + case .rawDownload(let rawDownloadURL): + let destinationURL = url.appending(component: rawDownloadURL.lastPathComponent) + try FileManager.default.copyItem(at: rawDownloadURL, to: destinationURL) + artifacts.append(.rawDownload(destinationURL)) + + case .application(let application): + let baseURL = application.url.deletingLastPathComponent() + + guard baseURL == url else { + throw ArtifactContainerError.applicationNotCoLocated + } + + artifacts.append(.application(application)) + } + } +} + +extension ArtifactContainer { + enum Artifact { + case rawDownload(URL) + case application(Application) + } +} + +enum ArtifactContainerError: Error { + case applicationNotCoLocated +} + +// MARK: - Deletable + +extension ArtifactContainer: Deletable { + func delete() async throws { + try FileManager.default.removeItem(at: url) + } +} diff --git a/Tophat/Utilities/ArtifactDownloader.swift b/Tophat/Utilities/ArtifactDownloader.swift index 6736e63..8980916 100644 --- a/Tophat/Utilities/ArtifactDownloader.swift +++ b/Tophat/Utilities/ArtifactDownloader.swift @@ -10,72 +10,30 @@ import Foundation import TophatFoundation import Logging -struct ArtifactResource: Identifiable { - let id: UUID - let url: URL - let application: Application -} - final class ArtifactDownloader { - private let artifactsURL: URL = .temporaryDirectory - .appending(path: Bundle.main.bundleIdentifier!) - .appending(path: "Artifacts") - private let artifactRetrievalCoordinator: ArtifactRetrievalCoordinating init(artifactRetrievalCoordinator: ArtifactRetrievalCoordinating) { self.artifactRetrievalCoordinator = artifactRetrievalCoordinator } - func download(from source: RemoteArtifactSource) async throws -> ArtifactResource { - do { - return try await _download(from: source) - } catch { - throw ArtifactDownloaderError.failedToDownloadArtifact - } - } - - private func _download(from source: RemoteArtifactSource) async throws -> ArtifactResource { - let resourceID = UUID() - let artifactDirectoryURL = artifactsURL.appending(path: resourceID.uuidString) - - try FileManager.default.createDirectory(at: artifactDirectoryURL, withIntermediateDirectories: true) - - let artifactURL: URL - + func download(from source: RemoteArtifactSource, to container: ArtifactContainer) async throws { switch source { case .artifactProvider(let metadata): - log.info("Downloading artifact from artifact provider", metadata: metadata.loggerMetadata) - let localURL = try await artifactRetrievalCoordinator.retrieve(metadata: metadata) - log.info("The artifact provider has made the artifact available at \(localURL)") - - let fileName = localURL.lastPathComponent - let destinationURL = artifactDirectoryURL.appending(component: fileName) + log.info("[ArtifactDownloader] Downloading artifact from artifact provider", metadata: metadata.loggerMetadata) + let fileURL = try await artifactRetrievalCoordinator.retrieve(metadata: metadata) + log.info("The artifact provider has made the artifact available at \(fileURL)") - log.info("Copying downloaded artifact to \(destinationURL)") - try FileManager.default.copyItem(at: localURL, to: destinationURL) + log.info("[ArtifactDownloader] Adding downloaded artifact to container with identifier \(container.id)") + try container.addCopy(of: .rawDownload(fileURL)) - log.info("Notifying artifact provider with identifier \(metadata.id) to clean up temporary files") - try await artifactRetrievalCoordinator.cleanUp(artifactProviderID: metadata.id, localURL: localURL) - - artifactURL = destinationURL + log.info("[ArtifactDownloader] Notifying artifact provider with identifier \(metadata.id) to clean up temporary files") + try await artifactRetrievalCoordinator.cleanUp(artifactProviderID: metadata.id, localURL: fileURL) case .file(let fileURL): - let fileName = fileURL.lastPathComponent - let destinationURL = artifactDirectoryURL.appending(component: fileName) - - log.info("Copying artifact on local filesystem to \(destinationURL)") - try FileManager.default.copyItem(at: fileURL, to: destinationURL) - - artifactURL = destinationURL + log.info("[ArtifactDownloader] Adding downloaded artifact to container with identifier \(container.id)") + try container.addCopy(of: .rawDownload(fileURL)) } - - log.info("Unpacking artifact at \(artifactURL)") - let application = try ArtifactUnpacker().unpack(artifactURL: artifactURL) - - log.info("Artifact unpacked to \(application.url)") - - return ArtifactResource(id: resourceID, url: artifactURL, application: application) } } diff --git a/Tophat/Utilities/ArtifactUnpacker.swift b/Tophat/Utilities/ArtifactUnpacker.swift index 3b5fe35..aca3ccb 100644 --- a/Tophat/Utilities/ArtifactUnpacker.swift +++ b/Tophat/Utilities/ArtifactUnpacker.swift @@ -11,14 +11,18 @@ import TophatFoundation import ZIPFoundation final class ArtifactUnpacker { - /// Unpacks a local artifact found on the local file system and returns the application contained in the artifact. - /// - Parameter artifactURL: The URL to the local artifact. - /// - Returns: An `Application` instance representing the build found in the artifact. - func unpack(artifactURL: URL) throws -> Application { - guard artifactURL.isFileURL else { + /// Unpacks a downloaded artifact in an `ArtifactContainer` and places it in the same container. + /// - Parameter container: The container in which the raw artifact is located and where to place the unpacked artifact. + func unpack(downloadedItemInContainer container: ArtifactContainer) async throws { + guard let rawDownloadURL = container.rawDownloads.first, rawDownloadURL.isFileURL else { throw ArtifactUnpackerError.artifactNotAvailable } + let application = try unpack(artifactURL: rawDownloadURL) + try container.addCopy(of: .application(application)) + } + + private func unpack(artifactURL: URL) throws -> Application { guard let fileFormat = ArtifactFileFormat(pathExtension: artifactURL.pathExtension) else { throw ArtifactUnpackerError.unknownFileFormat } @@ -56,12 +60,13 @@ final class ArtifactUnpacker { } private func extractArtifact(at url: URL) throws -> URL { - let destination = url.deletingLastPathComponent().appending(path: url.fileName) + let destinationURL = url.deletingLastPathComponent().appending(path: url.fileName) - try FileManager.default.unzipItem(at: url, to: destination) - try? FileManager.default.removeItem(at: url) + log.info("Uncompressing artifact at \(url)") + try FileManager.default.unzipItem(at: url, to: destinationURL) + log.info("Artifact uncompressed to \(destinationURL)") - return destination + return destinationURL } } diff --git a/Tophat/Utilities/CachingApplicationDownloader.swift b/Tophat/Utilities/CachingApplicationDownloader.swift new file mode 100644 index 0000000..403cf3c --- /dev/null +++ b/Tophat/Utilities/CachingApplicationDownloader.swift @@ -0,0 +1,115 @@ +// +// CachingApplicationDownloader.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-26. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation + +/// The class you use to perform the full download process of an artifact in order to +/// have access to the associated application locally. +/// +/// During the lifetime of an instance of this class, any requests are cached based on the +/// sources provided. A subsequent request with the exact same source value is considered +/// will either wait for an existing request or return a completed one. +actor CachingApplicationDownloader: ApplicationDownloading { + private let artifactDownloader: ArtifactDownloader + private let artifactUnpacker: ArtifactUnpacker + private let taskStatusReporter: TaskStatusReporter + + private var downloads: [RemoteArtifactSource: Download] = [:] + private var clearCacheTask: Task<(), Error>? + + init( + artifactDownloader: ArtifactDownloader, + artifactUnpacker: ArtifactUnpacker, + taskStatusReporter: TaskStatusReporter + ) { + self.artifactDownloader = artifactDownloader + self.artifactUnpacker = artifactUnpacker + self.taskStatusReporter = taskStatusReporter + } + + /// Downloads an application found in the artifact retreivable using a `RemoteArtifactSource`. If a + /// download is in progress, this function will wait for the existing download. + /// + /// - Parameter source: The source of the remote artifact to download. + /// - Returns: An application. + func download(from source: RemoteArtifactSource, context: OperationContext? = nil) async throws -> Application { + if let clearCacheTask { + // Wait if the cache is being cleared. + _ = await clearCacheTask.result + } + + if let existingDownload = downloads[source] { + return try await existingDownload.task.value + } + + let container = ArtifactContainer() + + let task = Task { + let taskStatus = TaskStatus( + displayName: "Fetching \(context?.appName ?? "App")", + initialState: .running(message: "Downloading", progress: .indeterminate) + ) + + await taskStatusReporter.add(status: taskStatus) + + defer { + Task { await taskStatus.markAsDone() } + } + + try await artifactDownloader.download(from: source, to: container) + + await taskStatus.update(state: .running(message: "Unpacking", progress: .indeterminate)) + try await artifactUnpacker.unpack(downloadedItemInContainer: container) + + guard let application = container.applications.first else { + throw CachingApplicationDownloaderError.applicationNotFoundInArtifactContainer + } + + return application + } + + downloads[source] = Download(container: container, task: task) + return try await task.value + } + + /// Deletes all cached data that was tracked by this instance. + /// + /// - Warning: Calling this function will cancel any in-progress downloads. + func cleanUp() async throws { + if let clearCacheTask { + try await clearCacheTask.value + return + } + + let task = Task { + downloads.values.forEach { $0.task.cancel() } + + for download in downloads.values { + try await download.container.delete() + } + + downloads.removeAll() + } + + clearCacheTask = task + try await task.value + clearCacheTask = nil + } +} + +extension CachingApplicationDownloader { + struct Download { + let container: ArtifactContainer + let task: Task + } +} + +enum CachingApplicationDownloaderError: Error { + case applicationNotFoundInArtifactContainer +} diff --git a/Tophat/Utilities/InstallCoordinator.swift b/Tophat/Utilities/InstallCoordinator.swift index b5bfd03..884483d 100644 --- a/Tophat/Utilities/InstallCoordinator.swift +++ b/Tophat/Utilities/InstallCoordinator.swift @@ -2,142 +2,104 @@ // InstallCoordinator.swift // Tophat // -// Created by Lukas Romsicki on 2022-10-25. -// Copyright © 2022 Shopify. All rights reserved. +// Created by Lukas Romsicki on 2024-11-27. +// Copyright © 2024 Shopify. All rights reserved. // import Foundation import TophatFoundation -import SwiftData -final class InstallCoordinator { - private unowned let deviceManager: DeviceManager - private unowned let taskStatusReporter: TaskStatusReporter - private let deviceSelectionManager: DeviceSelectionManager +protocol DeviceListLoading { + func loadDevices() async +} + +extension DeviceManager: DeviceListLoading {} +/// The object you use to trigger the installation of an application to the selected devices. +/// +/// All requests made within a 30-second period are cached if a previous request was +/// made with the same parameters. After 30 seconds, the cache is destroyed. +actor InstallCoordinator { private let artifactDownloader: ArtifactDownloader + private let deviceListLoader: DeviceListLoading + private let deviceSelector: DeviceSelecting + private let taskStatusReporter: TaskStatusReporter + + private var currentSession: InstallSession + + private var idleTimer: Task? init( - deviceManager: DeviceManager, - deviceSelectionManager: DeviceSelectionManager, - taskStatusReporter: TaskStatusReporter, - extensionHost: ExtensionHost + artifactDownloader: ArtifactDownloader, + deviceListLoader: DeviceListLoading, + deviceSelector: DeviceSelecting, + taskStatusReporter: TaskStatusReporter ) { - self.deviceManager = deviceManager - self.deviceSelectionManager = deviceSelectionManager + self.artifactDownloader = artifactDownloader + self.deviceListLoader = deviceListLoader + self.deviceSelector = deviceSelector self.taskStatusReporter = taskStatusReporter - self.artifactDownloader = ArtifactDownloader(artifactRetrievalCoordinator: ArtifactRetrievalCoordinator(appExtensionIdentityResolver: extensionHost)) + self.currentSession = InstallSession( + artifactDownloader: artifactDownloader, + deviceSelector: deviceSelector, + taskStatusReporter: taskStatusReporter + ) } /// Downloads, installs, and launches applications on selected devices. - /// + /// /// If an appropriate device is found for a recipe in advance, the device is booted in parallel /// with the download process to improve completion time. - /// + /// /// - Parameters: /// - recipes: A collection of recipes for retrieving builds. /// - context: Additional metadata for the operation. - func install(recipes: [InstallRecipe], context: LaunchContext? = nil) async throws { - await preflightInstallation(context: context) - - let fetchArtifact = FetchArtifactTask( - taskStatusReporter: taskStatusReporter, - artifactDownloader: artifactDownloader, - context: context - ) - - let machine = InstallationTicketMachine(deviceSelector: deviceSelectionManager, artifactDownloader: fetchArtifact) + func install(recipes: [InstallRecipe], context: OperationContext? = nil) async throws { + if idleTimer == nil { + observeSessionIdleState() + } - try await withThrowingTaskGroup(of: Void.self) { group in - for try await ticket in machine.process(recipes: recipes) { - group.addTask { [weak self] in - try await self?.install(ticket: ticket, context: context) - } - } + taskStatusReporter.notify(message: "Preparing to install \(context?.appName ?? "application")…") + await deviceListLoader.loadDevices() - try await group.waitForAll() - } + try await currentSession.install(recipes: recipes, context: context) } - /// Downloads, installs, and launches an artifact from a local or remote URL. - /// - /// The device to boot is not known ahead of time—it will be booted after the build is downloaded - /// and unpacked. To improve user experience, prefer ``launch(recipes:context:)`` - /// where possible so that devices are prepared ahead of time. - /// - /// - Parameters: - /// - artifactURL: The URL of the artifact to launch. - /// - context: Additional metadata for the operation. - func launch(artifactURL: URL, launchArguments: [String] = [], context: LaunchContext? = nil) async throws { - await preflightInstallation(context: context) - - let fetchArtifact = FetchArtifactTask( - taskStatusReporter: taskStatusReporter, + private func createNewSession() async { + currentSession = InstallSession( artifactDownloader: artifactDownloader, - context: context - ) - - let machine = InstallationTicketMachine(deviceSelector: deviceSelectionManager, artifactDownloader: fetchArtifact) - - let source: RemoteArtifactSource = if artifactURL.isFileURL { - .file(url: artifactURL) - } else { - .artifactProvider( - metadata: ArtifactProviderMetadata( - id: "http", - parameters: ["url": artifactURL.absoluteString] - ) - ) - } - - let recipe = InstallRecipe( - source: source, - launchArguments: launchArguments + deviceSelector: deviceSelector, + taskStatusReporter: taskStatusReporter ) - for try await ticket in machine.process(recipes: [recipe]) { - try await install(ticket: ticket) - } + observeSessionIdleState() } - private func install(ticket: InstallationTicketMachine.Ticket, context: LaunchContext? = nil) async throws { - let fetchArtifact = FetchArtifactTask( - taskStatusReporter: taskStatusReporter, - artifactDownloader: artifactDownloader, - context: context - ) + private func observeSessionIdleState() { + log.info("[InstallCoordinator] Observing install session idle state.") - let prepareDevice = PrepareDeviceTask(taskStatusReporter: taskStatusReporter) + idleTimer?.cancel() - async let futureFetchArtifactResult = fetchArtifact(from: ticket.artifactLocation) - async let futurePrepareDeviceResult = prepareDevice(device: ticket.device) + idleTimer = Task { + var sleepTimer: Task? - let (fetchArtifactResult, prepareDeviceResult) = await ( - try futureFetchArtifactResult, - try futurePrepareDeviceResult - ) + for await isIdle in currentSession.isIdleUpdates { + guard isIdle else { + sleepTimer?.cancel() + continue + } - if !prepareDeviceResult.deviceWasColdBooted { - // If the device wasn't cold booted, bring it to the foreground later in the process. - log.info("Bringing device to foreground") + sleepTimer = Task { + // A session (and consequently its cache) are valid for 30 seconds. + try? await Task.sleep(for: .seconds(30)) - // This is a non-critical feature, it is allowed to fail in case the - // user hasn't accepted permissions. - try? ticket.device.focus() + if !Task.isCancelled, await currentSession.isIdle { + log.info("[InstallCoordinator] Current install session expired. Creating next session.") + await createNewSession() + } + } + } } - - let installApplication = InstallApplicationTask(taskStatusReporter: taskStatusReporter, context: context) - - try await installApplication( - application: fetchArtifactResult.application, - device: ticket.device, - launchArguments: ticket.launchArguments - ) - } - - private func preflightInstallation(context: LaunchContext?) async { - taskStatusReporter.notify(message: "Preparing to install \(context?.appName ?? "application")…") - await deviceManager.loadDevices() } } diff --git a/Tophat/Utilities/InstallSession.swift b/Tophat/Utilities/InstallSession.swift new file mode 100644 index 0000000..7915cc4 --- /dev/null +++ b/Tophat/Utilities/InstallSession.swift @@ -0,0 +1,127 @@ +// +// InstallSession.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-26. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation + +/// An install session represents a short period of time during which the user indends to install +/// applications. An install session receives requests to launch applications and processes them. +/// +/// While an install session is active, it can continuously receive requests to install applications. During +/// this period, downloads are cached so that repeated requests do not redownload the same resources +/// Install sessions are intended to be destroyed and recreated each time Tophat becomes idle, so that +/// future requests are not subject to caching and latest artifacts can be downloaded. +actor InstallSession { + private let applicationDownloader: ApplicationDownloading + private let ticketMachine: InstallationTicketMachine + private let taskStatusReporter: TaskStatusReporter + + private var activeRequestsCount: Int = 0 { + didSet { + if activeRequestsCount != oldValue { + isIdleUpdatesContinuation.yield(activeRequestsCount == 0) + } + } + } + + var isIdle: Bool { + activeRequestsCount == 0 + } + + let isIdleUpdates: AsyncStream + private let isIdleUpdatesContinuation: AsyncStream.Continuation + + init( + artifactDownloader: ArtifactDownloader, + deviceSelector: DeviceSelecting, + taskStatusReporter: TaskStatusReporter + ) { + self.applicationDownloader = CachingApplicationDownloader( + artifactDownloader: artifactDownloader, + artifactUnpacker: ArtifactUnpacker(), + taskStatusReporter: taskStatusReporter + ) + + self.ticketMachine = InstallationTicketMachine( + deviceSelector: deviceSelector, + applicationDownloader: applicationDownloader + ) + + self.taskStatusReporter = taskStatusReporter + + (self.isIdleUpdates, self.isIdleUpdatesContinuation) = AsyncStream.makeStream() + } + + deinit { + Task { [applicationDownloader] in + log.info("[InstallSession] Cleaning up resources.") + try? await applicationDownloader.cleanUp() + } + } + + /// Downloads, installs, and launches applications on selected devices. + /// + /// If an appropriate device is found for a recipe in advance, the device is booted in parallel + /// with the download process to improve completion time. + /// + /// - Parameters: + /// - recipes: A collection of recipes for retrieving builds. + /// - context: Additional metadata for the operation. + func install(recipes: [InstallRecipe], context: OperationContext? = nil) async throws { + activeRequestsCount += 1 + defer { activeRequestsCount -= 1 } + + try await withThrowingTaskGroup(of: Void.self) { group in + for try await ticket in ticketMachine.process(recipes: recipes) { + group.addTask { [weak self] in + try await self?.install(ticket: ticket, context: context) + } + } + + try await group.waitForAll() + } + } + + private func install(ticket: InstallationTicketMachine.Ticket, context: OperationContext?) async throws { + let device = ticket.device + let prepareDevice = PrepareDeviceTask(taskStatusReporter: taskStatusReporter) + + async let futureApplication = { + switch ticket.artifactLocation { + case .remote(let source): + try await applicationDownloader.download(from: source, context: context) + case .local(let application): + application + } + }() + + async let futurePrepareDeviceResult = prepareDevice(device: device) + + let (application, prepareDeviceResult) = await ( + try futureApplication, + try futurePrepareDeviceResult + ) + + if !prepareDeviceResult.deviceWasColdBooted { + // If the device wasn't cold booted, bring it to the foreground later in the process. + log.info("Bringing device with identifier \(device.id) to foreground") + + // This is a non-critical feature, it is allowed to fail in case the + // user hasn't accepted permissions. + try? device.focus() + } + + let installApplication = InstallApplicationTask(taskStatusReporter: taskStatusReporter, context: context) + + try await installApplication( + application: application, + device: device, + launchArguments: ticket.launchArguments + ) + } +} diff --git a/Tophat/Utilities/InstallationTicketMachine.swift b/Tophat/Utilities/InstallationTicketMachine.swift index 0f61cf3..ca7c0f2 100644 --- a/Tophat/Utilities/InstallationTicketMachine.swift +++ b/Tophat/Utilities/InstallationTicketMachine.swift @@ -9,43 +9,47 @@ import Foundation import TophatFoundation -protocol DeviceSelecting { - var selectedDevices: [Device] { get } +protocol ApplicationDownloading { + func download(from source: RemoteArtifactSource, context: OperationContext?) async throws -> Application + func cleanUp() async throws } -protocol ArtifactDownloading { - func download(source: RemoteArtifactSource) async throws -> Application +extension ApplicationDownloading { + func download(from source: RemoteArtifactSource) async throws -> Application { + try await download(from: source, context: nil) + } } -extension DeviceSelectionManager: DeviceSelecting {} +protocol DeviceSelecting { + var selectedDevices: [Device] { get } +} /// A mechanism for producing installation tickets for selected devices based on the information /// provided by installation recipes. struct InstallationTicketMachine { - typealias Ticket = InstallationTicket typealias TicketSequence = AsyncThrowingStream private let deviceSelector: DeviceSelecting - private let artifactDownloader: ArtifactDownloading + private let applicationDownloader: ApplicationDownloading /// Creates a new instance of the processor. /// - Parameters: /// - deviceSelector: An instance that provides user-selected devices. - /// - artifactDownloader: An instance that provides downloading capability - init(deviceSelector: DeviceSelecting, artifactDownloader: ArtifactDownloading) { + /// - applicationDownloader: An instance that provides downloading capability + init(deviceSelector: DeviceSelecting, applicationDownloader: ApplicationDownloading) { self.deviceSelector = deviceSelector - self.artifactDownloader = artifactDownloader + self.applicationDownloader = applicationDownloader } /// Begins producing tickets for the provided recipes and returns them in an asynchronous /// sequence. /// - Parameter recipes: The recipes to be processed. /// - Returns: An asynchronous sequence of tickets. - func process(recipes: [InstallRecipe]) -> TicketSequence { + func process(recipes: [InstallRecipe], context: OperationContext? = nil) -> TicketSequence { AsyncThrowingStream { continuation in Task { do { - try await process(recipes: recipes, continuation: continuation) + try await process(recipes: recipes, continuation: continuation, context: context) continuation.finish() } catch { continuation.finish(throwing: error) @@ -54,7 +58,7 @@ struct InstallationTicketMachine { } } - private func process(recipes: [InstallRecipe], continuation: TicketSequence.Continuation) async throws { + private func process(recipes: [InstallRecipe], continuation: TicketSequence.Continuation, context: OperationContext?) async throws { let selectedDevices = deviceSelector.selectedDevices guard !selectedDevices.isEmpty else { @@ -90,7 +94,7 @@ struct InstallationTicketMachine { continue recipeLoop } - let application = try await artifactDownloader.download(source: recipe.source) + let application = try await applicationDownloader.download(from: recipe.source, context: context) providedBuildTypes[application.platform, default: []].formUnion(application.targets) @@ -128,14 +132,17 @@ struct InstallationTicketMachine { } } +extension InstallationTicketMachine { + struct Ticket { + let device: Device + let artifactLocation: ArtifactLocation + let launchArguments: [String] + } +} + enum InstallationTicketMachineError: Error { case noCompatibleDevices(providedBuildTypes: [Platform: Set]) case noSelectedDevices } -/// Structure representing a request to install an application on a given device. -struct InstallationTicket { - let device: Device - let artifactLocation: ArtifactLocation - let launchArguments: [String] -} +extension DeviceSelectionManager: DeviceSelecting {} diff --git a/Tophat/Utilities/LaunchAppAction.swift b/Tophat/Utilities/LaunchAppAction.swift index a413bb0..04643f1 100644 --- a/Tophat/Utilities/LaunchAppAction.swift +++ b/Tophat/Utilities/LaunchAppAction.swift @@ -16,15 +16,22 @@ struct LaunchAppAction { self.installCoordinator = installCoordinator } - func callAsFunction(artifactURL: URL, launchArguments: [String] = [], context: LaunchContext? = nil) async { - do { - try await installCoordinator.launch(artifactURL: artifactURL, launchArguments: launchArguments, context: context) - } catch { - ErrorNotifier().notify(error: error) + func callAsFunction(artifactURL: URL, launchArguments: [String] = [], context: OperationContext? = nil) async { + let source: RemoteArtifactSource = if artifactURL.isFileURL { + .file(url: artifactURL) + } else { + .artifactProvider( + metadata: ArtifactProviderMetadata( + id: "http", + parameters: ["url": artifactURL.absoluteString] + ) + ) } + + await callAsFunction(recipes: [InstallRecipe(source: source, launchArguments: launchArguments)], context: context) } - func callAsFunction(recipes: [InstallRecipe], context: LaunchContext? = nil) async { + func callAsFunction(recipes: [InstallRecipe], context: OperationContext? = nil) async { do { try await installCoordinator.install(recipes: recipes, context: context) } catch { diff --git a/Tophat/Utilities/Tasks/FetchArtifactTask.swift b/Tophat/Utilities/Tasks/FetchArtifactTask.swift deleted file mode 100644 index 577acba..0000000 --- a/Tophat/Utilities/Tasks/FetchArtifactTask.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// FetchArtifactTask.swift -// Tophat -// -// Created by Lukas Romsicki on 2023-01-11. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation -import TophatFoundation - -extension FetchArtifactTask: ArtifactDownloading { - func download(source: RemoteArtifactSource) async throws -> any Application { - try await callAsFunction(from: .remote(source: source)).application - } -} - -struct FetchArtifactTask { - struct Result { - let application: Application - } - - let taskStatusReporter: TaskStatusReporter - let buildDownloader: ArtifactDownloader - let context: LaunchContext? - - init( - taskStatusReporter: TaskStatusReporter, - artifactDownloader: ArtifactDownloader, - context: LaunchContext? - ) { - self.taskStatusReporter = taskStatusReporter - self.buildDownloader = artifactDownloader - self.context = context - } - - func callAsFunction(from location: ArtifactLocation) async throws -> Result { - let status = TaskStatus(displayName: "Downloading \(context?.appName ?? "App")", initialState: .running(message: "Downloading", progress: .indeterminate)) - await taskStatusReporter.add(status: status) - - defer { - Task { - await status.markAsDone() - } - } - - let application = switch location { - case .remote(let source): - try await buildDownloader.download(from: source).application - case .local(let application): - application - } - - Task { - let updateIcon = UpdateIconTask( - taskStatusReporter: taskStatusReporter, - context: context - ) - - try await updateIcon(application: application) - } - - return Result(application: application) - } -} diff --git a/Tophat/Utilities/Tasks/InstallApplicationTask.swift b/Tophat/Utilities/Tasks/InstallApplicationTask.swift index d6bd5f4..4751c2a 100644 --- a/Tophat/Utilities/Tasks/InstallApplicationTask.swift +++ b/Tophat/Utilities/Tasks/InstallApplicationTask.swift @@ -11,7 +11,7 @@ import TophatFoundation struct InstallApplicationTask { let taskStatusReporter: TaskStatusReporter - let context: LaunchContext? + let context: OperationContext? func callAsFunction(application: Application, device: Device, launchArguments: [String]) async throws { let metadata = InstallStatusMetadata(deviceId: device.id) @@ -27,9 +27,6 @@ struct InstallApplicationTask { defer { Task { await status.markAsDone() - - log.info("Cleaning up installed application bundle") - try? await application.delete() } } @@ -40,7 +37,7 @@ struct InstallApplicationTask { log.info("Installing application from local path \(application.url.path(percentEncoded: false))") taskStatusReporter.notify(message: "Installing \(notificationAppName) on \(device.name)…") - await status.update(state: .running(message: "Installing")) + await status.update(state: .running(message: "Installing to \(device.name)")) try device.install(application: application) @@ -53,8 +50,17 @@ struct InstallApplicationTask { log.info("Launching application with bundle identifier \(bundleIdentifier)") taskStatusReporter.notify(message: "Launching \(notificationAppName) on \(device.name)…") - await status.update(state: .running(message: "Launching")) + await status.update(state: .running(message: "Launching on \(device.name)")) try device.launch(application: application, arguments: launchArguments) + + Task { + let updateIcon = UpdateIconTask( + taskStatusReporter: taskStatusReporter, + context: context + ) + + try await updateIcon(application: application) + } } } diff --git a/Tophat/Utilities/Tasks/UpdateIconTask.swift b/Tophat/Utilities/Tasks/UpdateIconTask.swift index e212d83..e85745e 100644 --- a/Tophat/Utilities/Tasks/UpdateIconTask.swift +++ b/Tophat/Utilities/Tasks/UpdateIconTask.swift @@ -12,7 +12,7 @@ import SwiftData struct UpdateIconTask { let taskStatusReporter: TaskStatusReporter - let context: LaunchContext? + let context: OperationContext? @MainActor func callAsFunction(application: Application) async throws { diff --git a/Tophat/Views/Quick Launch/QuickLaunchPanel.swift b/Tophat/Views/Quick Launch/QuickLaunchPanel.swift index dcbd799..a8355aa 100644 --- a/Tophat/Views/Quick Launch/QuickLaunchPanel.swift +++ b/Tophat/Views/Quick Launch/QuickLaunchPanel.swift @@ -44,7 +44,8 @@ struct QuickLaunchPanel: View { } private func didSelect(entry: QuickLaunchEntry) { - let launchContext = LaunchContext(appName: entry.name, quickLaunchEntryID: entry.id) + let context = OperationContext(appName: entry.name, quickLaunchEntryID: entry.id) + let recipes = entry.sources.map { source in InstallRecipe( source: .artifactProvider( @@ -60,7 +61,7 @@ struct QuickLaunchPanel: View { } Task { - await launchApp?(recipes: recipes, context: launchContext) + await launchApp?(recipes: recipes, context: context) } } } diff --git a/TophatModules/Sources/TophatFoundation/Application.swift b/TophatModules/Sources/TophatFoundation/Application.swift index 10408a4..6bb8a68 100644 --- a/TophatModules/Sources/TophatFoundation/Application.swift +++ b/TophatModules/Sources/TophatFoundation/Application.swift @@ -9,7 +9,7 @@ import Foundation /// An installable application found on the local file system. -public protocol Application: Launchable, Deletable { +public protocol Application: Deletable { /// The display name of the application. var name: String? { get } diff --git a/TophatModules/Sources/TophatFoundation/InstallRecipe.swift b/TophatModules/Sources/TophatFoundation/InstallRecipe.swift index 8367ea2..a06c374 100644 --- a/TophatModules/Sources/TophatFoundation/InstallRecipe.swift +++ b/TophatModules/Sources/TophatFoundation/InstallRecipe.swift @@ -22,7 +22,7 @@ public struct InstallRecipe: Equatable, Hashable, Codable { public init( source: RemoteArtifactSource, - launchArguments: [String], + launchArguments: [String] = [], platformHint: Platform? = nil, destinationHint: DeviceType? = nil ) { diff --git a/TophatModules/Sources/TophatFoundation/Launchable.swift b/TophatModules/Sources/TophatFoundation/Launchable.swift deleted file mode 100644 index 33b5ad2..0000000 --- a/TophatModules/Sources/TophatFoundation/Launchable.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Launchable.swift -// TophatFoundation -// -// Created by Lukas Romsicki on 2023-01-19. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation - -public protocol Launchable { - var url: URL { get } -}