From 78bc9f39ef38afccb1e83ccbe4af26923a343d34 Mon Sep 17 00:00:00 2001 From: Lukas Romsicki <3951690+lfroms@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:17:37 -0500 Subject: [PATCH] Add initial extension functionality (#33) --- .github/workflows/appcast.yml | 2 +- .github/workflows/swiftlint.yml | 1 - .github/workflows/xcode.yml | 3 +- Tophat.xcodeproj/project.pbxproj | 357 ++++++++++++++---- .../xcschemes/TophatTests.xcscheme | 13 +- .../GoogleCloudSDK.imageset/Contents.json | 12 - .../GoogleCloudSDK.png | Bin 10489 -> 0 bytes ...BuildDownloaderError+LocalizedError.swift} | 14 +- .../DeviceError+LocalizedError.swift | 6 +- ...ionTicketMachineError+LocalizedError.swift | 61 +++ ...chRequestBuilderError+LocalizedError.swift | 45 --- Tophat/Models/AndroidApplication.swift | 8 +- Tophat/Models/AppleApplication.swift | 12 +- Tophat/Models/LaunchContext.swift | 4 +- Tophat/Models/LaunchRequest.swift | 14 - Tophat/Models/PinnedApplication.swift | 14 +- Tophat/TophatApp.swift | 89 ++--- Tophat/Utilities/ArtifactDownloader.swift | 125 +++--- Tophat/Utilities/DeviceSelectionManager.swift | 34 +- .../AppExtensionIdentity+WithXPCSession.swift | 28 ++ .../ArtifactRetrievalCoordinator.swift | 76 ++++ .../Utilities/Extensions/ExtensionHost.swift | 49 +++ .../Extensions/TophatExtension.swift | 34 ++ Tophat/Utilities/InstallCoordinator.swift | 162 ++++---- .../InstallCoordinatorDelegate.swift | 15 - .../Utilities/InstallationTicketMachine.swift | 141 +++++++ Tophat/Utilities/LaunchAppAction.swift | 8 +- Tophat/Utilities/LaunchRequestBuilder.swift | 70 ---- Tophat/Utilities/NotificationHandler.swift | 85 ++--- Tophat/Utilities/Notifications.swift | 2 +- .../Utilities/Tasks/FetchArtifactTask.swift | 55 ++- .../Tasks/InstallApplicationTask.swift | 8 +- Tophat/Utilities/URLHandler.swift | 136 ------- Tophat/Utilities/URLReader.swift | 149 ++++++++ Tophat/Utilities/UtilityPathPreferences.swift | 15 - Tophat/Views/DeviceList.swift | 4 +- Tophat/Views/DeviceMenu.swift | 4 +- Tophat/Views/DevicePicker.swift | 11 +- Tophat/Views/Generic/BadgedURL.swift | 34 -- Tophat/Views/Generic/SymbolChip.swift | 5 +- Tophat/Views/LaunchFromLocationMenuItem.swift | 2 +- Tophat/Views/LaunchFromURLPanel.swift | 2 +- .../GoogleCloudStorageOnboardingItem.swift | 42 --- .../Views/Onboarding/OnboardingTaskList.swift | 4 - .../Quick Launch/QuickLaunchEmptyState.swift | 1 + .../Views/Quick Launch/QuickLaunchPanel.swift | 11 +- .../Settings/AddPinnedApplicationSheet.swift | 291 ++++++++------ Tophat/Views/Settings/ExtensionsTab.swift | 94 +++++ Tophat/Views/Settings/InfoButton.swift | 30 ++ .../Locations/GoogleStorageUtilPicker.swift | 25 -- Tophat/Views/Settings/LocationsTab.swift | 4 - .../Views/Settings/ParameterTextField.swift | 32 ++ .../Views/Settings/PinnedApplicationRow.swift | 32 +- Tophat/Views/SettingsView.swift | 9 +- ...shopify.Tophat.extension.appextensionpoint | 11 + TophatCtl/Commands/Apps/Apps+Add.swift | 37 +- TophatCtl/Commands/Apps/Apps+Remove.swift | 2 +- TophatCtl/Commands/FastInstall.swift | 56 --- TophatCtl/Commands/Install.swift | 51 ++- TophatCtl/TophatCtl.swift | 1 - .../HTTPArtifactProvider.swift | 36 ++ .../ShellScriptArtifactProvider.swift | 64 ++++ .../TophatCoreExtension/Info.plist | 11 + .../TophatCoreExtension/Localizable.xcstrings | 33 ++ .../TophatCoreExtension.entitlements | 10 + .../TophatCoreExtension.swift | 22 ++ TophatKit/Package.swift | 20 + .../Sources/TophatKit/ArtifactProvider.swift | 44 +++ .../TophatKit/ArtifactProviderParameter.swift | 46 +++ .../TophatKit/ArtifactProviderResult.swift | 21 ++ .../TophatKit/ArtifactProviderValue.swift | 64 ++++ .../TophatKit/ArtifactProvidersBuilder.swift | 26 ++ .../AnyArtifactProviderParameter.swift | 19 + .../Internal/BuildProvider+Parameters.swift | 46 +++ .../Internal/ExtensionConfiguration.swift | 47 +++ .../TophatKit/Internal/ExtensionService.swift | 60 +++ .../Messages/CleanUpArtifactMessage.swift | 21 ++ .../FetchExtensionSpecificationMessage.swift | 70 ++++ .../Messages/RetrieveArtifactMessage.swift | 22 ++ .../Internal/XPC/ExtensionXPCMessage.swift | 20 + .../XPC/ExtensionXPCReceivedMessage.swift | 67 ++++ .../Internal/XPC/ExtensionXPCSession.swift | 112 ++++++ .../Sources/TophatKit/TophatExtension.swift | 60 +++ TophatModules/Package.swift | 12 +- .../AndroidDeviceKit/AndroidDevices.swift | 4 +- .../ConnectedDevice+Device.swift | 4 +- .../AndroidDeviceKit/ProxyVirtualDevice.swift | 6 +- .../VirtualDeviceNameMapping.swift | 6 +- .../ConnectedDevice+Device.swift | 2 +- .../AppleDeviceKit/Simulator+Device.swift | 6 +- .../Extensions/ShellOut+GsUtil.swift | 37 -- .../GoogleStorageKit/GoogleStorage.swift | 113 ------ .../GoogleStorageKit/GoogleStorageError.swift | 14 - .../Sources/GoogleStorageKit/Logging.swift | 11 - .../GoogleStoragePathResolverDelegate.swift | 16 - .../Utilities/PathResolver.swift | 46 --- .../Sources/TophatFoundation/Artifact.swift | 23 -- .../TophatFoundation/ArtifactLocation.swift | 17 + .../ArtifactProviderMetadata.swift | 20 + .../TophatFoundation/ArtifactSet.swift | 28 -- .../TophatFoundation/Collection+Safe.swift | 13 + .../Sources/TophatFoundation/DeviceType.swift | 12 +- .../TophatFoundation/InstallRecipe.swift | 34 ++ .../RemoteArtifactSource.swift | 15 + ...phatAddPinnedApplicationNotification.swift | 45 --- .../TophatInstallGenericNotification.swift | 36 -- ...phatAddPinnedApplicationNotification.swift | 28 ++ ...phatInstallConfigurationNotification.swift | 27 ++ .../TophatInstallURLNotification.swift} | 6 +- ...tRemovePinnedApplicationNotification.swift | 2 +- .../TophatInterProcessNotification.swift | 2 +- .../TophatInterProcessNotifier.swift | 2 +- .../Shared/UserSpecifiedInstallRecipe.swift | 16 + ...ecifiedQuickLaunchEntryConfiguration.swift | 15 + TophatTests/Utilities/URLReaderTests.swift | 264 +++++++++++++ 115 files changed, 3004 insertions(+), 1461 deletions(-) delete mode 100644 Tophat/Assets.xcassets/GoogleCloudSDK.imageset/Contents.json delete mode 100644 Tophat/Assets.xcassets/GoogleCloudSDK.imageset/GoogleCloudSDK.png rename Tophat/Extensions/{ArtifactDownloaderError+LocalizedError.swift => BuildDownloaderError+LocalizedError.swift} (61%) create mode 100644 Tophat/Extensions/InstallationTicketMachineError+LocalizedError.swift delete mode 100644 Tophat/Extensions/LaunchRequestBuilderError+LocalizedError.swift delete mode 100644 Tophat/Models/LaunchRequest.swift create mode 100644 Tophat/Utilities/Extensions/AppExtensionIdentity+WithXPCSession.swift create mode 100644 Tophat/Utilities/Extensions/ArtifactRetrievalCoordinator.swift create mode 100644 Tophat/Utilities/Extensions/ExtensionHost.swift create mode 100644 Tophat/Utilities/Extensions/TophatExtension.swift delete mode 100644 Tophat/Utilities/InstallCoordinatorDelegate.swift create mode 100644 Tophat/Utilities/InstallationTicketMachine.swift delete mode 100644 Tophat/Utilities/LaunchRequestBuilder.swift delete mode 100644 Tophat/Utilities/URLHandler.swift create mode 100644 Tophat/Utilities/URLReader.swift delete mode 100644 Tophat/Views/Generic/BadgedURL.swift delete mode 100644 Tophat/Views/Onboarding/GoogleCloudStorageOnboardingItem.swift create mode 100644 Tophat/Views/Settings/ExtensionsTab.swift create mode 100644 Tophat/Views/Settings/InfoButton.swift delete mode 100644 Tophat/Views/Settings/Locations/GoogleStorageUtilPicker.swift create mode 100644 Tophat/Views/Settings/ParameterTextField.swift create mode 100644 Tophat/com.shopify.Tophat.extension.appextensionpoint delete mode 100644 TophatCtl/Commands/FastInstall.swift create mode 100644 TophatExtensions/TophatCoreExtension/Build Providers/HTTPArtifactProvider.swift create mode 100644 TophatExtensions/TophatCoreExtension/Build Providers/ShellScriptArtifactProvider.swift create mode 100644 TophatExtensions/TophatCoreExtension/Info.plist create mode 100644 TophatExtensions/TophatCoreExtension/Localizable.xcstrings create mode 100644 TophatExtensions/TophatCoreExtension/TophatCoreExtension.entitlements create mode 100644 TophatExtensions/TophatCoreExtension/TophatCoreExtension.swift create mode 100644 TophatKit/Package.swift create mode 100644 TophatKit/Sources/TophatKit/ArtifactProvider.swift create mode 100644 TophatKit/Sources/TophatKit/ArtifactProviderParameter.swift create mode 100644 TophatKit/Sources/TophatKit/ArtifactProviderResult.swift create mode 100644 TophatKit/Sources/TophatKit/ArtifactProviderValue.swift create mode 100644 TophatKit/Sources/TophatKit/ArtifactProvidersBuilder.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/AnyArtifactProviderParameter.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/BuildProvider+Parameters.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/ExtensionConfiguration.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/ExtensionService.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/Messages/CleanUpArtifactMessage.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/Messages/FetchExtensionSpecificationMessage.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/Messages/RetrieveArtifactMessage.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCMessage.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCReceivedMessage.swift create mode 100644 TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCSession.swift create mode 100644 TophatKit/Sources/TophatKit/TophatExtension.swift delete mode 100644 TophatModules/Sources/GoogleStorageKit/Extensions/ShellOut+GsUtil.swift delete mode 100644 TophatModules/Sources/GoogleStorageKit/GoogleStorage.swift delete mode 100644 TophatModules/Sources/GoogleStorageKit/GoogleStorageError.swift delete mode 100644 TophatModules/Sources/GoogleStorageKit/Logging.swift delete mode 100644 TophatModules/Sources/GoogleStorageKit/Protocols/GoogleStoragePathResolverDelegate.swift delete mode 100644 TophatModules/Sources/GoogleStorageKit/Utilities/PathResolver.swift delete mode 100644 TophatModules/Sources/TophatFoundation/Artifact.swift create mode 100644 TophatModules/Sources/TophatFoundation/ArtifactLocation.swift create mode 100644 TophatModules/Sources/TophatFoundation/ArtifactProviderMetadata.swift delete mode 100644 TophatModules/Sources/TophatFoundation/ArtifactSet.swift create mode 100644 TophatModules/Sources/TophatFoundation/Collection+Safe.swift create mode 100644 TophatModules/Sources/TophatFoundation/InstallRecipe.swift create mode 100644 TophatModules/Sources/TophatFoundation/RemoteArtifactSource.swift delete mode 100644 TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift delete mode 100644 TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatInstallGenericNotification.swift create mode 100644 TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift create mode 100644 TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallConfigurationNotification.swift rename TophatModules/Sources/{TophatKit/Control Notifications/Definitions/TophatInstallHintedNotification.swift => TophatUtilities/Control Notifications/Definitions/TophatInstallURLNotification.swift} (78%) rename TophatModules/Sources/{TophatKit => TophatUtilities}/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift (96%) rename TophatModules/Sources/{TophatKit => TophatUtilities}/Control Notifications/TophatInterProcessNotification.swift (95%) rename TophatModules/Sources/{TophatKit => TophatUtilities}/Control Notifications/TophatInterProcessNotifier.swift (98%) create mode 100644 TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedInstallRecipe.swift create mode 100644 TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedQuickLaunchEntryConfiguration.swift create mode 100644 TophatTests/Utilities/URLReaderTests.swift diff --git a/.github/workflows/appcast.yml b/.github/workflows/appcast.yml index 6695b49..4a9d0c6 100644 --- a/.github/workflows/appcast.yml +++ b/.github/workflows/appcast.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Select Xcode Version - run: sudo xcode-select -switch /Applications/Xcode_15.4.app + run: sudo xcode-select -switch /Applications/Xcode_16.app - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml index b6d7e0e..bcd4afd 100644 --- a/.github/workflows/swiftlint.yml +++ b/.github/workflows/swiftlint.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] jobs: lint: diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index 8408029..c556875 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] jobs: build: @@ -20,7 +19,7 @@ jobs: run: brew install xcbeautify - name: Select Xcode Version - run: sudo xcode-select -switch /Applications/Xcode_15.4.app + run: sudo xcode-select -switch /Applications/Xcode_16.app - name: Run Tests run: set -o pipefail && xcodebuild test -project Tophat.xcodeproj -scheme TophatTests -destination 'platform=macOS,arch=arm64' CODE_SIGNING_ALLOWED=NO | xcbeautify diff --git a/Tophat.xcodeproj/project.pbxproj b/Tophat.xcodeproj/project.pbxproj index e56ea8a..5140af4 100644 --- a/Tophat.xcodeproj/project.pbxproj +++ b/Tophat.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -12,6 +12,8 @@ 7F35026924A5060700EE76EA /* TophatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F35026824A5060700EE76EA /* TophatTests.swift */; }; 7F4532AD251A6C4700F2CFC8 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 7FA71E0924C95CAC001C9574 /* Logging */; }; 7F4532AF251A6C4700F2CFC8 /* LoggingOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = 7F8EC94425086F3B00D4D42B /* LoggingOSLog */; }; + 8005282F2CB718C200226174 /* TophatKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8005282E2CB718C200226174 /* TophatKit */; }; + 800528312CB718C700226174 /* TophatKit in Frameworks */ = {isa = PBXBuildFile; productRef = 800528302CB718C700226174 /* TophatKit */; }; 8006E7E12943C9190089805E /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8006E7E02943C9190089805E /* Theme.swift */; }; 8006E7E32943C95D0089805E /* MenuItemButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8006E7E22943C95D0089805E /* MenuItemButtonStyle.swift */; }; 8006E7E52943C9970089805E /* SectionHeadingTextStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8006E7E42943C9970089805E /* SectionHeadingTextStyle.swift */; }; @@ -19,7 +21,7 @@ 8006E7E92943C9B80089805E /* ToggleableRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8006E7E82943C9B80089805E /* ToggleableRow.swift */; }; 8006E7ED2943CA250089805E /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8006E7EC2943CA250089805E /* Panel.swift */; }; 8006E7F02943D3090089805E /* VisualEffects in Frameworks */ = {isa = PBXBuildFile; productRef = 8006E7EF2943D3090089805E /* VisualEffects */; }; - 8020A6DE297F301700FEA490 /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8020A6DD297F301700FEA490 /* URLHandler.swift */; }; + 8020A6DE297F301700FEA490 /* URLReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8020A6DD297F301700FEA490 /* URLReader.swift */; }; 8025A5B429845EB5007B1BA0 /* Apps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8025A5B329845EB5007B1BA0 /* Apps.swift */; }; 802671452947C297001A804D /* MenuHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802671442947C297001A804D /* MenuHeader.swift */; }; 802671472947C33C001A804D /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802671462947C33C001A804D /* Bundle+Extensions.swift */; }; @@ -32,28 +34,33 @@ 80301626292C17560016F25E /* AppleApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80301625292C17560016F25E /* AppleApplication.swift */; }; 80301628292C17700016F25E /* AndroidApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80301627292C17700016F25E /* AndroidApplication.swift */; }; 8030162C292C1B490016F25E /* ApplicationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8030162B292C1B490016F25E /* ApplicationError.swift */; }; - 80318D1C2927EC4D002A5FD9 /* LaunchRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80318D1B2927EC4D002A5FD9 /* LaunchRequestBuilder.swift */; }; - 80318D1E2927EC54002A5FD9 /* LaunchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80318D1D2927EC54002A5FD9 /* LaunchRequest.swift */; }; + 80343E472CA39A1D00642D54 /* ExtensionsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80343E462CA39A1A00642D54 /* ExtensionsTab.swift */; }; 80346F042BEBD527002F54BC /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 80346F032BEBD527002F54BC /* AsyncAlgorithms */; }; 803B874B290055C70062F070 /* AndroidDeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = 803B874A290055C70062F070 /* AndroidDeviceKit */; }; + 80462F8F2CEFA780002F6E8F /* InfoButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80462F8E2CEFA77E002F6E8F /* InfoButton.swift */; }; + 80462F912CEFA7F2002F6E8F /* ParameterTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80462F902CEFA7EF002F6E8F /* ParameterTextField.swift */; }; + 80462F942CEFEF1A002F6E8F /* TophatExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80462F932CEFEF17002F6E8F /* TophatExtension.swift */; }; + 80462F972CEFEF73002F6E8F /* ExtensionHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80462F962CEFEF70002F6E8F /* ExtensionHost.swift */; }; + 80462F992CEFF04F002F6E8F /* AppExtensionIdentity+WithXPCSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80462F982CEFF04A002F6E8F /* AppExtensionIdentity+WithXPCSession.swift */; }; 804ECB7C2975C15300DE78F4 /* DevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804ECB7B2975C15300DE78F4 /* DevicePicker.swift */; }; 804ECB7E2975C18300DE78F4 /* DeviceMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804ECB7D2975C18300DE78F4 /* DeviceMenu.swift */; }; 804ECB802975C68400DE78F4 /* VisibleWhenButtonHoveredViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804ECB7F2975C68400DE78F4 /* VisibleWhenButtonHoveredViewModifier.swift */; }; 804F37F92C7CE46F0005A869 /* HostTrustResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804F37F82C7CE46F0005A869 /* HostTrustResult.swift */; }; 804F37FD2C7CEFB00005A869 /* TrustedHostAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804F37FC2C7CEFB00005A869 /* TrustedHostAlert.swift */; }; 804FF6592914239800147652 /* Collection+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804FF6582914239800147652 /* Collection+Filter.swift */; }; - 804FFE1829C3BEA5002B64AA /* BadgedURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804FFE1729C3BEA5002B64AA /* BadgedURL.swift */; }; 80518F4F2984600900FB8803 /* Apps+Add.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80518F4D29845FB100FB8803 /* Apps+Add.swift */; }; 80518F512984681200FB8803 /* Apps+Remove.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80518F502984681200FB8803 /* Apps+Remove.swift */; }; 80518F5329846E4300FB8803 /* NSRunningApplication+IsTophatRunning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80518F5229846E4300FB8803 /* NSRunningApplication+IsTophatRunning.swift */; }; 80518F572984804C00FB8803 /* TophatCtlSymbolicLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80518F562984804C00FB8803 /* TophatCtlSymbolicLinkManager.swift */; }; 80518F632984A64F00FB8803 /* OnboardingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80518F622984A64F00FB8803 /* OnboardingWindow.swift */; }; 80518F682984A6BF00FB8803 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80518F672984A6BF00FB8803 /* OnboardingView.swift */; }; - 80564B50298340D1002DC136 /* InstallCoordinatorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80564B4F298340D1002DC136 /* InstallCoordinatorDelegate.swift */; }; + 805543CC2CB715EB004E1D18 /* TophatUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = 805543CB2CB715EB004E1D18 /* TophatUtilities */; }; + 805543CE2CB715F1004E1D18 /* TophatUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = 805543CD2CB715F1004E1D18 /* TophatUtilities */; }; 80564B5229834137002DC136 /* TaskStatusReporterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80564B5129834137002DC136 /* TaskStatusReporterDelegate.swift */; }; 80564B542983414D002DC136 /* AlertOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80564B532983414D002DC136 /* AlertOptions.swift */; }; 80564B5629834203002DC136 /* FileTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80564B5529834203002DC136 /* FileTypes.swift */; }; - 805FC43229E9BE0A00A78208 /* ArtifactDownloaderError+LocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805FC43129E9BE0A00A78208 /* ArtifactDownloaderError+LocalizedError.swift */; }; + 8058B48C2CA630620075D38D /* URLReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8058B48B2CA630620075D38D /* URLReaderTests.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 /* PinnedApplicationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BEC2939818C0077960E /* PinnedApplicationRow.swift */; }; 80629BF32939818C0077960E /* List+GradientButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BED2939818C0077960E /* List+GradientButtons.swift */; }; @@ -64,12 +71,12 @@ 80629BFA293981A80077960E /* PinnedApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BF9293981A80077960E /* PinnedApplication.swift */; }; 80629BFC293981B10077960E /* CodableAppStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629BFB293981B10077960E /* CodableAppStorage.swift */; }; 80629C22293A8D270077960E /* ProvisioningProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80629C21293A8D270077960E /* ProvisioningProfile.swift */; }; + 80691D282CDA9AE9006572CD /* ArtifactDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80691D272CDA9ADE006572CD /* ArtifactDownloader.swift */; }; 8079E3562C850FE0000CB5B3 /* View+ShowDockIconWhenOpen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8079E3552C850FE0000CB5B3 /* View+ShowDockIconWhenOpen.swift */; }; 807D7B0F29835762007942B4 /* TophatFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 807D7B0E29835762007942B4 /* TophatFoundation */; }; 807D7B132983576C007942B4 /* TophatCtl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807D7B102983576C007942B4 /* TophatCtl.swift */; }; 807D7B1629835795007942B4 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = 807D7B1529835795007942B4 /* ArgumentParser */; }; 807D7B1A298357C6007942B4 /* tophatctl in Copy tophatctl */ = {isa = PBXBuildFile; fileRef = 807D7B0729835756007942B4 /* tophatctl */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - 807D7B1B298357D4007942B4 /* FastInstall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807D7B122983576C007942B4 /* FastInstall.swift */; }; 8090E201294FA29E003106B9 /* FluidMenuBarExtra in Frameworks */ = {isa = PBXBuildFile; productRef = 8090E200294FA29E003106B9 /* FluidMenuBarExtra */; }; 8090E2032950C1CC003106B9 /* CollapsibleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8090E2022950C1CC003106B9 /* CollapsibleSection.swift */; }; 8090E2132950E01E003106B9 /* DeviceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8090E2122950E01E003106B9 /* DeviceList.swift */; }; @@ -85,7 +92,6 @@ 809874AC294BB37A00EC541E /* DevicesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809874AB294BB37A00EC541E /* DevicesTab.swift */; }; 809BD035290C3A5200FD4043 /* DeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809BD034290C3A5200FD4043 /* DeviceManager.swift */; }; 809BD03E290CA40900FD4043 /* DeviceSelectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809BD03D290CA40900FD4043 /* DeviceSelectionManager.swift */; }; - 809BD04729106F5E00FD4043 /* GoogleStorageKit in Frameworks */ = {isa = PBXBuildFile; productRef = 809BD04629106F5E00FD4043 /* GoogleStorageKit */; }; 809C8571297B056F004CE6A2 /* LaunchAppAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809C8570297B056F004CE6A2 /* LaunchAppAction.swift */; }; 809C8573297B0625004CE6A2 /* LaunchFromLocationMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809C8572297B0625004CE6A2 /* LaunchFromLocationMenuItem.swift */; }; 809C8575297B0FA9004CE6A2 /* ShowingAdvancedOptionsViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809C8574297B0FA9004CE6A2 /* ShowingAdvancedOptionsViewModifier.swift */; }; @@ -93,12 +99,14 @@ 80A66D6D2981BC9900ECBCB6 /* ErrorNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A66D6A2981BC9900ECBCB6 /* ErrorNotifier.swift */; }; 80A66D6E2981BC9900ECBCB6 /* MirrorDeviceDisplayAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A66D6B2981BC9900ECBCB6 /* MirrorDeviceDisplayAction.swift */; }; 80A66D742981BD2200ECBCB6 /* UtilityPathPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A66D732981BD2200ECBCB6 /* UtilityPathPreferences.swift */; }; - 80A66D762981BD3A00ECBCB6 /* GoogleStorageUtilPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A66D752981BD3A00ECBCB6 /* GoogleStorageUtilPicker.swift */; }; 80A91A0D2981B9F900D8A8B9 /* ShowingAlternateItemsViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A91A0C2981B9F900D8A8B9 /* ShowingAlternateItemsViewModifier.swift */; }; 80A91A122981BA1300D8A8B9 /* CustomWindowPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A91A0F2981BA1300D8A8B9 /* CustomWindowPresentation.swift */; }; 80A91A132981BA1300D8A8B9 /* FloatingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A91A102981BA1300D8A8B9 /* FloatingPanel.swift */; }; 80A91A142981BA1300D8A8B9 /* FloatingPanelViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A91A112981BA1300D8A8B9 /* FloatingPanelViewModifier.swift */; }; 80A91A162981BA2D00D8A8B9 /* LaunchFromURLPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A91A152981BA2D00D8A8B9 /* LaunchFromURLPanel.swift */; }; + 80B48DE72C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint in Resources */ = {isa = PBXBuildFile; fileRef = 80B48DE62C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint */; }; + 80B48DE92C8BCBC400897317 /* com.shopify.Tophat.extension.appextensionpoint in CopyFiles */ = {isa = PBXBuildFile; fileRef = 80B48DE62C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint */; }; + 80B48E132C8BD1D500897317 /* ArtifactRetrievalCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B48E122C8BD1D500897317 /* ArtifactRetrievalCoordinator.swift */; }; 80B536052AB5407700EEB2EF /* SettingsLinkAdditionalActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B536042AB5407700EEB2EF /* SettingsLinkAdditionalActionButtonStyle.swift */; }; 80B7BAD329762C0800267C3C /* InlineButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B7BAD229762C0800267C3C /* InlineButtonStyle.swift */; }; 80B7BAD529762CBB00267C3C /* QuickLaunchEmptyState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B7BAD429762CBB00267C3C /* QuickLaunchEmptyState.swift */; }; @@ -117,9 +125,10 @@ 80CBACF72989921800F778DD /* AboutWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80CBACF029898F9A00F778DD /* AboutWindow.swift */; }; 80CBACFF2989B8B100F778DD /* ShowOnboardingWindowAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80CBACFE2989B8B100F778DD /* ShowOnboardingWindowAction.swift */; }; 80D2799829005DD000F03649 /* TophatServer in Frameworks */ = {isa = PBXBuildFile; productRef = 80D2799729005DD000F03649 /* TophatServer */; }; + 80D648442CAE254300135729 /* InstallationTicketMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D648432CAE254000135729 /* InstallationTicketMachine.swift */; }; + 80D648552CB0E20C00135729 /* TophatCoreExtension.appex in CopyFiles */ = {isa = PBXBuildFile; fileRef = 80D6484D2CB0E20C00135729 /* TophatCoreExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 80D71F1C2984CE720006E1BF /* XcodeOnboardingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D71F1B2984CE720006E1BF /* XcodeOnboardingItem.swift */; }; 80D71F1E2984CE850006E1BF /* AndroidStudioOnboardingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D71F1D2984CE850006E1BF /* AndroidStudioOnboardingItem.swift */; }; - 80D71F202984CE9F0006E1BF /* GoogleCloudStorageOnboardingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D71F1F2984CE9F0006E1BF /* GoogleCloudStorageOnboardingItem.swift */; }; 80D71F222984CEBD0006E1BF /* CommandLineHelperOnboardingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D71F212984CEBD0006E1BF /* CommandLineHelperOnboardingItem.swift */; }; 80D71F242984CEF40006E1BF /* OnboardingItemStatusIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D71F232984CEF40006E1BF /* OnboardingItemStatusIcon.swift */; }; 80D71F262984CF100006E1BF /* OnboardingItemLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D71F252984CF100006E1BF /* OnboardingItemLayout.swift */; }; @@ -134,7 +143,7 @@ 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 /* LaunchRequestBuilderError+LocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D4E296F658E0011DE5F /* LaunchRequestBuilderError+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 */; }; 80EB5D53296F6A380011DE5F /* Array+JoinedWithSpaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D52296F6A380011DE5F /* Array+JoinedWithSpaces.swift */; }; 80EB5D55297095890011DE5F /* TaskStatusMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D54297095890011DE5F /* TaskStatusMetadata.swift */; }; @@ -142,8 +151,6 @@ 80EB5D5D2970A25D0011DE5F /* UpdateIconTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D5C2970A25D0011DE5F /* UpdateIconTask.swift */; }; 80EB5D5F2970A7940011DE5F /* PinnedApplicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EB5D5E2970A7940011DE5F /* PinnedApplicationState.swift */; }; 80ED3E5E29835AAF00A734B7 /* URL+ExpressibleByArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80ED3E5C29835A9900A734B7 /* URL+ExpressibleByArgument.swift */; }; - 80ED3E8F298421B400A734B7 /* TophatKit in Frameworks */ = {isa = PBXBuildFile; productRef = 80ED3E8E298421B400A734B7 /* TophatKit */; }; - 80ED3E91298421B900A734B7 /* TophatKit in Frameworks */ = {isa = PBXBuildFile; productRef = 80ED3E90298421B900A734B7 /* TophatKit */; }; 80ED55462971CB3200B3AEBA /* MirrorDeviceDisplayTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80ED55452971CB3200B3AEBA /* MirrorDeviceDisplayTask.swift */; }; 80F380432984226800A9350F /* NotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F380422984226800A9350F /* NotificationHandler.swift */; }; 80F3804829843A9200A9350F /* Platform+ExpressibleByArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F3804629843A9000A9350F /* Platform+ExpressibleByArgument.swift */; }; @@ -154,7 +161,6 @@ 80FAC08A2AB29665004A8DB8 /* DeviceError+StyledAlertError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80FAC0892AB29665004A8DB8 /* DeviceError+StyledAlertError.swift */; }; 80FDFDB52947D4D9000606AC /* DeviceItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80FDFDB42947D4D9000606AC /* DeviceItem.swift */; }; 80FF03EF29087473008509E0 /* InstallCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80FF03EE29087473008509E0 /* InstallCoordinator.swift */; }; - 80FF03F129089975008509E0 /* ArtifactDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80FF03F029089975008509E0 /* ArtifactDownloader.swift */; }; B6AA44CF296DF8EF0017321C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = B6AA44CE296DF8EF0017321C /* ZIPFoundation */; }; B6AA44DD296F78670017321C /* GeneralTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6AA44DC296F78670017321C /* GeneralTab.swift */; }; /* End PBXBuildFile section */ @@ -174,6 +180,13 @@ remoteGlobalIDString = 807D7B0629835756007942B4; remoteInfo = tophatctl; }; + 80D648532CB0E20C00135729 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7F35024724A5060500EE76EA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 80D6484C2CB0E20C00135729; + remoteInfo = TophatBaseExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -197,6 +210,27 @@ name = "Copy tophatctl"; runOnlyForDeploymentPostprocessing = 0; }; + 80B48DE82C8BCBBE00897317 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + 80D648552CB0E20C00135729 /* TophatCoreExtension.appex in CopyFiles */, + 80B48DE92C8BCBC400897317 /* com.shopify.Tophat.extension.appextensionpoint in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 80B48E0A2C8BCC5C00897317 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -208,13 +242,14 @@ 7F35026424A5060600EE76EA /* TophatTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TophatTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7F35026824A5060700EE76EA /* TophatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TophatTests.swift; sourceTree = ""; }; 7F35026A24A5060700EE76EA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8005282D2CB7185E00226174 /* TophatKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = TophatKit; sourceTree = ""; }; 8006E7E02943C9190089805E /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 8006E7E22943C95D0089805E /* MenuItemButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemButtonStyle.swift; sourceTree = ""; }; 8006E7E42943C9970089805E /* SectionHeadingTextStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionHeadingTextStyle.swift; sourceTree = ""; }; 8006E7E62943C9AA0089805E /* ToggleableRowIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableRowIcon.swift; sourceTree = ""; }; 8006E7E82943C9B80089805E /* ToggleableRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableRow.swift; sourceTree = ""; }; 8006E7EC2943CA250089805E /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panel.swift; sourceTree = ""; }; - 8020A6DD297F301700FEA490 /* URLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandler.swift; sourceTree = ""; }; + 8020A6DD297F301700FEA490 /* URLReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLReader.swift; sourceTree = ""; }; 8025A5B329845EB5007B1BA0 /* Apps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Apps.swift; sourceTree = ""; }; 802671442947C297001A804D /* MenuHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuHeader.swift; sourceTree = ""; }; 802671462947C33C001A804D /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; @@ -227,26 +262,29 @@ 80301625292C17560016F25E /* AppleApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleApplication.swift; sourceTree = ""; }; 80301627292C17700016F25E /* AndroidApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AndroidApplication.swift; sourceTree = ""; }; 8030162B292C1B490016F25E /* ApplicationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationError.swift; sourceTree = ""; }; - 80318D1B2927EC4D002A5FD9 /* LaunchRequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchRequestBuilder.swift; sourceTree = ""; }; - 80318D1D2927EC54002A5FD9 /* LaunchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchRequest.swift; sourceTree = ""; }; + 80343E462CA39A1A00642D54 /* ExtensionsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionsTab.swift; sourceTree = ""; }; + 80462F8E2CEFA77E002F6E8F /* InfoButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoButton.swift; sourceTree = ""; }; + 80462F902CEFA7EF002F6E8F /* ParameterTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParameterTextField.swift; sourceTree = ""; }; + 80462F932CEFEF17002F6E8F /* TophatExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TophatExtension.swift; sourceTree = ""; }; + 80462F962CEFEF70002F6E8F /* ExtensionHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHost.swift; sourceTree = ""; }; + 80462F982CEFF04A002F6E8F /* AppExtensionIdentity+WithXPCSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppExtensionIdentity+WithXPCSession.swift"; sourceTree = ""; }; 804ECB7B2975C15300DE78F4 /* DevicePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePicker.swift; sourceTree = ""; }; 804ECB7D2975C18300DE78F4 /* DeviceMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMenu.swift; sourceTree = ""; }; 804ECB7F2975C68400DE78F4 /* VisibleWhenButtonHoveredViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleWhenButtonHoveredViewModifier.swift; sourceTree = ""; }; 804F37F82C7CE46F0005A869 /* HostTrustResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTrustResult.swift; sourceTree = ""; }; 804F37FC2C7CEFB00005A869 /* TrustedHostAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedHostAlert.swift; sourceTree = ""; }; 804FF6582914239800147652 /* Collection+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Filter.swift"; sourceTree = ""; }; - 804FFE1729C3BEA5002B64AA /* BadgedURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgedURL.swift; sourceTree = ""; }; 80518F4D29845FB100FB8803 /* Apps+Add.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Apps+Add.swift"; sourceTree = ""; }; 80518F502984681200FB8803 /* Apps+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Apps+Remove.swift"; sourceTree = ""; }; 80518F5229846E4300FB8803 /* NSRunningApplication+IsTophatRunning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRunningApplication+IsTophatRunning.swift"; sourceTree = ""; }; 80518F562984804C00FB8803 /* TophatCtlSymbolicLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TophatCtlSymbolicLinkManager.swift; sourceTree = ""; }; 80518F622984A64F00FB8803 /* OnboardingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindow.swift; sourceTree = ""; }; 80518F672984A6BF00FB8803 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; - 80564B4F298340D1002DC136 /* InstallCoordinatorDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallCoordinatorDelegate.swift; sourceTree = ""; }; 80564B5129834137002DC136 /* TaskStatusReporterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStatusReporterDelegate.swift; sourceTree = ""; }; 80564B532983414D002DC136 /* AlertOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertOptions.swift; sourceTree = ""; }; 80564B5529834203002DC136 /* FileTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTypes.swift; sourceTree = ""; }; - 805FC43129E9BE0A00A78208 /* ArtifactDownloaderError+LocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ArtifactDownloaderError+LocalizedError.swift"; sourceTree = ""; }; + 8058B48B2CA630620075D38D /* URLReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLReaderTests.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 /* PinnedApplicationRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedApplicationRow.swift; sourceTree = ""; }; 80629BED2939818C0077960E /* List+GradientButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "List+GradientButtons.swift"; sourceTree = ""; }; @@ -257,10 +295,10 @@ 80629BF9293981A80077960E /* PinnedApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedApplication.swift; sourceTree = ""; }; 80629BFB293981B10077960E /* CodableAppStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodableAppStorage.swift; sourceTree = ""; }; 80629C21293A8D270077960E /* ProvisioningProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProvisioningProfile.swift; sourceTree = ""; }; + 80691D272CDA9ADE006572CD /* ArtifactDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactDownloader.swift; sourceTree = ""; wrapsLines = 0; }; 8079E3552C850FE0000CB5B3 /* View+ShowDockIconWhenOpen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ShowDockIconWhenOpen.swift"; sourceTree = ""; }; 807D7B0729835756007942B4 /* tophatctl */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = tophatctl; sourceTree = BUILT_PRODUCTS_DIR; }; 807D7B102983576C007942B4 /* TophatCtl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TophatCtl.swift; sourceTree = ""; }; - 807D7B122983576C007942B4 /* FastInstall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastInstall.swift; sourceTree = ""; }; 8086AE3628F9E8680069217E /* TophatModules */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = TophatModules; sourceTree = ""; }; 8090E2022950C1CC003106B9 /* CollapsibleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleSection.swift; sourceTree = ""; }; 8090E2122950E01E003106B9 /* DeviceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceList.swift; sourceTree = ""; }; @@ -282,12 +320,13 @@ 80A66D6A2981BC9900ECBCB6 /* ErrorNotifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorNotifier.swift; sourceTree = ""; }; 80A66D6B2981BC9900ECBCB6 /* MirrorDeviceDisplayAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MirrorDeviceDisplayAction.swift; sourceTree = ""; }; 80A66D732981BD2200ECBCB6 /* UtilityPathPreferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UtilityPathPreferences.swift; sourceTree = ""; }; - 80A66D752981BD3A00ECBCB6 /* GoogleStorageUtilPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleStorageUtilPicker.swift; sourceTree = ""; }; 80A91A0C2981B9F900D8A8B9 /* ShowingAlternateItemsViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShowingAlternateItemsViewModifier.swift; sourceTree = ""; }; 80A91A0F2981BA1300D8A8B9 /* CustomWindowPresentation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomWindowPresentation.swift; sourceTree = ""; }; 80A91A102981BA1300D8A8B9 /* FloatingPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FloatingPanel.swift; sourceTree = ""; }; 80A91A112981BA1300D8A8B9 /* FloatingPanelViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FloatingPanelViewModifier.swift; sourceTree = ""; }; 80A91A152981BA2D00D8A8B9 /* LaunchFromURLPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LaunchFromURLPanel.swift; sourceTree = ""; }; + 80B48DE62C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = com.shopify.Tophat.extension.appextensionpoint; sourceTree = ""; }; + 80B48E122C8BD1D500897317 /* ArtifactRetrievalCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactRetrievalCoordinator.swift; sourceTree = ""; }; 80B536042AB5407700EEB2EF /* SettingsLinkAdditionalActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLinkAdditionalActionButtonStyle.swift; sourceTree = ""; }; 80B7BAD229762C0800267C3C /* InlineButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineButtonStyle.swift; sourceTree = ""; }; 80B7BAD429762CBB00267C3C /* QuickLaunchEmptyState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLaunchEmptyState.swift; sourceTree = ""; }; @@ -304,9 +343,10 @@ 80CBACF129898FFE00F778DD /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 80CBACF5298991CE00F778DD /* AboutWindowViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutWindowViewModifier.swift; sourceTree = ""; }; 80CBACFE2989B8B100F778DD /* ShowOnboardingWindowAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowOnboardingWindowAction.swift; sourceTree = ""; }; + 80D648432CAE254000135729 /* InstallationTicketMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationTicketMachine.swift; sourceTree = ""; }; + 80D6484D2CB0E20C00135729 /* TophatCoreExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = TophatCoreExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 80D71F1B2984CE720006E1BF /* XcodeOnboardingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeOnboardingItem.swift; sourceTree = ""; }; 80D71F1D2984CE850006E1BF /* AndroidStudioOnboardingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AndroidStudioOnboardingItem.swift; sourceTree = ""; }; - 80D71F1F2984CE9F0006E1BF /* GoogleCloudStorageOnboardingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleCloudStorageOnboardingItem.swift; sourceTree = ""; }; 80D71F212984CEBD0006E1BF /* CommandLineHelperOnboardingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandLineHelperOnboardingItem.swift; sourceTree = ""; }; 80D71F232984CEF40006E1BF /* OnboardingItemStatusIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingItemStatusIcon.swift; sourceTree = ""; }; 80D71F252984CF100006E1BF /* OnboardingItemLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingItemLayout.swift; sourceTree = ""; }; @@ -321,7 +361,7 @@ 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 /* LaunchRequestBuilderError+LocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LaunchRequestBuilderError+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 = ""; }; 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 = ""; }; @@ -337,11 +377,27 @@ 80F74E292909FAC80040F026 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = ""; }; 80FAC0892AB29665004A8DB8 /* DeviceError+StyledAlertError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceError+StyledAlertError.swift"; sourceTree = ""; }; 80FDFDB42947D4D9000606AC /* DeviceItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceItem.swift; sourceTree = ""; }; - 80FF03EE29087473008509E0 /* InstallCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallCoordinator.swift; sourceTree = ""; }; - 80FF03F029089975008509E0 /* ArtifactDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactDownloader.swift; sourceTree = ""; }; + 80FF03EE29087473008509E0 /* InstallCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallCoordinator.swift; sourceTree = ""; wrapsLines = 1; }; B6AA44DC296F78670017321C /* GeneralTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralTab.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 80D6485F2CB0E22200135729 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "TophatCoreExtension/Build Providers/HTTPArtifactProvider.swift", + "TophatCoreExtension/Build Providers/ShellScriptArtifactProvider.swift", + TophatCoreExtension/Localizable.xcstrings, + TophatCoreExtension/TophatCoreExtension.swift, + ); + target = 80D6484C2CB0E20C00135729 /* TophatCoreExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 80D648482CB0E1C200135729 /* TophatExtensions */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (80D6485F2CB0E22200135729 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TophatExtensions; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 7F35024C24A5060500EE76EA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -352,13 +408,13 @@ 8006E7F02943D3090089805E /* VisualEffects in Frameworks */, 7F4532AD251A6C4700F2CFC8 /* Logging in Frameworks */, 8090E268296775BE003106B9 /* Collections in Frameworks */, + 805543CC2CB715EB004E1D18 /* TophatUtilities in Frameworks */, 80C18345290232D1008D3B80 /* TophatFoundation in Frameworks */, 80346F042BEBD527002F54BC /* AsyncAlgorithms in Frameworks */, 80DC0FD82C82225600E5C9EE /* Sparkle in Frameworks */, + 8005282F2CB718C200226174 /* TophatKit in Frameworks */, 7F4532AF251A6C4700F2CFC8 /* LoggingOSLog in Frameworks */, - 809BD04729106F5E00FD4043 /* GoogleStorageKit in Frameworks */, 80D2799829005DD000F03649 /* TophatServer in Frameworks */, - 80ED3E8F298421B400A734B7 /* TophatKit in Frameworks */, 803B874B290055C70062F070 /* AndroidDeviceKit in Frameworks */, 8090E201294FA29E003106B9 /* FluidMenuBarExtra in Frameworks */, ); @@ -375,9 +431,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 80ED3E91298421B900A734B7 /* TophatKit in Frameworks */, 807D7B1629835795007942B4 /* ArgumentParser in Frameworks */, 807D7B0F29835762007942B4 /* TophatFoundation in Frameworks */, + 805543CE2CB715F1004E1D18 /* TophatUtilities in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 80D6484A2CB0E20C00135729 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 800528312CB718C700226174 /* TophatKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -391,6 +455,8 @@ 7F35026724A5060700EE76EA /* TophatTests */, 807D7B0829835756007942B4 /* TophatCtl */, 8086AE3628F9E8680069217E /* TophatModules */, + 8005282D2CB7185E00226174 /* TophatKit */, + 80D648482CB0E1C200135729 /* TophatExtensions */, 7F35025024A5060500EE76EA /* Products */, 8086AE3728F9F01F0069217E /* Frameworks */, ); @@ -402,6 +468,7 @@ 7F35024F24A5060500EE76EA /* Tophat.app */, 7F35026424A5060600EE76EA /* TophatTests.xctest */, 807D7B0729835756007942B4 /* tophatctl */, + 80D6484D2CB0E20C00135729 /* TophatCoreExtension.appex */, ); name = Products; sourceTree = ""; @@ -416,6 +483,7 @@ 7F35025624A5060600EE76EA /* Assets.xcassets */, 7F35025E24A5060600EE76EA /* Info.plist */, 7F35025F24A5060600EE76EA /* Tophat.entitlements */, + 80B48DE62C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint */, 80F74E262909FA1B0040F026 /* TophatApp.swift */, ); path = Tophat; @@ -424,6 +492,7 @@ 7F35026724A5060700EE76EA /* TophatTests */ = { isa = PBXGroup; children = ( + 8058B48A2CA6304C0075D38D /* Utilities */, 7F35026824A5060700EE76EA /* TophatTests.swift */, 7F35026A24A5060700EE76EA /* Info.plist */, ); @@ -439,24 +508,34 @@ 8029A080298AF1E90002C579 /* ApplicationIcon.swift */, 80EB5D56297096FB0011DE5F /* InstallStatusMetadata.swift */, 80EB5D50296F68CD0011DE5F /* LaunchContext.swift */, - 80318D1D2927EC54002A5FD9 /* LaunchRequest.swift */, 80629BF9293981A80077960E /* PinnedApplication.swift */, 80629C21293A8D270077960E /* ProvisioningProfile.swift */, ); path = Models; sourceTree = ""; }; + 80462F952CEFEF3C002F6E8F /* Extensions */ = { + isa = PBXGroup; + children = ( + 80462F982CEFF04A002F6E8F /* AppExtensionIdentity+WithXPCSession.swift */, + 80B48E122C8BD1D500897317 /* ArtifactRetrievalCoordinator.swift */, + 80462F962CEFEF70002F6E8F /* ExtensionHost.swift */, + 80462F932CEFEF17002F6E8F /* TophatExtension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 804FF6572914237D00147652 /* Extensions */ = { isa = PBXGroup; children = ( 80EB5D4C296F64F50011DE5F /* ApplicationError+LocalizedError.swift */, 80EB5D52296F6A380011DE5F /* Array+JoinedWithSpaces.swift */, - 805FC43129E9BE0A00A78208 /* ArtifactDownloaderError+LocalizedError.swift */, + 805FC43129E9BE0A00A78208 /* BuildDownloaderError+LocalizedError.swift */, 802671462947C33C001A804D /* Bundle+Extensions.swift */, 804FF6582914239800147652 /* Collection+Filter.swift */, 80EB5D4A296F64D70011DE5F /* DeviceError+LocalizedError.swift */, 80FAC0892AB29665004A8DB8 /* DeviceError+StyledAlertError.swift */, - 80EB5D4E296F658E0011DE5F /* LaunchRequestBuilderError+LocalizedError.swift */, + 80EB5D4E296F658E0011DE5F /* InstallationTicketMachineError+LocalizedError.swift */, 80B7BAD629762D8900267C3C /* NSApplication+ShowSettingsWindow.swift */, 80629BF72939819F0077960E /* String+IsValidURL.swift */, 8079E3552C850FE0000CB5B3 /* View+ShowDockIconWhenOpen.swift */, @@ -479,7 +558,6 @@ 80D71F1D2984CE850006E1BF /* AndroidStudioOnboardingItem.swift */, 80D71F212984CEBD0006E1BF /* CommandLineHelperOnboardingItem.swift */, 80D71F2D2985D11A0006E1BF /* CustomizeLocationsButton.swift */, - 80D71F1F2984CE9F0006E1BF /* GoogleCloudStorageOnboardingItem.swift */, 80D71F252984CF100006E1BF /* OnboardingItemLayout.swift */, 80D71F232984CEF40006E1BF /* OnboardingItemStatusIcon.swift */, 80D71F2B2985C69B0006E1BF /* OnboardingPopoverContent.swift */, @@ -492,15 +570,26 @@ path = Onboarding; sourceTree = ""; }; + 8058B48A2CA6304C0075D38D /* Utilities */ = { + isa = PBXGroup; + children = ( + 8058B48B2CA630620075D38D /* URLReaderTests.swift */, + ); + path = Utilities; + sourceTree = ""; + }; 80629BEB2939818C0077960E /* Settings */ = { isa = PBXGroup; children = ( - 80B7BAE229773AFA00267C3C /* Locations */, 80629BEE2939818C0077960E /* AddPinnedApplicationSheet.swift */, 80629BEF2939818C0077960E /* AppsTab.swift */, 809874AB294BB37A00EC541E /* DevicesTab.swift */, + 80343E462CA39A1A00642D54 /* ExtensionsTab.swift */, B6AA44DC296F78670017321C /* GeneralTab.swift */, + 80462F8E2CEFA77E002F6E8F /* InfoButton.swift */, + 80B7BAE229773AFA00267C3C /* Locations */, 80B7BAE029770C5800267C3C /* LocationsTab.swift */, + 80462F902CEFA7EF002F6E8F /* ParameterTextField.swift */, 80629BEC2939818C0077960E /* PinnedApplicationRow.swift */, ); path = Settings; @@ -509,7 +598,6 @@ 80629BFF293989600077960E /* Generic */ = { isa = PBXGroup; children = ( - 804FFE1729C3BEA5002B64AA /* BadgedURL.swift */, 8090E2022950C1CC003106B9 /* CollapsibleSection.swift */, 80629BF02939818C0077960E /* GradientButton.swift */, 80B7BAD229762C0800267C3C /* InlineButtonStyle.swift */, @@ -546,7 +634,6 @@ children = ( 80518F4C29845F9E00FB8803 /* Apps */, 8025A5B329845EB5007B1BA0 /* Apps.swift */, - 807D7B122983576C007942B4 /* FastInstall.swift */, 80F3804429843A8100A9350F /* Install.swift */, ); path = Commands; @@ -587,7 +674,6 @@ isa = PBXGroup; children = ( 80B7BAE729773E5100267C3C /* AndroidSDKPicker.swift */, - 80A66D752981BD3A00ECBCB6 /* GoogleStorageUtilPicker.swift */, 80B7BAE329773B0C00267C3C /* JavaHomePicker.swift */, 80B7BAE529773C3D00267C3C /* LocationDetectModePicker.swift */, 80B7BAEB297744B500267C3C /* LocationPicker.swift */, @@ -668,9 +754,10 @@ 80FF03ED29087440008509E0 /* Utilities */ = { isa = PBXGroup; children = ( + 80462F952CEFEF3C002F6E8F /* Extensions */, 8090E2552967741F003106B9 /* Status Reporting */, 80EB5D43296F59000011DE5F /* Tasks */, - 80FF03F029089975008509E0 /* ArtifactDownloader.swift */, + 80691D272CDA9ADE006572CD /* ArtifactDownloader.swift */, 8030161F292874B70016F25E /* ArtifactUnpacker.swift */, 80629BFB293981B10077960E /* CodableAppStorage.swift */, 809BD034290C3A5200FD4043 /* DeviceManager.swift */, @@ -679,10 +766,9 @@ 80564B5529834203002DC136 /* FileTypes.swift */, 804F37F82C7CE46F0005A869 /* HostTrustResult.swift */, 80FF03EE29087473008509E0 /* InstallCoordinator.swift */, - 80564B4F298340D1002DC136 /* InstallCoordinatorDelegate.swift */, + 80D648432CAE254000135729 /* InstallationTicketMachine.swift */, 809C8570297B056F004CE6A2 /* LaunchAppAction.swift */, 80CBACED298988B700F778DD /* LaunchAtLoginController.swift */, - 80318D1B2927EC4D002A5FD9 /* LaunchRequestBuilder.swift */, 80A66D6B2981BC9900ECBCB6 /* MirrorDeviceDisplayAction.swift */, 80F380422984226800A9350F /* NotificationHandler.swift */, 6038B795B47D50A397AF03DB /* Notifications.swift */, @@ -692,7 +778,7 @@ 80518F562984804C00FB8803 /* TophatCtlSymbolicLinkManager.swift */, 804F37FC2C7CEFB00005A869 /* TrustedHostAlert.swift */, 80DC0FD92C822E7F00E5C9EE /* UpdateController.swift */, - 8020A6DD297F301700FEA490 /* URLHandler.swift */, + 8020A6DD297F301700FEA490 /* URLReader.swift */, 80A66D732981BD2200ECBCB6 /* UtilityPathPreferences.swift */, ); path = Utilities; @@ -705,16 +791,19 @@ isa = PBXNativeTarget; buildConfigurationList = 7F35027824A5060700EE76EA /* Build configuration list for PBXNativeTarget "Tophat" */; buildPhases = ( + 80B48DE82C8BCBBE00897317 /* CopyFiles */, 809429C128FA01F900605490 /* SwiftLint */, 7F35024B24A5060500EE76EA /* Sources */, 7F35024C24A5060500EE76EA /* Frameworks */, 807D7B19298357AE007942B4 /* Copy tophatctl */, 7F35024D24A5060500EE76EA /* Resources */, + 80B48E0A2C8BCC5C00897317 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( 807D7B18298357A7007942B4 /* PBXTargetDependency */, + 80D648542CB0E20C00135729 /* PBXTargetDependency */, ); name = Tophat; packageProductDependencies = ( @@ -724,14 +813,14 @@ 80D2799729005DD000F03649 /* TophatServer */, 80C18344290232D1008D3B80 /* TophatFoundation */, 80F74E242909E8EA0040F026 /* AppleDeviceKit */, - 809BD04629106F5E00FD4043 /* GoogleStorageKit */, 8006E7EF2943D3090089805E /* VisualEffects */, 8090E200294FA29E003106B9 /* FluidMenuBarExtra */, 8090E267296775BE003106B9 /* Collections */, B6AA44CE296DF8EF0017321C /* ZIPFoundation */, - 80ED3E8E298421B400A734B7 /* TophatKit */, 80346F032BEBD527002F54BC /* AsyncAlgorithms */, 80DC0FD72C82225600E5C9EE /* Sparkle */, + 805543CB2CB715EB004E1D18 /* TophatUtilities */, + 8005282E2CB718C200226174 /* TophatKit */, ); productName = Tophat; productReference = 7F35024F24A5060500EE76EA /* Tophat.app */; @@ -771,12 +860,32 @@ packageProductDependencies = ( 807D7B0E29835762007942B4 /* TophatFoundation */, 807D7B1529835795007942B4 /* ArgumentParser */, - 80ED3E90298421B900A734B7 /* TophatKit */, + 805543CD2CB715F1004E1D18 /* TophatUtilities */, ); productName = tophatctl; productReference = 807D7B0729835756007942B4 /* tophatctl */; productType = "com.apple.product-type.tool"; }; + 80D6484C2CB0E20C00135729 /* TophatCoreExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 80D648572CB0E20C00135729 /* Build configuration list for PBXNativeTarget "TophatCoreExtension" */; + buildPhases = ( + 80D648492CB0E20C00135729 /* Sources */, + 80D6484A2CB0E20C00135729 /* Frameworks */, + 80D6484B2CB0E20C00135729 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TophatCoreExtension; + packageProductDependencies = ( + 800528302CB718C700226174 /* TophatKit */, + ); + productName = TophatBaseExtension; + productReference = 80D6484D2CB0E20C00135729 /* TophatCoreExtension.appex */; + productType = "com.apple.product-type.extensionkit-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -784,7 +893,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1420; + LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1540; ORGANIZATIONNAME = Shopify; TargetAttributes = { @@ -798,6 +907,9 @@ 807D7B0629835756007942B4 = { CreatedOnToolsVersion = 14.2; }; + 80D6484C2CB0E20C00135729 = { + CreatedOnToolsVersion = 16.0; + }; }; }; buildConfigurationList = 7F35024A24A5060500EE76EA /* Build configuration list for PBXProject "Tophat" */; @@ -827,6 +939,7 @@ 7F35024E24A5060500EE76EA /* Tophat */, 7F35026324A5060600EE76EA /* TophatTests */, 807D7B0629835756007942B4 /* tophatctl */, + 80D6484C2CB0E20C00135729 /* TophatCoreExtension */, ); }; /* End PBXProject section */ @@ -837,6 +950,7 @@ buildActionMask = 2147483647; files = ( 7F35025724A5060600EE76EA /* Assets.xcassets in Resources */, + 80B48DE72C8BCBA300897317 /* com.shopify.Tophat.extension.appextensionpoint in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -847,6 +961,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 80D6484B2CB0E20C00135729 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -885,7 +1006,8 @@ 80629BF82939819F0077960E /* String+IsValidURL.swift in Sources */, 809874AC294BB37A00EC541E /* DevicesTab.swift in Sources */, 80B7BAE129770C5800267C3C /* LocationsTab.swift in Sources */, - 80FF03F129089975008509E0 /* ArtifactDownloader.swift in Sources */, + 80462F972CEFEF73002F6E8F /* ExtensionHost.swift in Sources */, + 80462F942CEFEF1A002F6E8F /* TophatExtension.swift in Sources */, 804ECB7E2975C18300DE78F4 /* DeviceMenu.swift in Sources */, 80CBACFF2989B8B100F778DD /* ShowOnboardingWindowAction.swift in Sources */, 80F74E272909FA1B0040F026 /* TophatApp.swift in Sources */, @@ -894,6 +1016,7 @@ 80EB5D49296F5AAF0011DE5F /* InstallApplicationTask.swift in Sources */, 8006E7E32943C95D0089805E /* MenuItemButtonStyle.swift in Sources */, 8006E7E92943C9B80089805E /* ToggleableRow.swift in Sources */, + 80D648442CAE254300135729 /* InstallationTicketMachine.swift in Sources */, 804F37FD2C7CEFB00005A869 /* TrustedHostAlert.swift in Sources */, 80CBACF229898FFE00F778DD /* AboutView.swift in Sources */, 80629BF52939818C0077960E /* AppsTab.swift in Sources */, @@ -902,9 +1025,9 @@ 80CBACEE298988B700F778DD /* LaunchAtLoginController.swift in Sources */, 8090E2612967749F003106B9 /* TophatProgressViewStyle.swift in Sources */, 80B7BAD329762C0800267C3C /* InlineButtonStyle.swift in Sources */, - 80A66D762981BD3A00ECBCB6 /* GoogleStorageUtilPicker.swift in Sources */, 80EB5D47296F59270011DE5F /* PrepareDeviceTask.swift in Sources */, 8029B6A72AC239FE00BD1D30 /* DeviceIsLockedViewModifier.swift in Sources */, + 80343E472CA39A1D00642D54 /* ExtensionsTab.swift in Sources */, 80D71F242984CEF40006E1BF /* OnboardingItemStatusIcon.swift in Sources */, 8090E25F29677489003106B9 /* MainProgressView.swift in Sources */, 804F37F92C7CE46F0005A869 /* HostTrustResult.swift in Sources */, @@ -916,7 +1039,6 @@ 809BD03E290CA40900FD4043 /* DeviceSelectionManager.swift in Sources */, 80D71F282984CF240006E1BF /* OnboardingTaskList.swift in Sources */, 80A91A0D2981B9F900D8A8B9 /* ShowingAlternateItemsViewModifier.swift in Sources */, - 80318D1E2927EC54002A5FD9 /* LaunchRequest.swift in Sources */, 80A91A162981BA2D00D8A8B9 /* LaunchFromURLPanel.swift in Sources */, 8090E2032950C1CC003106B9 /* CollapsibleSection.swift in Sources */, 80EB5D5F2970A7940011DE5F /* PinnedApplicationState.swift in Sources */, @@ -927,7 +1049,7 @@ 80D71F2E2985D11A0006E1BF /* CustomizeLocationsButton.swift in Sources */, 80629BFA293981A80077960E /* PinnedApplication.swift in Sources */, 80EB5D57297096FB0011DE5F /* InstallStatusMetadata.swift in Sources */, - 8020A6DE297F301700FEA490 /* URLHandler.swift in Sources */, + 8020A6DE297F301700FEA490 /* URLReader.swift in Sources */, 80B7BAEC297744B500267C3C /* LocationPicker.swift in Sources */, 6038BC5B50B9DB89F651A6EA /* Notifications.swift in Sources */, 809BD035290C3A5200FD4043 /* DeviceManager.swift in Sources */, @@ -940,7 +1062,6 @@ 80ED55462971CB3200B3AEBA /* MirrorDeviceDisplayTask.swift in Sources */, 80EB5D53296F6A380011DE5F /* Array+JoinedWithSpaces.swift in Sources */, 80B536052AB5407700EEB2EF /* SettingsLinkAdditionalActionButtonStyle.swift in Sources */, - 804FFE1829C3BEA5002B64AA /* BadgedURL.swift in Sources */, 80564B542983414D002DC136 /* AlertOptions.swift in Sources */, 80D71F2A2985C4EE0006E1BF /* ScreenCopyOnboardingItem.swift in Sources */, 8090E25A2967741F003106B9 /* TaskProgress.swift in Sources */, @@ -952,17 +1073,21 @@ 80B7BAEA29773EA600267C3C /* ScreenCopyPicker.swift in Sources */, 80B7BAD729762D8900267C3C /* NSApplication+ShowSettingsWindow.swift in Sources */, 8006E7E12943C9190089805E /* Theme.swift in Sources */, + 80B48E132C8BD1D500897317 /* ArtifactRetrievalCoordinator.swift in Sources */, 8030162C292C1B490016F25E /* ApplicationError.swift in Sources */, 80A91A122981BA1300D8A8B9 /* CustomWindowPresentation.swift in Sources */, + 80462F912CEFA7F2002F6E8F /* ParameterTextField.swift in Sources */, 8006E7E72943C9AA0089805E /* ToggleableRowIcon.swift in Sources */, 8006E7ED2943CA250089805E /* Panel.swift in Sources */, 80564B5629834203002DC136 /* FileTypes.swift in Sources */, 80EB5D4B296F64D70011DE5F /* DeviceError+LocalizedError.swift in Sources */, 80564B5229834137002DC136 /* TaskStatusReporterDelegate.swift in Sources */, 8090E25D2967741F003106B9 /* TaskStatus.swift in Sources */, + 80691D282CDA9AE9006572CD /* ArtifactDownloader.swift in Sources */, 80629BF42939818C0077960E /* AddPinnedApplicationSheet.swift in Sources */, 804ECB7C2975C15300DE78F4 /* DevicePicker.swift in Sources */, - 805FC43229E9BE0A00A78208 /* ArtifactDownloaderError+LocalizedError.swift in Sources */, + 805FC43229E9BE0A00A78208 /* BuildDownloaderError+LocalizedError.swift in Sources */, + 80462F992CEFF04F002F6E8F /* AppExtensionIdentity+WithXPCSession.swift in Sources */, 80629C22293A8D270077960E /* ProvisioningProfile.swift in Sources */, 80D71F1C2984CE720006E1BF /* XcodeOnboardingItem.swift in Sources */, 802671452947C297001A804D /* MenuHeader.swift in Sources */, @@ -972,31 +1097,29 @@ 80CBACF6298991CE00F778DD /* AboutWindowViewModifier.swift in Sources */, 80A66D6E2981BC9900ECBCB6 /* MirrorDeviceDisplayAction.swift in Sources */, 80B7BAE629773C3D00267C3C /* LocationDetectModePicker.swift in Sources */, - 80318D1C2927EC4D002A5FD9 /* LaunchRequestBuilder.swift in Sources */, 80D71F222984CEBD0006E1BF /* CommandLineHelperOnboardingItem.swift in Sources */, 80629BF32939818C0077960E /* List+GradientButtons.swift in Sources */, 8006E7E52943C9970089805E /* SectionHeadingTextStyle.swift in Sources */, 80B7BAE829773E5100267C3C /* AndroidSDKPicker.swift in Sources */, 809C8575297B0FA9004CE6A2 /* ShowingAdvancedOptionsViewModifier.swift in Sources */, 80301620292874B70016F25E /* ArtifactUnpacker.swift in Sources */, - 80EB5D4F296F658E0011DE5F /* LaunchRequestBuilderError+LocalizedError.swift in Sources */, + 80EB5D4F296F658E0011DE5F /* InstallationTicketMachineError+LocalizedError.swift in Sources */, 8079E3562C850FE0000CB5B3 /* View+ShowDockIconWhenOpen.swift in Sources */, 80A66D6D2981BC9900ECBCB6 /* ErrorNotifier.swift in Sources */, 80FF03EF29087473008509E0 /* InstallCoordinator.swift in Sources */, B6AA44DD296F78670017321C /* GeneralTab.swift in Sources */, - 80D71F202984CE9F0006E1BF /* GoogleCloudStorageOnboardingItem.swift in Sources */, 8029A081298AF1E90002C579 /* ApplicationIcon.swift in Sources */, 80EB5D4D296F64F50011DE5F /* ApplicationError+LocalizedError.swift in Sources */, 80629BF62939818C0077960E /* GradientButton.swift in Sources */, 80F380432984226800A9350F /* NotificationHandler.swift in Sources */, 80D71F1E2984CE850006E1BF /* AndroidStudioOnboardingItem.swift in Sources */, + 80462F8F2CEFA780002F6E8F /* InfoButton.swift in Sources */, 80301626292C17560016F25E /* AppleApplication.swift in Sources */, 802671492947C72C001A804D /* QuickLaunchAppView.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 */, - 80564B50298340D1002DC136 /* InstallCoordinatorDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1004,6 +1127,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8058B48C2CA630620075D38D /* URLReaderTests.swift in Sources */, 7F35026924A5060700EE76EA /* TophatTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1018,12 +1142,18 @@ 80F3804929843A9F00A9350F /* Install.swift in Sources */, 807D7B132983576C007942B4 /* TophatCtl.swift in Sources */, 8025A5B429845EB5007B1BA0 /* Apps.swift in Sources */, - 807D7B1B298357D4007942B4 /* FastInstall.swift in Sources */, 80518F4F2984600900FB8803 /* Apps+Add.swift in Sources */, 80F3804829843A9200A9350F /* Platform+ExpressibleByArgument.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 80D648492CB0E20C00135729 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1037,6 +1167,11 @@ target = 807D7B0629835756007942B4 /* tophatctl */; targetProxy = 807D7B17298357A7007942B4 /* PBXContainerItemProxy */; }; + 80D648542CB0E20C00135729 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 80D6484C2CB0E20C00135729 /* TophatCoreExtension */; + targetProxy = 80D648532CB0E20C00135729 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1099,6 +1234,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -1155,6 +1291,7 @@ MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; name = Release; @@ -1162,6 +1299,7 @@ 7F35027924A5060700EE76EA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_ENTITLEMENTS = Tophat/Tophat.entitlements; @@ -1190,6 +1328,7 @@ 7F35027A24A5060700EE76EA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_ENTITLEMENTS = Tophat/Tophat.entitlements; @@ -1300,6 +1439,73 @@ }; name = Release; }; + 80D648582CB0E20C00135729 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = TophatExtensions/TophatCoreExtension/TophatCoreExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = A7XGC83MZE; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TophatExtensions/TophatCoreExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Tophat Core"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shopify. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.shopify.Tophat.TophatCoreExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 80D648592CB0E20C00135729 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = TophatExtensions/TophatCoreExtension/TophatCoreExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = A7XGC83MZE; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TophatExtensions/TophatCoreExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Tophat Core"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shopify. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.shopify.Tophat.TophatCoreExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1339,6 +1545,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 80D648572CB0E20C00135729 /* Build configuration list for PBXNativeTarget "TophatCoreExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 80D648582CB0E20C00135729 /* Debug */, + 80D648592CB0E20C00135729 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -1427,6 +1642,14 @@ package = 7FA71E0824C95CAC001C9574 /* XCRemoteSwiftPackageReference "swift-log" */; productName = Logging; }; + 8005282E2CB718C200226174 /* TophatKit */ = { + isa = XCSwiftPackageProductDependency; + productName = TophatKit; + }; + 800528302CB718C700226174 /* TophatKit */ = { + isa = XCSwiftPackageProductDependency; + productName = TophatKit; + }; 8006E7EF2943D3090089805E /* VisualEffects */ = { isa = XCSwiftPackageProductDependency; package = 8006E7EE2943D3090089805E /* XCRemoteSwiftPackageReference "VisualEffects" */; @@ -1441,6 +1664,14 @@ isa = XCSwiftPackageProductDependency; productName = AndroidDeviceKit; }; + 805543CB2CB715EB004E1D18 /* TophatUtilities */ = { + isa = XCSwiftPackageProductDependency; + productName = TophatUtilities; + }; + 805543CD2CB715F1004E1D18 /* TophatUtilities */ = { + isa = XCSwiftPackageProductDependency; + productName = TophatUtilities; + }; 807D7B0E29835762007942B4 /* TophatFoundation */ = { isa = XCSwiftPackageProductDependency; productName = TophatFoundation; @@ -1460,10 +1691,6 @@ package = 8090E266296775BE003106B9 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = Collections; }; - 809BD04629106F5E00FD4043 /* GoogleStorageKit */ = { - isa = XCSwiftPackageProductDependency; - productName = GoogleStorageKit; - }; 80C18344290232D1008D3B80 /* TophatFoundation */ = { isa = XCSwiftPackageProductDependency; productName = TophatFoundation; @@ -1477,14 +1704,6 @@ package = 80DC0FD62C82225600E5C9EE /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; - 80ED3E8E298421B400A734B7 /* TophatKit */ = { - isa = XCSwiftPackageProductDependency; - productName = TophatKit; - }; - 80ED3E90298421B900A734B7 /* TophatKit */ = { - isa = XCSwiftPackageProductDependency; - productName = TophatKit; - }; 80F74E242909E8EA0040F026 /* AppleDeviceKit */ = { isa = XCSwiftPackageProductDependency; productName = AppleDeviceKit; diff --git a/Tophat.xcodeproj/xcshareddata/xcschemes/TophatTests.xcscheme b/Tophat.xcodeproj/xcshareddata/xcschemes/TophatTests.xcscheme index 99a8dfd..bc6adda 100644 --- a/Tophat.xcodeproj/xcshareddata/xcschemes/TophatTests.xcscheme +++ b/Tophat.xcodeproj/xcshareddata/xcschemes/TophatTests.xcscheme @@ -10,7 +10,18 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES" + onlyGenerateCoverageForSpecifiedTargets = "YES"> + + + + diff --git a/Tophat/Assets.xcassets/GoogleCloudSDK.imageset/Contents.json b/Tophat/Assets.xcassets/GoogleCloudSDK.imageset/Contents.json deleted file mode 100644 index f58837e..0000000 --- a/Tophat/Assets.xcassets/GoogleCloudSDK.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "GoogleCloudSDK.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Tophat/Assets.xcassets/GoogleCloudSDK.imageset/GoogleCloudSDK.png b/Tophat/Assets.xcassets/GoogleCloudSDK.imageset/GoogleCloudSDK.png deleted file mode 100644 index 489735c0c40799004c08a01ff525c4965042fd32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10489 zcmb7q1yo$iwrw{J1b5fQ9h%_on&1St#tH839-QFrt|4e}2_Xav5ZoaU++7|y_uPBV z{qNoX{@*>SYwcBQ&Q){O-qm~T(UHoEGN?#INB{r;RZdn?^|=rD-4NiPzx#xt!_OV8 zg_wdE08k%?d=G_tJ_nn~sww~gUbN3~AppSb@Aw@6z!d@j>>2?8{AmCHzC%`~s5#`J)#zwKp~cD_h#T*gIR=JAfr5z%L=}ykL4wD?3wrcV`9w0F(#(D`y_e zzxV?3K!4f4i347y5IuK@4zk+L001)f?*;^9WDz`9z}-q+%SB7!HNT0yEi3eQQdSRJ zhuyYR<1JDg7t>`Amq?(#6GrpN-Ak-JR8)gVo;2f{mSzkB<%VlI`V7mgfi- zXHPp9s0WLkGu1yx{>dY0=4|3*<=|pvZwLO(3pKWPbrGVZ{GI4u$3N!jVrBkUCOhXp zWjzaI`#r+O&I)1spJ2~o6%_t8!+*nhKpp;o+1mb(NM{#Sv;UCspGaqQPX{wLRWoOM zS0@v*=Wwe3Fopm71pZZ%XUT%U)$nWo=CpDBT^C`Vmx6yt{5R)c1^Nd->wg0L-T8k4 zl$@;0p4a4$4D5d<_`C0K@qd(%U((FU%FWDF+R5JbkEL>eIysyD)-Oc)&jS2i$8RZr zRFL1w&e;WOXJRHNDg4aDYGq~0&&MMn$<5Bk%OwrrU}u*S7h`|PE5R!*CLzr&&MC$5 zEcm}c|C-NV%%=7xuC~uA{$w`&i<$3#WBwcTFJ?iu|0ea1Oa32MqTru{|1qife@^<} zpnpsHk1@IbSS7LljrMQQf2ZY_a596s*gL7)+uI2LuY1oQ>i-S$Cz~MK?_2Y4x9UIk zfj_*@8-Xy=GxDFCiZBv#d0rv_K*cX7DW>iLJkUqZ(tG16vMp@xZtm6~_k|W#Zv0DO z9U~%fuSAdBhk$Y^onMj*hSqyZWVV>f*2r9z@SbIj)44{CV*b|^q2|`QJ>-nkpFs4p zoRvm$n-mzbL)H|vOX*A5cf1czW7z@%cYaHAOWj$Uhl_))+dkWOooBu)H>2*OxCI#% z1ZBo~?*bX&|6dG)ZG!mP?MnuVqAv*PBPG!|MDr8VG?{yQj=B-dvFau!4$E^4YAxnS zXQqxh@@Bs6wE@)GabuNMcqU+~kbG5!SLB3HNw^AeZD?y1RfUs<1m9pgEZ7m&t3}j4 znQ$$Y%Js@~d%$}49y|F|DN&pyRFoI!+loo*#k!NGtU8cp$f%9cJGQY8F_osvIZ_et zWG+5YHEcfde?{isUfRAmvCBN>&YVU59wkr8o6!u7)@z$)B&wKB)AOqb9Z?fjh#;yY zuP~HI`Wr-a0eqvASDC1Xe)tNC!*T4`b0xLhM^15BkY$k!X=3{Iw8%N#^f}jU)gWH} zTl|QZ$3vPLE`?(6<+t4xj5+09JA|E_lmf=f~W-Cb%of8zmTZTe-XlXZMeUYtg@pXgVb;-5P2R8hGnV_P zufsyr7pf*6o`n%oQr6ScZ0&T$m?}A*j4#NDT}SJ0?AZk!v5&7U!0mHeUcG9J)NOpf z9ax?DLx)ZZ%b%%C!RV%}HWHF!S<~IflQq5rrqPSx|_r<#B9<^vP`<-iMn+DAp z6My3cStjHnhFRX}Xc(9?-emKo%mu9Bxe8v~)gO8m_Uz;qaJ_Yxv`8AMO3LWDR+E zl8;CDLZw2RW}9dX=R8M?XNvp1=H}Yv=14VP%?nIslw`B&%Q7Lm)7;_B0yrCP~JpHkFURSiqj>{mP0mBSL+B6N7S9_^KPY@CTxWT<{>XDPB zz)pXY^Lxq@|4--6>+{X}_zOokSuOgXS~Od_+OJb`nw4SV5lhryUuVK1xcY5NF)1Dk z?L~%|ab9`|OD%uo_TW!gFvRcoE8l*9J=2Q=k<31o6q6nNfZeqH6P;I{)!#+J$sUPY z%q_zv$SMJlX?4)Iy5N2ykodKkEO6F87}H--Wj_^j#a+%vr@d)=lXOy%^<(JM&Vb=! zD)q_aBqx7a3v2*%(CW=Sf}_#FTpXv?Pq^^>Gsh!T%1gO>?w$Lw$hd-SsxOjL3D?)8 zA@@OVGNW5NT4ffvr}WE;Uzn#J2YuG>;*7kkI>ibIB$mCK$?34_w0#qae)JR4Pxnst zJ}6t(pzgA%Mxi~SOiI>?rfJZjTYalzB`X2m41Xb$Mu8%A!A6V*WJO;?sWEBuJ)#sDG90vpjn1eOO^d-skPK0#QtGgWdr28MRTMcsU&RH8M}ECE4vrvPW~JSz(~cVPm9W-4(Q}^M!pXWQr=q z&<9ttW$QqC4?3IF3`$UFhXcWruW3KJn3ECuY^dI0*k$JKW2b5b*a?1hpNx+0vSie^ z!OCl_0ESqy`zbPF0^`NTJ*s+CE+=XfQPc2Qj%4j)3B|W3z46Oyrr=jVNSJJ?86L9u z^=mc^E2%o^JVtm^|E=DJ)LHhMyE+2lptZg92tG*yqHi9S34X5J@z=$UkHf<;o*Oxl;u#qN{BM8>FsGFUg3OTGyY=Xtb;$UQFRq;8A}7% zwj+c$U-S!XZPvxJU3wIaptuF6bQ}jwwA-Y!iJC5=BP`7mnKL}BQxJ+L^6?*!B71Br zR9Am;JW?b>P=#)ulupz*W4vU=$c3HG+wWd2^mZTT_1{rOuZjn$G7Y{n)%!;Wft9P6qx#Y*fc zLTDRr>Q;X%p$-4_0qV7__HqhUHuQZb)Fw*biQJGXHC{nm)M=nU*SFl7ASIq z@lp&&c%AbfIdrn5X@Qoa6D{I9Zm+VT!XXX^_1+@Z=oIuKY%*Bd75k#x_BgdI6iRvARW2u8 zbyd^dzTsBIQu~2lB4X5h95C8?PhvuaIskK|MlnvAcM(WZfxolcdqNcJTUU9B6W4Jq z6tDq4+&-qf<={`#)HTuis@mK5;7Tn_h(17&WhNOpnW&Ze)inB2{qx(~E4Jm%YHg-%MHTv{FZD}TVOfujng#|YW2nZFXv0AS3`z9`B*Rn zEgj-afL9PXo1TF;>;$c6jwIWNCN*rPq<9|vC&TQZ@b+V(a$$&Q9L0TMiE=o~ipL1& zVz84x)M5ngRX^1jgCurj3aR`ziNlL|N9-U@+b)6cnI;O=d&t&yLSDkW=a^Pnw0LwA zo)}GDyLABEeP()HW55T>Px*(4ig=Apic5SrL-b&M5=F5MgTSJuyhnjA{AN=GaZ;K474Y$dmmXLgJ$Qp-JZ@)Hp2c z`1G!`?`+*^Db_HmfIZGfizeEnG3|#`2DKl5p>Z|O(4dapueT~;mUJf*C^aPSqm$|pZXO4YEEC_J+ zK4*%a<91NG?W#ao_?e?ORWOL*EZ4CVT2{NG!xh)qUWPDZ(+d*kZ7{!{$L8q4x4tvKIyCMz|?8mb!<1wyOU9ADKB3YnJih%ux^-_66|SZqJ|q|YBOsl~N^ z@Pp)n;a+&nrk?!Q0@fX%A@MpM0ZYckI1Mf?QAf!ZXCi^&<@60 zFG%rS2OpR_lB8Yer{T{@6c~!ikA&rm!fUNEp}{9%AJ*4{w6fdqqwBDvkLk|y*N*8? zzo3|;kc)-IR>G7|cIH14(XBj0KmQINO55WwCZlzaC=%{6P=swt0N>`e@G^`EeXM-^ zPy_elG}V_XfhP&s*n1}cI8lPKlLki*CXx*2tRr~4?iP7acIV|?eMhiT%n6o=)*i*- z2wBs!wmWDWkDwvD%qH8dU~uOQg7Z8wNkZgW?c5c(X*T6R{#054Pm7v%_$D)>G4|DC zUsNjd{emW5|MJa8Iul=bsdl`B@r4XvNyL<62Ww+X^GEfapdK82rs}l&)=$%&pt@dH zfK^?G6YDPw=`iO6#Cf|4|9*Gx!$77~-q?P!9_4`kQYE9n=m9qK>=F0CH7n%TFBRSa zx$S>+$PHBrWc3DLPM<1@2lI^ z)3#zwwoP`!#t6W~m9gEAwF&VxAxz?OdQ-{48zVq52(GGHw|0`fSb!*EiZ}`-=ZMaV zt@N(Vvn!D82&Q{h7Z37vIYqRHA%Jk8JBIb30~3q5h-D+rV~ks%EVPAyR3$7xO%<-$ z{fWu=;SnKPlp&AiXZ$zp_WU=%`%w)+)&Qru(lBz9Mt{tONt$+-e?mEpaKz9BDbNK^*?_cmPTu}54n4@8$|lBTLRfb*dcvv z-s?l|LXU`Z9GG$t)*5C)m7SZ6Yu7=76FEG1hi-o0WLrJFYjDGKo0-tk;6d^kE~jCm z{-BuX^x_M0v~Wrt9F$?2_g#d}UT3}#?y+}Qn|_}oDdmGs=3z}<0tTpw@__D2P5uSm zelzS-XD=vIu*O{C5N0Y;d2pL%XRJywse8I@CiGaWt$ui%72lR0?M%L#0)4Is+^$DY zTxISPzM~5f+B5VRkw7$EG`PA^0^gkZEprozRi+dWcGui3xKV-=k@Dm8% zZ5}n11jiO49lkf9@W(JGKG7$11TSe4?9IBK09ydZsy(F)e~B3_ISu!YqeBnd!C6(1ijBr=yNxF*no})MO9AC;e3KDb? z!MjS9X!NWPASpbimy$GZnUaU3XtXqy-5)a+oc)IwHXI~Xzp4llu5$u{zwf%Qo~Q45 z?P~NmRLunMkVjrm;_w74r&T&^lZ)yqkGOc>(L35Hn4v8d9$>^G!4IILBm=VaM4cXH zQGbx|q4y|cO_F8Ui@GKl)D4EnsKZ@FJ)GuUaYBsEd_b$Aoc?NCr1 z3!~>K(N3%)yF8*`=8E2734l}Ts@-8tG8x}u6!;7uOuq-u=s9?6nhfeQk+NHUFN4Jj z#EFVBc}#Pq`Fww&b>F*O>l)xkwv|h{R-FbhO42reX~a1-)RBko$Wj{0$0-Z~)*sR- zD*&LB(GcW_+-WTipXSx`9rQTaO8CqrxS4O)vT=-JXdCt^|e{pk^7u;~&a( zZXmAgxpvRbyOMNbPt+<&gPw%^7qKHJgJg|AqeE0ntK9d}g%8fzXP{OGpt^fPC`W)U zB_JaCc4wLv;RdGS=^OSKK6h=%JYl8HMTFKqfBhb&^&JnwB?Qtp=q@d;C3!?a*f8GL9q6H;KP?}kVF^Qk{Oe$WGnTN-huGwyHW%{AW2RamQ zu*tLm+KGgPQWm%gLR43vSMC^Hx{Fq1F2ZD=PwfZm8gA65Qor-FmtC2QI>5(%1`wW# z`+X{PFgZ%e_*6gpL{=8suy_bfGYYGIP{9P&8cExPxa$*~qg!S(_g}4*wO%!D-LO%u zp6N8l_cg&`h9$iKSlxk`xI`jeeZwsZ`gC2%S55u6AXUAsJCB|i~#rOmtQzMX|W%NulzRb?jgQSu9?NQErvk{M&Iy@6(OqDNacBMemmx3GsKtE$?82j&6Twmyq(E zXa_6V8_W9460(oDIB*q)nqcL^eL+F7!Z4IMTbPgr}&ouwSWK ztp^h*q?6V+DbaMTwRUJ~*Ha^sZy^NshqEYq7eFtvqzERZGG3rK&>I2b*Y$(Wa3`e4 z_@{0Z6@8M0a{K8hepyk2u@$x^n}1p#l6!d8BTL92qu>Q|&f1NvD%%&>GHiuAo6D4x zW4sL1-FajzaVw7){1_u|7VR5kq{>{6{rD;EB9w(R;}Qr`euB4pbHYqC5j=1C{CdHA zLU_~XwPr$&SD~fAPP)82Z26)7o`~tel#mK36vt>f2fNsbI-DEy{Un??)qt zkL2Vg65cU)kf!jKxtG~jDL!|_mneSLDTb34ach)RUXZ({iEcWJ_yS`<88E|j*9DJ= znQid8@EbsH)$7upLXkK=Zjqgf0pr{+1QL~+%eRyDy?>eL8%N0ZFo)NC$*Gafqq|a} zbqsyntuR6C{L?*@cSYM^xD;b|j3%p!Y%D6^``CP{2t|Ir%=9xxR582?ME#v87MN4n zdrK@tm8gQG7A0(vG}ox4YuKbF*lbNH=?J7-O9x1~YLT%in=K3{T73wn98j6CpRSjv zhyow;BTCpjyfh#Kh|*_hq^eiAy?!+1CGyJR`)2$>kJQ$qYRBszWmP2VYHWls4W!Ye z3gS@u2rA&MGJf4S>2U)AjZ9QOej}*MQ2mWg-8dYcop7OUJpT`=2D^ce_j0(O(3)Dk z=MN^mwb2}B6|gYOj8x(5XnlnABSy359&Z?w(i|pN?Kq_~f>?la%ylI2oxUZR7c0BD zjz(a$Z)(bi6tbIHQ}w3vti*#0h31Hx0ZIJ4DiP{kP+*YM^w}`QF?PNZ)I{nlTkYP3 zvqArx#r*G|+bubfJhkq}5vJshe&HHO9_vo&yTwJkSd?Dz)U<*uhvZ544wFIpj+N8O$%m9Pr zRaTV*f-xxbP?2j4H|a&`B#2%_9p)Joch<73S|U$17=q><^%QN=S?OzMPX*Y3kDF1;`cGI7R7uf3&;ypXtjGA(1p1& zx+^X5t|rQcpp5E{1|*?3%!yVPR6=_U!-#NMWQXrsWAT8e>%i)yUyUMv85m~1?vKMO zC+>mEyuz<-@Vy~Ztu1Rh(?i8RkB6WkHQ_O|xwEN1k!uWE6ieFJvavX&4&ZmK8&2Ea zA54M>2CCY#72-=;(rU3dNE|Z4>qt=mTVOwn5omtnS~8+UIJ>b2t14EM&pya)AoO>U zaRfnNE@mc6tDeM^L(>4K6E*SIJGZOcCF?NC9&mou7BhvXrA(5=@UOlK2WEd7UFCG( z&8^+5ek2S4wgt2?I~K%pnNAG4te2qeqi<0#64)^m;Nk+?+hx{+|pg7{C+ETUXR_QLY{SOnbaYfLCY)w)Am zM{M;)B#VyI4JZa@11$OFSC<;=lrUSmZ~6A*aAojl4v}JddL=GvA*Xty2R|r50bJk7 z%CNcBd-0flwJYVYdA&?eN*)N_|6H}a5tqKqD>tN0iwsi=;bB{HbuY^h*E|e27xY9# zkXq*;psv_VzxGMl+sy4&EFnmFp*G6gzCw?j62e!9e2C5E#HTSfsS_6l3O1ibS%t;^ z=)rfFe#mm6Io)V_VTVTv;tU{c!*d_HRog2}5ERQcxmQ3uIsVLkDtVVS(X9uOFRQ8xoQnv|+JK9RG>pt9&I(tK^F{m$Qsu2m)so(O7pPb!yFCSZ7 z{7xjuNZQ}qGi`_F9Wl@KD)F+1{OM<4$qOYBm!F!QuDv-;=(VH9sFN|Z|PD_Y_tcZJi(wv(yd)Ag5ohy#Qbq4jTHK= zOAfG!GK)VD2>2tc(`-AJE-d3#_b+Fsi?T0=A;JTp# zI4L%y9;XvGpI`?C{tm(wLM?L2^4D;A-(6Y|`r-{kH!K@d013e0wSWptE@{Q~{r2hW z!aj?4ww8B{l3@ZVC$y4P251?Eqmgle{CU(x@8VH9$xv2j$6DuObdibus_WivuQ>;G z-v!h_TD~FF%+XIl*Nhx!h2iK#aj=4vKErr10(NR+0@fZQLtVIwU-(!Ui8cTc2~{^% zJij~|qO2_A#jx`RTnF{9fbjcOTi!W!Bkh`^uc}C5i&dM7$7m5#bkGzW7B3tlBP!2j z>wYE1Zc6Crz!K{0VvTO9%cf5WxeyFpe!5&0>jHk+0|Mf&^9$kEuYVM9e@B@x+?5;) z!}328T@QDt!&iPfY3>dG*!{K)q=;8e%F^RGus3~xw7?6vk`zCeqlAs#+s8f63dkR6 zLd)7Ch6yq7Na#tl-7iP3d~js$r)GZ#-N1&!A}Ge!P~h9_Lt6*cdjF9sEi0x z!h=UnLNQ-IFnS7c6pzhD=t@l1xfms}Xzs5I2rfXR?rCgrq~7bXeI?Q9cW@8GdcuwH zz4Wm&;%27q!&U3x(MG|kf^|bYyX~BEQ!^$`&5t|3xxuu;^nynOhJ`BifNj=m7Fvqr z320EKZ>WV)JLe>PPJPk$JF%sfh9NLZyy{e_rt|DI@g@IW8S|G zM(sMKjr#)lrmi#ATyjL{6YJYCal}UWnVLZU-l2@g+sn^$($IYyZOW8X++SBX%-LO!Ta>+rb0cK4pri}9%s?H4qY5PP&( zscA+134|rG=wbYEchD5jNmA3+sJqD4Dl=`-1N#vqI92hK>UsUKkv#USQplfkO6HOo zEbK<=e74kVv!r|#M{*PwejQ?@hmt3z5V%^0*bPg(MD>=c*?xnQIm*%A@N>ryvDL0awJtWY^QABt4EqR^wel4BmaRKK_yg6f|dSJl`#zrXOhlMaS1qI8RY zfxVtE+(@d-_Sdi-b!Nj;yo>8{7|lAB${P zKme(P_~wG0{Z$_ZuvC0oL^3!INc+CrTotAJokq>R1Gnwlrh)LTj-y}bk46eL!>wph zC|*_6DIZ`tykR32brjTkM8U4DsUPCB#E2!@4d_zuJQl)krwWM^y?<>H*Y@y`5Ku|I zK}mK0(n0=US(~duNX4Y)bR^Stxe~=Ha;Dqsh$la(Wk<^_!FM~knuIWg)F8`&frFoc z6@?dD6)J-vSULTZ8K%Aq8@eazcMt6R+PQqRp@xy#=udl-*A0{CV)DLz%Nt*zVmrJ) zx@uq~vj&E4xp?5|t2e75Q2*F{=h3Q?mA2jBwS0@-E($1=V$CbeiIy-Z$UdbtXm0XG zsh6(0E)GO#`q~C7%pP!Zi4O5~^IizsWRq(-i9Jboufib&?YyhQeY?A)P4$Vtvejwp zCub+mrXg_p7})T`fRdf(0Kbm?C0cy0+8Ymr;BFBL=CxYZ4=?=#Z0CaGA+RA1tfJu| z6MgzO6)M4YT#P0g!*7a0$3~Ff^TK%Di~I2)bP=*H*?+H;5gZ?XwWb39YS4pJszNqu zGIP>=dsH!f1UA~?lyvXyPd51?L7J5pQ?>A`rRj*o$cc_pF5SibTlQXvdJ#GFIy}aN z`LITk*#5nAl57z@9?FhfrI=5I|e2WR9S5)iR+UbV_( zOc2+nZ+IwU!qZP9M~=5&`!#?J3E7tVhWJ*x?r~<$Q;EO&yl^?PYH5VEsRNKgB863g z5l!5ZK4>DC-*Ay9uP}+A8!~li`i*Yym?;BGx+JMDY``h2zZ3_2a22vr_CSFr6l5E3 z^!DK|a-}A7IFq0HC35XNYTDh1JW_MUqiX}CXEuh1w><}RtC&K}Up1G+j7rOf7I+b$ zAW&&6*YDr7sH~WId)iUx4NV2~#;s~G5*c)2cD2{|e7H?|M{}2T<{VW=Z|+fWd371d z=R%9@8;U6eqa~#)CgPiBt)?bS0DjrQEneNQ$L$DSMx}}W?yzQG)TWQmA1lJq2j~My z3wXeP=hUoVuX%dyJirk!|2FvziaNXUfG95L|I6Tr3VeF8R-$pmQneoZ`|o;kQi_r_ I;zmLL1#Dl`h5!Hn diff --git a/Tophat/Extensions/ArtifactDownloaderError+LocalizedError.swift b/Tophat/Extensions/BuildDownloaderError+LocalizedError.swift similarity index 61% rename from Tophat/Extensions/ArtifactDownloaderError+LocalizedError.swift rename to Tophat/Extensions/BuildDownloaderError+LocalizedError.swift index 73b3310..c3b02eb 100644 --- a/Tophat/Extensions/ArtifactDownloaderError+LocalizedError.swift +++ b/Tophat/Extensions/BuildDownloaderError+LocalizedError.swift @@ -2,8 +2,8 @@ // ArtifactDownloaderError+LocalizedError.swift // Tophat // -// Created by Lukas Romsicki on 2023-04-14. -// Copyright © 2023 Shopify. All rights reserved. +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2024 Shopify. All rights reserved. // import Foundation @@ -12,18 +12,14 @@ extension ArtifactDownloaderError: LocalizedError { var errorDescription: String? { switch self { case .failedToDownloadArtifact: - return "The artifact could not be downloaded" - default: - return nil + return "The build could not be downloaded" } } var failureReason: String? { switch self { case .failedToDownloadArtifact: - return "An unexpected error occurred while downloading the artifact." - default: - return nil + return "An unexpected error occurred while downloading the build." } } @@ -31,8 +27,6 @@ extension ArtifactDownloaderError: LocalizedError { switch self { case .failedToDownloadArtifact: return "Check your network connection and try again." - default: - return nil } } } diff --git a/Tophat/Extensions/DeviceError+LocalizedError.swift b/Tophat/Extensions/DeviceError+LocalizedError.swift index 29be2f9..ed2de0a 100644 --- a/Tophat/Extensions/DeviceError+LocalizedError.swift +++ b/Tophat/Extensions/DeviceError+LocalizedError.swift @@ -33,7 +33,7 @@ extension DeviceError: LocalizedError { return "The device could not be started due to an unexpected error." case .deviceNotAvailable: return "The device is not available." - case .failedToInstallApp(_, deviceType: .physical): + case .failedToInstallApp(_, deviceType: .device): return "The application could not be installed." case .failedToInstallApp: return "The application could not be installed due to an unexpected error." @@ -52,11 +52,11 @@ extension DeviceError: LocalizedError { switch self { case .deviceNotAvailable: return "Ensure that it is connected and try again." - case .failedToInstallApp(_, deviceType: .physical), .deviceUnlockTimedOut: + case .failedToInstallApp(_, deviceType: .device), .deviceUnlockTimedOut: return "Make sure that the device is connected and unlocked and try again." case .failedToLaunchApp(_, .requiresManualProfileTrust, _): return "Go to Settings → General → VPN & Device Management to trust the developer." - case .failedToLaunchApp(_, _, deviceType: .physical): + case .failedToLaunchApp(_, _, deviceType: .device): return "Make sure that the device is connected and unlocked and try again." default: return nil diff --git a/Tophat/Extensions/InstallationTicketMachineError+LocalizedError.swift b/Tophat/Extensions/InstallationTicketMachineError+LocalizedError.swift new file mode 100644 index 0000000..1b4f94e --- /dev/null +++ b/Tophat/Extensions/InstallationTicketMachineError+LocalizedError.swift @@ -0,0 +1,61 @@ +// +// InstallationTicketMachineError+LocalizedError.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-10-04. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation + +extension InstallationTicketMachineError: LocalizedError { + var errorDescription: String? { + switch self { + case .noCompatibleDevices(let providedBuildTypes): + "No \(description(for: providedBuildTypes)) Selected" + case .noSelectedDevices: + "No Device Selected" + } + } + + var failureReason: String? { + switch self { + case .noCompatibleDevices: + "None of the specified builds are compatible with the selected devices." + default: + nil + } + } + + var recoverySuggestion: String? { + switch self { + case .noCompatibleDevices(let providedBuildTypes): + let text = description(for: providedBuildTypes) + return "Select \(text.startsWithVowel ? "an" : "a") \(text) using the Tophat menu and try again." + case .noSelectedDevices: + return "Select a device from the Tophat menu and try again." + } + } + + private func description(for providedBuildTypes: [Platform: Set]) -> String { + providedBuildTypes.map { "\($0.key) \(description(for: $0.value))" }.formatted(.list(type: .or)) + } + + private func description(for platforms: Set) -> String { + platforms.map { String(describing: $0) }.formatted(.list(type: .or)) + } + + private func description(for targets: Set) -> String { + targets.map { String(describing: $0) }.formatted(.list(type: .or)) + } + +} + +extension Character { + var isVowel: Bool { "aeiou".contains { String($0).compare(String(self).folding(options: .diacriticInsensitive, locale: nil), options: .caseInsensitive) == .orderedSame } } +} + +extension StringProtocol { + var startsWithVowel: Bool { first?.isVowel == true } +} diff --git a/Tophat/Extensions/LaunchRequestBuilderError+LocalizedError.swift b/Tophat/Extensions/LaunchRequestBuilderError+LocalizedError.swift deleted file mode 100644 index 8dde576..0000000 --- a/Tophat/Extensions/LaunchRequestBuilderError+LocalizedError.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// LaunchRequestBuilderError+LocalizedError.swift -// Tophat -// -// Created by Lukas Romsicki on 2023-01-11. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation -import TophatFoundation - -extension LaunchRequestBuilderError: LocalizedError { - var errorDescription: String? { - switch self { - case .failedToFindCompatibleDevice(let platform, _): - return "No \(platform) device selected" - - case .failedToFindCompatibleArtifact: - return "This artifact is not compatible with the selected device" - } - } - - var failureReason: String? { - switch self { - case .failedToFindCompatibleArtifact(let device, let availableTargets): - return "\(device.name) is a \(device.type) device, but the requested artifact was only built for \(description(for: availableTargets)) devices." - default: - return nil - } - } - - var recoverySuggestion: String? { - switch self { - case .failedToFindCompatibleDevice(let platform, let availableTargets): - return "Select a \(description(for: availableTargets)) \(platform) device using the Tophat menu and try again." - - case .failedToFindCompatibleArtifact(let device, let availableTargets): - return "Select a \(description(for: availableTargets)) \(device.runtime.platform) device and try again." - } - } - - private func description(for targets: Set) -> String { - targets.map { String(describing: $0) }.formatted(.list(type: .or)) - } -} diff --git a/Tophat/Models/AndroidApplication.swift b/Tophat/Models/AndroidApplication.swift index d584a53..616e5e4 100644 --- a/Tophat/Models/AndroidApplication.swift +++ b/Tophat/Models/AndroidApplication.swift @@ -20,7 +20,7 @@ struct AndroidApplication: Application { var icon: URL? { guard let path = try? ApkAnalyzer.getIconPath(apkUrl: url), - let archive = Archive(url: url, accessMode: .read), + let archive = try? Archive(url: url, accessMode: .read, pathEncoding: nil), let entry = archive[path] else { return nil @@ -34,7 +34,7 @@ struct AndroidApplication: Application { var targets: Set { // Android applications run anywhere. - [.virtual, .physical] + [.simulator, .device] } var platform: Platform { @@ -52,6 +52,10 @@ struct AndroidApplication: Application { } func validateEligibility(for device: Device) throws { + guard platform == device.runtime.platform else { + throw ApplicationError.incompatibleDeviceType + } + // Android applications can be installed on any Android device. } } diff --git a/Tophat/Models/AppleApplication.swift b/Tophat/Models/AppleApplication.swift index 61ff32e..7586c02 100644 --- a/Tophat/Models/AppleApplication.swift +++ b/Tophat/Models/AppleApplication.swift @@ -56,9 +56,9 @@ struct AppleApplication: Application { platformNames.forEach { platformName in switch platformName { case "iPhoneOS": - set.insert(.physical) + set.insert(.device) case "iPhoneSimulator": - set.insert(.virtual) + set.insert(.simulator) default: break } @@ -83,12 +83,16 @@ struct AppleApplication: Application { } func validateEligibility(for device: Device) throws { + guard platform == device.runtime.platform else { + throw ApplicationError.incompatibleDeviceType + } + if !targets.contains(device.type) { throw ApplicationError.incompatibleDeviceType } - if device.type == .virtual { - // Remaining checks are not needed for virtual devices. + if device.type == .simulator { + // Remaining checks are not needed for simulator devices. return } diff --git a/Tophat/Models/LaunchContext.swift b/Tophat/Models/LaunchContext.swift index a227b6f..633e8aa 100644 --- a/Tophat/Models/LaunchContext.swift +++ b/Tophat/Models/LaunchContext.swift @@ -9,11 +9,9 @@ struct LaunchContext { let appName: String? let pinnedApplicationId: PinnedApplication.ID? - let arguments: [String]? - init(appName: String? = nil, pinnedApplicationId: PinnedApplication.ID? = nil, arguments: [String]? = nil) { + init(appName: String? = nil, pinnedApplicationId: PinnedApplication.ID? = nil) { self.appName = appName self.pinnedApplicationId = pinnedApplicationId - self.arguments = arguments } } diff --git a/Tophat/Models/LaunchRequest.swift b/Tophat/Models/LaunchRequest.swift deleted file mode 100644 index 1015720..0000000 --- a/Tophat/Models/LaunchRequest.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// LaunchRequest.swift -// Tophat -// -// Created by Lukas Romsicki on 2022-11-17. -// Copyright © 2022 Shopify. All rights reserved. -// - -import TophatFoundation - -struct LaunchRequest { - let launchable: Launchable - let device: Device -} diff --git a/Tophat/Models/PinnedApplication.swift b/Tophat/Models/PinnedApplication.swift index 2c06c4b..6dd0f9e 100644 --- a/Tophat/Models/PinnedApplication.swift +++ b/Tophat/Models/PinnedApplication.swift @@ -12,8 +12,7 @@ import TophatFoundation struct PinnedApplication: Identifiable, Codable { let id: String let name: String - let platform: Platform - let artifacts: [Artifact] + let recipes: [InstallRecipe] var icon: ApplicationIcon? = nil /// Creates a new pinned application. @@ -25,12 +24,15 @@ struct PinnedApplication: Identifiable, Codable { /// - id: The identifier of the pinned application, if used for updating purposes. /// - name: The name of the pinned application. /// - platform: The platform of the pinned application. - /// - artifacts: The set of artifacts at which this pinned application can be found. - init(id: String? = nil, name: String, platform: Platform, artifacts: [Artifact] = []) { + /// - recipes: The set of recipes at which this pinned application can be found. + init(id: String? = nil, name: String, recipes: [InstallRecipe] = []) { self.id = id ?? UUID().uuidString self.name = name - self.platform = platform - self.artifacts = artifacts + self.recipes = recipes + } + + var platform: Platform { + recipes.first?.platformHint ?? .unknown } } diff --git a/Tophat/TophatApp.swift b/Tophat/TophatApp.swift index dab3ca2..2fbc018 100644 --- a/Tophat/TophatApp.swift +++ b/Tophat/TophatApp.swift @@ -16,7 +16,6 @@ import Sparkle import TophatServer import AndroidDeviceKit import AppleDeviceKit -import GoogleStorageKit import FluidMenuBarExtra import TophatFoundation @@ -35,7 +34,6 @@ struct TophatApp: App { AndroidDeviceKit.log = log AppleDeviceKit.log = log - GoogleStorageKit.log = log } var body: some Scene { @@ -43,13 +41,13 @@ struct TophatApp: App { SettingsView() .showDockIconWhenOpen() .environment(appDelegate.updateController) + .environment(appDelegate.extensionHost) .environmentObject(appDelegate.deviceManager) .environmentObject(appDelegate.pinnedApplicationState) .environmentObject(appDelegate.utilityPathPreferences) .environmentObject(appDelegate.launchAtLoginController) .environmentObject(appDelegate.symbolicLinkManager) } - .commandsRemoved() } } @@ -67,7 +65,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { ) private let server = TophatServer() - private let urlHandler = URLHandler() + private let urlHandler = URLReader() private let notificationHandler = NotificationHandler() let deviceManager: DeviceManager @@ -78,6 +76,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { let updateController: UpdateController + let extensionHost = ExtensionHost() + private let deviceSelectionManager: DeviceSelectionManager private let taskStatusReporter: TaskStatusReporter private let installCoordinator: InstallCoordinator @@ -105,7 +105,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { deviceManager: deviceManager, deviceSelectionManager: deviceSelectionManager, pinnedApplicationState: pinnedApplicationState, - taskStatusReporter: taskStatusReporter + taskStatusReporter: taskStatusReporter, + extensionHost: extensionHost ) self.utilityPathPreferences = UtilityPathPreferences() @@ -121,14 +122,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { ) AndroidPathResolver.delegate = self.utilityPathPreferences - GoogleStoragePathResolver.delegate = self.utilityPathPreferences super.init() configureEventSubscriptions() self.server.delegate = self - self.installCoordinator.delegate = self self.notificationHandler.delegate = self self.taskStatusReporter.delegate = self } @@ -176,6 +175,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { performFirstLaunchTasks() Notifications.requestPermissions() + extensionHost.discover() } func application(_ application: NSApplication, open urls: [URL]) { @@ -220,35 +220,24 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { } } } - - Publishers.MergeMany( - self.urlHandler.onLaunchArtifactURL, - self.notificationHandler.onLaunchArtifactURL - ) - .sink { [weak self] (url, launchArguments) in - Task.detached(priority: .userInitiated) { [weak self] in - await self?.launchApp(artifactURL: url, context: LaunchContext(arguments: launchArguments)) - } - } - .store(in: &cancellables) - - Publishers.MergeMany( - self.urlHandler.onLaunchArtifactSet, - self.notificationHandler.onLaunchArtifactSet - ) - .sink { [weak self] (artifactSet, platform, launchArguments) in - Task.detached(priority: .userInitiated) { [weak self] in - await self?.launchApp(artifactSet: artifactSet, on: platform, context: LaunchContext(arguments: launchArguments)) - } - } - .store(in: &cancellables) } private func handle(urls: [URL]) { do { - try urlHandler.handle(urls: urls) + for url in urls { + let urlReaderResult = try urlHandler.read(url: url) + + Task { + switch urlReaderResult { + case .localFile(let url): + await launchApp(artifactURL: url) + case .install(let recipes): + await launchApp(recipes: recipes) + } + } + } } catch let error { - if let error = error as? URLHandlerError { + if let error = error as? URLReaderError { switch error { case .malformedURL(let url): log.error("Attempting to handle URL but it was malformed: \(url.absoluteString)") @@ -270,32 +259,6 @@ extension AppDelegate: TophatServerDelegate { } } -// MARK: - InstallCoordinatorDelegate - -extension AppDelegate: InstallCoordinatorDelegate { - func installCoordinator(didSuccessfullyInstallAppForPlatform platform: Platform) { - DistributedNotificationCenter.default().postNotificationName( - .init("TophatInstallSucceeded"), - object: nil, - userInfo: ["platform": String(describing: platform).lowercased()], - deliverImmediately: true - ) - } - - func installCoordinator(didFailToInstallAppForPlatform platform: Platform?) { - DistributedNotificationCenter.default().postNotificationName( - .init("TophatInstallFailed"), - object: nil, - userInfo: ["platform": String(describing: platform).lowercased()], - deliverImmediately: true - ) - } - - func installCoordinator(didPromptToAllowUntrustedHost host: String) async -> HostTrustResult { - await TrustedHostAlert().requestTrust(for: host) - } -} - // MARK: - NotificationHandlerDelegate extension AppDelegate: NotificationHandlerDelegate { @@ -315,6 +278,18 @@ extension AppDelegate: NotificationHandlerDelegate { func notificationHandler(didReceiveRequestToRemovePinnedApplicationWithIdentifier pinnedApplicationIdentifier: PinnedApplication.ID) { pinnedApplicationState.pinnedApplications.removeAll { $0.id == pinnedApplicationIdentifier } } + + func notificationHandler(didOpenURL url: URL, launchArguments: [String]) { + Task { + await launchApp(artifactURL: url, launchArguments: launchArguments) + } + } + + func notificationHandler(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) { + Task { + await launchApp(recipes: recipes) + } + } } // MARK: - TaskStatusReporterDelegate diff --git a/Tophat/Utilities/ArtifactDownloader.swift b/Tophat/Utilities/ArtifactDownloader.swift index 700d63e..6736e63 100644 --- a/Tophat/Utilities/ArtifactDownloader.swift +++ b/Tophat/Utilities/ArtifactDownloader.swift @@ -2,87 +2,92 @@ // ArtifactDownloader.swift // Tophat // -// Created by Lukas Romsicki on 2022-10-25. -// Copyright © 2022 Shopify. All rights reserved. +// Created by Lukas Romsicki on 2024-11-05. +// Copyright © 2024 Shopify. All rights reserved. // import Foundation -import GoogleStorageKit -import AsyncAlgorithms - -final class ArtifactDownloader: NSObject { - private let downloadsDirectory = FileManager.default.temporaryDirectory.appending(paths: ["com.shopify.tophat", "downloads"]) - - let progressUpdates = AsyncChannel() - - fileprivate var progressObservation: NSKeyValueObservation? - - /// Downloads and extracts an artifact and returns the build. - /// - Parameter artifact: The artifact to download. - /// - Returns: The downloaded build derived from the artifact. - func download(artifactUrl: URL) async throws -> URL { - let temporaryDirectory = try createTemporaryDirectory() - let destinationURL = temporaryDirectory.appending(path: artifactUrl.lastPathComponent) - - if artifactUrl.isFileURL { - try FileManager.default.copyItem(at: artifactUrl, to: destinationURL) - } else if artifactUrl.isGoogleStorageURL { - for try await progress in try GoogleStorage.download(artifactURL: artifactUrl, to: destinationURL) { - let progress: TaskProgress = .determinate( - totalUnitCount: progress.totalUnitCount, - pendingUnitCount: progress.pendingUnitCount - ) - - await progressUpdates.send(progress) - } - } else { - try await downloadFile(url: artifactUrl, to: destinationURL) - } +import TophatFoundation +import Logging - return destinationURL - } +struct ArtifactResource: Identifiable { + let id: UUID + let url: URL + let application: Application +} - private func downloadFile(url: URL, to destinationURL: URL) async throws { - do { - defer { progressObservation = nil } +final class ArtifactDownloader { + private let artifactsURL: URL = .temporaryDirectory + .appending(path: Bundle.main.bundleIdentifier!) + .appending(path: "Artifacts") + + private let artifactRetrievalCoordinator: ArtifactRetrievalCoordinating - let (localURL, _) = try await URLSession.shared.download(from: url, delegate: self) - try FileManager.default.moveItem(at: localURL, to: destinationURL) + 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 createTemporaryDirectory() throws -> URL { - let temporaryDirectory = downloadsDirectory.appending(path: UUID().uuidString) + private func _download(from source: RemoteArtifactSource) async throws -> ArtifactResource { + let resourceID = UUID() + let artifactDirectoryURL = artifactsURL.appending(path: resourceID.uuidString) - do { - try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) - } catch { - throw ArtifactDownloaderError.failedToCreateDownloadsDirectory + try FileManager.default.createDirectory(at: artifactDirectoryURL, withIntermediateDirectories: true) + + let artifactURL: URL + + 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("Copying downloaded artifact to \(destinationURL)") + try FileManager.default.copyItem(at: localURL, to: destinationURL) + + 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 + + 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 } - return temporaryDirectory + 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) } } -extension ArtifactDownloader: URLSessionTaskDelegate { - func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { - progressObservation = task.progress.observe(\.fractionCompleted) { progress, observedChange in - let progress: TaskProgress = .determinate( - totalUnitCount: 1, - pendingUnitCount: progress.fractionCompleted - ) - - Task { [weak self] in - await self?.progressUpdates.send(progress) - } - } +private extension ArtifactProviderMetadata { + var loggerMetadata: Logger.Metadata { + [ + "id": .string(id), + "parameters": .dictionary(parameters.mapValues { .string($0) }) + ] } } enum ArtifactDownloaderError: Error { - case failedToCreateDownloadsDirectory case failedToDownloadArtifact } diff --git a/Tophat/Utilities/DeviceSelectionManager.swift b/Tophat/Utilities/DeviceSelectionManager.swift index 2dfe49e..4e842e9 100644 --- a/Tophat/Utilities/DeviceSelectionManager.swift +++ b/Tophat/Utilities/DeviceSelectionManager.swift @@ -14,36 +14,30 @@ import SwiftUI final class DeviceSelectionManager: ObservableObject { private unowned let deviceManager: DeviceManager - // Setting a default value as Picker does not handle optionals reliably. - @AppStorage("SelectedAppleDevice") var selectedAppleDeviceIdentifier: String = "" - @AppStorage("SelectedAndroidDevice") var selectedAndroidDeviceIdentifier: String = "" + @CodableAppStorage("SelectedDeviceIdentifiers") var selectedDeviceIdentifiers: [String] = [] init(deviceManager: DeviceManager) { self.deviceManager = deviceManager // Configure default devices if none were initially selected. - if selectedAppleDeviceIdentifier.isEmpty, - let firstAppleDevice = devices.filter(by: .iOS).first { - selectedAppleDeviceIdentifier = firstAppleDevice.id + if selectedDeviceIdentifiers.isEmpty { + for platform in Platform.allCases { + if let firstPlatformDevice = devices.filter(by: platform).first { + selectedDeviceIdentifiers.append(firstPlatformDevice.id) + } + } } - - if selectedAndroidDeviceIdentifier.isEmpty, - let firstAndroidDevice = devices.filter(by: .android).first { - selectedAndroidDeviceIdentifier = firstAndroidDevice.id - } - } - - var selectedAppleDevice: Device? { - devices.first { $0.id == selectedAppleDeviceIdentifier } - } - - var selectedAndroidDevice: Device? { - devices.first { $0.id == selectedAndroidDeviceIdentifier } } /// A collection of all currently selected devices. var selectedDevices: [Device] { - [selectedAppleDevice, selectedAndroidDevice].compactMap { $0 } + get { + devices.filter { selectedDeviceIdentifiers.contains($0.id) } + } + set { + selectedDeviceIdentifiers = newValue.map { $0.id } + objectWillChange.send() + } } private var devices: [Device] { diff --git a/Tophat/Utilities/Extensions/AppExtensionIdentity+WithXPCSession.swift b/Tophat/Utilities/Extensions/AppExtensionIdentity+WithXPCSession.swift new file mode 100644 index 0000000..eb44076 --- /dev/null +++ b/Tophat/Utilities/Extensions/AppExtensionIdentity+WithXPCSession.swift @@ -0,0 +1,28 @@ +// +// AppExtensionIdentity+WithXPCSession.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2024 Shopify. All rights reserved. +// + +import ExtensionFoundation +@_spi(TophatKitInternal) import TophatKit + +extension AppExtensionIdentity { + func withXPCSession(perform: (ExtensionXPCSession) async throws -> T) async throws -> T { + let configuration = AppExtensionProcess.Configuration(appExtensionIdentity: self) + let process = try await AppExtensionProcess(configuration: configuration) + + let connection = try process.makeXPCConnection() + let session = ExtensionXPCSession(connection: connection) + + session.activate() + + defer { + session.invalidate() + } + + return try await perform(session) + } +} diff --git a/Tophat/Utilities/Extensions/ArtifactRetrievalCoordinator.swift b/Tophat/Utilities/Extensions/ArtifactRetrievalCoordinator.swift new file mode 100644 index 0000000..1fd07d2 --- /dev/null +++ b/Tophat/Utilities/Extensions/ArtifactRetrievalCoordinator.swift @@ -0,0 +1,76 @@ +// +// ExtensionHost.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-09-06. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import ExtensionFoundation +import TophatFoundation +@_spi(TophatKitInternal) import TophatKit + +protocol AppExtensionIdentityResolving { + func identity(artifactProviderID: String) async -> AppExtensionIdentity? +} + +extension ExtensionHost: AppExtensionIdentityResolving { + func identity(artifactProviderID: String) async -> AppExtensionIdentity? { + let extensionWithIdentity = await availableExtensions.first { availableExtension in + availableExtension.specification.artifactProviders.contains { $0.id == artifactProviderID } + } + + guard let extensionWithIdentity else { + return nil + } + + return extensionWithIdentity.identity + } +} + +protocol ArtifactRetrievalCoordinating { + func retrieve(metadata: ArtifactProviderMetadata) async throws -> URL + func cleanUp(artifactProviderID: String, localURL: URL) async throws +} + +struct ArtifactRetrievalCoordinator { + private let appExtensionIdentityResolver: AppExtensionIdentityResolving + + init(appExtensionIdentityResolver: AppExtensionIdentityResolving) { + self.appExtensionIdentityResolver = appExtensionIdentityResolver + } +} + +extension ArtifactRetrievalCoordinator: ArtifactRetrievalCoordinating { + func retrieve(metadata: ArtifactProviderMetadata) async throws -> URL { + guard let artifactProvidingExtension = await appExtensionIdentityResolver.identity(artifactProviderID: metadata.id) else { + throw ArtifactRetrievalCoordinatorError.artifactProviderNotFound + } + + return try await artifactProvidingExtension.withXPCSession { session in + let message = RetrieveArtifactMessage( + providerID: metadata.id, + parameters: metadata.parameters + ) + + let result = try await session.send(message) + return result.localURL + } + } + + func cleanUp(artifactProviderID: String, localURL: URL) async throws { + guard let artifactProvidingExtension = await appExtensionIdentityResolver.identity(artifactProviderID: artifactProviderID) else { + throw ArtifactRetrievalCoordinatorError.artifactProviderNotFound + } + + try await artifactProvidingExtension.withXPCSession { session in + let message = CleanUpArtifactMessage(providerID: artifactProviderID, url: localURL) + try await session.send(message) + } + } +} + +enum ArtifactRetrievalCoordinatorError: Error { + case artifactProviderNotFound +} diff --git a/Tophat/Utilities/Extensions/ExtensionHost.swift b/Tophat/Utilities/Extensions/ExtensionHost.swift new file mode 100644 index 0000000..44f73b5 --- /dev/null +++ b/Tophat/Utilities/Extensions/ExtensionHost.swift @@ -0,0 +1,49 @@ +// +// ExtensionHost.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import ExtensionFoundation +@_spi(TophatKitInternal) import TophatKit + +@Observable final class ExtensionHost { + @MainActor private(set) var availableExtensions: [TophatExtension] = [] + + @MainActor func discover() { + Task { + do { + let sequence = try AppExtensionIdentity.matching( + appExtensionPointIDs: "com.shopify.Tophat.extension" + ) + + for await identities in sequence { + self.availableExtensions = try await withThrowingTaskGroup(of: TophatExtension.self, returning: [TophatExtension].self) { group in + for identity in identities { + group.addTask { + let specification = try await identity.withXPCSession { session in + return try await session.send(FetchExtensionSpecificationMessage()) + } + + return TophatExtension(identity: identity, specification: specification) + } + } + + var specifications: [TophatExtension] = [] + + for try await specification in group { + specifications.append(specification) + } + + return specifications + } + } + } catch { + print("Failed to discover extensions: \(error)") + } + } + } +} diff --git a/Tophat/Utilities/Extensions/TophatExtension.swift b/Tophat/Utilities/Extensions/TophatExtension.swift new file mode 100644 index 0000000..a6e8703 --- /dev/null +++ b/Tophat/Utilities/Extensions/TophatExtension.swift @@ -0,0 +1,34 @@ +// +// TophatExtension.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import ExtensionFoundation +@_spi(TophatKitInternal) import TophatKit + +struct TophatExtension { + let identity: AppExtensionIdentity + let specification: ExtensionSpecification +} + +extension TophatExtension: Identifiable { + var id: String { + identity.bundleIdentifier + } +} + +extension TophatExtension: Equatable { + static func == (lhs: TophatExtension, rhs: TophatExtension) -> Bool { + lhs.id == rhs.id + } +} + +extension TophatExtension: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/Tophat/Utilities/InstallCoordinator.swift b/Tophat/Utilities/InstallCoordinator.swift index 47cea1e..2644f5e 100644 --- a/Tophat/Utilities/InstallCoordinator.swift +++ b/Tophat/Utilities/InstallCoordinator.swift @@ -10,133 +10,139 @@ import Foundation import TophatFoundation final class InstallCoordinator { - weak var delegate: InstallCoordinatorDelegate? - private unowned let deviceManager: DeviceManager private unowned let pinnedApplicationState: PinnedApplicationState private unowned let taskStatusReporter: TaskStatusReporter - private let launchRequestBuilder: LaunchRequestBuilder + private let deviceSelectionManager: DeviceSelectionManager + + private let artifactDownloader: ArtifactDownloader init( deviceManager: DeviceManager, deviceSelectionManager: DeviceSelectionManager, pinnedApplicationState: PinnedApplicationState, - taskStatusReporter: TaskStatusReporter + taskStatusReporter: TaskStatusReporter, + extensionHost: ExtensionHost ) { self.deviceManager = deviceManager self.pinnedApplicationState = pinnedApplicationState - self.launchRequestBuilder = LaunchRequestBuilder(deviceSelectionManager: deviceSelectionManager) + self.deviceSelectionManager = deviceSelectionManager self.taskStatusReporter = taskStatusReporter + + self.artifactDownloader = ArtifactDownloader(artifactRetrievalCoordinator: ArtifactRetrievalCoordinator(appExtensionIdentityResolver: extensionHost)) } - /// Downloads, installs, and launches an artifact set on a device matching a given platform. - /// - /// If an appropriate device is found for the artifact set in advance, the device is booted in parallel + /// 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: - /// - artifactSet: The artifact set to launch. - /// - platform: The platform to launch on. + /// - recipes: A collection of recipes for retrieving builds. /// - context: Additional metadata for the operation. - func launch(artifactSet: ArtifactSet, on platform: Platform, context: LaunchContext? = nil) async throws { + func install(recipes: [InstallRecipe], context: LaunchContext? = nil) async throws { await preflightInstallation(context: context) - do { - let launchRequest = try launchRequestBuilder.createRequest(for: artifactSet, platform: platform) - try await launch(artifactURL: launchRequest.launchable.url, device: launchRequest.device, context: context) + let fetchArtifact = FetchArtifactTask( + taskStatusReporter: taskStatusReporter, + pinnedApplicationState: pinnedApplicationState, + artifactDownloader: artifactDownloader, + context: context + ) + + let machine = InstallationTicketMachine(deviceSelector: deviceSelectionManager, artifactDownloader: fetchArtifact) + + 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) + } + } - } catch let error { - notifyError(error: error, platform: platform) - throw error + try await group.waitForAll() } } /// 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 application is downloaded - /// and unpacked. To improve user experience, prefer ``launch(artifactSet:on:context:)`` + /// 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, context: LaunchContext? = nil) async throws { - do { - try await launch(artifactURL: artifactURL, device: nil, context: context) + func launch(artifactURL: URL, launchArguments: [String] = [], context: LaunchContext? = nil) async throws { + await preflightInstallation(context: context) - } catch let error { - notifyError(error: error) - throw error - } - } + let fetchArtifact = FetchArtifactTask( + taskStatusReporter: taskStatusReporter, + pinnedApplicationState: pinnedApplicationState, + artifactDownloader: artifactDownloader, + context: context + ) - private func launch(artifactURL: URL, device: Device?, context: LaunchContext? = nil) async throws { - guard await validateHostTrust(artifactURL: artifactURL) == .allow else { - return - } + let machine = InstallationTicketMachine(deviceSelector: deviceSelectionManager, artifactDownloader: fetchArtifact) - let fetchArtifact = FetchArtifactTask(taskStatusReporter: taskStatusReporter, pinnedApplicationState: pinnedApplicationState, context: context) - let prepareDevice = PrepareDeviceTask(taskStatusReporter: taskStatusReporter) + let source: RemoteArtifactSource = if artifactURL.isFileURL { + .file(url: artifactURL) + } else { + .artifactProvider( + metadata: ArtifactProviderMetadata( + id: "http", + parameters: ["url": artifactURL.absoluteString] + ) + ) + } - async let futureFetchArtifactResult = fetchArtifact(at: artifactURL) + let recipe = InstallRecipe( + source: source, + launchArguments: launchArguments + ) - if let device = device { - // We've been told what device we need in advance, so boot it in parallel to save time. - async let futurePrepareDeviceResult = prepareDevice(device: device) + for try await ticket in machine.process(recipes: [recipe]) { + try await install(ticket: ticket) + } + } - let (fetchArtifactResult, prepareDeviceResult) = await ( - try futureFetchArtifactResult, - try futurePrepareDeviceResult - ) + private func install(ticket: InstallationTicketMachine.Ticket, context: LaunchContext? = nil) async throws { + let fetchArtifact = FetchArtifactTask( + taskStatusReporter: taskStatusReporter, + pinnedApplicationState: pinnedApplicationState, + artifactDownloader: artifactDownloader, + context: context + ) - 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") + let prepareDevice = PrepareDeviceTask(taskStatusReporter: taskStatusReporter) - // This is a non-critical feature, it is allowed to fail in case the - // user hasn't accepted permissions. - try? device.focus() - } + async let futureFetchArtifactResult = fetchArtifact(from: ticket.artifactLocation) + async let futurePrepareDeviceResult = prepareDevice(device: ticket.device) - try await install(application: fetchArtifactResult.application, on: device, context: context) + let (fetchArtifactResult, prepareDeviceResult) = await ( + try futureFetchArtifactResult, + try futurePrepareDeviceResult + ) - } else { - // We don't know what device we will need. Determine the device based on the downloaded application. - await preflightInstallation(context: context) - let application = try await futureFetchArtifactResult.application - let device = try launchRequestBuilder.createRequest(for: application).device + 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") - try await prepareDevice(device: device) - try await install(application: application, on: device, context: context) + // This is a non-critical feature, it is allowed to fail in case the + // user hasn't accepted permissions. + try? ticket.device.focus() } - } - private func install(application: Application, on device: Device, context: LaunchContext? = nil) async throws { let installApplication = InstallApplicationTask(taskStatusReporter: taskStatusReporter, context: context) - try await installApplication(application: application, device: device) - delegate?.installCoordinator(didSuccessfullyInstallAppForPlatform: application.platform) + 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() } - - private func validateHostTrust(artifactURL: URL) async -> HostTrustResult { - if artifactURL.isFileURL { - return .allow - } - - guard let host = artifactURL.host() else { - return .block - } - - return await delegate?.installCoordinator(didPromptToAllowUntrustedHost: host) ?? .block - } - - private func notifyError(error: Error, platform: Platform? = nil) { - log.error("An error occurred while installing the application: \(error.localizedDescription)") - delegate?.installCoordinator(didFailToInstallAppForPlatform: platform) - } } diff --git a/Tophat/Utilities/InstallCoordinatorDelegate.swift b/Tophat/Utilities/InstallCoordinatorDelegate.swift deleted file mode 100644 index a60f322..0000000 --- a/Tophat/Utilities/InstallCoordinatorDelegate.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// InstallCoordinatorDelegate.swift -// Tophat -// -// Created by Lukas Romsicki on 2023-01-25. -// Copyright © 2023 Shopify. All rights reserved. -// - -import TophatFoundation - -protocol InstallCoordinatorDelegate: AnyObject { - func installCoordinator(didSuccessfullyInstallAppForPlatform platform: Platform) - func installCoordinator(didFailToInstallAppForPlatform platform: Platform?) - func installCoordinator(didPromptToAllowUntrustedHost host: String) async -> HostTrustResult -} diff --git a/Tophat/Utilities/InstallationTicketMachine.swift b/Tophat/Utilities/InstallationTicketMachine.swift new file mode 100644 index 0000000..0f61cf3 --- /dev/null +++ b/Tophat/Utilities/InstallationTicketMachine.swift @@ -0,0 +1,141 @@ +// +// InstallationTicketMachine.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-10-02. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation + +protocol DeviceSelecting { + var selectedDevices: [Device] { get } +} + +protocol ArtifactDownloading { + func download(source: RemoteArtifactSource) async throws -> Application +} + +extension DeviceSelectionManager: DeviceSelecting {} + +/// 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 + + /// 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) { + self.deviceSelector = deviceSelector + self.artifactDownloader = artifactDownloader + } + + /// 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 { + AsyncThrowingStream { continuation in + Task { + do { + try await process(recipes: recipes, continuation: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + private func process(recipes: [InstallRecipe], continuation: TicketSequence.Continuation) async throws { + let selectedDevices = deviceSelector.selectedDevices + + guard !selectedDevices.isEmpty else { + throw InstallationTicketMachineError.noSelectedDevices + } + + var processedTicketCount = 0 + + var providedBuildTypes: [Platform: Set] = recipes.reduce(into: [:]) { partialResult, recipe in + if let platform = recipe.platformHint, let destination = recipe.destinationHint { + partialResult[platform, default: []].insert(destination) + } + } + + try await withThrowingTaskGroup(of: Void.self) { group in + for device in selectedDevices { + group.addTask { + // If this ends up in the else case, it means there was not enough + // information in any recipe to be confident that the build will install + // to the device. + if let recipe = compatibleRecipeBasedOnHints(for: device, in: recipes) { + let ticket = Ticket( + device: device, + artifactLocation: .remote(source: recipe.source), + launchArguments: recipe.launchArguments + ) + + processedTicketCount += 1 + continuation.yield(ticket) + } else { + recipeLoop: for recipe in recipes where recipe.platformHint == nil { + if let destinationHint = recipe.destinationHint, device.type != destinationHint { + continue recipeLoop + } + + let application = try await artifactDownloader.download(source: recipe.source) + + providedBuildTypes[application.platform, default: []].formUnion(application.targets) + + guard + device.runtime.platform == application.platform, + application.targets.contains(device.type) + else { + continue recipeLoop + } + + let ticket = Ticket( + device: device, + artifactLocation: .local(application: application), + launchArguments: recipe.launchArguments + ) + + processedTicketCount += 1 + continuation.yield(ticket) + } + } + } + } + + try await group.waitForAll() + } + + guard processedTicketCount > 0 else { + throw InstallationTicketMachineError.noCompatibleDevices(providedBuildTypes: providedBuildTypes) + } + } + + private func compatibleRecipeBasedOnHints(for device: Device, in recipes: [InstallRecipe]) -> InstallRecipe? { + recipes.first { $0.platformHint == device.runtime.platform && $0.destinationHint == device.type } + ?? recipes.first { $0.platformHint == device.runtime.platform && $0.destinationHint == nil } + } +} + +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] +} diff --git a/Tophat/Utilities/LaunchAppAction.swift b/Tophat/Utilities/LaunchAppAction.swift index f1e2b92..a413bb0 100644 --- a/Tophat/Utilities/LaunchAppAction.swift +++ b/Tophat/Utilities/LaunchAppAction.swift @@ -16,17 +16,17 @@ struct LaunchAppAction { self.installCoordinator = installCoordinator } - func callAsFunction(artifactURL: URL, context: LaunchContext? = nil) async { + func callAsFunction(artifactURL: URL, launchArguments: [String] = [], context: LaunchContext? = nil) async { do { - try await installCoordinator.launch(artifactURL: artifactURL, context: context) + try await installCoordinator.launch(artifactURL: artifactURL, launchArguments: launchArguments, context: context) } catch { ErrorNotifier().notify(error: error) } } - func callAsFunction(artifactSet: ArtifactSet, on platform: Platform, context: LaunchContext? = nil) async { + func callAsFunction(recipes: [InstallRecipe], context: LaunchContext? = nil) async { do { - try await installCoordinator.launch(artifactSet: artifactSet, on: platform, context: context) + try await installCoordinator.install(recipes: recipes, context: context) } catch { ErrorNotifier().notify(error: error) } diff --git a/Tophat/Utilities/LaunchRequestBuilder.swift b/Tophat/Utilities/LaunchRequestBuilder.swift deleted file mode 100644 index 773bcbc..0000000 --- a/Tophat/Utilities/LaunchRequestBuilder.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// LaunchRequestBuilder.swift -// Tophat -// -// Created by Lukas Romsicki on 2022-11-17. -// Copyright © 2022 Shopify. All rights reserved. -// - -import Foundation -import TophatFoundation - -/// Utility class that coordinates selecting the correct devices and artifact to use when launching an application. -final class LaunchRequestBuilder { - private unowned let deviceSelectionManager: DeviceSelectionManager - - init(deviceSelectionManager: DeviceSelectionManager) { - self.deviceSelectionManager = deviceSelectionManager - } - - /// Determines which device to run on, which artifact to select for the device, and returns a launch request. - /// - Parameter artifactSet: The artifact set to read artifact details from. - /// - Returns: A launch containing the parameters of the launch operation. - func createRequest(for artifactSet: ArtifactSet, platform: Platform) throws -> LaunchRequest { - guard let device = getTargetDevice(for: platform) else { - throw LaunchRequestBuilderError.failedToFindCompatibleDevice( - platform: platform, - availableTargets: artifactSet.targets - ) - } - - guard let artifact = artifactSet.artifacts(targeting: device.type).first else { - throw LaunchRequestBuilderError.failedToFindCompatibleArtifact( - device: device, - availableTargets: artifactSet.targets - ) - } - - return LaunchRequest(launchable: artifact, device: device) - } - - func createRequest(for application: Application) throws -> LaunchRequest { - let platform = application.platform - let targets = application.targets - - guard let device = getTargetDevice(for: platform) else { - throw LaunchRequestBuilderError.failedToFindCompatibleDevice( - platform: platform, - availableTargets: targets - ) - } - - guard targets.contains(device.type) else { - throw LaunchRequestBuilderError.failedToFindCompatibleArtifact( - device: device, - availableTargets: targets - ) - } - - return LaunchRequest(launchable: application, device: device) - } - - private func getTargetDevice(for platform: Platform) -> Device? { - deviceSelectionManager.selectedDevices.first { $0.runtime.platform == platform } - } -} - -enum LaunchRequestBuilderError: Error { - case failedToFindCompatibleDevice(platform: Platform, availableTargets: Set) - case failedToFindCompatibleArtifact(device: Device, availableTargets: Set) -} diff --git a/Tophat/Utilities/NotificationHandler.swift b/Tophat/Utilities/NotificationHandler.swift index b82abb7..b4c29dc 100644 --- a/Tophat/Utilities/NotificationHandler.swift +++ b/Tophat/Utilities/NotificationHandler.swift @@ -9,48 +9,71 @@ import Foundation import Combine import TophatFoundation -import TophatKit +import TophatUtilities protocol NotificationHandlerDelegate: AnyObject { func notificationHandler(didReceiveRequestToAddPinnedApplication pinnedApplication: PinnedApplication) func notificationHandler(didReceiveRequestToRemovePinnedApplicationWithIdentifier pinnedApplicationIdentifier: PinnedApplication.ID) + func notificationHandler(didReceiveRequestToLaunchApplicationWithRecipes recipes: [InstallRecipe]) + func notificationHandler(didOpenURL url: URL, launchArguments: [String]) } final class NotificationHandler { weak var delegate: NotificationHandlerDelegate? - let onLaunchArtifactSet = PassthroughSubject<(ArtifactSet, Platform, [String]), Never>() - let onLaunchArtifactURL = PassthroughSubject<(URL, [String]), Never>() - private let notifier = TophatInterProcessNotifier() private var cancellables: Set = [] init() { notifier - .publisher(for: TophatInstallHintedNotification.self) - .map { payload in - (ArtifactSet(artifacts: payload.artifacts), payload.platform, payload.launchArguments) - } - .sink { [weak self] result in - self?.onLaunchArtifactSet.send(result) + .publisher(for: TophatInstallURLNotification.self) + .sink { [weak self] payload in + self?.delegate?.notificationHandler(didOpenURL: payload.url, launchArguments: payload.launchArguments) } .store(in: &cancellables) notifier - .publisher(for: TophatInstallGenericNotification.self) + .publisher(for: TophatInstallConfigurationNotification.self) .sink { [weak self] payload in - self?.onLaunchArtifactURL.send((payload.url, payload.launchArguments)) + let recipes = payload.installRecipes.map { recipe in + let artifactProviderMetadata = ArtifactProviderMetadata( + id: recipe.artifactProviderID, + parameters: recipe.artifactProviderParameters + ) + + return InstallRecipe( + source: .artifactProvider(metadata: artifactProviderMetadata), + launchArguments: recipe.launchArguments, + platformHint: recipe.platformHint, + destinationHint: recipe.destinationHint + ) + } + + self?.delegate?.notificationHandler(didReceiveRequestToLaunchApplicationWithRecipes: recipes) } .store(in: &cancellables) notifier .publisher(for: TophatAddPinnedApplicationNotification.self) .sink { [weak self] payload in + let configuration = payload.configuration + let pinnedApplication = PinnedApplication( - id: payload.id, - name: payload.name, - platform: payload.platform, - artifacts: payload.artifacts + id: configuration.id, + name: configuration.name, + recipes: configuration.sources.map { source in + let artifactProviderMetadata = ArtifactProviderMetadata( + id: source.artifactProviderID, + parameters: source.artifactProviderParameters + ) + + return InstallRecipe( + source: .artifactProvider(metadata: artifactProviderMetadata), + launchArguments: source.launchArguments, + platformHint: source.platformHint, + destinationHint: source.destinationHint + ) + } ) self?.delegate?.notificationHandler(didReceiveRequestToAddPinnedApplication: pinnedApplication) @@ -65,33 +88,3 @@ final class NotificationHandler { .store(in: &cancellables) } } - -private extension TophatInstallHintedNotification.Payload { - var artifacts: [Artifact] { - convertToArtifacts(virtualURL: virtualURL, physicalURL: physicalURL, universalURL: universalURL) - } -} - -private extension TophatAddPinnedApplicationNotification.Payload { - var artifacts: [Artifact] { - convertToArtifacts(virtualURL: virtualURL, physicalURL: physicalURL, universalURL: universalURL) - } -} - -private func convertToArtifacts(virtualURL: URL?, physicalURL: URL?, universalURL: URL?) -> [Artifact] { - var artifacts: [Artifact] = [] - - if let virtualURL = virtualURL { - artifacts.append(.init(url: virtualURL, targets: [.virtual])) - } - - if let physicalURL = physicalURL { - artifacts.append(.init(url: physicalURL, targets: [.physical])) - } - - if let universalURL = universalURL { - artifacts.append(.init(url: universalURL, targets: [.virtual, .physical])) - } - - return artifacts -} diff --git a/Tophat/Utilities/Notifications.swift b/Tophat/Utilities/Notifications.swift index 9ca588a..bd25449 100644 --- a/Tophat/Utilities/Notifications.swift +++ b/Tophat/Utilities/Notifications.swift @@ -17,7 +17,7 @@ enum Notifications { } static func notify(message: String) { - Task(priority: .high) { + Task(priority: .high) { @MainActor in let content = UNMutableNotificationContent() content.title = "Tophat" content.body = message diff --git a/Tophat/Utilities/Tasks/FetchArtifactTask.swift b/Tophat/Utilities/Tasks/FetchArtifactTask.swift index 0858871..843a863 100644 --- a/Tophat/Utilities/Tasks/FetchArtifactTask.swift +++ b/Tophat/Utilities/Tasks/FetchArtifactTask.swift @@ -9,6 +9,12 @@ 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 @@ -16,19 +22,23 @@ struct FetchArtifactTask { let taskStatusReporter: TaskStatusReporter let pinnedApplicationState: PinnedApplicationState + let buildDownloader: ArtifactDownloader let context: LaunchContext? - private let status: TaskStatus - - init(taskStatusReporter: TaskStatusReporter, pinnedApplicationState: PinnedApplicationState, context: LaunchContext?) { + init( + taskStatusReporter: TaskStatusReporter, + pinnedApplicationState: PinnedApplicationState, + artifactDownloader: ArtifactDownloader, + context: LaunchContext? + ) { self.taskStatusReporter = taskStatusReporter self.pinnedApplicationState = pinnedApplicationState + self.buildDownloader = artifactDownloader self.context = context - - self.status = TaskStatus(displayName: "Downloading \(context?.appName ?? "App")", initialState: .preparing) } - func callAsFunction(at url: URL) async throws -> Result { + 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 { @@ -37,18 +47,14 @@ struct FetchArtifactTask { } } - log.info("Downloading artifact from \(url.absoluteString)") - await status.update(state: .running(message: "Downloading")) - taskStatusReporter.notify(message: "Downloading \(context?.appName ?? "application")…") - let downloadedArtifactUrl = try await downloadArtifact(at: url) - log.info("Artifact downloaded to \(downloadedArtifactUrl.path(percentEncoded: false))") - - log.info("Unpacking artifact at \(downloadedArtifactUrl.path(percentEncoded: false))") - await status.update(state: .running(message: "Unpacking")) - let application = try ArtifactUnpacker().unpack(artifactURL: downloadedArtifactUrl) - log.info("Artifact unpacked to \(application.url.path(percentEncoded: false))") + let application = switch location { + case .remote(let source): + try await buildDownloader.download(from: source).application + case .local(let application): + application + } - Task.detached { + Task { let updateIcon = UpdateIconTask( taskStatusReporter: taskStatusReporter, pinnedApplicationState: pinnedApplicationState, @@ -60,19 +66,4 @@ struct FetchArtifactTask { return Result(application: application) } - - private func downloadArtifact(at url: URL) async throws -> URL { - let artifactDownloader = ArtifactDownloader() - - let task = Task { - for await progress in artifactDownloader.progressUpdates { - await status.update(state: .running(message: "Downloading", progress: progress)) - } - } - - let downloadedArtifactURL = try await artifactDownloader.download(artifactUrl: url) - task.cancel() - - return downloadedArtifactURL - } } diff --git a/Tophat/Utilities/Tasks/InstallApplicationTask.swift b/Tophat/Utilities/Tasks/InstallApplicationTask.swift index 340fa01..d6bd5f4 100644 --- a/Tophat/Utilities/Tasks/InstallApplicationTask.swift +++ b/Tophat/Utilities/Tasks/InstallApplicationTask.swift @@ -13,7 +13,7 @@ struct InstallApplicationTask { let taskStatusReporter: TaskStatusReporter let context: LaunchContext? - func callAsFunction(application: Application, device: Device) async throws { + func callAsFunction(application: Application, device: Device, launchArguments: [String]) async throws { let metadata = InstallStatusMetadata(deviceId: device.id) let appName = application.name ?? context?.appName let status = TaskStatus( @@ -44,17 +44,17 @@ struct InstallApplicationTask { try device.install(application: application) - let bundleId = try application.bundleIdentifier + let bundleIdentifier = try application.bundleIdentifier if try await device.isLocked { await status.update(state: .waiting(reason: .deviceIsLocked)) try await device.waitUntilUnlocked() } - log.info("Launching application with bundle identifier \(bundleId)") + log.info("Launching application with bundle identifier \(bundleIdentifier)") taskStatusReporter.notify(message: "Launching \(notificationAppName) on \(device.name)…") await status.update(state: .running(message: "Launching")) - try device.launch(application: application, arguments: context?.arguments) + try device.launch(application: application, arguments: launchArguments) } } diff --git a/Tophat/Utilities/URLHandler.swift b/Tophat/Utilities/URLHandler.swift deleted file mode 100644 index 326f045..0000000 --- a/Tophat/Utilities/URLHandler.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// URLHandler.swift -// Tophat -// -// Created by Lukas Romsicki on 2023-01-23. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation -import TophatFoundation -import Combine - -final class URLHandler { - let onLaunchArtifactSet = PassthroughSubject<(ArtifactSet, Platform, [String]), Never>() - let onLaunchArtifactURL = PassthroughSubject<(URL, [String]), Never>() - - func handle(urls: [URL]) throws { - for url in urls { - try handle(url: url) - } - } - - private func handle(url: URL) throws { - switch url.scheme { - case "file": - onLaunchArtifactURL.send((url, [])) - case "tophat": - try handle(tophatURL: url) - case "http": - try handle(httpURL: url) - default: - throw URLHandlerError.unsupportedURL(url) - } - } - - private func handle(tophatURL url: URL) throws { - // We don't use a host, we just pretend that the path starts right after the protocol. - // The first path item is actually interpreted as the host. - switch url.host() { - case "install": - try handle(installURL: url) - default: - throw URLHandlerError.unsupportedURL(url) - } - } - - private func handle(httpURL url: URL) throws { - // The first path component is the leading forward slash. - switch url.pathComponents.dropFirst().first { - case "install": - try handle(installURL: url) - default: - throw URLHandlerError.unsupportedURL(url) - } - } - - private func handle(installURL url: URL) throws { - guard - let components = NSURLComponents(url: url, resolvingAgainstBaseURL: true), - let queryItems = components.queryItems - else { - throw URLHandlerError.malformedURL(url) - } - - if let platform = Platform(from: url.lastPathComponent) { - try handle(installURL: url, platform: platform, queryItems: queryItems) - } else { - throw URLHandlerError.malformedURL(url) - } - } - - private func handle(installURL url: URL, platform: Platform, queryItems: [URLQueryItem]) throws { - let artifacts: [Artifact] = queryItems.compactMap { queryItem in - guard - let targets = Set(deviceTypeQueryParam: queryItem.name), - let decodedURL = queryItem.value?.removingPercentEncoding, - let url = URL(string: decodedURL) - else { - return nil - } - - return Artifact(url: url, targets: targets) - } - - let artifactSet = ArtifactSet(artifacts: artifacts) - - onLaunchArtifactSet.send((artifactSet, platform, launchArguments(from: queryItems))) - } - - private func launchArguments(from queryItems: [URLQueryItem]) -> [String] { - guard let value = queryItems.value(name: "launchArguments") else { - return [] - } - - return value.split(separator: ",").map { String($0) } - } -} - -private extension Array where Element == URLQueryItem { - func value(name: String) -> String? { - first { $0.name == name }?.value - } -} - -enum URLHandlerError: Error { - case malformedURL(URL) - case unsupportedURL(URL) -} - -private extension Set where Element == DeviceType { - init?(deviceTypeQueryParam: String) { - switch deviceTypeQueryParam { - case "virtual": - self = [.virtual] - case "physical": - self = [.physical] - case "universal": - self = [.virtual, .physical] - default: - return nil - } - } -} - -private extension Platform { - init?(from queryString: String) { - switch queryString { - case "ios": - self = .iOS - case "android": - self = .android - default: - return nil - } - } -} diff --git a/Tophat/Utilities/URLReader.swift b/Tophat/Utilities/URLReader.swift new file mode 100644 index 0000000..c5d4906 --- /dev/null +++ b/Tophat/Utilities/URLReader.swift @@ -0,0 +1,149 @@ +// +// URLReader.swift +// Tophat +// +// Created by Lukas Romsicki on 2023-01-23. +// Copyright © 2023 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation + +enum URLReaderResult: Equatable { + case localFile(url: URL) + case install(requests: [InstallRecipe]) +} + +struct URLReader { + func read(url: URL) throws -> URLReaderResult { + if url.isFileURL { + return .localFile(url: url) + } + + switch url.scheme { + case "tophat": + return try read(tophatURL: url) + case "http": + return try read(httpURL: url) + default: + throw URLReaderError.unsupportedURL(url) + } + } + + private func read(tophatURL url: URL) throws -> URLReaderResult { + // We don't use a host, we just pretend that the path starts right after the protocol. + // The first path item is actually interpreted as the host. + switch url.host() { + case "install": + return try read(installURL: url) + default: + throw URLReaderError.unsupportedURL(url) + } + } + + private func read(httpURL url: URL) throws -> URLReaderResult { + // The first path component is the leading forward slash. + switch url.pathComponents.dropFirst().first { + case "install": + return try read(installURL: url) + default: + throw URLReaderError.unsupportedURL(url) + } + } + + private func read(installURL url: URL) throws -> URLReaderResult { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + throw URLReaderError.malformedURL(url) + } + + let queryItems = components.queryItems ?? [] + + let binnedQueryItemValues = Dictionary(grouping: queryItems) { element in + element.name + }.mapValues { items in + items.compactMap(\.value) + } + + let parameterQueryItemValues = binnedQueryItemValues.filter { element in + element.key != "platform" && element.key != "destination" && element.key != "arguments" + } + + let valueCount = parameterQueryItemValues.values.first?.count ?? 0 + + if valueCount == 0, binnedQueryItemValues.values.contains(where: { $0.count > 1 }) { + throw URLReaderError.malformedURL(url) + } + + if parameterQueryItemValues.isEmpty { + return .install( + requests: [ + installRecipe( + at: 0, + in: binnedQueryItemValues, + artifactProviderID: url.lastPathComponent + ) + ] + ) + } + + guard parameterQueryItemValues.allSatisfy({ $1.count == valueCount }) else { + throw URLReaderError.malformedURL(url) + } + + let installRequests = (0.. InstallRecipe { + let parameters: [String: String] = binnedQueryItemValues.reduce(into: [:]) { partialResult, item in + if item.key != "platform", item.key != "destination", item.key != "arguments" { + partialResult[item.key] = item.value[index].removingPercentEncoding + } + } + + let platform: Platform? = if let platformString = binnedQueryItemValues["platform"]?[safe: index] { + Platform(rawValue: platformString) + } else { + nil + } + + let destination: DeviceType? = if let destinationString = binnedQueryItemValues["destination"]?[safe: index] { + destinationString == "device" ? .device : .simulator + } else { + nil + } + + let launchArguments = binnedQueryItemValues["arguments"]?[safe: index]? + .split(separator: ",", omittingEmptySubsequences: true) + .map(String.init) + .compactMap(\.removingPercentEncoding) + + return InstallRecipe( + source: .artifactProvider( + metadata: ArtifactProviderMetadata( + id: artifactProviderID, + parameters: parameters + ) + ), + launchArguments: launchArguments ?? [], + platformHint: platform, + destinationHint: destination + ) + } +} + +enum URLReaderError: Error, Equatable { + case malformedURL(URL) + case unsupportedURL(URL) +} diff --git a/Tophat/Utilities/UtilityPathPreferences.swift b/Tophat/Utilities/UtilityPathPreferences.swift index cbc57d0..ee184b7 100644 --- a/Tophat/Utilities/UtilityPathPreferences.swift +++ b/Tophat/Utilities/UtilityPathPreferences.swift @@ -8,13 +8,11 @@ import SwiftUI import AndroidDeviceKit -import GoogleStorageKit final class UtilityPathPreferences: ObservableObject { @AppStorage("AndroidSDKPath") var preferredAndroidSDKPath: String? @AppStorage("JavaHomePath") var preferredJavaHomePath: String? @AppStorage("ScrcpyPath") var preferredScrcpyPath: String? - @AppStorage("GSUtilPath") var preferredGSUtilPath: String? var resolvedAndroidSDKLocation: URL? { AndroidPathResolver.sdkRoot @@ -28,10 +26,6 @@ final class UtilityPathPreferences: ObservableObject { AndroidPathResolver.scrcpy } - var resolvedGSUtilLocation: URL? { - GoogleStoragePathResolver.gsUtilPath - } - @MainActor func refresh() { objectWillChange.send() @@ -60,12 +54,3 @@ extension UtilityPathPreferences: AndroidPathResolverDelegate { return URL(fileURLWithPath: preferredScrcpyPath) } } - -extension UtilityPathPreferences: GoogleStoragePathResolverDelegate { - func pathToGSUtil() -> URL? { - guard let preferredGSUtilPath = preferredGSUtilPath else { - return nil - } - return URL(filePath: preferredGSUtilPath) - } -} diff --git a/Tophat/Views/DeviceList.swift b/Tophat/Views/DeviceList.swift index 8c261e9..e9db1a8 100644 --- a/Tophat/Views/DeviceList.swift +++ b/Tophat/Views/DeviceList.swift @@ -64,13 +64,13 @@ struct DeviceList: View { private var primaryDevices: [Device] { supportedDevices.filter { device in - device.type == .physical || pinnedDeviceIdentifiers.contains(device.id) + device.type == .device || pinnedDeviceIdentifiers.contains(device.id) } } private var secondaryDevices: [Device] { supportedDevices.filter { device in - device.type == .virtual && !pinnedDeviceIdentifiers.contains(device.id) + device.type == .simulator && !pinnedDeviceIdentifiers.contains(device.id) } } } diff --git a/Tophat/Views/DeviceMenu.swift b/Tophat/Views/DeviceMenu.swift index 925b277..75eb235 100644 --- a/Tophat/Views/DeviceMenu.swift +++ b/Tophat/Views/DeviceMenu.swift @@ -18,7 +18,7 @@ struct DeviceMenu: View { var body: some View { Menu { - if device.type == .virtual { + if device.type == .simulator { Button(device.state == .ready ? "Running" : "Start") { Task { await prepareDevice?(device: device) @@ -55,7 +55,7 @@ struct DeviceMenu: View { Divider() - if device.type == .virtual { + if device.type == .simulator { Button("Reveal Device Window") { Task.detached { try? device.focus() diff --git a/Tophat/Views/DevicePicker.swift b/Tophat/Views/DevicePicker.swift index e51cbf7..b9e6cf3 100644 --- a/Tophat/Views/DevicePicker.swift +++ b/Tophat/Views/DevicePicker.swift @@ -32,13 +32,10 @@ struct DevicePicker: View { } private func didSelect(device: Device) { - switch device.runtime.platform { - case .iOS: - deviceSelectionManager.selectedAppleDeviceIdentifier = device.id - case .android: - deviceSelectionManager.selectedAndroidDeviceIdentifier = device.id - default: - break + if !deviceSelectionManager.selectedDevices.contains(where: { $0.id == device.id }) { + deviceSelectionManager.selectedDevices.append(device) + } else { + deviceSelectionManager.selectedDevices.removeAll { $0.id == device.id } } } diff --git a/Tophat/Views/Generic/BadgedURL.swift b/Tophat/Views/Generic/BadgedURL.swift deleted file mode 100644 index 3a74bcb..0000000 --- a/Tophat/Views/Generic/BadgedURL.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// BadgedURL.swift -// Tophat -// -// Created by Lukas Romsicki on 2023-03-16. -// Copyright © 2023 Shopify. All rights reserved. -// - -import SwiftUI - -struct BadgedURL: View { - let badges: [String] - let url: URL - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - ForEach(badges, id: \.description) { badge in - Text(badge) - .font(.caption) - .foregroundColor(.secondary) - .padding(.vertical, 1) - .padding(.horizontal, 4) - .background(.quaternary) - .cornerRadius(3) - } - - Text(url.absoluteString) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } -} diff --git a/Tophat/Views/Generic/SymbolChip.swift b/Tophat/Views/Generic/SymbolChip.swift index f3c8f35..47eba8b 100644 --- a/Tophat/Views/Generic/SymbolChip.swift +++ b/Tophat/Views/Generic/SymbolChip.swift @@ -28,10 +28,9 @@ struct SymbolChip: View { } var body: some View { - color - .overlay(LinearGradient(colors: [.white.opacity(0.25), .clear], startPoint: .top, endPoint: .bottom)) + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(color.gradient) .frame(width: 26, height: 26) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) .shadow(color: .black.opacity(0.2), radius: 0.5, x: 0, y: 0.5) .overlay { Group { diff --git a/Tophat/Views/LaunchFromLocationMenuItem.swift b/Tophat/Views/LaunchFromLocationMenuItem.swift index 7f08d36..40aa247 100644 --- a/Tophat/Views/LaunchFromLocationMenuItem.swift +++ b/Tophat/Views/LaunchFromLocationMenuItem.swift @@ -47,7 +47,7 @@ struct LaunchFromLocationMenuItem: View { return } - Task.detached { + Task { await launchApp?(artifactURL: url) } } diff --git a/Tophat/Views/LaunchFromURLPanel.swift b/Tophat/Views/LaunchFromURLPanel.swift index eb2b71e..26ed6b1 100644 --- a/Tophat/Views/LaunchFromURLPanel.swift +++ b/Tophat/Views/LaunchFromURLPanel.swift @@ -34,7 +34,7 @@ struct LaunchFromURLPanel: View { if let url = URL(string: text) { text = "" - Task.detached(priority: .userInitiated) { + Task(priority: .userInitiated) { await launchApp?(artifactURL: url) } } diff --git a/Tophat/Views/Onboarding/GoogleCloudStorageOnboardingItem.swift b/Tophat/Views/Onboarding/GoogleCloudStorageOnboardingItem.swift deleted file mode 100644 index a02afd3..0000000 --- a/Tophat/Views/Onboarding/GoogleCloudStorageOnboardingItem.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// GoogleCloudStorageOnboardingItem.swift -// Tophat -// -// Created by Lukas Romsicki on 2023-01-27. -// Copyright © 2023 Shopify. All rights reserved. -// - -import SwiftUI - -struct GoogleCloudStorageOnboardingItem: View { - @ObservedObject var utilityPathPreferences: UtilityPathPreferences - - var body: some View { - OnboardingItemLayout( - title: "Google Cloud SDK", - description: "Tophat uses the Google Cloud SDK to download apps." - ) { - Image(.googleCloudSDK) - .resizable() - .interpolation(.high) - .padding(5) - .shadow(color: .black.opacity(0.3), radius: 1, x: 0, y: 0.5) - } infoPopoverContent: { - OnboardingPopoverContent(title: "Getting Started", installCommand: "brew install google-cloud-sdk") { - Text("The Google Cloud SDK can be installed using [Homebrew](https://formulae.brew.sh/cask/google-cloud-sdk). Make sure to run the `gcloud auth login` command to authenticate.") - .lineLimit(3, reservesSpace: true) - } - } content: { - OnboardingItemStatusIcon(state: isComplete ? .complete : .warning) { - OnboardingPopoverContent(title: "Needs Setup") { - Text("If you need to download apps stored in Google Cloud Storage buckets, the Google Cloud SDK needs to be installed.") - .lineLimit(3, reservesSpace: true) - } - } - } - } - - private var isComplete: Bool { - utilityPathPreferences.resolvedGSUtilLocation?.isReachable() ?? false - } -} diff --git a/Tophat/Views/Onboarding/OnboardingTaskList.swift b/Tophat/Views/Onboarding/OnboardingTaskList.swift index 22633c6..a6f139f 100644 --- a/Tophat/Views/Onboarding/OnboardingTaskList.swift +++ b/Tophat/Views/Onboarding/OnboardingTaskList.swift @@ -22,10 +22,6 @@ struct OnboardingTaskList: View { AndroidStudioOnboardingItem(utilityPathPreferences: utilityPathPreferences) } - Section { - GoogleCloudStorageOnboardingItem(utilityPathPreferences: utilityPathPreferences) - } - Section { ScreenCopyOnboardingItem(utilityPathPreferences: utilityPathPreferences) } diff --git a/Tophat/Views/Quick Launch/QuickLaunchEmptyState.swift b/Tophat/Views/Quick Launch/QuickLaunchEmptyState.swift index 4e9192b..e852454 100644 --- a/Tophat/Views/Quick Launch/QuickLaunchEmptyState.swift +++ b/Tophat/Views/Quick Launch/QuickLaunchEmptyState.swift @@ -29,6 +29,7 @@ struct QuickLaunchEmptyState: View { } .buttonStyle( SettingsLinkAdditionalActionButtonStyle { + NSRunningApplication.current.activate() selectedTab = .apps } ) diff --git a/Tophat/Views/Quick Launch/QuickLaunchPanel.swift b/Tophat/Views/Quick Launch/QuickLaunchPanel.swift index bad2bf0..88ce725 100644 --- a/Tophat/Views/Quick Launch/QuickLaunchPanel.swift +++ b/Tophat/Views/Quick Launch/QuickLaunchPanel.swift @@ -23,9 +23,9 @@ struct QuickLaunchPanel: View { .frame(minWidth: 0, maxWidth: .infinity) } else { LazyVGrid(columns: columns, alignment: .leading, spacing: 14) { - ForEach(Array(pinnedApplicationState.pinnedApplications.enumerated()), id: \.element.id) { index, app in + ForEach(pinnedApplicationState.pinnedApplications) { app in Button { - didSelect(app: app, index: index) + didSelect(app: app) } label: { QuickLaunchAppView(app: app) } @@ -40,12 +40,11 @@ struct QuickLaunchPanel: View { } } - private func didSelect(app: PinnedApplication, index: Int) { + private func didSelect(app: PinnedApplication) { let launchContext = LaunchContext(appName: app.name, pinnedApplicationId: app.id) - Task.detached(priority: .userInitiated) { - let artifactSet = ArtifactSet(artifacts: app.artifacts) - await launchApp?(artifactSet: artifactSet, on: app.platform, context: launchContext) + Task { + await launchApp?(recipes: app.recipes, context: launchContext) } } } diff --git a/Tophat/Views/Settings/AddPinnedApplicationSheet.swift b/Tophat/Views/Settings/AddPinnedApplicationSheet.swift index cd5b2cc..504fd09 100644 --- a/Tophat/Views/Settings/AddPinnedApplicationSheet.swift +++ b/Tophat/Views/Settings/AddPinnedApplicationSheet.swift @@ -8,20 +8,23 @@ import SwiftUI import TophatFoundation +@_spi(TophatKitInternal) import TophatKit struct AddPinnedApplicationSheet: View { - /// If editing an existing PinnedApplication, we store the ID. If adding a new one, this is nil. - private let editingApplicationID: String? @Environment(\.presentationMode) private var presentationMode + @Environment(ExtensionHost.self) private var extensionHost @EnvironmentObject private var pinnedApplicationState: PinnedApplicationState + private var editingApplicationID: String? + @State private var name: String = "" @State private var platform: Platform = .iOS - @State private var url: String = "" - @State private var virtualURL: String = "" - @State private var physicalURL: String = "" - @State private var urlSetType: URLSetType = .universal + @State private var destinationPreset: DestinationPreset = .any + + @State private var artifactProviderID: String? + @State private var simulatorArtifactProviderParameters: [String: String] = [:] + @State private var deviceArtifactProviderParameters: [String: String] = [:] private var addOrUpdateText: String { editingApplicationID != nil ? "Update Quick Launch App" : "Add App to Quick Launch" @@ -30,10 +33,6 @@ struct AddPinnedApplicationSheet: View { editingApplicationID != nil ? "Update App" : "Add App" } - init() { - self.editingApplicationID = nil - } - var body: some View { VStack(alignment: .leading, spacing: 0) { Form { @@ -46,31 +45,48 @@ struct AddPinnedApplicationSheet: View { .tag(platform) } } + + Picker("Source", selection: $artifactProviderID) { + ForEach(artifactProviders) { artifactProvider in + Text(artifactProvider.title) + .tag(artifactProvider.id) + } + } } Section { - Picker(selection: $urlSetType) { - ForEach(URLSetType.allCases, id: \.self) { type in + Picker(selection: $destinationPreset) { + ForEach(DestinationPreset.allCases, id: \.self) { type in Text(type.description) } } label: { - Text("Build Type") - Text(buildTypeDescription) + Text("Destination") + Text(destinationPreset.helpText) } } - Section { - if urlSetType == .multiTarget { - TextField("Virtual", text: $virtualURL, prompt: Text("URL")) - TextField("Physical", text: $physicalURL, prompt: Text("URL")) - } else { - TextField("URL", text: $url, prompt: Text("URL")) + if let selectedArtifactProvider { + if destinationPreset == .all || destinationPreset == .simulatorOnly || destinationPreset == .any { + Section(destinationPreset == .all ? "Simulator Parameters" : "Parameters") { + ForEach(selectedArtifactProvider.parameters, id: \.key) { parameter in + ParameterTextField( + parameter: parameter, + text: simulatorArtifactProviderParameter(key: parameter.key) + ) + } + } } - Text("For builds that are updated regularly, use a constant URL that always points to the latest version.") - .font(.subheadline) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) + if destinationPreset == .all || destinationPreset == .deviceOnly { + Section(destinationPreset == .all ? "Device Parameters" : "Parameters") { + ForEach(selectedArtifactProvider.parameters, id: \.key) { parameter in + ParameterTextField( + parameter: parameter, + text: deviceArtifactProviderParameter(key: parameter.key) + ) + } + } + } } } .formStyle(.grouped) @@ -92,32 +108,41 @@ struct AddPinnedApplicationSheet: View { .frame(width: 500) .fixedSize() .scrollDisabled(true) + .onAppear { + if editingApplicationID == nil { + artifactProviderID = artifactProviders.first?.id + } + } + .onChange(of: artifactProviderID) { oldValue, newValue in + simulatorArtifactProviderParameters.removeAll() + deviceArtifactProviderParameters.removeAll() + } } - private var buildTypeDescription: String { - switch urlSetType { - case .universal: - return "This build can run on both virtual and physical devices." - case .multiTarget: - return "Virtual devices and physical devices require separate builds located at two separate URLs." - case .virtualOnly: - return "This build can only run on virtual devices." - case .physicalOnly: - return "This build can only run on physical devices." - } + private var artifactProviders: [ArtifactProviderSpecification] { + extensionHost.availableExtensions.flatMap(\.specification.artifactProviders) } - private var primaryActionDisabled: Bool { - let urlsValid: Bool = { - switch urlSetType { - case .universal, .virtualOnly, .physicalOnly: - return url.isValidURL - case .multiTarget: - return physicalURL.isValidURL && virtualURL.isValidURL - } - }() + private var selectedArtifactProvider: ArtifactProviderSpecification? { + artifactProviders.first { $0.id == artifactProviderID } + } - return name.isEmpty || !urlsValid + private func simulatorArtifactProviderParameter(key: String) -> Binding { + .init( + get: { simulatorArtifactProviderParameters[key, default: ""] }, + set: { simulatorArtifactProviderParameters[key] = $0 } + ) + } + + private func deviceArtifactProviderParameter(key: String) -> Binding { + .init( + get: { deviceArtifactProviderParameters[key, default: ""] }, + set: { deviceArtifactProviderParameters[key] = $0 } + ) + } + + private var primaryActionDisabled: Bool { + name.isEmpty || installRecipes.isEmpty } private func performCancelAction() { @@ -125,15 +150,14 @@ struct AddPinnedApplicationSheet: View { } private func performDefaultAction() { - if let editingApplicationID = editingApplicationID, + if let editingApplicationID, let existingIndex = pinnedApplicationState.pinnedApplications.firstIndex(where: { $0.id == editingApplicationID }) { let existingItem = pinnedApplicationState.pinnedApplications[existingIndex] var newPinnedApplication = PinnedApplication( id: editingApplicationID, name: name, - platform: platform, - artifacts: artifacts + recipes: installRecipes ) newPinnedApplication.icon = existingItem.icon pinnedApplicationState.pinnedApplications[existingIndex] = newPinnedApplication @@ -141,8 +165,7 @@ struct AddPinnedApplicationSheet: View { } else { let newPinnedApplication = PinnedApplication( name: name, - platform: platform, - artifacts: artifacts + recipes: installRecipes ) pinnedApplicationState.pinnedApplications.append(newPinnedApplication) } @@ -150,44 +173,100 @@ struct AddPinnedApplicationSheet: View { presentationMode.wrappedValue.dismiss() } - private var artifacts: [Artifact] { - // URLs are being force-unwrapped as performDefaultAction can only be called if its button is enabled. - // We check the validity before enabling the button. - switch urlSetType { - case .universal: - return [Artifact(url: URL(string: url)!, targets: [.virtual, .physical])] - case .multiTarget: - return [ - Artifact(url: URL(string: virtualURL)!, targets: [.virtual]), - Artifact(url: URL(string: physicalURL)!, targets: [.physical]) - ] - case .virtualOnly: - return [Artifact(url: URL(string: url)!, targets: [.virtual])] - case .physicalOnly: - return [Artifact(url: URL(string: url)!, targets: [.physical])] + private var installRecipes: [InstallRecipe] { + guard let selectedArtifactProvider else { + return [] + } + + let simulatorArtifactProviderMetadata = ArtifactProviderMetadata( + id: selectedArtifactProvider.id, + parameters: simulatorArtifactProviderParameters + ) + + let artifactProviderMetadata = ArtifactProviderMetadata( + id: selectedArtifactProvider.id, + parameters: deviceArtifactProviderParameters + ) + return switch destinationPreset { + case .any: + [ + .init( + source: .artifactProvider(metadata: simulatorArtifactProviderMetadata), + launchArguments: [], + platformHint: platform + ) + ] + case .all: + [ + .init( + source: .artifactProvider(metadata: simulatorArtifactProviderMetadata), + launchArguments: [], + platformHint: platform, + destinationHint: .simulator + ), + .init( + source: .artifactProvider(metadata: artifactProviderMetadata), + launchArguments: [], + platformHint: platform, + destinationHint: .device + ) + ] + case .simulatorOnly: + [ + .init( + source: .artifactProvider(metadata: simulatorArtifactProviderMetadata), + launchArguments: [], + platformHint: platform, + destinationHint: .simulator + ) + ] + case .deviceOnly: + [ + .init( + source: .artifactProvider(metadata: artifactProviderMetadata), + launchArguments: [], + platformHint: platform, + destinationHint: .device + ) + ] } } } -private enum URLSetType { - case universal - case multiTarget - case virtualOnly - case physicalOnly +private enum DestinationPreset { + case any + case all + case simulatorOnly + case deviceOnly +} + +extension DestinationPreset { + var helpText: LocalizedStringResource { + switch self { + case .any: + return "This build can run on both simulators and devices." + case .all: + return "Simulators and devices require separate builds." + case .simulatorOnly: + return "This build can only run on simulators." + case .deviceOnly: + return "This build can only run on devices." + } + } } -extension URLSetType: CaseIterable {} -extension URLSetType: CustomStringConvertible { +extension DestinationPreset: CaseIterable {} +extension DestinationPreset: CustomStringConvertible { var description: String { switch self { - case .universal: - return "Universal" - case .multiTarget: - return "Multi-Target" - case .virtualOnly: - return "Virtual Only" - case .physicalOnly: - return "Physical Only" + case .any: + return "Any" + case .all: + return "All" + case .simulatorOnly: + return "Simulator" + case .deviceOnly: + return "Device" } } } @@ -198,36 +277,34 @@ extension AddPinnedApplicationSheet { _name = State(initialValue: applicationToEdit.name) _platform = State(initialValue: applicationToEdit.platform) - let artifactSet = ArtifactSet(artifacts: applicationToEdit.artifacts) - let physicalArtifact = artifactSet.artifacts(targeting: .physical).first - let virtualArtifact = artifactSet.artifacts(targeting: .virtual).first - - if let virtualArtifact = virtualArtifact, - let physicalArtifact = physicalArtifact { - let isUniversal = physicalArtifact.url == virtualArtifact.url - - _urlSetType = State(initialValue: isUniversal ? .universal : .multiTarget) - - if isUniversal { - // They're identical, so just use any one of them. - _url = State(from: physicalArtifact) - } else { - _virtualURL = State(from: virtualArtifact) - _physicalURL = State(from: physicalArtifact) - } - } else if let physicalArtifact = physicalArtifact { - _urlSetType = State(initialValue: .physicalOnly) - _url = State(from: physicalArtifact) - } else if let virtualArtifact = virtualArtifact { - _urlSetType = State(initialValue: .virtualOnly) - _url = State(from: virtualArtifact) + let recipes = applicationToEdit.recipes + + _artifactProviderID = State(initialValue: recipes.first?.artifactProviderMetadata.id) + + if let virtualRecipe = recipes.first(where: { $0.destinationHint == .simulator }), + let physicalRecipe = recipes.first(where: { $0.destinationHint == .device }) { + _destinationPreset = State(initialValue: .all) + _simulatorArtifactProviderParameters = State(initialValue: virtualRecipe.artifactProviderMetadata.parameters) + _deviceArtifactProviderParameters = State(initialValue: physicalRecipe.artifactProviderMetadata.parameters) + } else if let physicalRecipe = recipes.first(where: { $0.destinationHint == .device }) { + _destinationPreset = State(initialValue: .deviceOnly) + _deviceArtifactProviderParameters = State(initialValue: physicalRecipe.artifactProviderMetadata.parameters) + } else if let virtualRecipe = recipes.first(where: { $0.destinationHint == .simulator }) { + _destinationPreset = State(initialValue: .simulatorOnly) + _simulatorArtifactProviderParameters = State(initialValue: virtualRecipe.artifactProviderMetadata.parameters) + } else if let firstRecipe = recipes.first { + _destinationPreset = State(initialValue: .any) + _simulatorArtifactProviderParameters = State(initialValue: firstRecipe.artifactProviderMetadata.parameters) } } - } -private extension State where Value == String { - init(from artifact: Artifact) { - self.init(initialValue: artifact.url.absoluteString) +private extension InstallRecipe { + var artifactProviderMetadata: ArtifactProviderMetadata { + guard case .artifactProvider(let metadata) = source else { + fatalError("Only build providers are supported in the graphical Quick Launch editor.") + } + + return metadata } } diff --git a/Tophat/Views/Settings/ExtensionsTab.swift b/Tophat/Views/Settings/ExtensionsTab.swift new file mode 100644 index 0000000..123eb39 --- /dev/null +++ b/Tophat/Views/Settings/ExtensionsTab.swift @@ -0,0 +1,94 @@ +// +// ExtensionsTab.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-09-24. +// Copyright © 2024 Shopify. All rights reserved. +// + +import SwiftUI +import ExtensionFoundation +import ExtensionKit +@_spi(TophatKitInternal) import TophatKit + +struct ExtensionsTab: View { + @Environment(ExtensionHost.self) private var extensionHost + + @State private var selectedExtension: TophatExtension? + + var body: some View { + Form { + Section { + ForEach(extensionHost.availableExtensions) { availableExtension in + LabeledContent { + Button("Info", systemImage: "info.circle") { + selectedExtension = availableExtension + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .font(.title2) + .fontWeight(.light) + .disabled(!availableExtension.specification.isConfigurable) + } label: { + Label { + Text(availableExtension.specification.title) + Text(availableExtension.specification.description ?? "") + } icon: { + SymbolChip(systemName: "puzzlepiece.extension.fill", color: .gray) + .imageScale(.medium) + .padding(.top, 3) + } + } + .padding([.leading, .vertical], 4) + } + } header: { + Text("Extensions") + Text("Extensions are used to add extra functionality to Tophat. Some extensions have adjustable settings that can be revealed using the \(Image(systemName: "info.circle")) button.") + } + + Section { + Text("Tophat extensions can be enabled or disabled in [System Settings](x-apple.systempreferences:com.apple.preference).") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .formStyle(.grouped) + .sheet(item: $selectedExtension) { selectedExtension in + VStack(spacing: 0) { + ExtensionSettingsHostingView(identity: selectedExtension.identity) + .frame(minHeight: 300) + + Divider() + + HStack { + Spacer() + Button("Done") { + self.selectedExtension = nil + } + } + .padding(20) + } + } + } +} + +private struct ExtensionSettingsHostingView: NSViewControllerRepresentable { + var identity: AppExtensionIdentity + + func makeNSViewController(context: Context) -> EXHostViewController { + let hostViewController = EXHostViewController() + hostViewController.configuration = EXHostViewController.Configuration( + appExtension: identity, + sceneID: "TophatExtensionSettings" + ) + + return hostViewController + } + + func updateNSViewController(_ nsViewController: EXHostViewController, context: Context) { + nsViewController.configuration = EXHostViewController.Configuration( + appExtension: identity, + sceneID: "TophatExtensionSettings" + ) + } +} diff --git a/Tophat/Views/Settings/InfoButton.swift b/Tophat/Views/Settings/InfoButton.swift new file mode 100644 index 0000000..cb0e712 --- /dev/null +++ b/Tophat/Views/Settings/InfoButton.swift @@ -0,0 +1,30 @@ +// +// InfoButton.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2024 Shopify. All rights reserved. +// + +import SwiftUI + +struct InfoButton: View { + @State private var isPresented = false + + var help: LocalizedStringResource + + var body: some View { + Button("Help", systemImage: "info.circle") { + isPresented.toggle() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .popover(isPresented: $isPresented) { + ScrollView { + Text(help) + .padding() + } + .frame(width: 400, height: 120, alignment: .topLeading) + } + } +} diff --git a/Tophat/Views/Settings/Locations/GoogleStorageUtilPicker.swift b/Tophat/Views/Settings/Locations/GoogleStorageUtilPicker.swift deleted file mode 100644 index d74f5ca..0000000 --- a/Tophat/Views/Settings/Locations/GoogleStorageUtilPicker.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// GoogleStorageUtilPicker.swift -// Tophat -// -// Created by Lukas Romsicki on 2023-01-24. -// Copyright © 2023 Shopify. All rights reserved. -// - -import SwiftUI - -struct GoogleStorageUtilPicker: View { - @EnvironmentObject private var utilityPathPreferences: UtilityPathPreferences - - var body: some View { - LocationPicker( - preferredValue: $utilityPathPreferences.preferredGSUtilPath, - resolvedValue: utilityPathPreferences.resolvedGSUtilLocation?.path(percentEncoded: false) - ) { - Text("gsutil") - Text("The location of the Google Cloud Storage utility.") - } icon: { - SymbolChip(systemName: "externaldrive.fill", color: .indigo) - } - } -} diff --git a/Tophat/Views/Settings/LocationsTab.swift b/Tophat/Views/Settings/LocationsTab.swift index a4a1a5e..47ec72c 100644 --- a/Tophat/Views/Settings/LocationsTab.swift +++ b/Tophat/Views/Settings/LocationsTab.swift @@ -22,10 +22,6 @@ struct LocationsTab: View { Section { ScreenCopyPicker() } - - Section { - GoogleStorageUtilPicker() - } } .formStyle(.grouped) } diff --git a/Tophat/Views/Settings/ParameterTextField.swift b/Tophat/Views/Settings/ParameterTextField.swift new file mode 100644 index 0000000..37c8caa --- /dev/null +++ b/Tophat/Views/Settings/ParameterTextField.swift @@ -0,0 +1,32 @@ +// +// ParameterTextField.swift +// Tophat +// +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2024 Shopify. All rights reserved. +// + +import SwiftUI +@_spi(TophatKitInternal) import TophatKit + +struct ParameterTextField: View { + var parameter: ArtifactProviderParameterSpecification + + @Binding var text: String + + var body: some View { + TextField(text: $text, prompt: Text(parameter.prompt ?? parameter.title)) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(parameter.title) + + if let help = parameter.help { + InfoButton(help: help) + } + } + + if let description = parameter.description { + Text(description) + } + } + } +} diff --git a/Tophat/Views/Settings/PinnedApplicationRow.swift b/Tophat/Views/Settings/PinnedApplicationRow.swift index 89ef2a7..a732a5f 100644 --- a/Tophat/Views/Settings/PinnedApplicationRow.swift +++ b/Tophat/Views/Settings/PinnedApplicationRow.swift @@ -8,8 +8,10 @@ import SwiftUI import TophatFoundation +@_spi(TophatKitInternal) import TophatKit struct PinnedApplicationRow: View { + @Environment(ExtensionHost.self) private var extensionHost @Environment(\.isEnabled) private var isEnabled let application: PinnedApplication @@ -24,21 +26,37 @@ struct PinnedApplicationRow: View { .pinnedApplicationImageStyle() } VStack(alignment: .leading, spacing: 3) { - Text("\(application.name) (\(application.platform.description))") + Text(application.name) .fontWeight(.medium) - ForEach(application.artifacts, id: \.url) { artifact in - BadgedURL(badges: formatted(targets: artifact.targets), url: artifact.url) + HStack { + BadgedText(text: "\(application.platform.description)") + + if case .artifactProvider(let metadata) = application.recipes.first?.source, let artifactProvider = artifactProviders.first(where: { $0.id == metadata.id }) { + BadgedText(text: artifactProvider.title) + } } } } .opacity(isEnabled ? 1 : 0.5) } - private func formatted(targets: Set) -> [String] { - Array(targets) - .map { String(describing: $0).capitalized } - .sorted() + private var artifactProviders: [ArtifactProviderSpecification] { + extensionHost.availableExtensions.flatMap(\.specification.artifactProviders) + } +} + +private struct BadgedText: View { + var text: LocalizedStringResource + + var body: some View { + Text(text) + .font(.caption) + .foregroundColor(.secondary) + .padding(.vertical, 1) + .padding(.horizontal, 4) + .background(.quaternary) + .cornerRadius(3) } } diff --git a/Tophat/Views/SettingsView.swift b/Tophat/Views/SettingsView.swift index ab80391..9d1044e 100644 --- a/Tophat/Views/SettingsView.swift +++ b/Tophat/Views/SettingsView.swift @@ -13,6 +13,7 @@ enum SettingsTab: Int { case apps case devices case locations + case extensions } struct SettingsView: View { @@ -43,10 +44,16 @@ struct SettingsView: View { Label("Locations", systemImage: "externaldrive") } .tag(SettingsTab.locations) - } + ExtensionsTab() + .tabItem { + Label("Extensions", systemImage: "puzzlepiece.extension") + } + .tag(SettingsTab.extensions) + } .frame(width: 600) .frame(maxHeight: 500) + .scrollContentBackground(.hidden) .fixedSize() } } diff --git a/Tophat/com.shopify.Tophat.extension.appextensionpoint b/Tophat/com.shopify.Tophat.extension.appextensionpoint new file mode 100644 index 0000000..82fae36 --- /dev/null +++ b/Tophat/com.shopify.Tophat.extension.appextensionpoint @@ -0,0 +1,11 @@ + + + + + com.shopify.Tophat.extension + + EXPresentsUserInterface + + + + diff --git a/TophatCtl/Commands/Apps/Apps+Add.swift b/TophatCtl/Commands/Apps/Apps+Add.swift index bcac06a..6097d53 100644 --- a/TophatCtl/Commands/Apps/Apps+Add.swift +++ b/TophatCtl/Commands/Apps/Apps+Add.swift @@ -9,7 +9,7 @@ import Foundation import ArgumentParser import TophatFoundation -import TophatKit +import TophatUtilities import AppKit extension Apps { @@ -19,44 +19,19 @@ extension Apps { discussion: "If an existing item with the same identifier already exists, the item will be updated with new information." ) - @Option(help: "The unique identifier of the entry. If not specified, a generated identifier will be used.") - var id: String? - - @Option(help: "The display name of the application. A short name is best.") - var name: String - - @Option(help: "The platform of the application.") - var platform: Platform - - @Option(help: "The URL of the the artifact built for virtual devices.") - var virtual: URL? - - @Option(help: "The URL of the the artifact built for physical devices.") - var physical: URL? - - @Option(help: "The URL of the the artifact built for any device type.") - var universal: URL? + @Argument(help: "The path to the configuration file for the app.") + var path: URL func run() throws { if !NSRunningApplication.isTophatRunning { print("Warning: Tophat must be running for this command to succeed, but it is not running.") } - if virtual == nil, physical == nil, universal == nil { - throw ValidationError("You must specify at least one of --virtual, --physical, or --universal.") - } - - if universal != nil, virtual != nil || physical != nil { - throw ValidationError("You must specify one of --universal, or a combination of --virtual and --physical.") - } + let data = try Data(contentsOf: path) + let configuration = try JSONDecoder().decode(UserSpecifiedQuickLaunchEntryConfiguration.self, from: data) let payload = TophatAddPinnedApplicationNotification.Payload( - id: id, - name: name, - platform: platform, - virtualURL: virtual, - physicalURL: physical, - universalURL: universal + configuration: configuration ) let notification = TophatAddPinnedApplicationNotification(payload: payload) diff --git a/TophatCtl/Commands/Apps/Apps+Remove.swift b/TophatCtl/Commands/Apps/Apps+Remove.swift index 0b919f1..2305a9d 100644 --- a/TophatCtl/Commands/Apps/Apps+Remove.swift +++ b/TophatCtl/Commands/Apps/Apps+Remove.swift @@ -9,7 +9,7 @@ import Foundation import ArgumentParser import TophatFoundation -import TophatKit +import TophatUtilities import AppKit extension Apps { diff --git a/TophatCtl/Commands/FastInstall.swift b/TophatCtl/Commands/FastInstall.swift deleted file mode 100644 index c8809c1..0000000 --- a/TophatCtl/Commands/FastInstall.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// FastInstall.swift -// tophatctl -// -// Created by Lukas Romsicki on 2023-01-26. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation -import ArgumentParser -import TophatFoundation -import TophatKit - -struct FastInstall: ParsableCommand { - static var configuration = CommandConfiguration( - commandName: "fast-install", - abstract: "Installs an application, preparing the required device in advance.", - discussion: "This command uses platform and build type hints to prepare the device earlier to speed up the installation process." - ) - - @Argument(help: "The platform to install the artifact on.") - var platform: Platform - - @Option(name: [.short, .long], help: "The URL or path of the artifact built for virtual devices.") - var virtual: URL? - - @Option(name: [.short, .long], help: "The URL or path of the artifact built for physical devices.") - var physical: URL? - - @Option(name: [.short, .long], help: "The URL or path of the artifact built for any device type.") - var universal: URL? - - @Option(parsing: .upToNextOption, help: "Arguments to pass to the application on launch.") - var launchArguments: [String] = [] - - func run() throws { - if virtual == nil, physical == nil, universal == nil { - throw ValidationError("You must specify at least one of --virtual, --physical, or --universal.") - } - - if universal != nil, virtual != nil || physical != nil { - throw ValidationError("You must specify either --universal, or a combination of --virtual and --physical.") - } - - let payload = TophatInstallHintedNotification.Payload( - platform: platform, - virtualURL: virtual, - physicalURL: physical, - universalURL: universal, - launchArguments: launchArguments - ) - - let notification = TophatInstallHintedNotification(payload: payload) - TophatInterProcessNotifier().send(notification: notification) - } -} diff --git a/TophatCtl/Commands/Install.swift b/TophatCtl/Commands/Install.swift index deaa223..fa7e045 100644 --- a/TophatCtl/Commands/Install.swift +++ b/TophatCtl/Commands/Install.swift @@ -9,7 +9,7 @@ import Foundation import ArgumentParser import TophatFoundation -import TophatKit +import TophatUtilities struct Install: ParsableCommand { static var configuration = CommandConfiguration( @@ -17,19 +17,50 @@ struct Install: ParsableCommand { discussion: "This command infers platform and build type after the artifact has been downloaded. It is ideal for local artifacts that don't take any time to download." ) - @Argument(help: "The URL or local path of the artifact.") - var url: URL + @Option(name: [.short, .long], help: "The URL or local path of the artifact.") + var url: URL? = nil - @Option(parsing: .upToNextOption, help: "Arguments to pass to the application on launch.") + @Option(name: [.short, .long], help: "The path to the configuration file to use for installation.") + var configuration: URL? = nil + + @Option(parsing: .upToNextOption, help: "Arguments to pass to the application on launch when using --url.") var launchArguments: [String] = [] func run() throws { - let payload = TophatInstallGenericNotification.Payload( - url: url, - launchArguments: launchArguments - ) + guard url != nil || configuration != nil else { + throw ValidationError("You must specify one of --url or --configuration.") + } + + guard url == nil || configuration == nil else { + throw ValidationError("You must specify only one of --url or --configuration, but not both.") + } + + if configuration != nil, !launchArguments.isEmpty { + throw ValidationError("--launch-arguments can only be used with --url. When using --configuration, launch arguments are specified in the configuration file.") + } + + let notification: (any TophatInterProcessNotification)? = if let url { + TophatInstallURLNotification( + payload: TophatInstallURLNotification.Payload( + url: url, + launchArguments: launchArguments + ) + ) + } else if let configuration { + TophatInstallConfigurationNotification( + payload: TophatInstallConfigurationNotification.Payload( + installRecipes: try JSONDecoder().decode( + [UserSpecifiedInstallRecipe].self, + from: Data(contentsOf: configuration) + ) + ) + ) + } else { + nil + } - let notification = TophatInstallGenericNotification(payload: payload) - TophatInterProcessNotifier().send(notification: notification) + if let notification { + TophatInterProcessNotifier().send(notification: notification) + } } } diff --git a/TophatCtl/TophatCtl.swift b/TophatCtl/TophatCtl.swift index 6e735cc..5f29030 100644 --- a/TophatCtl/TophatCtl.swift +++ b/TophatCtl/TophatCtl.swift @@ -15,7 +15,6 @@ struct TophatCtl: ParsableCommand { abstract: "A utility for interacting with Tophat from command line applications.", subcommands: [ Install.self, - FastInstall.self, Apps.self ] ) diff --git a/TophatExtensions/TophatCoreExtension/Build Providers/HTTPArtifactProvider.swift b/TophatExtensions/TophatCoreExtension/Build Providers/HTTPArtifactProvider.swift new file mode 100644 index 0000000..32fda94 --- /dev/null +++ b/TophatExtensions/TophatCoreExtension/Build Providers/HTTPArtifactProvider.swift @@ -0,0 +1,36 @@ +// +// HTTPArtifactProvider.swift +// TophatCoreExtension +// +// Created by Lukas Romsicki on 2024-10-05. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatKit + +struct HTTPArtifactProvider: ArtifactProvider { + static let id = "http" + static let title: LocalizedStringResource = "Basic HTTP" + + @Parameter(key: "url", title: "URL") + var url: URL + + func retrieve() async throws -> some ArtifactProviderResult { + let (downloadedFileURL, response) = try await URLSession.shared.download(from: url) + + let destinationDirectoryURL: URL = .temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true) + + let destinationURL = destinationDirectoryURL + .appending(component: response.suggestedFilename ?? url.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) + } +} diff --git a/TophatExtensions/TophatCoreExtension/Build Providers/ShellScriptArtifactProvider.swift b/TophatExtensions/TophatCoreExtension/Build Providers/ShellScriptArtifactProvider.swift new file mode 100644 index 0000000..c38b71b --- /dev/null +++ b/TophatExtensions/TophatCoreExtension/Build Providers/ShellScriptArtifactProvider.swift @@ -0,0 +1,64 @@ +// +// ShellScriptArtifactProvider.swift +// TophatCoreExtension +// +// Created by Lukas Romsicki on 2024-10-09. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatKit + +struct ShellScriptArtifactProvider: ArtifactProvider { + static let id = "shell" + static let title: LocalizedStringResource = "Shell Script" + + @Parameter( + key: "script", + title: "Script", + description: "The file name of the script to run.", + prompt: "File Name", + help: "Place scripts in the ~/Library/Application Scripts/\(Bundle.main.bundleIdentifier!) folder." + ) + var script: String + + func retrieve() async throws -> some ArtifactProviderResult { + let temporaryDirectoryURL: URL = .temporaryDirectory.appending(path: UUID().uuidString) + let stagingDirectoryURL = temporaryDirectoryURL.appending(path: "Staging") + let outputDirectoryURL = temporaryDirectoryURL.appending(path: "Output") + + try FileManager.default.createDirectory(at: stagingDirectoryURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outputDirectoryURL, withIntermediateDirectories: true) + + let applicationScriptsURL = try FileManager.default.url( + for: .applicationScriptsDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + + let task = try NSUserUnixTask(url: applicationScriptsURL.appending(component: script)) + try await task.execute(withArguments: [stagingDirectoryURL.path(), outputDirectoryURL.path()]) + + let directoryContents = try FileManager.default.contentsOfDirectory( + at: outputDirectoryURL, + includingPropertiesForKeys: nil + ) + + guard let fileURL = directoryContents.first else { + throw ShellScriptArtifactProviderError.fileNotFound + } + + return .result(localURL: fileURL) + } + + func cleanUp(localURL: URL) async throws { + try FileManager.default.removeItem( + at: localURL.deletingLastPathComponent().deletingLastPathComponent() + ) + } +} + +enum ShellScriptArtifactProviderError: Error { + case fileNotFound +} diff --git a/TophatExtensions/TophatCoreExtension/Info.plist b/TophatExtensions/TophatCoreExtension/Info.plist new file mode 100644 index 0000000..b67d638 --- /dev/null +++ b/TophatExtensions/TophatCoreExtension/Info.plist @@ -0,0 +1,11 @@ + + + + + EXAppExtensionAttributes + + EXExtensionPointIdentifier + com.shopify.Tophat.extension + + + diff --git a/TophatExtensions/TophatCoreExtension/Localizable.xcstrings b/TophatExtensions/TophatCoreExtension/Localizable.xcstrings new file mode 100644 index 0000000..b1d1c26 --- /dev/null +++ b/TophatExtensions/TophatCoreExtension/Localizable.xcstrings @@ -0,0 +1,33 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Basic HTTP" : { + + }, + "Built-in Tophat functionality" : { + + }, + "Core Features" : { + + }, + "File Name" : { + + }, + "Place scripts in the ~/Library/Application Scripts/%@ folder." : { + + }, + "Script" : { + + }, + "Shell Script" : { + + }, + "The file name of the script to run." : { + + }, + "URL" : { + + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/TophatExtensions/TophatCoreExtension/TophatCoreExtension.entitlements b/TophatExtensions/TophatCoreExtension/TophatCoreExtension.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/TophatExtensions/TophatCoreExtension/TophatCoreExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/TophatExtensions/TophatCoreExtension/TophatCoreExtension.swift b/TophatExtensions/TophatCoreExtension/TophatCoreExtension.swift new file mode 100644 index 0000000..007b7bc --- /dev/null +++ b/TophatExtensions/TophatCoreExtension/TophatCoreExtension.swift @@ -0,0 +1,22 @@ +// +// TophatBaseExtension.swift +// TophatBaseExtension +// +// Created by Lukas Romsicki on 2024-10-04. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import TophatKit +import SwiftUI + +@main +struct TophatCoreExtension: TophatExtension, ArtifactProviding { + static let title: LocalizedStringResource = "Core Features" + static var description: LocalizedStringResource? = "Built-in Tophat functionality" + + static var artifactProviders: some ArtifactProviders { + HTTPArtifactProvider() + ShellScriptArtifactProvider() + } +} diff --git a/TophatKit/Package.swift b/TophatKit/Package.swift new file mode 100644 index 0000000..37ddca0 --- /dev/null +++ b/TophatKit/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "TophatKit", + platforms: [ + .macOS(.v14) + ], + products: [ + .library( + name: "TophatKit", + targets: ["TophatKit"] + ), + ], + targets: [ + .target(name: "TophatKit") + ] +) diff --git a/TophatKit/Sources/TophatKit/ArtifactProvider.swift b/TophatKit/Sources/TophatKit/ArtifactProvider.swift new file mode 100644 index 0000000..d6bfead --- /dev/null +++ b/TophatKit/Sources/TophatKit/ArtifactProvider.swift @@ -0,0 +1,44 @@ +// +// ArtifactProvider.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-06. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +/// The type you use to define a mechanism for retrieving builds for installation +/// with Tophat. +/// +/// Create a ``ArtifactProvider`` for each type of build source, such as for retrieving +/// from the local filesystem, a continuous integration provider, or cloud storage provider. +/// If the source requires authentication, handle it in the ``retrieve()`` function as +/// well. +public protocol ArtifactProvider { + associatedtype Result = ArtifactProviderResult + typealias Parameter = ArtifactProviderParameter + + /// The unique identifier of the build provider. + /// + /// Tophat exposes this value through its own interfaces or in the graphical + /// user interface so that people can specify which provider to use when + /// retrieving a build. + static var id: String { get } + + /// A human-readable title for this build provider. + static var title: LocalizedStringResource { get } + + init() + + /// The function used to retrieve the build. + /// + /// Throw any errors if they ocurred. Use any parameters wrapped with ``Parameter`` to + /// collect inputs from Tophat to implement the retrieval mechanism. + /// - Returns: A ``ArtifactProviderResult`` containing the output. + func retrieve() async throws -> Result + + /// The function used to clean up the downloaded build once it is no longer needed. + /// - Parameter localURL: The URL of the local resource that should be cleaned up. + func cleanUp(localURL: URL) async throws +} diff --git a/TophatKit/Sources/TophatKit/ArtifactProviderParameter.swift b/TophatKit/Sources/TophatKit/ArtifactProviderParameter.swift new file mode 100644 index 0000000..13a0644 --- /dev/null +++ b/TophatKit/Sources/TophatKit/ArtifactProviderParameter.swift @@ -0,0 +1,46 @@ +// +// ArtifactProviderParameter.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-06. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +@propertyWrapper +public final class ArtifactProviderParameter: @unchecked Sendable where Value: ArtifactProviderValue, Value: Sendable { + public let key: String + public let title: LocalizedStringResource + public let description: LocalizedStringResource? + public let prompt: LocalizedStringResource? + public let help: LocalizedStringResource? + + var storage: Value? + + public var wrappedValue: Value { + get { + if let storage { return storage } + fatalError("Attempting to access parameter value before initialization!") + } + set { + storage = newValue + } + } + + public init( + key: String, + title: LocalizedStringResource, + description: LocalizedStringResource? = nil, + prompt: LocalizedStringResource? = nil, + help: LocalizedStringResource? = nil + ) { + self.key = key + self.title = title + self.description = description + self.prompt = prompt + self.help = help + } +} + +extension ArtifactProviderParameter: AnyArtifactProviderParameter {} diff --git a/TophatKit/Sources/TophatKit/ArtifactProviderResult.swift b/TophatKit/Sources/TophatKit/ArtifactProviderResult.swift new file mode 100644 index 0000000..278b16f --- /dev/null +++ b/TophatKit/Sources/TophatKit/ArtifactProviderResult.swift @@ -0,0 +1,21 @@ +// +// ArtifactProviderResult.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-06. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public protocol ArtifactProviderResult {} + +public struct ArtifactProviderResultContainer: Codable, ArtifactProviderResult { + public var localURL: URL +} + +public extension ArtifactProviderResult { + static func result(localURL: URL) -> Self where Self == ArtifactProviderResultContainer { + return .init(localURL: localURL) + } +} diff --git a/TophatKit/Sources/TophatKit/ArtifactProviderValue.swift b/TophatKit/Sources/TophatKit/ArtifactProviderValue.swift new file mode 100644 index 0000000..c17e791 --- /dev/null +++ b/TophatKit/Sources/TophatKit/ArtifactProviderValue.swift @@ -0,0 +1,64 @@ +// +// ArtifactProviderValue.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-06. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public protocol ArtifactProviderValue { + init?(stringRepresentation: String) +} + +extension String: ArtifactProviderValue { + public init?(stringRepresentation: String) { + self = stringRepresentation + } +} + +// MARK: - RawRepresentable + +extension RawRepresentable where Self: ArtifactProviderValue, RawValue: ArtifactProviderValue { + public init?(stringRepresentation: String) { + if let value = RawValue(stringRepresentation: stringRepresentation) { + self.init(rawValue: value) + } else { + return nil + } + } +} + +// MARK: - Optional + +extension Optional: ArtifactProviderValue where Wrapped: ArtifactProviderValue { + public init?(stringRepresentation: String) { + if let value = Wrapped(stringRepresentation: stringRepresentation) { + self.init(value) + } else { + return nil + } + } +} + +// MARK: - URL + +extension URL: ArtifactProviderValue { + public init?(stringRepresentation: String) { + self.init(string: stringRepresentation) + } +} + +// MARK: - LosslessStringConvertible + +extension LosslessStringConvertible where Self: ArtifactProviderValue { + public init?(stringRepresentation: String) { + self.init(stringRepresentation) + } +} + +extension Int: ArtifactProviderValue {} +extension Double: ArtifactProviderValue {} +extension Float: ArtifactProviderValue {} +extension Bool: ArtifactProviderValue {} diff --git a/TophatKit/Sources/TophatKit/ArtifactProvidersBuilder.swift b/TophatKit/Sources/TophatKit/ArtifactProvidersBuilder.swift new file mode 100644 index 0000000..66dcbfb --- /dev/null +++ b/TophatKit/Sources/TophatKit/ArtifactProvidersBuilder.swift @@ -0,0 +1,26 @@ +// +// ArtifactProvidersBuilder.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-07. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public protocol ArtifactProviders {} + +extension ArtifactProviders { + var arrayValue: [any ArtifactProvider]? { + self as? [any ArtifactProvider] + } +} + +extension Array: ArtifactProviders where Element == any ArtifactProvider {} + +@resultBuilder +public struct ArtifactProvidersBuilder { + public static func buildBlock(_ components: (any ArtifactProvider)...) -> some ArtifactProviders { + components + } +} diff --git a/TophatKit/Sources/TophatKit/Internal/AnyArtifactProviderParameter.swift b/TophatKit/Sources/TophatKit/Internal/AnyArtifactProviderParameter.swift new file mode 100644 index 0000000..461a730 --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/AnyArtifactProviderParameter.swift @@ -0,0 +1,19 @@ +// +// AnyArtifactProviderParameter.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-06. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +protocol AnyArtifactProviderParameter: AnyObject, Sendable { + associatedtype Value: ArtifactProviderValue, Sendable + + var key: String { get } + var title: LocalizedStringResource { get } + var description: LocalizedStringResource? { get } + var prompt: LocalizedStringResource? { get } + var help: LocalizedStringResource? { get } +} diff --git a/TophatKit/Sources/TophatKit/Internal/BuildProvider+Parameters.swift b/TophatKit/Sources/TophatKit/Internal/BuildProvider+Parameters.swift new file mode 100644 index 0000000..d705133 --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/BuildProvider+Parameters.swift @@ -0,0 +1,46 @@ +// +// ArtifactProvider+Parameters.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-06. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +extension ArtifactProvider { + func setParameters(to parameterDictionary: [String: String]) throws { + for parameter in parameters { + let key = parameter.key + + guard let value = parameterDictionary[key] else { + throw ArtifactProviderError.missingParameter + } + + try parameter.store(stringRepresentation: value) + } + } + + var parameters: [any AnyArtifactProviderParameter] { + Mirror(reflecting: self) + .children + .compactMap { child in + child.value as? any AnyArtifactProviderParameter + } + } +} + +private extension AnyArtifactProviderParameter { + func store(stringRepresentation: String) throws { + guard let parameter = self as? ArtifactProviderParameter else { + throw ArtifactProviderError.invalidType + } + + parameter.storage = Value(stringRepresentation: stringRepresentation) + } +} + +enum ArtifactProviderError: Error { + case missingParameter + case invalidType +} diff --git a/TophatKit/Sources/TophatKit/Internal/ExtensionConfiguration.swift b/TophatKit/Sources/TophatKit/Internal/ExtensionConfiguration.swift new file mode 100644 index 0000000..e057dbe --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/ExtensionConfiguration.swift @@ -0,0 +1,47 @@ +// +// ExtensionConfiguration.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-08. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import ExtensionFoundation + +struct ExtensionConfiguration: AppExtensionConfiguration { + private let service: ExtensionService + + init(appExtension: some TophatExtension) { + self.service = ExtensionService(appExtension: appExtension) + } + + func accept(connection: NSXPCConnection) -> Bool { + let session = ExtensionXPCSession(connection: connection) + session.activate() + + Task { + for await message in session.receivedMessages { + if let retrieveBuildMessage = try? message.decode(as: RetrieveArtifactMessage.self) { + do { + let result = try await service.handleRetreiveArtifact(message: retrieveBuildMessage.value) + retrieveBuildMessage.reply(.success(result)) + } catch { + retrieveBuildMessage.reply(.failure(error)) + } + } + + if let fetchExtensionDescriptorMessage = try? message.decode(as: FetchExtensionSpecificationMessage.self) { + let reply = await service.handleExtensionDescriptor(message: fetchExtensionDescriptorMessage.value) + fetchExtensionDescriptorMessage.reply(.success(reply)) + } + + if let cleanUpBuildMessage = try? message.decode(as: CleanUpArtifactMessage.self) { + try? await service.handleCleanUp(message: cleanUpBuildMessage.value) + } + } + } + + return true + } +} diff --git a/TophatKit/Sources/TophatKit/Internal/ExtensionService.swift b/TophatKit/Sources/TophatKit/Internal/ExtensionService.swift new file mode 100644 index 0000000..7b43fb3 --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/ExtensionService.swift @@ -0,0 +1,60 @@ +// +// ExtensionService.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-08. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +struct ExtensionService { + private let appExtension: any TophatExtension + + init(appExtension: some TophatExtension) { + self.appExtension = appExtension + } + + func handleRetreiveArtifact(message: RetrieveArtifactMessage) async throws -> RetrieveArtifactMessage.Reply { + guard let artifactProvider = makeArtifactProvider(id: message.providerID) else { + throw RetreiveArtifactError.noArtifactProviders + } + + try artifactProvider.setParameters(to: message.parameters) + + guard let resultContainer = try await artifactProvider.retrieve() as? ArtifactProviderResultContainer else { + throw RetreiveArtifactError.invalidResult + } + + return resultContainer + } + + func handleExtensionDescriptor(message: FetchExtensionSpecificationMessage) -> FetchExtensionSpecificationMessage.Reply { + ExtensionSpecification(provider: appExtension) + } + + func handleCleanUp(message: CleanUpArtifactMessage) async throws { + guard let artifactProvider = makeArtifactProvider(id: message.providerID) else { + throw RetreiveArtifactError.noArtifactProviders + } + + try await artifactProvider.cleanUp(localURL: message.url) + } + + private func makeArtifactProvider(id: String) -> (any ArtifactProvider)? { + guard + let artifactProviding = appExtension as? any ArtifactProviding, + let artifactProviders = type(of: artifactProviding).artifactProviders.arrayValue, + let firstMatchingArtifactProvider = artifactProviders.first(where: { type(of: $0).id == id }) + else { + return nil + } + + return type(of: firstMatchingArtifactProvider).init() + } +} + +enum RetreiveArtifactError: Error { + case noArtifactProviders + case invalidResult +} diff --git a/TophatKit/Sources/TophatKit/Internal/Messages/CleanUpArtifactMessage.swift b/TophatKit/Sources/TophatKit/Internal/Messages/CleanUpArtifactMessage.swift new file mode 100644 index 0000000..bcc5582 --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/Messages/CleanUpArtifactMessage.swift @@ -0,0 +1,21 @@ +// +// CleanUpArtifactMessage.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-10-05. +// + +import Foundation + +@_spi(TophatKitInternal) +public struct CleanUpArtifactMessage: ExtensionXPCMessage { + public typealias Reply = Never + + let providerID: String + let url: URL + + public init(providerID: String, url: URL) { + self.providerID = providerID + self.url = url + } +} diff --git a/TophatKit/Sources/TophatKit/Internal/Messages/FetchExtensionSpecificationMessage.swift b/TophatKit/Sources/TophatKit/Internal/Messages/FetchExtensionSpecificationMessage.swift new file mode 100644 index 0000000..8024b21 --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/Messages/FetchExtensionSpecificationMessage.swift @@ -0,0 +1,70 @@ +// +// FetchExtensionSpecificationMessage.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-09. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +@_spi(TophatKitInternal) +public struct FetchExtensionSpecificationMessage: ExtensionXPCMessage { + public typealias Reply = ExtensionSpecification + + public init() {} +} + +@_spi(TophatKitInternal) +public struct ExtensionSpecification: Codable { + public let title: LocalizedStringResource + public let description: LocalizedStringResource? + public let isConfigurable: Bool + public let artifactProviders: [ArtifactProviderSpecification] + + init(provider: some TophatExtension) { + let providerType = type(of: provider) + + self.title = providerType.title + self.description = providerType.description + self.isConfigurable = provider is any SettingsProviding + + self.artifactProviders = if let artifactProviding = provider as? any ArtifactProviding { + type(of: artifactProviding).artifactProviders.arrayValue?.map { .init(provider: $0) } ?? [] + } else { + [] + } + } +} + +@_spi(TophatKitInternal) +public struct ArtifactProviderSpecification: Identifiable, Codable { + public let id: String + public let title: LocalizedStringResource + public let parameters: [ArtifactProviderParameterSpecification] + + init(provider: some ArtifactProvider) { + let providerType = type(of: provider) + + self.id = providerType.id + self.title = providerType.title + self.parameters = provider.parameters.map { .init(parameter: $0) } + } +} + +@_spi(TophatKitInternal) +public struct ArtifactProviderParameterSpecification: Codable { + public let key: String + public let title: LocalizedStringResource + public let description: LocalizedStringResource? + public let prompt: LocalizedStringResource? + public let help: LocalizedStringResource? + + init(parameter: some AnyArtifactProviderParameter) { + self.key = parameter.key + self.title = parameter.title + self.description = parameter.description + self.prompt = parameter.prompt + self.help = parameter.help + } +} diff --git a/TophatKit/Sources/TophatKit/Internal/Messages/RetrieveArtifactMessage.swift b/TophatKit/Sources/TophatKit/Internal/Messages/RetrieveArtifactMessage.swift new file mode 100644 index 0000000..0223fad --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/Messages/RetrieveArtifactMessage.swift @@ -0,0 +1,22 @@ +// +// RetrieveArtifactMessage.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-09. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +@_spi(TophatKitInternal) +public struct RetrieveArtifactMessage: ExtensionXPCMessage { + public typealias Reply = ArtifactProviderResultContainer + + let providerID: String + let parameters: [String: String] + + public init(providerID: String, parameters: [String: String]) { + self.providerID = providerID + self.parameters = parameters + } +} diff --git a/TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCMessage.swift b/TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCMessage.swift new file mode 100644 index 0000000..1966507 --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCMessage.swift @@ -0,0 +1,20 @@ +// +// ExtensionXPCMessage.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-24. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +@_spi(TophatKitInternal) +public protocol ExtensionXPCMessage: Codable { + associatedtype Reply: Codable +} + +extension ExtensionXPCMessage { + var identifier: String { + String(describing: Self.self) + } +} diff --git a/TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCReceivedMessage.swift b/TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCReceivedMessage.swift new file mode 100644 index 0000000..d892caa --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCReceivedMessage.swift @@ -0,0 +1,67 @@ +// +// ExtensionXPCReceivedMessage.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-08. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +struct ExtensionXPCReceivedMessageContainer { + private let identifier: String + private let data: Data + private let replyHandler: (Data?, Error?) -> Void + + init(identifier: String, data: Data, replyHandler: @escaping (Data?, Error?) -> Void) { + self.identifier = identifier + self.data = data + self.replyHandler = replyHandler + } + + public func decode(as type: T.Type) throws -> ExtensionXPCReceivedMessage { + let value = try JSONDecoder().decode(T.self, from: data) + + guard value.identifier == identifier else { + throw DecodeError.invalidIdentifier + } + + return ExtensionXPCReceivedMessage(value: value, container: self) + } + + fileprivate func reply(_ result: Result) { + switch result { + case .success(let success): + do { + let data = try JSONEncoder().encode(success) + replyHandler(data, nil) + } catch { + replyHandler(nil, error) + } + case .failure(let error): + replyHandler(nil, error) + } + } +} + +extension ExtensionXPCReceivedMessageContainer { + enum DecodeError: Error { + /// The type to decode to does not match the identifier of the received message. + case invalidIdentifier + } +} + +struct ExtensionXPCReceivedMessage { + private let container: ExtensionXPCReceivedMessageContainer + + public let value: Message + + init(value: Message, container: ExtensionXPCReceivedMessageContainer) { + self.value = value + self.container = container + } + + public func reply(_ result: Result) { + container.reply(result) + } +} diff --git a/TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCSession.swift b/TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCSession.swift new file mode 100644 index 0000000..923d801 --- /dev/null +++ b/TophatKit/Sources/TophatKit/Internal/XPC/ExtensionXPCSession.swift @@ -0,0 +1,112 @@ +// +// TophatExtensionXPCProtocol.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-06. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +@objc private protocol TophatExtensionXPCProtocol: NSObjectProtocol { + func send(identifier: String, data: Data) + func send(identifier: String, data: Data, reply: @escaping @Sendable (Data?, Error?) -> Void) +} + +@_spi(TophatKitInternal) +public final class ExtensionXPCSession: NSObject, @unchecked Sendable { + private let connection: NSXPCConnection + + let receivedMessages: AsyncStream + private let receivedMessagesContinuation: AsyncStream.Continuation + + public init(connection: NSXPCConnection) { + self.connection = connection + (self.receivedMessages, self.receivedMessagesContinuation) = AsyncStream.makeStream() + + super.init() + + let interface = NSXPCInterface(with: TophatExtensionXPCProtocol.self) + + connection.exportedInterface = interface + connection.exportedObject = self + connection.remoteObjectInterface = interface + } + + public func activate() { + connection.activate() + } + + public func invalidate() { + connection.invalidate() + } +} + +extension ExtensionXPCSession: TophatExtensionXPCProtocol { + fileprivate func send(identifier: String, data: Data) { + send(identifier: identifier, data: data, reply: { _, _ in }) + } + + fileprivate func send(identifier: String, data: Data, reply: @escaping @Sendable (Data?, Error?) -> Void) { + let message = ExtensionXPCReceivedMessageContainer( + identifier: identifier, + data: data, + replyHandler: reply + ) + + receivedMessagesContinuation.yield(message) + } +} + +extension ExtensionXPCSession { + public func send(_ message: Message) async throws where Message.Reply == Never { + guard let service = connection.remoteObjectProxy as? TophatExtensionXPCProtocol else { + return + } + + let data = try JSONEncoder().encode(message) + service.send(identifier: message.identifier, data: data) + } + + public func send(_ message: Message) async throws -> Message.Reply { + let dataToSend = try JSONEncoder().encode(message) + + return try await withCheckedThrowingContinuation { continuation in + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + continuation.resume(throwing: error) + } + + guard let service = proxy as? TophatExtensionXPCProtocol else { + continuation.resume(throwing: TophatExtensionXPCSessionError.invalidProtocol) + return + } + + service.send(identifier: message.identifier, data: dataToSend) { dataFromReply, error in + if let error { + continuation.resume(throwing: error) + return + } + + if let dataFromReply { + do { + let reply = try JSONDecoder().decode(Message.Reply.self, from: dataFromReply) + continuation.resume(returning: reply) + } catch { + continuation.resume(throwing: TophatExtensionXPCSessionError.invalidData) + } + + return + } + + continuation.resume(throwing: TophatExtensionXPCSessionError.missingData) + } + } + } +} + +@_spi(TophatKitInternal) +public enum TophatExtensionXPCSessionError: Error { + case invalidProtocol + case invalidData + case missingData +} diff --git a/TophatKit/Sources/TophatKit/TophatExtension.swift b/TophatKit/Sources/TophatKit/TophatExtension.swift new file mode 100644 index 0000000..4b42866 --- /dev/null +++ b/TophatKit/Sources/TophatKit/TophatExtension.swift @@ -0,0 +1,60 @@ +// +// TophatExtension.swift +// TophatKit +// +// Created by Lukas Romsicki on 2024-09-06. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation +import ExtensionFoundation +import ExtensionKit +import SwiftUI + +/// The primary entry point for a Tophat extension. +/// +/// Use this type to register the components of an extension to provide Tophat with +/// the functionality you implement. +public protocol TophatExtension: AppExtension { + /// The human-readable name for the extension. + static var title: LocalizedStringResource { get } + + /// The human-readable description for the extension. + static var description: LocalizedStringResource? { get } +} + +/// A type that supports registering build providers in a Tophat extension. +public protocol ArtifactProviding { + associatedtype ExtensionArtifactProviders: ArtifactProviders + + /// A collection of `ArtifactProvider` objects that Tophat can use to retrieve + /// artifacts from various sources. + @ArtifactProvidersBuilder static var artifactProviders: ExtensionArtifactProviders { get } +} + +/// A type that supports providing a settings view in a Tophat extension. +public protocol SettingsProviding { + associatedtype SettingsBody: View + + /// The view to display in the Tophat Settings window to allow + /// the extension to be configured. + @ViewBuilder static var settings: SettingsBody { get } +} + +public extension TophatExtension { + var configuration: some AppExtensionConfiguration { + ExtensionConfiguration(appExtension: self) + } +} + +public extension TophatExtension where Self: SettingsProviding { + var configuration: AppExtensionSceneConfiguration { + AppExtensionSceneConfiguration( + PrimitiveAppExtensionScene(id: "TophatExtensionSettings") { + Self.settings + .scrollContentBackground(.hidden) + }, + configuration: ExtensionConfiguration(appExtension: self) + ) + } +} diff --git a/TophatModules/Package.swift b/TophatModules/Package.swift index d861c88..8d9e1be 100644 --- a/TophatModules/Package.swift +++ b/TophatModules/Package.swift @@ -10,10 +10,9 @@ let package = Package( products: [ .library(name: "AndroidDeviceKit", targets: ["AndroidDeviceKit"]), .library(name: "AppleDeviceKit", targets: ["AppleDeviceKit"]), - .library(name: "GoogleStorageKit", targets: ["GoogleStorageKit"]), .library(name: "ShellKit", targets: ["ShellKit"]), .library(name: "TophatFoundation", targets: ["TophatFoundation"]), - .library(name: "TophatKit", targets: ["TophatKit"]), + .library(name: "TophatUtilities", targets: ["TophatUtilities"]), .library(name: "TophatServer", targets: ["TophatServer"]) ], dependencies: [ @@ -37,13 +36,6 @@ let package = Package( .target(name: "ShellKit") ] ), - .target( - name: "GoogleStorageKit", - dependencies: [ - .product(name: "Logging", package: "swift-log"), - .target(name: "ShellKit") - ] - ), .target( name: "ShellKit", dependencies: [ @@ -52,7 +44,7 @@ let package = Package( ), .target(name: "TophatFoundation"), .target( - name: "TophatKit", + name: "TophatUtilities", dependencies: [ .target(name: "TophatFoundation") ] diff --git a/TophatModules/Sources/AndroidDeviceKit/AndroidDevices.swift b/TophatModules/Sources/AndroidDeviceKit/AndroidDevices.swift index 4b7c5c6..1e0c25f 100644 --- a/TophatModules/Sources/AndroidDeviceKit/AndroidDevices.swift +++ b/TophatModules/Sources/AndroidDeviceKit/AndroidDevices.swift @@ -30,8 +30,8 @@ public struct AndroidDevices: DeviceProvider { } // We only care about physical connected devices because adb and avdmanager overlap. - // avdmanager takes care of returning all virtual devices. - let physicalDevices = connectedDevices.filter(type: .physical) + // avdmanager takes care of returning all simulator devices. + let physicalDevices = connectedDevices.filter(type: .device) return physicalDevices + proxyVirtualDevices } diff --git a/TophatModules/Sources/AndroidDeviceKit/ConnectedDevice+Device.swift b/TophatModules/Sources/AndroidDeviceKit/ConnectedDevice+Device.swift index e4f7556..4d5cdcb 100644 --- a/TophatModules/Sources/AndroidDeviceKit/ConnectedDevice+Device.swift +++ b/TophatModules/Sources/AndroidDeviceKit/ConnectedDevice+Device.swift @@ -26,10 +26,10 @@ extension ConnectedDevice: Device { var type: DeviceType { guard let product = product else { // In case the product name is not available, fall back to checking the serial. - return serial.contains("emulator") ? .virtual : .physical + return serial.contains("emulator") ? .simulator : .device } - return product.contains("sdk_gphone") ? .virtual : .physical + return product.contains("sdk_gphone") ? .simulator : .device } var connection: Connection { diff --git a/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift b/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift index c137cc5..3523195 100644 --- a/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift +++ b/TophatModules/Sources/AndroidDeviceKit/ProxyVirtualDevice.swift @@ -9,7 +9,7 @@ import Foundation import TophatFoundation -/// A container class that can track the connected device associated with a virtual device +/// A container class that can track the connected device associated with a simulator device /// on the fly. final class ProxyVirtualDevice { private let virtualDevice: VirtualDevice @@ -29,7 +29,7 @@ final class ProxyVirtualDevice { extension ProxyVirtualDevice: Device { var id: String { - // Because ADB serials aren't always available, we'll always reference virtual devices by name to have a stable value. + // Because ADB serials aren't always available, we'll always reference simulator devices by name to have a stable value. virtualDevice.name } @@ -43,7 +43,7 @@ extension ProxyVirtualDevice: Device { } var type: DeviceType { - .virtual + .simulator } var connection: Connection { diff --git a/TophatModules/Sources/AndroidDeviceKit/VirtualDeviceNameMapping.swift b/TophatModules/Sources/AndroidDeviceKit/VirtualDeviceNameMapping.swift index 9c6f1eb..eb8cfab 100644 --- a/TophatModules/Sources/AndroidDeviceKit/VirtualDeviceNameMapping.swift +++ b/TophatModules/Sources/AndroidDeviceKit/VirtualDeviceNameMapping.swift @@ -24,12 +24,12 @@ extension Collection where Element == ConnectedDevice { /// /// This is an expensive operation as `adb` needs to be called once for each connected device. The /// result of this function should be cached as early as possible so that these values are only resolved - /// once. Only virtual devices are queried, physical devices are ignored and are not returned. - /// - Returns: A collection of containers including the connected device and its associated virtual + /// once. Only simulator devices are queried, physical devices are ignored and are not returned. + /// - Returns: A collection of containers including the connected device and its associated simulator /// device name. func mappedToVirtualDeviceNames() async -> [VirtualDeviceNameMapping] { return await withTaskGroup(of: VirtualDeviceNameMapping.self, returning: [VirtualDeviceNameMapping].self) { group in - filter(type: .virtual).forEach { connectedVirtualDevice in + filter(type: .simulator).forEach { connectedVirtualDevice in group.addTask { return VirtualDeviceNameMapping( connectedDevice: connectedVirtualDevice, diff --git a/TophatModules/Sources/AppleDeviceKit/ConnectedDevice+Device.swift b/TophatModules/Sources/AppleDeviceKit/ConnectedDevice+Device.swift index 28e12f8..2dfa214 100644 --- a/TophatModules/Sources/AppleDeviceKit/ConnectedDevice+Device.swift +++ b/TophatModules/Sources/AppleDeviceKit/ConnectedDevice+Device.swift @@ -23,7 +23,7 @@ extension ConnectedDevice: Device { } var type: DeviceType { - .physical + .device } var connection: Connection { diff --git a/TophatModules/Sources/AppleDeviceKit/Simulator+Device.swift b/TophatModules/Sources/AppleDeviceKit/Simulator+Device.swift index 08281f3..0cb104f 100644 --- a/TophatModules/Sources/AppleDeviceKit/Simulator+Device.swift +++ b/TophatModules/Sources/AppleDeviceKit/Simulator+Device.swift @@ -21,7 +21,7 @@ extension Simulator: Device { } var type: DeviceType { - .virtual + .simulator } var connection: Connection { @@ -61,7 +61,7 @@ extension Simulator: Device { try SimCtl.install(udid: id, bundleUrl: application.url) } catch { - throw DeviceError.failedToInstallApp(bundleUrl: application.url, deviceType: .virtual) + throw DeviceError.failedToInstallApp(bundleUrl: application.url, deviceType: .simulator) } } @@ -71,7 +71,7 @@ extension Simulator: Device { do { try SimCtl.launch(udid: id, bundleIdentifier: bundleIdentifier, arguments: arguments ?? []) } catch { - throw DeviceError.failedToLaunchApp(bundleId: bundleIdentifier, reason: .unexpected, deviceType: .virtual) + throw DeviceError.failedToLaunchApp(bundleId: bundleIdentifier, reason: .unexpected, deviceType: .simulator) } } diff --git a/TophatModules/Sources/GoogleStorageKit/Extensions/ShellOut+GsUtil.swift b/TophatModules/Sources/GoogleStorageKit/Extensions/ShellOut+GsUtil.swift deleted file mode 100644 index b89679a..0000000 --- a/TophatModules/Sources/GoogleStorageKit/Extensions/ShellOut+GsUtil.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// ShellOutCommand+GSUtil.swift -// GoogleStorageKit -// -// Created by Lukas Romsicki on 2022-10-31. -// Copyright © 2022 Shopify. All rights reserved. -// - -import Foundation -import ShellKit - -extension ShellCommand where Self == GSUtilCommand { - static func gsUtil(_ command: Self) -> Self { - command - } -} - -enum GSUtilCommand { - case copy(remoteUrl: URL, localUrl: URL) -} - -extension GSUtilCommand: ShellCommand { - var executable: Executable { - .url(PathResolver.gsUtilPath) - } - - var environment: [String: String] { - PathResolver.gsUtilEnvironment ?? [:] - } - - var arguments: [String] { - switch self { - case .copy(let remoteUrl, let localUrl): - return ["cp", remoteUrl.absoluteString, localUrl.path(percentEncoded: false).wrappedInQuotationMarks()] - } - } -} diff --git a/TophatModules/Sources/GoogleStorageKit/GoogleStorage.swift b/TophatModules/Sources/GoogleStorageKit/GoogleStorage.swift deleted file mode 100644 index 904f02a..0000000 --- a/TophatModules/Sources/GoogleStorageKit/GoogleStorage.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// GoogleStorage.swift -// GoogleStorageKit -// -// Created by Jared Hendry on 2022-09-10. -// Copyright © 2020 Shopify. All rights reserved. -// - -import Foundation -import ShellKit -import RegexBuilder - -public extension URL { - var isGoogleStorageURL: Bool { - scheme == "gs" || host() == "storage.cloud.google.com" - } -} - -public struct GoogleStorage { - public static func download( - artifactURL: URL, - to localURL: URL - ) throws -> AsyncCompactMapSequence, DownloadProgress> { - guard let googleStorageUrl = convertToGoogleCloudURL(url: artifactURL) else { - throw GoogleStorageError.invalidUrl - } - - return runAsync(command: .gsUtil(.copy(remoteUrl: googleStorageUrl, localUrl: localURL)), log: log) - .compactMap { output in - guard - case .standardError(let line) = output, - let (_, dataDownloaded, dataDownloadedUnit, totalData, totalDataUnit) = line.firstMatch(of: search)?.output - else { - return nil - } - - let downloadedBytes = dataDownloaded * dataDownloadedUnit.multiplier - let totalBytes = totalData * totalDataUnit.multiplier - - return DownloadProgress(totalUnitCount: totalBytes, pendingUnitCount: downloadedBytes) - } - } - - private static func convertToGoogleCloudURL(url: URL) -> URL? { - if url.scheme == "gs" { - // Support URLs that are already in the correct format. - return url - } - - if url.host != "storage.cloud.google.com" { - return nil - } - - guard let decodedGoogleStoragePath = url.path.removingPercentEncoding else { - return nil - } - - return URL(string: "gs:/\(decodedGoogleStoragePath)") - } - - private static let dataQuantityCapture = TryCapture { - OneOrMore(CharacterClass.anyNonNewline) - } transform: { dataQuantity in - Double(dataQuantity) - } - - private static let dataUnitCapture = TryCapture { - ChoiceOf { - "B" - "KiB" - "MiB" - "GiB" - } - } transform: { DownloadSizeUnit(rawValue: String($0)) } - - private static let search = Regex { - "][" - ZeroOrMore(.whitespace) - dataQuantityCapture - One(.whitespace) - dataUnitCapture - "/" - dataQuantityCapture - One(.whitespace) - dataUnitCapture - "]" - } - - public struct DownloadProgress { - public let totalUnitCount: Double - public let pendingUnitCount: Double - } -} - -private enum DownloadSizeUnit: String { - case bytes = "B" - case kibibytes = "KiB" - case mebibytes = "MiB" - case gibibytes = "GiB" - - var multiplier: Double { - switch self { - case .bytes: - return 1 - case .kibibytes: - return 1024 - case .mebibytes: - return 1024 * 1024 - case .gibibytes: - return 1024 * 1024 * 1024 - } - } -} diff --git a/TophatModules/Sources/GoogleStorageKit/GoogleStorageError.swift b/TophatModules/Sources/GoogleStorageKit/GoogleStorageError.swift deleted file mode 100644 index 74e1b96..0000000 --- a/TophatModules/Sources/GoogleStorageKit/GoogleStorageError.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// GoogleStorageError.swift -// GoogleStorageKit -// -// Created by Lukas Romsicki on 2022-10-31. -// Copyright © 2022 Shopify. All rights reserved. -// - -import Foundation - -public enum GoogleStorageError: Error { - case invalidUrl - case cannotInvokeGSUtil -} diff --git a/TophatModules/Sources/GoogleStorageKit/Logging.swift b/TophatModules/Sources/GoogleStorageKit/Logging.swift deleted file mode 100644 index 39b49e1..0000000 --- a/TophatModules/Sources/GoogleStorageKit/Logging.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// Logging.swift -// GoogleStorageKit -// -// Created by Lukas Romsicki on 2022-01-02. -// Copyright © 2022 Shopify. All rights reserved. -// - -import Logging - -public var log: Logger? diff --git a/TophatModules/Sources/GoogleStorageKit/Protocols/GoogleStoragePathResolverDelegate.swift b/TophatModules/Sources/GoogleStorageKit/Protocols/GoogleStoragePathResolverDelegate.swift deleted file mode 100644 index b635084..0000000 --- a/TophatModules/Sources/GoogleStorageKit/Protocols/GoogleStoragePathResolverDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// GoogleStoragePathResolverDelegate.swift -// GoogleStorageKit -// -// Created by Lukas Romsicki on 2023-01-24. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation - -/// A delegate that provides customized settings for the Google Storage environment. -public protocol GoogleStoragePathResolverDelegate { - /// If a custom `gsutil` path should be used, return it from this function. - /// - Returns: The path to the `gsutil` executable. - func pathToGSUtil() -> URL? -} diff --git a/TophatModules/Sources/GoogleStorageKit/Utilities/PathResolver.swift b/TophatModules/Sources/GoogleStorageKit/Utilities/PathResolver.swift deleted file mode 100644 index ae6b74b..0000000 --- a/TophatModules/Sources/GoogleStorageKit/Utilities/PathResolver.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// PathResolver.swift -// GoogleStorageKit -// -// Created by Lukas Romsicki on 2022-10-31. -// Copyright © 2022 Shopify. All rights reserved. -// - -import Foundation -import TophatFoundation - -// Shorthand for use within the library. -typealias PathResolver = GoogleStoragePathResolver - -public struct GoogleStoragePathResolver { - public static var delegate: GoogleStoragePathResolverDelegate? - - private static let caskPath = URL(filePath: "/opt/homebrew/Caskroom/google-cloud-sdk/latest/google-cloud-sdk/bin/gsutil") - private static let legacyCaskPath = URL(filePath: "/usr/local/Caskroom/google-cloud-sdk/latest/google-cloud-sdk/bin/gsutil") - private static let usrLocalPath = URL(filePath: "/usr/local/bin/gsutil") - private static let cloudSDKVariable = ["CLOUDSDK_PYTHON": "/usr/local/opt/python@3.8/libexec/bin/python"] - - public static var gsUtilPath: URL { - if let customPath = delegate?.pathToGSUtil() { - return customPath - } - - if caskPath.isReachable() { - return caskPath - } - - if legacyCaskPath.isReachable() { - return legacyCaskPath - } - - return usrLocalPath - } - - static var gsUtilEnvironment: [String: String]? { - if caskPath.isReachable(), - legacyCaskPath.isReachable() { - return cloudSDKVariable - } - return nil - } -} diff --git a/TophatModules/Sources/TophatFoundation/Artifact.swift b/TophatModules/Sources/TophatFoundation/Artifact.swift deleted file mode 100644 index 311bf52..0000000 --- a/TophatModules/Sources/TophatFoundation/Artifact.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Artifact.swift -// TophatFoundation -// -// Created by Lukas Romsicki on 2022-10-25. -// Copyright © 2022 Shopify. All rights reserved. -// - -import Foundation - -/// Structure representing a tophat-able artifact. -public struct Artifact: Launchable, Codable { - /// The location of the remote artifact. - public let url: URL - - /// The target devices of the artifact. - public let targets: Set - - public init(url: URL, targets: Set) { - self.url = url - self.targets = targets - } -} diff --git a/TophatModules/Sources/TophatFoundation/ArtifactLocation.swift b/TophatModules/Sources/TophatFoundation/ArtifactLocation.swift new file mode 100644 index 0000000..67c413a --- /dev/null +++ b/TophatModules/Sources/TophatFoundation/ArtifactLocation.swift @@ -0,0 +1,17 @@ +// +// ArtifactLocation.swift +// TophatFoundation +// +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2024 Shopify. All rights reserved. +// + +/// The location of an artifact. +public enum ArtifactLocation { + /// The artifact is located in a remote location, either on the web or on the local machine. A remote + /// location is a location that is **not** controlled by Tophat. + case remote(source: RemoteArtifactSource) + + /// The artifact is located in a location controlled by Tophat and has already been processed. + case local(application: Application) +} diff --git a/TophatModules/Sources/TophatFoundation/ArtifactProviderMetadata.swift b/TophatModules/Sources/TophatFoundation/ArtifactProviderMetadata.swift new file mode 100644 index 0000000..1c0f046 --- /dev/null +++ b/TophatModules/Sources/TophatFoundation/ArtifactProviderMetadata.swift @@ -0,0 +1,20 @@ +// +// ArtifactProviderMetadata.swift +// TophatFoundation +// +// Created by Lukas Romsicki on 2024-11-21. +// + +/// The metadata required to retrieve a artifact from a artifact provider. +public struct ArtifactProviderMetadata: Equatable, Hashable, Codable { + /// The identifier of the artifact provider that should retrieve the artifact. + public let id: String + + /// The parameters passed to the artifact provider used to retrieve the artifact. + public let parameters: [String: String] + + public init(id: String, parameters: [String: String]) { + self.id = id + self.parameters = parameters + } +} diff --git a/TophatModules/Sources/TophatFoundation/ArtifactSet.swift b/TophatModules/Sources/TophatFoundation/ArtifactSet.swift deleted file mode 100644 index be008d8..0000000 --- a/TophatModules/Sources/TophatFoundation/ArtifactSet.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// ArtifactSet.swift -// TophatFoundation -// -// Created by Lukas Romsicki on 2022-11-17. -// Copyright © 2022 Shopify. All rights reserved. -// - -/// Structure representing a set of artifacts to use for launching. -public struct ArtifactSet { - /// The provided artifacts. - public let artifacts: [Artifact] - - /// The targets for which this artifact set is able to provide artifacts. - public var targets: Set { - Set(artifacts.flatMap { $0.targets }) - } - - public init(artifacts: [Artifact]) { - self.artifacts = artifacts - } -} - -public extension ArtifactSet { - func artifacts(targeting target: DeviceType) -> [Artifact] { - artifacts.filter { $0.targets.contains(target) } - } -} diff --git a/TophatModules/Sources/TophatFoundation/Collection+Safe.swift b/TophatModules/Sources/TophatFoundation/Collection+Safe.swift new file mode 100644 index 0000000..788fac3 --- /dev/null +++ b/TophatModules/Sources/TophatFoundation/Collection+Safe.swift @@ -0,0 +1,13 @@ +// +// Collection+Safe.swift +// TophatFoundation +// +// Created by Lukas Romsicki on 2024-09-27. +// Copyright © 2024 Shopify. All rights reserved. +// + +public extension Collection { + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/TophatModules/Sources/TophatFoundation/DeviceType.swift b/TophatModules/Sources/TophatFoundation/DeviceType.swift index e6c4fee..4da21b3 100644 --- a/TophatModules/Sources/TophatFoundation/DeviceType.swift +++ b/TophatModules/Sources/TophatFoundation/DeviceType.swift @@ -8,8 +8,8 @@ /// The type of a device. public enum DeviceType: String, Codable, CaseIterable { - case virtual - case physical + case simulator + case device } // MARK: - CustomStringConvertible @@ -17,10 +17,10 @@ public enum DeviceType: String, Codable, CaseIterable { extension DeviceType: CustomStringConvertible { public var description: String { switch self { - case .virtual: - return "virtual" - case .physical: - return "physical" + case .simulator: + return "Simulator" + case .device: + return "Device" } } } diff --git a/TophatModules/Sources/TophatFoundation/InstallRecipe.swift b/TophatModules/Sources/TophatFoundation/InstallRecipe.swift new file mode 100644 index 0000000..8367ea2 --- /dev/null +++ b/TophatModules/Sources/TophatFoundation/InstallRecipe.swift @@ -0,0 +1,34 @@ +// +// InstallRecipe.swift +// TophatFoundation +// +// Created by Lukas Romsicki on 2024-09-27. +// Copyright © 2024 Shopify. All rights reserved. +// + +/// Structure representing instructions for installing a artifact from a remote source. +public struct InstallRecipe: Equatable, Hashable, Codable { + /// The source of the artifact to install. + public let source: RemoteArtifactSource + + /// The arguments to pass to the application at launch. + public let launchArguments: [String] + + /// The expected platform of the artifact, used to preheat the target device. + public let platformHint: Platform? + + /// The expected destination of the artifact, used to preheat the target device. + public let destinationHint: DeviceType? + + public init( + source: RemoteArtifactSource, + launchArguments: [String], + platformHint: Platform? = nil, + destinationHint: DeviceType? = nil + ) { + self.source = source + self.launchArguments = launchArguments + self.platformHint = platformHint + self.destinationHint = destinationHint + } +} diff --git a/TophatModules/Sources/TophatFoundation/RemoteArtifactSource.swift b/TophatModules/Sources/TophatFoundation/RemoteArtifactSource.swift new file mode 100644 index 0000000..cb1b142 --- /dev/null +++ b/TophatModules/Sources/TophatFoundation/RemoteArtifactSource.swift @@ -0,0 +1,15 @@ +// +// RemoteArtifactSource.swift +// TophatFoundation +// +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2022 Shopify. All rights reserved. +// + +import Foundation + +/// The source of an artifact. +public enum RemoteArtifactSource: Equatable, Hashable, Codable { + case artifactProvider(metadata: ArtifactProviderMetadata) + case file(url: URL) +} diff --git a/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift b/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift deleted file mode 100644 index ea9597b..0000000 --- a/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// TophatAddPinnedApplicationNotification.swift -// TophatKit -// -// Created by Lukas Romsicki on 2023-01-27. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation -import TophatFoundation - -public struct TophatAddPinnedApplicationNotification: TophatInterProcessNotification { - public static let name = "TophatAddPinnedApplication" - - public struct Payload: Codable { - public let id: String? - public let name: String - public let platform: Platform - public let virtualURL: URL? - public let physicalURL: URL? - public let universalURL: URL? - - public init( - id: String? = nil, - name: String, - platform: Platform, - virtualURL: URL?, - physicalURL: URL?, - universalURL: URL? - ) { - self.id = id - self.name = name - self.platform = platform - self.virtualURL = virtualURL - self.physicalURL = physicalURL - self.universalURL = universalURL - } - } - - public let payload: Payload - - public init(payload: Payload) { - self.payload = payload - } -} diff --git a/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatInstallGenericNotification.swift b/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatInstallGenericNotification.swift deleted file mode 100644 index 6d3b78c..0000000 --- a/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatInstallGenericNotification.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// TophatInstallHintedNotification.swift -// TophatKit -// -// Created by Lukas Romsicki on 2023-01-27. -// Copyright © 2023 Shopify. All rights reserved. -// - -import Foundation -import TophatFoundation - -public struct TophatInstallHintedNotification: TophatInterProcessNotification { - public static let name = "TophatInstallApplicationHinted" - - public struct Payload: Codable { - public let platform: Platform - public let virtualURL: URL? - public let physicalURL: URL? - public let universalURL: URL? - public let launchArguments: [String] - - public init(platform: Platform, virtualURL: URL?, physicalURL: URL?, universalURL: URL?, launchArguments: [String]) { - self.platform = platform - self.virtualURL = virtualURL - self.physicalURL = physicalURL - self.universalURL = universalURL - self.launchArguments = launchArguments - } - } - - public let payload: Payload - - public init(payload: Payload) { - self.payload = payload - } -} diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift new file mode 100644 index 0000000..b6ce965 --- /dev/null +++ b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatAddPinnedApplicationNotification.swift @@ -0,0 +1,28 @@ +// +// TophatAddPinnedApplicationNotification.swift +// TophatUtilities +// +// Created by Lukas Romsicki on 2023-01-27. +// Copyright © 2023 Shopify. All rights reserved. +// + +import Foundation +import TophatFoundation + +public struct TophatAddPinnedApplicationNotification: TophatInterProcessNotification { + public static let name = "TophatAddPinnedApplication" + + public struct Payload: Codable { + public let configuration: UserSpecifiedQuickLaunchEntryConfiguration + + public init(configuration: UserSpecifiedQuickLaunchEntryConfiguration) { + self.configuration = configuration + } + } + + public let payload: Payload + + public init(payload: Payload) { + self.payload = payload + } +} diff --git a/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallConfigurationNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallConfigurationNotification.swift new file mode 100644 index 0000000..0272b2d --- /dev/null +++ b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallConfigurationNotification.swift @@ -0,0 +1,27 @@ +// +// TophatInstallConfigurationNotification.swift +// TophatUtilities +// +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Foundation + +public struct TophatInstallConfigurationNotification: TophatInterProcessNotification { + public static let name = "TophatInstallConfiguration" + + public struct Payload: Codable { + public let installRecipes: [UserSpecifiedInstallRecipe] + + public init(installRecipes: [UserSpecifiedInstallRecipe]) { + self.installRecipes = installRecipes + } + } + + public let payload: Payload + + public init(payload: Payload) { + self.payload = payload + } +} diff --git a/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatInstallHintedNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallURLNotification.swift similarity index 78% rename from TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatInstallHintedNotification.swift rename to TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallURLNotification.swift index e0cb461..53d7a0c 100644 --- a/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatInstallHintedNotification.swift +++ b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatInstallURLNotification.swift @@ -1,6 +1,6 @@ // -// TophatInstallGenericNotification.swift -// TophatKit +// TophatInstallURLNotification.swift +// TophatUtilities // // Created by Lukas Romsicki on 2023-01-27. // Copyright © 2023 Shopify. All rights reserved. @@ -8,7 +8,7 @@ import Foundation -public struct TophatInstallGenericNotification: TophatInterProcessNotification { +public struct TophatInstallURLNotification: TophatInterProcessNotification { public static let name = "TophatInstallApplicationGeneric" public struct Payload: Codable { diff --git a/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift similarity index 96% rename from TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift rename to TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift index 7c46afa..8975bf0 100644 --- a/TophatModules/Sources/TophatKit/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift +++ b/TophatModules/Sources/TophatUtilities/Control Notifications/Definitions/TophatRemovePinnedApplicationNotification.swift @@ -1,6 +1,6 @@ // // TophatRemovePinnedApplicationNotification.swift -// TophatKit +// TophatUtilities // // Created by Lukas Romsicki on 2023-01-27. // Copyright © 2023 Shopify. All rights reserved. diff --git a/TophatModules/Sources/TophatKit/Control Notifications/TophatInterProcessNotification.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotification.swift similarity index 95% rename from TophatModules/Sources/TophatKit/Control Notifications/TophatInterProcessNotification.swift rename to TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotification.swift index 7631836..e21e2b5 100644 --- a/TophatModules/Sources/TophatKit/Control Notifications/TophatInterProcessNotification.swift +++ b/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotification.swift @@ -1,6 +1,6 @@ // // TophatInterProcessNotification.swift -// TophatKit +// TophatUtilities // // Created by Lukas Romsicki on 2023-01-27. // Copyright © 2023 Shopify. All rights reserved. diff --git a/TophatModules/Sources/TophatKit/Control Notifications/TophatInterProcessNotifier.swift b/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotifier.swift similarity index 98% rename from TophatModules/Sources/TophatKit/Control Notifications/TophatInterProcessNotifier.swift rename to TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotifier.swift index 0552333..db19af2 100644 --- a/TophatModules/Sources/TophatKit/Control Notifications/TophatInterProcessNotifier.swift +++ b/TophatModules/Sources/TophatUtilities/Control Notifications/TophatInterProcessNotifier.swift @@ -1,6 +1,6 @@ // // TophatInterProcessNotifier.swift -// TophatKit +// TophatUtilities // // Created by Lukas Romsicki on 2023-01-27. // Copyright © 2023 Shopify. All rights reserved. diff --git a/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedInstallRecipe.swift b/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedInstallRecipe.swift new file mode 100644 index 0000000..36aa252 --- /dev/null +++ b/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedInstallRecipe.swift @@ -0,0 +1,16 @@ +// +// UserSpecifiedInstallRecipe.swift +// TophatUtilities +// +// Created by Lukas Romsicki on 2024-11-21. +// + +import TophatFoundation + +public struct UserSpecifiedInstallRecipe: Codable { + public let artifactProviderID: String + public let artifactProviderParameters: [String: String] + public let launchArguments: [String] + public let platformHint: Platform? + public let destinationHint: DeviceType? +} diff --git a/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedQuickLaunchEntryConfiguration.swift b/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedQuickLaunchEntryConfiguration.swift new file mode 100644 index 0000000..db31930 --- /dev/null +++ b/TophatModules/Sources/TophatUtilities/Shared/UserSpecifiedQuickLaunchEntryConfiguration.swift @@ -0,0 +1,15 @@ +// +// UserSpecifiedQuickLaunchEntryConfiguration.swift +// TophatUtilities +// +// Created by Lukas Romsicki on 2024-11-21. +// Copyright © 2024 Shopify. All rights reserved. +// + +public struct UserSpecifiedQuickLaunchEntryConfiguration: Codable { + public typealias Source = UserSpecifiedInstallRecipe + + public let id: String + public let name: String + public let sources: [Source] +} diff --git a/TophatTests/Utilities/URLReaderTests.swift b/TophatTests/Utilities/URLReaderTests.swift new file mode 100644 index 0000000..9c5901a --- /dev/null +++ b/TophatTests/Utilities/URLReaderTests.swift @@ -0,0 +1,264 @@ +// +// URLReaderTests.swift +// TophatTests +// +// Created by Lukas Romsicki on 2024-09-26. +// Copyright © 2024 Shopify. All rights reserved. +// + +import Testing +import Foundation +import TophatFoundation + +@testable import Tophat + +struct URLReaderTests { + static let urlPrefixes = [ + "http://localhost:1234/", + "tophat://" + ] + + @Test("Handles local files") + func handlesLocalFiles() async throws { + let url: URL = .documentsDirectory.appending(path: "test.zip") + let result = try result(url: url) + + #expect(result == .localFile(url: url)) + } + + @Test("Throws for unsupported scheme") + func throwsForUnsupportedScheme() async throws { + let url = URL(string: "other://test")! + + #expect(throws: URLReaderError.unsupportedURL(url)) { + try result(url: url) + } + } + + @Test("Throws for unsupported route", arguments: urlPrefixes) + func throwsForUnsupportedRoute(urlPrefix: String) async throws { + let url = url(prefix: urlPrefix, path: "unsupported") + + #expect(throws: URLReaderError.unsupportedURL(url)) { + try result(url: url) + } + } + + @Test("Handles basic install route", arguments: urlPrefixes) + func handlesBasicInstallRoute(urlPrefix: String) async throws { + let result = try result(url: url(prefix: urlPrefix, path: "install/test?one=a&two=b")) + + let expectedRequests: [InstallRecipe] = [ + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: ["one": "a", "two": "b"] + ) + ), + launchArguments: [], + platformHint: nil, + destinationHint: nil + ) + ] + + #expect(result == .install(requests: expectedRequests)) + } + + @Test("Handles basic install route with platform hint", arguments: urlPrefixes, Platform.allCases) + func handlesBasicInstallRouteWithPlatformHint(urlPrefix: String, platform: Platform) async throws { + let result = try result(url: url(prefix: urlPrefix, path: "install/test?one=a&two=b&platform=\(platform.rawValue)")) + + let expectedRequests: [InstallRecipe] = [ + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: ["one": "a", "two": "b"] + ) + ), + launchArguments: [], + platformHint: platform, + destinationHint: nil + ) + ] + + #expect(result == .install(requests: expectedRequests)) + } + + @Test("Handles basic install route with destination hint", arguments: urlPrefixes, ["simulator", "device"]) + func handlesBasicInstallRouteWithDestinationHint(urlPrefix: String, destination: String) async throws { + let result = try result(url: url(prefix: urlPrefix, path: "install/test?one=a&two=b&destination=\(destination)")) + + let expectedRequests: [InstallRecipe] = [ + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: ["one": "a", "two": "b"] + ) + ), + launchArguments: [], + platformHint: nil, + destinationHint: destination == "simulator" ? .simulator : .device + ) + ] + + #expect(result == .install(requests: expectedRequests)) + } + + @Test("Handles install route with no parameters", arguments: urlPrefixes) + func handlesInstallRouteWithNoParameters(urlPrefix: String) async throws { + let result = try result(url: url(prefix: urlPrefix, path: "install/test")) + + let expectedRequests: [InstallRecipe] = [ + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: [:] + ) + ), + launchArguments: [], + platformHint: nil, + destinationHint: nil + ) + ] + + #expect(result == .install(requests: expectedRequests)) + } + + @Test("Handles install route with hint parameter but no build provider parameters", arguments: urlPrefixes) + func handlesInstallRouteWithHintParameterNoBuildProviderParameters(urlPrefix: String) async throws { + let result = try result(url: url(prefix: urlPrefix, path: "install/test?platform=ios")) + + let expectedRequests: [InstallRecipe] = [ + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: [:] + ) + ), + launchArguments: [], + platformHint: .iOS, + destinationHint: nil + ) + ] + + #expect(result == .install(requests: expectedRequests)) + } + + @Test("Handles install route with repeated parameters", arguments: urlPrefixes) + func handlesInstallRouteWithRepeatedParameters(urlPrefix: String) async throws { + let result = try result(url: url(prefix: urlPrefix, path: "install/test?one=a&two=b&one=c&two=d")) + + let expectedRequests: [InstallRecipe] = [ + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: ["one": "a", "two": "b"] + ) + ), + launchArguments: [], + platformHint: nil, + destinationHint: nil + ), + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: ["one": "c", "two": "d"] + ) + ), + launchArguments: [], + platformHint: nil, + destinationHint: nil + ) + ] + + #expect(result == .install(requests: expectedRequests)) + } + + @Test("Throws on install route if repeated parameters are unbalanced", arguments: urlPrefixes) + func throwsOnInstallRouteIfRepeatedParametersUnbalanced(urlPrefix: String) async throws { + let url = url(prefix: urlPrefix, path: "install/test?one=a&two=b&one=c") + + #expect(throws: URLReaderError.malformedURL(url)) { + try result(url: url) + } + } + + @Test("Handles install route with one set of optional parameters", arguments: urlPrefixes) + func handlesInstallRouteOneSetOptionalParameters(urlPrefix: String) async throws { + let result = try result(url: url(prefix: urlPrefix, path: "install/test?one=a&two=b&platform=ios&destination=simulator&arguments=one,two&one=c&two=d")) + + let expectedRequests: [InstallRecipe] = [ + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: ["one": "a", "two": "b"] + ) + ), + launchArguments: ["one", "two"], + platformHint: .iOS, + destinationHint: .simulator + ), + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: ["one": "c", "two": "d"] + ) + ), + launchArguments: [], + platformHint: nil, + destinationHint: nil + ) + ] + + #expect(result == .install(requests: expectedRequests)) + } + + @Test("Handles install route with all parameters", arguments: urlPrefixes) + func handlesInstallRouteAllParameters(urlPrefix: String) async throws { + let result = try result(url: url(prefix: urlPrefix, path: "install/test?one=a&two=b&platform=ios&destination=simulator&arguments=one,two&one=c&two=d&platform=android&destination=device&arguments=three,four")) + + let expectedRequests: [InstallRecipe] = [ + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: ["one": "a", "two": "b"] + ) + ), + launchArguments: ["one", "two"], + platformHint: .iOS, + destinationHint: .simulator + ), + .init( + source: .artifactProvider( + metadata: .init( + id: "test", + parameters: ["one": "c", "two": "d"] + ) + ), + launchArguments: ["three", "four"], + platformHint: .android, + destinationHint: .device + ) + ] + + #expect(result == .install(requests: expectedRequests)) + } + + private func url(prefix: String, path: String) -> URL { + URL(string: "\(prefix)\(path)")! + } + + private func result(url: URL) throws -> URLReaderResult { + try URLReader().read(url: url) + } +}