From 63dda42e34b3bee4b44ebb85f6cd1b9ac752edd7 Mon Sep 17 00:00:00 2001 From: Jinyu Meng Date: Sun, 21 Sep 2025 23:36:29 +0900 Subject: [PATCH 01/18] Update zh_Hans translations. --- Xcodes/Resources/Localizable.xcstrings | 35 ++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 2d503460..2dec37ab 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -4783,7 +4783,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "" + "value" : "Apple芯片" } }, "zh-Hant" : { @@ -5055,7 +5055,14 @@ } }, "Architecture" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "架构" + } + } + } }, "Authenticating" : { "extractionState" : "manual", @@ -5964,7 +5971,14 @@ } }, "Category" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "类别" + } + } + } }, "Change" : { "localizations" : { @@ -11371,7 +11385,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Install Apple Silicon" + "value" : "安装Apple芯片版本" } }, "zh-Hant" : { @@ -11495,7 +11509,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Install Universal" + "value" : "安装通用版本" } }, "zh-Hant" : { @@ -14127,7 +14141,14 @@ } }, "Installed Only" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅已安装" + } + } + } }, "InstallHelper" : { "localizations" : { @@ -23811,7 +23832,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Universal" + "value" : "通用" } }, "zh-Hant" : { From ec5779cb1a8179f778f96f79be50b352434a2bec Mon Sep 17 00:00:00 2001 From: Jinyu Meng Date: Sun, 21 Sep 2025 23:36:58 +0900 Subject: [PATCH 02/18] Show localized strings for "Platform" view. --- .../XcodesKit/Models/XcodeReleases/Architecture.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift index 607d7f8c..62849a9d 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift @@ -19,9 +19,9 @@ public enum Architecture: String, Codable, Equatable, Hashable, Identifiable, Ca public var displayString: String { switch self { case .arm64: - return "Apple Silicon" + return localizeString("Apple Silicon") case .x86_64: - return "Intel" + return localizeString("Intel") } } @@ -44,9 +44,9 @@ public enum ArchitectureVariant: String, Codable, Equatable, Hashable, Identifia public var displayString: String { switch self { case .appleSilicon: - return "Apple Silicon" + return localizeString("Apple Silicon") case .universal: - return "Universal" + return localizeString("Universal") } } From db97f3e98359cd2a767b6090654c7fdc9f8b59a6 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Sun, 24 May 2026 13:30:37 -0500 Subject: [PATCH 03/18] Share concurrency-enabled XcodesKit --- HelperXPCShared/HelperXPCShared.swift | 2 +- Scripts/fix_libfido2_framework.sh | 42 +- Xcodes.xcodeproj/project.pbxproj | 153 +- .../xcshareddata/swiftpm/Package.resolved | 26 +- Xcodes/AppleAPI/.gitignore | 5 - Xcodes/AppleAPI/Package.swift | 28 - Xcodes/AppleAPI/README.md | 3 - Xcodes/AppleAPI/Sources/AppleAPI/Client.swift | 606 ----- .../Sources/AppleAPI/Environment.swift | 24 - .../AppleAPI/Sources/AppleAPI/Hashcash.swift | 96 - .../Sources/AppleAPI/URLRequest+Apple.swift | 204 -- .../Tests/AppleAPITests/AppleAPITests.swift | 27 - .../Tests/AppleAPITests/XCTestManifests.swift | 9 - Xcodes/AppleAPI/Tests/LinuxMain.swift | 7 - Xcodes/Backend/AppState+Install.swift | 837 +++--- Xcodes/Backend/AppState+Runtimes.swift | 507 ++-- Xcodes/Backend/AppState+Update.swift | 299 +-- Xcodes/Backend/AppState.swift | 1083 ++++---- Xcodes/Backend/DataSource.swift | 18 +- Xcodes/Backend/Downloader.swift | 17 +- Xcodes/Backend/Downloads.swift | 27 - Xcodes/Backend/Environment.swift | 451 ++-- Xcodes/Backend/FileManager+.swift | 5 +- Xcodes/Backend/Foundation.swift | 28 - Xcodes/Backend/Hardware.swift | 29 +- Xcodes/Backend/HelperClient.swift | 336 +-- Xcodes/Backend/HelperInstallState.swift | 2 +- Xcodes/Backend/InstalledXcode.swift | 75 - Xcodes/Backend/NotificationManager.swift | 114 +- Xcodes/Backend/Path+.swift | 39 +- Xcodes/Backend/Process.swift | 64 +- Xcodes/Backend/Progress+.swift | 100 - Xcodes/Backend/Publisher+Resumable.swift | 44 - Xcodes/Backend/SDKs+Xcode.swift | 28 +- Xcodes/Backend/SelectedActionType.swift | 33 +- Xcodes/Backend/URLRequest+Apple.swift | 38 - .../URLSession+DownloadTaskPublisher.swift | 54 - Xcodes/Backend/Version+.swift | 34 - Xcodes/Backend/Version+Xcode.swift | 72 - Xcodes/Backend/Version+XcodeReleases.swift | 55 - Xcodes/Backend/Xcode.swift | 61 +- Xcodes/Backend/XcodeCommands.swift | 3 +- Xcodes/Backend/XcodeInstallState.swift | 28 - Xcodes/Frontend/About/AboutView.swift | 2 +- .../Common/ObservingProgressIndicator.swift | 36 +- Xcodes/Frontend/Common/XcodesSheet.swift | 2 +- .../Frontend/InfoPane/CompatibilityView.swift | 2 +- Xcodes/Frontend/InfoPane/IconView.swift | 1 + .../InfoPane/IdenticalBuildView.swift | 6 +- Xcodes/Frontend/InfoPane/InfoPane.swift | 12 +- .../Frontend/InfoPane/InfoPaneControls.swift | 1 + .../InfoPane/InstalledStateButtons.swift | 2 +- .../InfoPane/NotInstalledStateButtons.swift | 3 +- Xcodes/Frontend/InfoPane/PlatformsView.swift | 5 +- Xcodes/Frontend/MainWindow.swift | 27 +- .../Preferences/AdvancedPreferencePane.swift | 5 +- .../Preferences/DownloadPreferencePane.swift | 2 +- .../ExperiementsPreferencePane.swift | 2 +- .../Preferences/GeneralPreferencePane.swift | 4 +- .../Preferences/PlatformsListView.swift | 2 +- .../Preferences/UpdatesPreferencePane.swift | 17 +- Xcodes/Frontend/SignIn/AttributedText.swift | 2 +- Xcodes/Frontend/SignIn/SignIn2FAView.swift | 3 +- .../SignIn/SignInCredentialsView.swift | 1 + .../Frontend/SignIn/SignInPhoneListView.swift | 3 +- Xcodes/Frontend/SignIn/SignInSMSView.swift | 3 +- .../SignIn/SignInSecurityKeyPinView.swift | 4 +- .../SignIn/SignInSecurityKeyTouchView.swift | 4 +- .../Frontend/XcodeList/BottomStatusBar.swift | 1 + Xcodes/Frontend/XcodeList/XcodeListView.swift | 2 + .../Frontend/XcodeList/XcodeListViewRow.swift | 7 +- Xcodes/Resources/Licenses.rtf | 29 +- Xcodes/XcodesApp.swift | 17 +- Xcodes/XcodesKit/Package.resolved | 32 + Xcodes/XcodesKit/Package.swift | 10 +- .../Concurrency/OneShotContinuation.swift | 74 + .../Extensions/DateFormatter+XcodeList.swift} | 4 +- .../Extensions/FileManager+Trash.swift | 17 + .../XcodesKit/Extensions/Foundation.swift | 29 +- .../Sources/XcodesKit/Extensions/Logger.swift | 2 +- .../OperatingSystemVersion+Xcodes.swift | 7 + .../XcodesKit/Extensions/Path+Ownership.swift | 11 + .../Extensions/Path+XcodeBundle.swift} | 7 +- .../Extensions/Progress+Xcodes.swift | 78 + .../XcodesKit/Extensions/Version+Gem.swift | 53 + .../Extensions/Version+Matching.swift | 23 + .../XcodesKit/Extensions/Version+Xcode.swift | 150 ++ .../XcodesKit/Models}/Aria2CError.swift | 12 +- .../Models/Runtimes/CoreSimulatorImage.swift | 6 +- .../Models/Runtimes/RuntimeInstallState.swift | 4 +- .../Runtimes/RuntimeInstallationStep.swift | 2 +- .../XcodesKit/Models/Runtimes/Runtimes.swift | 86 +- .../XcodesKit/Models/XcodeInstallState.swift | 17 +- .../Models/XcodeInstallationStep.swift | 2 +- .../Models/XcodeReleases/Architecture.swift | 4 +- .../Models/XcodeReleases/Checksums.swift | 2 +- .../Models/XcodeReleases/Compilers.swift | 2 +- .../XcodesKit/Models/XcodeReleases/Link.swift | 4 +- .../Models/XcodeReleases/Release.swift | 2 +- .../XcodesKit/Models/XcodeReleases/SDKs.swift | 15 +- .../Models/XcodeReleases/XcodeRelease.swift | 2 +- .../Models/XcodeReleases/XcodeVersion.swift | 2 +- .../XcodesKit/Models/XcodeReleases/YMD.swift | 2 +- .../Models/Xcodes/AutoInstallationType.swift | 23 + .../Models/Xcodes/AvailableXcode.swift | 93 + .../Xcodes/AvailableXcodeRelease.swift} | 17 +- .../XcodesKit/Models/Xcodes/Downloads.swift | 41 + .../Models/Xcodes/InstalledXcode.swift | 58 + .../Models/Xcodes/InstalledXcodeBundle.swift | 60 + .../Models/Xcodes/SelectedActionType.swift | 24 + .../XcodesKit/Models/Xcodes/XcodeID.swift | 17 + .../Models/Xcodes/XcodeListItem.swift | 57 + .../XcodesKit/Models/XcodesKitError.swift | 13 + .../ApplicationSupportMigrationService.swift | 42 + .../ArchiveCancellationCleanupService.swift | 44 + .../Services/ArchiveDownloadService.swift | 100 + .../ArchiveDownloadStrategyService.swift | 63 + .../Services/Aria2DownloadService.swift | 70 + .../XcodesKit/Services/AsyncRetry.swift | 55 + .../Services/AvailableXcodeCache.swift | 52 + .../XcodesKit/Services/CodableFileStore.swift | 50 + .../Services/DownloadableRuntimeCache.swift | 43 + .../XcodesKit/Services/HostHardware.swift | 23 + .../InstalledXcodeDiscoveryService.swift | 39 + .../Services/ProgressObservation.swift | 100 + ...untimeArchiveDownloadStrategyService.swift | 69 + .../RuntimeArchiveInstallService.swift | 47 + .../Services/RuntimeArchiveService.swift | 69 + .../Services/RuntimeInstallPolicy.swift | 65 + .../RuntimeInstallationLookupService.swift | 42 + .../RuntimeListPresentationService.swift | 183 ++ .../XcodesKit/Services/RuntimeListStore.swift | 67 + .../RuntimePackageInstallService.swift | 102 + .../XcodesKit/Services/RuntimeService.swift | 104 +- .../RuntimeXcodebuildInstallService.swift | 38 + .../Services/URLSession+DownloadTask.swift | 114 + .../Services/XcodeArchiveInstallService.swift | 76 + .../Services/XcodeArchiveService.swift | 82 + .../Services/XcodeAutoInstallService.swift | 38 + .../Services/XcodeCompatibilityService.swift | 98 + .../XcodeInstallResolutionService.swift | 123 + .../Services/XcodeInstallRetryService.swift | 50 + .../Services/XcodeListComposer.swift | 82 + .../XcodeListPresentationService.swift | 129 + .../XcodesKit/Services/XcodeListService.swift | 225 ++ .../XcodesKit/Services/XcodeListStore.swift | 88 + .../XcodePostInstallPreparationService.swift | 30 + .../Services/XcodePostInstallService.swift | 39 + .../XcodePostInstallWorkflowService.swift | 40 + .../XcodeSelectionFilesystemService.swift | 88 + .../Services/XcodeSelectionService.swift | 80 + .../Services/XcodeSignatureVerifier.swift | 51 + .../Services/XcodeUnarchiveService.swift | 91 + .../Services/XcodeUninstallService.swift | 32 + .../Services/XcodeUpdatePolicy.swift | 27 + .../Services/XcodeValidationService.swift | 61 + .../Services/XcodeVersionFileService.swift | 34 + .../XcodebuildRuntimeDownloadService.swift | 42 + .../Services/XcodesPathResolver.swift | 59 + .../Sources/XcodesKit/Shell/Process.swift | 221 +- .../Shell/ProcessProgressStream.swift | 145 + .../Sources/XcodesKit/Shell/XcodesShell.swift | 69 +- .../XcodesKit/XcodesKitEnvironment.swift | 46 +- ...licationSupportMigrationServiceTests.swift | 89 + .../ArchiveDownloadStrategyServiceTests.swift | 128 + .../AutoInstallationTypeTests.swift | 36 + .../CodableFileStoreTests.swift | 99 + .../XcodesKitTests/HostHardwareTests.swift | 16 + .../XcodesKitTests/InstalledXcodeTests.swift | 177 ++ .../OperatingSystemVersionXcodesTests.swift | 10 + .../ProgressObservationTests.swift | 55 + .../XcodesKitTests/ProgressXcodesTests.swift | 33 + ...eArchiveDownloadStrategyServiceTests.swift | 184 ++ .../RuntimeListStoreTests.swift | 97 + .../Tests/XcodesKitTests/SDKsTests.swift | 34 + .../SelectedActionTypeTests.swift | 13 + .../XcodesKitTests/VersionGemTests.swift | 13 + .../XcodesKitTests/VersionMatchingTests.swift | 42 + .../XcodesKitTests/VersionXcodeTests.swift | 37 + .../XcodeArchiveDownloaderTests.swift | 14 + .../XcodeAutoInstallServiceTests.swift | 77 + .../XcodeCompatibilityServiceTests.swift | 86 + .../XcodesKitTests/XcodeListItemTests.swift | 53 + .../XcodesKitTests/XcodeListStoreTests.swift | 104 + ...XcodePostInstallWorkflowServiceTests.swift | 121 + .../XcodeVersionFileServiceTests.swift | 38 + .../Tests/XcodesKitTests/XcodesKitTests.swift | 2322 ++++++++++++++++- .../XcodesPathResolverTests.swift | 88 + XcodesTests/AppStateTests.swift | 784 +++++- XcodesTests/AppStateUpdateTests.swift | 175 +- XcodesTests/Environment+Mock.swift | 188 +- com.xcodesorg.xcodesapp.Helper/Logger.swift | 2 +- .../XPCDelegate.swift | 2 +- 193 files changed, 11292 insertions(+), 4536 deletions(-) delete mode 100644 Xcodes/AppleAPI/.gitignore delete mode 100644 Xcodes/AppleAPI/Package.swift delete mode 100644 Xcodes/AppleAPI/README.md delete mode 100644 Xcodes/AppleAPI/Sources/AppleAPI/Client.swift delete mode 100644 Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift delete mode 100644 Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift delete mode 100644 Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift delete mode 100644 Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift delete mode 100644 Xcodes/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift delete mode 100644 Xcodes/AppleAPI/Tests/LinuxMain.swift delete mode 100644 Xcodes/Backend/Downloads.swift delete mode 100644 Xcodes/Backend/Foundation.swift delete mode 100644 Xcodes/Backend/InstalledXcode.swift delete mode 100644 Xcodes/Backend/Publisher+Resumable.swift delete mode 100644 Xcodes/Backend/URLRequest+Apple.swift delete mode 100644 Xcodes/Backend/URLSession+DownloadTaskPublisher.swift delete mode 100644 Xcodes/Backend/Version+.swift delete mode 100644 Xcodes/Backend/Version+Xcode.swift delete mode 100644 Xcodes/Backend/Version+XcodeReleases.swift delete mode 100644 Xcodes/Backend/XcodeInstallState.swift create mode 100644 Xcodes/XcodesKit/Package.resolved create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Concurrency/OneShotContinuation.swift rename Xcodes/{Backend/DateFormatter+.swift => XcodesKit/Sources/XcodesKit/Extensions/DateFormatter+XcodeList.swift} (76%) create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/FileManager+Trash.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/OperatingSystemVersion+Xcodes.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+Ownership.swift rename Xcodes/{Backend/Entry+.swift => XcodesKit/Sources/XcodesKit/Extensions/Path+XcodeBundle.swift} (92%) create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Progress+Xcodes.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Gem.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Matching.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Xcode.swift rename Xcodes/{Backend => XcodesKit/Sources/XcodesKit/Models}/Aria2CError.swift (94%) create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AutoInstallationType.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift rename Xcodes/{Backend/AvailableXcode.swift => XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcodeRelease.swift} (76%) create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/Downloads.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcode.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/SelectedActionType.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeID.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListItem.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodesKitError.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ApplicationSupportMigrationService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveCancellationCleanupService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadStrategyService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/Aria2DownloadService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/AsyncRetry.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/AvailableXcodeCache.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/CodableFileStore.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/DownloadableRuntimeCache.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/HostHardware.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/InstalledXcodeDiscoveryService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ProgressObservation.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveDownloadStrategyService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveInstallService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallPolicy.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallationLookupService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListStore.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimePackageInstallService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeXcodebuildInstallService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/URLSession+DownloadTask.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveInstallService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeAutoInstallService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeCompatibilityService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallResolutionService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallRetryService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListComposer.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListStore.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallPreparationService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallWorkflowService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionFilesystemService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSignatureVerifier.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUnarchiveService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUninstallService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUpdatePolicy.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeValidationService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeVersionFileService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodesPathResolver.swift create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Shell/ProcessProgressStream.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ApplicationSupportMigrationServiceTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ArchiveDownloadStrategyServiceTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/AutoInstallationTypeTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/CodableFileStoreTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/HostHardwareTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/InstalledXcodeTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/OperatingSystemVersionXcodesTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressObservationTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressXcodesTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeArchiveDownloadStrategyServiceTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeListStoreTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/SDKsTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/SelectedActionTypeTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/VersionGemTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/VersionMatchingTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/VersionXcodeTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeArchiveDownloaderTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeAutoInstallServiceTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeCompatibilityServiceTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListItemTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListStoreTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodePostInstallWorkflowServiceTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeVersionFileServiceTests.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesPathResolverTests.swift diff --git a/HelperXPCShared/HelperXPCShared.swift b/HelperXPCShared/HelperXPCShared.swift index 80f89b86..d72d7be9 100644 --- a/HelperXPCShared/HelperXPCShared.swift +++ b/HelperXPCShared/HelperXPCShared.swift @@ -5,7 +5,7 @@ let clientBundleID = "com.xcodesorg.xcodesapp" let subjectOrganizationalUnit = Bundle.main.infoDictionary!["CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT"] as! String @objc(HelperXPCProtocol) -protocol HelperXPCProtocol { +protocol HelperXPCProtocol: Sendable { func getVersion(completion: @escaping (String) -> Void) func xcodeSelect(absolutePath: String, completion: @escaping (Error?) -> Void) func devToolsSecurityEnable(completion: @escaping (Error?) -> Void) diff --git a/Scripts/fix_libfido2_framework.sh b/Scripts/fix_libfido2_framework.sh index d9111062..805dd72b 100755 --- a/Scripts/fix_libfido2_framework.sh +++ b/Scripts/fix_libfido2_framework.sh @@ -1,26 +1,34 @@ #!/bin/sh -# Fix libfido2.framework structure for macOS validation -# If this script is not run, the build will fail because xcodebuild is expecting the library in a specific structure -FRAMEWORK_PATH="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks/libfido2.framework" +set -e -if [ -d "$FRAMEWORK_PATH" ] && [ -f "$FRAMEWORK_PATH/Info.plist" ] && [ ! -d "$FRAMEWORK_PATH/Versions" ]; then +FRAMEWORKS_DIR="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Frameworks" +LIBFIDO_FRAMEWORK_PATH="${FRAMEWORKS_DIR}/libfido2.framework" + +if [ -d "$LIBFIDO_FRAMEWORK_PATH" ] && [ -f "$LIBFIDO_FRAMEWORK_PATH/Info.plist" ] && [ ! -d "$LIBFIDO_FRAMEWORK_PATH/Versions" ]; then echo "Fixing libfido2.framework bundle structure..." - # Create proper bundle structure - mkdir -p "$FRAMEWORK_PATH/Versions/A/Resources" + mkdir -p "$LIBFIDO_FRAMEWORK_PATH/Versions/A/Resources" - # Move files to proper locations - mv "$FRAMEWORK_PATH/Info.plist" "$FRAMEWORK_PATH/Versions/A/Resources/" - mv "$FRAMEWORK_PATH/libfido2" "$FRAMEWORK_PATH/Versions/A/" - if [ -f "$FRAMEWORK_PATH/LICENSE" ]; then - mv "$FRAMEWORK_PATH/LICENSE" "$FRAMEWORK_PATH/Versions/A/" + mv "$LIBFIDO_FRAMEWORK_PATH/Info.plist" "$LIBFIDO_FRAMEWORK_PATH/Versions/A/Resources/" + mv "$LIBFIDO_FRAMEWORK_PATH/libfido2" "$LIBFIDO_FRAMEWORK_PATH/Versions/A/" + if [ -f "$LIBFIDO_FRAMEWORK_PATH/LICENSE" ]; then + mv "$LIBFIDO_FRAMEWORK_PATH/LICENSE" "$LIBFIDO_FRAMEWORK_PATH/Versions/A/" fi - # Create symbolic links - ln -sf A "$FRAMEWORK_PATH/Versions/Current" - ln -sf Versions/Current/libfido2 "$FRAMEWORK_PATH/libfido2" - ln -sf Versions/Current/Resources "$FRAMEWORK_PATH/Resources" - - echo "libfido2.framework structure fixed" + ln -sf A "$LIBFIDO_FRAMEWORK_PATH/Versions/Current" + ln -sf Versions/Current/libfido2 "$LIBFIDO_FRAMEWORK_PATH/libfido2" + ln -sf Versions/Current/Resources "$LIBFIDO_FRAMEWORK_PATH/Resources" fi + +SIGN_IDENTITY="${EXPANDED_CODE_SIGN_IDENTITY:--}" +for item in \ + "$BUILT_PRODUCTS_DIR/libcrypto.3.dylib" \ + "$BUILT_PRODUCTS_DIR/libcbor.0.11.0.dylib" \ + "$FRAMEWORKS_DIR/libcrypto.3.dylib" \ + "$FRAMEWORKS_DIR/libcbor.0.11.0.dylib" \ + "$LIBFIDO_FRAMEWORK_PATH"; do + if [ -e "$item" ]; then + codesign --force --sign "$SIGN_IDENTITY" "$item" + fi +done diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 7aeb76cf..6c9cba56 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 15F5B8902CCF09B900705E2F /* CryptoKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15F5B88F2CCF09B900705E2F /* CryptoKit.framework */; }; - 33027E342CA8C18800CB387C /* LibFido2Swift in Frameworks */ = {isa = PBXBuildFile; productRef = 334A932B2CA885A400A5E079 /* LibFido2Swift */; }; 3328073F2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */; }; 332807412CA5EA820036F691 /* SignInSecurityKeyTouchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */; }; 36741BFD291E4FDB00A85AAE /* DownloadPreferencePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36741BFC291E4FDB00A85AAE /* DownloadPreferencePane.swift */; }; @@ -32,7 +31,6 @@ BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */; }; CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; }; - CA25192A25A9644800F08414 /* XcodeInstallState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA25192925A9644800F08414 /* XcodeInstallState.swift */; }; CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; }; @@ -48,10 +46,7 @@ CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF84D2595079F00E47BAF /* ScrollingTextView.swift */; }; CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */; }; CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8652595130600E47BAF /* View+IsHidden.swift */; }; - CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */; }; CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF87A2595293E00E47BAF /* DataSource.swift */; }; - CA9FF88125955C7000E47BAF /* AvailableXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF88025955C7000E47BAF /* AvailableXcode.swift */; }; - CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8862595607900E47BAF /* InstalledXcode.swift */; }; CA9FF8B12595967A00E47BAF /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8B02595967A00E47BAF /* main.swift */; }; CA9FF8CF25959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; CA9FF8D025959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; @@ -59,31 +54,20 @@ CA9FF8E025959BAA00E47BAF /* ConnectionVerifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8DF25959BAA00E47BAF /* ConnectionVerifier.swift */; }; CA9FF8E625959BB800E47BAF /* AuditTokenHack.m in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8E525959BB800E47BAF /* AuditTokenHack.m */; }; CA9FF9362595B44700E47BAF /* HelperClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF9352595B44700E47BAF /* HelperClient.swift */; }; - CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */; }; CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */; }; CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */; }; CAA8587C25A2B37900ACF8C0 /* IsTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */; }; CAA8589325A2B77E00ACF8C0 /* aria2c in Copy aria2c */ = {isa = PBXBuildFile; fileRef = CAA8588025A2B63A00ACF8C0 /* aria2c */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - CAA8589B25A2B83000ACF8C0 /* Aria2CError.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA8589A25A2B83000ACF8C0 /* Aria2CError.swift */; }; CAA858C425A2BE4E00ACF8C0 /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */; }; CAA858CD25A3D8BC00ACF8C0 /* ErrorHandling in Frameworks */ = {isa = PBXBuildFile; productRef = CAA858CC25A3D8BC00ACF8C0 /* ErrorHandling */; }; CAA858DB25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz in Resources */ = {isa = PBXBuildFile; fileRef = CAA858DA25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz */; }; CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7B72449575100113D76 /* AppStateTests.swift */; }; - CABFA9BB2592EEEA00380FEE /* DateFormatter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */; }; CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A92592EEE900380FEE /* Environment.swift */; }; - CABFA9BF2592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */; }; - CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A82592EEE900380FEE /* Version+.swift */; }; - CABFA9C22592EEEA00380FEE /* Publisher+Resumable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B02592EEEA00380FEE /* Publisher+Resumable.swift */; }; - CABFA9C32592EEEA00380FEE /* Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B92592EEEA00380FEE /* Downloads.swift */; }; CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B82592EEEA00380FEE /* FileManager+.swift */; }; - CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B22592EEEA00380FEE /* Entry+.swift */; }; - CABFA9C92592EEEA00380FEE /* URLRequest+Apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */; }; CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A72592EEE900380FEE /* AppState+Update.swift */; }; CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9AE2592EEE900380FEE /* Path+.swift */; }; - CABFA9CD2592EEEA00380FEE /* Foundation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9AC2592EEE900380FEE /* Foundation.swift */; }; - CABFA9CE2592EEEA00380FEE /* Version+Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A62592EEE900380FEE /* Version+Xcode.swift */; }; CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B42592EEEA00380FEE /* Process.swift */; }; CABFA9E42592F08E00380FEE /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = CABFA9E32592F08E00380FEE /* Version */; }; CABFA9EE2592F0CC00380FEE /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = CABFA9ED2592F0CC00380FEE /* SwiftSoup */; }; @@ -92,7 +76,6 @@ CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA2B2592FBFC00380FEE /* Configure.swift */; }; CABFAA432593104F00380FEE /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA422593104F00380FEE /* AboutView.swift */; }; CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */; }; - CAC28188259EE27200B8AB0B /* CombineExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = CAC28187259EE27200B8AB0B /* CombineExpectations */; }; CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281CC259F97FA00B8AB0B /* ObservingProgressIndicator.swift */; }; CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281E1259FA44600B8AB0B /* Bundle+XcodesTests.swift */; }; CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */; }; @@ -124,6 +107,8 @@ E84E4F542B333864003F3959 /* PlatformsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E4F532B333864003F3959 /* PlatformsListView.swift */; }; E84E4F572B335094003F3959 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E84E4F562B335094003F3959 /* OrderedCollections */; }; E862D43B2CC8B26F00BAA376 /* SRP in Frameworks */ = {isa = PBXBuildFile; productRef = E862D43A2CC8B26F00BAA376 /* SRP */; }; + E89CBD382D5FAB950037ED95 /* XcodesLoginKit in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD372D5FAB950037ED95 /* XcodesLoginKit */; }; + E89CBD3A2D5FB8920037ED95 /* XcodesLoginKitSecurityKey in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD392D5FB8920037ED95 /* XcodesLoginKitSecurityKey */; }; E86671272B309D2F0048559A /* PlatformsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E86671262B309D2F0048559A /* PlatformsView.swift */; }; E87AB3C52939B65E00D72F43 /* Hardware.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87AB3C42939B65E00D72F43 /* Hardware.swift */; }; E87DD6EB25D053FA00D86808 /* Progress+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87DD6EA25D053FA00D86808 /* Progress+.swift */; }; @@ -222,7 +207,6 @@ BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingIconLabelStyle.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; - CA25192925A9644800F08414 /* XcodeInstallState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeInstallState.swift; sourceTree = ""; }; CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; @@ -230,7 +214,6 @@ CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; CA452BBF259FDDFE0072DFA4 /* Stub-version.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Stub-version.plist"; sourceTree = ""; }; CA452BEA25A236500072DFA4 /* Stub-0.0.0.Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Stub-0.0.0.Info.plist"; sourceTree = ""; }; - CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = ""; }; CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = ""; }; CA61A6DF259835580008926E /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; CA735108257BF96D00EA9CF8 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = ""; }; @@ -243,10 +226,7 @@ CA9FF84D2595079F00E47BAF /* ScrollingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingTextView.swift; sourceTree = ""; }; CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; CA9FF8652595130600E47BAF /* View+IsHidden.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+IsHidden.swift"; sourceTree = ""; }; - CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+XcodeReleases.swift"; sourceTree = ""; }; CA9FF87A2595293E00E47BAF /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; - CA9FF88025955C7000E47BAF /* AvailableXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailableXcode.swift; sourceTree = ""; }; - CA9FF8862595607900E47BAF /* InstalledXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledXcode.swift; sourceTree = ""; }; CA9FF8AE2595967A00E47BAF /* com.xcodesorg.xcodesapp.Helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = com.xcodesorg.xcodesapp.Helper; sourceTree = BUILT_PRODUCTS_DIR; }; CA9FF8B02595967A00E47BAF /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; CA9FF8C22595988B00E47BAF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -267,26 +247,16 @@ CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsTesting.swift; sourceTree = ""; }; CAA8588025A2B63A00ACF8C0 /* aria2c */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = aria2c; sourceTree = ""; }; CAA8588A25A2B69300ACF8C0 /* aria2c.LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = aria2c.LICENSE; sourceTree = ""; }; - CAA8589A25A2B83000ACF8C0 /* Aria2CError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2CError.swift; sourceTree = ""; }; CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloader.swift; sourceTree = ""; }; CAA858DA25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz */ = {isa = PBXFileReference; lastKnownFileType = archive.gzip; path = "aria2-release-1.35.0.tar.gz"; sourceTree = ""; }; CABFA9A02592EAF500380FEE /* R&PLogo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "R&PLogo.png"; sourceTree = ""; }; CABFA9A12592EAFB00380FEE /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; CABFA9A32592ED5700380FEE /* Apple.paw */ = {isa = PBXFileReference; lastKnownFileType = file; name = Apple.paw; path = ../xcodes/Apple.paw; sourceTree = ""; }; - CABFA9A62592EEE900380FEE /* Version+Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+Xcode.swift"; sourceTree = ""; }; CABFA9A72592EEE900380FEE /* AppState+Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Update.swift"; sourceTree = ""; }; - CABFA9A82592EEE900380FEE /* Version+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+.swift"; sourceTree = ""; }; CABFA9A92592EEE900380FEE /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; - CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+Apple.swift"; sourceTree = ""; }; - CABFA9AC2592EEE900380FEE /* Foundation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Foundation.swift; sourceTree = ""; }; CABFA9AE2592EEE900380FEE /* Path+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Path+.swift"; sourceTree = ""; }; - CABFA9B02592EEEA00380FEE /* Publisher+Resumable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Resumable.swift"; sourceTree = ""; }; - CABFA9B22592EEEA00380FEE /* Entry+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Entry+.swift"; sourceTree = ""; }; - CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+DownloadTaskPublisher.swift"; sourceTree = ""; }; CABFA9B42592EEEA00380FEE /* Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Process.swift; sourceTree = ""; }; CABFA9B82592EEEA00380FEE /* FileManager+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+.swift"; sourceTree = ""; }; - CABFA9B92592EEEA00380FEE /* Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloads.swift; sourceTree = ""; }; - CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+.swift"; sourceTree = ""; }; CABFA9D42592EF6300380FEE /* DECISIONS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DECISIONS.md; sourceTree = ""; }; CABFAA2B2592FBFC00380FEE /* Configure.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Configure.swift; path = Xcodes/Backend/Configure.swift; sourceTree = SOURCE_ROOT; }; CABFAA422593104F00380FEE /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -329,6 +299,7 @@ E84E4F512B323A5F003F3959 /* CornerRadiusModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerRadiusModifier.swift; sourceTree = ""; }; E84E4F532B333864003F3959 /* PlatformsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsListView.swift; sourceTree = ""; }; E856BB73291EDD3D00DC438B /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = ""; }; + E89CBD3B2D5FC0B10037ED95 /* XcodesLoginKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesLoginKit; path = ../XcodesLoginKit; sourceTree = ""; }; E86671262B309D2F0048559A /* PlatformsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsView.swift; sourceTree = ""; }; E87AB3C42939B65E00D72F43 /* Hardware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hardware.swift; sourceTree = ""; }; E87DD6EA25D053FA00D86808 /* Progress+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Progress+.swift"; sourceTree = ""; }; @@ -358,7 +329,6 @@ buildActionMask = 2147483647; files = ( 15F5B8902CCF09B900705E2F /* CryptoKit.framework in Frameworks */, - 33027E342CA8C18800CB387C /* LibFido2Swift in Frameworks */, CABFA9E42592F08E00380FEE /* Version in Frameworks */, CABFA9FD2592F13300380FEE /* LegibleError in Frameworks */, E689540325BE8C64000EBCEA /* DockProgress in Frameworks */, @@ -366,9 +336,10 @@ E83FDC442CBB649100679C6B /* Sparkle in Frameworks */, E862D43B2CC8B26F00BAA376 /* SRP in Frameworks */, CAA858CD25A3D8BC00ACF8C0 /* ErrorHandling in Frameworks */, + E89CBD3A2D5FB8920037ED95 /* XcodesLoginKitSecurityKey in Frameworks */, + E89CBD382D5FAB950037ED95 /* XcodesLoginKit in Frameworks */, E8C0EB1A291EF43E0081528A /* XcodesKit in Frameworks */, E8FD5727291EE4AC001E004C /* AsyncNetworkService in Frameworks */, - CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */, CABFA9EE2592F0CC00380FEE /* SwiftSoup in Frameworks */, E84E4F572B335094003F3959 /* OrderedCollections in Frameworks */, E8F44A1E296B4CD7002D6592 /* Path in Frameworks */, @@ -379,7 +350,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CAC28188259EE27200B8AB0B /* CombineExpectations in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -502,37 +472,23 @@ E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */, CAE424B3259A764700B8B246 /* AppState+Install.swift */, CABFA9A72592EEE900380FEE /* AppState+Update.swift */, - CAA8589A25A2B83000ACF8C0 /* Aria2CError.swift */, - CA9FF88025955C7000E47BAF /* AvailableXcode.swift */, CABFAA2B2592FBFC00380FEE /* Configure.swift */, CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */, CA9FF87A2595293E00E47BAF /* DataSource.swift */, - CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */, - CABFA9B92592EEEA00380FEE /* Downloads.swift */, CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */, - CABFA9B22592EEEA00380FEE /* Entry+.swift */, CABFA9A92592EEE900380FEE /* Environment.swift */, 36741BFE291E50F500A85AAE /* FileError.swift */, CABFA9B82592EEEA00380FEE /* FileManager+.swift */, CAFBDB942598FE96003DCC5A /* FocusedValues.swift */, - CABFA9AC2592EEE900380FEE /* Foundation.swift */, CA9FF9352595B44700E47BAF /* HelperClient.swift */, CAC9F92C25BCDA4400B4965F /* HelperInstallState.swift */, - CA9FF8862595607900E47BAF /* InstalledXcode.swift */, CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */, E89342F925EDCC17007CF557 /* NotificationManager.swift */, CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */, CABFA9AE2592EEE900380FEE /* Path+.swift */, CABFA9B42592EEEA00380FEE /* Process.swift */, - CABFA9B02592EEEA00380FEE /* Publisher+Resumable.swift */, CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */, - CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */, - CABFA9B32592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift */, - CABFA9A82592EEE900380FEE /* Version+.swift */, - CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */, - CABFA9A62592EEE900380FEE /* Version+Xcode.swift */, CA61A6DF259835580008926E /* Xcode.swift */, - CA25192925A9644800F08414 /* XcodeInstallState.swift */, CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */, E87DD6EA25D053FA00D86808 /* Progress+.swift */, E81D7E9F2805250100A205FC /* Collection+.swift */, @@ -581,6 +537,7 @@ isa = PBXGroup; children = ( E856BB73291EDD3D00DC438B /* XcodesKit */, + E89CBD3B2D5FC0B10037ED95 /* XcodesLoginKit */, CA8FB5F8256E0F9400469DA5 /* README.md */, CABFA9D42592EF6300380FEE /* DECISIONS.md */, CABFA9A02592EAF500380FEE /* R&PLogo.png */, @@ -589,7 +546,6 @@ CA8FB61C256E115700469DA5 /* .github */, CA9FF9252595A7EB00E47BAF /* Scripts */, CA9FF8242594F10700E47BAF /* AcknowledgementsGenerator */, - CA538A0C255A4F1A00E64DD7 /* AppleAPI */, CAD2E7A02449574E00113D76 /* Xcodes */, CAD2E7B62449575100113D76 /* XcodesTests */, CA9FF8AF2595967A00E47BAF /* com.xcodesorg.xcodesapp.Helper */, @@ -711,6 +667,7 @@ CAD2E79C2449574E00113D76 /* Resources */, CA9FF8BB259596B500E47BAF /* Copy Helper */, CAA8589225A2B76F00ACF8C0 /* Copy aria2c */, + D971F84C2E79102E005F84C9 /* Fix libfido2 structure */, ); buildRules = ( ); @@ -719,7 +676,6 @@ ); name = Xcodes; packageProductDependencies = ( - CAA1CB2C255A5262003FD669 /* AppleAPI */, CABFA9E32592F08E00380FEE /* Version */, CABFA9ED2592F0CC00380FEE /* SwiftSoup */, CABFA9F72592F0F900380FEE /* KeychainAccess */, @@ -731,8 +687,9 @@ E8F44A1D296B4CD7002D6592 /* Path */, E84E4F562B335094003F3959 /* OrderedCollections */, E83FDC432CBB649100679C6B /* Sparkle */, - 334A932B2CA885A400A5E079 /* LibFido2Swift */, E862D43A2CC8B26F00BAA376 /* SRP */, + E89CBD372D5FAB950037ED95 /* XcodesLoginKit */, + E89CBD392D5FB8920037ED95 /* XcodesLoginKitSecurityKey */, ); productName = XcodesMac; productReference = CAD2E79E2449574E00113D76 /* Xcodes.app */; @@ -753,7 +710,6 @@ ); name = XcodesTests; packageProductDependencies = ( - CAC28187259EE27200B8AB0B /* CombineExpectations */, ); productName = XcodesMacTests; productReference = CAD2E7B32449575100113D76 /* XcodesTests.xctest */; @@ -815,13 +771,11 @@ CABFA9F62592F0F900380FEE /* XCRemoteSwiftPackageReference "KeychainAccess" */, CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */, CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */, - CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */, E689540125BE8C64000EBCEA /* XCRemoteSwiftPackageReference "DockProgress" */, E8FD5725291EE4AC001E004C /* XCRemoteSwiftPackageReference "AsyncHTTPNetworkService" */, E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */, E84E4F552B335094003F3959 /* XCRemoteSwiftPackageReference "swift-collections" */, E83FDC422CBB649100679C6B /* XCRemoteSwiftPackageReference "Sparkle" */, - 33027E282CA8BB5800CB387C /* XCRemoteSwiftPackageReference "LibFido2Swift" */, ); productRefGroup = CAD2E79F2449574E00113D76 /* Products */; projectDirPath = ""; @@ -881,6 +835,24 @@ shellPath = /bin/sh; shellScript = "cd \"${SRCROOT}/Xcodes/AcknowledgementsGenerator\"\nxcrun -sdk macosx swift run AcknowledgementsGenerator \\\n -p \"${SRCROOT}/Xcodes.xcodeproj\" \\\n -o \"${SRCROOT}/Xcodes/Resources/Licenses.rtf\"\n"; }; + D971F84C2E79102E005F84C9 /* Fix libfido2 structure */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Fix libfido2 structure"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "sh \"${SRCROOT}/Scripts/fix_libfido2_framework.sh\"\n"; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -905,7 +877,6 @@ CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */, CAFBDC4E2599B33D003DCC5A /* MainToolbar.swift in Sources */, CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */, - CAA8589B25A2B83000ACF8C0 /* Aria2CError.swift in Sources */, 536CFDD2263C94DE00026CE0 /* SignedInView.swift in Sources */, CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */, CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */, @@ -917,22 +888,17 @@ CAA8587C25A2B37900ACF8C0 /* IsTesting.swift in Sources */, CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */, CA44901F2463AD34003D8213 /* Tag.swift in Sources */, - CABFA9BF2592EEEA00380FEE /* URLSession+DownloadTaskPublisher.swift in Sources */, CAFE4AAC25B7D2C70064FE51 /* GeneralPreferencePane.swift in Sources */, - CABFA9BB2592EEEA00380FEE /* DateFormatter+.swift in Sources */, CAFE4ABC25B7D54B0064FE51 /* UpdatesPreferencePane.swift in Sources */, CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */, - CABFA9C32592EEEA00380FEE /* Downloads.swift in Sources */, E8CBDB8B27AE02FF00B22292 /* ExperiementsPreferencePane.swift in Sources */, E8E98A9625D863D700EC89A0 /* InstallationStepDetailView.swift in Sources */, CA378F992466567600A58CE0 /* AppState.swift in Sources */, CAD2E7A42449574E00113D76 /* XcodeListView.swift in Sources */, CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */, CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */, - CABFA9CD2592EEEA00380FEE /* Foundation.swift in Sources */, B0403CFC2AD9A6BF00137C09 /* InstalledStateButtons.swift in Sources */, 36741BFF291E50F500A85AAE /* FileError.swift in Sources */, - CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */, E84E4F522B323A5F003F3959 /* CornerRadiusModifier.swift in Sources */, B0403CF22AD934B600137C09 /* CompatibilityView.swift in Sources */, B0403CFE2ADA712C00137C09 /* InfoPaneControls.swift in Sources */, @@ -946,22 +912,18 @@ CAFE4AB425B7D3AF0064FE51 /* AdvancedPreferencePane.swift in Sources */, CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */, E832EAF82B0FBCF4001B570D /* RuntimeInstallationStepDetailView.swift in Sources */, - CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */, BDBAB7452B9FF55800694B0B /* TrailingIconLabelStyle.swift in Sources */, E8D655C0288DD04700A139C2 /* SelectedActionType.swift in Sources */, 36741BFD291E4FDB00A85AAE /* DownloadPreferencePane.swift in Sources */, E84E4F542B333864003F3959 /* PlatformsListView.swift in Sources */, E86671272B309D2F0048559A /* PlatformsView.swift in Sources */, CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */, - CABFA9CE2592EEEA00380FEE /* Version+Xcode.swift in Sources */, CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */, CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, - CA25192A25A9644800F08414 /* XcodeInstallState.swift in Sources */, E8DA461125FAF7FB002E85EF /* NotificationsView.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, E81D7EA02805250100A205FC /* Collection+.swift in Sources */, E8B20CBF2A2EDEC20057D816 /* SDKs+Xcode.swift in Sources */, - CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */, CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */, E84B7D0D2B296A8900DBDA2B /* NavigationSplitViewWrapper.swift in Sources */, CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, @@ -970,7 +932,6 @@ CAC9F92D25BCDA4400B4965F /* HelperInstallState.swift in Sources */, E87DD6EB25D053FA00D86808 /* Progress+.swift in Sources */, CAC281CD259F97FA00B8AB0B /* ObservingProgressIndicator.swift in Sources */, - CABFA9C22592EEEA00380FEE /* Publisher+Resumable.swift in Sources */, B0C6AD0B2AD9178E00E64698 /* IdenticalBuildView.swift in Sources */, CAFBDC68259A308B003DCC5A /* InfoPane.swift in Sources */, B0403CF82AD991F800137C09 /* UnselectedView.swift in Sources */, @@ -979,7 +940,6 @@ CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */, CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, CAFFFED8259CDA5000903F81 /* XcodeListViewRow.swift in Sources */, - CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */, B0403CFA2AD9942A00137C09 /* NotInstalledStateButtons.swift in Sources */, CAE424B4259A764700B8B246 /* AppState+Install.swift in Sources */, CAE42487259A68A300B8B246 /* XcodeListCategory.swift in Sources */, @@ -987,7 +947,6 @@ B0403CF62AD9849E00137C09 /* CompilersView.swift in Sources */, E8977EA325C11E1500835F80 /* PreferencesView.swift in Sources */, CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */, - CABFA9C92592EEEA00380FEE /* URLRequest+Apple.swift in Sources */, CABFAA432593104F00380FEE /* AboutView.swift in Sources */, E8D0296F284B029800647641 /* BottomStatusBar.swift in Sources */, E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */, @@ -999,7 +958,6 @@ E89342FA25EDCC17007CF557 /* NotificationManager.swift in Sources */, CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */, CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */, - CA9FF88125955C7000E47BAF /* AvailableXcode.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1120,7 +1078,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Test; }; @@ -1143,7 +1101,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Xcodes.app/Contents/MacOS/Xcodes"; }; name = Test; @@ -1169,7 +1127,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "com.xcodesorg.xcodesapp.Helper/com.xcodesorg.xcodesapp.Helper-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1196,7 +1154,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "com.xcodesorg.xcodesapp.Helper/com.xcodesorg.xcodesapp.Helper-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Test; }; @@ -1222,7 +1180,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "com.xcodesorg.xcodesapp.Helper/com.xcodesorg.xcodesapp.Helper-Bridging-Header.h"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -1372,7 +1330,7 @@ MARKETING_VERSION = 3.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1401,7 +1359,7 @@ MARKETING_VERSION = 3.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -1422,7 +1380,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = com.robotsandpencils.XcodesAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Xcodes.app/Contents/MacOS/Xcodes"; }; name = Debug; @@ -1445,7 +1403,7 @@ MACOSX_DEPLOYMENT_TARGET = 13.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp.XcodesAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Xcodes.app/Contents/MacOS/Xcodes"; }; name = Release; @@ -1496,14 +1454,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 33027E282CA8BB5800CB387C /* XCRemoteSwiftPackageReference "LibFido2Swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/kinoroy/LibFido2Swift"; - requirement = { - branch = main; - kind = branch; - }; - }; CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/RobotsAndPencils/ErrorHandling"; @@ -1544,14 +1494,6 @@ minimumVersion = 1.0.1; }; }; - CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/groue/CombineExpectations"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.6.0; - }; - }; E689540125BE8C64000EBCEA /* XCRemoteSwiftPackageReference "DockProgress" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sindresorhus/DockProgress"; @@ -1586,7 +1528,7 @@ }; E8FD5725291EE4AC001E004C /* XCRemoteSwiftPackageReference "AsyncHTTPNetworkService" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService"; + repositoryURL = "https://github.com/XcodesOrg/AsyncHTTPNetworkService"; requirement = { branch = main; kind = branch; @@ -1595,14 +1537,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 334A932B2CA885A400A5E079 /* LibFido2Swift */ = { - isa = XCSwiftPackageProductDependency; - productName = LibFido2Swift; - }; - CAA1CB2C255A5262003FD669 /* AppleAPI */ = { - isa = XCSwiftPackageProductDependency; - productName = AppleAPI; - }; CAA858CC25A3D8BC00ACF8C0 /* ErrorHandling */ = { isa = XCSwiftPackageProductDependency; package = CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */; @@ -1628,11 +1562,6 @@ package = CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */; productName = LegibleError; }; - CAC28187259EE27200B8AB0B /* CombineExpectations */ = { - isa = XCSwiftPackageProductDependency; - package = CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */; - productName = CombineExpectations; - }; E689540225BE8C64000EBCEA /* DockProgress */ = { isa = XCSwiftPackageProductDependency; package = E689540125BE8C64000EBCEA /* XCRemoteSwiftPackageReference "DockProgress" */; @@ -1652,6 +1581,14 @@ isa = XCSwiftPackageProductDependency; productName = SRP; }; + E89CBD372D5FAB950037ED95 /* XcodesLoginKit */ = { + isa = XCSwiftPackageProductDependency; + productName = XcodesLoginKit; + }; + E89CBD392D5FB8920037ED95 /* XcodesLoginKitSecurityKey */ = { + isa = XCSwiftPackageProductDependency; + productName = XcodesLoginKitSecurityKey; + }; E8C0EB19291EF43E0081528A /* XcodesKit */ = { isa = XCSwiftPackageProductDependency; productName = XcodesKit; diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 02c22651..28600d14 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -3,10 +3,10 @@ "pins": [ { "package": "AsyncNetworkService", - "repositoryURL": "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService", + "repositoryURL": "https://github.com/XcodesOrg/AsyncHTTPNetworkService", "state": { "branch": "main", - "revision": "97770856c4e429f880d4b4dd68cfaf286dc00c30", + "revision": "12e225af8b5dc25afcfabfcf582a165b0581ab19", "version": null } }, @@ -19,15 +19,6 @@ "version": "2.0.2" } }, - { - "package": "CombineExpectations", - "repositoryURL": "https://github.com/groue/CombineExpectations", - "state": { - "branch": null, - "revision": "989a92221899929ab8347a5878aa2b16db8b81ca", - "version": "0.6.0" - } - }, { "package": "DockProgress", "repositoryURL": "https://github.com/sindresorhus/DockProgress", @@ -68,9 +59,9 @@ "package": "LibFido2Swift", "repositoryURL": "https://github.com/kinoroy/LibFido2Swift", "state": { - "branch": "main", + "branch": null, "revision": "b87a93300c5b35307c9f26ae490963196bd927f1", - "version": null + "version": "0.1.5" } }, { @@ -135,6 +126,15 @@ "revision": "087c91fedc110f9f833b14ef4c32745dabca8913", "version": "1.0.3" } + }, + { + "package": "Yams", + "repositoryURL": "https://github.com/jpsim/Yams", + "state": { + "branch": null, + "revision": "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3", + "version": "5.0.6" + } } ] }, diff --git a/Xcodes/AppleAPI/.gitignore b/Xcodes/AppleAPI/.gitignore deleted file mode 100644 index 95c43209..00000000 --- a/Xcodes/AppleAPI/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ diff --git a/Xcodes/AppleAPI/Package.swift b/Xcodes/AppleAPI/Package.swift deleted file mode 100644 index f3eb5b75..00000000 --- a/Xcodes/AppleAPI/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// swift-tools-version:5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "AppleAPI", - platforms: [.macOS(.v11)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "AppleAPI", - targets: ["AppleAPI"]), - ], - dependencies: [ - .package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main") - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "AppleAPI", - dependencies: [.product(name: "SRP", package: "swift-srp")]), - .testTarget( - name: "AppleAPITests", - dependencies: ["AppleAPI"]), - ] -) diff --git a/Xcodes/AppleAPI/README.md b/Xcodes/AppleAPI/README.md deleted file mode 100644 index 158b3f2f..00000000 --- a/Xcodes/AppleAPI/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# AppleAPI - -A description of this package. diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift deleted file mode 100644 index 1e8e4735..00000000 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift +++ /dev/null @@ -1,606 +0,0 @@ -import Foundation -import Combine -import SRP -import Crypto -import CommonCrypto - - -public class Client { - private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] - - public init() {} - - // MARK: - Login - - public func srpLogin(accountName: String, password: String) -> AnyPublisher { - var serviceKey: String! - - let client = SRPClient(configuration: SRPConfiguration(.N2048)) - let clientKeys = client.generateKeys() - let a = clientKeys.public - - return Current.network.dataTask(with: URLRequest.itcServiceKey) - .map(\.data) - .decode(type: ServiceKeyResponse.self, decoder: JSONDecoder()) - .flatMap { serviceKeyResponse -> AnyPublisher<(String, String), Swift.Error> in - serviceKey = serviceKeyResponse.authServiceKey - - // Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360 - // On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow - // Without this addition, Apple ID's would get set to locked - return self.loadHashcash(accountName: accountName, serviceKey: serviceKey) - .map { return (serviceKey, $0)} - .eraseToAnyPublisher() - } - .flatMap { (serviceKey, hashcash) -> AnyPublisher<(String, String, ServerSRPInitResponse), Swift.Error> in - - return Current.network.dataTask(with: URLRequest.SRPInit(serviceKey: serviceKey, a: Data(a.bytes).base64EncodedString(), accountName: accountName)) - .map(\.data) - .decode(type: ServerSRPInitResponse.self, decoder: JSONDecoder()) - .map { return (serviceKey, hashcash, $0) } - .eraseToAnyPublisher() - } - .flatMap { (serviceKey, hashcash, srpInit) -> AnyPublisher in - guard let decodedB = Data(base64Encoded: srpInit.b) else { - return Fail(error: AuthenticationError.srpInvalidPublicKey) - .eraseToAnyPublisher() - } - - guard let decodedSalt = Data(base64Encoded: srpInit.salt) else { - return Fail(error: AuthenticationError.srpInvalidPublicKey) - .eraseToAnyPublisher() - } - - let iterations = srpInit.iteration - - do { - guard let encryptedPassword = self.pbkdf2(password: password, saltData: decodedSalt, keyByteCount: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), rounds: iterations, protocol: srpInit.protocol) else { - return Fail(error: AuthenticationError.srpInvalidPublicKey) - .eraseToAnyPublisher() - } - - let sharedSecret = try client.calculateSharedSecret(password: encryptedPassword, salt: [UInt8](decodedSalt), clientKeys: clientKeys, serverPublicKey: .init([UInt8](decodedB))) - - let m1 = client.calculateClientProof(username: accountName, salt: [UInt8](decodedSalt), clientPublicKey: a, serverPublicKey: .init([UInt8](decodedB)), sharedSecret: .init(sharedSecret.bytes)) - let m2 = client.calculateServerProof(clientPublicKey: a, clientProof: m1, sharedSecret: .init([UInt8](sharedSecret.bytes))) - - return Current.network.dataTask(with: URLRequest.SRPComplete(serviceKey: serviceKey, hashcash: hashcash, accountName: accountName, c: srpInit.c, m1: Data(m1).base64EncodedString(), m2: Data(m2).base64EncodedString())) - .mapError { $0 as Swift.Error } - .eraseToAnyPublisher() - } catch { - return Fail(error: AuthenticationError.srpInvalidPublicKey) - .eraseToAnyPublisher() - } - } - .flatMap { result -> AnyPublisher in - let (data, response) = result - return Just(data) - .decode(type: SignInResponse.self, decoder: JSONDecoder()) - .flatMap { responseBody -> AnyPublisher in - let httpResponse = response as! HTTPURLResponse - - switch httpResponse.statusCode { - case 200: - return Current.network.dataTask(with: URLRequest.olympusSession) - .map { _ in AuthenticationState.authenticated } - .mapError { $0 as Swift.Error } - .eraseToAnyPublisher() - case 401: - return Fail(error: AuthenticationError.invalidUsernameOrPassword(username: accountName)) - .eraseToAnyPublisher() - case 403: - let errorMessage = responseBody.serviceErrors?.first?.description.replacingOccurrences(of: "-20209: ", with: "") ?? "" - return Fail(error: AuthenticationError.accountLocked(errorMessage)) - .eraseToAnyPublisher() - case 409: - return self.handleTwoStepOrFactor(data: data, response: response, serviceKey: serviceKey) - case 412 where Client.authTypes.contains(responseBody.authType ?? ""): - return Fail(error: AuthenticationError.appleIDAndPrivacyAcknowledgementRequired) - .eraseToAnyPublisher() - default: - return Fail(error: AuthenticationError.unexpectedSignInResponse(statusCode: httpResponse.statusCode, - message: responseBody.serviceErrors?.map { $0.description }.joined(separator: ", "))) - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - } - .mapError { $0 as Swift.Error } - .eraseToAnyPublisher() - } - - func loadHashcash(accountName: String, serviceKey: String) -> AnyPublisher { - - Result { - try URLRequest.federate(account: accountName, serviceKey: serviceKey) - } - .publisher - .flatMap { request in - Current.network.dataTask(with: request) - .mapError { $0 as Error } - .tryMap { (data, response) throws -> (String) in - guard let urlResponse = response as? HTTPURLResponse else { - throw AuthenticationError.invalidSession - } - switch urlResponse.statusCode { - case 200..<300: - - let httpResponse = response as! HTTPURLResponse - guard let bitsString = httpResponse.allHeaderFields["X-Apple-HC-Bits"] as? String, let bits = UInt(bitsString) else { - throw AuthenticationError.invalidHashcash - } - guard let challenge = httpResponse.allHeaderFields["X-Apple-HC-Challenge"] as? String else { - throw AuthenticationError.invalidHashcash - } - guard let hashcash = Hashcash().mint(resource: challenge, bits: bits) else { - throw AuthenticationError.invalidHashcash - } - return (hashcash) - case 400, 401: - throw AuthenticationError.invalidHashcash - case let code: - throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse) - } - } - } - .eraseToAnyPublisher() - - } - - func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher { - let httpResponse = response as! HTTPURLResponse - let sessionID = (httpResponse.allHeaderFields["X-Apple-ID-Session-Id"] as! String) - let scnt = (httpResponse.allHeaderFields["scnt"] as! String) - - return Current.network.dataTask(with: URLRequest.authOptions(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) - .map(\.data) - .decode(type: AuthOptionsResponse.self, decoder: JSONDecoder()) - .flatMap { authOptions -> AnyPublisher in - switch authOptions.kind { - case .twoStep: - return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication) - .eraseToAnyPublisher() - case .twoFactor, .securityKey: - return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) - .eraseToAnyPublisher() - case .unknown: - let possibleResponseString = String(data: data, encoding: .utf8) - return Fail(error: AuthenticationError.accountUsesUnknownAuthenticationKind(possibleResponseString)) - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - } - - func handleTwoFactor(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> AnyPublisher { - let option: TwoFactorOption - - // SMS was sent automatically - if authOptions.smsAutomaticallySent { - option = .smsSent(authOptions.trustedPhoneNumbers!.first!) - // SMS wasn't sent automatically because user needs to choose a phone to send to - } else if authOptions.canFallBackToSMS { - option = .smsPendingChoice - // Code is shown on trusted devices - } else if authOptions.fsaChallenge != nil { - option = .securityKey - // User needs to use a physical security key to respond to the challenge - } else { - option = .codeSent - } - - let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) - return Just(AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - // MARK: - Continue 2FA - - public func requestSMSSecurityCode(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) -> AnyPublisher { - Result { - try URLRequest.requestSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, trustedPhoneID: trustedPhoneNumber.id) - } - .publisher - .flatMap { request in - Current.network.dataTask(with: request) - .mapError { $0 as Error } - } - .map { _ in AuthenticationState.waitingForSecondFactor(.smsSent(trustedPhoneNumber), authOptions, sessionData) } - .eraseToAnyPublisher() - } - - public func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) -> AnyPublisher { - Result { - try URLRequest.submitSecurityCode(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, code: code) - } - .publisher - .flatMap { request in - Current.network.dataTask(with: request) - .mapError { $0 as Error } - .tryMap { (data, response) throws -> (Data, URLResponse) in - guard let urlResponse = response as? HTTPURLResponse else { return (data, response) } - switch urlResponse.statusCode { - case 200..<300: - return (data, urlResponse) - case 400, 401: - throw AuthenticationError.incorrectSecurityCode - case 412: - throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired - case let code: - throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse) - } - } - .flatMap { (data, response) -> AnyPublisher in - self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) - } - } - .eraseToAnyPublisher() - } - - public func submitChallenge(response: Data, sessionData: AppleSessionData) -> AnyPublisher { - Result { - URLRequest.respondToChallenge(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, response: response) - } - .publisher - .flatMap { request in - Current.network.dataTask(with: request) - .mapError { $0 as Error } - .tryMap { (data, response) throws -> (Data, URLResponse) in - guard let urlResponse = response as? HTTPURLResponse else { return (data, response) } - switch urlResponse.statusCode { - case 200..<300: - return (data, urlResponse) - case 400, 401: - throw AuthenticationError.incorrectSecurityCode - case 412: - throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired - case let code: - throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse) - } - } - .flatMap { (data, response) -> AnyPublisher in - self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) - } - }.eraseToAnyPublisher() - } - - // MARK: - Session - - /// Use the olympus session endpoint to see if the existing session is still valid - public func validateSession() -> AnyPublisher { - return Current.network.dataTask(with: URLRequest.olympusSession) - .tryMap { result -> Data in - let httpResponse = result.response as! HTTPURLResponse - if httpResponse.statusCode == 401 { - throw AuthenticationError.notAuthorized - } - - return result.data - } - .decode(type: AppleSession.self, decoder: JSONDecoder()) - .tryMap { session in - // A user that is a non-paid Apple Developer will have a provider == nil - // Those users can still download Xcode. - // Non Apple Developers will get caught in the download as invalid -// if session.provider == nil { -// throw AuthenticationError.notDeveloperAppleId -// } - } - .eraseToAnyPublisher() - } - - func updateSession(serviceKey: String, sessionID: String, scnt: String) -> AnyPublisher { - return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) - .flatMap { (data, response) in - Current.network.dataTask(with: URLRequest.olympusSession) - .map { _ in AuthenticationState.authenticated } - } - .mapError { $0 as Error } - .eraseToAnyPublisher() - } - - func sha256(data : Data) -> Data { - var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) - data.withUnsafeBytes { - _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) - } - return Data(hash) - } - - private func pbkdf2(password: String, saltData: Data, keyByteCount: Int, prf: CCPseudoRandomAlgorithm, rounds: Int, protocol srpProtocol: SRPProtocol) -> Data? { - guard let passwordData = password.data(using: .utf8) else { return nil } - let hashedPasswordDataRaw = sha256(data: passwordData) - let hashedPasswordData = switch srpProtocol { - case .s2k: hashedPasswordDataRaw - // the legacy s2k_fo protocol requires hex-encoding the digest before performing PBKDF2. - case .s2k_fo: Data(hashedPasswordDataRaw.hexEncodedString().lowercased().utf8) - } - - var derivedKeyData = Data(repeating: 0, count: keyByteCount) - let derivedCount = derivedKeyData.count - let derivationStatus: Int32 = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in - let keyBuffer: UnsafeMutablePointer = - derivedKeyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - return saltData.withUnsafeBytes { saltBytes -> Int32 in - let saltBuffer: UnsafePointer = saltBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - return hashedPasswordData.withUnsafeBytes { hashedPasswordBytes -> Int32 in - let passwordBuffer: UnsafePointer = hashedPasswordBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - return CCKeyDerivationPBKDF( - CCPBKDFAlgorithm(kCCPBKDF2), - passwordBuffer, - hashedPasswordData.count, - saltBuffer, - saltData.count, - prf, - UInt32(rounds), - keyBuffer, - derivedCount) - } - } - } - return derivationStatus == kCCSuccess ? derivedKeyData : nil - } - -} - -// MARK: - Types - -public enum AuthenticationState: Equatable { - case unauthenticated - case waitingForSecondFactor(TwoFactorOption, AuthOptionsResponse, AppleSessionData) - case authenticated - case notAppleDeveloper -} - -public enum AuthenticationError: Swift.Error, LocalizedError, Equatable { - case invalidSession - case invalidHashcash - case invalidUsernameOrPassword(username: String) - case incorrectSecurityCode - case unexpectedSignInResponse(statusCode: Int, message: String?) - case appleIDAndPrivacyAcknowledgementRequired - case accountUsesTwoStepAuthentication - case accountUsesUnknownAuthenticationKind(String?) - case accountLocked(String) - case badStatusCode(statusCode: Int, data: Data, response: HTTPURLResponse) - case notDeveloperAppleId - case notAuthorized - case invalidResult(resultString: String?) - case srpInvalidPublicKey - - public var errorDescription: String? { - switch self { - case .invalidSession: - return "Your authentication session is invalid. Try signing in again." - case .invalidHashcash: - return "Could not create a hashcash for the session." - case .invalidUsernameOrPassword: - return "Invalid username and password combination." - case .incorrectSecurityCode: - return "The code that was entered is incorrect." - case let .unexpectedSignInResponse(statusCode, message): - return """ - Received an unexpected sign in response. If you continue to have problems, please submit a bug report in the Help menu and include the following information: - - Status code: \(statusCode) - \(message != nil ? ("Message: " + message!) : "") - """ - case .appleIDAndPrivacyAcknowledgementRequired: - return "You must sign in to https://appstoreconnect.apple.com and acknowledge the Apple ID & Privacy agreement." - case .accountUsesTwoStepAuthentication: - return "Received a response from Apple that indicates this account has two-step authentication enabled. xcodes currently only supports the newer two-factor authentication, though. Please consider upgrading to two-factor authentication, or explain why this isn't an option for you by making a new feature request in the Help menu." - case .accountUsesUnknownAuthenticationKind: - return "Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response. If you continue to have problems, please submit a bug report in the Help menu." - case let .accountLocked(message): - return message - case let .badStatusCode(statusCode, _, _): - return "Received an unexpected status code: \(statusCode). If you continue to have problems, please submit a bug report in the Help menu." - case .notDeveloperAppleId: - return "You are not registered as an Apple Developer. Please visit Apple Developer Registration. https://developer.apple.com/register/" - case .notAuthorized: - return "You are not authorized. Please Sign in with your Apple ID first." - case let .invalidResult(resultString): - return resultString ?? "If you continue to have problems, please submit a bug report in the Help menu." - case .srpInvalidPublicKey: - return "Invalid Key" - } - } -} - -public struct AppleSessionData: Equatable, Identifiable { - public let serviceKey: String - public let sessionID: String - public let scnt: String - - public var id: String { sessionID } - - public init(serviceKey: String, sessionID: String, scnt: String) { - self.serviceKey = serviceKey - self.sessionID = sessionID - self.scnt = scnt - } -} - -struct ServiceKeyResponse: Decodable { - let authServiceKey: String -} - -struct SignInResponse: Decodable { - let authType: String? - let serviceErrors: [ServiceError]? - - struct ServiceError: Decodable, CustomStringConvertible { - let code: String - let message: String - - var description: String { - return "\(code): \(message)" - } - } -} - -public enum TwoFactorOption: Equatable { - case smsSent(AuthOptionsResponse.TrustedPhoneNumber) - case codeSent - case smsPendingChoice - case securityKey -} - -public struct FSAChallenge: Equatable, Decodable { - public let challenge: String - public let keyHandles: [String] - public let allowedCredentials: String -} - -public struct AuthOptionsResponse: Equatable, Decodable { - public let trustedPhoneNumbers: [TrustedPhoneNumber]? - public let trustedDevices: [TrustedDevice]? - public let securityCode: SecurityCodeInfo? - public let noTrustedDevices: Bool? - public let serviceErrors: [ServiceError]? - public let fsaChallenge: FSAChallenge? - - public init( - trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?, - trustedDevices: [AuthOptionsResponse.TrustedDevice]?, - securityCode: AuthOptionsResponse.SecurityCodeInfo, - noTrustedDevices: Bool? = nil, - serviceErrors: [ServiceError]? = nil, - fsaChallenge: FSAChallenge? = nil - ) { - self.trustedPhoneNumbers = trustedPhoneNumbers - self.trustedDevices = trustedDevices - self.securityCode = securityCode - self.noTrustedDevices = noTrustedDevices - self.serviceErrors = serviceErrors - self.fsaChallenge = fsaChallenge - } - - public var kind: Kind { - if trustedDevices != nil { - return .twoStep - } else if trustedPhoneNumbers != nil { - return .twoFactor - } else if fsaChallenge != nil { - return .securityKey - } else { - return .unknown - } - } - - // One time with a new testing account I had a response where noTrustedDevices was nil, but the account didn't have any trusted devices. - // This should have been a situation where an SMS security code was sent automatically. - // This resolved itself either after some time passed, or by signing into appleid.apple.com with the account. - // Not sure if it's worth explicitly handling this case or if it'll be really rare. - public var canFallBackToSMS: Bool { - noTrustedDevices == true - } - - public var smsAutomaticallySent: Bool { - trustedPhoneNumbers?.count == 1 && canFallBackToSMS - } - - public struct TrustedPhoneNumber: Equatable, Decodable, Identifiable { - public let id: Int - public let numberWithDialCode: String - - public init(id: Int, numberWithDialCode: String) { - self.id = id - self.numberWithDialCode = numberWithDialCode - } - } - - public struct TrustedDevice: Equatable, Decodable { - public let id: String - public let name: String - public let modelName: String - - public init(id: String, name: String, modelName: String) { - self.id = id - self.name = name - self.modelName = modelName - } - } - - public struct SecurityCodeInfo: Equatable, Decodable { - public let length: Int - public let tooManyCodesSent: Bool - public let tooManyCodesValidated: Bool - public let securityCodeLocked: Bool - public let securityCodeCooldown: Bool - - public init( - length: Int, - tooManyCodesSent: Bool = false, - tooManyCodesValidated: Bool = false, - securityCodeLocked: Bool = false, - securityCodeCooldown: Bool = false - ) { - self.length = length - self.tooManyCodesSent = tooManyCodesSent - self.tooManyCodesValidated = tooManyCodesValidated - self.securityCodeLocked = securityCodeLocked - self.securityCodeCooldown = securityCodeCooldown - } - } - - public enum Kind: Equatable { - case twoStep, twoFactor, securityKey, unknown - } -} - -public struct ServiceError: Decodable, Equatable { - let code: String - let message: String -} - -public enum SecurityCode { - case device(code: String) - case sms(code: String, phoneNumberId: Int) - - var urlPathComponent: String { - switch self { - case .device: return "trusteddevice" - case .sms: return "phone" - } - } -} - -/// Object returned from olympus/v1/session -/// Used to check Provider, and show name -/// If Provider is nil, we can assume the Apple User is NOT an Apple Developer and can't download Xcode. -public struct AppleSession: Decodable, Equatable { - public let user: AppleUser - public let provider: AppleProvider? -} - -public struct AppleProvider: Decodable, Equatable { - public let providerId: Int - public let name: String -} - -public struct AppleUser: Decodable, Equatable { - public let fullName: String -} - -public struct ServerSRPInitResponse: Decodable { - let iteration: Int - let salt: String - let b: String - let c: String - let `protocol`: SRPProtocol -} - - - -extension String { - func base64ToU8Array() -> Data { - return Data(base64Encoded: self) ?? Data() - } -} -extension Data { - func hexEncodedString() -> String { - return map { String(format: "%02hhx", $0) }.joined() - } -} diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift deleted file mode 100644 index 53c5fc50..00000000 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Environment.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -import Combine - -/** - Lightweight dependency injection using global mutable state :P - - - SeeAlso: https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy - - SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable - - SeeAlso: https://vimeo.com/291588126 - */ -public struct Environment { - public var network = Network() -} - -public var Current = Environment() - -public struct Network { - public var session = URLSession.shared - - public var dataTask: (URLRequest) -> URLSession.DataTaskPublisher = { Current.network.session.dataTaskPublisher(for: $0) } - public func dataTask(with request: URLRequest) -> URLSession.DataTaskPublisher { - dataTask(request) - } -} diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift deleted file mode 100644 index 135f1ea5..00000000 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// Hashcash.swift -// -// -// Created by Matt Kiazyk on 2023-02-23. -// - -import Foundation -import CryptoKit -import CommonCrypto - -/* -# This App Store Connect hashcash spec was generously donated by... - # - # __ _ - # __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___ - # / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __| - # | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \ - # \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/ - # |_| |_| |___/ - # - # -*/ -public struct Hashcash { - /// A function to returned a minted hash, using a bit and resource string - /// - /** - X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373 - ^ ^ ^ ^ ^ - | | | | +-- Counter - | | | +-- Resource - | | +-- Date YYMMDD[hhmm[ss]] - | +-- Bits (number of leading zeros) - +-- Version - - We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention. - 1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all. - 2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation. - - Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits - We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits - */ - /// - Parameters: - /// - resource: a string to be used for minting - /// - bits: grabbed from `X-Apple-HC-Bits` header - /// - date: Default uses Date() otherwise used for testing to check. - /// - Returns: A String hash to use in `X-Apple-HC` header on /signin - public func mint(resource: String, - bits: UInt = 10, - date: String? = nil) -> String? { - - let ver = "1" - - var ts: String - if let date = date { - ts = date - } else { - let formatter = DateFormatter() - formatter.dateFormat = "yyMMddHHmmss" - ts = formatter.string(from: Date()) - } - - let challenge = "\(ver):\(bits):\(ts):\(resource):" - - var counter = 0 - - while true { - guard let digest = ("\(challenge):\(counter)").sha1 else { - print("ERROR: Can't generate SHA1 digest") - return nil - } - - if digest == bits { - return "\(challenge):\(counter)" - } - counter += 1 - } - } -} - -extension String { - var sha1: Int? { - - let data = Data(self.utf8) - var digest = [UInt8](repeating: 0, count:Int(CC_SHA1_DIGEST_LENGTH)) - data.withUnsafeBytes { - _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest) - } - let bigEndianValue = digest.withUnsafeBufferPointer { - ($0.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: 1) { $0 }) - }.pointee - let value = UInt32(bigEndian: bigEndianValue) - return value.leadingZeroBitCount - } -} - diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift b/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift deleted file mode 100644 index 14601503..00000000 --- a/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift +++ /dev/null @@ -1,204 +0,0 @@ -import Foundation - -public extension URL { - static let itcServiceKey = URL(string: "https://appstoreconnect.apple.com/olympus/v1/app/config?hostname=itunesconnect.apple.com")! - static let signIn = URL(string: "https://idmsa.apple.com/appleauth/auth/signin")! - static let authOptions = URL(string: "https://idmsa.apple.com/appleauth/auth")! - static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")! - static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! } - static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")! - static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")! - static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")! - static let keyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")! - - static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")! - static let srpComplete = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/complete?isRememberMeEnabled=false")! - -} - -public extension URLRequest { - static var itcServiceKey: URLRequest { - return URLRequest(url: .itcServiceKey) - } - - static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest { - struct Body: Encodable { - let accountName: String - let password: String - let rememberMe = true - } - - var request = URLRequest(url: .signIn) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash - request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript" - request.httpMethod = "POST" - request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password)) - return request - } - - static func authOptions(serviceKey: String, sessionID: String, scnt: String) -> URLRequest { - var request = URLRequest(url: .authOptions) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["accept"] = "application/json" - return request - } - - static func requestSecurityCode(serviceKey: String, sessionID: String, scnt: String, trustedPhoneID: Int) throws -> URLRequest { - struct Body: Encodable { - let phoneNumber: PhoneNumber - let mode = "sms" - - struct PhoneNumber: Encodable { - let id: Int - } - } - - var request = URLRequest(url: .requestSecurityCode) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["accept"] = "application/json" - request.httpMethod = "PUT" - request.httpBody = try JSONEncoder().encode(Body(phoneNumber: .init(id: trustedPhoneID))) - return request - } - - static func submitSecurityCode(serviceKey: String, sessionID: String, scnt: String, code: SecurityCode) throws -> URLRequest { - struct DeviceSecurityCodeRequest: Encodable { - let securityCode: SecurityCode - - struct SecurityCode: Encodable { - let code: String - } - } - - struct SMSSecurityCodeRequest: Encodable { - let securityCode: SecurityCode - let phoneNumber: PhoneNumber - let mode = "sms" - - struct SecurityCode: Encodable { - let code: String - } - struct PhoneNumber: Encodable { - let id: Int - } - } - - var request = URLRequest(url: .submitSecurityCode(code)) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.httpMethod = "POST" - switch code { - case .device(let code): - request.httpBody = try JSONEncoder().encode(DeviceSecurityCodeRequest(securityCode: .init(code: code))) - case .sms(let code, let phoneNumberId): - request.httpBody = try JSONEncoder().encode(SMSSecurityCodeRequest(securityCode: .init(code: code), phoneNumber: .init(id: phoneNumberId))) - } - return request - } - - static func respondToChallenge(serviceKey: String, sessionID: String, scnt: String, response: Data) -> URLRequest { - var request = URLRequest(url: .keyAuth) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.httpMethod = "POST" - request.httpBody = response - return request - } - - static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest { - var request = URLRequest(url: .trust) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["scnt"] = scnt - request.allHTTPHeaderFields?["Accept"] = "application/json" - return request - } - - static var olympusSession: URLRequest { - return URLRequest(url: .olympusSession) - } - - static func federate(account: String, serviceKey: String) throws -> URLRequest { - struct FederateRequest: Encodable { - let accountName: String - let rememberMe: Bool - } - var request = URLRequest(url: .signIn) - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.httpMethod = "GET" - -// let encoder = JSONEncoder() -// encoder.outputFormatting = .withoutEscapingSlashes -// request.httpBody = try encoder.encode(FederateRequest(accountName: account, rememberMe: true)) - - return request - } - - static func SRPInit(serviceKey: String, a: String, accountName: String) -> URLRequest { - struct ServerSRPInitRequest: Encodable { - public let a: String - public let accountName: String - public let protocols: [SRPProtocol] - } - - var request = URLRequest(url: .srpInit) - request.httpMethod = "POST" - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - - request.httpBody = try? JSONEncoder().encode(ServerSRPInitRequest(a: a, accountName: accountName, protocols: [.s2k, .s2k_fo])) - return request - } - - static func SRPComplete(serviceKey: String, hashcash: String, accountName: String, c: String, m1: String, m2: String) -> URLRequest { - struct ServerSRPCompleteRequest: Encodable { - let accountName: String - let c: String - let m1: String - let m2: String - let rememberMe: Bool - } - - var request = URLRequest(url: .srpComplete) - request.httpMethod = "POST" - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "application/json" - request.allHTTPHeaderFields?["Content-Type"] = "application/json" - request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" - request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey - request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash - - request.httpBody = try? JSONEncoder().encode(ServerSRPCompleteRequest(accountName: accountName, c: c, m1: m1, m2: m2, rememberMe: false)) - return request - } -} - -public enum SRPProtocol: String, Codable { - case s2k, s2k_fo -} - - diff --git a/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift b/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift deleted file mode 100644 index ecb93e7e..00000000 --- a/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift +++ /dev/null @@ -1,27 +0,0 @@ -import XCTest -@testable import AppleAPI - -final class AppleAPITests: XCTestCase { - - func testValidHashCashMint() { - let bits: UInt = 11 - let resource = "4d74fb15eb23f465f1f6fcbf534e5877" - let testDate = "20230223170600" - - let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate) - XCTAssertEqual(stamp, "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373") - } - func testValidHashCashMint2() { - let bits: UInt = 10 - let resource = "bb63edf88d2f9c39f23eb4d6f0281158" - let testDate = "20230224001754" - - let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate) - XCTAssertEqual(stamp, "1:10:20230224001754:bb63edf88d2f9c39f23eb4d6f0281158::866") - } - - static var allTests = [ - ("testValidHashCashMint", testValidHashCashMint), - ("testValidHashCashMint2", testValidHashCashMint2), - ] -} diff --git a/Xcodes/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift b/Xcodes/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift deleted file mode 100644 index 97d152cf..00000000 --- a/Xcodes/AppleAPI/Tests/AppleAPITests/XCTestManifests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(AppleAPITests.allTests), - ] -} -#endif diff --git a/Xcodes/AppleAPI/Tests/LinuxMain.swift b/Xcodes/AppleAPI/Tests/LinuxMain.swift deleted file mode 100644 index 9c8128cf..00000000 --- a/Xcodes/AppleAPI/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import AppleAPITests - -var tests = [XCTestCaseEntry]() -tests += AppleAPITests.allTests() -XCTMain(tests) diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 5e2a0742..a116556e 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -1,530 +1,471 @@ -import Combine import Foundation -import Path -import AppleAPI -import Version +@preconcurrency import Path +@preconcurrency import Version import LegibleError import os.log import DockProgress import XcodesKit +import XcodesLoginKit /// Downloads and installs Xcodes extension AppState { - + // check to see if we should auto install for the user public func autoInstallIfNeeded() { guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return } - if autoInstallType == .none { return } - - // get newest xcode version - guard let newestXcode = allXcodes.first, newestXcode.installState == .notInstalled else { - Logger.appState.info("User has latest Xcode already installed") + let decision = XcodeAutoInstallService().decision( + autoInstallationType: autoInstallType, + xcodes: allXcodes.map(\.listItem) + ) + + switch decision { + case .disabled: return - } - - if autoInstallType == .newestBeta { + case .alreadyInstalled: + Logger.appState.info("User has latest Xcode already installed") + case let .installNewestBeta(id): Logger.appState.info("Auto installing newest Xcode Beta") - // install it, as user doesn't have it installed and it's either latest beta or latest release - checkMinVersionAndInstall(id: newestXcode.id) - } else if autoInstallType == .newestVersion && newestXcode.version.isNotPrerelease { + checkMinVersionAndInstall(id: id) + case let .installNewestVersion(id): Logger.appState.info("Auto installing newest Xcode") - checkMinVersionAndInstall(id: newestXcode.id) - } else { + checkMinVersionAndInstall(id: id) + case .noNewVersion: Logger.appState.info("No new Xcodes version found to auto install") } } - - public func install(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher { - install(installationType, downloader: downloader, attemptNumber: 0) - .map { _ in Void() } - .eraseToAnyPublisher() - } - - private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher { - - Logger.appState.info("Using \(downloader) downloader") - - setupDockProgress() - - return validateSession() - .flatMap { _ in - self.getXcodeArchive(installationType, downloader: downloader) - } - .flatMap { xcode, url -> AnyPublisher in - self.installArchivedXcode(xcode, at: url) - } - .catch { error -> AnyPublisher in - self.resetDockProgressTracking() - - switch error { - case InstallationError.damagedXIP(let damagedXIPURL): - guard attemptNumber < 1 else { return Fail(error: error).eraseToAnyPublisher() } - - switch installationType { - case .version: - // If the XIP was just downloaded, remove it and try to recover. - do { - Logger.appState.error("\(error.legibleLocalizedDescription)") - Logger.appState.info("Removing damaged XIP and re-attempting installation.") - try Current.files.removeItem(at: damagedXIPURL) - return self.install(installationType, downloader: downloader, attemptNumber: attemptNumber + 1) - .eraseToAnyPublisher() - } catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - } - default: - return Fail(error: error) - .eraseToAnyPublisher() + + func installAsync(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) async throws -> InstalledXcode { + try await xcodeInstallRetryService.install( + attemptNumber: attemptNumber, + shouldRetryAfterDamagedArchive: installationType.shouldRetryAfterDamagedArchive, + attempt: { @MainActor _ in + Logger.appState.info("Using \(downloader) downloader") + setupDockProgress() + + try await validateSessionAsync() + let (xcode, url) = try await getXcodeArchiveAsync(installationType, downloader: downloader) + try Task.checkCancellation() + let installedXcode = try await installArchivedXcodeAsync(xcode, at: url) + + guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { + return installedXcode } + allXcodes[index].installState = .installed(installedXcode.path) + return installedXcode + }, + onAttemptFailed: { @MainActor _ in + resetDockProgressTracking() + }, + onRetryDamagedArchive: { error, _ in + Logger.appState.error("\(error.legibleLocalizedDescription)") + Logger.appState.info("Removing damaged XIP and re-attempting installation.") } - .handleEvents(receiveOutput: { installedXcode in - DispatchQueue.main.async { - guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { return } - self.allXcodes[index].installState = .installed(installedXcode.path) - } - }) - .eraseToAnyPublisher() + ) + } + + private var xcodeInstallRetryService: XcodeInstallRetryService { + XcodeInstallRetryService( + damagedArchiveURL: { error in + guard case InstallationError.damagedXIP(let url) = error else { return nil } + return url + }, + removeDamagedArchive: { url in + try Current.files.removeItem(at: url) + } + ) } - - private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> { + + private func getXcodeArchiveAsync(_ installationType: InstallationType, downloader: Downloader) async throws -> (AvailableXcode, URL) { switch installationType { case .version(let availableXcode): - if let installedXcode = Current.files.installedXcodes(Path.installDirectory).first(where: { $0.version.isEquivalent(to: availableXcode.version) }) { - return Fail(error: InstallationError.versionAlreadyInstalled(installedXcode)) - .eraseToAnyPublisher() + let resolution = try mapInstallResolutionError { + try XcodeInstallResolutionService().resolve( + .availableXcode(availableXcode), + availableXcodes: [], + installedXcodes: Current.files.installedXcodes(Path.installDirectory), + willInstall: true + ) } - - return downloadXcode(availableXcode: availableXcode, downloader: downloader) + + return try await archive(for: resolution, downloader: downloader) } } - private func downloadXcode(availableXcode: AvailableXcode, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> { - self.downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in - DispatchQueue.main.async { - self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress)) - self.overallProgress.addChild(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight) - } - }) - .map { return (availableXcode, $0) } - .eraseToAnyPublisher() - } - - public func downloadOrUseExistingArchive(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { - // Check to see if the archive is in the expected path in case it was downloaded but failed to install - let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" - // aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete - let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2") - var aria2DownloadIsIncomplete = false - if case .aria2 = downloader, aria2DownloadMetadataPath.exists { - aria2DownloadIsIncomplete = true + private func archive(for resolution: XcodeInstallResolution, downloader: Downloader) async throws -> (AvailableXcode, URL) { + switch resolution { + case let .download(_, .some(availableXcode)): + return try await downloadXcodeAsync(availableXcode: availableXcode, downloader: downloader) + case .download: + throw XcodesKitError("Expected Xcode install resolution to include a selected Xcode") + case let .localArchive(xcode, url): + return (xcode, url) } - if Current.files.fileExistsAtPath(expectedArchivePath.string), aria2DownloadIsIncomplete == false { - Logger.appState.info("Found existing archive that will be used for installation at \(expectedArchivePath).") - return Just(expectedArchivePath.url) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + } + + private func mapInstallResolutionError(_ body: () throws -> T) throws -> T { + do { + return try body() + } catch let error as XcodeInstallResolutionError { + switch error { + case let .invalidVersion(version): + throw InstallationError.invalidVersion(version) + case .noReleaseVersionAvailable: + throw InstallationError.noNonPrereleaseVersionAvailable + case .noPrereleaseVersionAvailable: + throw InstallationError.noPrereleaseVersionAvailable + case let .versionAlreadyInstalled(installedXcode): + throw InstallationError.versionAlreadyInstalled(installedXcode) + } } - else { - let destination = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" - switch downloader { - case .aria2: - let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! - return downloadXcodeWithAria2( - availableXcode, - to: destination, - aria2Path: aria2Path, - progressChanged: progressChanged - ) - case .urlSession: - return downloadXcodeWithURLSession( - availableXcode, - to: destination, + } + + private func downloadXcodeAsync(availableXcode: AvailableXcode, downloader: Downloader) async throws -> (AvailableXcode, URL) { + let expectedInstallationTaskID = installationTaskIDs[availableXcode.xcodeID] + let url = try await downloadOrUseExistingArchiveAsync(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in + Task { @MainActor in + if let expectedInstallationTaskID, self.installationTaskIDs[availableXcode.xcodeID] != expectedInstallationTaskID { + return + } + self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress)) + self.addDockProgressChildIfNeeded(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight) + } + }) + + return (availableXcode, url) + } + + public func downloadOrUseExistingArchiveAsync(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping @Sendable (Progress) -> Void) async throws -> URL { + let url = try await archiveService().archiveURL( + for: XcodeArchive(availableXcode), + downloader: downloader, + progressChanged: progressChanged + ) + Logger.appState.info("Using Xcode archive at \(url.path).") + return url + } + + private func archiveService() -> XcodeArchiveService { + XcodeArchiveService( + applicationSupportPath: Path.xcodesApplicationSupport, + fileExists: { Current.files.fileExistsAtPath($0.string) }, + download: { archive, destination, downloader, progressChanged in + try await self.archiveDownloadStrategyService.download( + archive: archive, + destination: destination, + downloader: downloader, + applicationSupportPath: Path.xcodesApplicationSupport, progressChanged: progressChanged ) } - } + ) } - - public func downloadXcodeWithAria2(_ availableXcode: AvailableXcode, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { - let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: availableXcode.url) ?? [] - - let (progress, publisher) = Current.shell.downloadWithAria2( - aria2Path, - availableXcode.url, - destination, - cookies + + private var archiveDownloadStrategyService: ArchiveDownloadStrategyService { + ArchiveDownloadStrategyService( + archiveDownloadService: archiveDownloadService, + aria2Path: { Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! }, + cookiesForURL: { Current.network.session.configuration.httpCookieStorage?.cookies(for: $0) ?? [] } ) - progressChanged(progress) - - return publisher - .map { _ in destination.url } - .eraseToAnyPublisher() } - public func downloadXcodeWithURLSession(_ availableXcode: AvailableXcode, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { - let resumeDataPath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).resumedata" - let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string) - - return attemptResumableTask(maximumRetryCount: 3) { resumeData -> AnyPublisher in - let (progress, publisher) = Current.network.downloadTask(with: availableXcode.url, - to: destination.url, - resumingWith: resumeData ?? persistedResumeData) - progressChanged(progress) - - return publisher - .map { $0.saveLocation } - .eraseToAnyPublisher() - } - .handleEvents(receiveCompletion: { completion in - self.persistOrCleanUpResumeData(at: resumeDataPath, for: completion) - }) - .eraseToAnyPublisher() + private var archiveDownloadService: ArchiveDownloadService { + ArchiveDownloadService( + aria2Download: { aria2Path, url, destination, cookies in + Current.shell.downloadWithAria2Async(aria2Path, url, destination, cookies) + }, + urlSessionDownload: { url, destination, resumeData in + Current.network.downloadTaskAsync(with: url, to: destination, resumingWith: resumeData) + }, + contentsAtPath: { path in + Current.files.contents(atPath: path) + }, + createFile: { path, data in + Current.files.createFile(atPath: path, contents: data) + }, + removeItem: { try Current.files.removeItem(at: $0) }, + shouldRetry: { error in + error as? AuthenticationError != .notAuthorized + }, + validateResponse: { response in + try ArchiveDownloadService.validateDeveloperDownloadResponse( + response, + unauthorizedError: { AuthenticationError.notAuthorized } + ) + } + ) } - public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher { + public func installArchivedXcodeAsync(_ availableXcode: AvailableXcode, at archiveURL: URL) async throws -> InstalledXcode { unxipProgress.completedUnitCount = 0 - overallProgress.addChild(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight) - + addDockProgressChildIfNeeded(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight) + + let installedXcode: InstalledXcode do { - let destinationURL = Path.installDirectory.join("Xcode-\(availableXcode.version.descriptionWithoutBuildMetadata).app").url - switch archiveURL.pathExtension { - case "xip": - return unarchiveAndMoveXIP(availableXcode: availableXcode, at: archiveURL, to: destinationURL) - .tryMap { xcodeURL throws -> InstalledXcode in - guard - let path = Path(url: xcodeURL), - Current.files.fileExists(atPath: path.string), - let installedXcode = InstalledXcode(path: path) - else { throw InstallationError.failedToMoveXcodeToApplications } - return installedXcode - } - .flatMap { installedXcode -> AnyPublisher in - do { - self.setInstallationStep(of: availableXcode.version, to: .trashingArchive) - try Current.files.trashItem(at: archiveURL) - self.setInstallationStep(of: availableXcode.version, to: .checkingSecurity) - - return self.verifySecurityAssessment(of: installedXcode) - .combineLatest(self.verifySigningCertificate(of: installedXcode.path.url)) - .map { _ in installedXcode } - .eraseToAnyPublisher() - } catch { - return Fail(error: error) - .eraseToAnyPublisher() - } - } - .flatMap { installedXcode -> AnyPublisher in - self.setInstallationStep(of: availableXcode.version, to: .finishing) - - return self.performPostInstallSteps(for: installedXcode) - .map { installedXcode } - // Show post-install errors but don't fail because of them - .handleEvents(receiveCompletion: { [unowned self] completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription) - } - resetDockProgressTracking() - }) - .catch { _ in - Just(installedXcode) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - case "dmg": - throw InstallationError.unsupportedFileFormat(extension: "dmg") - default: - throw InstallationError.unsupportedFileFormat(extension: archiveURL.pathExtension) + installedXcode = try await xcodeArchiveInstallService.installArchivedXcode( + availableXcode, + at: archiveURL, + cleanArchive: { try Current.files.trashItem(at: $0) } + ) { step in + switch step { + case .unarchive(.unarchiving): + await self.setInstallationStep(of: availableXcode.version, to: .unarchiving) + case let .unarchive(.moving(destination)): + await self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination)) + case .cleaningArchive: + await self.setInstallationStep(of: availableXcode.version, to: .trashingArchive) + case .checkingSecurity: + await self.setInstallationStep(of: availableXcode.version, to: .checkingSecurity) + } } } catch { - return Fail(error: error) - .eraseToAnyPublisher() + throw mapXcodeArchiveInstallError(error, availableXcode: availableXcode) + } + + setInstallationStep(of: availableXcode.version, to: .finishing) + do { + try await performPostInstallStepsAsync(for: installedXcode) + } catch { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription) } + resetDockProgressTracking() + + return installedXcode } - func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher { - self.setInstallationStep(of: availableXcode.version, to: .unarchiving) - - return unxipOrUnxipExperiment(source) - .catch { error -> AnyPublisher in - if let executionError = error as? ProcessExecutionError { - if executionError.standardError.contains("damaged and can’t be expanded") { - return Fail(error: InstallationError.damagedXIP(url: source)) - .eraseToAnyPublisher() - } else if executionError.standardError.contains("can’t be expanded because the selected volume doesn’t have enough free space.") { - return Fail(error: InstallationError.notEnoughFreeSpaceToExpandArchive(archivePath: Path(url: source)!, - version: availableXcode.version)) - .eraseToAnyPublisher() - } - } - return Fail(error: error) - .eraseToAnyPublisher() - } - .tryMap { output -> URL in - self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination.path)) + private var xcodeUnarchiveService: XcodeUnarchiveService { + XcodeUnarchiveService( + unarchive: { _ = try await self.unxipOrUnxipExperimentAsync($0) }, + fileExists: { path in Current.files.fileExists(atPath: path) }, + moveItem: { source, destination in try Current.files.moveItem(at: source, to: destination) }, + removeItem: { url in try Current.files.removeItem(at: url) } + ) + } - let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") - let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") - if Current.files.fileExists(atPath: xcodeURL.path) { - try Current.files.moveItem(at: xcodeURL, to: destination) - } - else if Current.files.fileExists(atPath: xcodeBetaURL.path) { - try Current.files.moveItem(at: xcodeBetaURL, to: destination) + private var xcodeArchiveInstallService: XcodeArchiveInstallService { + XcodeArchiveInstallService( + destinationDirectory: .installDirectory, + unarchiveService: xcodeUnarchiveService, + validationService: xcodeValidationService, + fileExists: { path in Current.files.fileExists(atPath: path) }, + makeInstalledXcode: { path in + InstalledXcode( + path: path, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + ) } + ) + } - return destination - } - .handleEvents(receiveCancel: { - if Current.files.fileExists(atPath: source.path) { - try? Current.files.removeItem(source) + private func mapXcodeArchiveInstallError(_ error: Error, availableXcode: AvailableXcode) -> Error { + switch error { + case let error as XcodeArchiveInstallError: + switch error { + case .failedToMoveXcodeToDestination: + return InstallationError.failedToMoveXcodeToApplications + case let .unsupportedFileFormat(fileExtension): + return InstallationError.unsupportedFileFormat(extension: fileExtension) } - if Current.files.fileExists(atPath: destination.path) { - try? Current.files.removeItem(destination) + case let error as XcodeUnarchiveError: + switch error { + case let .damagedXIP(url): + return InstallationError.damagedXIP(url: url) + case let .notEnoughFreeSpaceToExpandArchive(url): + return InstallationError.notEnoughFreeSpaceToExpandArchive( + archivePath: Path(url: url)!, + version: availableXcode.version + ) } - }) - .eraseToAnyPublisher() + case let error as XcodeValidationError: + switch error { + case let .failedSecurityAssessment(xcode, output): + return InstallationError.failedSecurityAssessment(xcode: xcode, output: output) + case let .codesignVerifyFailed(output): + return InstallationError.codesignVerifyFailed(output: output) + case let .unexpectedCodeSigningIdentity(identifier, certificateAuthority): + return InstallationError.unexpectedCodeSigningIdentity( + identifier: identifier, + certificateAuthority: certificateAuthority + ) + } + default: + return error + } } - - func unxipOrUnxipExperiment(_ source: URL) -> AnyPublisher { + + func unxipOrUnxipExperimentAsync(_ source: URL) async throws -> ProcessOutput { if unxipExperiment { // All hard work done by https://github.com/saagarjha/unxip // Compiled to binary with `swiftc -parse-as-library -O unxip.swift` - return Current.shell.unxipExperiment(source) + return try await Current.shell.unxipExperiment(source) } else { - return Current.shell.unxip(source) + return try await Current.shell.unxip(source) } } - public func verifySecurityAssessment(of xcode: InstalledXcode) -> AnyPublisher { - return Current.shell.spctlAssess(xcode.path.url) - .catch { (error: Swift.Error) -> AnyPublisher in - var output = "" - if let executionError = error as? ProcessExecutionError { - output = [executionError.standardOutput, executionError.standardError].joined(separator: "\n") - } - return Fail(error: InstallationError.failedSecurityAssessment(xcode: xcode, output: output)) - .eraseToAnyPublisher() - } - .map { _ in Void() } - .eraseToAnyPublisher() + private var xcodeValidationService: XcodeValidationService { + XcodeValidationService( + assessSecurity: { url in try await Current.shell.spctlAssess(url) }, + verifyCodesign: { url in try await Current.shell.codesignVerify(url) } + ) } - func verifySigningCertificate(of url: URL) -> AnyPublisher { - return Current.shell.codesignVerify(url) - .catch { error -> AnyPublisher in - var output = "" - if let executionError = error as? ProcessExecutionError { - output = [executionError.standardOutput, executionError.standardError].joined(separator: "\n") + // MARK: - Post-Install + + /// Attemps to install the helper once, then performs all post-install steps + public func performPostInstallSteps(for xcode: InstalledXcode) { + postInstallTask?.cancel() + let taskID = UUID() + postInstallTaskID = taskID + postInstallTask = Task { @MainActor in + defer { + if postInstallTaskID == taskID { + postInstallTask = nil + postInstallTaskID = nil } - return Fail(error: InstallationError.codesignVerifyFailed(output: output)) - .eraseToAnyPublisher() - } - .map { output -> CertificateInfo in - // codesign prints to stderr - return self.parseCertificateInfo(output.err) } - .tryMap { cert in - guard - cert.teamIdentifier == XcodeTeamIdentifier, - cert.authority == XcodeCertificateAuthority - else { throw InstallationError.unexpectedCodeSigningIdentity(identifier: cert.teamIdentifier, certificateAuthority: cert.authority) } - - return Void() + do { + try await performPostInstallStepsAsync(for: xcode) + } catch is CancellationError { + } catch { + guard postInstallTaskID == taskID else { return } + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription) } - .eraseToAnyPublisher() + } } - public struct CertificateInfo { - public var authority: [String] - public var teamIdentifier: String - public var bundleIdentifier: String + /// Attemps to install the helper once, then performs all post-install steps + public func performPostInstallStepsAsync(for xcode: InstalledXcode) async throws { + do { + if helperInstallState != .installed { + // If the helper isn't installed yet then we need to prepare the user for the install prompt, + // and then actually perform the installation. + try await waitForHelperInstallConsent(version: xcode.version) + } + + try await installHelperIfNecessaryAsync() + try await xcodePostInstallWorkflowService.performPostInstallSteps(for: xcode) + } catch { + Logger.appState.error("Performing post-install steps failed: \(error.legibleLocalizedDescription)") + throw InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: helperInstallState) + } } - public func parseCertificateInfo(_ rawInfo: String) -> CertificateInfo { - var info = CertificateInfo(authority: [], teamIdentifier: "", bundleIdentifier: "") + private func waitForHelperInstallConsent(version: Version) async throws { + unxipProgress.completedUnitCount = AppState.totalProgressUnits + resetDockProgressTracking() - for part in rawInfo.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) { - if part.hasPrefix("Authority") { - info.authority.append(part.components(separatedBy: "=")[1]) - } - if part.hasPrefix("TeamIdentifier") { - info.teamIdentifier = part.components(separatedBy: "=")[1] - } - if part.hasPrefix("Identifier") { - info.bundleIdentifier = part.components(separatedBy: "=")[1] + let helperConsent = OneShotContinuation() + let helperPreparationID = UUID() + try await helperConsent.value(onCancel: { [weak self] in + Task { @MainActor [weak self] in + guard let self, self.helperActionPreparationID == helperPreparationID else { return } + self.helperActionPreparationID = nil + self.isPreparingUserForActionRequiringHelper = nil + if self.presentedAlert?.id == XcodesAlert.privilegedHelper.id { + self.presentedAlert = nil + } } - } + }) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { + helperConsent.resume(throwing: CancellationError()) + return + } - return info - } - - // MARK: - Post-Install - - /// Attemps to install the helper once, then performs all post-install steps - public func performPostInstallSteps(for xcode: InstalledXcode) { - performPostInstallSteps(for: xcode) - .sink( - receiveCompletion: { completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription) + self.prepareForHelperAction(preparationID: helperPreparationID) { [weak self] userConsented in + guard let self else { + helperConsent.resume(throwing: CancellationError()) + return } - }, - receiveValue: {} - ) - .store(in: &cancellables) - } - - /// Attemps to install the helper once, then performs all post-install steps - public func performPostInstallSteps(for xcode: InstalledXcode) -> AnyPublisher { - let postInstallPublisher: AnyPublisher = - Deferred { [unowned self] in - self.installHelperIfNecessary() - } - .flatMap { [unowned self] in - self.enableDeveloperMode() - } - .flatMap { [unowned self] in - self.approveLicense(for: xcode) - } - .flatMap { [unowned self] in - self.installComponents(for: xcode) - } - .mapError { [unowned self] error in - Logger.appState.error("Performing post-install steps failed: \(error.legibleLocalizedDescription)") - return InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: self.helperInstallState) - } - .eraseToAnyPublisher() - - guard helperInstallState == .installed else { - // If the helper isn't installed yet then we need to prepare the user for the install prompt, - // and then actually perform the installation, - // and the post-install steps need to wait until that is complete. - // This subject, which completes upon isPreparingUserForActionRequiringHelper being invoked, is used to achieve that. - // This is not the most straightforward code I've ever written... - let helperInstallConsentSubject = PassthroughSubject() - - // Need to dispatch this to avoid duplicate alerts, - // the second of which will crash when force-unwrapping isPreparingUserForActionRequiringHelper - DispatchQueue.main.async { - self.isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in if userConsented { - helperInstallConsentSubject.send() + helperConsent.resume() } else { Logger.appState.info("User did not consent to installing helper during post-install steps.") - - helperInstallConsentSubject.send( - completion: .failure( - InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: self.helperInstallState) + helperConsent.resume( + throwing: InstallationError.postInstallStepsNotPerformed( + version: version, + helperInstallState: self.helperInstallState ) ) } } - self.presentedAlert = .privilegedHelper } - - unxipProgress.completedUnitCount = AppState.totalProgressUnits - resetDockProgressTracking() - - return helperInstallConsentSubject - .flatMap { - postInstallPublisher - } - .eraseToAnyPublisher() } - - return postInstallPublisher } - private func enableDeveloperMode() -> AnyPublisher { - Current.helper.devToolsSecurityEnable() - .flatMap { - Current.helper.addStaffToDevelopersGroup() - } - .eraseToAnyPublisher() + private var xcodePostInstallWorkflowService: XcodePostInstallWorkflowService { + XcodePostInstallWorkflowService( + preparationService: xcodePostInstallPreparationService, + postInstallService: xcodePostInstallService + ) } - private func approveLicense(for xcode: InstalledXcode) -> AnyPublisher { - Current.helper.acceptXcodeLicense(xcode.path.string) - .eraseToAnyPublisher() + private var xcodePostInstallService: XcodePostInstallService { + XcodePostInstallService( + runFirstLaunch: { xcode in try await Current.helper.runFirstLaunchAsync(xcode.path.string) }, + getUserCacheDirectory: { try await Current.shell.getUserCacheDir() }, + getMacOSBuildVersion: { try await Current.shell.buildVersion() }, + getXcodeBuildVersion: { xcode in try await Current.shell.xcodeBuildVersion(xcode) }, + touchInstallCheck: { cacheDirectory, macOSBuildVersion, toolsVersion in + try await Current.shell.touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion) + } + ) } - private func installComponents(for xcode: InstalledXcode) -> AnyPublisher { - Current.helper.runFirstLaunch(xcode.path.string) - .flatMap { - Current.shell.getUserCacheDir().map { $0.out } - .combineLatest( - Current.shell.buildVersion().map { $0.out }, - Current.shell.xcodeBuildVersion(xcode).map { $0.out } - ) - } - .flatMap { cacheDirectory, macOSBuildVersion, toolsVersion in - Current.shell.touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion) - } - .map { _ in Void() } - .eraseToAnyPublisher() + private var xcodePostInstallPreparationService: XcodePostInstallPreparationService { + XcodePostInstallPreparationService( + enableDeveloperTools: { try await Current.helper.devToolsSecurityEnableAsync() }, + addStaffToDevelopersGroup: { try await Current.helper.addStaffToDevelopersGroupAsync() }, + acceptLicense: { xcode in try await Current.helper.acceptXcodeLicenseAsync(xcode.path.string) } + ) } - + // MARK: - Dock Progress Tracking - + private func setupDockProgress() { - Task { @MainActor in - DockProgress.progressInstance = nil - DockProgress.style = .bar - - let progress = Progress(totalUnitCount: AppState.totalProgressUnits) - progress.kind = .file - progress.fileOperationKind = .downloading - overallProgress = progress - - DockProgress.progressInstance = overallProgress - } - + DockProgress.progressInstance = nil + DockProgress.style = .bar + + let progress = Progress(totalUnitCount: AppState.totalProgressUnits) + progress.kind = .file + progress.fileOperationKind = .downloading + overallProgress = progress + overallProgressChildIDs = [] + unxipProgress = AppState.makeUnxipProgress() + + DockProgress.progressInstance = overallProgress + } - + + private func addDockProgressChildIfNeeded(_ progress: Progress, withPendingUnitCount pendingUnitCount: Int64) { + let progressID = ObjectIdentifier(progress) + guard overallProgressChildIDs.insert(progressID).inserted else { return } + overallProgress.addChild(progress, withPendingUnitCount: pendingUnitCount) + } + func resetDockProgressTracking() { - Task { @MainActor in - DockProgress.progress = 1 // Only way to completely remove overlay with DockProgress is setting progress to complete - } + DockProgress.progress = 1 // Only way to completely remove overlay with DockProgress is setting progress to complete } - - // MARK: - - + + // MARK: - + func setInstallationStep(of version: Version, to step: XcodeInstallationStep) { - DispatchQueue.main.async { - guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return } - self.allXcodes[index].installState = .installing(step) - - let xcode = self.allXcodes[index] - Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal) - } - } - - func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep, postNotification: Bool = true) { - DispatchQueue.main.async { - guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } - self.downloadableRuntimes[index].installState = .installing(step) - if postNotification { - Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal) - } - } + guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return } + allXcodes[index].installState = .installing(step) + + let xcode = allXcodes[index] + Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal) } -} -extension AppState { - func persistOrCleanUpResumeData(at path: Path, for completion: Subscribers.Completion) { - switch completion { - case .finished: - try? Current.files.removeItem(at: path.url) - case .failure(let error): - guard let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data else { return } - Current.files.createFile(atPath: path.string, contents: resumeData) + func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep, postNotification: Bool = true) { + guard let index = downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } + downloadableRuntimes[index].installState = .installing(step) + if postNotification { + Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal) } } } @@ -590,34 +531,16 @@ public enum InstallationError: LocalizedError, Equatable { } } -public enum InstallationType { +public enum InstallationType: Sendable { case version(AvailableXcode) -} -public enum AutoInstallationType: Int, Identifiable { - case none = 0 - case newestVersion - case newestBeta - - public var id: Self { self } - - public var isAutoInstalling: Bool { - get { - return self != .none - } - set { - self = newValue ? .newestVersion : .none - } - } - public var isAutoInstallingBeta: Bool { - get { - return self == .newestBeta - } - set { - self = newValue ? .newestBeta : (isAutoInstalling ? .newestVersion : .none) + var shouldRetryAfterDamagedArchive: Bool { + switch self { + case .version: + return true } } } -let XcodeTeamIdentifier = "59GAB85EFG" -let XcodeCertificateAuthority = ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"] +let XcodeTeamIdentifier = XcodeSignatureVerifier.expectedTeamIdentifier +let XcodeCertificateAuthority = XcodeSignatureVerifier.expectedCertificateAuthority diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index a0a0ba86..78cf486e 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -1,356 +1,321 @@ import Foundation import XcodesKit import OSLog -import Combine import Path -import AppleAPI import Version extension AppState { func updateDownloadableRuntimes() { - Task { - do { - - let downloadableRuntimes = try await self.runtimeService.downloadableRuntimes() - let runtimes = downloadableRuntimes.downloadables.map { runtime in - var updatedRuntime = runtime - - // This loops through and matches up the simulatorVersion to the mappings - let simulatorBuildUpdate = downloadableRuntimes.sdkToSimulatorMappings.filter { SDKToSimulatorMapping in - SDKToSimulatorMapping.simulatorBuildUpdate == runtime.simulatorVersion.buildUpdate - } - updatedRuntime.sdkBuildUpdate = simulatorBuildUpdate.map { $0.sdkBuildUpdate } - return updatedRuntime + downloadableRuntimesTask?.cancel() + let taskID = UUID() + downloadableRuntimesTaskID = taskID + downloadableRuntimesTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if self.downloadableRuntimesTaskID == taskID { + self.downloadableRuntimesTask = nil + self.downloadableRuntimesTaskID = nil } - - DispatchQueue.main.async { - self.downloadableRuntimes = runtimes - } - try? cacheDownloadableRuntimes(runtimes) + } + do { + var store = self.runtimeListStore + let runtimes = try await store.updateDownloadableRuntimes() + try Task.checkCancellation() + + self.downloadableRuntimes = runtimes + } catch is CancellationError { } catch { Logger.appState.error("Error downloading runtimes: \(error.localizedDescription)") } } } - + func updateInstalledRuntimes() { - Task { + installedRuntimesTask?.cancel() + let taskID = UUID() + installedRuntimesTaskID = taskID + installedRuntimesTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if self.installedRuntimesTaskID == taskID { + self.installedRuntimesTask = nil + self.installedRuntimesTaskID = nil + } + } do { Logger.appState.info("Loading Installed runtimes") let runtimes = try await self.runtimeService.localInstalledRuntimes() - - DispatchQueue.main.async { - self.installedRuntimes = runtimes - } + try Task.checkCancellation() + + self.installedRuntimes = runtimes + } catch is CancellationError { } catch { Logger.appState.error("Error loading installed runtimes: \(error.localizedDescription)") } } } - + func downloadRuntime(runtime: DownloadableRuntime) { - guard let selectedXcode = self.allXcodes.first(where: { $0.selected }) else { - Logger.appState.error("No selected Xcode") - DispatchQueue.main.async { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "No selected Xcode. Please make an Xcode active") + do { + let method = try RuntimeInstallPolicy().installMethod( + for: runtime, + selectedXcodeVersion: allXcodes.first(where: { $0.selected })?.version + ) + + switch method { + case .archive: + downloadRuntimeViaArchive(runtime: runtime) + case let .xcodebuild(architecture): + downloadRuntimeViaXcodeBuild(runtime: runtime, architecture: architecture) } - return + } catch { + presentRuntimeInstallPolicyError(error) } - // new runtimes - if runtime.contentType == .cryptexDiskImage { - // only selected xcodes > 16.1 beta 3 can download runtimes via a xcodebuild -downloadPlatform version - // only Runtimes coming from cryptexDiskImage can be downloaded via xcodebuild - if selectedXcode.version > Version(major: 16, minor: 0, patch: 0) { - - if runtime.architectures?.isAppleSilicon ?? false { - // Need Xcode 26 but with some RC/Beta's its simpler to just to greater > 25 - if selectedXcode.version > Version(major: 25, minor: 0, patch: 0) { - downloadRuntimeViaXcodeBuild(runtime: runtime) - } else { - // not supported - Logger.appState.error("Trying to download a runtime we can't download") - DispatchQueue.main.async { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode26")) - } - return - } - - } else { - downloadRuntimeViaXcodeBuild(runtime: runtime) - } - } else { - // not supported - Logger.appState.error("Trying to download a runtime we can't download") - DispatchQueue.main.async { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode16.1")) - } - return + } + + private func presentRuntimeInstallPolicyError(_ error: Error) { + Logger.appState.error("Trying to download a runtime we can't download: \(error.localizedDescription)") + + if let error = error as? RuntimeInstallPolicyError { + switch error { + case .noSelectedXcode: + presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: "No selected Xcode. Please make an Xcode active") + case .xcode16_1OrGreaterRequired: + presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode16.1")) + case .xcode26OrGreaterRequired: + presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: localizeString("Alert.Install.Error.Need.Xcode26")) } } else { - downloadRuntimeObseleteWay(runtime: runtime) + presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) } } - - func downloadRuntimeViaXcodeBuild(runtime: DownloadableRuntime) { - - let downloadRuntimeTask = Current.shell.downloadRuntime(runtime.platform.shortName, runtime.simulatorVersion.buildUpdate, runtime.architectures?.isAppleSilicon ?? false ? Architecture.arm64.rawValue : nil) - - runtimePublishers[runtime.identifier] = Task { [weak self] in + + func downloadRuntimeViaXcodeBuild(runtime: DownloadableRuntime, architecture: String? = nil) { + runtimeTasks[runtime.identifier]?.cancel() + let runtimeTaskID = UUID() + runtimeTaskIDs[runtime.identifier] = runtimeTaskID + runtimeTasks[runtime.identifier] = Task { @MainActor [weak self] in guard let self = self else { return } + defer { + if self.runtimeTaskIDs[runtime.identifier] == runtimeTaskID { + self.runtimeTasks[runtime.identifier] = nil + self.runtimeTaskIDs[runtime.identifier] = nil + } + } do { - for try await progress in downloadRuntimeTask { - if progress.isIndeterminate { - DispatchQueue.main.async { + try await RuntimeXcodebuildInstallService(download: Current.shell.downloadRuntime).downloadAndInstall( + runtime: runtime, + architecture: architecture + ) { progress in + Task { @MainActor [weak self] in + guard + let self, + self.runtimeTaskIDs[runtime.identifier] == runtimeTaskID + else { return } + + if progress.isIndeterminate { self.setInstallationStep(of: runtime, to: .installing, postNotification: false) - } - } else { - DispatchQueue.main.async { + } else { self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false) } } - } + try Task.checkCancellation() Logger.appState.debug("Done downloading runtime - \(runtime.name)") - - DispatchQueue.main.async { - guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } - self.downloadableRuntimes[index].installState = .installed - self.update() - } - + + guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } + self.downloadableRuntimes[index].installState = .installed + self.update() + + } catch is CancellationError { } catch { - Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") - DispatchQueue.main.async { - self.error = error - if let error = error as? String { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error) - } else { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) - } - } + Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") + self.error = error + if let error = error as? XcodesKitError { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.message) + } else { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) } + } } } - - func downloadRuntimeObseleteWay(runtime: DownloadableRuntime) { - runtimePublishers[runtime.identifier] = Task { - do { - let downloadedURL = try await downloadRunTimeFull(runtime: runtime) - if !Task.isCancelled { - Logger.appState.debug("Installing runtime: \(runtime.name)") - DispatchQueue.main.async { - self.setInstallationStep(of: runtime, to: .installing) - } - switch runtime.contentType { - case .cryptexDiskImage: - // not supported yet (do we need to for old packages?) - throw "Installing via cryptexDiskImage not support - please install manually from \(downloadedURL.description)" - case .package: - // not supported yet (do we need to for old packages?) - throw "Installing via package not support - please install manually from \(downloadedURL.description)" - case .diskImage: - try await self.installFromImage(dmgURL: downloadedURL) - DispatchQueue.main.async { - self.setInstallationStep(of: runtime, to: .trashingArchive) - } - try Current.files.removeItem(at: downloadedURL) - } - - DispatchQueue.main.async { - guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } - self.downloadableRuntimes[index].installState = .installed - } - updateInstalledRuntimes() + + func downloadRuntimeViaArchive(runtime: DownloadableRuntime) { + runtimeTasks[runtime.identifier]?.cancel() + let runtimeTaskID = UUID() + runtimeTaskIDs[runtime.identifier] = runtimeTaskID + runtimeTasks[runtime.identifier] = Task { @MainActor [weak self] in + guard let self = self else { return } + defer { + if self.runtimeTaskIDs[runtime.identifier] == runtimeTaskID { + self.runtimeTasks[runtime.identifier] = nil + self.runtimeTaskIDs[runtime.identifier] = nil } - } - catch { - Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") - DispatchQueue.main.async { - self.error = error - if let error = error as? String { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error) - } else { - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) + do { + let downloadedURL = try await downloadRuntimeArchive(runtime: runtime, taskID: runtimeTaskID) + try Task.checkCancellation() + Logger.appState.debug("Installing runtime: \(runtime.name)") + try await self.runtimeArchiveInstallService.install( + runtime: runtime, + archiveURL: downloadedURL, + stepChanged: { step in + await self.setInstallationStep(of: runtime, to: step) } + ) + + guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } + self.downloadableRuntimes[index].installState = .installed + updateInstalledRuntimes() + } catch is CancellationError { + } catch { + Logger.appState.error("Error downloading runtime: \(error.localizedDescription)") + self.error = error + if let error = error as? XcodesKitError { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.message) + } else { + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) } } } } - - func downloadRunTimeFull(runtime: DownloadableRuntime) async throws -> URL { - guard let source = runtime.source else { - throw "Invalid runtime source" - } - - guard let downloadPath = runtime.downloadPath else { - throw "Invalid runtime downloadPath" - } - - // sets a proper cookie for runtimes - try await validateADCSession(path: downloadPath) - + + func downloadRuntimeArchive(runtime: DownloadableRuntime, taskID: UUID? = nil) async throws -> URL { let downloader = Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2 - - let url = URL(string: source)! - let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)" - // aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete - let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2") - var aria2DownloadIsIncomplete = false - if case .aria2 = downloader, aria2DownloadMetadataPath.exists { - aria2DownloadIsIncomplete = true - } - if Current.files.fileExistsAtPath(expectedRuntimePath.string), aria2DownloadIsIncomplete == false { - Logger.appState.info("Found existing runtime that will be used for installation at \(expectedRuntimePath).") - return expectedRuntimePath.url - } - + Logger.appState.info("Downloading \(runtime.visibleIdentifier) with \(downloader)") - switch downloader { - case .aria2: - let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! - for try await progress in downloadRuntimeWithAria2(runtime, to: expectedRuntimePath, aria2Path: aria2Path) { - DispatchQueue.main.async { - self.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false) - } + let archiveURL = try await runtimeArchiveService().archiveURL( + for: runtime, + destinationDirectory: .xcodesApplicationSupport, + downloader: downloader + ) { progress in + let expectedTaskID = taskID + Task { @MainActor [weak self] in + if let expectedTaskID, self?.runtimeTaskIDs[runtime.identifier] != expectedTaskID { + return } + self?.setInstallationStep(of: runtime, to: .downloading(progress: progress), postNotification: false) + } + } + Logger.appState.info("Using runtime archive at \(archiveURL.path).") + return archiveURL + } + + private func runtimeArchiveService() -> RuntimeArchiveService { + RuntimeArchiveService( + fileExists: { Current.files.fileExistsAtPath($0.string) }, + download: { runtime, url, destination, downloader, progressChanged in + let archiveURL = try await self.runtimeArchiveDownloadStrategyService.download( + runtime: runtime, + url: url, + destination: destination, + downloader: downloader, + progressChanged: progressChanged + ) Logger.appState.debug("Done downloading runtime") + return archiveURL + } + ) + } - case .urlSession: - throw "Downloading runtimes with URLSession is not supported. Please use aria2" - } - return expectedRuntimePath.url + private var runtimeArchiveDownloadStrategyService: RuntimeArchiveDownloadStrategyService { + RuntimeArchiveDownloadStrategyService( + validateDownloadPath: { path in + // Validating the ADC path sets the session cookie required for runtime downloads. + try await self.validateADCSession(path: path) + }, + aria2Path: { Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! }, + cookiesForURL: { Current.network.session.configuration.httpCookieStorage?.cookies(for: $0) ?? [] } + ) } - public func downloadRuntimeWithAria2(_ runtime: DownloadableRuntime, to destination: Path, aria2Path: Path) -> AsyncThrowingStream { - guard let url = runtime.url else { - return AsyncThrowingStream { continuation in - continuation.finish(throwing: "Invalid or non existant runtime url") + private var runtimeArchiveInstallService: RuntimeArchiveInstallService { + let runtimeService = self.runtimeService + return RuntimeArchiveInstallService( + installDiskImage: { url in + try await runtimeService.installRuntimeImage(dmgURL: url) + }, + removeArchive: { url in + try Current.files.removeItem(at: url) } - } - - let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: url) ?? [] - - return Current.shell.downloadWithAria2Async(aria2Path, url, destination, cookies) + ) } - - + public func installFromImage(dmgURL: URL) async throws { try await self.runtimeService.installRuntimeImage(dmgURL: dmgURL) } - + func cancelRuntimeInstall(runtime: DownloadableRuntime) { - // Cancel the publisher - - runtimePublishers[runtime.identifier]?.cancel() - runtimePublishers[runtime.identifier] = nil - - // If the download is cancelled by the user, clean up the download files that aria2 creates. - guard let source = runtime.source else { - return - } - let url = URL(string: source)! - let expectedRuntimePath = Path.xcodesApplicationSupport/"\(url.lastPathComponent)" - let aria2DownloadMetadataPath = expectedRuntimePath.parent/(expectedRuntimePath.basename() + ".aria2") - - try? Current.files.removeItem(at: expectedRuntimePath.url) - try? Current.files.removeItem(at: aria2DownloadMetadataPath.url) - + runtimeTasks[runtime.identifier]?.cancel() + runtimeTasks[runtime.identifier] = nil + runtimeTaskIDs[runtime.identifier] = nil + + ArchiveCancellationCleanupService( + removeItem: { try Current.files.removeItem(at: $0) } + ).cleanupRuntimeArchive( + for: runtime, + destinationDirectory: .xcodesApplicationSupport + ) + guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return } self.downloadableRuntimes[index].installState = .notInstalled - + updateInstalledRuntimes() } - + func runtimeInstallPath(xcode: Xcode, runtime: DownloadableRuntime) -> Path? { - if let coreSimulatorInfo = coreSimulatorInfo(runtime: runtime) { - let urlString = coreSimulatorInfo.path["relative"]! - // app was not allowed to open up file:// url's so remove - let fileRemovedString = urlString.replacingOccurrences(of: "file://", with: "") - let url = URL(fileURLWithPath: fileRemovedString) - - return Path(url: url)! - } - return nil + RuntimeInstallationLookupService() + .installPath(for: runtime, in: installedRuntimes) } - + func coreSimulatorInfo(runtime: DownloadableRuntime) -> CoreSimulatorImage? { - return installedRuntimes.filter({ - $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate && - ((runtime.architectures ?? []).isEmpty ? true : - $0.runtimeInfo.supportedArchitectures == runtime.architectures )}).first + RuntimeInstallationLookupService() + .coreSimulatorImage(for: runtime, in: installedRuntimes) } - + func deleteRuntime(runtime: DownloadableRuntime) async throws { if let info = coreSimulatorInfo(runtime: runtime) { try await runtimeService.deleteRuntime(identifier: info.uuid) - + // give it some time to actually finish deleting before updating - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.updateInstalledRuntimes() - } + try await Task.sleep(nanoseconds: 500_000_000) + updateInstalledRuntimes() } else { - throw "No simulator found with \(runtime.identifier)" + throw XcodesKitError("No simulator found with \(runtime.identifier)") } } -} -extension AnyPublisher { - func async() async throws -> Output { - try await withCheckedThrowingContinuation { continuation in - var cancellable: AnyCancellable? - - cancellable = first() - .sink { result in - switch result { - case .finished: - break - case let .failure(error): - continuation.resume(throwing: error) - } - cancellable?.cancel() - } receiveValue: { value in - continuation.resume(with: .success(value)) + func confirmDeleteRuntime(runtime: DownloadableRuntime) { + deleteRuntimeTask?.cancel() + let taskID = UUID() + deleteRuntimeTaskID = taskID + deleteRuntimeTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if self.deleteRuntimeTaskID == taskID { + self.deleteRuntimeTask = nil + self.deleteRuntimeTaskID = nil } - } - } -} -extension AnyPublisher where Failure: Error { - struct Subscriber { - fileprivate let send: (Output) -> Void - fileprivate let complete: (Subscribers.Completion) -> Void - - func send(_ value: Output) { self.send(value) } - func send(completion: Subscribers.Completion) { self.complete(completion) } - } - - init(_ closure: (Subscriber) -> AnyCancellable) { - let subject = PassthroughSubject() - - let subscriber = Subscriber( - send: subject.send, - complete: subject.send(completion:) - ) - let cancel = closure(subscriber) + } - self = subject - .handleEvents(receiveCancel: cancel.cancel) - .eraseToAnyPublisher() + do { + try await self.deleteRuntime(runtime: runtime) + } catch is CancellationError { + } catch { + guard self.deleteRuntimeTaskID == taskID else { return } + self.presentedPreferenceAlert = .generic( + title: "Error", + message: self.runtimeDeletionErrorMessage(error) + ) + } + } } -} -extension AnyPublisher where Failure == Error { - init(taskPriority: TaskPriority? = nil, asyncFunc: @escaping () async throws -> Output) { - self.init { subscriber in - let task = Task(priority: taskPriority) { - do { - subscriber.send(try await asyncFunc()) - subscriber.send(completion: .finished) - } catch { - subscriber.send(completion: .failure(error)) - } - } - return AnyCancellable { task.cancel() } + private func runtimeDeletionErrorMessage(_ error: Error) -> String { + if let error = error as? XcodesKitError { + return error.message } + + return error.localizedDescription } } diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index 52c280ef..ab9ea3d8 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -1,34 +1,34 @@ -import Combine import Foundation import Path -import Version -import SwiftSoup -import AppleAPI +import XcodesLoginKit import XcodesKit extension AppState { var isReadyForUpdate: Bool { - guard let lastUpdated = Current.defaults.date(forKey: "lastUpdated"), - // This is bad date math but for this use case it doesn't need to be exact - lastUpdated < Current.date().addingTimeInterval(-60 * 60 * 5) - else { - return false - } - return true + XcodeUpdatePolicy(now: { Current.date() }).shouldUpdate( + cachedXcodes: availableXcodes, + lastUpdated: Current.defaults.date(forKey: "lastUpdated") + ) } func updateIfNeeded() { guard isReadyForUpdate - else { - updatePublisher = updateSelectedXcodePath() - .sink( - receiveCompletion: { _ in - self.updatePublisher = nil - }, - receiveValue: { _ in } - ) + else { + updateTask?.cancel() + let taskID = UUID() + updateTaskID = taskID + let task = Task { @MainActor in + defer { + if updateTaskID == taskID { + updateTask = nil + updateTaskID = nil + } + } + await self.updateSelectedXcodePathAsync() + } + updateTask = task return } update() as Void @@ -38,77 +38,53 @@ extension AppState { guard !isUpdating else { return } updateDownloadableRuntimes() updateInstalledRuntimes() - updatePublisher = updateSelectedXcodePath() - .flatMap { _ in - self.updateAvailableXcodes(from: self.dataSource) - } - .sink( - receiveCompletion: { [unowned self] completion in - switch completion { - case let .failure(error): - // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead - if error as? AuthenticationError != .invalidSession { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: error.legibleLocalizedDescription) - } - case .finished: - Current.defaults.setDate(Current.date(), forKey: "lastUpdated") - } - self.updatePublisher = nil - }, - receiveValue: { _ in } - ) + let taskID = UUID() + updateTaskID = taskID + let task = Task { @MainActor in + defer { + if updateTaskID == taskID { + updateTask = nil + updateTaskID = nil + } + } + do { + await self.updateSelectedXcodePathAsync() + let xcodes = try await self.updateAvailableXcodes(from: self.dataSource) + try Task.checkCancellation() + self.availableXcodes = xcodes + Current.defaults.setDate(Current.date(), forKey: "lastUpdated") + } catch is CancellationError { + } catch { + // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead + if error as? AuthenticationError != .invalidSession { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.Update.Error.Title"), message: error.legibleLocalizedDescription) + } + } + } + updateTask = task } - - func updateSelectedXcodePath() -> AnyPublisher { - Current.shell.xcodeSelectPrintPath() - .handleEvents(receiveOutput: { output in self.selectedXcodePath = output.out }) + + func updateSelectedXcodePathAsync() async { + do { + let output = try await Current.shell.xcodeSelectPrintPath() + selectedXcodePath = output.out + } catch { // Ignore xcode-select failures - .map { _ in Void() } - .catch { _ in Just(()) } - .eraseToAnyPublisher() + } } - private func updateAvailableXcodes(from dataSource: DataSource) -> AnyPublisher<[AvailableXcode], Error> { - switch dataSource { - case .apple: - return signInIfNeeded() - .flatMap { [unowned self] in - // this will check to see if the Apple ID is a valid Apple Developer or not. - // If it's not, we can't use the Apple source to get xcode info. - self.validateSession() - } - .flatMap { [unowned self] in self.releasedXcodes().combineLatest(self.prereleaseXcodes()) } - .receive(on: DispatchQueue.main) - .map { releasedXcodes, prereleaseXcodes in - // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. - // Previously pre-release versions only appeared on developer.apple.com/download. - // /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build. - // If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes. - let xcodes = releasedXcodes.filter { releasedXcode in - prereleaseXcodes.contains { $0.version.isEquivalent(to: releasedXcode.version) } == false - } + prereleaseXcodes - return xcodes - } - .handleEvents( - receiveOutput: { xcodes in - self.availableXcodes = xcodes - try? self.cacheAvailableXcodes(xcodes) - } - ) - .eraseToAnyPublisher() - case .xcodeReleases: - return xcodeReleases() - .receive(on: DispatchQueue.main) - .handleEvents( - receiveOutput: { xcodes in - self.availableXcodes = xcodes - try? self.cacheAvailableXcodes(xcodes) - } - ) - .eraseToAnyPublisher() + private func updateAvailableXcodes(from dataSource: DataSource) async throws -> [AvailableXcode] { + if dataSource == .apple { + try await signInIfNeededAsync() + // This checks whether the Apple ID is a valid Apple Developer account. + try await validateSessionAsync() } + + let service = XcodeListService(urlSession: Current.network.session) + var store = availableXcodeListStore(service: service) + return try await store.updateAvailableXcodes(from: dataSource) } } @@ -116,128 +92,69 @@ extension AppState { // MARK: - Available Xcode Cache func loadCachedAvailableXcodes() throws { - guard let data = Current.files.contents(atPath: Path.cacheFile.string) else { return } - let xcodes = try JSONDecoder().decode([AvailableXcode].self, from: data) - self.availableXcodes = xcodes + var store = availableXcodeListStore() + try store.loadCachedAvailableXcodes() + availableXcodes = store.availableXcodes } func cacheAvailableXcodes(_ xcodes: [AvailableXcode]) throws { - let data = try JSONEncoder().encode(xcodes) - try FileManager.default.createDirectory(at: Path.cacheFile.url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: Path.cacheFile.url) + try availableXcodeListStore().saveAvailableXcodes(xcodes) + } + + private func availableXcodeListStore(service: XcodeListService = XcodeListService()) -> XcodeListStore { + XcodeListStore( + cache: availableXcodeCache, + service: service, + now: { Current.date() } + ) + } + + private var availableXcodeCache: AvailableXcodeCache { + AvailableXcodeCache( + cacheFile: .cacheFile, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + writeData: { data, url in try Current.files.write(data, to: url) }, + createDirectory: { url, createIntermediates, attributes in + try Current.files.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates, + attributes: attributes + ) + } + ) } // MARK: Runtime Cache func loadCacheDownloadableRuntimes() throws { - guard let data = Current.files.contents(atPath: Path.runtimeCacheFile.string) else { return } - let runtimes = try JSONDecoder().decode([DownloadableRuntime].self, from: data) - self.downloadableRuntimes = runtimes + var store = runtimeListStore + try store.loadCachedDownloadableRuntimes() + downloadableRuntimes = store.downloadableRuntimes } func cacheDownloadableRuntimes(_ runtimes: [DownloadableRuntime]) throws { - let data = try JSONEncoder().encode(runtimes) - try FileManager.default.createDirectory(at: Path.runtimeCacheFile.url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: Path.runtimeCacheFile.url) + try runtimeListStore.saveDownloadableRuntimes(runtimes) } -} -extension AppState { - // MARK: - Apple - - private func releasedXcodes() -> AnyPublisher<[AvailableXcode], Swift.Error> { - Current.network.dataTask(with: URLRequest.downloads) - .map(\.data) - .decode(type: Downloads.self, decoder: configure(JSONDecoder()) { - $0.dateDecodingStrategy = .formatted(.downloadsDateModified) - }) - .tryMap { downloads -> [AvailableXcode] in - if downloads.hasError { - throw AuthenticationError.invalidResult(resultString: downloads.resultsString) - } - guard let downloadList = downloads.downloads else { - throw AuthenticationError.invalidResult(resultString: localizeString("DownloadingError")) - } - let xcodes = downloadList - .filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil } - .compactMap { download -> AvailableXcode? in - let urlPrefix = URL(string: "https://download.developer.apple.com/")! - guard - let xcodeFile = download.files.first(where: { $0.remotePath.hasSuffix("dmg") || $0.remotePath.hasSuffix("xip") }), - let version = Version(xcodeVersion: download.name) - else { return nil } - - let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath) - return AvailableXcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified, fileSize: xcodeFile.fileSize) - } - return xcodes - } - .eraseToAnyPublisher() + var runtimeListStore: RuntimeListStore { + RuntimeListStore( + cache: downloadableRuntimeCache, + service: runtimeService + ) } - private func prereleaseXcodes() -> AnyPublisher<[AvailableXcode], Error> { - Current.network.dataTask(with: URLRequest.download) - .tryMap { (data, _) -> [AvailableXcode] in - try self.parsePrereleaseXcodes(from: data) - } - .eraseToAnyPublisher() - } - - private func parsePrereleaseXcodes(from data: Data) throws -> [AvailableXcode] { - let body = String(data: data, encoding: .utf8)! - let document = try SwiftSoup.parse(body) - - guard - let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(), - let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""), - let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""), - let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion), - let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"), - let url = URL(string: "https://developer.apple.com" + path) - else { return [] } - - let filename = String(path.suffix(fromLast: "/")) - - return [AvailableXcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))] - } -} - -extension AppState { - // MARK: - XcodeReleases - - private func xcodeReleases() -> AnyPublisher<[AvailableXcode], Error> { - Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!)) - .map(\.data) - .decode(type: [XcodeRelease].self, decoder: JSONDecoder()) - .map { xcReleasesXcodes in - let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> AvailableXcode? in - guard - let downloadURL = xcReleasesXcode.links?.download?.url, - let version = Version(xcReleasesXcode: xcReleasesXcode) - else { return nil } - - let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents( - year: xcReleasesXcode.date.year, - month: xcReleasesXcode.date.month, - day: xcReleasesXcode.date.day - )) - - return AvailableXcode( - version: version, - url: downloadURL, - filename: String(downloadURL.path.suffix(fromLast: "/")), - releaseDate: releaseDate, - requiredMacOSVersion: xcReleasesXcode.requires, - releaseNotesURL: xcReleasesXcode.links?.notes?.url, - sdks: xcReleasesXcode.sdks, - compilers: xcReleasesXcode.compilers, - architectures: xcReleasesXcode.architectures - ) - } - return xcodes + private var downloadableRuntimeCache: DownloadableRuntimeCache { + DownloadableRuntimeCache( + cacheFile: .runtimeCacheFile, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + writeData: { data, url in try Current.files.write(data, to: url) }, + createDirectory: { url, createIntermediates, attributes in + try Current.files.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates, + attributes: attributes + ) } - .eraseToAnyPublisher() + ) } } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 1f6419dc..e6bdd3ca 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -1,6 +1,6 @@ import AppKit -import AppleAPI -import Combine +import XcodesLoginKit +import XcodesLoginKitSecurityKey import Path import LegibleError import KeychainAccess @@ -9,7 +9,6 @@ import Version import os.log import DockProgress import XcodesKit -import LibFido2Swift enum PreferenceKey: String { case installPath @@ -31,12 +30,13 @@ enum PreferenceKey: String { func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) } } +@MainActor class AppState: ObservableObject { - private let client = AppleAPI.Client() - internal let runtimeService = RuntimeService() - + private var client: XcodesLoginKit.Client { Current.network.loginClient } + internal var runtimeService: RuntimeService + // MARK: - Published Properties - + @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { @@ -44,8 +44,8 @@ class AppState: ObservableObject { Current.notificationManager.scheduleNotification(title: localizeString("Notification.NewXcodeVersion.Title"), body: localizeString("Notification.NewXcodeVersion.Body"), category: .normal) } updateAllXcodes( - availableXcodes: newValue, - installedXcodes: Current.files.installedXcodes(Path.installDirectory), + availableXcodes: newValue, + installedXcodes: Current.files.installedXcodes(Path.installDirectory), selectedXcodePath: selectedXcodePath ) } @@ -63,10 +63,14 @@ class AppState: ObservableObject { ) } } - @Published var updatePublisher: AnyCancellable? - var isUpdating: Bool { updatePublisher != nil } + @Published var updateTask: Task? + var updateTaskID: UUID? + var isUpdating: Bool { updateTask != nil } @Published var presentedSheet: XcodesSheet? = nil @Published var isProcessingAuthRequest = false + private var authenticationRequestID: UUID? + private var authenticationTask: Task? + private var authenticationTaskID: UUID? @Published var xcodeBeingConfirmedForUninstallation: Xcode? @Published var presentedAlert: XcodesAlert? @Published var presentedPreferenceAlert: XcodesPreferencesAlert? @@ -74,19 +78,20 @@ class AppState: ObservableObject { /// Whether the user is being prepared for the helper installation alert with an explanation. /// This closure will be performed after the user chooses whether or not to proceed. @Published var isPreparingUserForActionRequiringHelper: ((Bool) -> Void)? + var helperActionPreparationID: UUID? // MARK: - Errors @Published var error: Error? @Published var authError: Error? - + // MARK: Advanced Preferences @Published var localPath = "" { didSet { Current.defaults.set(localPath, forKey: "localPath") } } - + var disableLocalPathChange: Bool { PreferenceKey.localPath.isManaged() } @Published var installPath = "" { @@ -102,7 +107,7 @@ class AppState: ObservableObject { Current.defaults.set(unxipExperiment, forKey: "unxipExperiment") } } - + var disableUnxipExperiment: Bool { PreferenceKey.unxipExperiment.isManaged() } @Published var createSymLinkOnSelect = false { @@ -110,21 +115,21 @@ class AppState: ObservableObject { Current.defaults.set(createSymLinkOnSelect, forKey: "createSymLinkOnSelect") } } - + var createSymLinkOnSelectDisabled: Bool { return onSelectActionType == .rename || PreferenceKey.createSymLinkOnSelect.isManaged() } - + @Published var onSelectActionType = SelectedActionType.none { didSet { Current.defaults.set(onSelectActionType.rawValue, forKey: "onSelectActionType") - + if onSelectActionType == .rename { createSymLinkOnSelect = false } } } - + var onSelectActionTypeDisabled: Bool { PreferenceKey.onSelectActionType.isManaged() } @Published var showOpenInRosettaOption = false { @@ -132,41 +137,58 @@ class AppState: ObservableObject { Current.defaults.set(showOpenInRosettaOption, forKey: "showOpenInRosettaOption") } } - + @Published var terminateAfterLastWindowClosed = false { didSet { Current.defaults.set(terminateAfterLastWindowClosed, forKey: "terminateAfterLastWindowClosed") } } - + // MARK: - Runtimes - + @Published var downloadableRuntimes: [DownloadableRuntime] = [] @Published var installedRuntimes: [CoreSimulatorImage] = [] - // MARK: - Publisher Cancellables - - var cancellables = Set() - private var installationPublishers: [XcodeID: AnyCancellable] = [:] - internal var runtimePublishers: [String: Task<(), any Error>] = [:] - private var selectPublisher: AnyCancellable? - private var uninstallPublisher: AnyCancellable? + // MARK: - Operation State + + var downloadableRuntimesTask: Task? + var downloadableRuntimesTaskID: UUID? + var installedRuntimesTask: Task? + var installedRuntimesTaskID: UUID? + internal var installationTasks: [XcodeID: Task] = [:] + internal var installationTaskIDs: [XcodeID: UUID] = [:] + internal var runtimeTasks: [String: Task] = [:] + internal var runtimeTaskIDs: [String: UUID] = [:] + internal var deleteRuntimeTask: Task? + internal var deleteRuntimeTaskID: UUID? + internal var helperInstallTask: Task? + internal var helperInstallTaskID: UUID? + internal var postInstallTask: Task? + internal var postInstallTaskID: UUID? + private var helperStatusTask: Task? + internal var selectTask: Task? + internal var selectTaskID: UUID? + internal var uninstallTask: Task? + internal var uninstallTaskID: UUID? private var autoInstallTimer: Timer? - + // MARK: - Dock Progress Tracking - + public static let totalProgressUnits = Int64(10) public static let unxipProgressWeight = Int64(1) var overallProgress = Progress() - var unxipProgress = { + var unxipProgress = AppState.makeUnxipProgress() + var overallProgressChildIDs = Set() + + static func makeUnxipProgress() -> Progress { let progress = Progress(totalUnitCount: totalProgressUnits) progress.kind = .file progress.fileOperationKind = .copying return progress - }() - - // MARK: - - + } + + // MARK: - + var dataSource: DataSource { Current.defaults.string(forKey: "dataSource").flatMap(DataSource.init(rawValue:)) ?? .default } @@ -178,29 +200,33 @@ class AppState: ObservableObject { var hasSavedUsername: Bool { savedUsername != nil } - + var bottomStatusBarMessage: String { let formatter = DateFormatter() formatter.dateFormat = "dd/MM/yyyy" let finishDate = formatter.date(from: "11/06/2022") - + if Date().compare(finishDate!) == .orderedAscending { return String(format: localizeString("WWDC.Message"), "2022") } return "" } - + // MARK: - Init - - init() { + + init(runtimeService: RuntimeService = RuntimeService()) { + self.runtimeService = runtimeService guard !isTesting else { return } try? loadCachedAvailableXcodes() try? loadCacheDownloadableRuntimes() - checkIfHelperIsInstalled() + helperStatusTask = Task { @MainActor in + await checkIfHelperIsInstalled() + helperStatusTask = nil + } setupAutoInstallTimer() setupDefaults() } - + func setupDefaults() { localPath = Current.defaults.string(forKey: "localPath") ?? Path.defaultXcodesApplicationSupport.string unxipExperiment = Current.defaults.bool(forKey: "unxipExperiment") ?? false @@ -210,101 +236,66 @@ class AppState: ObservableObject { showOpenInRosettaOption = Current.defaults.bool(forKey: "showOpenInRosettaOption") ?? false terminateAfterLastWindowClosed = Current.defaults.bool(forKey: "terminateAfterLastWindowClosed") ?? false } - + // MARK: Timer /// Runs a timer every 6 hours when app is open to check if it needs to auto install any xcodes func setupAutoInstallTimer() { guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return } if autoInstallType == .none { return } - + autoInstallTimer = Timer.scheduledTimer(withTimeInterval: 60*60*6, repeats: true) { [weak self] _ in - self?.updateIfNeeded() + Task { @MainActor in + self?.updateIfNeeded() + } } } // MARK: - Authentication - - func validateADCSession(path: String) -> AnyPublisher { - return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)) - .receive(on: DispatchQueue.main) - .tryMap { result -> Void in - let httpResponse = result.response as! HTTPURLResponse - if httpResponse.statusCode == 401 { - throw AuthenticationError.notAuthorized - } - } - .eraseToAnyPublisher() - } - + func validateADCSession(path: String) async throws { - let result = try await Current.network.dataTaskAsync(with: URLRequest.downloadADCAuth(path: path)) - let httpResponse = result.1 as! HTTPURLResponse - if httpResponse.statusCode == 401 { - throw AuthenticationError.notAuthorized - } - } - - func validateSession() -> AnyPublisher { - - return Current.network.validateSession() - .receive(on: DispatchQueue.main) - .handleEvents(receiveCompletion: { completion in - if case .failure = completion { - // this is causing some awkwardness with showing an alert with the error and also popping up the sign in view - // self.authenticationState = .unauthenticated - // self.presentedSheet = .signIn - } - }) - .eraseToAnyPublisher() - } - - func signInIfNeeded() -> AnyPublisher { - validateSession() - .catch { (error) -> AnyPublisher in - guard - let username = self.savedUsername, - let password = try? Current.keychain.getString(username) - else { - return Fail(error: error) - .eraseToAnyPublisher() - } + try await DeveloperPortalSessionService( + loadData: { request in + try await Current.network.dataTaskAsync(with: request) + }, + unauthorizedError: { AuthenticationError.notAuthorized } + ).validateADCSession(path: path) + } + + func validateSessionAsync() async throws { + try await Current.network.validateSessionAsync() + } - return self.signIn(username: username, password: password) - .map { _ in Void() } - .eraseToAnyPublisher() + func signInIfNeededAsync() async throws { + do { + try await validateSessionAsync() + } catch { + guard + let username = savedUsername, + let password = try? Current.keychain.getString(username) + else { + throw error } - .eraseToAnyPublisher() + + _ = try await signInAsync(username: username, password: password) + } } - + func signIn(username: String, password: String) { authError = nil - signIn(username: username.lowercased(), password: password) - .sink( - receiveCompletion: { _ in }, - receiveValue: { _ in } - ) - .store(in: &cancellables) + startAuthenticationTask { + _ = try await self.signInAsync(username: username.lowercased(), password: password) + } } - - func signIn(username: String, password: String) -> AnyPublisher { + + func signInAsync(username: String, password: String) async throws -> AuthenticationState { try? Current.keychain.set(password, key: username) Current.defaults.set(username, forKey: "username") - - isProcessingAuthRequest = true - return client.srpLogin(accountName: username, password: password) - .receive(on: DispatchQueue.main) - .handleEvents( - receiveOutput: { authenticationState in - self.authenticationState = authenticationState - }, - receiveCompletion: { completion in - self.handleAuthenticationFlowCompletion(completion) - self.isProcessingAuthRequest = false - } - ) - .eraseToAnyPublisher() + + return try await performAuthenticationRequest { + try await client.srpLogin(accountName: username, password: password) + } } - + func handleTwoFactorOption(_ option: TwoFactorOption, authOptions: AuthOptionsResponse, serviceKey: String, sessionID: String, scnt: String) { let sessionData = AppleSessionData(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) @@ -319,25 +310,14 @@ class AppState: ObservableObject { } } - func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { - isProcessingAuthRequest = true - client.requestSMSSecurityCode(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { completion in - self.handleAuthenticationFlowCompletion(completion) - self.isProcessingAuthRequest = false - }, - receiveValue: { authenticationState in - self.authenticationState = authenticationState - if case let AuthenticationState.waitingForSecondFactor(option, authOptions, sessionData) = authenticationState { - self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) - } - } - ) - .store(in: &cancellables) + func requestSMS(to trustedPhoneNumber: AuthOptionsResponse.TrustedPhoneNumber, authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { + startAuthenticationTask { + _ = try await self.performAuthenticationRequest { + try await self.client.requestSMSSecurityCode(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData) + } + } } - + func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { self.presentedSheet = .twoFactor(.init( option: .smsPendingChoice, @@ -345,125 +325,113 @@ class AppState: ObservableObject { sessionData: sessionData )) } - + func submitSecurityCode(_ code: SecurityCode, sessionData: AppleSessionData) { - isProcessingAuthRequest = true - client.submitSecurityCode(code, sessionData: sessionData) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { completion in - self.handleAuthenticationFlowCompletion(completion) - self.isProcessingAuthRequest = false - }, - receiveValue: { authenticationState in - self.authenticationState = authenticationState - } - ) - .store(in: &cancellables) + startAuthenticationTask { + _ = try await self.performAuthenticationRequest { + try await self.client.submitSecurityCode(code, sessionData: sessionData) + } + } } - - private lazy var fido2 = FIDO2() func createAndSubmitSecurityKeyAssertationWithPinCode(_ pinCode: String?, sessionData: AppleSessionData, authOptions: AuthOptionsResponse) { self.presentedSheet = .securityKeyTouchToConfirm - - guard let fsaChallenge = authOptions.fsaChallenge else { - // This shouldn't happen - // we shouldn't have called this method without setting the fsaChallenge - // so this is an assertionFailure - assertionFailure() - self.authError = "Something went wrong. Please file a bug report" - return - } - - // The challenge is encoded in Base64URL encoding - let challengeUrl = fsaChallenge.challenge - let challenge = FIDO2.base64urlToBase64(base64url: challengeUrl) - let origin = "https://idmsa.apple.com" - let rpId = "apple.com" - // Allowed creds is sent as a comma separated string - let validCreds = fsaChallenge.allowedCredentials.split(separator: ",").map(String.init) - Task { - do { - let response = try fido2.respondToChallenge(args: ChallengeArgs(rpId: rpId, validCredentials: validCreds, devPin: pinCode, challenge: challenge, origin: origin)) - - Task { @MainActor in - self.isProcessingAuthRequest = true - } - - let respData = try JSONEncoder().encode(response) - client.submitChallenge(response: respData, sessionData: AppleSessionData(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)) - .receive(on: DispatchQueue.main) - .handleEvents( - receiveOutput: { authenticationState in - self.authenticationState = authenticationState - }, - receiveCompletion: { completion in - self.handleAuthenticationFlowCompletion(completion) - self.isProcessingAuthRequest = false - } - ).sink( - receiveCompletion: { _ in }, - receiveValue: { _ in } - ).store(in: &cancellables) - } catch FIDO2Error.canceledByUser { - // User cancelled the auth flow - // we don't have to show an error - // because the sheet will already be dismissed - } catch { - Task { @MainActor in - authError = error - } + startAuthenticationTask { + _ = try await self.performAuthenticationRequest { + try await self.client.submitSecurityKeyPinCode(pinCode, sessionData: sessionData, authOptions: authOptions) } } } func fido2DeviceIsPresent() -> Bool { - fido2.hasDeviceAttached() + client.hasSecurityKeyDeviceAttached() } func fido2DeviceNeedsPin() -> Bool { do { - return try fido2.deviceHasPin() + return try client.securityKeyDeviceNeedsPin() } catch { - Task { @MainActor in - authError = error - } - + authError = error return true } } - + func cancelSecurityKeyAssertationRequest() { - self.fido2.cancel() - } - - private func handleAuthenticationFlowCompletion(_ completion: Subscribers.Completion) { - switch completion { - case let .failure(error): - // remove saved username and any stored keychain password if authentication fails so it doesn't try again. - clearLoginCredentials() - Logger.appState.error("Authentication error: \(error.legibleDescription)") - self.authError = error - case .finished: - switch self.authenticationState { - case .authenticated, .unauthenticated, .notAppleDeveloper: - self.presentedSheet = nil - case let .waitingForSecondFactor(option, authOptions, sessionData): - self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) + self.client.cancelSecurityKeyAssertationRequest() + } + + private func handleAuthenticationFlowFailure(_ error: Error) { + // remove saved username and any stored keychain password if authentication fails so it doesn't try again. + clearLoginCredentials() + Logger.appState.error("Authentication error: \(error.legibleDescription)") + self.authError = error + } + + private func handleAuthenticationFlowSuccess() { + switch self.authenticationState { + case .authenticated, .unauthenticated, .notAppleDeveloper: + self.presentedSheet = nil + case let .waitingForSecondFactor(option, authOptions, sessionData): + self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) + } + } + + private func performAuthenticationRequest( + _ operation: () async throws -> AuthenticationState + ) async throws -> AuthenticationState { + let requestID = UUID() + authenticationRequestID = requestID + isProcessingAuthRequest = true + defer { + if authenticationRequestID == requestID { + isProcessingAuthRequest = false + authenticationRequestID = nil + } + } + + do { + let authenticationState = try await operation() + guard authenticationRequestID == requestID else { return authenticationState } + self.authenticationState = authenticationState + handleAuthenticationFlowSuccess() + return authenticationState + } catch { + if authenticationRequestID == requestID { + handleAuthenticationFlowFailure(error) + } + throw error + } + } + + private func startAuthenticationTask(_ operation: @escaping () async throws -> Void) { + authenticationTask?.cancel() + let taskID = UUID() + authenticationTaskID = taskID + authenticationTask = Task { @MainActor in + defer { + if authenticationTaskID == taskID { + authenticationTask = nil + authenticationTaskID = nil + } + } + do { + try await operation() + } catch is CancellationError { + } catch { + // performAuthenticationRequest owns auth error presentation. } } } - + func signOut() { clearLoginCredentials() - AppleAPI.Current.network.session.configuration.httpCookieStorage?.removeCookies(since: .distantPast) + Current.network.signout() authenticationState = .unauthenticated } - + // MARK: - Helper - + /// Install the privileged helper if it isn't already installed. /// /// The way this is done is a little roundabout, because it requires user interaction in an alert before installation should be attempted. @@ -476,66 +444,90 @@ class AppState: ObservableObject { /// - Parameter shouldPrepareUserForHelperInstallation: Whether the user should be presented with an alert preparing them for helper installation. func installHelperIfNecessary(shouldPrepareUserForHelperInstallation: Bool = true) { guard helperInstallState == .installed || shouldPrepareUserForHelperInstallation == false else { - isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in + prepareForHelperAction { [weak self] userConsented in guard userConsented else { return } - self.installHelperIfNecessary(shouldPrepareUserForHelperInstallation: false) + self?.installHelperIfNecessary(shouldPrepareUserForHelperInstallation: false) } - presentedAlert = .privilegedHelper return } - - installHelperIfNecessary() - .sink( - receiveCompletion: { [unowned self] completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.PrivilegedHelper.Error.Title"), message: error.legibleLocalizedDescription) - } - }, - receiveValue: {} - ) - .store(in: &cancellables) - } - - func installHelperIfNecessary() -> AnyPublisher { - Result { - if helperInstallState == .notInstalled { - try Current.helper.install() - checkIfHelperIsInstalled() + + helperInstallTask?.cancel() + let taskID = UUID() + helperInstallTaskID = taskID + helperInstallTask = Task { @MainActor in + defer { + if helperInstallTaskID == taskID { + helperInstallTask = nil + helperInstallTaskID = nil + } + } + do { + try await installHelperIfNecessaryAsync() + } catch is CancellationError { + } catch { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.PrivilegedHelper.Error.Title"), message: error.legibleLocalizedDescription) } } - .publisher - .subscribe(on: DispatchQueue.main) - .eraseToAnyPublisher() } - - private func checkIfHelperIsInstalled() { + + func installHelperIfNecessaryAsync() async throws { + if helperInstallState == .unknown { + await checkIfHelperIsInstalled() + try Task.checkCancellation() + } + + if helperInstallState == .notInstalled { + try Task.checkCancellation() + try await Current.helper.install() + try Task.checkCancellation() + await checkIfHelperIsInstalled() + } + } + + private func checkIfHelperIsInstalled() async { helperInstallState = .unknown - Current.helper.checkIfLatestHelperIsInstalled() - .receive(on: DispatchQueue.main) - .sink( - receiveValue: { installed in - self.helperInstallState = installed ? .installed : .notInstalled - } - ) - .store(in: &cancellables) + let installed = (try? await Current.helper.checkIfLatestHelperIsInstalledAsync()) ?? false + helperInstallState = installed ? .installed : .notInstalled + } + + @discardableResult + func prepareForHelperAction(preparationID: UUID = UUID(), _ action: @escaping (Bool) -> Void) -> UUID { + helperActionPreparationID = preparationID + var didHandleResponse = false + isPreparingUserForActionRequiringHelper = { [weak self] userConsented in + guard let self else { return } + guard self.helperActionPreparationID == preparationID else { return } + guard didHandleResponse == false else { return } + didHandleResponse = true + helperActionPreparationID = nil + isPreparingUserForActionRequiringHelper = nil + action(userConsented) + } + presentedAlert = .privilegedHelper + return preparationID } - + + func respondToPreparedHelperAction(userConsented: Bool) { + let helperAction = isPreparingUserForActionRequiringHelper + helperAction?(userConsented) + presentedAlert = nil + } + // MARK: - Install - + func checkMinVersionAndInstall(id: XcodeID) { guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } - - // Check to see if users macOS is supported - if let requiredMacOSVersion = availableXcode.requiredMacOSVersion { - if hasMinSupportedOS(requiredMacOSVersion: requiredMacOSVersion) { - // prompt - self.presentedAlert = .checkMinSupportedVersion(xcode: availableXcode, macOS: ProcessInfo.processInfo.operatingSystemVersion.versionString()) - return - } + + switch compatibilityStatus(for: availableXcode) { + case .supported: + break + case let .unsupported(_, currentMacOSVersion): + self.presentedAlert = .checkMinSupportedVersion(xcode: availableXcode, macOS: currentMacOSVersion) + return } - + switch self.dataSource { case .apple: install(id: id) @@ -543,165 +535,144 @@ class AppState: ObservableObject { install(id: id) } } - + func hasMinSupportedOS(requiredMacOSVersion: String) -> Bool { - let split = requiredMacOSVersion.components(separatedBy: ".").compactMap { Int($0) } - let xcodeMinimumMacOSVersion = OperatingSystemVersion(majorVersion: split[safe: 0] ?? 0, minorVersion: split[safe: 1] ?? 0, patchVersion: split[safe: 2] ?? 0) - - return !ProcessInfo.processInfo.isOperatingSystemAtLeast(xcodeMinimumMacOSVersion) + compatibilityStatus(requiredMacOSVersion: requiredMacOSVersion).isUnsupported + } + + private func compatibilityStatus(for availableXcode: AvailableXcode) -> XcodeCompatibilityStatus { + XcodeCompatibilityService().status( + for: availableXcode, + currentOSVersion: ProcessInfo.processInfo.operatingSystemVersion + ) } - + + private func compatibilityStatus(requiredMacOSVersion: String) -> XcodeCompatibilityStatus { + XcodeCompatibilityService().status( + requiredMacOSVersion: requiredMacOSVersion, + currentOSVersion: ProcessInfo.processInfo.operatingSystemVersion + ) + } + func install(id: XcodeID) { guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } - installationPublishers[id] = signInIfNeeded() - .handleEvents( - receiveSubscription: { [unowned self] _ in - self.setInstallationStep(of: availableXcode.version, to: .authenticating) + installationTasks[id]?.cancel() + let installationTaskID = UUID() + installationTaskIDs[id] = installationTaskID + installationTasks[id] = Task { @MainActor in + defer { + if installationTaskIDs[id] == installationTaskID { + installationTasks[id] = nil + installationTaskIDs[id] = nil } - ) - .flatMap { [unowned self] in - // signInIfNeeded might finish before the user actually authenticates if UI is involved. - // This publisher will wait for the @Published authentication state to change to authenticated or unauthenticated before finishing, - // indicating that the user finished what they were doing in the UI. - self.$authenticationState - .filter { state in - switch state { - case .authenticated, .unauthenticated, .notAppleDeveloper: return true - case .waitingForSecondFactor: return false - } - } - .prefix(1) - .tryMap { state in - if state == .unauthenticated { - throw AuthenticationError.invalidSession - } - if state == .notAppleDeveloper { - throw AuthenticationError.notDeveloperAppleId - } - return Void() - } } - .flatMap { - // This request would've already been made if the Apple data source were being used. - // That's not the case for the Xcode Releases data source. - // We need the cookies from its response in order to download Xcodes though, - // so perform it here first just to be sure. - Current.network.dataTask(with: URLRequest.downloads) - .map(\.data) - .decode(type: Downloads.self, decoder: configure(JSONDecoder()) { - $0.dateDecodingStrategy = .formatted(.downloadsDateModified) - }) - .tryMap { downloads -> Void in - if downloads.hasError { - throw AuthenticationError.invalidResult(resultString: downloads.resultsString) - } - if downloads.downloads == nil { - throw AuthenticationError.invalidResult(resultString: localizeString("DownloadingError")) - } - } - .mapError { $0 as Error } + do { + setInstallationStep(of: availableXcode.version, to: .authenticating) + try await signInIfNeededAsync() + try await waitForAuthenticationTerminalState() + try await validateDeveloperDownloads() + _ = try await installAsync(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2, attemptNumber: 0) + } catch is CancellationError { + } catch { + handleInstallError(error, id: id) } - .flatMap { [unowned self] in - self.install(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2) + } + } + + private func validateDeveloperDownloads() async throws { + do { + try await XcodeListService { request in + try await Current.network.dataTaskAsync(with: request) + }.validateDeveloperDownloads(missingDownloadsMessage: localizeString("DownloadingError")) + } catch let error as XcodeListService.Error { + switch error { + case let .invalidResult(resultString): + throw AuthenticationError.invalidResult(resultString: resultString) } - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [unowned self] completion in - self.installationPublishers[id] = nil - if case let .failure(error) = completion { - // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead - if let error = error as? AuthenticationError, case .notAuthorized = error { - self.error = error - self.presentedAlert = .unauthenticated - - } else if error as? AuthenticationError != .invalidSession { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) - } - if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { - self.allXcodes[index].installState = .notInstalled - } - } - }, - receiveValue: { _ in } - ) + } } - + /// Skips using the username/password to log in to Apple, and simply gets a Auth Cookie used in downloading /// As of Nov 2022 this was returning a 403 forbidden func installWithoutLogin(id: Xcode.ID) { guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } - - installationPublishers[id] = self.install(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [unowned self] completion in - self.installationPublishers[id] = nil - if case let .failure(error) = completion { - // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead - if error as? AuthenticationError != .invalidSession { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) - } - if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { - self.allXcodes[index].installState = .notInstalled - } - } - }, - receiveValue: { _ in } - ) + + installationTasks[id]?.cancel() + let installationTaskID = UUID() + installationTaskIDs[id] = installationTaskID + installationTasks[id] = Task { @MainActor in + defer { + if installationTaskIDs[id] == installationTaskID { + installationTasks[id] = nil + installationTaskIDs[id] = nil + } + } + do { + _ = try await installAsync(.version(availableXcode), downloader: Downloader(rawValue: Current.defaults.string(forKey: "downloader") ?? "aria2") ?? .aria2, attemptNumber: 0) + } catch is CancellationError { + } catch { + handleInstallError(error, id: id) + } + } } - + func cancelInstall(id: Xcode.ID) { guard let availableXcode = availableXcodes.first(where: { $0.xcodeID == id }) else { return } - // Cancel the publisher - installationPublishers[id] = nil - + installationTasks[id]?.cancel() + installationTasks[id] = nil + installationTaskIDs[id] = nil + resetDockProgressTracking() - + // If the download is cancelled by the user, clean up the download files that aria2 creates. // This isn't done as part of the publisher with handleEvents(receiveCancel:) because it shouldn't happen when e.g. the app quits. - let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" - let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2") - try? Current.files.removeItem(at: expectedArchivePath.url) - try? Current.files.removeItem(at: aria2DownloadMetadataPath.url) - - if let index = allXcodes.firstIndex(where: { $0.id == id }) { + ArchiveCancellationCleanupService( + removeItem: { try Current.files.removeItem(at: $0) } + ).cleanupXcodeArchive( + for: availableXcode, + applicationSupportPath: .xcodesApplicationSupport + ) + + if let index = allXcodes.firstIndex(where: { $0.id == id }) { allXcodes[index].installState = .notInstalled } } - + // MARK: - Uninstall func uninstall(xcode: Xcode) { - guard - let installedXcodePath = xcode.installedPath, - uninstallPublisher == nil - else { return } - - uninstallPublisher = uninstallXcode(path: installedXcodePath) - .flatMap { [unowned self] _ in - self.updateSelectedXcodePath() + guard let installedXcodePath = xcode.installedPath else { return } + + uninstallTask?.cancel() + let taskID = UUID() + uninstallTaskID = taskID + uninstallTask = Task { @MainActor in + defer { + if uninstallTaskID == taskID { + uninstallTask = nil + uninstallTaskID = nil + } } - .sink( - receiveCompletion: { [unowned self] completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription) - } - self.uninstallPublisher = nil - }, - receiveValue: { _ in } - ) + do { + try Task.checkCancellation() + try await uninstallXcodeAsync(path: installedXcodePath) + try Task.checkCancellation() + await updateSelectedXcodePathAsync() + } catch is CancellationError { + } catch { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription) + } + } } - + func reveal(_ path: Path?) { // TODO: show error if not guard let path = path else { return } NSWorkspace.shared.activateFileViewerSelecting([path.url]) } - + func reveal(path: String) { let url = URL(fileURLWithPath: path) NSWorkspace.shared.activateFileViewerSelecting([url]) @@ -710,7 +681,7 @@ class AppState: ObservableObject { /// Make an Xcode active, a.k.a select it, in the `xcode-select` sense. /// /// The underlying work is done by the privileged helper, so we need to make sure that it's installed first. - /// The way this is done is a little roundabout, because it requires user interaction in an alert before the `selectPublisher` is subscribed to. + /// The way this is done is a little roundabout, because it requires user interaction in an alert before the selection task is started. /// The first time this method is invoked should be with `shouldPrepareUserForHelperInstallation` set to true. /// If the helper is already installed, the Xcode will be made active immediately. /// If the helper is not already installed, the user will be prepared for installation and this method will return early. @@ -721,47 +692,49 @@ class AppState: ObservableObject { /// - Parameter shouldPrepareUserForHelperInstallation: Whether the user should be presented with an alert preparing them for helper installation before making the Xcode version active. func select(xcode: Xcode, shouldPrepareUserForHelperInstallation: Bool = true) { guard helperInstallState == .installed || shouldPrepareUserForHelperInstallation == false else { - isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in + prepareForHelperAction { [weak self] userConsented in guard userConsented else { return } - self.select(xcode: xcode, shouldPrepareUserForHelperInstallation: false) + self?.select(xcode: xcode, shouldPrepareUserForHelperInstallation: false) } - presentedAlert = .privilegedHelper return } guard - var installedXcodePath = xcode.installedPath, - selectPublisher == nil + var installedXcodePath = xcode.installedPath else { return } - + if onSelectActionType == .rename { guard let newDestinationXcodePath = renameToXcode(xcode: xcode) else { return } installedXcodePath = newDestinationXcodePath } - - selectPublisher = installHelperIfNecessary() - .flatMap { - Current.helper.switchXcodePath(installedXcodePath.string) + + selectTask?.cancel() + let taskID = UUID() + selectTaskID = taskID + selectTask = Task { @MainActor in + defer { + if selectTaskID == taskID { + selectTask = nil + selectTaskID = nil + } } - .flatMap { [unowned self] _ in - self.updateSelectedXcodePath() + do { + try await installHelperIfNecessaryAsync() + try Task.checkCancellation() + try await Current.helper.switchXcodePathAsync(installedXcodePath.string) + try Task.checkCancellation() + await updateSelectedXcodePathAsync() + if createSymLinkOnSelect && onSelectActionType != .rename { + createSymbolicLink(to: installedXcodePath) + } + } catch is CancellationError { + } catch { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.Select.Error.Title"), message: error.legibleLocalizedDescription) } - .sink( - receiveCompletion: { [unowned self] completion in - if case let .failure(error) = completion { - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.Select.Error.Title"), message: error.legibleLocalizedDescription) - } else { - if self.createSymLinkOnSelect { - createSymbolicLink(xcode: xcode) - } - } - self.selectPublisher = nil - }, - receiveValue: { _ in } - ) + } } - + func open(xcode: Xcode, openInRosetta: Bool? = false) { switch xcode.installState { case let .installed(path): @@ -776,10 +749,10 @@ class AppState: ObservableObject { return } } - + func copyPath(xcode: Xcode) { guard let installedXcodePath = xcode.installedPath else { return } - + NSPasteboard.general.declareTypes([.URL, .string], owner: nil) NSPasteboard.general.writeObjects([installedXcodePath.url as NSURL]) NSPasteboard.general.setString(installedXcodePath.string, forType: .string) @@ -791,64 +764,51 @@ class AppState: ObservableObject { NSPasteboard.general.writeObjects([url as NSURL]) NSPasteboard.general.setString(url.absoluteString, forType: .string) } - + func createSymbolicLink(xcode: Xcode, isBeta: Bool = false) { guard let installedXcodePath = xcode.installedPath else { return } - - let destinationPath: Path = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app" - - // does an Xcode.app file exist? - if FileManager.default.fileExists(atPath: destinationPath.string) { - do { - // if it's not a symlink, error because we don't want to delete an actual xcode.app file - let attributes: [FileAttributeKey : Any]? = try? FileManager.default.attributesOfItem(atPath: destinationPath.string) - - if attributes?[.type] as? FileAttributeType == FileAttributeType.typeSymbolicLink { - try FileManager.default.removeItem(atPath: destinationPath.string) - Logger.appState.info("Successfully deleted old symlink") - } else { - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: localizeString("Alert.SymLink.Message")) - return - } - } catch { - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.localizedDescription) - } - } - + createSymbolicLink(to: installedXcodePath, isBeta: isBeta) + } + + func createSymbolicLink(to installedXcodePath: Path, isBeta: Bool = false) { + let destinationPath = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app" + do { - try FileManager.default.createSymbolicLink(atPath: destinationPath.string, withDestinationPath: installedXcodePath.string) + let service = XcodeSelectionFilesystemService( + installedXcode: { Current.files.installedXcode(destination: $0) } + ) + let result = try service.createSymbolicLink( + to: installedXcodePath, + in: Path.installDirectory, + isBeta: isBeta + ) + if result.replacedExistingSymlink { + Logger.appState.info("Successfully deleted old symlink") + } Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app") } catch { Logger.appState.error("Unable to create symbolic Link") self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) + let message = error as? XcodeSelectionFilesystemError == .destinationExistsAndIsNotSymlink(destinationPath) + ? localizeString("Alert.SymLink.Message") + : error.legibleLocalizedDescription + self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: message) } } - + func renameToXcode(xcode: Xcode) -> Path? { guard let installedXcodePath = xcode.installedPath else { return nil } - - let destinationPath: Path = Path.installDirectory/"Xcode.app" - - // rename any old named `Xcode.app` to the Xcodes versioned named files - if FileManager.default.fileExists(atPath: destinationPath.string) { - if let originalXcode = Current.files.installedXcode(destination: destinationPath) { - let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app" - Logger.appState.debug("Found Xcode.app - renaming back to \(newName)") - do { - try destinationPath.rename(to: newName) - } catch { - Logger.appState.error("Unable to create rename Xcode.app back to original") - self.error = error - // TODO UPDATE MY ERROR STRING - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) - } - } - } - // rename passed in xcode to xcode.app - Logger.appState.debug("Found Xcode.app - renaming back to Xcode.app") + do { - return try installedXcodePath.rename(to: "Xcode.app") + let service = XcodeSelectionFilesystemService( + installedXcode: { Current.files.installedXcode(destination: $0) } + ) + let renamedPath = try service.renameForSelection( + installedXcodePath: installedXcodePath, + in: Path.installDirectory + ) + Logger.appState.debug("Renamed selected Xcode to Xcode.app") + return renamedPath } catch { Logger.appState.error("Unable to create rename Xcode.app back to original") self.error = error @@ -859,124 +819,71 @@ class AppState: ObservableObject { } func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) { - var adjustedAvailableXcodes = availableXcodes - - // First, adjust all of the available Xcodes so that available and installed versions line up and the second part of this function works properly. - if dataSource == .apple { - for installedXcode in installedXcodes { - // We can trust that build metadata identifiers are unique for each version of Xcode, so if we have it then it's all we need. - // If build metadata matches exactly, replace the available version with the installed version. - // This should handle Apple versions from /downloads/more which don't have build metadata identifiers. - if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) { - adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID - } - // If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version - // Not all prerelease Apple versions available online include build metadata - else if let index = adjustedAvailableXcodes.firstIndex(where: { availableXcode in - availableXcode.version.isEquivalent(to: installedXcode.version) && - availableXcode.version.buildMetadataIdentifiers.isEmpty - }) { - adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID - } - } - } + let existingXcodes = allXcodes.map(\.listItem) + let items = XcodeListComposer().compose( + availableXcodes: availableXcodes, + installedXcodes: installedXcodes, + selectedXcodePath: selectedXcodePath, + existingXcodes: existingXcodes, + dataSource: dataSource + ) - // Map all of the available versions into Xcode values that join available and installed Xcode data for display. - var newAllXcodes = adjustedAvailableXcodes - .filter { availableXcode in - // If we don't have the build identifier, don't attempt to filter prerelease versions with identical build identifiers - guard !availableXcode.version.buildMetadataIdentifiers.isEmpty else { return true } - - let availableXcodesWithIdenticalBuildIdentifiers = availableXcodes - .filter({ $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers }) - - // Include this version if there's only one with this build identifier - return availableXcodesWithIdenticalBuildIdentifiers.count == 1 || - // Or if there's more than one with this build identifier and this is the release version - - availableXcodesWithIdenticalBuildIdentifiers.count > 1 && (availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.architectures?.count ?? 0 != 0) - } - .map { availableXcode -> Xcode in - let installedXcode = installedXcodes.first(where: { installedXcode in - // if we want to have only specific Xcodes as selected instead of the Architecture Equivalent. - // if availableXcode.architectures == nil { -// return availableXcode.version.isEquivalent(to: installedXcode.version) -// } else { -// return availableXcode.xcodeID == installedXcode.xcodeID -// } - return availableXcode.version.isEquivalent(to: installedXcode.version) - }) - - let identicalBuilds: [XcodeID] - let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = availableXcodes - .filter { - return $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers && - !$0.version.prereleaseIdentifiers.isEmpty && - // If we don't have the build identifier, don't consider this as a potential identical build - !$0.version.buildMetadataIdentifiers.isEmpty - } - // If this is the release version, add the identical builds to it - if !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, availableXcode.version.prereleaseIdentifiers.isEmpty { - identicalBuilds = [availableXcode.xcodeID] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.xcodeID) - } else { - identicalBuilds = [] - } - - // If the existing install state is "installing", keep it - let existingXcodeInstallState = allXcodes.first { $0.id == availableXcode.xcodeID && $0.installState.installing }?.installState - // Otherwise, determine it from whether there's an installed Xcode - let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled - - return Xcode( - version: availableXcode.version, - identicalBuilds: identicalBuilds, - installState: existingXcodeInstallState ?? defaultXcodeInstallState, - selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true, - icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)), - requiredMacOSVersion: availableXcode.requiredMacOSVersion, - releaseNotesURL: availableXcode.releaseNotesURL, - releaseDate: availableXcode.releaseDate, - sdks: availableXcode.sdks, - compilers: availableXcode.compilers, - downloadFileSize: availableXcode.fileSize, - architectures: availableXcode.architectures - ) - } - - // If an installed version isn't listed in the available versions, add the installed version - // Xcode Releases should have all versions - // Apple didn't used to keep all prerelease versions around but has started to recently - for installedXcode in installedXcodes { - if !newAllXcodes.contains(where: { xcode in xcode.version.isEquivalent(to: installedXcode.version) }) { - newAllXcodes.append( - Xcode( - version: installedXcode.version, - installState: .installed(installedXcode.path), - selected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true, - icon: NSWorkspace.shared.icon(forFile: installedXcode.path.string) - ) - ) - } + self.allXcodes = items.map { item in + Xcode(item, icon: item.installedPath.map { NSWorkspace.shared.icon(forFile: $0.string) }) } - - self.allXcodes = newAllXcodes.sorted { $0.version > $1.version } } - + // MARK: - Private - - private func uninstallXcode(path: Path) -> AnyPublisher { - return Deferred { - Future { promise in - do { - try Current.files.trashItem(at: path.url) - promise(.success(())) - } catch { - promise(.failure(error)) - } + + private func uninstallXcodeAsync(path: Path) async throws { + let xcode = InstalledXcode( + path: path, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + )! + _ = try XcodeUninstallService( + removeItem: { url in try Current.files.removeItem(at: url) }, + trashItem: { url in try Current.files.trashItem(at: url) } + ).uninstall(xcode, emptyTrash: false) + } + + private func waitForAuthenticationTerminalState() async throws { + func validate(_ state: AuthenticationState) throws -> Bool { + switch state { + case .authenticated: + return true + case .unauthenticated: + throw AuthenticationError.invalidSession + case .notAppleDeveloper: + throw AuthenticationError.notDeveloperAppleId + case .waitingForSecondFactor: + return false } } - .eraseToAnyPublisher() + + try Task.checkCancellation() + if try validate(authenticationState) { return } + + for await state in $authenticationState.values { + try Task.checkCancellation() + if try validate(state) { return } + } + } + + private func handleInstallError(_ error: Error, id: XcodeID) { + // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead + if let error = error as? AuthenticationError, case .notAuthorized = error { + self.error = error + self.presentedAlert = .unauthenticated + + } else if error as? AuthenticationError != .invalidSession { + self.error = error + self.presentedAlert = .generic(title: localizeString("Alert.Install.Error.Title"), message: error.legibleLocalizedDescription) + } + if let index = self.allXcodes.firstIndex(where: { $0.id == id }) { + self.allXcodes[index].installState = .notInstalled + } } /// removes saved username and credentials stored in keychain @@ -996,9 +903,3 @@ class AppState: ObservableObject { var id: String { title + message } } } - -extension OperatingSystemVersion { - func versionString() -> String { - return String(majorVersion) + "." + String(minorVersion) + "." + String(patchVersion) - } -} diff --git a/Xcodes/Backend/DataSource.swift b/Xcodes/Backend/DataSource.swift index 03911848..ef4e7331 100644 --- a/Xcodes/Backend/DataSource.swift +++ b/Xcodes/Backend/DataSource.swift @@ -1,19 +1,7 @@ -import Foundation +import XcodesKit -public enum DataSource: String, CaseIterable, Identifiable, CustomStringConvertible { - case apple - case xcodeReleases - - public var id: Self { self } - - public static var `default` = DataSource.xcodeReleases - - public var description: String { - switch self { - case .apple: return "Apple" - case .xcodeReleases: return "Xcode Releases" - } - } +public typealias DataSource = XcodesKit.DataSource +extension DataSource { var isManaged: Bool { PreferenceKey.dataSource.isManaged() } } diff --git a/Xcodes/Backend/Downloader.swift b/Xcodes/Backend/Downloader.swift index e155703a..ea048e77 100644 --- a/Xcodes/Backend/Downloader.swift +++ b/Xcodes/Backend/Downloader.swift @@ -1,18 +1,7 @@ -import Foundation -import Path +import XcodesKit -public enum Downloader: String, CaseIterable, Identifiable, CustomStringConvertible { - case aria2 - case urlSession - - public var id: Self { self } - - public var description: String { - switch self { - case .urlSession: return "URLSession" - case .aria2: return "aria2" - } - } +public typealias Downloader = XcodeArchiveDownloader +extension Downloader { var isManaged: Bool { PreferenceKey.downloader.isManaged() } } diff --git a/Xcodes/Backend/Downloads.swift b/Xcodes/Backend/Downloads.swift deleted file mode 100644 index aab97790..00000000 --- a/Xcodes/Backend/Downloads.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation -import Path -import Version - -struct Downloads: Codable { - let resultCode: Int - let resultsString: String? - let downloads: [Download]? - - var hasError: Bool { - return resultCode != 0 - } -} - -// Set to Int64 as ByteCountFormatter uses it. -public typealias ByteCount = Int64 - -public struct Download: Codable { - public let name: String - public let files: [File] - public let dateModified: Date - - public struct File: Codable { - public let remotePath: String - public let fileSize: ByteCount - } -} diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index b515e11e..47fb9420 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -1,9 +1,9 @@ -import Combine import Foundation import Path -import AppleAPI import KeychainAccess import XcodesKit +import XcodesLoginKit +import os /** Lightweight dependency injection using global mutable state :P @@ -11,326 +11,125 @@ import XcodesKit - SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable - SeeAlso: https://vimeo.com/291588126 */ -public struct Environment { +public struct Environment: Sendable { public var shell = Shell() public var files = Files() public var network = Network() public var keychain = Keychain() public var defaults = Defaults() - public var date: () -> Date = Date.init + public var date: @Sendable () -> Date = { Date() } public var helper = Helper() public var notificationManager = NotificationManager() } -public var Current = Environment() +private let currentEnvironment = CurrentEnvironmentStorage(Environment()) -public struct Shell { - public var unxip: (URL) -> AnyPublisher = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") } - public var spctlAssess: (URL) -> AnyPublisher = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") } - public var codesignVerify: (URL) -> AnyPublisher = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") } - public var buildVersion: () -> AnyPublisher = { Process.run(Path.root.usr.bin.sw_vers, "-buildVersion") } - public var xcodeBuildVersion: (InstalledXcode) -> AnyPublisher = { Process.run(Path.root.usr.libexec.PlistBuddy, "-c", "Print :ProductBuildVersion", "\($0.path.string)/Contents/version.plist") } - public var getUserCacheDir: () -> AnyPublisher = { Process.run(Path.root.usr.bin.getconf, "DARWIN_USER_CACHE_DIR") } - public var touchInstallCheck: (String, String, String) -> AnyPublisher = { Process.run(Path.root.usr.bin/"touch", "\($0)com.apple.dt.Xcode.InstallCheckCache_\($1)_\($2)") } +public var Current: Environment { + get { currentEnvironment.value } + set { currentEnvironment.value = newValue } +} - public var xcodeSelectPrintPath: () -> AnyPublisher = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } - - public var downloadWithAria2: (Path, URL, Path, [HTTPCookie]) -> (Progress, AnyPublisher) = { aria2Path, url, destination, cookies in - let process = Process() - process.executableURL = aria2Path.url - process.arguments = [ - "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", - "--max-connection-per-server=16", - "--split=16", - "--summary-interval=1", - "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process - "--dir=\(destination.parent.string)", - "--out=\(destination.basename())", - "--human-readable=false", // sets the output to use bytes instead of formatting - url.absoluteString, - ] - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - var progress = Progress() - progress.kind = .file - progress.fileOperationKind = .downloading - - let observer = NotificationCenter.default.addObserver( - forName: .NSFileHandleDataAvailable, - object: nil, - queue: OperationQueue.main - ) { note in - guard - // This should always be the case for Notification.Name.NSFileHandleDataAvailable - let handle = note.object as? FileHandle, - handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading - else { return } - - defer { handle.waitForDataInBackgroundAndNotify() } - - let string = String(decoding: handle.availableData, as: UTF8.self) - - progress.updateFromAria2(string: string) - } +private final class CurrentEnvironmentStorage: Sendable { + private let environment: OSAllocatedUnfairLock - stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - - do { - try process.run() - } catch { - return (progress, Fail(error: error).eraseToAnyPublisher()) - } + var value: Environment { + get { environment.withLock { $0 } } + set { environment.withLock { $0 = newValue } } + } - let publisher = Deferred { - Future { promise in - DispatchQueue.global(qos: .default).async { - process.waitUntilExit() - - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { - return promise(.failure(aria2cError)) - } else { - return promise(.failure(ProcessExecutionError(process: process, standardOutput: "", standardError: ""))) - } - } - promise(.success(())) - } - } - } - .handleEvents(receiveCancel: { - process.terminate() - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - }) - .eraseToAnyPublisher() - - return (progress, publisher) + init(_ environment: Environment) { + self.environment = OSAllocatedUnfairLock(initialState: environment) } +} + +public struct Shell: Sendable { + private static let shared = XcodesShell() + + public var unxip = Shell.shared.unxip + public var spctlAssess = Shell.shared.spctlAssess + public var codesignVerify = Shell.shared.codesignVerify + public var buildVersion = Shell.shared.buildVersion + public var xcodeBuildVersion = Shell.shared.xcodeBuildVersion + public var archs = Shell.shared.archs + public var getUserCacheDir = Shell.shared.getUserCacheDir + public var touchInstallCheck = Shell.shared.touchInstallCheck + + public var xcodeSelectPrintPath = Shell.shared.xcodeSelectPrintPath - public var downloadWithAria2Async: (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream = { aria2Path, url, destination, cookies in - return AsyncThrowingStream { continuation in - - Task { - // Assume progress will not have data races, so we manually opt-out isolation checks. - nonisolated(unsafe) var progress = Progress() - progress.kind = .file - progress.fileOperationKind = .downloading - - let process = Process() - process.executableURL = aria2Path.url - process.arguments = [ - "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", - "--max-connection-per-server=16", - "--split=16", - "--summary-interval=1", - "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", // if xcodes quits, stop aria2 process - "--dir=\(destination.parent.string)", - "--out=\(destination.basename())", - "--human-readable=false", // sets the output to use bytes instead of formatting - url.absoluteString, - ] - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - let observer = NotificationCenter.default.addObserver( - forName: .NSFileHandleDataAvailable, - object: nil, - queue: OperationQueue.main - ) { note in - guard - // This should always be the case for Notification.Name.NSFileHandleDataAvailable - let handle = note.object as? FileHandle, - handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading - else { return } - - defer { handle.waitForDataInBackgroundAndNotify() } - - let string = String(decoding: handle.availableData, as: UTF8.self) - // TODO: fix warning. ObservingProgressView is currently tied to an updating progress - progress.updateFromAria2(string: string) - - continuation.yield(progress) - } - - stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - - continuation.onTermination = { @Sendable _ in - process.terminate() - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - } - - do { - try process.run() - } catch { - continuation.finish(throwing: error) - } - - process.waitUntilExit() - - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { - continuation.finish(throwing: aria2cError) - } else { - continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: "")) - } - return - } - continuation.finish() - } - } + public var downloadWithAria2Async: @Sendable (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream = { aria2Path, url, destination, cookies in + Aria2DownloadService().download(aria2Path: aria2Path, url: url, destination: destination, cookies: cookies) } - public var unxipExperiment: (URL) -> AnyPublisher = { url in + public var unxipExperiment: @Sendable (URL) async throws -> ProcessOutput = { url in let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)! - return Process.run(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"]) + return try await Process.runAsync(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"]) } - public var downloadRuntime: (String, String, String?) -> AsyncThrowingStream = { platform, version, architecture in - return AsyncThrowingStream { continuation in - Task { - // Assume progress will not have data races, so we manually opt-out isolation checks. - nonisolated(unsafe) var progress = Progress() - progress.kind = .file - progress.fileOperationKind = .downloading - - var process = Process() - let xcodeBuildPath = Path.root.usr.bin.join("xcodebuild").url - - process.executableURL = xcodeBuildPath - process.arguments = [ - "-downloadPlatform", - "\(platform)", - "-buildVersion", - "\(version)" - ] - - if let architecture { - process.arguments?.append(contentsOf: [ - "-architectureVariant", - "\(architecture)" - ]) - } - - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - let observer = NotificationCenter.default.addObserver( - forName: .NSFileHandleDataAvailable, - object: nil, - queue: OperationQueue.main - ) { note in - guard - // This should always be the case for Notification.Name.NSFileHandleDataAvailable - let handle = note.object as? FileHandle, - handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading - else { return } - - defer { handle.waitForDataInBackgroundAndNotify() } - - let string = String(decoding: handle.availableData, as: UTF8.self) - - // TODO: fix warning. ObservingProgressView is currently tied to an updating progress - progress.updateFromXcodebuild(text: string) - - continuation.yield(progress) - } - - stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() - - continuation.onTermination = { @Sendable _ in - process.terminate() - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - } - - do { - try process.run() - } catch { - continuation.finish(throwing: error) - } - - process.waitUntilExit() - - NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - continuation.finish(throwing: ProcessExecutionError(process: process, standardOutput: "", standardError: "")) - return - } - continuation.finish() - } - } + public var downloadRuntime: @Sendable (String, String, String?) -> AsyncThrowingStream = { platform, version, architecture in + XcodebuildRuntimeDownloadService().download(platform: platform, buildVersion: version, architecture: architecture) } } -public struct Files { - public var fileExistsAtPath: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } +public struct Files: Sendable { + public var fileExistsAtPath: @Sendable (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } public func fileExists(atPath path: String) -> Bool { return fileExistsAtPath(path) } - public var moveItem: (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) } + public var moveItem: @Sendable (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) } public func moveItem(at srcURL: URL, to dstURL: URL) throws { try moveItem(srcURL, dstURL) } - public var contentsAtPath: (String) -> Data? = { FileManager.default.contents(atPath: $0) } + public var contentsAtPath: @Sendable (String) -> Data? = { FileManager.default.contents(atPath: $0) } public func contents(atPath path: String) -> Data? { return contentsAtPath(path) } - public var removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) } + public var removeItem: @Sendable (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) } public func removeItem(at URL: URL) throws { try removeItem(URL) } - public var trashItem: (URL) throws -> URL = { try FileManager.default.trashItem(at: $0) } + public var trashItem: @Sendable (URL) throws -> URL = { try FileManager.default.trashItem(at: $0) } @discardableResult public func trashItem(at URL: URL) throws -> URL { return try trashItem(URL) } - public var createFile: (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) } + public var createFile: @Sendable (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) } @discardableResult public func createFile(atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey : Any]? = nil) -> Bool { return createFile(path, data, attr) } - public var createDirectory: (URL, Bool, [FileAttributeKey : Any]?) throws -> Void = FileManager.default.createDirectory(at:withIntermediateDirectories:attributes:) + public var createDirectory: @Sendable (URL, Bool, [FileAttributeKey : Any]?) throws -> Void = { url, createIntermediates, attributes in + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: createIntermediates, attributes: attributes) + } public func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { try createDirectory(url, createIntermediates, attributes) } - public var installedXcodes = _installedXcodes + public var installedXcodes: @Sendable (Path) -> [InstalledXcode] = { destination in + _installedXcodes(destination: destination) + } public func installedXcode(destination: Path) -> InstalledXcode? { - if Path.isAppBundle(path: destination) && Path.infoPlist(path: destination)?.bundleID == "com.apple.dt.Xcode" { - return InstalledXcode.init(path: destination) - } else { - return nil - } + InstalledXcodeDiscoveryService( + listDirectory: { _ in [] }, + contentsAtPath: contentsAtPath, + loadArchitectures: Current.shell.archs + ).installedXcode(at: destination) } - public var write: (Data, URL) throws -> Void = { try $0.write(to: $1) } + public var write: @Sendable (Data, URL) throws -> Void = { try $0.write(to: $1) } public func write(_ data: Data, to url: URL) throws { try write(data, url) @@ -338,104 +137,152 @@ public struct Files { } private func _installedXcodes(destination: Path) -> [InstalledXcode] { - destination.ls() - .filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" } - .map { $0 } - .compactMap(InstalledXcode.init) + InstalledXcodeDiscoveryService( + listDirectory: { $0.ls() }, + contentsAtPath: { path in FileManager.default.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + ).installedXcodes(in: destination) } -public struct Network { - private static let client = AppleAPI.Client() - - public var dataTask: (URLRequest) -> AnyPublisher = { - AppleAPI.Current.network.session.dataTaskPublisher(for: $0) - .mapError { $0 as Error } - .eraseToAnyPublisher() - } - - public func dataTask(with request: URLRequest) -> AnyPublisher { - dataTask(request) +public struct Network: Sendable { + public private(set) var loginClient: XcodesLoginKit.Client + + public var session: URLSession { + get { loginClient.urlSession } + set { + let loginClient = XcodesLoginKit.Client(urlSession: newValue) + self.loginClient = loginClient + configureDefaultOperations(using: loginClient) + } } - + + public var loadData: @Sendable (URLRequest) async throws -> (Data, URLResponse) + public func dataTaskAsync(with request: URLRequest) async throws -> (Data, URLResponse) { - return try await AppleAPI.Current.network.session.data(for: request) + try await loadData(request) } - public var downloadTask: (URL, URL, Data?) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) } + public var downloadTaskAsync: @Sendable (URL, URL, Data?) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>) - public func downloadTask(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) { - return downloadTask(url, saveLocation, resumeData) + public func downloadTaskAsync(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, task: Task<(saveLocation: URL, response: URLResponse), Error>) { + downloadTaskAsync(url, saveLocation, resumeData) } - public var validateSession: () -> AnyPublisher = { - return client.validateSession() + public var validateSessionAsync: @Sendable () async throws -> Void + + public var signout: @Sendable () -> Void + + public init( + session: URLSession? = nil, + loadData: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil, + downloadTaskAsync: (@Sendable (URL, URL, Data?) -> (Progress, Task<(saveLocation: URL, response: URLResponse), Error>))? = nil, + validateSessionAsync: (@Sendable () async throws -> Void)? = nil, + signout: (@Sendable () -> Void)? = nil + ) { + let loginClient: XcodesLoginKit.Client + if let session { + loginClient = XcodesLoginKit.Client(urlSession: session) + } else { + loginClient = XcodesLoginKit.Client() + } + self.loginClient = loginClient + self.loadData = loadData ?? { request in + try await loginClient.urlSession.data(for: request) + } + self.downloadTaskAsync = downloadTaskAsync ?? { url, saveLocation, resumeData in + loginClient.urlSession.downloadTaskAsync(with: url, to: saveLocation, resumingWith: resumeData) + } + self.validateSessionAsync = validateSessionAsync ?? { + _ = try await loginClient.validateSession() + } + self.signout = signout ?? { + loginClient.signout() + } + } + + private mutating func configureDefaultOperations(using loginClient: XcodesLoginKit.Client) { + self.loadData = { request in + try await loginClient.urlSession.data(for: request) + } + self.downloadTaskAsync = { url, saveLocation, resumeData in + loginClient.urlSession.downloadTaskAsync(with: url, to: saveLocation, resumingWith: resumeData) + } + self.validateSessionAsync = { + _ = try await loginClient.validateSession() + } + self.signout = { + loginClient.signout() + } } } -public struct Keychain { - private static let keychain = KeychainAccess.Keychain(service: "com.robotsandpencils.XcodesApp") +public struct Keychain: Sendable { + private static var keychain: KeychainAccess.Keychain { + KeychainAccess.Keychain(service: "com.robotsandpencils.XcodesApp") + } - public var getString: (String) throws -> String? = keychain.getString(_:) + public var getString: @Sendable (String) throws -> String? = { try keychain.getString($0) } public func getString(_ key: String) throws -> String? { try getString(key) } - public var set: (String, String) throws -> Void = keychain.set(_:key:) + public var set: @Sendable (String, String) throws -> Void = { try keychain.set($0, key: $1) } public func set(_ value: String, key: String) throws { try set(value, key) } - public var remove: (String) throws -> Void = keychain.remove(_:) + public var remove: @Sendable (String) throws -> Void = { try keychain.remove($0) } public func remove(_ key: String) throws -> Void { try remove(key) } } -public struct Defaults { - public var string: (String) -> String? = { UserDefaults.standard.string(forKey: $0) } +public struct Defaults: Sendable { + public var string: @Sendable (String) -> String? = { UserDefaults.standard.string(forKey: $0) } public func string(forKey key: String) -> String? { string(key) } - public var date: (String) -> Date? = { Date(timeIntervalSince1970: UserDefaults.standard.double(forKey: $0)) } + public var date: @Sendable (String) -> Date? = { Date(timeIntervalSince1970: UserDefaults.standard.double(forKey: $0)) } public func date(forKey key: String) -> Date? { date(key) } - public var setDate: (Date?, String) -> Void = { UserDefaults.standard.set($0?.timeIntervalSince1970, forKey: $1) } + public var setDate: @Sendable (Date?, String) -> Void = { UserDefaults.standard.set($0?.timeIntervalSince1970, forKey: $1) } public func setDate(_ value: Date?, forKey key: String) { setDate(value, key) } - public var set: (Any?, String) -> Void = { UserDefaults.standard.set($0, forKey: $1) } + public var set: @Sendable (Any?, String) -> Void = { UserDefaults.standard.set($0, forKey: $1) } public func set(_ value: Any?, forKey key: String) { set(value, key) } - public var removeObject: (String) -> Void = { UserDefaults.standard.removeObject(forKey: $0) } + public var removeObject: @Sendable (String) -> Void = { UserDefaults.standard.removeObject(forKey: $0) } public func removeObject(forKey key: String) { removeObject(key) } - public var get: (String) -> Any? = { UserDefaults.standard.value(forKey: $0) } + public var get: @Sendable (String) -> Any? = { UserDefaults.standard.value(forKey: $0) } public func get(forKey key: String) -> Any? { get(key) } - public var bool: (String) -> Bool? = { UserDefaults.standard.bool(forKey: $0) } + public var bool: @Sendable (String) -> Bool? = { UserDefaults.standard.bool(forKey: $0) } public func bool(forKey key: String) -> Bool? { bool(key) } } +@MainActor private let helperClient = HelperClient() -public struct Helper { - var install: () throws -> Void = helperClient.install - var checkIfLatestHelperIsInstalled: () -> AnyPublisher = helperClient.checkIfLatestHelperIsInstalled - var getVersion: () -> AnyPublisher = helperClient.getVersion - var switchXcodePath: (_ absolutePath: String) -> AnyPublisher = helperClient.switchXcodePath - var devToolsSecurityEnable: () -> AnyPublisher = helperClient.devToolsSecurityEnable - var addStaffToDevelopersGroup: () -> AnyPublisher = helperClient.addStaffToDevelopersGroup - var acceptXcodeLicense: (_ absoluteXcodePath: String) -> AnyPublisher = helperClient.acceptXcodeLicense - var runFirstLaunch: (_ absoluteXcodePath: String) -> AnyPublisher = helperClient.runFirstLaunch +public struct Helper: Sendable { + var install: @Sendable () async throws -> Void = { try await helperClient.install() } + var checkIfLatestHelperIsInstalledAsync: @Sendable () async throws -> Bool = { try await helperClient.checkIfLatestHelperIsInstalledAsync() } + var getVersionAsync: @Sendable () async throws -> String = { try await helperClient.getVersionAsync() } + var switchXcodePathAsync: @Sendable (_ absolutePath: String) async throws -> Void = { try await helperClient.switchXcodePathAsync($0) } + var devToolsSecurityEnableAsync: @Sendable () async throws -> Void = { try await helperClient.devToolsSecurityEnableAsync() } + var addStaffToDevelopersGroupAsync: @Sendable () async throws -> Void = { try await helperClient.addStaffToDevelopersGroupAsync() } + var acceptXcodeLicenseAsync: @Sendable (_ absoluteXcodePath: String) async throws -> Void = { try await helperClient.acceptXcodeLicenseAsync(absoluteXcodePath: $0) } + var runFirstLaunchAsync: @Sendable (_ absoluteXcodePath: String) async throws -> Void = { try await helperClient.runFirstLaunchAsync(absoluteXcodePath: $0) } } diff --git a/Xcodes/Backend/FileManager+.swift b/Xcodes/Backend/FileManager+.swift index 72fd1ee6..7ca2d5b9 100644 --- a/Xcodes/Backend/FileManager+.swift +++ b/Xcodes/Backend/FileManager+.swift @@ -1,4 +1,5 @@ import Foundation +import XcodesKit extension FileManager { /** @@ -10,12 +11,10 @@ extension FileManager { */ @discardableResult func trashItem(at url: URL) throws -> URL { - var resultingItemURL: NSURL! if fileExists(atPath: url.path) { - try trashItem(at: url, resultingItemURL: &resultingItemURL) + return try xcodesTrashItem(at: url) } else { throw FileError.fileNotFound(url.lastPathComponent) } - return resultingItemURL as URL } } diff --git a/Xcodes/Backend/Foundation.swift b/Xcodes/Backend/Foundation.swift deleted file mode 100644 index 8ff82aed..00000000 --- a/Xcodes/Backend/Foundation.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -public extension BidirectionalCollection where Element: Equatable { - func suffix(fromLast delimiter: Element) -> Self.SubSequence { - guard - let lastIndex = lastIndex(of: delimiter), - index(after: lastIndex) < endIndex - else { return suffix(0) } - return suffix(from: index(after: lastIndex)) - } -} - -public extension NumberFormatter { - convenience init(numberStyle: NumberFormatter.Style) { - self.init() - self.numberStyle = numberStyle - } - - func string(from number: N) -> String? { - return string(from: number as! NSNumber) - } -} - -extension Sequence { - func sorted(_ keyPath: KeyPath) -> [Element] { - sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) - } -} diff --git a/Xcodes/Backend/Hardware.swift b/Xcodes/Backend/Hardware.swift index 0f7601bb..df4907d2 100644 --- a/Xcodes/Backend/Hardware.swift +++ b/Xcodes/Backend/Hardware.swift @@ -1,28 +1,3 @@ -import Foundation +import XcodesKit - -struct Hardware { - - /// - /// Determines the architecture of the Mac on which we're running. Returns `arm64` for Apple Silicon - /// and `x86_64` for Intel-based Macs or `nil` if the system call fails. - static func getMachineHardwareName() -> String? - { - var sysInfo = utsname() - let retVal = uname(&sysInfo) - var finalString: String? = nil - - if retVal == EXIT_SUCCESS - { - let bytes = Data(bytes: &sysInfo.machine, count: Int(_SYS_NAMELEN)) - finalString = String(data: bytes, encoding: .utf8) - } - - // _SYS_NAMELEN will include a billion null-terminators. Clear those out so string comparisons work as you expect. - return finalString?.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) - } - - static func isAppleSilicon() -> Bool { - return Hardware.getMachineHardwareName() == "arm64" - } -} +typealias Hardware = HostHardware diff --git a/Xcodes/Backend/HelperClient.swift b/Xcodes/Backend/HelperClient.swift index 284d23da..8d2858bb 100644 --- a/Xcodes/Backend/HelperClient.swift +++ b/Xcodes/Backend/HelperClient.swift @@ -1,41 +1,43 @@ -import Combine import Foundation import os.log import ServiceManagement +import XcodesKit +@MainActor final class HelperClient { private var connection: NSXPCConnection? - + private func currentConnection() -> NSXPCConnection? { guard self.connection == nil else { return self.connection } - + let connection = NSXPCConnection(machServiceName: machServiceName, options: .privileged) connection.remoteObjectInterface = NSXPCInterface(with: HelperXPCProtocol.self) - connection.invalidationHandler = { - self.connection?.invalidationHandler = nil - DispatchQueue.main.async { + connection.invalidationHandler = { [weak self, weak connection] in + Task { @MainActor [weak self, weak connection] in + guard let self, let connection, self.connection === connection else { return } + connection.invalidationHandler = nil self.connection = nil } } - + self.connection = connection connection.resume() - + return self.connection } - - private func helper(errorSubject: PassthroughSubject) -> HelperXPCProtocol? { - guard + + private func helper(errorHandler: @escaping @Sendable (Error) -> Void) -> HelperXPCProtocol? { + guard let helper = self.currentConnection()?.remoteObjectProxyWithErrorHandler({ error in - errorSubject.send(completion: .failure(error)) - }) as? HelperXPCProtocol + errorHandler(error) + }) as? HelperXPCProtocol else { return nil } return helper } - - func checkIfLatestHelperIsInstalled() -> AnyPublisher { + + func checkIfLatestHelperIsInstalledAsync() async throws -> Bool { Logger.helperClient.info(#function) let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LaunchServices/" + machServiceName) @@ -43,270 +45,104 @@ final class HelperClient { let helperBundleInfo = CFBundleCopyInfoDictionaryForURL(helperURL as CFURL) as? [String: Any], let bundledHelperVersion = helperBundleInfo["CFBundleShortVersionString"] as? String else { - return Just(false) - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): false") + return false } - - return getVersion() - .map { installedHelperVersion in installedHelperVersion == bundledHelperVersion } - .catch { _ in Just(false) } - // Failure is Never, so don't bother logging completion - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0), privacy: .public)") }) - .eraseToAnyPublisher() + + let isInstalled = try await getVersionAsync() == bundledHelperVersion + Logger.helperClient.info("\(#function): \(String(describing: isInstalled), privacy: .public)") + return isInstalled } - - func getVersion() -> AnyPublisher { + + func getVersionAsync() async throws -> String { Logger.helperClient.info(#function) - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.getVersion { version in - promise(.success(version)) - } + let version = try await performHelperRequest { helper, finish in + helper.getVersion { version in + finish(.success(version)) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0), privacy: .public)") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): \(String(describing: version), privacy: .public)") + return version } - - func switchXcodePath(_ absolutePath: String) -> AnyPublisher { + + func switchXcodePathAsync(_ absolutePath: String) async throws { Logger.helperClient.info("\(#function): \(absolutePath, privacy: .private(mask: .hash))") - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.xcodeSelect(absolutePath: absolutePath, completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + try await performVoidHelperRequest { helper, finish in + helper.xcodeSelect(absolutePath: absolutePath) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): finished") } - - func devToolsSecurityEnable() -> AnyPublisher { + + func devToolsSecurityEnableAsync() async throws { Logger.helperClient.info(#function) - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.devToolsSecurityEnable(completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + try await performVoidHelperRequest { helper, finish in + helper.devToolsSecurityEnable { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): finished") } - - func addStaffToDevelopersGroup() -> AnyPublisher { + + func addStaffToDevelopersGroupAsync() async throws { Logger.helperClient.info(#function) - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.addStaffToDevelopersGroup(completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + try await performVoidHelperRequest { helper, finish in + helper.addStaffToDevelopersGroup { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): finished") } - - func acceptXcodeLicense(absoluteXcodePath: String) -> AnyPublisher { + + func acceptXcodeLicenseAsync(absoluteXcodePath: String) async throws { Logger.helperClient.info("\(#function): \(absoluteXcodePath, privacy: .private(mask: .hash))") - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() - } - - return Deferred { - Future { promise in - helper.acceptXcodeLicense(absoluteXcodePath: absoluteXcodePath, completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + try await performVoidHelperRequest { helper, finish in + helper.acceptXcodeLicense(absoluteXcodePath: absoluteXcodePath) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() + Logger.helperClient.info("\(#function): finished") } - - func runFirstLaunch(absoluteXcodePath: String) -> AnyPublisher { + + func runFirstLaunchAsync(absoluteXcodePath: String) async throws { Logger.helperClient.info("\(#function): \(absoluteXcodePath, privacy: .private(mask: .hash))") - let connectionErrorSubject = PassthroughSubject() - guard - let helper = self.helper(errorSubject: connectionErrorSubject) - else { - return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) - .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) - .eraseToAnyPublisher() + try await performVoidHelperRequest { helper, finish in + helper.runFirstLaunch(absoluteXcodePath: absoluteXcodePath) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + + private func performVoidHelperRequest(_ operation: @escaping @Sendable (HelperXPCProtocol, @escaping @Sendable (Result) -> Void) -> Void) async throws { + try await performHelperRequest(operation) + } + + private func performHelperRequest(_ operation: @escaping @Sendable (HelperXPCProtocol, @escaping @Sendable (Result) -> Void) -> Void) async throws -> T { + let request = OneShotContinuation() + guard let helper = helper(errorHandler: { error in + request.resume(throwing: error) + }) else { + throw HelperClientError.failedToCreateRemoteObjectProxy } - - return Deferred { - Future { promise in - helper.runFirstLaunch(absoluteXcodePath: absoluteXcodePath, completion: { (possibleError) in - if let error = possibleError { - promise(.failure(error)) - } else { - promise(.success(())) - } - }) + + return try await request.value { + operation(helper) { result in + request.resume(with: result) } } - // Take values, but fail when connectionErrorSubject fails - .zip( - connectionErrorSubject - .prepend("") - .map { _ in Void() } - ) - .map { $0.0 } - .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, - receiveCompletion: { completion in - switch completion { - case .finished: - Logger.helperClient.info("\(#function): finished") - case let .failure(error): - Logger.helperClient.error("\(#function): \(String(describing: error))") - } - }) - .eraseToAnyPublisher() - } - + } + // MARK: - Install // From https://github.com/securing/SimpleXPCApp/ - + func install() throws { Logger.helperClient.info(#function) @@ -325,15 +161,15 @@ final class HelperClient { self.connection?.invalidate() self.connection = nil - + Logger.helperClient.info("\(#function): Finished installation") } catch { Logger.helperClient.error("\(#function): \(error.localizedDescription)") - + throw error } } - + private func executeAuthorizationFunction(_ authorizationFunction: () -> (OSStatus) ) throws { let osStatus = authorizationFunction() guard osStatus == errAuthorizationSuccess else { @@ -344,7 +180,7 @@ final class HelperClient { } } } - + func authorizationRef(_ rights: UnsafePointer?, _ environment: UnsafePointer?, _ flags: AuthorizationFlags) throws -> AuthorizationRef? { @@ -357,7 +193,7 @@ final class HelperClient { enum HelperClientError: LocalizedError { case failedToCreateRemoteObjectProxy case message(String) - + var errorDescription: String? { switch self { case .failedToCreateRemoteObjectProxy: diff --git a/Xcodes/Backend/HelperInstallState.swift b/Xcodes/Backend/HelperInstallState.swift index 922ab071..92c20186 100644 --- a/Xcodes/Backend/HelperInstallState.swift +++ b/Xcodes/Backend/HelperInstallState.swift @@ -1,6 +1,6 @@ import Foundation -public enum HelperInstallState: Equatable { +public enum HelperInstallState: Equatable, Sendable { case unknown case notInstalled case installed diff --git a/Xcodes/Backend/InstalledXcode.swift b/Xcodes/Backend/InstalledXcode.swift deleted file mode 100644 index bc2ddd32..00000000 --- a/Xcodes/Backend/InstalledXcode.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation -import Version -import Path -import XcodesKit - -/// A version of Xcode that's already installed -public struct InstalledXcode: Equatable { - public let path: Path - public let xcodeID: XcodeID - - /// Composed of the bundle short version from Info.plist and the product build version from version.plist - public var version: Version { - return xcodeID.version - } - - public init?(path: Path) { - self.path = path - - let infoPlistPath = path.join("Contents").join("Info.plist") - let versionPlistPath = path.join("Contents").join("version.plist") - guard - let infoPlistData = Current.files.contents(atPath: infoPlistPath.string), - let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData), - let bundleShortVersion = infoPlist.bundleShortVersion, - let bundleVersion = Version(tolerant: bundleShortVersion), - - let versionPlistData = Current.files.contents(atPath: versionPlistPath.string), - let versionPlist = try? PropertyListDecoder().decode(VersionPlist.self, from: versionPlistData) - else { return nil } - - // Installed betas don't include the beta number anywhere, so try to parse it from the filename or fall back to simply "beta" - var prereleaseIdentifiers = bundleVersion.prereleaseIdentifiers - if let filenameVersion = Version(path.basename(dropExtension: true).replacingOccurrences(of: "Xcode-", with: "")) { - prereleaseIdentifiers = filenameVersion.prereleaseIdentifiers - } - else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") { - prereleaseIdentifiers = ["beta"] - } - - let archsString = try? XcodesKit.Current.shell.archs(path.url.appending(path: "Contents/MacOS/Xcode")).out - - let architectures = archsString? - .trimmingCharacters(in: .whitespacesAndNewlines) - .split(separator: " ") - .compactMap { Architecture(rawValue: String($0)) } - - let version = Version(major: bundleVersion.major, - minor: bundleVersion.minor, - patch: bundleVersion.patch, - prereleaseIdentifiers: prereleaseIdentifiers, - buildMetadataIdentifiers: [versionPlist.productBuildVersion].compactMap { $0 }) - - self.xcodeID = XcodeID(version: version, architectures: architectures) - } -} - -public struct InfoPlist: Decodable { - public let bundleID: String? - public let bundleShortVersion: String? - public let bundleIconName: String? - - public enum CodingKeys: String, CodingKey { - case bundleID = "CFBundleIdentifier" - case bundleShortVersion = "CFBundleShortVersionString" - case bundleIconName = "CFBundleIconName" - } -} - -public struct VersionPlist: Decodable { - public let productBuildVersion: String - - public enum CodingKeys: String, CodingKey { - case productBuildVersion = "ProductBuildVersion" - } -} diff --git a/Xcodes/Backend/NotificationManager.swift b/Xcodes/Backend/NotificationManager.swift index 7c2a3af6..4b5fb8c3 100644 --- a/Xcodes/Backend/NotificationManager.swift +++ b/Xcodes/Backend/NotificationManager.swift @@ -29,23 +29,45 @@ public enum XcodesNotificationType: String, Identifiable, CaseIterable, CustomSt } } -public class NotificationManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { +@MainActor +public final class NotificationManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { private let notificationCenter = UNUserNotificationCenter.current() + private var notificationStatusTask: Task? + private var notificationStatusTaskID: UUID? + private var requestAccessTask: Task? + private var requestAccessTaskID: UUID? @Published var notificationStatus = NotificationPermissionPromptStatus.unknown - public override init() { + nonisolated public override init() { super.init() - loadNotificationStatus() - - notificationCenter.delegate = self + Task { @MainActor [weak self] in + guard let self else { return } + loadNotificationStatus() + notificationCenter.delegate = self + } + } + + deinit { + notificationStatusTask?.cancel() + requestAccessTask?.cancel() } public func loadNotificationStatus() { - UNUserNotificationCenter.current().getNotificationSettings(completionHandler: { [weak self] (settings) in + notificationStatusTask?.cancel() + let taskID = UUID() + notificationStatusTaskID = taskID + notificationStatusTask = Task { [weak self] in + let settings = await UNUserNotificationCenter.current().notificationSettings() + guard !Task.isCancelled else { + await self?.clearNotificationStatusTask(id: taskID) + return + } + let status = NotificationManager.systemPromptStatusFromSettings(settings) - self?.notificationStatus = status - }) + await self?.setNotificationStatus(status, ifNotificationStatusTaskID: taskID) + await self?.clearNotificationStatusTask(id: taskID) + } } private class func systemPromptStatusFromSettings(_ settings: UNNotificationSettings) -> NotificationPermissionPromptStatus { @@ -62,18 +84,73 @@ public class NotificationManager: NSObject, UNUserNotificationCenterDelegate, Ob } public func requestAccess() { - - notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in - DispatchQueue.main.async { - if let error = error { - // Handle the error here. - Logger.appState.error("Error requesting notification accesss: \(error.legibleLocalizedDescription)") - } else { - Logger.appState.log("User has \(granted ? "Granted" : "NOT GRANTED") notification permission") + notificationStatusTask?.cancel() + notificationStatusTask = nil + notificationStatusTaskID = nil + requestAccessTask?.cancel() + let taskID = UUID() + requestAccessTaskID = taskID + requestAccessTask = Task { [weak self] in + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) + guard !Task.isCancelled else { + await self?.clearRequestAccessTask(id: taskID) + return + } + + Logger.appState.log("User has \(granted ? "Granted" : "NOT GRANTED") notification permission") + } catch { + guard !Task.isCancelled else { + await self?.clearRequestAccessTask(id: taskID) + return } - self?.loadNotificationStatus() + + Logger.appState.error("Error requesting notification accesss: \(error.legibleLocalizedDescription)") } + + let settings = await UNUserNotificationCenter.current().notificationSettings() + guard !Task.isCancelled else { + await self?.clearRequestAccessTask(id: taskID) + return + } + + let status = NotificationManager.systemPromptStatusFromSettings(settings) + await self?.setNotificationStatus(status, ifRequestAccessTaskID: taskID) + await self?.clearRequestAccessTask(id: taskID) + } + } + + @MainActor + private func setNotificationStatus( + _ status: NotificationPermissionPromptStatus, + ifNotificationStatusTaskID notificationStatusTaskID: UUID? = nil, + ifRequestAccessTaskID requestAccessTaskID: UUID? = nil + ) { + if let notificationStatusTaskID, notificationStatusTaskID != self.notificationStatusTaskID { + return } + + if let requestAccessTaskID, requestAccessTaskID != self.requestAccessTaskID { + return + } + + notificationStatus = status + } + + @MainActor + private func clearNotificationStatusTask(id: UUID) { + guard id == notificationStatusTaskID else { return } + + notificationStatusTask = nil + notificationStatusTaskID = nil + } + + @MainActor + private func clearRequestAccessTask(id: UUID) { + guard id == requestAccessTaskID else { return } + + requestAccessTask = nil + requestAccessTaskID = nil } func scheduleNotification(title: String?, body: String, category: XcodesNotificationCategory) { @@ -93,8 +170,7 @@ public class NotificationManager: NSObject, UNUserNotificationCenterDelegate, Ob // MARK: UNUserNotificationCenterDelegate - public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + nonisolated public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler( [.banner, .badge, .sound]) } } - diff --git a/Xcodes/Backend/Path+.swift b/Xcodes/Backend/Path+.swift index 06bbe63e..f89b08ea 100644 --- a/Xcodes/Backend/Path+.swift +++ b/Xcodes/Backend/Path+.swift @@ -1,46 +1,33 @@ import Path import Foundation +import XcodesKit extension Path { - static let defaultXcodesApplicationSupport = Path.applicationSupport/"com.robotsandpencils.XcodesApp" + static var defaultXcodesApplicationSupport: Path { + XcodesPathResolver.appDefaultApplicationSupport + } + static var xcodesApplicationSupport: Path { - guard let savedApplicationSupport = Current.defaults.string(forKey: "localPath") else { - return defaultXcodesApplicationSupport - } - guard let path = Path(savedApplicationSupport) else { - return defaultXcodesApplicationSupport - } - return path + XcodesPathResolver.appApplicationSupport(savedPath: Current.defaults.string(forKey: "localPath")) } static var cacheFile: Path { - return xcodesApplicationSupport/"available-xcodes.json" + XcodesPathResolver.availableXcodesCacheFile(in: xcodesApplicationSupport) } - static let defaultInstallDirectory = Path.root/"Applications" + static var defaultInstallDirectory: Path { + XcodesPathResolver.appDefaultInstallDirectory + } static var installDirectory: Path { - guard let savedInstallDirectory = Current.defaults.string(forKey: "installPath") else { - return defaultInstallDirectory - } - guard let path = Path(savedInstallDirectory) else { - return defaultInstallDirectory - } - return path + XcodesPathResolver.appInstallDirectory(savedPath: Current.defaults.string(forKey: "installPath")) } static var runtimeCacheFile: Path { - return xcodesApplicationSupport/"downloadable-runtimes.json" + XcodesPathResolver.downloadableRuntimesCacheFile(in: xcodesApplicationSupport) } static var xcodesCaches: Path { - return caches/"com.xcodesorg.xcodesapp" - } - - @discardableResult - func setCurrentUserAsOwner() -> Path { - let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName() - try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string) - return self + XcodesPathResolver.appCaches() } } diff --git a/Xcodes/Backend/Process.swift b/Xcodes/Backend/Process.swift index b3e5a64f..d922c20f 100644 --- a/Xcodes/Backend/Process.swift +++ b/Xcodes/Backend/Process.swift @@ -1,70 +1,18 @@ -import Combine import Foundation -import os.log import Path import XcodesKit -public typealias ProcessOutput = (status: Int32, out: String, err: String) +public typealias ProcessOutput = XcodesKit.ProcessOutput +public typealias ProcessExecutionError = XcodesKit.ProcessExecutionError extension Process { @discardableResult - static func run(_ executable: any Pathish, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher { - return run(executable.url, workingDirectory: workingDirectory, input: input, arguments) + static func runAsync(_ executable: any Pathish, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { + try await runAsync(executable.url, workingDirectory: workingDirectory, input: input, arguments) } @discardableResult - static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) -> AnyPublisher { - Deferred { - Future { promise in - DispatchQueue.global().async { - let process = Process() - process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent() - process.executableURL = executable - process.arguments = arguments - - let (stdout, stderr) = (Pipe(), Pipe()) - process.standardOutput = stdout - process.standardError = stderr - - if let input = input { - let inputPipe = Pipe() - process.standardInput = inputPipe.fileHandleForReading - inputPipe.fileHandleForWriting.write(Data(input.utf8)) - inputPipe.fileHandleForWriting.closeFile() - } - - do { - Logger.subprocess.info("Process.run executable: \(executable), input: \(input ?? ""), arguments: \(arguments.joined(separator: ", "))") - - try process.run() - process.waitUntilExit() - - let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - - Logger.subprocess.info("Process.run output: \(output)") - if !error.isEmpty { - Logger.subprocess.error("Process.run error: \(error)") - } - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - DispatchQueue.main.async { - promise(.failure(ProcessExecutionError(process: process, standardOutput: output, standardError: error))) - } - return - } - - DispatchQueue.main.async { - promise(.success((process.terminationStatus, output, error))) - } - } catch { - DispatchQueue.main.async { - promise(.failure(error)) - } - } - } - } - } - .eraseToAnyPublisher() + static func runAsync(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { + try await XcodesProcess.run(executable, workingDirectory: workingDirectory, input: input, arguments) } } diff --git a/Xcodes/Backend/Progress+.swift b/Xcodes/Backend/Progress+.swift index ce2ff3be..5b720a22 100644 --- a/Xcodes/Backend/Progress+.swift +++ b/Xcodes/Backend/Progress+.swift @@ -1,107 +1,7 @@ -import os.log import Foundation extension Progress { var xcodesLocalizedDescription: String { return localizedAdditionalDescription.replacingOccurrences(of: " — ", with: "\n") } - func updateFromAria2(string: String) { - - let range = NSRange(location: 0, length: string.utf16.count) - - // MARK: Total Downloaded - let regexTotalDownloaded = try! NSRegularExpression(pattern: #"(?<= )(.*)(?=\/)"#) - - if let match = regexTotalDownloaded.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range(at: 0), in: string), - let totalDownloaded = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) { - self.completedUnitCount = Int64(totalDownloaded) - } - - // MARK: Filesize - let regexTotalFileSize = try! NSRegularExpression(pattern: #"(?<=/)(.*)(?=\()"#) - - if let match = regexTotalFileSize.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range(at: 0), in: string), - let totalFileSize = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) { - - if totalFileSize > 0 { - self.totalUnitCount = Int64(totalFileSize) - } - } - - // MARK: PERCENT DOWNLOADED - // Since we get fractionCompleted from completedUnitCount + totalUnitCount, no need to process - // let regexPercent = try! NSRegularExpression(pattern: #"((?\d+)%\))"#) - - // MARK: Speed - let regexSpeed = try! NSRegularExpression(pattern: #"(?<=DL:)(.*)(?= )"#) - - if let match = regexSpeed.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range(at: 0), in: string), - let speed = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) { - self.throughput = speed - } else { - Logger.appState.debug("Could not parse throughput from aria2 download output") - } - - // MARK: Estimated Time Remaining - let regexETA = try! NSRegularExpression(pattern: #"(?<=ETA:)(?\d*h)?(?\d*m)?(?\d*s)?"#) - - if let match = regexETA.firstMatch(in: string, options: [], range: range) { - var seconds: Int = 0 - - if let matchRange = Range(match.range(withName: "hours"), in: string), - let hours = Int(string[matchRange].replacingOccurrences(of: "h", with: "")) { - seconds += (hours * 60 * 60) - } - - if let matchRange = Range(match.range(withName: "minutes"), in: string), - let minutes = Int(string[matchRange].replacingOccurrences(of: "m", with: "")) { - seconds += (minutes * 60) - } - - if let matchRange = Range(match.range(withName: "seconds"), in: string), - let second = Int(string[matchRange].replacingOccurrences(of: "s", with: "")) { - seconds += (second) - } - - self.estimatedTimeRemaining = TimeInterval(seconds) - } - - } - - func updateFromXcodebuild(text: String) { - self.totalUnitCount = 100 - self.completedUnitCount = 0 - self.localizedAdditionalDescription = "" // to not show the addtional - - do { - - let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"# - let downloadRegex = try NSRegularExpression(pattern: downloadPattern) - - // Search for matches in the text - if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) { - // Extract the percentage - simpler then trying to extract size MB/GB and convert to bytes. - if let percentRange = Range(match.range(at: 1), in: text), let percentDouble = Double(text[percentRange]) { - let percent = Int64(percentDouble.rounded()) - self.completedUnitCount = percent - } - } - - // "Downloading tvOS 18.1 Simulator (22J5567a): Installing..." or - // "Downloading tvOS 18.1 Simulator (22J5567a): Installing (registering download)..." - if text.range(of: "Installing") != nil { - // sets the progress to indeterminite to show animating progress - self.totalUnitCount = 0 - self.completedUnitCount = 0 - } - - } catch { - Logger.appState.error("Invalid regular expression") - } - - } } - diff --git a/Xcodes/Backend/Publisher+Resumable.swift b/Xcodes/Backend/Publisher+Resumable.swift deleted file mode 100644 index f7eee9b5..00000000 --- a/Xcodes/Backend/Publisher+Resumable.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Combine -import Foundation - -/// Attempt and retry a task that fails with resume data up to `maximumRetryCount` times -func attemptResumableTask( - maximumRetryCount: Int = 3, - delayBeforeRetry: TimeInterval = 2, - _ body: @escaping (Data?) -> AnyPublisher -) -> AnyPublisher { - var attempts = 0 - func attempt(with resumeData: Data? = nil) -> AnyPublisher { - attempts += 1 - return body(resumeData) - .catch { error -> AnyPublisher in - guard - attempts < maximumRetryCount, - let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data - else { return Fail(error: error).eraseToAnyPublisher() } - - return attempt(with: resumeData) - .delay(for: .seconds(delayBeforeRetry), scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - return attempt() -} - -///// Attempt and retry a task up to `maximumRetryCount` times -//func attemptRetryableTask( -// maximumRetryCount: Int = 3, -// delayBeforeRetry: DispatchTimeInterval = .seconds(2), -// _ body: @escaping () -> AnyPublisher -//) -> AnyPublisher { -// var attempts = 0 -// func attempt() -> Promise { -// attempts += 1 -// return body().recover { error -> Promise in -// guard attempts < maximumRetryCount else { throw error } -// return after(delayBeforeRetry).then(on: nil) { attempt() } -// } -// } -// return attempt() -//} diff --git a/Xcodes/Backend/SDKs+Xcode.swift b/Xcodes/Backend/SDKs+Xcode.swift index 716c7fae..f35f69e2 100644 --- a/Xcodes/Backend/SDKs+Xcode.swift +++ b/Xcodes/Backend/SDKs+Xcode.swift @@ -6,34 +6,8 @@ // Copyright © 2023 Robots and Pencils. All rights reserved. // -import Foundation -import XcodesKit import SwiftUI - -extension SDKs { - /// Loops through all SDK's and returns an array of buildNumbers (to be used to correlate runtimes) - func allBuilds() -> [String] { - var buildNumbers: [String] = [] - - if let iOS = self.iOS?.compactMap({ $0.build }) { - buildNumbers += iOS - } - if let tvOS = self.tvOS?.compactMap({ $0.build }) { - buildNumbers += tvOS - } - if let macOS = self.macOS?.compactMap({ $0.build }) { - buildNumbers += macOS - } - if let watchOS = self.watchOS?.compactMap({ $0.build }) { - buildNumbers += watchOS - } - if let visionOS = self.visionOS?.compactMap({ $0.build }) { - buildNumbers += visionOS - } - - return buildNumbers - } -} +import XcodesKit extension DownloadableRuntime { func icon() -> Image { diff --git a/Xcodes/Backend/SelectedActionType.swift b/Xcodes/Backend/SelectedActionType.swift index bce3a883..9471b791 100644 --- a/Xcodes/Backend/SelectedActionType.swift +++ b/Xcodes/Backend/SelectedActionType.swift @@ -1,31 +1,4 @@ -// -// SelectedActionType.swift -// Xcodes -// -// Created by Matt Kiazyk on 2022-07-24. -// Copyright © 2022 Robots and Pencils. All rights reserved. -// +import XcodesKit -import Foundation -public enum SelectedActionType: String, CaseIterable, Identifiable, CustomStringConvertible { - case none - case rename - - public var id: Self { self } - - public static var `default` = SelectedActionType.none - - public var description: String { - switch self { - case .none: return localizeString("OnSelectDoNothing") - case .rename: return localizeString("OnSelectRenameXcode") - } - } - - public var detailedDescription: String { - switch self { - case .none: return localizeString("OnSelectDoNothingDescription") - case .rename: return localizeString("OnSelectRenameXcodeDescription") - } - } -} +public typealias AutoInstallationType = XcodesKit.AutoInstallationType +public typealias SelectedActionType = XcodesKit.SelectedActionType diff --git a/Xcodes/Backend/URLRequest+Apple.swift b/Xcodes/Backend/URLRequest+Apple.swift deleted file mode 100644 index 99e4b6e4..00000000 --- a/Xcodes/Backend/URLRequest+Apple.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -extension URL { - static let download = URL(string: "https://developer.apple.com/download")! - static let downloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")! - static let downloadXcode = URL(string: "https://developer.apple.com/devcenter/download.action")! - static let downloadADCAuth = URL(string: "https://developerservices2.apple.com/services/download")! -} - -extension URLRequest { - static var download: URLRequest { - return URLRequest(url: .download) - } - - static var downloads: URLRequest { - var request = URLRequest(url: .downloads) - request.httpMethod = "POST" - return request - } - - static func downloadXcode(path: String) -> URLRequest { - var components = URLComponents(url: .downloadXcode, resolvingAgainstBaseURL: false)! - components.queryItems = [URLQueryItem(name: "path", value: path)] - var request = URLRequest(url: components.url!) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "*/*" - return request - } - - static func downloadADCAuth(path: String) -> URLRequest { - var components = URLComponents(url: .downloadADCAuth, resolvingAgainstBaseURL: false)! - components.queryItems = [URLQueryItem(name: "path", value: path)] - var request = URLRequest(url: components.url!) - request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] - request.allHTTPHeaderFields?["Accept"] = "*/*" - return request - } -} diff --git a/Xcodes/Backend/URLSession+DownloadTaskPublisher.swift b/Xcodes/Backend/URLSession+DownloadTaskPublisher.swift deleted file mode 100644 index d287b4d1..00000000 --- a/Xcodes/Backend/URLSession+DownloadTaskPublisher.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Combine -import Foundation - -extension URLSession { - /** - - Parameter convertible: A URL or URLRequest. - - Parameter saveLocation: A URL to move the downloaded file to after it completes. Apple deletes the temporary file immediately after the underyling completion handler returns. - - Parameter resumeData: Data describing the state of a previously cancelled or failed download task. See the Discussion section for `downloadTask(withResumeData:completionHandler:)` https://developer.apple.com/documentation/foundation/urlsession/1411598-downloadtask# - - - Returns: Tuple containing a Progress object for the task and a publisher of the save location and response. - - - Note: We do not create the destination directory for you, because we move the file with FileManager.moveItem which changes its behavior depending on the directory status of the URL you provide. So create your own directory first! - */ - public func downloadTask( - with url: URL, - to saveLocation: URL, - resumingWith resumeData: Data? - ) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) { - var progress: Progress! - var task: URLSessionDownloadTask! - - // Intentionally not wrapping in Deferred because we need to return the Progress and URLSessionDownloadTask immediately. - // Probably a sign that this should be implemented differently... - let promise = Future<(saveLocation: URL, response: URLResponse), Error> { promise in - let completionHandler = { (temporaryURL: URL?, response: URLResponse?, error: Error?) in - if let error = error { - promise(.failure(error)) - } else if let response = response, let temporaryURL = temporaryURL { - do { - try FileManager.default.moveItem(at: temporaryURL, to: saveLocation) - promise(.success((saveLocation, response))) - } catch { - promise(.failure(error)) - } - } else { - fatalError("Expecting either a temporary URL and a response, or an error, but got neither.") - } - } - - if let resumeData = resumeData { - task = self.downloadTask(withResumeData: resumeData, completionHandler: completionHandler) - } - else { - task = self.downloadTask(with: url, completionHandler: completionHandler) - } - progress = task.progress - task.resume() - } - .handleEvents(receiveCancel: task.cancel) - .eraseToAnyPublisher() - - return (progress, promise) - } -} diff --git a/Xcodes/Backend/Version+.swift b/Xcodes/Backend/Version+.swift deleted file mode 100644 index e86d4a52..00000000 --- a/Xcodes/Backend/Version+.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Version - -public extension Version { - /// Determines if two Xcode versions should be treated equivalently. This is not the same as equality. - /// - /// We need a way to determine if two Xcode versions are the same without always having full information, and supporting different data sources. - /// For example, the Apple data source often doesn't have build metadata identifiers. - func isEquivalent(to other: Version) -> Bool { - // If we don't have build metadata identifiers for both Versions, compare major, minor, patch and prerelease identifiers. - if buildMetadataIdentifiers.isEmpty || other.buildMetadataIdentifiers.isEmpty { - return major == other.major && - minor == other.minor && - patch == other.patch && - prereleaseIdentifiers.map { $0.lowercased() } == other.prereleaseIdentifiers.map { $0.lowercased() } - // If we have build metadata identifiers for both, we can ignore the prerelease identifiers. - } else { - return major == other.major && - minor == other.minor && - patch == other.patch && - buildMetadataIdentifiers.map { $0.lowercased() } == other.buildMetadataIdentifiers.map { $0.lowercased() } - } - } - - var descriptionWithoutBuildMetadata: String { - var base = "\(major).\(minor).\(patch)" - if !prereleaseIdentifiers.isEmpty { - base += "-" + prereleaseIdentifiers.joined(separator: ".") - } - return base - } - - var isPrerelease: Bool { prereleaseIdentifiers.isEmpty == false } - var isNotPrerelease: Bool { prereleaseIdentifiers.isEmpty == true } -} diff --git a/Xcodes/Backend/Version+Xcode.swift b/Xcodes/Backend/Version+Xcode.swift deleted file mode 100644 index 5166fba9..00000000 --- a/Xcodes/Backend/Version+Xcode.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -import Version - -public extension Version { - /** - E.g.: - Xcode 10.2 Beta 4 - Xcode 10.2 GM - Xcode 10.2 GM seed 2 - Xcode 10.2 - Xcode 10.2.1 - 10.2 Beta 4 - 10.2 GM - 10.2 - 10.2.1 - */ - init?(xcodeVersion: String, buildMetadataIdentifier: String? = nil) { - let nsrange = NSRange(xcodeVersion.startIndex.. String? { - let nsrange = range(withName: name) - guard let range = Range(nsrange, in: string) else { return nil } - return String(string[range]) - } -} diff --git a/Xcodes/Backend/Version+XcodeReleases.swift b/Xcodes/Backend/Version+XcodeReleases.swift deleted file mode 100644 index 7c4dd219..00000000 --- a/Xcodes/Backend/Version+XcodeReleases.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Version -import XcodesKit - -extension Version { - /// Initialize a Version from an XcodeReleases' XCModel.Xcode - /// - /// This is kinda quick-and-dirty, and it would probably be better for us to adopt something closer to XCModel.Xcode under the hood and map the scraped data to it instead. - init?(xcReleasesXcode: XcodeRelease) { - var versionString = xcReleasesXcode.version.number ?? "" - - // Append trailing ".0" in order to get a fully-specified version string - let components = versionString.components(separatedBy: ".") - versionString += Array(repeating: ".0", count: 3 - components.count).joined() - - // Append prerelease identifier - switch xcReleasesXcode.version.release { - case let .beta(beta): - versionString += "-Beta" - if beta > 1 { - versionString += ".\(beta)" - } - case let .dp(dp): - versionString += "-DP" - if dp > 1 { - versionString += ".\(dp)" - } - case .gm: - break - case let .gmSeed(gmSeed): - versionString += "-GM.Seed" - if gmSeed > 1 { - versionString += ".\(gmSeed)" - } - case let .rc(rc): - versionString += "-Release.Candidate" - if rc > 1 { - versionString += ".\(rc)" - } - case .release: - break - } - - // Append build identifier - if let buildNumber = xcReleasesXcode.version.build { - versionString += "+\(buildNumber)" - } - - self.init(versionString) - } - - var buildMetadataIdentifiersDisplay: String { - return !buildMetadataIdentifiers.isEmpty ? "(\(buildMetadataIdentifiers.joined(separator: " ")))" : "" - } -} - diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index b1721499..21f6ea99 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -1,24 +1,9 @@ import AppKit import Foundation -import Version import Path +import Version import XcodesKit -public struct XcodeID: Codable, Hashable, Identifiable { - public let version: Version - public let architectures: [Architecture]? - - public var id: String { - let architectures = architectures?.map { $0.rawValue}.joined() ?? "" - return version.description + architectures - } - - public init(version: Version, architectures: [Architecture]? = nil) { - self.version = version - self.architectures = architectures - } -} - struct Xcode: Identifiable, CustomStringConvertible { var version: Version { return id.version @@ -64,26 +49,48 @@ struct Xcode: Identifiable, CustomStringConvertible { self.architectures = architectures self.id = XcodeID(version: version, architectures: architectures) } + + init(_ item: XcodeListItem, icon: NSImage?) { + self.identicalBuilds = item.identicalBuilds + self.installState = item.installState + self.selected = item.selected + self.icon = icon + self.requiredMacOSVersion = item.requiredMacOSVersion + self.releaseNotesURL = item.releaseNotesURL + self.releaseDate = item.releaseDate + self.sdks = item.sdks + self.compilers = item.compilers + self.downloadFileSize = item.downloadFileSize + self.architectures = item.architectures + self.id = item.id + } + + var listItem: XcodeListItem { + XcodeListItem( + version: version, + identicalBuilds: identicalBuilds, + installState: installState, + selected: selected, + requiredMacOSVersion: requiredMacOSVersion, + releaseNotesURL: releaseNotesURL, + releaseDate: releaseDate, + sdks: sdks, + compilers: compilers, + downloadFileSize: downloadFileSize, + architectures: architectures + ) + } var description: String { version.appleDescription } var downloadFileSizeString: String? { - if let downloadFileSize = downloadFileSize { - return ByteCountFormatter.string(fromByteCount: downloadFileSize, countStyle: .file) - } else { - return nil - } + listItem.downloadFileSizeString } var installedPath: Path? { - switch installState { - case .installed(let path): - return path - default: - return nil - } + installState.installedPath } } diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index cd21bf4e..344c7ebd 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -115,7 +115,7 @@ struct OpenButton: View { let xcode: Xcode? var openInRosetta: Bool { - appState.showOpenInRosettaOption && Hardware.isAppleSilicon() + appState.showOpenInRosettaOption && HostHardware.isAppleSilicon() } var body: some View { @@ -347,4 +347,3 @@ struct CreateSymbolicLinkCommand: View { .disabled(selectedXcode.unwrapped?.installState.installed != true) } } - diff --git a/Xcodes/Backend/XcodeInstallState.swift b/Xcodes/Backend/XcodeInstallState.swift deleted file mode 100644 index a289bb59..00000000 --- a/Xcodes/Backend/XcodeInstallState.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation -import Path -import XcodesKit - -enum XcodeInstallState: Equatable { - case notInstalled - case installing(XcodeInstallationStep) - case installed(Path) - - var notInstalled: Bool { - switch self { - case .notInstalled: return true - default: return false - } - } - var installing: Bool { - switch self { - case .installing: return true - default: return false - } - } - var installed: Bool { - switch self { - case .installed: return true - default: return false - } - } -} diff --git a/Xcodes/Frontend/About/AboutView.swift b/Xcodes/Frontend/About/AboutView.swift index b9135397..a8511518 100644 --- a/Xcodes/Frontend/About/AboutView.swift +++ b/Xcodes/Frontend/About/AboutView.swift @@ -1,7 +1,7 @@ import SwiftUI struct AboutView: View { - let showAcknowledgementsWindow: () -> Void + let showAcknowledgementsWindow: @MainActor () -> Void @SwiftUI.Environment(\.openURL) var openURL: OpenURLAction var body: some View { diff --git a/Xcodes/Frontend/Common/ObservingProgressIndicator.swift b/Xcodes/Frontend/Common/ObservingProgressIndicator.swift index 3302f0a3..3c6cdbe2 100644 --- a/Xcodes/Frontend/Common/ObservingProgressIndicator.swift +++ b/Xcodes/Frontend/Common/ObservingProgressIndicator.swift @@ -1,5 +1,5 @@ -import Combine import SwiftUI +import XcodesKit /// A ProgressIndicator that reflects the state of a Progress object. /// This functionality is already built in to ProgressView, @@ -23,17 +23,39 @@ public struct ObservingProgressIndicator: View { self.showsAdditionalDescription = showsAdditionalDescription } + @MainActor class ProgressWrapper: ObservableObject { var progress: Progress - var cancellable: AnyCancellable! + private var observationTask: Task? init(progress: Progress) { self.progress = progress - cancellable = progress.publisher(for: \.fractionCompleted) - .combineLatest(progress.publisher(for: \.localizedAdditionalDescription)) - .combineLatest(progress.publisher(for: \.isIndeterminate)) - .throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] _ in self?.objectWillChange.send() } + observationTask = Task { [weak self] in + await self?.observeProgress() + } + } + + deinit { + observationTask?.cancel() + } + + private func observeProgress() async { + for await _ in Self.progressChanges(for: progress) { + objectWillChange.send() + + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + } + } + + private nonisolated static func progressChanges(for progress: Progress) -> AsyncStream { + ProgressObservation.changes( + for: progress, + observing: [.fractionCompleted, .localizedAdditionalDescription, .isIndeterminate] + ) } } diff --git a/Xcodes/Frontend/Common/XcodesSheet.swift b/Xcodes/Frontend/Common/XcodesSheet.swift index 2aa8f2c8..5d60fc94 100644 --- a/Xcodes/Frontend/Common/XcodesSheet.swift +++ b/Xcodes/Frontend/Common/XcodesSheet.swift @@ -1,5 +1,5 @@ import Foundation -import AppleAPI +import XcodesLoginKit enum XcodesSheet: Identifiable { case signIn diff --git a/Xcodes/Frontend/InfoPane/CompatibilityView.swift b/Xcodes/Frontend/InfoPane/CompatibilityView.swift index f5a583b7..7de7c6c0 100644 --- a/Xcodes/Frontend/InfoPane/CompatibilityView.swift +++ b/Xcodes/Frontend/InfoPane/CompatibilityView.swift @@ -36,7 +36,7 @@ struct CompatibilityView: View { } } -#Preview { +#Preview { @MainActor in CompatibilityView(requiredMacOSVersion: "10.15.4") .padding() .environmentObject(AppState()) diff --git a/Xcodes/Frontend/InfoPane/IconView.swift b/Xcodes/Frontend/InfoPane/IconView.swift index ba9aa4f8..0940d4e6 100644 --- a/Xcodes/Frontend/InfoPane/IconView.swift +++ b/Xcodes/Frontend/InfoPane/IconView.swift @@ -9,6 +9,7 @@ import Path import SwiftUI import Version +import XcodesKit struct IconView: View { let xcode: Xcode diff --git a/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift b/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift index 7d2d11a7..47f38c73 100644 --- a/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift +++ b/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift @@ -8,6 +8,7 @@ import SwiftUI import Version +import XcodesKit struct IdenticalBuildsView: View { let builds: [Version] @@ -50,10 +51,11 @@ struct IdenticalBuildsView: View { } } -let builds: [Version] = [.init(xcodeVersion: "15.0")!, .init(xcodeVersion: "15.1")!] +@MainActor +private let previewBuilds: [Version] = [.init(xcodeVersion: "15.0")!, .init(xcodeVersion: "15.1")!] #Preview("Has Some Builds") { - IdenticalBuildsView(builds: builds) + IdenticalBuildsView(builds: previewBuilds) .padding() } diff --git a/Xcodes/Frontend/InfoPane/InfoPane.swift b/Xcodes/Frontend/InfoPane/InfoPane.swift index b2171040..4a5a14f6 100644 --- a/Xcodes/Frontend/InfoPane/InfoPane.swift +++ b/Xcodes/Frontend/InfoPane/InfoPane.swift @@ -69,6 +69,7 @@ struct InfoPane: View { #Preview(XcodePreviewName.allCases[4].rawValue) { makePreviewContent(for: 4) } #Preview(XcodePreviewName.allCases[5].rawValue) { makePreviewContent(for: 5) } +@MainActor private func makePreviewContent(for index: Int) -> some View { let name = XcodePreviewName.allCases[index] return InfoPane(xcode: xcodeDict[name]!) @@ -90,7 +91,8 @@ enum XcodePreviewName: String, CaseIterable, Identifiable { var id: XcodePreviewName { self } } -var xcodeDict: [XcodePreviewName: Xcode] = [ +@MainActor +let xcodeDict: [XcodePreviewName: Xcode] = [ .Populated_Installed_Selected: .init( version: _versionNoMeta, installState: .installed(Path(_path)!), @@ -156,7 +158,8 @@ var xcodeDict: [XcodePreviewName: Xcode] = [ ), ] -var downloadableRuntimes: [DownloadableRuntime] = { +@MainActor +let downloadableRuntimes: [DownloadableRuntime] = { var runtimes = try! JSONDecoder().decode([DownloadableRuntime].self, from: Current.files.contents(atPath: Path.runtimeCacheFile.string)!) // set iOS to installed let iOSIndex = 0//runtimes.firstIndex { $0.sdkBuildUpdate.contains == "19E239" }! @@ -183,13 +186,16 @@ var downloadableRuntimes: [DownloadableRuntime] = { return runtimes }() -var installedRuntimes: [CoreSimulatorImage] = { +@MainActor +let installedRuntimes: [CoreSimulatorImage] = { [CoreSimulatorImage(uuid: "85B22F5B-048B-4331-B6E2-F4196D8B7475", path: ["relative" : "file:///Library/Developer/CoreSimulator/Images/85B22F5B-048B-4331-B6E2-F4196D8B7475.dmg"], runtimeInfo: CoreSimulatorRuntimeInfo(build: "19E240")), CoreSimulatorImage(uuid: "85B22F5B-048B-4331-B6E2-F4196D8B7473", path: ["relative" : "file:///Library/Developer/CoreSimulator/Images/85B22F5B-048B-4331-B6E2-F4196D8B7475.dmg"], runtimeInfo: CoreSimulatorRuntimeInfo(build: "21N5233f"))] }() +@MainActor private let _versionNoMeta = Version(major: 12, minor: 3, patch: 0) +@MainActor private let _versionWithMeta = Version(major: 12, minor: 3, patch: 1, buildMetadataIdentifiers: ["1234A"]) private let _path = "/Applications/Xcode-12.3.0.app" private let _requiredMacOSVersion = "10.15.4" diff --git a/Xcodes/Frontend/InfoPane/InfoPaneControls.swift b/Xcodes/Frontend/InfoPane/InfoPaneControls.swift index 4e6df41c..de1356ed 100644 --- a/Xcodes/Frontend/InfoPane/InfoPaneControls.swift +++ b/Xcodes/Frontend/InfoPane/InfoPaneControls.swift @@ -42,6 +42,7 @@ struct InfoPaneControls: View { #Preview(XcodePreviewName.allCases[4].rawValue) { makePreviewContent(for: 4) } #Preview(XcodePreviewName.allCases[5].rawValue) { makePreviewContent(for: 5) } +@MainActor private func makePreviewContent(for index: Int) -> some View { let name = XcodePreviewName.allCases[index] diff --git a/Xcodes/Frontend/InfoPane/InstalledStateButtons.swift b/Xcodes/Frontend/InfoPane/InstalledStateButtons.swift index d1068728..0de4924b 100644 --- a/Xcodes/Frontend/InfoPane/InstalledStateButtons.swift +++ b/Xcodes/Frontend/InfoPane/InstalledStateButtons.swift @@ -42,7 +42,7 @@ struct InstalledStateButtons: View { } } -#Preview { +#Preview { @MainActor in InstalledStateButtons(xcode: xcode) .environmentObject(configure(AppState()) { $0.allXcodes = [xcode] diff --git a/Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift b/Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift index 9e6bf5cb..5247dcbf 100644 --- a/Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift +++ b/Xcodes/Frontend/InfoPane/NotInstalledStateButtons.swift @@ -8,6 +8,7 @@ import SwiftUI import Version +import XcodesKit struct NotInstalledStateButtons: View { let downloadFileSizeString: String? @@ -39,7 +40,7 @@ struct NotInstalledStateButtons: View { } } -#Preview { +#Preview { @MainActor in NotInstalledStateButtons( downloadFileSizeString: "1,19 GB", id: XcodeID(version: Version(major: 12, minor: 3, patch: 0), architectures: nil) diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index ea268c13..d71ffa6b 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -17,7 +17,7 @@ struct PlatformsView: View { var body: some View { - let builds = xcode.sdks?.allBuilds() + let builds = xcode.sdks?.allBuilds let runtimes = builds?.flatMap { sdkBuild in appState.downloadableRuntimes.filter { $0.sdkBuildUpdate?.contains(sdkBuild) ?? false && @@ -121,8 +121,9 @@ struct PlatformsView: View { } } -#Preview(XcodePreviewName.allCases[0].rawValue) { makePreviewContent(for: 0) } +#Preview(XcodePreviewName.allCases[0].rawValue) { @MainActor in makePreviewContent(for: 0) } +@MainActor private func makePreviewContent(for index: Int) -> some View { let name = XcodePreviewName.allCases[index] let runtimes = downloadableRuntimes diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 3808d63e..8eab7308 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -119,7 +119,7 @@ struct MainWindow: View { @ViewBuilder private func signInView() -> some View { - if appState.authenticationState == .authenticated { + if case .authenticated = appState.authenticationState { VStack { SignedInView() .padding(32) @@ -155,30 +155,10 @@ struct MainWindow: View { title: Text("Alert.PrivilegedHelper.Title"), message: Text("Alert.PrivilegedHelper.Message"), primaryButton: .default(Text("Install"), action: { - // The isPreparingUserForActionRequiringHelper closure is set to nil by the alert's binding when its dismissed. - // We need to capture it to be invoked after that happens. - let helperAction = appState.isPreparingUserForActionRequiringHelper - DispatchQueue.main.async { - // This really shouldn't be nil, but sometimes this alert is being shown twice and I don't know why. - // There are some DispatchQueue.main.async's scattered around which make this better but in some situations it's still happening. - // When that happens, the second time the user clicks an alert button isPreparingUserForActionRequiringHelper will be nil. - // To at least not crash, we're using ? - helperAction?(true) - appState.presentedAlert = nil - } + appState.respondToPreparedHelperAction(userConsented: true) }), secondaryButton: .cancel { - // The isPreparingUserForActionRequiringHelper closure is set to nil by the alert's binding when its dismissed. - // We need to capture it to be invoked after that happens. - let helperAction = appState.isPreparingUserForActionRequiringHelper - DispatchQueue.main.async { - // This really shouldn't be nil, but sometimes this alert is being shown twice and I don't know why. - // There are some DispatchQueue.main.async's scattered around which make this better but in some situations it's still happening. - // When that happens, the second time the user clicks an alert button isPreparingUserForActionRequiringHelper will be nil. - // To at least not crash, we're using ? - helperAction?(false) - appState.presentedAlert = nil - } + appState.respondToPreparedHelperAction(userConsented: false) } ) case let .generic(title, message): @@ -235,6 +215,7 @@ struct MainWindow: View { } struct MainWindow_Previews: PreviewProvider { + @MainActor static var previews: some View { MainWindow().environmentObject({ () -> AppState in let a = AppState() diff --git a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift index 13ab718e..c938bb3e 100644 --- a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift @@ -1,6 +1,6 @@ -import AppleAPI import SwiftUI import Path +import XcodesKit struct AdvancedPreferencePane: View { @EnvironmentObject var appState: AppState @@ -115,7 +115,7 @@ struct AdvancedPreferencePane: View { } .groupBoxStyle(PreferencesGroupBoxStyle()) - if Hardware.isAppleSilicon() { + if HostHardware.isAppleSilicon() { GroupBox(label: Text("Apple Silicon")) { Toggle("ShowOpenInRosetta", isOn: $appState.showOpenInRosettaOption) .disabled(appState.createSymLinkOnSelectDisabled) @@ -159,6 +159,7 @@ struct AdvancedPreferencePane: View { } struct AdvancedPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { AdvancedPreferencePane() diff --git a/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift b/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift index 854c6159..db082ff2 100644 --- a/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/DownloadPreferencePane.swift @@ -1,4 +1,3 @@ -import AppleAPI import SwiftUI struct DownloadPreferencePane: View { @@ -53,6 +52,7 @@ struct DownloadPreferencePane: View { } struct DownloadPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { DownloadPreferencePane() diff --git a/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift b/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift index 68dad51e..db137e6c 100644 --- a/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift @@ -1,4 +1,3 @@ -import AppleAPI import Path import SwiftUI @@ -27,6 +26,7 @@ struct ExperimentsPreferencePane: View { } struct ExperimentsPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { ExperimentsPreferencePane() diff --git a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift index b15f5c6d..8a1516ff 100644 --- a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift @@ -1,4 +1,3 @@ -import AppleAPI import SwiftUI struct GeneralPreferencePane: View { @@ -7,7 +6,7 @@ struct GeneralPreferencePane: View { var body: some View { VStack(alignment: .leading) { GroupBox(label: Text("AppleID")) { - if appState.authenticationState == .authenticated { + if case .authenticated = appState.authenticationState { SignedInView() } else { Button("SignIn", action: { self.appState.presentedSheet = .signIn }) @@ -31,6 +30,7 @@ struct GeneralPreferencePane: View { } struct GeneralPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { GeneralPreferencePane() diff --git a/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index 855bd7e2..d789e8a5 100644 --- a/Xcodes/Frontend/Preferences/PlatformsListView.swift +++ b/Xcodes/Frontend/Preferences/PlatformsListView.swift @@ -74,7 +74,7 @@ struct PlatformsListView: View { } -#Preview { +#Preview { @MainActor in PlatformsListView() .environmentObject({ () -> AppState in let a = AppState() diff --git a/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift b/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift index edad69bd..de9ab1f1 100644 --- a/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift @@ -1,4 +1,3 @@ -import AppleAPI import Sparkle import SwiftUI @@ -74,6 +73,7 @@ struct UpdatesPreferencePane: View { } } +@MainActor class ObservableUpdater: ObservableObject { private let updater: SPUUpdater private let updaterDelegate = UpdaterDelegate() @@ -110,16 +110,22 @@ class ObservableUpdater: ObservableObject { automaticallyChecksForUpdatesObservation = updater.observe( \.automaticallyChecksForUpdates, options: [.initial, .new, .old], - changeHandler: { [unowned self] updater, change in + changeHandler: { [weak self] updater, change in guard change.newValue != change.oldValue else { return } - self.automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates + let automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates + Task { @MainActor [weak self] in + self?.automaticallyChecksForUpdates = automaticallyChecksForUpdates + } } ) lastUpdateCheckDateObservation = updater.observe( \.lastUpdateCheckDate, options: [.initial, .new, .old], - changeHandler: { [unowned self] updater, change in - self.lastUpdateCheckDate = updater.lastUpdateCheckDate + changeHandler: { [weak self] updater, change in + let lastUpdateCheckDate = updater.lastUpdateCheckDate + Task { @MainActor [weak self] in + self?.lastUpdateCheckDate = lastUpdateCheckDate + } } ) includePrereleaseVersions = Current.defaults.bool(forKey: "includePrereleaseVersions") ?? false @@ -149,6 +155,7 @@ extension String { } struct UpdatesPreferencePane_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { UpdatesPreferencePane() diff --git a/Xcodes/Frontend/SignIn/AttributedText.swift b/Xcodes/Frontend/SignIn/AttributedText.swift index e15df805..79a53ee6 100644 --- a/Xcodes/Frontend/SignIn/AttributedText.swift +++ b/Xcodes/Frontend/SignIn/AttributedText.swift @@ -46,7 +46,7 @@ fileprivate struct InnerAttributedStringText: NSViewRepresentable { func updateNSView(_ label: NSTextView, context _: NSViewRepresentableContext) { // This must happen on the next run loop so that we don't update the view hierarchy while already in the middle of an update - DispatchQueue.main.async { + Task { @MainActor in label.textStorage?.setAttributedString(attributedString) // Calculates the height based on the current frame label.layoutManager?.ensureLayout(for: label.textContainer!) diff --git a/Xcodes/Frontend/SignIn/SignIn2FAView.swift b/Xcodes/Frontend/SignIn/SignIn2FAView.swift index a8dfa988..cbc385a7 100644 --- a/Xcodes/Frontend/SignIn/SignIn2FAView.swift +++ b/Xcodes/Frontend/SignIn/SignIn2FAView.swift @@ -1,5 +1,5 @@ import SwiftUI -import AppleAPI +import XcodesLoginKit struct SignIn2FAView: View { @EnvironmentObject var appState: AppState @@ -42,6 +42,7 @@ struct SignIn2FAView: View { } struct SignIn2FAView_Previews: PreviewProvider { + @MainActor static var previews: some View { SignIn2FAView( isPresented: .constant(true), diff --git a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift index b9f527d3..7c108e23 100644 --- a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift +++ b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift @@ -63,6 +63,7 @@ struct SignInCredentialsView: View { } struct SignInCredentialsView_Previews: PreviewProvider { + @MainActor static var previews: some View { SignInCredentialsView() .environmentObject(AppState()) diff --git a/Xcodes/Frontend/SignIn/SignInPhoneListView.swift b/Xcodes/Frontend/SignIn/SignInPhoneListView.swift index fc31d97b..f907c6f2 100644 --- a/Xcodes/Frontend/SignIn/SignInPhoneListView.swift +++ b/Xcodes/Frontend/SignIn/SignInPhoneListView.swift @@ -1,4 +1,4 @@ -import AppleAPI +import XcodesLoginKit import SwiftUI struct SignInPhoneListView: View { @@ -48,6 +48,7 @@ struct SignInPhoneListView: View { } struct SignInPhoneListView_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { SignInPhoneListView( diff --git a/Xcodes/Frontend/SignIn/SignInSMSView.swift b/Xcodes/Frontend/SignIn/SignInSMSView.swift index 6d42f692..8a1cd918 100644 --- a/Xcodes/Frontend/SignIn/SignInSMSView.swift +++ b/Xcodes/Frontend/SignIn/SignInSMSView.swift @@ -1,5 +1,5 @@ import SwiftUI -import AppleAPI +import XcodesLoginKit struct SignInSMSView: View { @EnvironmentObject var appState: AppState @@ -41,6 +41,7 @@ struct SignInSMSView: View { } struct SignInSMSView_Previews: PreviewProvider { + @MainActor static var previews: some View { SignInSMSView( isPresented: .constant(true), diff --git a/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift b/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift index d2b16465..6f5b2c2c 100644 --- a/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift +++ b/Xcodes/Frontend/SignIn/SignInSecurityKeyPinView.swift @@ -7,7 +7,7 @@ // import SwiftUI -import AppleAPI +import XcodesLoginKit struct SignInSecurityKeyPinView: View { @EnvironmentObject var appState: AppState @@ -59,7 +59,7 @@ struct SignInSecurityKeyPinView: View { } } -#Preview { +#Preview { @MainActor in SignInSecurityKeyPinView(isPresented: .constant(true), authOptions: AuthOptionsResponse( trustedPhoneNumbers: nil, diff --git a/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift b/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift index 362a54fe..1cdd2e64 100644 --- a/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift +++ b/Xcodes/Frontend/SignIn/SignInSecurityKeyTouchView.swift @@ -7,7 +7,7 @@ // import SwiftUI -import AppleAPI +import XcodesLoginKit struct SignInSecurityKeyTouchView: View { @EnvironmentObject var appState: AppState @@ -48,7 +48,7 @@ struct SignInSecurityKeyTouchView: View { } } -#Preview { +#Preview { @MainActor in SignInSecurityKeyTouchView(isPresented: .constant(true)) .environmentObject(AppState()) } diff --git a/Xcodes/Frontend/XcodeList/BottomStatusBar.swift b/Xcodes/Frontend/XcodeList/BottomStatusBar.swift index 3f6e2e6c..77c298f4 100644 --- a/Xcodes/Frontend/XcodeList/BottomStatusBar.swift +++ b/Xcodes/Frontend/XcodeList/BottomStatusBar.swift @@ -54,6 +54,7 @@ extension View { } struct Previews_BottomStatusBar_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { HStack { diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 3c10e0bc..8b6363ab 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -1,6 +1,7 @@ import Path import SwiftUI import Version +import XcodesKit struct XcodeListView: View { @EnvironmentObject var appState: AppState @@ -111,6 +112,7 @@ struct PlatformsPocket: View { } struct XcodeListView_Previews: PreviewProvider { + @MainActor static var previews: some View { Group { XcodeListView(selectedXcodeID: .constant(nil), searchText: "", category: .all, isInstalledOnly: false, architecture: .appleSilicon) diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index cc7f311a..d2a350b2 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -1,6 +1,7 @@ import Path import SwiftUI import Version +import XcodesKit struct XcodeListViewRow: View { let xcode: Xcode @@ -69,7 +70,11 @@ struct XcodeListViewRow: View { #if DEBUG Divider() Button("Perform post-install steps") { - appState.performPostInstallSteps(for: InstalledXcode(path: path)!) as Void + appState.performPostInstallSteps(for: InstalledXcode( + path: path, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + )!) as Void } #endif } diff --git a/Xcodes/Resources/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index cef9fff5..db57f9fe 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -1,4 +1,4 @@ -{\rtf1\ansi\ansicpg1252\cocoartf2865 +{\rtf1\ansi\ansicpg1252\cocoartf2869 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 .SFNS-Regular;} {\colortbl;\red255\green255\blue255;} {\*\expandedcolortbl;;} @@ -85,6 +85,33 @@ SOFTWARE.\ \ \ +\fs34 Yams\ +\ + +\fs26 The MIT License (MIT)\ +\ +Copyright (c) 2016 JP Simard.\ +\ +Permission is hereby granted, free of charge, to any person obtaining a copy\ +of this software and associated documentation files (the "Software"), to deal\ +in the Software without restriction, including without limitation the rights\ +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ +copies of the Software, and to permit persons to whom the Software is\ +furnished to do so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included in all\ +copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ +SOFTWARE.\ +\ +\ + \fs34 ErrorHandling\ \ diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index a7835dd6..0d46447a 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -1,6 +1,7 @@ import AppKit import Sparkle import SwiftUI +import XcodesKit @main struct XcodesApp: App { @@ -96,20 +97,7 @@ struct XcodesApp: App { primaryButton: .destructive( Text("Alert.DeletePlatform.PrimaryButton"), action: { - Task { - do { - try await self.appState.deleteRuntime(runtime: runtime) - } catch { - var errorString: String - if let error = error as? String { - errorString = error - } else { - errorString = error.localizedDescription - } - self.appState.presentedPreferenceAlert = .generic(title: "Error", message: errorString) - } - - } + self.appState.confirmDeleteRuntime(runtime: runtime) } ), secondaryButton: .cancel(Text("Cancel")) @@ -127,6 +115,7 @@ struct XcodesApp: App { } } +@MainActor class AppDelegate: NSObject, NSApplicationDelegate { private lazy var aboutWindow = configure(NSWindow( contentRect: .zero, diff --git a/Xcodes/XcodesKit/Package.resolved b/Xcodes/XcodesKit/Package.resolved new file mode 100644 index 00000000..424ceaf5 --- /dev/null +++ b/Xcodes/XcodesKit/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "path.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mxcl/Path.swift", + "state" : { + "revision" : "8e355c28e9393c42e58b18c54cace2c42c98a616", + "version" : "1.4.1" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup", + "state" : { + "revision" : "aeb5b4249c273d1783a5299e05be1b26e061ea81", + "version" : "2.0.0" + } + }, + { + "identity" : "version", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mxcl/Version", + "state" : { + "revision" : "087c91fedc110f9f833b14ef4c32745dabca8913", + "version" : "1.0.3" + } + } + ], + "version" : 2 +} diff --git a/Xcodes/XcodesKit/Package.swift b/Xcodes/XcodesKit/Package.swift index abb461f2..aeaed308 100644 --- a/Xcodes/XcodesKit/Package.swift +++ b/Xcodes/XcodesKit/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -14,8 +14,9 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService", branch: "main"), .package(url: "https://github.com/mxcl/Path.swift", from: "1.0.0"), + .package(url: "https://github.com/mxcl/Version", .upToNextMinor(from: "1.0.3")), + .package(url: "https://github.com/scinfu/SwiftSoup", .upToNextMinor(from: "2.0.0")), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -23,8 +24,9 @@ let package = Package( .target( name: "XcodesKit", dependencies: [ - .product(name: "AsyncNetworkService", package: "AsyncHTTPNetworkService"), - .product(name: "Path", package: "Path.swift") + .product(name: "Path", package: "Path.swift"), + "SwiftSoup", + "Version", ]), .testTarget( name: "XcodesKitTests", diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Concurrency/OneShotContinuation.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Concurrency/OneShotContinuation.swift new file mode 100644 index 00000000..c07318de --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Concurrency/OneShotContinuation.swift @@ -0,0 +1,74 @@ +import Foundation +import os + +public final class OneShotContinuation: Sendable { + private enum State: Sendable { + case pending(CheckedContinuation?) + case completed(Result) + } + + private let state = OSAllocatedUnfairLock(initialState: State.pending(nil)) + + public init() {} + + public func value( + onCancel: @escaping @Sendable () -> Void = {}, + start: @Sendable () throws -> Void + ) async throws -> Value { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if setContinuation(continuation) { + do { + try start() + } catch { + resume(throwing: error) + } + } + } + } onCancel: { + onCancel() + resume(throwing: CancellationError()) + } + } + + @discardableResult + public func setContinuation(_ continuation: CheckedContinuation) -> Bool { + let result = state.withLock { + switch $0 { + case .pending: + $0 = .pending(continuation) + return nil as Result? + case .completed(let result): + return result + } + } + + guard let result else { return true } + continuation.resume(with: result) + return false + } + + public func resume(with result: Result) { + let continuation = state.withLock { + switch $0 { + case .pending(let continuation): + $0 = .completed(result) + return continuation + case .completed: + return nil + } + } + + continuation?.resume(with: result) + } + + public func resume(throwing error: Error) { + resume(with: .failure(error)) + } +} + +extension OneShotContinuation where Value == Void { + public func resume() { + resume(with: .success(())) + } +} diff --git a/Xcodes/Backend/DateFormatter+.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/DateFormatter+XcodeList.swift similarity index 76% rename from Xcodes/Backend/DateFormatter+.swift rename to Xcodes/XcodesKit/Sources/XcodesKit/Extensions/DateFormatter+XcodeList.swift index a0073de1..b7f8ef6c 100644 --- a/Xcodes/Backend/DateFormatter+.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/DateFormatter+XcodeList.swift @@ -1,7 +1,6 @@ import Foundation -extension DateFormatter { - /// Date format used in JSON returned from `URL.downloads` +public extension DateFormatter { static let downloadsDateModified: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "MM/dd/yy HH:mm" @@ -10,7 +9,6 @@ extension DateFormatter { return formatter }() - /// Date format used in HTML returned from `URL.download` static let downloadsReleaseDate: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "MMMM d, yyyy" diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/FileManager+Trash.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/FileManager+Trash.swift new file mode 100644 index 00000000..925f773d --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/FileManager+Trash.swift @@ -0,0 +1,17 @@ +import Foundation + +public extension FileManager { + /** + Moves an item to the trash. + + This implementation exists only to make the existing method more idiomatic by returning the resulting URL instead of setting the value on an inout argument. + + FB6735133: FileManager.trashItem(at:resultingItemURL:) is not an idiomatic Swift API + */ + @discardableResult + func xcodesTrashItem(at url: URL) throws -> URL { + var resultingItemURL: NSURL? + try trashItem(at: url, resultingItemURL: &resultingItemURL) + return resultingItemURL as URL? ?? url + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift index 02b1e022..a270ed99 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift @@ -1,6 +1,33 @@ import Foundation -extension NSRegularExpression { +public extension BidirectionalCollection where Element: Equatable { + func suffix(fromLast delimiter: Element) -> Self.SubSequence { + guard + let lastIndex = lastIndex(of: delimiter), + index(after: lastIndex) < endIndex + else { return suffix(0) } + return suffix(from: index(after: lastIndex)) + } +} + +public extension NumberFormatter { + convenience init(numberStyle: NumberFormatter.Style) { + self.init() + self.numberStyle = numberStyle + } + + func string(from number: N) -> String? { + string(from: number as! NSNumber) + } +} + +public extension Sequence { + func sorted(_ keyPath: KeyPath) -> [Element] { + sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) + } +} + +public extension NSRegularExpression { func firstString(in string: String, options: NSRegularExpression.MatchingOptions = []) -> String? { let range = NSRange(location: 0, length: string.utf16.count) guard let firstMatch = firstMatch(in: string, options: options, range: range), diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift index 222e9080..5c059853 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift @@ -2,7 +2,7 @@ import Foundation import os.log extension Logger { - private static var subsystem = Bundle.main.bundleIdentifier! + private static let subsystem = Bundle.main.bundleIdentifier ?? "com.robotsandpencils.XcodesKit" static public let appState = Logger(subsystem: subsystem, category: "appState") static public let helperClient = Logger(subsystem: subsystem, category: "helperClient") diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/OperatingSystemVersion+Xcodes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/OperatingSystemVersion+Xcodes.swift new file mode 100644 index 00000000..2790fe21 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/OperatingSystemVersion+Xcodes.swift @@ -0,0 +1,7 @@ +import Foundation + +public extension OperatingSystemVersion { + func versionString() -> String { + "\(majorVersion).\(minorVersion).\(patchVersion)" + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+Ownership.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+Ownership.swift new file mode 100644 index 00000000..81a7cd08 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+Ownership.swift @@ -0,0 +1,11 @@ +import Foundation +@preconcurrency import Path + +public extension Path { + @discardableResult + func setCurrentUserAsOwner() -> Path { + let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName() + try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string) + return self + } +} diff --git a/Xcodes/Backend/Entry+.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+XcodeBundle.swift similarity index 92% rename from Xcodes/Backend/Entry+.swift rename to Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+XcodeBundle.swift index b195fb0b..d4b0b08d 100644 --- a/Xcodes/Backend/Entry+.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+XcodeBundle.swift @@ -1,12 +1,13 @@ import Foundation -import Path +@preconcurrency import Path -extension Path { +public extension Path { static func isAppBundle(path: Path) -> Bool { path.isDirectory && path.extension == "app" && !path.isSymlink } + static func infoPlist(path: Path) -> InfoPlist? { let infoPlistPath = path.join("Contents").join("Info.plist") guard @@ -16,7 +17,7 @@ extension Path { return infoPlist } - + var isAppBundle: Bool { Path.isAppBundle(path: self) } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Progress+Xcodes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Progress+Xcodes.swift new file mode 100644 index 00000000..c15769af --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Progress+Xcodes.swift @@ -0,0 +1,78 @@ +import Foundation + +public extension Progress { + func updateFromAria2(string: String) { + let range = NSRange(location: 0, length: string.utf16.count) + + let regexTotalDownloaded = try! NSRegularExpression(pattern: #"(?<= )(.*)(?=\/)"#) + if let match = regexTotalDownloaded.firstMatch(in: string, options: [], range: range), + let matchRange = Range(match.range(at: 0), in: string), + let totalDownloaded = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) + { + completedUnitCount = Int64(totalDownloaded) + } + + let regexTotalFileSize = try! NSRegularExpression(pattern: #"(?<=/)(.*)(?=\()"#) + if let match = regexTotalFileSize.firstMatch(in: string, options: [], range: range), + let matchRange = Range(match.range(at: 0), in: string), + let totalFileSize = Int(string[matchRange].replacingOccurrences(of: "B", with: "")), + totalFileSize > 0 + { + totalUnitCount = Int64(totalFileSize) + } + + let regexSpeed = try! NSRegularExpression(pattern: #"(?<=DL:)(.*)(?= )"#) + if let match = regexSpeed.firstMatch(in: string, options: [], range: range), + let matchRange = Range(match.range(at: 0), in: string), + let speed = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) + { + throughput = speed + } + + let regexETA = try! NSRegularExpression(pattern: #"(?<=ETA:)(?\d*h)?(?\d*m)?(?\d*s)?"#) + if let match = regexETA.firstMatch(in: string, options: [], range: range) { + var seconds = 0 + + if let matchRange = Range(match.range(withName: "hours"), in: string), + let hours = Int(string[matchRange].replacingOccurrences(of: "h", with: "")) + { + seconds += hours * 60 * 60 + } + + if let matchRange = Range(match.range(withName: "minutes"), in: string), + let minutes = Int(string[matchRange].replacingOccurrences(of: "m", with: "")) + { + seconds += minutes * 60 + } + + if let matchRange = Range(match.range(withName: "seconds"), in: string), + let second = Int(string[matchRange].replacingOccurrences(of: "s", with: "")) + { + seconds += second + } + + estimatedTimeRemaining = TimeInterval(seconds) + } + } + + func updateFromXcodebuild(text: String) { + totalUnitCount = 100 + completedUnitCount = 0 + localizedAdditionalDescription = "" + + let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"# + let downloadRegex = try! NSRegularExpression(pattern: downloadPattern) + + if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), + let percentRange = Range(match.range(at: 1), in: text), + let percentDouble = Double(text[percentRange]) + { + completedUnitCount = Int64(percentDouble.rounded()) + } + + if text.range(of: "Installing") != nil { + totalUnitCount = 0 + completedUnitCount = 0 + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Gem.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Gem.swift new file mode 100644 index 00000000..b88a60b1 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Gem.swift @@ -0,0 +1,53 @@ +import Foundation +import Version + +public extension Version { + /** + Attempts to parse Gem::Version representations. + + E.g.: + 9.2b3 + 9.1.2 + 9.2 + 9 + + Doesn't handle GM prerelease identifier + */ + init?(gemVersion: String) { + let nsrange = NSRange(gemVersion.startIndex.. String in + switch identifier.lowercased() { + case "a": return "Alpha" + case "b": return "Beta" + default: return identifier + } + } + + self = Version(major: major, minor: minor, patch: patch, prereleaseIdentifiers: prereleaseIdentifiers) + } +} + +private extension NSTextCheckingResult { + func groupNamed(_ name: String, in string: String) -> String? { + let nsrange = range(withName: name) + guard let range = Range(nsrange, in: string) else { return nil } + return String(string[range]) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Matching.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Matching.swift new file mode 100644 index 00000000..673eacd3 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Matching.swift @@ -0,0 +1,23 @@ +import Foundation +import Version + +public enum XcodeVersionMatcher: Sendable { + public static func find(version: Version, in xcodes: [XcodeType], versionKeyPath: KeyPath) -> XcodeType? { + if let equivalentXcode = xcodes.first(where: { $0[keyPath: versionKeyPath].isEquivalent(to: version) }) { + return equivalentXcode + } else if version.prereleaseIdentifiers.isEmpty && version.buildMetadataIdentifiers.isEmpty, + xcodes.filter({ $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) }).count == 1 { + return xcodes.first(where: { $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) }) + } else { + return nil + } + } +} + +public extension Version { + func isEqualWithoutAllIdentifiers(to other: Version) -> Bool { + major == other.major && + minor == other.minor && + patch == other.patch + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Xcode.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Xcode.swift new file mode 100644 index 00000000..950dc0af --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Xcode.swift @@ -0,0 +1,150 @@ +import Foundation +import Version + +public extension Version { + init?(xcodeVersion: String, buildMetadataIdentifier: String? = nil) { + let nsrange = NSRange(xcodeVersion.startIndex.. 1 { + versionString += ".\(beta)" + } + case let .dp(dp): + versionString += "-DP" + if dp > 1 { + versionString += ".\(dp)" + } + case .gm: + break + case let .gmSeed(gmSeed): + versionString += "-GM.Seed" + if gmSeed > 1 { + versionString += ".\(gmSeed)" + } + case let .rc(rc): + versionString += "-Release.Candidate" + if rc > 1 { + versionString += ".\(rc)" + } + case .release: + break + } + + if let buildNumber = xcReleasesXcode.version.build { + versionString += "+\(buildNumber)" + } + + self.init(versionString) + } + + /// The intent here is to match Apple's marketing version. + var appleDescription: String { + var base = "\(major).\(minor)" + if patch != 0 { + base += ".\(patch)" + } + if !prereleaseIdentifiers.isEmpty { + base += " " + prereleaseIdentifiers + .map { identifier in + identifier + .replacingOccurrences(of: "-", with: " ") + .capitalized + .replacingOccurrences(of: "Gm", with: "GM") + .replacingOccurrences(of: "Rc", with: "RC") + } + .joined(separator: " ") + } + return base + } + + var appleDescriptionWithBuildIdentifier: String { + [appleDescription, buildMetadataIdentifiersDisplay].filter { !$0.isEmpty }.joined(separator: " ") + } + + var buildMetadataIdentifiersDisplay: String { + !buildMetadataIdentifiers.isEmpty ? "(\(buildMetadataIdentifiers.joined(separator: " ")))" : "" + } + + func isEquivalent(to other: Version) -> Bool { + if buildMetadataIdentifiers.isEmpty || other.buildMetadataIdentifiers.isEmpty { + return major == other.major && + minor == other.minor && + patch == other.patch && + prereleaseIdentifiers.map { $0.lowercased() } == other.prereleaseIdentifiers.map { $0.lowercased() } + } else { + return major == other.major && + minor == other.minor && + patch == other.patch && + buildMetadataIdentifiers.map { $0.lowercased() } == other.buildMetadataIdentifiers.map { $0.lowercased() } + } + } + + var descriptionWithoutBuildMetadata: String { + var base = "\(major).\(minor).\(patch)" + if !prereleaseIdentifiers.isEmpty { + base += "-" + prereleaseIdentifiers.joined(separator: ".") + } + return base + } + + var isPrerelease: Bool { prereleaseIdentifiers.isEmpty == false } + var isNotPrerelease: Bool { prereleaseIdentifiers.isEmpty == true } +} + +private extension NSTextCheckingResult { + func groupNamed(_ name: String, in string: String) -> String? { + let nsrange = range(withName: name) + guard let range = Range(nsrange, in: string) else { return nil } + return String(string[range]) + } +} diff --git a/Xcodes/Backend/Aria2CError.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Aria2CError.swift similarity index 94% rename from Xcodes/Backend/Aria2CError.swift rename to Xcodes/XcodesKit/Sources/XcodesKit/Models/Aria2CError.swift index c6526266..fc51e507 100644 --- a/Xcodes/Backend/Aria2CError.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Aria2CError.swift @@ -1,20 +1,20 @@ import Foundation /// A LocalizedError that represents a non-zero exit code from running aria2c. -struct Aria2CError: LocalizedError { - var code: Code +public struct Aria2CError: LocalizedError, Sendable { + public var code: Code - init?(exitStatus: Int32) { + public init?(exitStatus: Int32) { guard let code = Code(rawValue: exitStatus) else { return nil } self.code = code } - var errorDescription: String? { + public var errorDescription: String? { "aria2c error: \(code.description)" } // https://github.com/aria2/aria2/blob/master/src/error_code.h - enum Code: Int32, CustomStringConvertible { + public enum Code: Int32, CustomStringConvertible, Sendable { case undefined = -1 // Ignoring, not an error // case finished = 0 @@ -51,7 +51,7 @@ struct Aria2CError: LocalizedError { case removed case checksumError - var description: String { + public var description: String { switch self { case .undefined: return "Undefined" diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift index 3ea12dbf..e994682f 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift @@ -7,7 +7,7 @@ import Foundation -public struct CoreSimulatorPlist: Decodable { +public struct CoreSimulatorPlist: Decodable, Sendable { public let images: [CoreSimulatorImage] public init(images: [CoreSimulatorImage]) { @@ -15,7 +15,7 @@ public struct CoreSimulatorPlist: Decodable { } } -public struct CoreSimulatorImage: Decodable, Identifiable, Equatable { +public struct CoreSimulatorImage: Decodable, Identifiable, Equatable, Sendable { public var id: String { return uuid } @@ -35,7 +35,7 @@ public struct CoreSimulatorImage: Decodable, Identifiable, Equatable { } } -public struct CoreSimulatorRuntimeInfo: Decodable { +public struct CoreSimulatorRuntimeInfo: Decodable, Sendable { public let build: String public let supportedArchitectures: [Architecture]? diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift index f50e4a22..9859c299 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift @@ -6,9 +6,9 @@ // import Foundation -import Path +@preconcurrency import Path -public enum RuntimeInstallState: Equatable, Hashable { +public enum RuntimeInstallState: Equatable, Hashable, Sendable { case notInstalled case installing(RuntimeInstallationStep) case installed diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift index 231971f7..5d420625 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift @@ -7,7 +7,7 @@ import Foundation -public enum RuntimeInstallationStep: Equatable, CustomStringConvertible, Hashable { +public enum RuntimeInstallationStep: Equatable, CustomStringConvertible, Hashable, Sendable { case downloading(progress: Progress) case installing case trashingArchive diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift index 581b6c03..ada7c174 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift @@ -1,6 +1,11 @@ import Foundation -public struct DownloadableRuntimesResponse: Codable { +public func makeRuntimeVersion(for osVersion: String, betaNumber: Int?) -> String { + let betaSuffix = betaNumber.flatMap { "-beta\($0)" } ?? "" + return osVersion + betaSuffix +} + +public struct DownloadableRuntimesResponse: Codable, Sendable { public let sdkToSimulatorMappings: [SDKToSimulatorMapping] public let sdkToSeedMappings: [SDKToSeedMapping] public let refreshInterval: Int @@ -8,7 +13,7 @@ public struct DownloadableRuntimesResponse: Codable { public let version: String } -public struct DownloadableRuntime: Codable, Identifiable, Hashable { +public struct DownloadableRuntime: Codable, Identifiable, Hashable, Sendable { public let category: Category public let simulatorVersion: SimulatorVersion public let source: String? @@ -53,24 +58,23 @@ public struct DownloadableRuntime: Codable, Identifiable, Hashable { case architectures } - var betaNumber: Int? { + public var betaNumber: Int? { enum Regex { static let shared = try! NSRegularExpression(pattern: "b[0-9]+") } guard var foundString = Regex.shared.firstString(in: identifier) else { return nil } foundString.removeFirst() return Int(foundString)! } - var completeVersion: String { - makeVersion(for: simulatorVersion.version, betaNumber: betaNumber) + public var completeVersion: String { + makeRuntimeVersion(for: simulatorVersion.version, betaNumber: betaNumber) } public var visibleIdentifier: String { return platform.shortName + " " + completeVersion } - func makeVersion(for osVersion: String, betaNumber: Int?) -> String { - let betaSuffix = betaNumber.flatMap { "-beta\($0)" } ?? "" - return osVersion + betaSuffix + public func makeVersion(for osVersion: String, betaNumber: Int?) -> String { + makeRuntimeVersion(for: osVersion, betaNumber: betaNumber) } public var downloadFileSizeString: String { @@ -86,13 +90,13 @@ public struct DownloadableRuntime: Codable, Identifiable, Hashable { } } -public struct SDKToSeedMapping: Codable { +public struct SDKToSeedMapping: Codable, Sendable { public let buildUpdate: String public let platform: DownloadableRuntime.Platform public let seedNumber: Int } -public struct SDKToSimulatorMapping: Codable { +public struct SDKToSimulatorMapping: Codable, Sendable { public let sdkBuildUpdate: String public let simulatorBuildUpdate: String public let sdkIdentifier: String @@ -100,33 +104,34 @@ public struct SDKToSimulatorMapping: Codable { } extension DownloadableRuntime { - public struct SimulatorVersion: Codable, Hashable { + public struct SimulatorVersion: Codable, Hashable, Sendable { public let buildUpdate: String public let version: String } - public struct HostRequirements: Codable, Hashable { - let maxHostVersion: String? - let excludedHostArchitectures: [String]? - let minHostVersion: String? - let minXcodeVersion: String? + public struct HostRequirements: Codable, Hashable, Sendable { + public let maxHostVersion: String? + public let excludedHostArchitectures: [String]? + public let minHostVersion: String? + public let minXcodeVersion: String? } - public enum Authentication: String, Codable { + public enum Authentication: String, Codable, Sendable { case virtual = "virtual" } - public enum Category: String, Codable { + public enum Category: String, Codable, Sendable { case simulator = "simulator" } - public enum ContentType: String, Codable { + public enum ContentType: String, Codable, Sendable { case diskImage = "diskImage" case package = "package" case cryptexDiskImage = "cryptexDiskImage" + case patchableCryptexDiskImage = "patchableCryptexDiskImage" } - public enum Platform: String, Codable { + public enum Platform: String, Codable, Sendable { case iOS = "com.apple.platform.iphoneos" case macOS = "com.apple.platform.macosx" case watchOS = "com.apple.platform.watchos" @@ -156,37 +161,39 @@ extension DownloadableRuntime { } } -public struct InstalledRuntime: Decodable { - let build: String - let deletable: Bool - let identifier: UUID - let kind: Kind - let lastUsedAt: Date? - let path: String - let platformIdentifier: Platform - let runtimeBundlePath: String - let runtimeIdentifier: String - let signatureState: String - let state: String - let version: String - let sizeBytes: Int? - let supportedArchitectures: [Architecture]? +public struct InstalledRuntime: Decodable, Sendable { + public let build: String + public let deletable: Bool + public let identifier: UUID + public let kind: Kind + public let lastUsedAt: Date? + public let path: String + public let platformIdentifier: Platform + public let runtimeBundlePath: String + public let runtimeIdentifier: String + public let signatureState: String + public let state: String + public let version: String + public let sizeBytes: Int? + public let supportedArchitectures: [Architecture]? } extension InstalledRuntime { - enum Kind: String, Decodable { - case diskImage = "Disk Image" + public enum Kind: String, Decodable, Sendable { case bundled = "Bundled with Xcode" + case cryptexDiskImage = "Cryptex Disk Image" + case diskImage = "Disk Image" case legacyDownload = "Legacy Download" + case patchableCryptexDiskImage = "Patchable Cryptex Disk Image" } - enum Platform: String, Decodable { + public enum Platform: String, Decodable, Sendable { case tvOS = "com.apple.platform.appletvsimulator" case iOS = "com.apple.platform.iphonesimulator" case watchOS = "com.apple.platform.watchsimulator" case visionOS = "com.apple.platform.xrsimulator" - var asPlatformOS: DownloadableRuntime.Platform { + public var asPlatformOS: DownloadableRuntime.Platform { switch self { case .watchOS: return .watchOS case .iOS: return .iOS @@ -196,4 +203,3 @@ extension InstalledRuntime { } } } - diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift index 0f824a5a..01f46f07 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift @@ -6,29 +6,36 @@ // import Foundation -import Path +@preconcurrency import Path -public enum XcodeInstallState: Equatable { +public enum XcodeInstallState: Equatable, Sendable { case notInstalled case installing(XcodeInstallationStep) case installed(Path) - var notInstalled: Bool { + public var notInstalled: Bool { switch self { case .notInstalled: return true default: return false } } - var installing: Bool { + public var installing: Bool { switch self { case .installing: return true default: return false } } - var installed: Bool { + public var installed: Bool { switch self { case .installed: return true default: return false } } + + public var installedPath: Path? { + switch self { + case .installed(let path): return path + default: return nil + } + } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift index 9a4349e1..98b941c2 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift @@ -8,7 +8,7 @@ import Foundation // A numbered step -public enum XcodeInstallationStep: Equatable, CustomStringConvertible { +public enum XcodeInstallationStep: Equatable, CustomStringConvertible, Sendable { case authenticating case downloading(progress: Progress) case unarchiving diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift index 62849a9d..b66edc4a 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift @@ -8,7 +8,7 @@ import Foundation /// The name of an Architecture. -public enum Architecture: String, Codable, Equatable, Hashable, Identifiable, CaseIterable { +public enum Architecture: String, Codable, Equatable, Hashable, Identifiable, CaseIterable, Sendable { public var id: Self { self } /// The Arm64 architecture (Apple Silicon) @@ -35,7 +35,7 @@ public enum Architecture: String, Codable, Equatable, Hashable, Identifiable, Ca } } -public enum ArchitectureVariant: String, Codable, Equatable, Hashable, Identifiable, CaseIterable { +public enum ArchitectureVariant: String, Codable, Equatable, Hashable, Identifiable, CaseIterable, Sendable { public var id: Self { self } case universal diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift index b944c698..5612f77b 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift @@ -9,7 +9,7 @@ import Foundation -public struct Checksums: Codable { +public struct Checksums: Codable, Sendable { public let sha1: String? diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift index 3f012f04..cb91782b 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift @@ -8,7 +8,7 @@ import Foundation -public struct Compilers: Codable { +public struct Compilers: Codable, Equatable, Sendable { public let gcc: Array? public let llvm_gcc: Array? public let llvm: Array? diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift index 67e5736b..c01fb274 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift @@ -8,7 +8,7 @@ import Foundation -public struct Link: Codable { +public struct Link: Codable, Sendable { public let url: URL public let sizeMB: Int? /// The platforms supported by this link, if applicable. @@ -21,7 +21,7 @@ public struct Link: Codable { // } } -public struct Links: Codable { +public struct Links: Codable, Sendable { public let download: Link? public let notes: Link? diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift index 9c83e62f..63567c0e 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift @@ -8,7 +8,7 @@ import Foundation -public enum Release: Codable { +public enum Release: Codable, Equatable, Sendable { public enum CodingKeys: String, CodingKey { case gm, gmSeed, rc, beta, dp, release diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift index 1dcffd6d..e3ef99c9 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift @@ -8,7 +8,7 @@ import Foundation -public struct SDKs: Codable { +public struct SDKs: Codable, Equatable, Sendable { public let macOS: Array? public let iOS: Array? public let watchOS: Array? @@ -54,4 +54,17 @@ public struct SDKs: Codable { self.tvOS = tvOS?.isEmpty == true ? nil : tvOS self.visionOS = visionOS?.isEmpty == true ? nil : visionOS } + + /// All SDK build numbers, used to correlate downloadable runtimes with Xcode releases. + public var allBuilds: [String] { + [ + iOS, + tvOS, + macOS, + watchOS, + visionOS, + ] + .compactMap { $0 } + .flatMap { versions in versions.compactMap(\.build) } + } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift index 4df4b307..7d17a011 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift @@ -8,7 +8,7 @@ import Foundation -public struct XcodeRelease: Codable { +public struct XcodeRelease: Codable, Sendable { public let name: String public let version: XcodeVersion public let date: YMD diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift index 09138399..7899ca6b 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift @@ -9,7 +9,7 @@ import Foundation public typealias V = XcodeVersion -public struct XcodeVersion: Codable { +public struct XcodeVersion: Codable, Equatable, Sendable { public let number: String? public let build: String? public let release: Release diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift index a97bd4d0..396a57b2 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift @@ -8,7 +8,7 @@ import Foundation -public struct YMD: Codable { +public struct YMD: Codable, Sendable { public let year: Int public let month: Int public let day: Int diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AutoInstallationType.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AutoInstallationType.swift new file mode 100644 index 00000000..b60d3de2 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AutoInstallationType.swift @@ -0,0 +1,23 @@ +import Foundation + +public enum AutoInstallationType: Int, Identifiable, Sendable { + case none = 0 + case newestVersion + case newestBeta + + public var id: Self { self } + + public var isAutoInstalling: Bool { + get { self != .none } + set { + self = newValue ? .newestVersion : .none + } + } + + public var isAutoInstallingBeta: Bool { + get { self == .newestBeta } + set { + self = newValue ? .newestBeta : (isAutoInstalling ? .newestVersion : .none) + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift new file mode 100644 index 00000000..ff93fa7e --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift @@ -0,0 +1,93 @@ +import Foundation +@preconcurrency import Version + +/// A version of Xcode that's available for installation. +public struct AvailableXcode: Codable, Equatable, Sendable { + public var version: Version { + xcodeID.version + } + + public let url: URL + public let filename: String + public let releaseDate: Date? + public let requiredMacOSVersion: String? + public let releaseNotesURL: URL? + public let sdks: SDKs? + public let compilers: Compilers? + public let fileSize: Int64? + public let architectures: [Architecture]? + public var xcodeID: XcodeID + + public var downloadPath: String { + url.path + } + + public init( + version: Version, + url: URL, + filename: String, + releaseDate: Date?, + requiredMacOSVersion: String? = nil, + releaseNotesURL: URL? = nil, + sdks: SDKs? = nil, + compilers: Compilers? = nil, + fileSize: Int64? = nil, + architectures: [Architecture]? = nil + ) { + self.url = url + self.filename = filename + self.releaseDate = releaseDate + self.requiredMacOSVersion = requiredMacOSVersion + self.releaseNotesURL = releaseNotesURL + self.sdks = sdks + self.compilers = compilers + self.fileSize = fileSize + self.architectures = architectures + self.xcodeID = XcodeID(version: version, architectures: architectures) + } + + public init(release: AvailableXcodeRelease) { + self.init( + version: release.version, + url: release.url, + filename: release.filename, + releaseDate: release.releaseDate, + requiredMacOSVersion: release.requiredMacOSVersion, + releaseNotesURL: release.releaseNotesURL, + sdks: release.sdks, + compilers: release.compilers, + fileSize: release.fileSize, + architectures: release.architectures + ) + } + + public init(_ archive: XcodeArchive) { + self.init( + version: archive.version, + url: archive.downloadURL, + filename: archive.filename, + releaseDate: nil + ) + } +} + +public extension XcodeArchive { + init(_ xcode: AvailableXcode) { + self.init( + version: xcode.version, + downloadURL: xcode.url, + filename: xcode.filename + ) + } +} + +public extension Array where Element == AvailableXcode { + /// Returns the first Xcode that unambiguously has the same version as `version`. + /// + /// If there's an exact match that takes prerelease identifiers into account, that's returned. + /// Otherwise, if a version without prerelease or build metadata identifiers is provided, and there's a single match based on only the major, minor and patch numbers, that's returned. + /// If there are multiple matches, or no matches, nil is returned. + func first(withVersion version: Version) -> AvailableXcode? { + XcodeVersionMatcher.find(version: version, in: self, versionKeyPath: \AvailableXcode.version) + } +} diff --git a/Xcodes/Backend/AvailableXcode.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcodeRelease.swift similarity index 76% rename from Xcodes/Backend/AvailableXcode.swift rename to Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcodeRelease.swift index 84151180..ccb29af2 100644 --- a/Xcodes/Backend/AvailableXcode.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcodeRelease.swift @@ -1,12 +1,9 @@ import Foundation -import Version -import XcodesKit +@preconcurrency import Version -/// A version of Xcode that's available for installation -public struct AvailableXcode: Codable { - public var version: Version { - return xcodeID.version - } +/// A source-neutral Xcode release that can be mapped into app- or CLI-specific state. +public struct AvailableXcodeRelease: Codable, Sendable { + public let version: Version public let url: URL public let filename: String public let releaseDate: Date? @@ -16,10 +13,10 @@ public struct AvailableXcode: Codable { public let compilers: Compilers? public let fileSize: Int64? public let architectures: [Architecture]? + public var downloadPath: String { - return url.path + url.path } - public var xcodeID: XcodeID public init( version: Version, @@ -33,6 +30,7 @@ public struct AvailableXcode: Codable { fileSize: Int64? = nil, architectures: [Architecture]? = nil ) { + self.version = version self.url = url self.filename = filename self.releaseDate = releaseDate @@ -42,6 +40,5 @@ public struct AvailableXcode: Codable { self.compilers = compilers self.fileSize = fileSize self.architectures = architectures - self.xcodeID = XcodeID(version: version, architectures: architectures) } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/Downloads.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/Downloads.swift new file mode 100644 index 00000000..a089f407 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/Downloads.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct Downloads: Codable, Sendable { + public let resultCode: Int? + public let resultsString: String? + public let downloads: [Download]? + + public init(resultCode: Int? = nil, resultsString: String? = nil, downloads: [Download]?) { + self.resultCode = resultCode + self.resultsString = resultsString + self.downloads = downloads + } + + public var hasError: Bool { + (resultCode ?? 0) != 0 + } +} + +public typealias ByteCount = Int64 + +public struct Download: Codable, Sendable { + public let name: String + public let files: [File] + public let dateModified: Date + + public init(name: String, files: [File], dateModified: Date) { + self.name = name + self.files = files + self.dateModified = dateModified + } + + public struct File: Codable, Sendable { + public let remotePath: String + public let fileSize: ByteCount? + + public init(remotePath: String, fileSize: ByteCount? = nil) { + self.remotePath = remotePath + self.fileSize = fileSize + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcode.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcode.swift new file mode 100644 index 00000000..422588c9 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcode.swift @@ -0,0 +1,58 @@ +import Foundation +@preconcurrency import Path +@preconcurrency import Version + +/// A version of Xcode that's already installed. +public struct InstalledXcode: Equatable, Sendable { + public typealias ContentsAtPath = @Sendable (String) -> Data? + public typealias LoadArchitectures = @Sendable (URL) throws -> ProcessOutput + + public let path: Path + public let xcodeID: XcodeID + + /// Composed of the bundle short version from Info.plist and the product build version from version.plist. + public var version: Version { + xcodeID.version + } + + public init(path: Path, version: Version, architectures: [Architecture]? = nil) { + self.path = path + self.xcodeID = XcodeID(version: version, architectures: architectures) + } + + public init?(path: Path) { + self.init( + path: path, + contentsAtPath: { path in Current.files.contents(atPath: path) }, + loadArchitectures: Current.shell.archs + ) + } + + public init?( + path: Path, + contentsAtPath: ContentsAtPath, + loadArchitectures: LoadArchitectures + ) { + guard + let bundle = XcodeBundleInfo(path: path, contentsAtPath: contentsAtPath), + bundle.bundleID == "com.apple.dt.Xcode" + else { return nil } + self.path = bundle.path + + let xcodeBinaryURL = path.url.appending(path: "Contents/MacOS/Xcode") + let archsString = try? loadArchitectures(xcodeBinaryURL).out + let architectures = archsString? + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: " ") + .compactMap { Architecture(rawValue: String($0)) } + + self.xcodeID = XcodeID(version: bundle.version, architectures: architectures) + } +} + +public extension Array where Element == InstalledXcode { + /// Returns the first installed Xcode that unambiguously has the same version as `version`. + func first(withVersion version: Version) -> InstalledXcode? { + XcodeVersionMatcher.find(version: version, in: self, versionKeyPath: \InstalledXcode.version) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift new file mode 100644 index 00000000..d1d0be5e --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift @@ -0,0 +1,60 @@ +import Foundation +@preconcurrency import Path +@preconcurrency import Version + +public struct XcodeBundleInfo: Equatable, Sendable { + public let path: Path + public let bundleID: String? + public let version: Version + + public init?(path: Path, contentsAtPath: @Sendable (String) -> Data?) { + let infoPlistPath = path.join("Contents").join("Info.plist") + let versionPlistPath = path.join("Contents").join("version.plist") + + guard + let infoPlistData = contentsAtPath(infoPlistPath.string), + let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData), + let bundleShortVersion = infoPlist.bundleShortVersion, + let bundleVersion = Version(tolerant: bundleShortVersion), + let versionPlistData = contentsAtPath(versionPlistPath.string), + let versionPlist = try? PropertyListDecoder().decode(VersionPlist.self, from: versionPlistData) + else { return nil } + + var prereleaseIdentifiers = bundleVersion.prereleaseIdentifiers + if let filenameVersion = Version(path.basename(dropExtension: true).replacingOccurrences(of: "Xcode-", with: "")) { + prereleaseIdentifiers = filenameVersion.prereleaseIdentifiers + } else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") { + prereleaseIdentifiers = ["beta"] + } + + self.path = path + self.bundleID = infoPlist.bundleID + self.version = Version( + major: bundleVersion.major, + minor: bundleVersion.minor, + patch: bundleVersion.patch, + prereleaseIdentifiers: prereleaseIdentifiers, + buildMetadataIdentifiers: [versionPlist.productBuildVersion].compactMap { $0 } + ) + } +} + +public struct InfoPlist: Decodable, Sendable { + public let bundleID: String? + public let bundleShortVersion: String? + public let bundleIconName: String? + + public enum CodingKeys: String, CodingKey { + case bundleID = "CFBundleIdentifier" + case bundleShortVersion = "CFBundleShortVersionString" + case bundleIconName = "CFBundleIconName" + } +} + +public struct VersionPlist: Decodable, Sendable { + public let productBuildVersion: String + + public enum CodingKeys: String, CodingKey { + case productBuildVersion = "ProductBuildVersion" + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/SelectedActionType.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/SelectedActionType.swift new file mode 100644 index 00000000..c1a82ee4 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/SelectedActionType.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum SelectedActionType: String, CaseIterable, CustomStringConvertible, Identifiable, Sendable { + case none + case rename + + public var id: Self { self } + + public static var `default`: SelectedActionType { .none } + + public var description: String { + switch self { + case .none: return localizeString("OnSelectDoNothing") + case .rename: return localizeString("OnSelectRenameXcode") + } + } + + public var detailedDescription: String { + switch self { + case .none: return localizeString("OnSelectDoNothingDescription") + case .rename: return localizeString("OnSelectRenameXcodeDescription") + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeID.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeID.swift new file mode 100644 index 00000000..e49b0344 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeID.swift @@ -0,0 +1,17 @@ +import Foundation +@preconcurrency import Version + +public struct XcodeID: Codable, Hashable, Identifiable, Sendable { + public let version: Version + public let architectures: [Architecture]? + + public var id: String { + let architectures = architectures?.map(\.rawValue).joined() ?? "" + return version.description + architectures + } + + public init(version: Version, architectures: [Architecture]? = nil) { + self.version = version + self.architectures = architectures + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListItem.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListItem.swift new file mode 100644 index 00000000..6c8e5878 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListItem.swift @@ -0,0 +1,57 @@ +import Foundation +@preconcurrency import Path +@preconcurrency import Version + +public struct XcodeListItem: Identifiable, Sendable { + public var version: Version { + id.version + } + + public let identicalBuilds: [XcodeID] + public let installState: XcodeInstallState + public let selected: Bool + public let requiredMacOSVersion: String? + public let releaseNotesURL: URL? + public let releaseDate: Date? + public let sdks: SDKs? + public let compilers: Compilers? + public let downloadFileSize: Int64? + public let architectures: [Architecture]? + public let id: XcodeID + + public init( + version: Version, + identicalBuilds: [XcodeID] = [], + installState: XcodeInstallState, + selected: Bool, + requiredMacOSVersion: String? = nil, + releaseNotesURL: URL? = nil, + releaseDate: Date? = nil, + sdks: SDKs? = nil, + compilers: Compilers? = nil, + downloadFileSize: Int64? = nil, + architectures: [Architecture]? = nil + ) { + self.identicalBuilds = identicalBuilds + self.installState = installState + self.selected = selected + self.requiredMacOSVersion = requiredMacOSVersion + self.releaseNotesURL = releaseNotesURL + self.releaseDate = releaseDate + self.sdks = sdks + self.compilers = compilers + self.downloadFileSize = downloadFileSize + self.architectures = architectures + self.id = XcodeID(version: version, architectures: architectures) + } + + public var installedPath: Path? { + installState.installedPath + } + + public var downloadFileSizeString: String? { + downloadFileSize.map { + ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodesKitError.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodesKitError.swift new file mode 100644 index 00000000..48c91ec5 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodesKitError.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct XcodesKitError: LocalizedError, Equatable, Sendable { + public let message: String + + public init(_ message: String) { + self.message = message + } + + public var errorDescription: String? { + message + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ApplicationSupportMigrationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ApplicationSupportMigrationService.swift new file mode 100644 index 00000000..c4667d82 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ApplicationSupportMigrationService.swift @@ -0,0 +1,42 @@ +import Foundation +@preconcurrency import Path + +public struct ApplicationSupportMigrationService: Sendable { + public enum Result: Equatable, Sendable { + case noMigrationNeeded + case migratedOldSupportFiles + case removedOldSupportFiles + } + + public typealias FileExists = @Sendable (String) -> Bool + public typealias MoveItem = @Sendable (URL, URL) throws -> Void + public typealias RemoveItem = @Sendable (URL) throws -> Void + + private let fileExists: FileExists + private let moveItem: MoveItem + private let removeItem: RemoveItem + + public init( + fileExists: @escaping FileExists = { path in FileManager.default.fileExists(atPath: path) }, + moveItem: @escaping MoveItem = { source, destination in try FileManager.default.moveItem(at: source, to: destination) }, + removeItem: @escaping RemoveItem = { url in try FileManager.default.removeItem(at: url) } + ) { + self.fileExists = fileExists + self.moveItem = moveItem + self.removeItem = removeItem + } + + public func migrate(oldSupportPath: Path, newSupportPath: Path) -> Result { + guard fileExists(oldSupportPath.string) else { + return .noMigrationNeeded + } + + if fileExists(newSupportPath.string) { + try? removeItem(oldSupportPath.url) + return .removedOldSupportFiles + } else { + try? moveItem(oldSupportPath.url, newSupportPath.url) + return .migratedOldSupportFiles + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveCancellationCleanupService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveCancellationCleanupService.swift new file mode 100644 index 00000000..a5c983eb --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveCancellationCleanupService.swift @@ -0,0 +1,44 @@ +import Foundation +@preconcurrency import Path + +public struct ArchiveCancellationCleanupService: Sendable { + public typealias RemoveItem = @Sendable (URL) throws -> Void + + private let removeItem: RemoveItem + + public init(removeItem: @escaping RemoveItem = { url in try FileManager.default.removeItem(at: url) }) { + self.removeItem = removeItem + } + + public func cleanupXcodeArchive( + for xcode: AvailableXcode, + applicationSupportPath: Path + ) { + let service = XcodeArchiveService( + applicationSupportPath: applicationSupportPath, + fileExists: { _ in false }, + download: { _, _, _, _ in throw XcodesKitError("Archive cleanup does not download") } + ) + cleanupArchive(at: service.expectedArchivePath(for: XcodeArchive(xcode))) + } + + public func cleanupRuntimeArchive( + for runtime: DownloadableRuntime, + destinationDirectory: Path + ) { + let service = RuntimeArchiveService( + fileExists: { _ in false }, + download: { _, _, _, _, _ in throw XcodesKitError("Archive cleanup does not download") } + ) + cleanupArchive(at: service.expectedArchivePath(for: runtime, destinationDirectory: destinationDirectory)) + } + + public func cleanupArchive(at archivePath: Path) { + try? removeItem(archivePath.url) + try? removeItem(aria2MetadataPath(for: archivePath).url) + } + + public func aria2MetadataPath(for archivePath: Path) -> Path { + archivePath.parent/(archivePath.basename() + ".aria2") + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadService.swift new file mode 100644 index 00000000..c135dc97 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadService.swift @@ -0,0 +1,100 @@ +import Foundation +@preconcurrency import Path + +public struct ArchiveDownloadService: Sendable { + public typealias Aria2Download = @Sendable (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream + public typealias URLSessionDownload = @Sendable (URL, URL, Data?) -> (progress: Progress, task: Task<(saveLocation: URL, response: URLResponse), Error>) + public typealias ResponseValidator = @Sendable (URLResponse) throws -> Void + public typealias ErrorFactory = @Sendable () -> Error + + private let aria2Download: Aria2Download + private let urlSessionDownload: URLSessionDownload + private let contentsAtPath: @Sendable (String) -> Data? + private let createFile: @Sendable (String, Data) -> Void + private let removeItem: @Sendable (URL) throws -> Void + private let shouldRetry: @Sendable (Error) -> Bool + private let validateResponse: ResponseValidator + + public init( + aria2Download: @escaping Aria2Download, + urlSessionDownload: @escaping URLSessionDownload, + contentsAtPath: @escaping @Sendable (String) -> Data?, + createFile: @escaping @Sendable (String, Data) -> Void, + removeItem: @escaping @Sendable (URL) throws -> Void, + shouldRetry: @escaping @Sendable (Error) -> Bool = { _ in true }, + validateResponse: @escaping ResponseValidator = { _ in } + ) { + self.aria2Download = aria2Download + self.urlSessionDownload = urlSessionDownload + self.contentsAtPath = contentsAtPath + self.createFile = createFile + self.removeItem = removeItem + self.shouldRetry = shouldRetry + self.validateResponse = validateResponse + } + + public func downloadWithAria2( + aria2Path: Path, + url: URL, + destination: Path, + cookies: [HTTPCookie], + progressChanged: @escaping @Sendable (Progress) -> Void + ) async throws -> URL { + try await attemptRetryableTask(shouldRetry: shouldRetry) { + let progressStream = aria2Download(aria2Path, url, destination, cookies) + + for try await progress in progressStream { + progressChanged(progress) + } + + return destination.url + } + } + + public func downloadWithURLSession( + url: URL, + destination: Path, + resumeDataPath: Path, + progressChanged: @escaping @Sendable (Progress) -> Void + ) async throws -> URL { + let persistedResumeData = contentsAtPath(resumeDataPath.string) + + do { + let url = try await attemptResumableTask(shouldRetry: shouldRetry) { resumeData -> URL in + let (progress, task) = urlSessionDownload( + url, + destination.url, + resumeData ?? persistedResumeData + ) + progressChanged(progress) + + let result = try await task.value + try validateResponse(result.response) + return result.saveLocation + } + try? removeItem(resumeDataPath.url) + return url + } catch { + if let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data { + createFile(resumeDataPath.string, resumeData) + } + + throw error + } + } + + public static func resumeDataPath(for archive: XcodeArchive, in directory: Path) -> Path { + directory/"Xcode-\(archive.version).resumedata" + } + + /// Apple redirects unauthorized downloads to an HTML page with a 200 status. Treat that + /// as an authorization failure before the caller tries to unarchive the page as a XIP. + public static func validateDeveloperDownloadResponse( + _ response: URLResponse, + unauthorizedError: ErrorFactory = { XcodesKitError("Received 403: Unauthorized.") } + ) throws { + guard response.url?.lastPathComponent != "unauthorized" else { + throw unauthorizedError() + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadStrategyService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadStrategyService.swift new file mode 100644 index 00000000..a85c1fc8 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadStrategyService.swift @@ -0,0 +1,63 @@ +import Foundation +@preconcurrency import Path + +public struct ArchiveDownloadStrategyService: Sendable { + public typealias Aria2Path = @Sendable () throws -> Path + public typealias CookiesForURL = @Sendable (URL) -> [HTTPCookie] + + private let archiveDownloadService: ArchiveDownloadService + private let aria2Path: Aria2Path + private let cookiesForURL: CookiesForURL + + public init( + archiveDownloadService: ArchiveDownloadService, + aria2Path: @escaping Aria2Path, + cookiesForURL: @escaping CookiesForURL = { _ in [] } + ) { + self.archiveDownloadService = archiveDownloadService + self.aria2Path = aria2Path + self.cookiesForURL = cookiesForURL + } + + public func download( + url: URL, + destination: Path, + downloader: XcodeArchiveDownloader, + resumeDataPath: Path, + progressChanged: @escaping @Sendable (Progress) -> Void + ) async throws -> URL { + switch downloader { + case .aria2: + return try await archiveDownloadService.downloadWithAria2( + aria2Path: aria2Path(), + url: url, + destination: destination, + cookies: cookiesForURL(url), + progressChanged: progressChanged + ) + case .urlSession: + return try await archiveDownloadService.downloadWithURLSession( + url: url, + destination: destination, + resumeDataPath: resumeDataPath, + progressChanged: progressChanged + ) + } + } + + public func download( + archive: XcodeArchive, + destination: Path, + downloader: XcodeArchiveDownloader, + applicationSupportPath: Path, + progressChanged: @escaping @Sendable (Progress) -> Void + ) async throws -> URL { + try await download( + url: archive.downloadURL, + destination: destination, + downloader: downloader, + resumeDataPath: ArchiveDownloadService.resumeDataPath(for: archive, in: applicationSupportPath), + progressChanged: progressChanged + ) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/Aria2DownloadService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/Aria2DownloadService.swift new file mode 100644 index 00000000..2b2f307a --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/Aria2DownloadService.swift @@ -0,0 +1,70 @@ +import Foundation +import os +@preconcurrency import Path + +public struct Aria2DownloadService: Sendable { + public init() {} + + public func download( + aria2Path: Path, + url: URL, + destination: Path, + cookies: [HTTPCookie], + progress: Progress = Progress(), + unauthorizedError: (@Sendable () -> Error)? = nil + ) -> AsyncThrowingStream { + let process = Process() + process.executableURL = aria2Path.url + process.arguments = [ + "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", + "--max-connection-per-server=16", + "--split=16", + "--summary-interval=1", + "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", + "--dir=\(destination.parent.string)", + "--out=\(destination.basename())", + "--human-readable=false", + url.absoluteString, + ] + + let state = UnauthorizedState() + let runner = ProcessProgressStreamRunner( + process: process, + progress: progress, + outputHandler: { string, progress in + if string.contains("Redirecting to https://developer.apple.com/unauthorized/") { + state.markUnauthorized() + } + + progress.updateFromAria2(string: string) + }, + failureHandler: { process in + if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { + return aria2cError + } else { + return ProcessExecutionError(process: process, standardOutput: "", standardError: "") + } + }, + successHandler: { + guard !state.isUnauthorized else { + return unauthorizedError?() ?? XcodesKitError("Received 403: Unauthorized.") + } + return nil + } + ) + + return runner.stream() + } +} + +private final class UnauthorizedState: Sendable { + private let unauthorized = OSAllocatedUnfairLock(initialState: false) + + var isUnauthorized: Bool { + unauthorized.withLock { $0 } + } + + func markUnauthorized() { + unauthorized.withLock { $0 = true } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/AsyncRetry.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/AsyncRetry.swift new file mode 100644 index 00000000..7518fe2b --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/AsyncRetry.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Attempts a resumable async task and retries failures that include URLSession resume data. +public func attemptResumableTask( + maximumRetryCount: Int = 3, + delayBeforeRetry: Duration = .seconds(2), + shouldRetry: @escaping @Sendable (Error) -> Bool = { _ in true }, + _ body: @escaping @Sendable (Data?) async throws -> T +) async throws -> T { + var attempts = 0 + var resumeData: Data? + + while true { + attempts += 1 + + do { + return try await body(resumeData) + } catch { + guard + attempts < maximumRetryCount, + shouldRetry(error), + let nextResumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data + else { + throw error + } + + resumeData = nextResumeData + try await Task.sleep(for: delayBeforeRetry) + } + } +} + +/// Attempts an async task and retries caller-approved failures. +public func attemptRetryableTask( + maximumRetryCount: Int = 3, + delayBeforeRetry: Duration = .seconds(2), + shouldRetry: @escaping @Sendable (Error) -> Bool = { _ in true }, + _ body: @escaping @Sendable () async throws -> T +) async throws -> T { + var attempts = 0 + + while true { + attempts += 1 + + do { + return try await body() + } catch { + guard attempts < maximumRetryCount, shouldRetry(error) else { + throw error + } + + try await Task.sleep(for: delayBeforeRetry) + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/AvailableXcodeCache.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/AvailableXcodeCache.swift new file mode 100644 index 00000000..35fa4a81 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/AvailableXcodeCache.swift @@ -0,0 +1,52 @@ +import Foundation +@preconcurrency import Path + +public struct AvailableXcodeCache: Sendable { + public typealias Attributes = [FileAttributeKey: Any] + public typealias ContentsAtPath = @Sendable (String) -> Data? + public typealias WriteData = @Sendable (Data, URL) throws -> Void + public typealias CreateDirectory = @Sendable (URL, Bool, Attributes?) throws -> Void + public typealias AttributesOfItem = @Sendable (String) throws -> Attributes + + public let cacheFile: Path + private var contentsAtPath: ContentsAtPath + private var writeData: WriteData + private var createDirectory: CreateDirectory + private var attributesOfItem: AttributesOfItem + + public init( + cacheFile: Path, + contentsAtPath: @escaping ContentsAtPath = { FileManager.default.contents(atPath: $0) }, + writeData: @escaping WriteData = { try $0.write(to: $1) }, + createDirectory: @escaping CreateDirectory = { url, createIntermediates, attributes in + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates, + attributes: attributes + ) + }, + attributesOfItem: @escaping AttributesOfItem = { try FileManager.default.attributesOfItem(atPath: $0) } + ) { + self.cacheFile = cacheFile + self.contentsAtPath = contentsAtPath + self.writeData = writeData + self.createDirectory = createDirectory + self.attributesOfItem = attributesOfItem + } + + public func load() throws -> [AvailableXcode]? { + guard let data = contentsAtPath(cacheFile.string) else { return nil } + return try JSONDecoder().decode([AvailableXcode].self, from: data) + } + + public func lastModified() -> Date? { + let attributes = try? attributesOfItem(cacheFile.string) + return attributes?[.modificationDate] as? Date + } + + public func save(_ xcodes: [AvailableXcode]) throws { + let data = try JSONEncoder().encode(xcodes) + try createDirectory(cacheFile.url.deletingLastPathComponent(), true, nil) + try writeData(data, cacheFile.url) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/CodableFileStore.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/CodableFileStore.swift new file mode 100644 index 00000000..3d14469a --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/CodableFileStore.swift @@ -0,0 +1,50 @@ +import Foundation +@preconcurrency import Path + +public struct CodableFileStore: Sendable { + public typealias Attributes = [FileAttributeKey: Any] + public typealias ContentsAtPath = @Sendable (String) -> Data? + public typealias CreateDirectory = @Sendable (URL, Bool, Attributes?) throws -> Void + public typealias CreateFile = @Sendable (String, Data?, Attributes?) -> Bool + public typealias Decode = @Sendable (Data) throws -> Value + public typealias Encode = @Sendable (Value) throws -> Data + + private let contentsAtPath: ContentsAtPath + private let createDirectory: CreateDirectory + private let createFile: CreateFile + private let decode: Decode + private let encode: Encode + + public init( + contentsAtPath: @escaping ContentsAtPath = { path in FileManager.default.contents(atPath: path) }, + createDirectory: @escaping CreateDirectory = { url, createIntermediates, attributes in + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates, + attributes: attributes + ) + }, + createFile: @escaping CreateFile = { path, data, attributes in + FileManager.default.createFile(atPath: path, contents: data, attributes: attributes) + }, + decode: @escaping Decode = { data in try JSONDecoder().decode(Value.self, from: data) }, + encode: @escaping Encode = { value in try JSONEncoder().encode(value) } + ) { + self.contentsAtPath = contentsAtPath + self.createDirectory = createDirectory + self.createFile = createFile + self.decode = decode + self.encode = encode + } + + public func load(from file: Path) throws -> Value? { + guard let data = contentsAtPath(file.string) else { return nil } + return try decode(data) + } + + public func save(_ value: Value, to file: Path) throws { + let data = try encode(value) + try createDirectory(file.url.deletingLastPathComponent(), true, nil) + _ = createFile(file.string, data, nil) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/DownloadableRuntimeCache.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/DownloadableRuntimeCache.swift new file mode 100644 index 00000000..a8050c64 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/DownloadableRuntimeCache.swift @@ -0,0 +1,43 @@ +import Foundation +@preconcurrency import Path + +public struct DownloadableRuntimeCache: Sendable { + public typealias Attributes = [FileAttributeKey: Any] + public typealias ContentsAtPath = @Sendable (String) -> Data? + public typealias WriteData = @Sendable (Data, URL) throws -> Void + public typealias CreateDirectory = @Sendable (URL, Bool, Attributes?) throws -> Void + + public let cacheFile: Path + private let contentsAtPath: ContentsAtPath + private let writeData: WriteData + private let createDirectory: CreateDirectory + + public init( + cacheFile: Path, + contentsAtPath: @escaping ContentsAtPath = { FileManager.default.contents(atPath: $0) }, + writeData: @escaping WriteData = { try $0.write(to: $1) }, + createDirectory: @escaping CreateDirectory = { url, createIntermediates, attributes in + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: createIntermediates, + attributes: attributes + ) + } + ) { + self.cacheFile = cacheFile + self.contentsAtPath = contentsAtPath + self.writeData = writeData + self.createDirectory = createDirectory + } + + public func load() throws -> [DownloadableRuntime]? { + guard let data = contentsAtPath(cacheFile.string) else { return nil } + return try JSONDecoder().decode([DownloadableRuntime].self, from: data) + } + + public func save(_ runtimes: [DownloadableRuntime]) throws { + let data = try JSONEncoder().encode(runtimes) + try createDirectory(cacheFile.url.deletingLastPathComponent(), true, nil) + try writeData(data, cacheFile.url) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/HostHardware.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/HostHardware.swift new file mode 100644 index 00000000..8d38e31a --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/HostHardware.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct HostHardware: Sendable { + public init() {} + + /// Determines the architecture of the Mac on which we're running. + public static func currentMachineHardwareName() -> String? { + var sysInfo = utsname() + let result = uname(&sysInfo) + + guard result == EXIT_SUCCESS else { + return nil + } + + let bytes = Data(bytes: &sysInfo.machine, count: Int(_SYS_NAMELEN)) + return String(data: bytes, encoding: .utf8)? + .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) + } + + public static func isAppleSilicon(machineHardwareName: String? = currentMachineHardwareName()) -> Bool { + machineHardwareName == Architecture.arm64.rawValue + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/InstalledXcodeDiscoveryService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/InstalledXcodeDiscoveryService.swift new file mode 100644 index 00000000..b286b8a7 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/InstalledXcodeDiscoveryService.swift @@ -0,0 +1,39 @@ +import Foundation +@preconcurrency import Path + +public struct InstalledXcodeDiscoveryService: Sendable { + public typealias ListDirectory = @Sendable (Path) -> [Path] + public typealias IsAppBundle = @Sendable (Path) -> Bool + public typealias ContentsAtPath = InstalledXcode.ContentsAtPath + public typealias LoadArchitectures = InstalledXcode.LoadArchitectures + + private let listDirectory: ListDirectory + private let isAppBundle: IsAppBundle + private let contentsAtPath: ContentsAtPath + private let loadArchitectures: LoadArchitectures + + public init( + listDirectory: @escaping ListDirectory, + isAppBundle: @escaping IsAppBundle = { path in Path.isAppBundle(path: path) }, + contentsAtPath: @escaping ContentsAtPath, + loadArchitectures: @escaping LoadArchitectures + ) { + self.listDirectory = listDirectory + self.isAppBundle = isAppBundle + self.contentsAtPath = contentsAtPath + self.loadArchitectures = loadArchitectures + } + + public func installedXcodes(in directory: Path) -> [InstalledXcode] { + listDirectory(directory).compactMap(installedXcode(at:)) + } + + public func installedXcode(at path: Path) -> InstalledXcode? { + guard isAppBundle(path) else { return nil } + return InstalledXcode( + path: path, + contentsAtPath: contentsAtPath, + loadArchitectures: loadArchitectures + ) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ProgressObservation.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ProgressObservation.swift new file mode 100644 index 00000000..4047fbdc --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ProgressObservation.swift @@ -0,0 +1,100 @@ +import Foundation +import os + +public enum ProgressObservedProperty: Sendable, Hashable { + case fractionCompleted + case localizedAdditionalDescription + case isIndeterminate +} + +public final class ProgressObservation: Sendable { + private let observations = OSAllocatedUnfairLock(initialState: [NSKeyValueObservation]()) + + public init() {} + + deinit { + invalidate() + } + + public func observe(_ progress: Progress, onChange: @escaping @Sendable (Progress) -> Void) { + observe(progress, observing: [.fractionCompleted], onChange: onChange) + } + + public func observe(_ progress: Progress, observing properties: Set, onChange: @escaping @Sendable (Progress) -> Void) { + let observations = properties.sortedForObservation().map { property in + switch property { + case .fractionCompleted: + progress.observe(\.fractionCompleted) { progress, _ in + onChange(progress) + } + case .localizedAdditionalDescription: + progress.observe(\.localizedAdditionalDescription) { progress, _ in + onChange(progress) + } + case .isIndeterminate: + progress.observe(\.isIndeterminate) { progress, _ in + onChange(progress) + } + } + } + + let previousObservations = self.observations.withLock { + let previousObservations = $0 + $0 = observations + return previousObservations + } + + for observation in previousObservations { + observation.invalidate() + } + } + + public static func changes( + for progress: Progress, + observing properties: Set = [.fractionCompleted] + ) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Void.self, bufferingPolicy: .bufferingNewest(1)) + let observation = ProgressObservation() + + observation.observe(progress, observing: properties) { _ in + continuation.yield() + } + + continuation.onTermination = { _ in + observation.invalidate() + } + + return stream + } + + public func invalidate() { + let observations = self.observations.withLock { + let observations = $0 + $0 = [] + return observations + } + + for observation in observations { + observation.invalidate() + } + } +} + +private extension ProgressObservedProperty { + var sortOrder: Int { + switch self { + case .fractionCompleted: + 0 + case .localizedAdditionalDescription: + 1 + case .isIndeterminate: + 2 + } + } +} + +private extension Set where Element == ProgressObservedProperty { + func sortedForObservation() -> [ProgressObservedProperty] { + sorted { $0.sortOrder < $1.sortOrder } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveDownloadStrategyService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveDownloadStrategyService.swift new file mode 100644 index 00000000..63fe0b5d --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveDownloadStrategyService.swift @@ -0,0 +1,69 @@ +import Foundation +@preconcurrency import Path + +public struct RuntimeArchiveDownloadStrategyService: Sendable { + public typealias ValidateDownloadPath = @Sendable (String) async throws -> Void + public typealias Aria2Path = @Sendable () throws -> Path + public typealias Aria2Download = @Sendable (DownloadableRuntime, Path, Path, @escaping @Sendable (URL) -> [HTTPCookie]) -> AsyncThrowingStream + public typealias CookiesForURL = @Sendable (URL) -> [HTTPCookie] + public typealias URLSessionDownload = @Sendable (URL, Path, @escaping @Sendable (Progress) -> Void) async throws -> URL + public typealias MissingDownloadPathError = @Sendable (DownloadableRuntime) -> Error + + private let validateDownloadPath: ValidateDownloadPath + private let aria2Path: Aria2Path + private let aria2Download: Aria2Download + private let cookiesForURL: CookiesForURL + private let urlSessionDownload: URLSessionDownload? + private let missingDownloadPathError: MissingDownloadPathError + + public init( + validateDownloadPath: @escaping ValidateDownloadPath, + aria2Path: @escaping Aria2Path, + aria2Download: @escaping Aria2Download = { runtime, destination, aria2Path, cookiesForURL in + RuntimeArchiveService.downloadWithAria2( + runtime: runtime, + to: destination, + aria2Path: aria2Path, + cookiesForURL: cookiesForURL + ) + }, + cookiesForURL: @escaping CookiesForURL = { _ in [] }, + urlSessionDownload: URLSessionDownload? = nil, + missingDownloadPathError: @escaping MissingDownloadPathError = { _ in XcodesKitError("Invalid runtime downloadPath") } + ) { + self.validateDownloadPath = validateDownloadPath + self.aria2Path = aria2Path + self.aria2Download = aria2Download + self.cookiesForURL = cookiesForURL + self.urlSessionDownload = urlSessionDownload + self.missingDownloadPathError = missingDownloadPathError + } + + public func download( + runtime: DownloadableRuntime, + url: URL, + destination: Path, + downloader: XcodeArchiveDownloader, + progressChanged: @escaping @Sendable (Progress) -> Void + ) async throws -> URL { + guard let downloadPath = runtime.downloadPath else { + throw missingDownloadPathError(runtime) + } + + try await validateDownloadPath(downloadPath) + + switch downloader { + case .aria2: + for try await progress in aria2Download(runtime, destination, try aria2Path(), cookiesForURL) { + progressChanged(progress) + } + return destination.url + case .urlSession: + guard let urlSessionDownload else { + throw XcodesKitError("Downloading runtimes with URLSession is not supported. Please use aria2") + } + + return try await urlSessionDownload(url, destination, progressChanged) + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveInstallService.swift new file mode 100644 index 00000000..962eaca8 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveInstallService.swift @@ -0,0 +1,47 @@ +import Foundation + +public enum RuntimeArchiveInstallError: LocalizedError, Equatable, Sendable { + case unsupportedContentType(DownloadableRuntime.ContentType, archiveURL: URL) + + public var errorDescription: String? { + switch self { + case let .unsupportedContentType(contentType, archiveURL): + return "Installing via \(contentType.rawValue) not support - please install manually from \(archiveURL.description)" + } + } +} + +public struct RuntimeArchiveInstallService: Sendable { + public typealias StepChanged = @Sendable (RuntimeInstallationStep) async -> Void + + private let installDiskImage: @Sendable (URL) async throws -> Void + private let removeArchive: @Sendable (URL) throws -> Void + + public init( + installDiskImage: @escaping @Sendable (URL) async throws -> Void, + removeArchive: @escaping @Sendable (URL) throws -> Void + ) { + self.installDiskImage = installDiskImage + self.removeArchive = removeArchive + } + + public func install( + runtime: DownloadableRuntime, + archiveURL: URL, + deleteArchive: Bool = true, + stepChanged: StepChanged = { _ in } + ) async throws { + switch runtime.contentType { + case .diskImage: + await stepChanged(.installing) + try await installDiskImage(archiveURL) + try Task.checkCancellation() + + guard deleteArchive else { return } + await stepChanged(.trashingArchive) + try removeArchive(archiveURL) + case .package, .cryptexDiskImage, .patchableCryptexDiskImage: + throw RuntimeArchiveInstallError.unsupportedContentType(runtime.contentType, archiveURL: archiveURL) + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveService.swift new file mode 100644 index 00000000..2f0f3be0 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveService.swift @@ -0,0 +1,69 @@ +import Foundation +@preconcurrency import Path + +public struct RuntimeArchiveService: Sendable { + public typealias Download = @Sendable (DownloadableRuntime, URL, Path, XcodeArchiveDownloader, @escaping @Sendable (Progress) -> Void) async throws -> URL + + private let fileExists: @Sendable (Path) -> Bool + private let download: Download + + public init( + fileExists: @escaping @Sendable (Path) -> Bool, + download: @escaping Download + ) { + self.fileExists = fileExists + self.download = download + } + + public func archiveURL( + for runtime: DownloadableRuntime, + destinationDirectory: Path, + downloader: XcodeArchiveDownloader, + progressChanged: @escaping @Sendable (Progress) -> Void + ) async throws -> URL { + guard let url = runtime.url else { + throw XcodesKitError("Invalid or non existent runtime url") + } + + let destination = expectedArchivePath(for: runtime, destinationDirectory: destinationDirectory) + let metadataPath = aria2MetadataPath(for: destination) + let aria2DownloadIsIncomplete = downloader == .aria2 && fileExists(metadataPath) + + if fileExists(destination), aria2DownloadIsIncomplete == false { + return destination.url + } + + return try await download(runtime, url, destination, downloader, progressChanged) + } + + public func expectedArchivePath(for runtime: DownloadableRuntime, destinationDirectory: Path) -> Path { + guard let url = runtime.url else { + return destinationDirectory/runtime.identifier + } + return destinationDirectory/url.lastPathComponent + } + + public func aria2MetadataPath(for archivePath: Path) -> Path { + archivePath.parent/(archivePath.basename() + ".aria2") + } + + public static func downloadWithAria2( + runtime: DownloadableRuntime, + to destination: Path, + aria2Path: Path, + cookiesForURL: @escaping @Sendable (URL) -> [HTTPCookie] + ) -> AsyncThrowingStream { + guard let url = runtime.url else { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) + continuation.finish(throwing: XcodesKitError("Invalid or non existent runtime url")) + return stream + } + + return Aria2DownloadService().download( + aria2Path: aria2Path, + url: url, + destination: destination, + cookies: cookiesForURL(url) + ) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallPolicy.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallPolicy.swift new file mode 100644 index 00000000..e7da4598 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallPolicy.swift @@ -0,0 +1,65 @@ +import Foundation +@preconcurrency import Version + +public enum RuntimeInstallMethod: Equatable, Sendable { + case archive + case xcodebuild(architecture: String?) +} + +public enum RuntimeInstallPolicyError: LocalizedError, Equatable, Sendable { + case noSelectedXcode + case xcode16_1OrGreaterRequired(Version) + case xcode26OrGreaterRequired(Version) + + public var errorDescription: String? { + switch self { + case .noSelectedXcode: + return "No Xcode is currently selected, please make sure that you have one selected and installed before trying to install this runtime" + case let .xcode16_1OrGreaterRequired(version): + return "Installing this runtime requires Xcode 16.1 or greater to be selected, but is currently \(version.description)" + case let .xcode26OrGreaterRequired(version): + return "Installing this runtime for Apple Silicon requires Xcode 26 or greater to be selected, but is currently \(version.description)" + } + } +} + +public struct RuntimeInstallPolicy: Sendable { + public init() {} + + public func installMethod( + for runtime: DownloadableRuntime, + selectedXcodeVersion: Version? + ) throws -> RuntimeInstallMethod { + guard runtime.contentType == .cryptexDiskImage else { + return .archive + } + + guard let selectedXcodeVersion else { + throw RuntimeInstallPolicyError.noSelectedXcode + } + + guard selectedXcodeVersion > Version(major: 16, minor: 0, patch: 0) else { + throw RuntimeInstallPolicyError.xcode16_1OrGreaterRequired(selectedXcodeVersion) + } + + if runtime.architectures?.isAppleSilicon == true { + guard selectedXcodeVersion > Version(major: 25, minor: 0, patch: 0) else { + throw RuntimeInstallPolicyError.xcode26OrGreaterRequired(selectedXcodeVersion) + } + return .xcodebuild(architecture: Architecture.arm64.rawValue) + } + + return .xcodebuild(architecture: nil) + } + + public func selectedXcodeVersion(fromXcodebuildVersionOutput output: String) -> Version? { + let versionPattern = #"Xcode (\d+\.\d+)"# + guard let versionRegex = try? NSRegularExpression(pattern: versionPattern), + let match = versionRegex.firstMatch(in: output, range: NSRange(output.startIndex..., in: output)), + let versionRange = Range(match.range(at: 1), in: output) else { + return nil + } + + return Version(tolerant: String(output[versionRange])) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallationLookupService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallationLookupService.swift new file mode 100644 index 00000000..aa004a39 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallationLookupService.swift @@ -0,0 +1,42 @@ +import Foundation +@preconcurrency import Path + +public struct RuntimeInstallationLookupService: Sendable { + public init() {} + + public func coreSimulatorImage( + for runtime: DownloadableRuntime, + in installedRuntimes: [CoreSimulatorImage] + ) -> CoreSimulatorImage? { + installedRuntimes.first { + $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate && + runtimeArchitectureMatches(runtime, installedRuntime: $0) + } + } + + public func installPath( + for runtime: DownloadableRuntime, + in installedRuntimes: [CoreSimulatorImage] + ) -> Path? { + guard + let image = coreSimulatorImage(for: runtime, in: installedRuntimes), + let relativePath = image.path["relative"] + else { + return nil + } + + let path = relativePath.replacingOccurrences(of: "file://", with: "") + return Path(url: URL(fileURLWithPath: path)) + } + + private func runtimeArchitectureMatches( + _ runtime: DownloadableRuntime, + installedRuntime: CoreSimulatorImage + ) -> Bool { + guard let architectures = runtime.architectures, architectures.isEmpty == false else { + return true + } + + return installedRuntime.runtimeInfo.supportedArchitectures == architectures + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift new file mode 100644 index 00000000..d2b73b73 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift @@ -0,0 +1,183 @@ +import Foundation +@preconcurrency import Version + +public struct RuntimeListPresentationService: Sendable { + public struct RuntimeRow: Sendable { + public let platform: DownloadableRuntime.Platform + public let betaNumber: Int? + public let version: String + public let build: String + public let kind: InstalledRuntime.Kind? + public var hasDuplicateVersion: Bool + public let architectures: [Architecture]? + + public var completeVersion: String { + makeRuntimeVersion(for: version, betaNumber: betaNumber) + } + + public var visibleIdentifier: String { + let architectureDescription = architectures?.map(\.rawValue).joined(separator: "|") + return platform.shortName + " " + completeVersion + (architectureDescription != nil ? " \(architectureDescription!)" : "") + } + + fileprivate init( + platform: DownloadableRuntime.Platform, + betaNumber: Int?, + version: String, + build: String, + kind: InstalledRuntime.Kind? = nil, + hasDuplicateVersion: Bool = false, + architectures: [Architecture]? + ) { + self.platform = platform + self.betaNumber = betaNumber + self.version = version + self.build = build + self.kind = kind + self.hasDuplicateVersion = hasDuplicateVersion + self.architectures = architectures + } + } + + public init() {} + + public func rows( + downloadableRuntimes: DownloadableRuntimesResponse, + installedRuntimes: [InstalledRuntime], + includeBetas: Bool + ) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] { + rows( + downloadableRuntimes: downloadableRuntimes.downloadablesWithSDKBuildUpdates(), + installedRuntimes: installedRuntimes, + includeBetas: includeBetas, + sdkToSeedMappings: downloadableRuntimes.sdkToSeedMappings + ) + } + + public func rows( + downloadableRuntimes: [DownloadableRuntime], + installedRuntimes: [InstalledRuntime], + includeBetas: Bool, + sdkToSeedMappings: [SDKToSeedMapping] = [] + ) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] { + var unmatchedInstalledRuntimes = installedRuntimes + var rows: [RuntimeRow] = [] + + downloadableRuntimes.forEach { downloadable in + let matchingInstalledRuntimes = unmatchedInstalledRuntimes.removeAll { + $0.build == downloadable.simulatorVersion.buildUpdate + } + + if matchingInstalledRuntimes.isEmpty { + rows.append(RuntimeRow(downloadable)) + } else { + matchingInstalledRuntimes.forEach { installedRuntime in + rows.append(RuntimeRow(downloadable, kind: installedRuntime.kind)) + } + } + } + + unmatchedInstalledRuntimes.forEach { installedRuntime in + let betaNumber = sdkToSeedMappings.first { + $0.buildUpdate == installedRuntime.build + }?.seedNumber + var row = RuntimeRow(installedRuntime, betaNumber: betaNumber) + + rows.indices.filter { row.visibleIdentifier == rows[$0].visibleIdentifier }.forEach { index in + row.hasDuplicateVersion = true + rows[index].hasDuplicateVersion = true + } + + rows.append(row) + } + + return Dictionary(grouping: rows, by: \.platform) + .sorted(\.key.order) + .map { platform, runtimes in + ( + platform: platform, + runtimes: runtimes + .filter { includeBetas || $0.betaNumber == nil || $0.kind != nil } + .sorted(by: sortRuntimes) + ) + } + } + + public func line(for row: RuntimeRow) -> String { + var string = row.visibleIdentifier + if row.hasDuplicateVersion { + string += " (\(row.build))" + } + if let kind = row.kind { + switch kind { + case .bundled: + string += " (Bundled with selected Xcode)" + case .legacyDownload, .diskImage, .cryptexDiskImage, .patchableCryptexDiskImage: + string += " (Installed)" + } + } + return string + } + + private func sortRuntimes(_ first: RuntimeRow, _ second: RuntimeRow) -> Bool { + let firstVersion = Version(tolerant: first.completeVersion)! + let secondVersion = Version(tolerant: second.completeVersion)! + if firstVersion == secondVersion { + return first.build.compare(second.build, options: .numeric) == .orderedAscending + } + return firstVersion < secondVersion + } +} + +public extension DownloadableRuntimesResponse { + func downloadablesWithSDKBuildUpdates() -> [DownloadableRuntime] { + downloadables.map { runtime in + var updatedRuntime = runtime + let mappings = sdkToSimulatorMappings.filter { + $0.simulatorBuildUpdate == runtime.simulatorVersion.buildUpdate + } + updatedRuntime.sdkBuildUpdate = mappings.map(\.sdkBuildUpdate) + return updatedRuntime + } + } +} + +private extension RuntimeListPresentationService.RuntimeRow { + init(_ runtime: DownloadableRuntime, kind: InstalledRuntime.Kind? = nil) { + self.init( + platform: runtime.platform, + betaNumber: runtime.betaNumber, + version: runtime.simulatorVersion.version, + build: runtime.simulatorVersion.buildUpdate, + kind: kind, + architectures: runtime.architectures + ) + } + + init(_ runtime: InstalledRuntime, betaNumber: Int?) { + self.init( + platform: runtime.platformIdentifier.asPlatformOS, + betaNumber: betaNumber, + version: runtime.version, + build: runtime.build, + kind: runtime.kind, + architectures: nil + ) + } +} + +private extension Array { + mutating func removeAll(where predicate: (Element) -> Bool) -> [Element] { + guard !isEmpty else { return [] } + var removed: [Element] = [] + self = filter { current in + let satisfy = predicate(current) + if satisfy { + removed.append(current) + } + return !satisfy + } + return removed + } + +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListStore.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListStore.swift new file mode 100644 index 00000000..fc5efcea --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListStore.swift @@ -0,0 +1,67 @@ +import Foundation + +public struct RuntimeListStore: Sendable { + public typealias FetchDownloadableRuntimes = @Sendable () async throws -> DownloadableRuntimesResponse + + public struct UpdateResult: Sendable { + public let runtimes: [DownloadableRuntime] + public let sdkToSeedMappings: [SDKToSeedMapping] + } + + public private(set) var downloadableRuntimes: [DownloadableRuntime] + + private var cache: DownloadableRuntimeCache + private var fetchDownloadableRuntimes: FetchDownloadableRuntimes + + public init( + downloadableRuntimes: [DownloadableRuntime] = [], + cache: DownloadableRuntimeCache, + fetchDownloadableRuntimes: @escaping FetchDownloadableRuntimes + ) { + self.downloadableRuntimes = downloadableRuntimes + self.cache = cache + self.fetchDownloadableRuntimes = fetchDownloadableRuntimes + } + + public init( + downloadableRuntimes: [DownloadableRuntime] = [], + cache: DownloadableRuntimeCache, + service: RuntimeService + ) { + self.init( + downloadableRuntimes: downloadableRuntimes, + cache: cache, + fetchDownloadableRuntimes: { + try await service.downloadableRuntimes() + } + ) + } + + public mutating func loadCachedDownloadableRuntimes() throws { + guard let runtimes = try cache.load() else { return } + + downloadableRuntimes = runtimes + } + + @discardableResult + public mutating func updateDownloadableRuntimes() async throws -> [DownloadableRuntime] { + try await updateDownloadableRuntimeList().runtimes + } + + @discardableResult + public mutating func updateDownloadableRuntimeList() async throws -> UpdateResult { + let response = try await fetchDownloadableRuntimes() + let runtimes = response.downloadablesWithSDKBuildUpdates() + + downloadableRuntimes = runtimes + try? cache.save(runtimes) + return UpdateResult( + runtimes: runtimes, + sdkToSeedMappings: response.sdkToSeedMappings + ) + } + + public func saveDownloadableRuntimes(_ runtimes: [DownloadableRuntime]) throws { + try cache.save(runtimes) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimePackageInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimePackageInstallService.swift new file mode 100644 index 00000000..1cf0453e --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimePackageInstallService.swift @@ -0,0 +1,102 @@ +import Foundation +@preconcurrency import Path + +public struct RuntimePackageInstallService: Sendable { + public typealias ProcessOperation = @Sendable (URL, URL) async throws -> ProcessOutput + public typealias InstallPackage = @Sendable (URL, String) async throws -> ProcessOutput + + private let mountDMG: @Sendable (URL) async throws -> URL + private let unmountDMG: @Sendable (URL) async throws -> Void + private let packagePath: @Sendable (URL) throws -> Path + private let prepareDirectory: @Sendable (Path) throws -> Void + private let expandPkg: ProcessOperation + private let createPkg: ProcessOperation + private let installPkg: InstallPackage + private let contentsAtPath: @Sendable (String) -> Data? + private let writeData: @Sendable (Data, URL) throws -> Void + private let removeItem: @Sendable (URL) throws -> Void + + public init( + mountDMG: @escaping @Sendable (URL) async throws -> URL, + unmountDMG: @escaping @Sendable (URL) async throws -> Void, + packagePath: @escaping @Sendable (URL) throws -> Path = { mountedURL in + guard let mountedPath = Path(url: mountedURL), let packagePath = mountedPath.ls().first else { + throw XcodesKitError("Could not find runtime package in mounted disk image.") + } + return packagePath + }, + prepareDirectory: @escaping @Sendable (Path) throws -> Void = { path in + try path.mkdir().setCurrentUserAsOwner() + }, + expandPkg: @escaping ProcessOperation, + createPkg: @escaping ProcessOperation, + installPkg: @escaping InstallPackage, + contentsAtPath: @escaping @Sendable (String) -> Data?, + writeData: @escaping @Sendable (Data, URL) throws -> Void, + removeItem: @escaping @Sendable (URL) throws -> Void + ) { + self.mountDMG = mountDMG + self.unmountDMG = unmountDMG + self.packagePath = packagePath + self.prepareDirectory = prepareDirectory + self.expandPkg = expandPkg + self.createPkg = createPkg + self.installPkg = installPkg + self.contentsAtPath = contentsAtPath + self.writeData = writeData + self.removeItem = removeItem + } + + public func installPackageRuntime( + from diskImageURL: URL, + runtime: DownloadableRuntime, + cachesDirectory: Path + ) async throws { + let mountedURL = try await mountDMG(diskImageURL) + var didUnmount = false + + do { + let mountedPackagePath = try packagePath(mountedURL) + + try prepareDirectory(cachesDirectory) + + let expandedPkgPath = cachesDirectory/runtime.identifier + try? removeItem(expandedPkgPath.url) + _ = try await expandPkg(mountedPackagePath.url, expandedPkgPath.url) + + try await unmountDMG(mountedURL) + didUnmount = true + + let packageInfoPath = expandedPkgPath/"PackageInfo" + guard let packageInfoData = contentsAtPath(packageInfoPath.string), + var packageInfoContents = String(data: packageInfoData, encoding: .utf8) else { + throw XcodesKitError("Could not read PackageInfo for \(runtime.visibleIdentifier).") + } + + let runtimeDestination = runtimeDestinationPath(for: runtime) + packageInfoContents = packageInfoContents.replacingOccurrences( + of: " Path { + let runtimeFileName = "\(runtime.visibleIdentifier).simruntime" + return Path("/Library/Developer/CoreSimulator/Profiles/Runtimes/\(runtimeFileName)")! + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift index 1efbc2e2..02711574 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift @@ -1,40 +1,86 @@ import Foundation -import AsyncNetworkService -import Path +@preconcurrency import Path -extension URL { +public extension URL { static let downloadableRuntimes = URL(string: "https://devimages-cdn.apple.com/downloads/xcode/simulators/index2.dvtdownloadableindex")! } -public struct RuntimeService { - var networkService: AsyncHTTPNetworkService - public enum Error: LocalizedError, Equatable { +public struct RuntimeService: Sendable { + public typealias LoadData = @Sendable (URLRequest) async throws -> (Data, URLResponse) + public typealias ContentsAtPath = @Sendable (String) -> Data? + public typealias LoadShellOutput = @Sendable () async throws -> ProcessOutput + public typealias RuntimeURLShellOutput = @Sendable (URL) async throws -> ProcessOutput + public typealias ProcessURLShellOutput = @Sendable (URL, URL) async throws -> ProcessOutput + public typealias InstallPackageOutput = @Sendable (URL, String) async throws -> ProcessOutput + public typealias DeleteRuntimeOutput = @Sendable (String) async throws -> ProcessOutput + + private var loadData: LoadData + private var contentsAtPath: ContentsAtPath + private var installedRuntimesOutput: LoadShellOutput + private var installRuntimeImageOutput: RuntimeURLShellOutput + private var mountDMGOutput: RuntimeURLShellOutput + private var unmountDMGOutput: RuntimeURLShellOutput + private var expandPkgOutput: ProcessURLShellOutput + private var createPkgOutput: ProcessURLShellOutput + private var installPkgOutput: InstallPackageOutput + private var deleteRuntimeOutput: DeleteRuntimeOutput + + public enum Error: LocalizedError, Equatable, Sendable { case unavailableRuntime(String) case failedMountingDMG } - public init() { - networkService = AsyncHTTPNetworkService() + public init(urlSession: URLSession = URLSession(configuration: .ephemeral)) { + let shell = XcodesShell() + self.init( + loadData: { try await urlSession.data(for: $0) }, + contentsAtPath: { path in FileManager.default.contents(atPath: path) }, + installedRuntimesOutput: { try await shell.installedRuntimes() }, + installRuntimeImageOutput: { url in try await shell.installRuntimeImage(url) }, + mountDMGOutput: { url in try await shell.mountDmg(url) }, + unmountDMGOutput: { url in try await shell.unmountDmg(url) }, + expandPkgOutput: { packageURL, destinationURL in try await shell.expandPkg(packageURL, destinationURL) }, + createPkgOutput: { packageURL, destinationURL in try await shell.createPkg(packageURL, destinationURL) }, + installPkgOutput: { packageURL, target in try await shell.installPkg(packageURL, target) }, + deleteRuntimeOutput: { identifier in try await shell.deleteRuntime(identifier) } + ) + } + + public init( + loadData: @escaping LoadData, + contentsAtPath: @escaping ContentsAtPath = { path in FileManager.default.contents(atPath: path) }, + installedRuntimesOutput: @escaping LoadShellOutput, + installRuntimeImageOutput: @escaping RuntimeURLShellOutput, + mountDMGOutput: @escaping RuntimeURLShellOutput, + unmountDMGOutput: @escaping RuntimeURLShellOutput, + expandPkgOutput: @escaping ProcessURLShellOutput = { packageURL, destinationURL in try await XcodesShell().expandPkg(packageURL, destinationURL) }, + createPkgOutput: @escaping ProcessURLShellOutput = { packageURL, destinationURL in try await XcodesShell().createPkg(packageURL, destinationURL) }, + installPkgOutput: @escaping InstallPackageOutput = { packageURL, target in try await XcodesShell().installPkg(packageURL, target) }, + deleteRuntimeOutput: @escaping DeleteRuntimeOutput = { identifier in try await XcodesShell().deleteRuntime(identifier) } + ) { + self.loadData = loadData + self.contentsAtPath = contentsAtPath + self.installedRuntimesOutput = installedRuntimesOutput + self.installRuntimeImageOutput = installRuntimeImageOutput + self.mountDMGOutput = mountDMGOutput + self.unmountDMGOutput = unmountDMGOutput + self.expandPkgOutput = expandPkgOutput + self.createPkgOutput = createPkgOutput + self.installPkgOutput = installPkgOutput + self.deleteRuntimeOutput = deleteRuntimeOutput } public func downloadableRuntimes() async throws -> DownloadableRuntimesResponse { let urlRequest = URLRequest(url: .downloadableRuntimes) // Apple gives a plist for download - let (data, _) = try await networkService.requestData(urlRequest, validators: []) - do { - let decodedResponse = try PropertyListDecoder().decode(DownloadableRuntimesResponse.self, from: data) - return decodedResponse - } catch { - print("error: \(error)") - throw error - } - + let (data, _) = try await loadData(urlRequest) + return try PropertyListDecoder().decode(DownloadableRuntimesResponse.self, from: data) } public func installedRuntimes() async throws -> [InstalledRuntime] { // This only uses the Selected Xcode, so we don't know what other SDK's have been installed in previous versions - let output = try await Current.shell.installedRuntimes() + let output = try await installedRuntimesOutput() let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -48,8 +94,8 @@ public struct RuntimeService { /// Loops through `/Library/Developer/CoreSimulator/images/images.plist` which contains a list of downloaded Simuator Runtimes /// This is different then using `simctl` (`installedRuntimes()`) which only returns the installed runtimes for the selected xcode version. public func localInstalledRuntimes() async throws -> [CoreSimulatorImage] { - guard let path = Path("/Library/Developer/CoreSimulator/images/images.plist") else { throw "Could not find images.plist for CoreSimulators" } - guard let infoPlistData = FileManager.default.contents(atPath: path.string) else { throw "Could not get data from \(path.string)" } + guard let path = Path("/Library/Developer/CoreSimulator/images/images.plist") else { throw XcodesKitError("Could not find images.plist for CoreSimulators") } + guard let infoPlistData = contentsAtPath(path.string) else { throw XcodesKitError("Could not get data from \(path.string)") } do { let infoPlist: CoreSimulatorPlist = try PropertyListDecoder().decode(CoreSimulatorPlist.self, from: infoPlistData) @@ -60,11 +106,11 @@ public struct RuntimeService { } public func installRuntimeImage(dmgURL: URL) async throws { - _ = try await Current.shell.installRuntimeImage(dmgURL) + _ = try await installRuntimeImageOutput(dmgURL) } public func mountDMG(dmgUrl: URL) async throws -> URL { - let resultPlist = try await Current.shell.mountDmg(dmgUrl) + let resultPlist = try await mountDMGOutput(dmgUrl) let dict = try? (PropertyListSerialization.propertyList(from: resultPlist.out.data(using: .utf8)!, format: nil) as? NSDictionary) let systemEntities = dict?["system-entities"] as? NSArray @@ -75,31 +121,29 @@ public struct RuntimeService { } public func unmountDMG(mountedURL: URL) async throws { - _ = try await Current.shell.unmountDmg(mountedURL) + _ = try await unmountDMGOutput(mountedURL) } public func expand(pkgPath: Path, expandedPkgPath: Path) async throws { - _ = try await Current.shell.expandPkg(pkgPath.url, expandedPkgPath.url) + _ = try await expandPkgOutput(pkgPath.url, expandedPkgPath.url) } public func createPkg(pkgPath: Path, expandedPkgPath: Path) async throws { - _ = try await Current.shell.createPkg(pkgPath.url, expandedPkgPath.url) + _ = try await createPkgOutput(pkgPath.url, expandedPkgPath.url) } public func installPkg(pkgPath: Path, expandedPkgPath: Path) async throws { - _ = try await Current.shell.installPkg(pkgPath.url, expandedPkgPath.url.absoluteString) + _ = try await installPkgOutput(pkgPath.url, expandedPkgPath.url.absoluteString) } public func deleteRuntime(identifier: String) async throws { do { - _ = try await Current.shell.deleteRuntime(identifier) + _ = try await deleteRuntimeOutput(identifier) } catch { if let executionError = error as? ProcessExecutionError { - throw executionError.standardError + throw XcodesKitError(executionError.standardError) } throw error } } } - -extension String: Error {} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeXcodebuildInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeXcodebuildInstallService.swift new file mode 100644 index 00000000..6ec949de --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeXcodebuildInstallService.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct RuntimeXcodebuildInstallService: Sendable { + public typealias Download = @Sendable (String, String, String?) -> AsyncThrowingStream + public typealias ProgressChanged = @Sendable (Progress) -> Void + + private let download: Download + + public init( + download: @escaping Download = { platform, buildVersion, architecture in + XcodebuildRuntimeDownloadService().download( + platform: platform, + buildVersion: buildVersion, + architecture: architecture + ) + } + ) { + self.download = download + } + + public func downloadAndInstall( + runtime: DownloadableRuntime, + architecture: String? = nil, + progressChanged: ProgressChanged + ) async throws { + let stream = download( + runtime.platform.shortName, + runtime.simulatorVersion.buildUpdate, + architecture + ) + + for try await progress in stream { + try Task.checkCancellation() + progressChanged(progress) + } + try Task.checkCancellation() + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/URLSession+DownloadTask.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/URLSession+DownloadTask.swift new file mode 100644 index 00000000..df9dfb53 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/URLSession+DownloadTask.swift @@ -0,0 +1,114 @@ +import Foundation +import os + +extension URLSession { + /** + - Parameter request: The URL request to download. + - Parameter saveLocation: A URL to move the downloaded file to after it completes. Apple deletes the temporary file immediately after the underlying completion handler returns. + - Parameter resumeData: Data describing the state of a previously cancelled or failed download task. See the Discussion section for `downloadTask(withResumeData:completionHandler:)` https://developer.apple.com/documentation/foundation/urlsession/1411598-downloadtask# + + - Returns: Tuple containing a Progress object for the task and a task containing the save location and response. + + - Note: We do not create the destination directory for you, because we move the file with FileManager.moveItem which changes its behavior depending on the directory status of the URL you provide. So create your own directory first. + */ + public func downloadTask( + with request: URLRequest, + to saveLocation: URL, + resumingWith resumeData: Data? + ) -> (progress: Progress, task: Task<(saveLocation: URL, response: URLResponse), Error>) { + let runner = URLSessionDownloadTaskRunner( + session: self, + request: request, + saveLocation: saveLocation, + resumeData: resumeData + ) + let task = Task { + try await runner.resume() + } + return (runner.progress, task) + } + + public func downloadTaskAsync( + with url: URL, + to saveLocation: URL, + resumingWith resumeData: Data? + ) -> (progress: Progress, task: Task<(saveLocation: URL, response: URLResponse), Error>) { + downloadTask(with: URLRequest(url: url), to: saveLocation, resumingWith: resumeData) + } +} + +private final class URLSessionDownloadTaskRunner: Sendable { + let progress: Progress + + private let task: URLSessionDownloadTask + private let saveLocation: URL + private let request = OneShotContinuation<(temporaryURL: URL, response: URLResponse)>() + + init(session: URLSession, request: URLRequest, saveLocation: URL, resumeData: Data?) { + self.saveLocation = saveLocation + + let callbackBox = URLSessionDownloadTaskCallbackBox() + let createdTask: URLSessionDownloadTask + if let resumeData { + createdTask = session.downloadTask(withResumeData: resumeData) { temporaryURL, response, error in + callbackBox.complete(temporaryURL: temporaryURL, response: response, error: error) + } + } else { + createdTask = session.downloadTask(with: request) { temporaryURL, response, error in + callbackBox.complete(temporaryURL: temporaryURL, response: response, error: error) + } + } + + self.task = createdTask + self.progress = createdTask.progress + callbackBox.handler = { [weak self] temporaryURL, response, error in + self?.complete(temporaryURL: temporaryURL, response: response, error: error) + } + } + + func resume() async throws -> (saveLocation: URL, response: URLResponse) { + let output = try await request.value(onCancel: { [task] in + task.cancel() + }) { + task.resume() + } + try FileManager.default.moveItem(at: output.temporaryURL, to: saveLocation) + return (saveLocation, output.response) + } + + private func complete(temporaryURL: URL?, response: URLResponse?, error: Error?) { + let result: Result<(temporaryURL: URL, response: URLResponse), Error> + if let error { + result = .failure(error) + } else if let temporaryURL, let response { + result = .success((temporaryURL, response)) + } else { + result = .failure(URLError(.badServerResponse)) + } + + finish(result) + } + + private func finish(_ result: Result<(temporaryURL: URL, response: URLResponse), Error>) { + request.resume(with: result) + } +} + +private final class URLSessionDownloadTaskCallbackBox: Sendable { + typealias Handler = @Sendable (URL?, URLResponse?, Error?) -> Void + + private let storedHandler = OSAllocatedUnfairLock(initialState: nil) + + var handler: Handler? { + get { + storedHandler.withLock { $0 } + } + set { + storedHandler.withLock { $0 = newValue } + } + } + + func complete(temporaryURL: URL?, response: URLResponse?, error: Error?) { + handler?(temporaryURL, response, error) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveInstallService.swift new file mode 100644 index 00000000..58b2e320 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveInstallService.swift @@ -0,0 +1,76 @@ +import Foundation +@preconcurrency import Path + +public enum XcodeArchiveInstallError: Error, Equatable, Sendable { + case failedToMoveXcodeToDestination(Path) + case unsupportedFileFormat(extension: String) +} + +public enum XcodeArchiveInstallStep: Equatable, Sendable { + case unarchive(XcodeUnarchiveStep) + case cleaningArchive(archiveName: String) + case checkingSecurity +} + +public struct XcodeArchiveInstallService: Sendable { + public typealias CleanArchive = @Sendable (URL) throws -> Void + public typealias StepChanged = @Sendable (XcodeArchiveInstallStep) async -> Void + public typealias MakeInstalledXcode = @Sendable (Path) -> InstalledXcode? + + private let destinationDirectory: Path + private let unarchiveService: XcodeUnarchiveService + private let validationService: XcodeValidationService + private let fileExists: @Sendable (String) -> Bool + private let makeInstalledXcode: MakeInstalledXcode + + public init( + destinationDirectory: Path, + unarchiveService: XcodeUnarchiveService, + validationService: XcodeValidationService, + fileExists: @escaping @Sendable (String) -> Bool, + makeInstalledXcode: @escaping MakeInstalledXcode + ) { + self.destinationDirectory = destinationDirectory + self.unarchiveService = unarchiveService + self.validationService = validationService + self.fileExists = fileExists + self.makeInstalledXcode = makeInstalledXcode + } + + public func installArchivedXcode( + _ xcode: AvailableXcode, + at archiveURL: URL, + cleanArchive: @escaping CleanArchive, + stepChanged: @escaping StepChanged = { _ in } + ) async throws -> InstalledXcode { + guard archiveURL.pathExtension == "xip" else { + throw XcodeArchiveInstallError.unsupportedFileFormat(extension: archiveURL.pathExtension) + } + + let destinationURL = destinationDirectory + .join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app") + .url + + let xcodeURL = try await unarchiveService.unarchiveAndMoveXIP(at: archiveURL, to: destinationURL) { step in + await stepChanged(.unarchive(step)) + } + + guard + let path = Path(url: xcodeURL), + fileExists(path.string), + let installedXcode = makeInstalledXcode(path) + else { + throw XcodeArchiveInstallError.failedToMoveXcodeToDestination(destinationDirectory) + } + + await stepChanged(.cleaningArchive(archiveName: archiveURL.lastPathComponent)) + try cleanArchive(archiveURL) + + await stepChanged(.checkingSecurity) + async let securityAssessment: Void = validationService.verifySecurityAssessment(of: installedXcode) + async let signingCertificate: Void = validationService.verifySigningCertificate(of: installedXcode.path.url) + _ = try await (securityAssessment, signingCertificate) + + return installedXcode + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveService.swift new file mode 100644 index 00000000..9df87348 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveService.swift @@ -0,0 +1,82 @@ +import Foundation +@preconcurrency import Path +@preconcurrency import Version + +public struct XcodeArchive: Sendable { + public let version: Version + public let downloadURL: URL + public let filename: String + + public init(version: Version, downloadURL: URL, filename: String) { + self.version = version + self.downloadURL = downloadURL + self.filename = filename + } +} + +public enum XcodeArchiveDownloader: String, CaseIterable, Identifiable, CustomStringConvertible, Sendable { + case urlSession + case aria2 + + public var id: Self { self } + + public var description: String { + switch self { + case .urlSession: return "URLSession" + case .aria2: return "aria2" + } + } +} + +public struct XcodeArchiveService: Sendable { + public typealias Download = @Sendable (XcodeArchive, Path, XcodeArchiveDownloader, @escaping @Sendable (Progress) -> Void) async throws -> URL + + private let applicationSupportPath: Path + private let fileExists: @Sendable (Path) -> Bool + private let download: Download + + public init( + applicationSupportPath: Path, + fileExists: @escaping @Sendable (Path) -> Bool, + download: @escaping Download + ) { + self.applicationSupportPath = applicationSupportPath + self.fileExists = fileExists + self.download = download + } + + public func archiveURL( + for archive: XcodeArchive, + downloader: XcodeArchiveDownloader, + progressChanged: @escaping @Sendable (Progress) -> Void + ) async throws -> URL { + if let existingArchiveURL = existingArchiveURL(for: archive, downloader: downloader) { + return existingArchiveURL + } + + return try await download(archive, expectedArchivePath(for: archive), downloader, progressChanged) + } + + public func existingArchiveURL( + for archive: XcodeArchive, + downloader: XcodeArchiveDownloader + ) -> URL? { + let destination = expectedArchivePath(for: archive) + let metadataPath = aria2MetadataPath(for: destination) + let aria2DownloadIsIncomplete = downloader == .aria2 && fileExists(metadataPath) + + if fileExists(destination), aria2DownloadIsIncomplete == false { + return destination.url + } + + return nil + } + + public func expectedArchivePath(for archive: XcodeArchive) -> Path { + applicationSupportPath/"Xcode-\(archive.version).\(archive.filename.suffix(fromLast: "."))" + } + + public func aria2MetadataPath(for archivePath: Path) -> Path { + archivePath.parent/(archivePath.basename() + ".aria2") + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeAutoInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeAutoInstallService.swift new file mode 100644 index 00000000..ea466429 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeAutoInstallService.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum XcodeAutoInstallDecision: Equatable, Sendable { + case disabled + case alreadyInstalled + case installNewestBeta(XcodeID) + case installNewestVersion(XcodeID) + case noNewVersion +} + +public struct XcodeAutoInstallService: Sendable { + public init() {} + + public func decision( + autoInstallationType: AutoInstallationType, + xcodes: [XcodeListItem] + ) -> XcodeAutoInstallDecision { + guard autoInstallationType != .none else { + return .disabled + } + + guard let newestXcode = xcodes.first, newestXcode.installState == .notInstalled else { + return .alreadyInstalled + } + + switch autoInstallationType { + case .none: + return .disabled + case .newestBeta: + return .installNewestBeta(newestXcode.id) + case .newestVersion: + if newestXcode.version.isNotPrerelease { + return .installNewestVersion(newestXcode.id) + } + return .noNewVersion + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeCompatibilityService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeCompatibilityService.swift new file mode 100644 index 00000000..b075848b --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeCompatibilityService.swift @@ -0,0 +1,98 @@ +import Foundation + +public enum XcodeCompatibilityStatus: Equatable, Sendable { + case supported + case unsupported(requiredMacOSVersion: String, currentMacOSVersion: String) + + public var isSupported: Bool { + switch self { + case .supported: + true + case .unsupported: + false + } + } + + public var isUnsupported: Bool { + !isSupported + } +} + +public struct XcodeCompatibilityService: Sendable { + public init() {} + + public func status( + for xcode: AvailableXcode, + currentOSVersion: OperatingSystemVersion + ) -> XcodeCompatibilityStatus { + status( + requiredMacOSVersion: xcode.requiredMacOSVersion, + currentOSVersion: currentOSVersion + ) + } + + public func status( + requiredMacOSVersion: String?, + currentOSVersion: OperatingSystemVersion + ) -> XcodeCompatibilityStatus { + guard let requiredMacOSVersion else { + return .supported + } + + let requiredVersion = operatingSystemVersion(from: requiredMacOSVersion) + if currentOSVersion >= requiredVersion { + return .supported + } + + return .unsupported( + requiredMacOSVersion: requiredMacOSVersion, + currentMacOSVersion: currentOSVersion.versionString() + ) + } + + public func isSupported( + requiredMacOSVersion: String?, + currentOSVersion: OperatingSystemVersion + ) -> Bool { + status( + requiredMacOSVersion: requiredMacOSVersion, + currentOSVersion: currentOSVersion + ).isSupported + } + + public func isUnsupported( + requiredMacOSVersion: String?, + currentOSVersion: OperatingSystemVersion + ) -> Bool { + !isSupported( + requiredMacOSVersion: requiredMacOSVersion, + currentOSVersion: currentOSVersion + ) + } + + public func operatingSystemVersion(from versionString: String) -> OperatingSystemVersion { + let components = versionString + .components(separatedBy: ".") + .compactMap { Int($0) } + + return OperatingSystemVersion( + majorVersion: components.count > 0 ? components[0] : 0, + minorVersion: components.count > 1 ? components[1] : 0, + patchVersion: components.count > 2 ? components[2] : 0 + ) + } +} + +private extension OperatingSystemVersion { + static func >= (lhs: OperatingSystemVersion, rhs: OperatingSystemVersion) -> Bool { + if lhs.majorVersion != rhs.majorVersion { + return lhs.majorVersion > rhs.majorVersion + } + + if lhs.minorVersion != rhs.minorVersion { + return lhs.minorVersion > rhs.minorVersion + } + + return lhs.patchVersion >= rhs.patchVersion + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallResolutionService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallResolutionService.swift new file mode 100644 index 00000000..22a264cb --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallResolutionService.swift @@ -0,0 +1,123 @@ +import Foundation +@preconcurrency import Path +@preconcurrency import Version + +public enum XcodeInstallRequest: Equatable, Sendable { + case latest + case latestPrerelease + case availableXcode(AvailableXcode) + case version(String) + case path(versionString: String, path: Path) +} + +public enum XcodeInstallResolution: Equatable, Sendable { + case download(version: Version, resolvedXcode: AvailableXcode?) + case localArchive(AvailableXcode, URL) +} + +public enum XcodeInstallResolutionError: LocalizedError, Equatable, Sendable { + case invalidVersion(String) + case noReleaseVersionAvailable + case noPrereleaseVersionAvailable + case versionAlreadyInstalled(InstalledXcode) + + public var errorDescription: String? { + switch self { + case let .invalidVersion(version): + return "\(version) is not a valid version number." + case .noReleaseVersionAvailable: + return "No release versions available." + case .noPrereleaseVersionAvailable: + return "No prerelease versions available." + case let .versionAlreadyInstalled(installedXcode): + return "\(installedXcode.version.appleDescription) is already installed at \(installedXcode.path)" + } + } +} + +public struct XcodeInstallResolutionService: Sendable { + private let versionFile: XcodeVersionFileService + + public init(versionFile: XcodeVersionFileService = XcodeVersionFileService()) { + self.versionFile = versionFile + } + + public func resolve( + _ request: XcodeInstallRequest, + availableXcodes: [AvailableXcode], + installedXcodes: [InstalledXcode], + willInstall: Bool, + versionFileDirectory: Path = Path(.cwd) + ) throws -> XcodeInstallResolution { + switch request { + case .latest: + guard let xcode = latestRelease(in: availableXcodes) else { + throw XcodeInstallResolutionError.noReleaseVersionAvailable + } + try ensureNotInstalled(xcode.version, installedXcodes: installedXcodes, willInstall: willInstall) + return .download(version: xcode.version, resolvedXcode: xcode) + + case .latestPrerelease: + guard let xcode = latestPrerelease(in: availableXcodes) else { + throw XcodeInstallResolutionError.noPrereleaseVersionAvailable + } + try ensureNotInstalled(xcode.version, installedXcodes: installedXcodes, willInstall: willInstall) + return .download(version: xcode.version, resolvedXcode: xcode) + + case let .availableXcode(xcode): + try ensureNotInstalled(xcode.version, installedXcodes: installedXcodes, willInstall: willInstall) + return .download(version: xcode.version, resolvedXcode: xcode) + + case let .version(versionString): + let version = try parsedVersion(versionString, versionFileDirectory: versionFileDirectory) + try ensureNotInstalled(version, installedXcodes: installedXcodes, willInstall: willInstall) + return .download(version: version, resolvedXcode: nil) + + case let .path(versionString, path): + let version = try parsedVersion(versionString, versionFileDirectory: versionFileDirectory) + let xcode = AvailableXcode( + version: version, + url: path.url, + filename: String(path.string.suffix(fromLast: "/")), + releaseDate: nil + ) + return .localArchive(xcode, path.url) + } + } + + public func latestRelease(in availableXcodes: [AvailableXcode]) -> AvailableXcode? { + availableXcodes + .filter(\.version.isNotPrerelease) + .sorted(\.version) + .last + } + + public func latestPrerelease(in availableXcodes: [AvailableXcode]) -> AvailableXcode? { + availableXcodes + .filter { $0.version.isPrerelease } + .filter { $0.releaseDate != nil } + .sorted { $0.releaseDate! < $1.releaseDate! } + .last + } + + private func parsedVersion( + _ versionString: String, + versionFileDirectory: Path + ) throws -> Version { + if let version = Version(xcodeVersion: versionString) ?? versionFile.version(inDirectory: versionFileDirectory) { + return version + } + throw XcodeInstallResolutionError.invalidVersion(versionString) + } + + private func ensureNotInstalled( + _ version: Version, + installedXcodes: [InstalledXcode], + willInstall: Bool + ) throws { + guard willInstall else { return } + if let installedXcode = installedXcodes.first(where: { $0.version.isEquivalent(to: version) }) { + throw XcodeInstallResolutionError.versionAlreadyInstalled(installedXcode) + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallRetryService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallRetryService.swift new file mode 100644 index 00000000..444b4d58 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallRetryService.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct XcodeInstallRetryService: Sendable { + public typealias Attempt = @Sendable (Int) async throws -> InstalledXcode + public typealias AttemptFailed = @Sendable (Error) async -> Void + public typealias RetryDamagedArchive = @Sendable (Error, URL) async -> Void + + private let damagedArchiveURL: @Sendable (Error) -> URL? + private let removeDamagedArchive: @Sendable (URL) throws -> Void + + public init( + damagedArchiveURL: @escaping @Sendable (Error) -> URL?, + removeDamagedArchive: @escaping @Sendable (URL) throws -> Void + ) { + self.damagedArchiveURL = damagedArchiveURL + self.removeDamagedArchive = removeDamagedArchive + } + + public func install( + attemptNumber: Int = 0, + shouldRetryAfterDamagedArchive: Bool = true, + attempt: Attempt, + onAttemptFailed: AttemptFailed = { _ in }, + onRetryDamagedArchive: RetryDamagedArchive = { _, _ in } + ) async throws -> InstalledXcode { + do { + return try await attempt(attemptNumber) + } catch { + await onAttemptFailed(error) + + guard + let damagedArchiveURL = damagedArchiveURL(error), + attemptNumber < 1, + shouldRetryAfterDamagedArchive + else { + throw error + } + + await onRetryDamagedArchive(error, damagedArchiveURL) + try removeDamagedArchive(damagedArchiveURL) + return try await install( + attemptNumber: attemptNumber + 1, + shouldRetryAfterDamagedArchive: shouldRetryAfterDamagedArchive, + attempt: attempt, + onAttemptFailed: onAttemptFailed, + onRetryDamagedArchive: onRetryDamagedArchive + ) + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListComposer.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListComposer.swift new file mode 100644 index 00000000..37f2b7d3 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListComposer.swift @@ -0,0 +1,82 @@ +import Foundation + +public struct XcodeListComposer: Sendable { + public init() {} + + public func compose( + availableXcodes: [AvailableXcode], + installedXcodes: [InstalledXcode], + selectedXcodePath: String?, + existingXcodes: [XcodeListItem], + dataSource: XcodeListDataSource + ) -> [XcodeListItem] { + var adjustedAvailableXcodes = availableXcodes + + if dataSource == .apple { + adjustedAvailableXcodes = Self.adjustingAvailableXcodesForInstalledBuildMetadata( + availableXcodes, + installedXcodes: installedXcodes + ) + } + + var newAllXcodes = XcodeListService.filteringPrereleasesWithDuplicateBuildMetadata(adjustedAvailableXcodes) + .map { availableXcode -> XcodeListItem in + let installedXcode = installedXcodes.first { installedXcode in + availableXcode.version.isEquivalent(to: installedXcode.version) + } + let identicalBuilds = XcodeListService.identicalBuildIDs(for: availableXcode, in: availableXcodes) + let existingXcodeInstallState = existingXcodes + .first { $0.id == availableXcode.xcodeID && $0.installState.installing }? + .installState + let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled + + return XcodeListItem( + version: availableXcode.version, + identicalBuilds: identicalBuilds, + installState: existingXcodeInstallState ?? defaultXcodeInstallState, + selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true, + requiredMacOSVersion: availableXcode.requiredMacOSVersion, + releaseNotesURL: availableXcode.releaseNotesURL, + releaseDate: availableXcode.releaseDate, + sdks: availableXcode.sdks, + compilers: availableXcode.compilers, + downloadFileSize: availableXcode.fileSize, + architectures: availableXcode.architectures + ) + } + + for installedXcode in installedXcodes { + if !newAllXcodes.contains(where: { xcode in xcode.version.isEquivalent(to: installedXcode.version) }) { + newAllXcodes.append( + XcodeListItem( + version: installedXcode.version, + installState: .installed(installedXcode.path), + selected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true + ) + ) + } + } + + return newAllXcodes.sorted { $0.version > $1.version } + } + + public static func adjustingAvailableXcodesForInstalledBuildMetadata( + _ availableXcodes: [AvailableXcode], + installedXcodes: [InstalledXcode] + ) -> [AvailableXcode] { + var adjustedAvailableXcodes = availableXcodes + + for installedXcode in installedXcodes { + if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) { + adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID + } else if let index = adjustedAvailableXcodes.firstIndex(where: { availableXcode in + availableXcode.version.isEquivalent(to: installedXcode.version) && + availableXcode.version.buildMetadataIdentifiers.isEmpty + }) { + adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID + } + } + + return adjustedAvailableXcodes + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift new file mode 100644 index 00000000..767a8ac2 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift @@ -0,0 +1,129 @@ +import Foundation +@preconcurrency import Path +@preconcurrency import Version + +public struct XcodeListPresentationService: Sendable { + public struct AvailableRow: Equatable, Sendable { + public let version: Version + public let versionDescription: String + public let isInstalled: Bool + public let isSelected: Bool + } + + public struct InstalledRow: Equatable, Sendable { + public let version: Version + public let versionDescription: String + public let path: Path + public let isSelected: Bool + } + + public init() {} + + public func availableRows( + availableXcodes: [AvailableXcode], + installedXcodes: [InstalledXcode], + selectedXcodePath: String?, + dataSource: XcodeListDataSource + ) -> [AvailableRow] { + struct ReleasedVersion { + let version: Version + let releaseDate: Date? + } + + let adjustedAvailableXcodes = dataSource == .apple + ? XcodeListComposer.adjustingAvailableXcodesForInstalledBuildMetadata( + availableXcodes, + installedXcodes: installedXcodes + ) + : availableXcodes + + var releasedVersions = adjustedAvailableXcodes.map { + ReleasedVersion(version: $0.version, releaseDate: $0.releaseDate) + } + + for installedXcode in installedXcodes { + if !releasedVersions.contains(where: { $0.version.isEquivalent(to: installedXcode.version) }) { + releasedVersions.append(ReleasedVersion(version: installedXcode.version, releaseDate: nil)) + } else if let index = releasedVersions.firstIndex(where: { + $0.version.isEquivalent(to: installedXcode.version) && + $0.version.buildMetadataIdentifiers.isEmpty + }) { + releasedVersions[index] = ReleasedVersion(version: installedXcode.version, releaseDate: nil) + } + } + + let selectedInstalledXcode = Self.selectedInstalledXcode( + in: installedXcodes, + selectedXcodePath: selectedXcodePath + ) + + return releasedVersions + .sorted { first, second -> Bool in + if first.version.isPrerelease, + second.version.isPrerelease, + let firstDate = first.releaseDate, + let secondDate = second.releaseDate { + return firstDate < secondDate + } + return first.version < second.version + } + .map { releasedVersion in + let installedXcode = installedXcodes.first { + releasedVersion.version.isEquivalent(to: $0.version) + } + return AvailableRow( + version: releasedVersion.version, + versionDescription: releasedVersion.version.appleDescriptionWithBuildIdentifier, + isInstalled: installedXcode != nil, + isSelected: installedXcode?.path == selectedInstalledXcode?.path + ) + } + } + + public func installedRows( + installedXcodes: [InstalledXcode], + selectedXcodePath: String? + ) -> [InstalledRow] { + installedXcodes + .sorted { $0.version < $1.version } + .map { installedXcode in + InstalledRow( + version: installedXcode.version, + versionDescription: installedXcode.version.appleDescriptionWithBuildIdentifier, + path: installedXcode.path, + isSelected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true + ) + } + } + + public func installedLines( + rows: [InstalledRow], + interactive: Bool, + selectedMarker: String = "(Selected)" + ) -> [String] { + let firstColumns = rows.map { row in + row.versionDescription + (row.isSelected ? " \(selectedMarker)" : "") + } + let maxWidthOfFirstColumn = (firstColumns.map(\.count).max() ?? 0) + 1 + + return rows.enumerated().map { index, row in + let firstColumn = firstColumns[index] + if interactive { + let spaceBetweenColumns = maxWidthOfFirstColumn - firstColumn.count + return firstColumn + + String(repeating: " ", count: max(spaceBetweenColumns, 0)) + + row.path.string + } else { + return "\(firstColumn)\t\(row.path.string)" + } + } + } + + public static func selectedInstalledXcode( + in installedXcodes: [InstalledXcode], + selectedXcodePath: String? + ) -> InstalledXcode? { + guard let selectedXcodePath else { return nil } + return installedXcodes.first { selectedXcodePath.hasPrefix($0.path.string) } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListService.swift new file mode 100644 index 00000000..eaf8c6ce --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListService.swift @@ -0,0 +1,225 @@ +import Foundation +import SwiftSoup +import Version + +extension URL { + static let developerDownload = URL(string: "https://developer.apple.com/download")! + static let developerDownloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")! + static let xcodeReleasesData = URL(string: "https://xcodereleases.com/data.json")! +} + +public extension URLRequest { + static var developerDownload: URLRequest { + URLRequest(url: .developerDownload) + } + + static var developerDownloads: URLRequest { + var request = URLRequest(url: .developerDownloads) + request.httpMethod = "POST" + return request + } + + static var xcodeReleasesData: URLRequest { + URLRequest(url: .xcodeReleasesData) + } +} + +public enum DataSource: String, CaseIterable, Identifiable, CustomStringConvertible, Sendable { + case apple + case xcodeReleases + + public var id: Self { self } + + public static var `default`: Self { .xcodeReleases } + + public var description: String { + switch self { + case .apple: + return "Apple" + case .xcodeReleases: + return "Xcode Releases" + } + } +} + +public typealias XcodeListDataSource = DataSource + +public struct XcodeListService: Sendable { + public typealias LoadData = @Sendable (URLRequest) async throws -> (Data, URLResponse) + + public enum Error: LocalizedError, Equatable, Sendable { + case invalidResult(String?) + + public var errorDescription: String? { + switch self { + case let .invalidResult(result): + return result ?? "Downloading error" + } + } + } + + private var loadData: LoadData + + public init(urlSession: URLSession = URLSession(configuration: .ephemeral)) { + self.loadData = { request in + try await urlSession.data(for: request) + } + } + + public init(loadData: @escaping LoadData) { + self.loadData = loadData + } + + public func availableXcodes(from dataSource: XcodeListDataSource) async throws -> [AvailableXcodeRelease] { + switch dataSource { + case .apple: + async let released = releasedXcodes() + async let prerelease = prereleaseXcodes() + let (releasedXcodes, prereleaseXcodes) = try await (released, prerelease) + + return releasedXcodes.filter { releasedXcode in + prereleaseXcodes.contains { $0.version.isEquivalent(to: releasedXcode.version) } == false + } + prereleaseXcodes + case .xcodeReleases: + return try await xcodeReleases() + } + } + + public func releasedXcodes() async throws -> [AvailableXcodeRelease] { + let downloads = try await developerDownloads() + let downloadList = try validate(downloads, missingDownloadsMessage: "Downloading error") + + let urlPrefix = URL(string: "https://download.developer.apple.com/")! + return downloadList + .filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil } + .compactMap { download -> AvailableXcodeRelease? in + guard + let xcodeFile = download.files.first(where: { $0.remotePath.hasSuffix("dmg") || $0.remotePath.hasSuffix("xip") }), + let version = Version(xcodeVersion: download.name) + else { return nil } + + let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath) + return AvailableXcodeRelease( + version: version, + url: url, + filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), + releaseDate: download.dateModified, + fileSize: xcodeFile.fileSize + ) + } + } + + public func validateDeveloperDownloads(missingDownloadsMessage: String = "Downloading error") async throws { + let downloads = try await developerDownloads() + _ = try validate(downloads, missingDownloadsMessage: missingDownloadsMessage) + } + + public func developerDownloads() async throws -> Downloads { + let (data, _) = try await loadData(.developerDownloads) + return try JSONDecoder.downloads.decode(Downloads.self, from: data) + } + + private func validate(_ downloads: Downloads, missingDownloadsMessage: String) throws -> [Download] { + if downloads.hasError { + throw Error.invalidResult(downloads.resultsString) + } + guard let downloadList = downloads.downloads else { + throw Error.invalidResult(missingDownloadsMessage) + } + return downloadList + } + + public func prereleaseXcodes() async throws -> [AvailableXcodeRelease] { + let (data, _) = try await loadData(.developerDownload) + return try Self.parsePrereleaseXcodes(from: data) + } + + public func xcodeReleases() async throws -> [AvailableXcodeRelease] { + let (data, _) = try await loadData(.xcodeReleasesData) + let releases = try JSONDecoder().decode([XcodeRelease].self, from: data) + + return releases.compactMap { release -> AvailableXcodeRelease? in + guard + let downloadURL = release.links?.download?.url, + let version = Version(xcReleasesXcode: release) + else { return nil } + + let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents( + year: release.date.year, + month: release.date.month, + day: release.date.day + )) + + return AvailableXcodeRelease( + version: version, + url: downloadURL, + filename: String(downloadURL.path.suffix(fromLast: "/")), + releaseDate: releaseDate, + requiredMacOSVersion: release.requires, + releaseNotesURL: release.links?.notes?.url, + sdks: release.sdks, + compilers: release.compilers, + architectures: release.architectures + ) + } + } + + public static func parsePrereleaseXcodes(from data: Data) throws -> [AvailableXcodeRelease] { + let body = String(data: data, encoding: .utf8)! + let document = try SwiftSoup.parse(body) + + guard + let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(), + let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""), + let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""), + let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion), + let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"), + let url = URL(string: "https://developer.apple.com" + path) + else { return [] } + + return [ + AvailableXcodeRelease( + version: version, + url: url, + filename: String(path.suffix(fromLast: "/")), + releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString) + ) + ] + } + + public static func filteringPrereleasesWithDuplicateBuildMetadata(_ xcodes: [AvailableXcode]) -> [AvailableXcode] { + xcodes.filter { availableXcode in + guard !availableXcode.version.buildMetadataIdentifiers.isEmpty else { return true } + + let availableXcodesWithIdenticalBuildIdentifiers = xcodes.filter { + $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers + } + + return availableXcodesWithIdenticalBuildIdentifiers.count == 1 || + availableXcodesWithIdenticalBuildIdentifiers.count > 1 && + (availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.architectures?.isEmpty == false) + } + } + + public static func identicalBuildIDs(for xcode: AvailableXcode, in xcodes: [AvailableXcode]) -> [XcodeID] { + let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = xcodes.filter { + $0.version.buildMetadataIdentifiers == xcode.version.buildMetadataIdentifiers && + !$0.version.prereleaseIdentifiers.isEmpty && + !$0.version.buildMetadataIdentifiers.isEmpty + } + + guard !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, + xcode.version.prereleaseIdentifiers.isEmpty + else { return [] } + + return [xcode.xcodeID] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.xcodeID) + } +} + +private extension JSONDecoder { + static var downloads: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(.downloadsDateModified) + return decoder + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListStore.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListStore.swift new file mode 100644 index 00000000..1cdb3458 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListStore.swift @@ -0,0 +1,88 @@ +import Foundation +import Version + +public struct XcodeListStore: Sendable { + public typealias FetchAvailableXcodes = @Sendable (XcodeListDataSource) async throws -> [AvailableXcodeRelease] + public typealias Now = @Sendable () -> Date + + public private(set) var availableXcodes: [AvailableXcode] + public private(set) var lastUpdated: Date? + + private var cache: AvailableXcodeCache + private var fetchAvailableXcodes: FetchAvailableXcodes + private var updatePolicy: XcodeUpdatePolicy + private var now: Now + + public init( + availableXcodes: [AvailableXcode] = [], + lastUpdated: Date? = nil, + cache: AvailableXcodeCache, + fetchAvailableXcodes: @escaping FetchAvailableXcodes, + updatePolicy: XcodeUpdatePolicy = XcodeUpdatePolicy(), + now: @escaping Now = { Date() } + ) { + self.availableXcodes = availableXcodes + self.lastUpdated = lastUpdated + self.cache = cache + self.fetchAvailableXcodes = fetchAvailableXcodes + self.updatePolicy = updatePolicy + self.now = now + } + + public init( + availableXcodes: [AvailableXcode] = [], + lastUpdated: Date? = nil, + cache: AvailableXcodeCache, + service: XcodeListService, + updatePolicy: XcodeUpdatePolicy = XcodeUpdatePolicy(), + now: @escaping Now = { Date() } + ) { + self.init( + availableXcodes: availableXcodes, + lastUpdated: lastUpdated, + cache: cache, + fetchAvailableXcodes: { dataSource in + try await service.availableXcodes(from: dataSource) + }, + updatePolicy: updatePolicy, + now: now + ) + } + + public var shouldUpdateBeforeListingVersions: Bool { + updatePolicy.shouldUpdate( + cachedXcodes: availableXcodes, + lastUpdated: lastUpdated + ) + } + + public func shouldUpdateBeforeDownloading(version: Version) -> Bool { + availableXcodes.first(withVersion: version) == nil + } + + public mutating func loadCachedAvailableXcodes() throws { + guard let xcodes = try cache.load() else { return } + + availableXcodes = xcodes + lastUpdated = cache.lastModified() + } + + @discardableResult + public mutating func updateAvailableXcodes(from dataSource: XcodeListDataSource) async throws -> [AvailableXcode] { + let releases = try await fetchAvailableXcodes(dataSource) + let xcodes = Self.postprocess(releases.map(AvailableXcode.init)) + + availableXcodes = xcodes + lastUpdated = now() + try? cache.save(xcodes) + return xcodes + } + + public func saveAvailableXcodes(_ xcodes: [AvailableXcode]) throws { + try cache.save(xcodes) + } + + public static func postprocess(_ xcodes: [AvailableXcode]) -> [AvailableXcode] { + XcodeListService.filteringPrereleasesWithDuplicateBuildMetadata(xcodes) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallPreparationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallPreparationService.swift new file mode 100644 index 00000000..9368cc8b --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallPreparationService.swift @@ -0,0 +1,30 @@ +import Foundation + +public struct XcodePostInstallPreparationService: Sendable { + public typealias EnableDeveloperTools = @Sendable () async throws -> Void + public typealias AddStaffToDevelopersGroup = @Sendable () async throws -> Void + public typealias AcceptLicense = @Sendable (InstalledXcode) async throws -> Void + + private let enableDeveloperTools: EnableDeveloperTools + private let addStaffToDevelopersGroup: AddStaffToDevelopersGroup + private let acceptLicense: AcceptLicense + + public init( + enableDeveloperTools: @escaping EnableDeveloperTools, + addStaffToDevelopersGroup: @escaping AddStaffToDevelopersGroup, + acceptLicense: @escaping AcceptLicense + ) { + self.enableDeveloperTools = enableDeveloperTools + self.addStaffToDevelopersGroup = addStaffToDevelopersGroup + self.acceptLicense = acceptLicense + } + + public func enableDeveloperMode() async throws { + try await enableDeveloperTools() + try await addStaffToDevelopersGroup() + } + + public func approveLicense(for xcode: InstalledXcode) async throws { + try await acceptLicense(xcode) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallService.swift new file mode 100644 index 00000000..09d5f96a --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallService.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct XcodePostInstallService: Sendable { + public typealias RunFirstLaunch = @Sendable (InstalledXcode) async throws -> Void + public typealias LoadShellOutput = @Sendable () async throws -> ProcessOutput + public typealias LoadXcodeBuildVersion = @Sendable (InstalledXcode) async throws -> ProcessOutput + public typealias TouchInstallCheck = @Sendable (String, String, String) async throws -> ProcessOutput + + private let runFirstLaunch: RunFirstLaunch + private let getUserCacheDirectory: LoadShellOutput + private let getMacOSBuildVersion: LoadShellOutput + private let getXcodeBuildVersion: LoadXcodeBuildVersion + private let touchInstallCheck: TouchInstallCheck + + public init( + runFirstLaunch: @escaping RunFirstLaunch, + getUserCacheDirectory: @escaping LoadShellOutput, + getMacOSBuildVersion: @escaping LoadShellOutput, + getXcodeBuildVersion: @escaping LoadXcodeBuildVersion, + touchInstallCheck: @escaping TouchInstallCheck + ) { + self.runFirstLaunch = runFirstLaunch + self.getUserCacheDirectory = getUserCacheDirectory + self.getMacOSBuildVersion = getMacOSBuildVersion + self.getXcodeBuildVersion = getXcodeBuildVersion + self.touchInstallCheck = touchInstallCheck + } + + public func installComponents(for xcode: InstalledXcode) async throws { + try await runFirstLaunch(xcode) + try Task.checkCancellation() + + async let cacheDirectory = getUserCacheDirectory().out + async let macOSBuildVersion = getMacOSBuildVersion().out + async let toolsVersion = getXcodeBuildVersion(xcode).out + + _ = try await touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallWorkflowService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallWorkflowService.swift new file mode 100644 index 00000000..9c0a0189 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallWorkflowService.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct XcodePostInstallWorkflowService: Sendable { + public typealias EnableDeveloperMode = @Sendable () async throws -> Void + public typealias ApproveLicense = @Sendable (InstalledXcode) async throws -> Void + public typealias InstallComponents = @Sendable (InstalledXcode) async throws -> Void + + private let enableDeveloperMode: EnableDeveloperMode + private let approveLicense: ApproveLicense + private let installComponents: InstallComponents + + public init( + preparationService: XcodePostInstallPreparationService, + postInstallService: XcodePostInstallService + ) { + self.init( + enableDeveloperMode: { try await preparationService.enableDeveloperMode() }, + approveLicense: { try await preparationService.approveLicense(for: $0) }, + installComponents: { try await postInstallService.installComponents(for: $0) } + ) + } + + public init( + enableDeveloperMode: @escaping EnableDeveloperMode, + approveLicense: @escaping ApproveLicense, + installComponents: @escaping InstallComponents + ) { + self.enableDeveloperMode = enableDeveloperMode + self.approveLicense = approveLicense + self.installComponents = installComponents + } + + public func performPostInstallSteps(for xcode: InstalledXcode) async throws { + try await enableDeveloperMode() + try Task.checkCancellation() + try await approveLicense(xcode) + try Task.checkCancellation() + try await installComponents(xcode) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionFilesystemService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionFilesystemService.swift new file mode 100644 index 00000000..f28d6403 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionFilesystemService.swift @@ -0,0 +1,88 @@ +import Foundation +@preconcurrency import Path + +public enum XcodeSelectionFilesystemError: LocalizedError, Equatable, Sendable { + case destinationExistsAndIsNotSymlink(Path) + + public var errorDescription: String? { + switch self { + case let .destinationExistsAndIsNotSymlink(path): + return "A non-symbolic-link item already exists at \(path.string)." + } + } +} + +public struct XcodeSelectionFilesystemService: Sendable { + public typealias FileExists = @Sendable (String) -> Bool + public typealias AttributesOfItem = @Sendable (String) throws -> [FileAttributeKey: Any] + public typealias RemoveItem = @Sendable (String) throws -> Void + public typealias CreateSymbolicLink = @Sendable (String, String) throws -> Void + public typealias InstalledXcodeAtPath = @Sendable (Path) -> InstalledXcode? + public typealias Rename = @Sendable (Path, String) throws -> Path + + public struct SymbolicLinkResult: Equatable, Sendable { + public let destinationPath: Path + public let replacedExistingSymlink: Bool + } + + private let fileExists: FileExists + private let attributesOfItem: AttributesOfItem + private let removeItem: RemoveItem + private let createSymbolicLink: CreateSymbolicLink + private let installedXcode: InstalledXcodeAtPath + private let rename: Rename + + public init( + fileExists: @escaping FileExists = { path in FileManager.default.fileExists(atPath: path) }, + attributesOfItem: @escaping AttributesOfItem = { path in try FileManager.default.attributesOfItem(atPath: path) }, + removeItem: @escaping RemoveItem = { path in try FileManager.default.removeItem(atPath: path) }, + createSymbolicLink: @escaping CreateSymbolicLink = { path, destination in + try FileManager.default.createSymbolicLink(atPath: path, withDestinationPath: destination) + }, + installedXcode: @escaping InstalledXcodeAtPath, + rename: @escaping Rename = { try $0.rename(to: $1) } + ) { + self.fileExists = fileExists + self.attributesOfItem = attributesOfItem + self.removeItem = removeItem + self.createSymbolicLink = createSymbolicLink + self.installedXcode = installedXcode + self.rename = rename + } + + public func createSymbolicLink( + to installedXcodePath: Path, + in installDirectory: Path, + isBeta: Bool = false + ) throws -> SymbolicLinkResult { + let destinationPath = installDirectory/"Xcode\(isBeta ? "-Beta" : "").app" + var replacedExistingSymlink = false + + if fileExists(destinationPath.string) { + let attributes = try attributesOfItem(destinationPath.string) + if attributes[.type] as? FileAttributeType == .typeSymbolicLink { + try removeItem(destinationPath.string) + replacedExistingSymlink = true + } else { + throw XcodeSelectionFilesystemError.destinationExistsAndIsNotSymlink(destinationPath) + } + } + + try createSymbolicLink(destinationPath.string, installedXcodePath.string) + return SymbolicLinkResult(destinationPath: destinationPath, replacedExistingSymlink: replacedExistingSymlink) + } + + public func renameForSelection( + installedXcodePath: Path, + in installDirectory: Path + ) throws -> Path { + let destinationPath = installDirectory/"Xcode.app" + + if fileExists(destinationPath.string), let originalXcode = installedXcode(destinationPath) { + let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app" + _ = try rename(destinationPath, newName) + } + + return try rename(installedXcodePath, "Xcode.app") + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionService.swift new file mode 100644 index 00000000..374312a9 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionService.swift @@ -0,0 +1,80 @@ +import Foundation +@preconcurrency import Path +@preconcurrency import Version + +public enum XcodeSelectionError: LocalizedError, Equatable, Sendable { + case invalidIndex(min: Int, max: Int, given: String?) + + public var errorDescription: String? { + switch self { + case let .invalidIndex(min, max, given): + return "Not a valid number. Expecting a whole number between \(min)-\(max), but given \(given ?? "nothing")." + } + } +} + +public enum XcodeSelectionRequest: Equatable, Sendable { + case alreadySelectedVersion(Version) + case alreadySelectedPath(String) + case selectInstalledXcode(InstalledXcode) + case selectPath(String) +} + +public struct XcodeSelectionService: Sendable { + private let versionFile: XcodeVersionFileService + + public init(versionFile: XcodeVersionFileService = XcodeVersionFileService()) { + self.versionFile = versionFile + } + + public func request( + pathOrVersion: String, + installedXcodes: [InstalledXcode], + selectedXcodePath: String, + versionFileDirectory: Path = Path(.cwd) + ) -> XcodeSelectionRequest { + let versionToSelect = pathOrVersion.isEmpty + ? versionFile.version(inDirectory: versionFileDirectory) + : Version(xcodeVersion: pathOrVersion) + + if let version = versionToSelect, + let installedXcode = installedXcodes.first(withVersion: version) { + let selectedInstalledXcode = XcodeListPresentationService.selectedInstalledXcode( + in: installedXcodes, + selectedXcodePath: selectedXcodePath + ) + + if installedXcode.version == selectedInstalledXcode?.version { + return .alreadySelectedVersion(version) + } + + return .selectInstalledXcode(installedXcode) + } + + let pathToSelect = pathOrVersion.trimmingCharacters(in: .whitespacesAndNewlines) + let currentPath = selectedXcodePath.trimmingCharacters(in: .whitespacesAndNewlines) + + if pathToSelect == currentPath { + return .alreadySelectedPath(pathOrVersion) + } + + return .selectPath(pathToSelect) + } + + public func installedXcode( + fromSelection selection: String?, + installedXcodes: [InstalledXcode] + ) throws -> InstalledXcode { + let sortedInstalledXcodes = installedXcodes.sorted { $0.version < $1.version } + + guard + let selection, + let selectionNumber = Int(selection), + sortedInstalledXcodes.indices.contains(selectionNumber - 1) + else { + throw XcodeSelectionError.invalidIndex(min: 1, max: sortedInstalledXcodes.count, given: selection) + } + + return sortedInstalledXcodes[selectionNumber - 1] + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSignatureVerifier.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSignatureVerifier.swift new file mode 100644 index 00000000..bc8a6897 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSignatureVerifier.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct XcodeSignature: Equatable, Sendable { + public var authority: [String] + public var teamIdentifier: String + public var bundleIdentifier: String + + public init(authority: [String] = [], teamIdentifier: String = "", bundleIdentifier: String = "") { + self.authority = authority + self.teamIdentifier = teamIdentifier + self.bundleIdentifier = bundleIdentifier + } +} + +public struct XcodeSignatureVerifier: Sendable { + public static let expectedTeamIdentifier = "59GAB85EFG" + public static let expectedCertificateAuthority = [ + "Software Signing", + "Apple Code Signing Certification Authority", + "Apple Root CA", + ] + + public init() {} + + public func parse(_ rawInfo: String) -> XcodeSignature { + var signature = XcodeSignature() + + for line in rawInfo.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) { + let parts = line.trimmingCharacters(in: .whitespaces).split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + + switch parts[0] { + case "Authority": + signature.authority.append(String(parts[1])) + case "TeamIdentifier": + signature.teamIdentifier = String(parts[1]) + case "Identifier": + signature.bundleIdentifier = String(parts[1]) + default: + continue + } + } + + return signature + } + + public func isValid(_ signature: XcodeSignature) -> Bool { + signature.teamIdentifier == Self.expectedTeamIdentifier && + signature.authority == Self.expectedCertificateAuthority + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUnarchiveService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUnarchiveService.swift new file mode 100644 index 00000000..6843ab24 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUnarchiveService.swift @@ -0,0 +1,91 @@ +import Foundation + +public enum XcodeUnarchiveError: Error, Equatable, Sendable { + case damagedXIP(url: URL) + case notEnoughFreeSpaceToExpandArchive(url: URL) +} + +public enum XcodeUnarchiveStep: Equatable, Sendable { + case unarchiving + case moving(destination: String) +} + +public struct XcodeUnarchiveService: Sendable { + public typealias Unarchive = @Sendable (URL) async throws -> Void + public typealias FileExists = @Sendable (String) -> Bool + public typealias MoveItem = @Sendable (URL, URL) throws -> Void + public typealias RemoveItem = @Sendable (URL) throws -> Void + public typealias StepChanged = @Sendable (XcodeUnarchiveStep) async -> Void + + private let unarchive: Unarchive + private let fileExists: FileExists + private let moveItem: MoveItem + private let removeItem: RemoveItem + + public init( + unarchive: @escaping Unarchive, + fileExists: @escaping FileExists, + moveItem: @escaping MoveItem, + removeItem: @escaping RemoveItem + ) { + self.unarchive = unarchive + self.fileExists = fileExists + self.moveItem = moveItem + self.removeItem = removeItem + } + + public func unarchiveAndMoveXIP( + at source: URL, + to destination: URL, + stepChanged: @escaping StepChanged = { _ in } + ) async throws -> URL { + try await withTaskCancellationHandler { + try await unarchiveAndMoveXIPWithoutCancellationHandler( + at: source, + to: destination, + stepChanged: stepChanged + ) + } onCancel: { + if fileExists(source.path) { + try? removeItem(source) + } + if fileExists(destination.path) { + try? removeItem(destination) + } + } + } + + private func unarchiveAndMoveXIPWithoutCancellationHandler( + at source: URL, + to destination: URL, + stepChanged: StepChanged + ) async throws -> URL { + await stepChanged(.unarchiving) + + do { + try await unarchive(source) + } catch { + if let executionError = error as? ProcessExecutionError { + if executionError.standardError.contains("damaged and can’t be expanded") { + throw XcodeUnarchiveError.damagedXIP(url: source) + } + if executionError.standardError.contains("can’t be expanded because the selected volume doesn’t have enough free space.") { + throw XcodeUnarchiveError.notEnoughFreeSpaceToExpandArchive(url: source) + } + } + throw error + } + + await stepChanged(.moving(destination: destination.path)) + + let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") + let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") + if fileExists(xcodeURL.path) { + try moveItem(xcodeURL, destination) + } else if fileExists(xcodeBetaURL.path) { + try moveItem(xcodeBetaURL, destination) + } + + return destination + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUninstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUninstallService.swift new file mode 100644 index 00000000..cf0cde68 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUninstallService.swift @@ -0,0 +1,32 @@ +import Foundation + +public struct XcodeUninstallService: Sendable { + public struct Result: Equatable, Sendable { + public let xcode: InstalledXcode + public let trashURL: URL? + + public var didDeleteImmediately: Bool { + trashURL == nil + } + } + + private let removeItem: @Sendable (URL) throws -> Void + private let trashItem: @Sendable (URL) throws -> URL + + public init( + removeItem: @escaping @Sendable (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }, + trashItem: @escaping @Sendable (URL) throws -> URL = { try FileManager.default.xcodesTrashItem(at: $0) } + ) { + self.removeItem = removeItem + self.trashItem = trashItem + } + + public func uninstall(_ xcode: InstalledXcode, emptyTrash: Bool) throws -> Result { + if emptyTrash { + try removeItem(xcode.path.url) + return Result(xcode: xcode, trashURL: nil) + } + + return Result(xcode: xcode, trashURL: try trashItem(xcode.path.url)) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUpdatePolicy.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUpdatePolicy.swift new file mode 100644 index 00000000..b246a451 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUpdatePolicy.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct XcodeUpdatePolicy: Sendable { + public static let defaultMaximumCacheAge = TimeInterval(60 * 60 * 5) + + private let maximumCacheAge: TimeInterval + private let now: @Sendable () -> Date + + public init( + maximumCacheAge: TimeInterval = Self.defaultMaximumCacheAge, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.maximumCacheAge = maximumCacheAge + self.now = now + } + + public func shouldUpdate( + cachedXcodes: [AvailableXcode], + lastUpdated: Date? + ) -> Bool { + guard cachedXcodes.isEmpty == false, let lastUpdated else { + return true + } + + return lastUpdated < now().addingTimeInterval(-maximumCacheAge) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeValidationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeValidationService.swift new file mode 100644 index 00000000..40404c34 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeValidationService.swift @@ -0,0 +1,61 @@ +import Foundation + +public enum XcodeValidationError: Error, Equatable, Sendable { + case failedSecurityAssessment(xcode: InstalledXcode, output: String) + case codesignVerifyFailed(output: String) + case unexpectedCodeSigningIdentity(identifier: String, certificateAuthority: [String]) +} + +public struct XcodeValidationService: Sendable { + public typealias AssessSecurity = @Sendable (URL) async throws -> ProcessOutput + public typealias VerifyCodesign = @Sendable (URL) async throws -> ProcessOutput + + private let assessSecurity: AssessSecurity + private let verifyCodesign: VerifyCodesign + private let signatureVerifier: XcodeSignatureVerifier + + public init( + assessSecurity: @escaping AssessSecurity, + verifyCodesign: @escaping VerifyCodesign, + signatureVerifier: XcodeSignatureVerifier = XcodeSignatureVerifier() + ) { + self.assessSecurity = assessSecurity + self.verifyCodesign = verifyCodesign + self.signatureVerifier = signatureVerifier + } + + public func verifySecurityAssessment(of xcode: InstalledXcode) async throws { + do { + _ = try await assessSecurity(xcode.path.url) + } catch { + throw XcodeValidationError.failedSecurityAssessment( + xcode: xcode, + output: Self.processOutput(from: error) + ) + } + } + + public func verifySigningCertificate(of url: URL) async throws { + let output: ProcessOutput + do { + output = try await verifyCodesign(url) + } catch { + throw XcodeValidationError.codesignVerifyFailed(output: Self.processOutput(from: error)) + } + + let signature = signatureVerifier.parse(output.err) + guard signatureVerifier.isValid(signature) else { + throw XcodeValidationError.unexpectedCodeSigningIdentity( + identifier: signature.teamIdentifier, + certificateAuthority: signature.authority + ) + } + } + + private static func processOutput(from error: Error) -> String { + guard let executionError = error as? ProcessExecutionError else { return "" } + return [executionError.standardOutput, executionError.standardError] + .filter { !$0.isEmpty } + .joined(separator: "\n") + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeVersionFileService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeVersionFileService.swift new file mode 100644 index 00000000..cc9bf8ab --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeVersionFileService.swift @@ -0,0 +1,34 @@ +import Foundation +@preconcurrency import Path +@preconcurrency import Version + +public struct XcodeVersionFileService: Sendable { + public typealias FileExists = @Sendable (String) -> Bool + public typealias ContentsAtPath = @Sendable (String) -> Data? + + private let fileExists: FileExists + private let contentsAtPath: ContentsAtPath + + public init( + fileExists: @escaping FileExists = { path in FileManager.default.fileExists(atPath: path) }, + contentsAtPath: @escaping ContentsAtPath = { path in FileManager.default.contents(atPath: path) } + ) { + self.fileExists = fileExists + self.contentsAtPath = contentsAtPath + } + + /// Attempts to parse the `.xcode-version` file in the provided directory. + public func version(inDirectory directory: Path = Path(.cwd)) -> Version? { + let xcodeVersionFilePath = directory.join(".xcode-version") + + guard + fileExists(xcodeVersionFilePath.string), + let contents = contentsAtPath(xcodeVersionFilePath.string), + let versionString = String(data: contents, encoding: .utf8) + else { + return nil + } + + return Version(gemVersion: versionString) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift new file mode 100644 index 00000000..f8faf416 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift @@ -0,0 +1,42 @@ +import Foundation +@preconcurrency import Path + +public struct XcodebuildRuntimeDownloadService: Sendable { + public init() {} + + public func download( + platform: String, + buildVersion: String, + architecture: String? = nil + ) -> AsyncThrowingStream { + let progress = Progress() + let process = Process() + let xcodebuildPath = Path.root.usr.bin.join("xcodebuild").url + + process.executableURL = xcodebuildPath + process.arguments = [ + "-downloadPlatform", + platform, + "-buildVersion", + buildVersion + ] + + if let architecture { + process.arguments?.append(contentsOf: [ + "-architectureVariant", + architecture + ]) + } + + return ProcessProgressStreamRunner( + process: process, + progress: progress, + outputHandler: { string, progress in + progress.updateFromXcodebuild(text: string) + }, + failureHandler: { process in + ProcessExecutionError(process: process, standardOutput: "", standardError: "") + } + ).stream() + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodesPathResolver.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodesPathResolver.swift new file mode 100644 index 00000000..d1555a2a --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodesPathResolver.swift @@ -0,0 +1,59 @@ +import Foundation +@preconcurrency import Path + +public struct XcodesPathResolver: Sendable { + public static let appDefaultApplicationSupport = Path.applicationSupport/"com.robotsandpencils.XcodesApp" + public static let appDefaultInstallDirectory = Path.root/"Applications" + + public static func appApplicationSupport(savedPath: String?) -> Path { + path(from: savedPath) ?? appDefaultApplicationSupport + } + + public static func appInstallDirectory(savedPath: String?) -> Path { + path(from: savedPath) ?? appDefaultInstallDirectory + } + + public static func appCaches() -> Path { + Path.caches/"com.xcodesorg.xcodesapp" + } + + public static func availableXcodesCacheFile(in applicationSupport: Path) -> Path { + applicationSupport/"available-xcodes.json" + } + + public static func downloadableRuntimesCacheFile(in applicationSupport: Path) -> Path { + applicationSupport/"downloadable-runtimes.json" + } + + public static func cliHome(environment: [String: String] = ProcessInfo.processInfo.environment) -> Path { + environment["HOME"].flatMap(Path.init) ?? Path(.home) + } + + public static func cliApplicationSupport(home: Path) -> Path { + home/"Library/Application Support/com.robotsandpencils.xcodes" + } + + public static func cliOldApplicationSupport(home: Path) -> Path { + home/"Library/Application Support/ca.brandonevans.xcodes" + } + + public static func cliCaches(home: Path) -> Path { + home/"Library/Caches/com.robotsandpencils.xcodes" + } + + public static func cliDownloads(home: Path) -> Path { + home/"Downloads" + } + + public static func cliAvailableXcodesCacheFile(applicationSupport: Path) -> Path { + availableXcodesCacheFile(in: applicationSupport) + } + + public static func cliConfigurationFile(applicationSupport: Path) -> Path { + applicationSupport/"configuration.json" + } + + private static func path(from savedPath: String?) -> Path? { + savedPath.flatMap(Path.init) + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift index daf28e97..a506a50f 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift @@ -1,12 +1,60 @@ import Foundation -import Path +@preconcurrency import Path +import os import os.log public typealias ProcessOutput = (status: Int32, out: String, err: String) +public enum XcodesProcess: Sendable { + public static func sudo(password: String? = nil, _ executable: P, workingDirectory: URL? = nil, _ arguments: String...) async throws -> ProcessOutput { + try await sudo(password: password, executable, workingDirectory: workingDirectory, arguments) + } + + public static func sudo(password: String? = nil, _ executable: P, workingDirectory: URL? = nil, _ arguments: [String]) async throws -> ProcessOutput { + var arguments = [executable.string] + arguments + if password != nil { + arguments.insert("-S", at: 0) + } + return try await run(Path.root.usr.bin.sudo.url, workingDirectory: workingDirectory, input: password, arguments) + } + + public static func run(_ executable: P, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { + try await run(executable, workingDirectory: workingDirectory, input: input, arguments) + } + + public static func run(_ executable: P, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { + try await run(executable.url, workingDirectory: workingDirectory, input: input, arguments) + } + + public static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { + try await Process.run(executable.url, workingDirectory: workingDirectory, input: input, arguments) + } + + public static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { + try await Process.run(executable, workingDirectory: workingDirectory, input: input, arguments) + } +} + +public extension Process { + @discardableResult + static func sudoAsync(password: String? = nil, _ executable: P, workingDirectory: URL? = nil, _ arguments: String...) async throws -> ProcessOutput { + try await XcodesProcess.sudo(password: password, executable, workingDirectory: workingDirectory, arguments) + } + + @discardableResult + static func runAsync(_ executable: P, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { + try await XcodesProcess.run(executable, workingDirectory: workingDirectory, input: input, arguments) + } + + @discardableResult + static func runAsync(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { + try await XcodesProcess.run(executable, workingDirectory: workingDirectory, input: input, arguments) + } +} + extension Process { static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { - return try run(executable.url, workingDirectory: workingDirectory, input: input, arguments) + return try await run(executable.url, workingDirectory: workingDirectory, input: input, arguments) } static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) throws -> ProcessOutput { @@ -46,7 +94,7 @@ extension Process { } guard process.terminationReason == .exit, process.terminationStatus == 0 else { - throw ProcessExecutionError(process: process, standardOutput: output, standardError: error) + throw ProcessExecutionError(process: process, terminationStatus: process.terminationStatus, standardOutput: output, standardError: error) } return (process.terminationStatus, output, error) @@ -54,17 +102,172 @@ extension Process { throw error } } + + static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { + let process = Process() + process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent() + process.executableURL = executable + process.arguments = arguments + + let (stdout, stderr) = (Pipe(), Pipe()) + process.standardOutput = stdout + process.standardError = stderr + + if let input = input { + let inputPipe = Pipe() + process.standardInput = inputPipe.fileHandleForReading + inputPipe.fileHandleForWriting.write(Data(input.utf8)) + inputPipe.fileHandleForWriting.closeFile() + } + + Logger.subprocess.info("Process.run executable: \(executable), input: \(input ?? ""), arguments: \(arguments.joined(separator: ", "))") + + let runner = AsyncProcessRunner(process: process, stdout: stdout, stderr: stderr) + return try await withTaskCancellationHandler { + try await runner.run() + } onCancel: { + runner.cancel() + } + } } -public struct ProcessExecutionError: Error { - public let process: Process +private final class AsyncProcessRunner: Sendable { + private let process: Process + private let stdout: Pipe + private let stderr: Pipe + private let request = OneShotContinuation() + private let output = OSAllocatedUnfairLock(initialState: OutputStorage()) + + init(process: Process, stdout: Pipe, stderr: Pipe) { + self.process = process + self.stdout = stdout + self.stderr = stderr + } + + func run() async throws -> ProcessOutput { + try await request.value { + startReadingOutput() + + process.terminationHandler = { [weak self] process in + self?.finish(process: process) + } + + do { + try process.run() + } catch { + clearReadabilityHandlers() + throw error + } + } + } + + func cancel() { + if process.isRunning { + process.terminate() + } + clearReadabilityHandlers() + request.resume(throwing: CancellationError()) + } + + private func finish(process: Process) { + clearReadabilityHandlers() + appendRemainingOutput() + + let data = output.withLock { $0 } + let output = string(from: data.stdout) + let error = string(from: data.stderr) + + Logger.subprocess.info("Process.run output: \(output)") + if !error.isEmpty { + Logger.subprocess.error("Process.run error: \(error)") + } + + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + resume(throwing: ProcessExecutionError(process: process, terminationStatus: process.terminationStatus, standardOutput: output, standardError: error)) + return + } + + resume(returning: (process.terminationStatus, output, error)) + } + + private func resume(returning output: ProcessOutput) { + request.resume(with: .success(output)) + } + + private func resume(throwing error: Swift.Error) { + request.resume(throwing: error) + } + + private func startReadingOutput() { + stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in + self?.appendAvailableData(from: handle, stream: .stdout) + } + stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in + self?.appendAvailableData(from: handle, stream: .stderr) + } + } + + private func appendAvailableData(from handle: FileHandle, stream: OutputStream) { + let data = handle.availableData + guard data.isEmpty == false else { return } + + output.withLock { + append(data, to: stream, storage: &$0) + } + } + + private func appendRemainingOutput() { + let remainingStdout = stdout.fileHandleForReading.readDataToEndOfFile() + let remainingStderr = stderr.fileHandleForReading.readDataToEndOfFile() + + output.withLock { + append(remainingStdout, to: .stdout, storage: &$0) + append(remainingStderr, to: .stderr, storage: &$0) + } + } + + private func append(_ data: Data, to stream: OutputStream, storage: inout OutputStorage) { + guard data.isEmpty == false else { return } + + switch stream { + case .stdout: + storage.stdout.append(data) + case .stderr: + storage.stderr.append(data) + } + } + + private func clearReadabilityHandlers() { + stdout.fileHandleForReading.readabilityHandler = nil + stderr.fileHandleForReading.readabilityHandler = nil + } + + private func string(from data: Data) -> String { + String(data: data, encoding: .utf8) ?? "" + } + + private enum OutputStream { + case stdout + case stderr + } + + private struct OutputStorage: Sendable { + var stdout = Data() + var stderr = Data() + } +} + +public struct ProcessExecutionError: Error, Sendable { + public let processDescription: String + public let terminationStatus: Int32 public let standardOutput: String public let standardError: String - public init(process: Process, standardOutput: String, standardError: String) { - self.process = process - self.standardOutput = standardOutput - self.standardError = standardError + public init(process: Process, terminationStatus: Int32 = 0, standardOutput: String?, standardError: String?) { + self.processDescription = process.description + self.terminationStatus = terminationStatus + self.standardOutput = standardOutput ?? "" + self.standardError = standardError ?? "" } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/ProcessProgressStream.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/ProcessProgressStream.swift new file mode 100644 index 00000000..e499ba75 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/ProcessProgressStream.swift @@ -0,0 +1,145 @@ +import Foundation +import os + +final class ProcessProgressStreamRunner: Sendable { + typealias OutputHandler = @Sendable (String, Progress) -> Void + typealias FailureHandler = @Sendable (Process) -> Error + typealias SuccessHandler = @Sendable () -> Error? + + private let process: Process + private let progress: Progress + private let outputHandler: OutputHandler + private let failureHandler: FailureHandler + private let successHandler: SuccessHandler + private let continuation = OSAllocatedUnfairLock.Continuation?>(initialState: nil) + + init( + process: Process, + progress: Progress, + outputHandler: @escaping OutputHandler, + failureHandler: @escaping FailureHandler, + successHandler: @escaping SuccessHandler = { nil } + ) { + self.process = process + self.progress = progress + self.outputHandler = outputHandler + self.failureHandler = failureHandler + self.successHandler = successHandler + } + + func stream() -> AsyncThrowingStream { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) + self.continuation.withLock { + $0 = continuation + } + + continuation.onTermination = { _ in + self.cancel() + } + + start() + + return stream + } + + private func start() { + progress.kind = .file + progress.fileOperationKind = .downloading + continuation.withLock { + _ = $0?.yield(progress) + } + + let stdOutPipe = Pipe() + process.standardOutput = stdOutPipe + let stdErrPipe = Pipe() + process.standardError = stdErrPipe + + let handleData: @Sendable (FileHandle) -> Void = { [weak self] handle in + guard let self else { return } + let data = handle.availableData + guard data.isEmpty == false else { return } + + let string = String(decoding: data, as: UTF8.self) + self.continuation.withLock { + self.outputHandler(string, self.progress) + _ = $0?.yield(self.progress) + } + } + + stdOutPipe.fileHandleForReading.readabilityHandler = handleData + stdErrPipe.fileHandleForReading.readabilityHandler = handleData + + process.terminationHandler = { [weak self] process in + self?.finish(process: process) + } + + do { + try process.run() + } catch { + finish(throwing: error) + } + } + + func cancel() { + if process.isRunning { + process.terminate() + } + clearHandlers() + continuation.withLock { + $0 = nil + } + } + + private func finish(process: Process) { + clearHandlers() + consumeRemainingOutput() + + guard process.terminationReason == .exit, process.terminationStatus == 0 else { + finish(throwing: failureHandler(process)) + return + } + + if let error = successHandler() { + finish(throwing: error) + return + } + + takeContinuation()?.finish() + } + + private func finish(throwing error: Error) { + clearHandlers() + takeContinuation()?.finish(throwing: error) + } + + private func takeContinuation() -> AsyncThrowingStream.Continuation? { + continuation.withLock { + let continuation = $0 + $0 = nil + return continuation + } + } + + private func clearHandlers() { + (process.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil + (process.standardError as? Pipe)?.fileHandleForReading.readabilityHandler = nil + } + + private func consumeRemainingOutput() { + consumeRemainingOutput(from: process.standardOutput as? Pipe) + consumeRemainingOutput(from: process.standardError as? Pipe) + } + + private func consumeRemainingOutput(from pipe: Pipe?) { + guard let pipe else { return } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard data.isEmpty == false else { return } + + let string = String(decoding: data, as: UTF8.self) + continuation.withLock { + outputHandler(string, progress) + _ = $0?.yield(progress) + } + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift index ef091f21..878eb64d 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift @@ -1,33 +1,62 @@ import Foundation -import Path +@preconcurrency import Path -public struct XcodesShell { - public var installedRuntimes: () async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j") +public struct XcodesShell: Sendable { + public init() {} + + public var unxip: @Sendable (URL) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", $0.path) + } + public var spctlAssess: @Sendable (URL) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", $0.path) + } + public var codesignVerify: @Sendable (URL) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.codesign, "-vv", "-d", $0.path) + } + public var buildVersion: @Sendable () async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.sw_vers, "-buildVersion") + } + public var xcodeBuildVersion: @Sendable (InstalledXcode) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.libexec.PlistBuddy, "-c", "Print :ProductBuildVersion", "\($0.path.string)/Contents/version.plist") + } + public var getUserCacheDir: @Sendable () async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.getconf, "DARWIN_USER_CACHE_DIR") } - public var mountDmg: (URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path) + public var touchInstallCheck: @Sendable (String, String, String) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin/"touch", "\($0)com.apple.dt.Xcode.InstallCheckCache_\($1)_\($2)") } - public var unmountDmg: (URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) + public var installedRuntimes: @Sendable () async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j") } - public var expandPkg: (URL, URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.sbin.join("pkgutil"), "--verbose", "--expand", $0.path, $1.path) + public var mountDmg: @Sendable (URL) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path) } - public var createPkg: (URL, URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path) + public var unmountDmg: @Sendable (URL) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) } - public var installPkg: (URL, String) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) + public var expandPkg: @Sendable (URL, URL) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.sbin.join("pkgutil"), "--verbose", "--expand", $0.path, $1.path) } - public var installRuntimeImage: (URL) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $0.path) + public var createPkg: @Sendable (URL, URL) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path) } - public var deleteRuntime: (String) async throws -> ProcessOutput = { - try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "delete", $0) + public var installPkg: @Sendable (URL, String) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) } - - public var archs: (URL) throws -> ProcessOutput = { + public var installRuntimeImage: @Sendable (URL) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $0.path) + } + public var deleteRuntime: @Sendable (String) async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "delete", $0) + } + public var xcodeSelectPrintPath: @Sendable () async throws -> ProcessOutput = { + try await XcodesProcess.run(Path.root.usr.bin.join("xcode-select"), "-p") + } + public var xcodeSelectSwitch: @Sendable (String?, String) async throws -> ProcessOutput = { + try await XcodesProcess.sudo(password: $0, Path.root.usr.bin.join("xcode-select"), "-s", $1) + } + + public var archs: @Sendable (URL) throws -> ProcessOutput = { try Process.run(Path.root.usr.bin.join("lipo"), "-archs", $0.path) } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift b/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift index c6a52435..4f65a73c 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift @@ -1,7 +1,47 @@ import Foundation +import os -public struct XcodesKitEnvironment { - public var shell = XcodesShell() +public final class XcodesKitEnvironment: Sendable { + private let environment = OSAllocatedUnfairLock(initialState: Storage()) + + public var files: XcodesKitFiles { + get { environment.withLock { $0.files } } + set { environment.withLock { $0.files = newValue } } + } + + public var shell: XcodesShell { + get { environment.withLock { $0.shell } } + set { environment.withLock { $0.shell = newValue } } + } + + public init() {} + + private struct Storage: Sendable { + var files = XcodesKitFiles() + var shell = XcodesShell() + } } -public var Current = XcodesKitEnvironment() +let Current = XcodesKitEnvironment() + +public struct XcodesKitFiles: Sendable { + public var contentsAtPath: @Sendable (String) -> Data? = { + try? Data(contentsOf: URL(fileURLWithPath: $0)) + } + + public func contents(atPath path: String) -> Data? { + contentsAtPath(path) + } +} + +public func configureXcodesKitFileContents(_ contentsAtPath: @escaping @Sendable (String) -> Data?) { + var files = Current.files + files.contentsAtPath = contentsAtPath + Current.files = files +} + +public func configureXcodesKitArchs(_ archs: @escaping @Sendable (URL) throws -> ProcessOutput) { + var shell = Current.shell + shell.archs = archs + Current.shell = shell +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ApplicationSupportMigrationServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ApplicationSupportMigrationServiceTests.swift new file mode 100644 index 00000000..3861d12e --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/ApplicationSupportMigrationServiceTests.swift @@ -0,0 +1,89 @@ +@preconcurrency import Path +import XCTest +import os +@testable import XcodesKit + +final class ApplicationSupportMigrationServiceTests: XCTestCase { + private let oldSupportPath = Path("/tmp/old-support")! + private let newSupportPath = Path("/tmp/new-support")! + + func testMigrationDoesNothingWhenOldSupportFilesDoNotExist() { + let service = ApplicationSupportMigrationService( + fileExists: { _ in false }, + moveItem: { _, _ in XCTFail("Should not move support files") }, + removeItem: { _ in XCTFail("Should not remove support files") } + ) + + XCTAssertEqual( + service.migrate(oldSupportPath: oldSupportPath, newSupportPath: newSupportPath), + .noMigrationNeeded + ) + } + + func testMigrationMovesOldSupportFilesWhenNewSupportFilesDoNotExist() { + let recorder = MigrationRecorder() + let oldSupportPathString = oldSupportPath.string + let service = ApplicationSupportMigrationService( + fileExists: { $0 == oldSupportPathString }, + moveItem: { source, destination in + recorder.recordMove(source: source, destination: destination) + }, + removeItem: { _ in XCTFail("Should not remove support files") } + ) + + XCTAssertEqual( + service.migrate(oldSupportPath: oldSupportPath, newSupportPath: newSupportPath), + .migratedOldSupportFiles + ) + XCTAssertEqual(recorder.movedSource, oldSupportPath.url) + XCTAssertEqual(recorder.movedDestination, newSupportPath.url) + } + + func testMigrationRemovesOldSupportFilesWhenNewSupportFilesAlreadyExist() { + let recorder = MigrationRecorder() + let service = ApplicationSupportMigrationService( + fileExists: { _ in true }, + moveItem: { _, _ in XCTFail("Should not move support files") }, + removeItem: { recorder.recordRemoval($0) } + ) + + XCTAssertEqual( + service.migrate(oldSupportPath: oldSupportPath, newSupportPath: newSupportPath), + .removedOldSupportFiles + ) + XCTAssertEqual(recorder.removedURL, oldSupportPath.url) + } +} + +private final class MigrationRecorder: Sendable { + private struct State: Sendable { + var source: URL? + var destination: URL? + var removed: URL? + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var movedSource: URL? { + state.withLock { $0.source } + } + + var movedDestination: URL? { + state.withLock { $0.destination } + } + + var removedURL: URL? { + state.withLock { $0.removed } + } + + func recordMove(source: URL, destination: URL) { + state.withLock { + $0.source = source + $0.destination = destination + } + } + + func recordRemoval(_ url: URL) { + state.withLock { $0.removed = url } + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchiveDownloadStrategyServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchiveDownloadStrategyServiceTests.swift new file mode 100644 index 00000000..dd5826e2 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchiveDownloadStrategyServiceTests.swift @@ -0,0 +1,128 @@ +import XCTest +@preconcurrency import Path +@testable import XcodesKit + +final class ArchiveDownloadStrategyServiceTests: XCTestCase { + func testAria2StrategyUsesAria2PathCookiesAndDestination() async throws { + let aria2Path = try XCTUnwrap(Path("/usr/local/bin/aria2c")) + let url = try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")) + let destination = try XCTUnwrap(Path("/tmp/Xcode.xip")) + let cookie = try XCTUnwrap(HTTPCookie(properties: [ + .domain: "example.com", + .path: "/", + .name: "session", + .value: "cookie" + ])) + let recorder = Aria2Recorder() + let service = ArchiveDownloadStrategyService( + archiveDownloadService: ArchiveDownloadService( + aria2Download: { path, url, destination, cookies in + return AsyncThrowingStream { continuation in + Task { + await recorder.record(path: path, url: url, destination: destination, cookies: cookies) + continuation.finish() + } + } + }, + urlSessionDownload: { _, _, _ in + XCTFail("URLSession should not be used for aria2 downloads") + return (Progress(), Task { throw URLError(.unknown) }) + }, + contentsAtPath: { _ in nil }, + createFile: { _, _ in }, + removeItem: { _ in } + ), + aria2Path: { aria2Path }, + cookiesForURL: { _ in [cookie] } + ) + + let result = try await service.download( + url: url, + destination: destination, + downloader: .aria2, + resumeDataPath: try XCTUnwrap(Path("/tmp/Xcode.xip.resumedata")), + progressChanged: { _ in } + ) + + XCTAssertEqual(result, destination.url) + let recorded = await recorder.values + XCTAssertEqual(recorded.path, aria2Path.string) + XCTAssertEqual(recorded.url, url) + XCTAssertEqual(recorded.destination, destination.string) + XCTAssertEqual(recorded.cookies, [cookie]) + } + + func testURLSessionStrategyUsesResumeDataPathAndSkipsAria2() async throws { + let url = try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")) + let destination = try XCTUnwrap(Path("/tmp/Xcode.xip")) + let resumeDataPath = try XCTUnwrap(Path("/tmp/Xcode.xip.resumedata")) + let resumeData = Data("resume".utf8) + let recorder = URLSessionRecorder() + let service = ArchiveDownloadStrategyService( + archiveDownloadService: ArchiveDownloadService( + aria2Download: { _, _, _, _ in + XCTFail("aria2 should not be used for URLSession downloads") + return AsyncThrowingStream { continuation in + continuation.finish(throwing: URLError(.unknown)) + } + }, + urlSessionDownload: { url, destination, resumeData in + return ( + Progress(), + Task { + await recorder.record(url: url, destination: destination, resumeData: resumeData) + return ( + saveLocation: destination, + response: URLResponse( + url: url, + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil + ) + ) + } + ) + }, + contentsAtPath: { path in + path == resumeDataPath.string ? resumeData : nil + }, + createFile: { _, _ in }, + removeItem: { _ in } + ), + aria2Path: { + XCTFail("aria2 path should not be requested for URLSession downloads") + return try XCTUnwrap(Path("/usr/local/bin/aria2c")) + } + ) + + let result = try await service.download( + url: url, + destination: destination, + downloader: .urlSession, + resumeDataPath: resumeDataPath, + progressChanged: { _ in } + ) + + XCTAssertEqual(result, destination.url) + let recorded = await recorder.values + XCTAssertEqual(recorded.url, url) + XCTAssertEqual(recorded.destination, destination.url) + XCTAssertEqual(recorded.resumeData, resumeData) + } +} + +private actor Aria2Recorder { + private(set) var values: (path: String?, url: URL?, destination: String?, cookies: [HTTPCookie]?) = (nil, nil, nil, nil) + + func record(path: Path, url: URL, destination: Path, cookies: [HTTPCookie]) { + values = (path.string, url, destination.string, cookies) + } +} + +private actor URLSessionRecorder { + private(set) var values: (url: URL?, destination: URL?, resumeData: Data?) = (nil, nil, nil) + + func record(url: URL, destination: URL, resumeData: Data?) { + values = (url, destination, resumeData) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/AutoInstallationTypeTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/AutoInstallationTypeTests.swift new file mode 100644 index 00000000..e642c374 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/AutoInstallationTypeTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import XcodesKit + +final class AutoInstallationTypeTests: XCTestCase { + func testAutoInstallingSetterEnablesNewestVersion() { + var type = AutoInstallationType.none + + type.isAutoInstalling = true + + XCTAssertEqual(type, .newestVersion) + } + + func testAutoInstallingSetterDisablesInstallation() { + var type = AutoInstallationType.newestBeta + + type.isAutoInstalling = false + + XCTAssertEqual(type, .none) + } + + func testAutoInstallingBetaSetterPreservesEnabledReleaseStateWhenDisabled() { + var type = AutoInstallationType.newestVersion + + type.isAutoInstallingBeta = false + + XCTAssertEqual(type, .newestVersion) + } + + func testAutoInstallingBetaSetterEnablesNewestBeta() { + var type = AutoInstallationType.none + + type.isAutoInstallingBeta = true + + XCTAssertEqual(type, .newestBeta) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/CodableFileStoreTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/CodableFileStoreTests.swift new file mode 100644 index 00000000..2bc122fb --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/CodableFileStoreTests.swift @@ -0,0 +1,99 @@ +@preconcurrency import Path +import XCTest +import os +@testable import XcodesKit + +final class CodableFileStoreTests: XCTestCase { + private struct Fixture: Codable, Equatable { + var name: String + } + + private let file = Path("/tmp/configuration.json")! + + func testLoadReturnsNilWhenFileIsMissing() throws { + let store = CodableFileStore( + contentsAtPath: { _ in nil } + ) + + XCTAssertNil(try store.load(from: file)) + } + + func testLoadDecodesValueFromFile() throws { + let data = try JSONEncoder().encode(Fixture(name: "Xcodes")) + let store = CodableFileStore( + contentsAtPath: { _ in data } + ) + + XCTAssertEqual(try store.load(from: file), Fixture(name: "Xcodes")) + } + + func testLoadThrowsDecodeErrorForInvalidFile() { + let store = CodableFileStore( + contentsAtPath: { _ in Data("not json".utf8) } + ) + + XCTAssertThrowsError(try store.load(from: file)) + } + + func testSaveCreatesParentDirectoryAndWritesEncodedValue() throws { + let recorder = CodableFileStoreRecorder() + + let store = CodableFileStore( + createDirectory: { url, createIntermediates, _ in + recorder.recordDirectory(url, createIntermediates: createIntermediates) + }, + createFile: { path, data, _ in + recorder.recordFile(path: path, data: data) + return true + } + ) + + try store.save(Fixture(name: "Xcodes"), to: file) + + XCTAssertEqual(recorder.createdDirectory, file.url.deletingLastPathComponent()) + XCTAssertEqual(recorder.createdIntermediates, true) + XCTAssertEqual(recorder.writtenPath, file.string) + XCTAssertEqual(try JSONDecoder().decode(Fixture.self, from: XCTUnwrap(recorder.writtenData)), Fixture(name: "Xcodes")) + } +} + +private final class CodableFileStoreRecorder: Sendable { + private struct State: Sendable { + var directory: URL? + var createIntermediates: Bool? + var path: String? + var data: Data? + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var createdDirectory: URL? { + state.withLock { $0.directory } + } + + var createdIntermediates: Bool? { + state.withLock { $0.createIntermediates } + } + + var writtenPath: String? { + state.withLock { $0.path } + } + + var writtenData: Data? { + state.withLock { $0.data } + } + + func recordDirectory(_ url: URL, createIntermediates: Bool) { + state.withLock { + $0.directory = url + $0.createIntermediates = createIntermediates + } + } + + func recordFile(path: String, data: Data?) { + state.withLock { + $0.path = path + $0.data = data + } + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/HostHardwareTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/HostHardwareTests.swift new file mode 100644 index 00000000..7e9be9ff --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/HostHardwareTests.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import XcodesKit + +final class HostHardwareTests: XCTestCase { + func testIsAppleSiliconReturnsTrueForArm64() { + XCTAssertTrue(HostHardware.isAppleSilicon(machineHardwareName: "arm64")) + } + + func testIsAppleSiliconReturnsFalseForIntel() { + XCTAssertFalse(HostHardware.isAppleSilicon(machineHardwareName: "x86_64")) + } + + func testIsAppleSiliconReturnsFalseForUnknownHardware() { + XCTAssertFalse(HostHardware.isAppleSilicon(machineHardwareName: nil)) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/InstalledXcodeTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/InstalledXcodeTests.swift new file mode 100644 index 00000000..40af37ab --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/InstalledXcodeTests.swift @@ -0,0 +1,177 @@ +import XCTest +@preconcurrency import Path +import Version +import os +@testable import XcodesKit + +final class InstalledXcodeTests: XCTestCase { + func testInitParsesBundleInfoAndArchitecturesFromInjectedLoaders() throws { + let path = try XCTUnwrap(Path("/Applications/Xcode-15.0.0.app")) + let architectureURL = URLRecorder() + + let xcode = try XCTUnwrap(InstalledXcode( + path: path, + contentsAtPath: { requestedPath in + switch requestedPath { + case "/Applications/Xcode-15.0.0.app/Contents/Info.plist": + return Self.plistData(""" + + CFBundleIdentifier + com.apple.dt.Xcode + CFBundleShortVersionString + 15.0 + + """) + case "/Applications/Xcode-15.0.0.app/Contents/version.plist": + return Self.plistData(""" + + ProductBuildVersion + 15A240d + + """) + default: + XCTFail("Unexpected path \(requestedPath)") + return nil + } + }, + loadArchitectures: { url in + architectureURL.record(url) + return (0, "x86_64 arm64\n", "") + } + )) + + XCTAssertEqual(xcode.path, path) + XCTAssertEqual(xcode.version, Version("15.0.0+15A240d")) + XCTAssertEqual(xcode.xcodeID.architectures, [.x86_64, .arm64]) + XCTAssertEqual(architectureURL.url?.path, "/Applications/Xcode-15.0.0.app/Contents/MacOS/Xcode") + } + + func testInitReturnsNilForNonXcodeBundle() throws { + let path = try XCTUnwrap(Path("/Applications/Other.app")) + + let xcode = InstalledXcode( + path: path, + contentsAtPath: { requestedPath in + switch requestedPath { + case "/Applications/Other.app/Contents/Info.plist": + return Self.plistData(""" + + CFBundleIdentifier + com.example.Other + CFBundleShortVersionString + 15.0 + + """) + case "/Applications/Other.app/Contents/version.plist": + return Self.plistData(""" + + ProductBuildVersion + 15A240d + + """) + default: + return nil + } + }, + loadArchitectures: { _ in (0, "arm64\n", "") } + ) + + XCTAssertNil(xcode) + } + + func testInitReturnsNilWhenBundleInfoCannotBeLoaded() throws { + let path = try XCTUnwrap(Path("/Applications/Xcode.app")) + + let xcode = InstalledXcode( + path: path, + contentsAtPath: { _ in nil }, + loadArchitectures: { _ in + XCTFail("Architectures should not be loaded without bundle info") + return (0, "", "") + } + ) + + XCTAssertNil(xcode) + } + + func testDiscoveryServiceLoadsInstalledXcodesFromInjectedDirectoryListing() throws { + let xcodePath = try XCTUnwrap(Path("/Applications/Xcode.app")) + let otherPath = try XCTUnwrap(Path("/Applications/Other.app")) + let ignoredPath = try XCTUnwrap(Path("/Applications/README.txt")) + + let service = InstalledXcodeDiscoveryService( + listDirectory: { directory in + XCTAssertEqual(directory, try! XCTUnwrap(Path("/Applications"))) + return [xcodePath, otherPath, ignoredPath] + }, + isAppBundle: { $0.extension == "app" }, + contentsAtPath: { requestedPath in + switch requestedPath { + case "/Applications/Xcode.app/Contents/Info.plist": + return Self.plistData(""" + + CFBundleIdentifier + com.apple.dt.Xcode + CFBundleShortVersionString + 15.0 + + """) + case "/Applications/Xcode.app/Contents/version.plist": + return Self.plistData(""" + + ProductBuildVersion + 15A240d + + """) + case "/Applications/Other.app/Contents/Info.plist": + return Self.plistData(""" + + CFBundleIdentifier + com.example.Other + CFBundleShortVersionString + 1.0 + + """) + case "/Applications/Other.app/Contents/version.plist": + return Self.plistData(""" + + ProductBuildVersion + 1A1 + + """) + default: + return nil + } + }, + loadArchitectures: { _ in (0, "arm64\n", "") } + ) + + let xcodes = service.installedXcodes(in: try XCTUnwrap(Path("/Applications"))) + + XCTAssertEqual(xcodes, [ + InstalledXcode(path: xcodePath, version: Version("15.0.0+15A240d")!, architectures: [.arm64]) + ]) + } + + private static func plistData(_ body: String) -> Data { + Data(""" + + + + \(body) + + """.utf8) + } +} + +private final class URLRecorder: Sendable { + private let storedURL = OSAllocatedUnfairLock(initialState: nil) + + var url: URL? { + storedURL.withLock { $0 } + } + + func record(_ url: URL) { + storedURL.withLock { $0 = url } + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/OperatingSystemVersionXcodesTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/OperatingSystemVersionXcodesTests.swift new file mode 100644 index 00000000..f45d81db --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/OperatingSystemVersionXcodesTests.swift @@ -0,0 +1,10 @@ +import XCTest +@testable import XcodesKit + +final class OperatingSystemVersionXcodesTests: XCTestCase { + func testVersionStringIncludesMajorMinorAndPatchVersions() { + let version = OperatingSystemVersion(majorVersion: 14, minorVersion: 6, patchVersion: 1) + + XCTAssertEqual(version.versionString(), "14.6.1") + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressObservationTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressObservationTests.swift new file mode 100644 index 00000000..58d2bb4a --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressObservationTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import XcodesKit + +final class ProgressObservationTests: XCTestCase { + func testObserveDefaultsToFractionCompletedChanges() { + let progress = Progress(totalUnitCount: 10) + let observation = ProgressObservation() + let expectation = expectation(description: "progress changed") + + observation.observe(progress) { changedProgress in + XCTAssertEqual(changedProgress.completedUnitCount, 1) + expectation.fulfill() + } + + progress.completedUnitCount = 1 + + wait(for: [expectation], timeout: 1) + } + + func testChangesYieldsObservedPropertyChanges() async throws { + let progress = Progress(totalUnitCount: 10) + let stream = ProgressObservation.changes( + for: progress, + observing: [.fractionCompleted, .localizedAdditionalDescription, .isIndeterminate] + ) + + progress.completedUnitCount = 1 + + try await waitForNextValue(in: stream) + } + + private func waitForNextValue(in stream: AsyncStream) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + var iterator = stream.makeAsyncIterator() + guard await iterator.next() != nil else { + throw ProgressObservationTestError.streamFinished + } + } + + group.addTask { + try await Task.sleep(nanoseconds: 1_000_000_000) + throw ProgressObservationTestError.timedOut + } + + try await group.next() + group.cancelAll() + } + } +} + +private enum ProgressObservationTestError: Error { + case streamFinished + case timedOut +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressXcodesTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressXcodesTests.swift new file mode 100644 index 00000000..bcc13b05 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressXcodesTests.swift @@ -0,0 +1,33 @@ +import XCTest + +final class ProgressXcodesTests: XCTestCase { + func testUpdateFromAria2Output() { + let progress = Progress() + + progress.updateFromAria2(string: "[#123abc 1024B/4096B(25%) CN:4 DL:512B ETA:1m2s]") + + XCTAssertEqual(progress.completedUnitCount, 1024) + XCTAssertEqual(progress.totalUnitCount, 4096) + XCTAssertEqual(progress.throughput, 512) + XCTAssertEqual(progress.estimatedTimeRemaining, 62) + } + + func testUpdateFromXcodebuildDownloadOutput() { + let progress = Progress() + + progress.updateFromXcodebuild(text: "Downloading iOS 18.1 Simulator (22B83): 42.6% (1.2 GB of 2.8 GB)") + + XCTAssertEqual(progress.totalUnitCount, 100) + XCTAssertEqual(progress.completedUnitCount, 43) + } + + func testUpdateFromXcodebuildInstallingOutputIsIndeterminate() { + let progress = Progress(totalUnitCount: 100) + progress.completedUnitCount = 50 + + progress.updateFromXcodebuild(text: "Downloading tvOS 18.1 Simulator (22J5567a): Installing...") + + XCTAssertEqual(progress.totalUnitCount, 0) + XCTAssertEqual(progress.completedUnitCount, 0) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeArchiveDownloadStrategyServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeArchiveDownloadStrategyServiceTests.swift new file mode 100644 index 00000000..041040f0 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeArchiveDownloadStrategyServiceTests.swift @@ -0,0 +1,184 @@ +import XCTest +@preconcurrency import Path +@testable import XcodesKit + +final class RuntimeArchiveDownloadStrategyServiceTests: XCTestCase { + func testAria2StrategyValidatesDownloadPathAndForwardsProgress() async throws { + let runtime = downloadableRuntime(source: "https://example.com/runtimes/iOS.dmg") + let destination = try XCTUnwrap(Path("/tmp/iOS.dmg")) + let aria2Path = try XCTUnwrap(Path("/usr/local/bin/aria2c")) + let recorder = RuntimeArchiveDownloadRecorder() + let progress = Progress(totalUnitCount: 10) + progress.completedUnitCount = 4 + + let service = RuntimeArchiveDownloadStrategyService( + validateDownloadPath: { path in + await recorder.recordValidation(path) + }, + aria2Path: { aria2Path }, + aria2Download: { runtime, destination, aria2Path, _ in + AsyncThrowingStream { continuation in + Task { + await recorder.recordAria2(runtime: runtime, destination: destination, aria2Path: aria2Path) + continuation.yield(progress) + continuation.finish() + } + } + } + ) + + let result = try await service.download( + runtime: runtime, + url: try XCTUnwrap(runtime.url), + destination: destination, + downloader: .aria2, + progressChanged: { progress in + Task { await recorder.recordProgress(progress.fractionCompleted) } + } + ) + + XCTAssertEqual(result, destination.url) + await recorder.waitForProgress(count: 1) + let recorded = await recorder.snapshot() + XCTAssertEqual(recorded.validatedPath, "/runtimes/iOS.dmg") + XCTAssertEqual(recorded.aria2Runtime, runtime) + XCTAssertEqual(recorded.aria2Destination, destination) + XCTAssertEqual(recorded.aria2Path, aria2Path) + XCTAssertEqual(recorded.progressFractions, [0.4]) + } + + func testURLSessionStrategyUsesProvidedDownload() async throws { + let runtime = downloadableRuntime(source: "https://example.com/runtimes/iOS.dmg") + let destination = try XCTUnwrap(Path("/tmp/iOS.dmg")) + let recorder = RuntimeArchiveDownloadRecorder() + + let service = RuntimeArchiveDownloadStrategyService( + validateDownloadPath: { path in + await recorder.recordValidation(path) + }, + aria2Path: { + XCTFail("aria2 path should not be needed for URLSession downloads") + throw URLError(.unknown) + }, + urlSessionDownload: { url, destination, _ in + await recorder.recordURLSession(url: url, destination: destination) + return destination.url + } + ) + + let result = try await service.download( + runtime: runtime, + url: try XCTUnwrap(runtime.url), + destination: destination, + downloader: .urlSession, + progressChanged: { _ in } + ) + + XCTAssertEqual(result, destination.url) + let recorded = await recorder.snapshot() + XCTAssertEqual(recorded.validatedPath, "/runtimes/iOS.dmg") + XCTAssertEqual(recorded.urlSessionURL, runtime.url) + XCTAssertEqual(recorded.urlSessionDestination, destination) + } + + func testURLSessionStrategyThrowsWhenUnavailable() async throws { + let runtime = downloadableRuntime(source: "https://example.com/runtimes/iOS.dmg") + let destination = try XCTUnwrap(Path("/tmp/iOS.dmg")) + let service = RuntimeArchiveDownloadStrategyService( + validateDownloadPath: { _ in }, + aria2Path: { try XCTUnwrap(Path("/usr/local/bin/aria2c")) } + ) + + do { + _ = try await service.download( + runtime: runtime, + url: try XCTUnwrap(runtime.url), + destination: destination, + downloader: .urlSession, + progressChanged: { _ in } + ) + XCTFail("Expected URLSession runtime downloads to throw when no URLSession download is supplied") + } catch { + XCTAssertEqual(error.localizedDescription, "Downloading runtimes with URLSession is not supported. Please use aria2") + } + } + + private func downloadableRuntime(source: String?) -> DownloadableRuntime { + DownloadableRuntime( + category: .simulator, + simulatorVersion: .init(buildUpdate: "20A360", version: "16.0"), + source: source, + architectures: nil, + dictionaryVersion: 1, + contentType: .diskImage, + platform: .iOS, + identifier: "com.apple.CoreSimulator.SimRuntime.iOS-16-0", + version: "16.0", + fileSize: 42, + hostRequirements: nil, + name: "iOS 16.0", + authentication: .virtual + ) + } +} + +private actor RuntimeArchiveDownloadRecorder { + private(set) var validatedPath: String? + private(set) var aria2Runtime: DownloadableRuntime? + private(set) var aria2Destination: Path? + private(set) var aria2Path: Path? + private(set) var urlSessionURL: URL? + private(set) var urlSessionDestination: Path? + private(set) var progressFractions: [Double] = [] + private var progressWaiters: [CheckedContinuation] = [] + + func recordValidation(_ path: String) { + validatedPath = path + } + + func recordAria2(runtime: DownloadableRuntime, destination: Path, aria2Path: Path) { + aria2Runtime = runtime + aria2Destination = destination + self.aria2Path = aria2Path + } + + func recordURLSession(url: URL, destination: Path) { + urlSessionURL = url + urlSessionDestination = destination + } + + func recordProgress(_ fraction: Double) { + progressFractions.append(fraction) + progressWaiters.forEach { $0.resume() } + progressWaiters.removeAll() + } + + func waitForProgress(count: Int) async { + if progressFractions.count >= count { return } + await withCheckedContinuation { continuation in + progressWaiters.append(continuation) + } + } + + func snapshot() -> RuntimeArchiveDownloadRecord { + RuntimeArchiveDownloadRecord( + validatedPath: validatedPath, + aria2Runtime: aria2Runtime, + aria2Destination: aria2Destination, + aria2Path: aria2Path, + urlSessionURL: urlSessionURL, + urlSessionDestination: urlSessionDestination, + progressFractions: progressFractions + ) + } +} + +private struct RuntimeArchiveDownloadRecord { + let validatedPath: String? + let aria2Runtime: DownloadableRuntime? + let aria2Destination: Path? + let aria2Path: Path? + let urlSessionURL: URL? + let urlSessionDestination: Path? + let progressFractions: [Double] +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeListStoreTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeListStoreTests.swift new file mode 100644 index 00000000..17419bd4 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeListStoreTests.swift @@ -0,0 +1,97 @@ +import XCTest +@preconcurrency import Path +import os +@testable import XcodesKit + +final class RuntimeListStoreTests: XCTestCase { + func testLoadsCachedDownloadableRuntimes() throws { + let runtime = Self.downloadableRuntime(buildUpdate: "20A360") + let cache = DownloadableRuntimeCache( + cacheFile: try XCTUnwrap(Path("/tmp/downloadable-runtimes.json")), + contentsAtPath: { _ in try? JSONEncoder().encode([runtime]) } + ) + var store = RuntimeListStore(cache: cache, fetchDownloadableRuntimes: { + XCTFail("Cache load should not fetch runtimes") + return Self.downloadableResponse(downloadables: []) + }) + + try store.loadCachedDownloadableRuntimes() + + XCTAssertEqual(store.downloadableRuntimes, [runtime]) + } + + func testUpdateFetchesAddsSDKBuildUpdatesAndSavesRuntimes() async throws { + let runtime = Self.downloadableRuntime(buildUpdate: "20A360") + let response = Self.downloadableResponse( + downloadables: [runtime], + sdkToSimulatorMappings: [ + SDKToSimulatorMapping( + sdkBuildUpdate: "20A361", + simulatorBuildUpdate: "20A360", + sdkIdentifier: "com.apple.platform.iphonesimulator", + downloadableIdentifiers: nil + ) + ] + ) + let savedRuntimes = RuntimeCacheSaveRecorder() + let cache = DownloadableRuntimeCache( + cacheFile: try XCTUnwrap(Path("/tmp/downloadable-runtimes.json")), + contentsAtPath: { _ in nil }, + writeData: { data, _ in + try savedRuntimes.record(data) + }, + createDirectory: { _, _, _ in } + ) + var store = RuntimeListStore(cache: cache, fetchDownloadableRuntimes: { response }) + + let runtimes = try await store.updateDownloadableRuntimes() + + XCTAssertEqual(runtimes.map(\.sdkBuildUpdate), [["20A361"]]) + XCTAssertEqual(store.downloadableRuntimes, runtimes) + XCTAssertEqual(savedRuntimes.value, runtimes) + } + + private static func downloadableRuntime(buildUpdate: String) -> DownloadableRuntime { + DownloadableRuntime( + category: .simulator, + simulatorVersion: .init(buildUpdate: buildUpdate, version: "16.0"), + source: "https://example.com/iOS.dmg", + architectures: nil, + dictionaryVersion: 1, + contentType: .diskImage, + platform: .iOS, + identifier: "com.apple.CoreSimulator.SimRuntime.iOS-16-0", + version: "16.0", + fileSize: 42, + hostRequirements: nil, + name: "iOS 16.0", + authentication: .virtual + ) + } + + private static func downloadableResponse( + downloadables: [DownloadableRuntime], + sdkToSimulatorMappings: [SDKToSimulatorMapping] = [] + ) -> DownloadableRuntimesResponse { + DownloadableRuntimesResponse( + sdkToSimulatorMappings: sdkToSimulatorMappings, + sdkToSeedMappings: [], + refreshInterval: 0, + downloadables: downloadables, + version: "1" + ) + } +} + +private final class RuntimeCacheSaveRecorder: Sendable { + private let storedValue = OSAllocatedUnfairLock<[DownloadableRuntime]?>(initialState: nil) + + var value: [DownloadableRuntime]? { + storedValue.withLock { $0 } + } + + func record(_ data: Data) throws { + let value = try JSONDecoder().decode([DownloadableRuntime].self, from: data) + storedValue.withLock { $0 = value } + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/SDKsTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/SDKsTests.swift new file mode 100644 index 00000000..e587dd7f --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/SDKsTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import XcodesKit + +final class SDKsTests: XCTestCase { + func testAllBuildsReturnsBuildsAcrossAllPlatformsInAppOrder() { + let sdks = SDKs( + macOS: [XcodeVersion("24A335")], + iOS: [XcodeVersion("22A336"), XcodeVersion(number: "18.0")], + watchOS: [XcodeVersion("22R349")], + tvOS: [XcodeVersion("22J357")], + visionOS: [XcodeVersion("22N320")] + ) + + XCTAssertEqual(sdks.allBuilds, [ + "22A336", + "22J357", + "24A335", + "22R349", + "22N320", + ]) + } + + func testAllBuildsSkipsMissingPlatformAndBuildValues() { + let sdks = SDKs( + macOS: nil, + iOS: [XcodeVersion(number: "18.0")], + watchOS: nil, + tvOS: [XcodeVersion("22J357")], + visionOS: nil + ) + + XCTAssertEqual(sdks.allBuilds, ["22J357"]) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/SelectedActionTypeTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/SelectedActionTypeTests.swift new file mode 100644 index 00000000..b954f8a2 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/SelectedActionTypeTests.swift @@ -0,0 +1,13 @@ +import XCTest +@testable import XcodesKit + +final class SelectedActionTypeTests: XCTestCase { + func testRawValuesMatchStoredPreferenceValues() { + XCTAssertEqual(SelectedActionType.none.rawValue, "none") + XCTAssertEqual(SelectedActionType.rename.rawValue, "rename") + } + + func testDefaultDoesNothing() { + XCTAssertEqual(SelectedActionType.default, .none) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionGemTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionGemTests.swift new file mode 100644 index 00000000..1ec6582b --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionGemTests.swift @@ -0,0 +1,13 @@ +import XCTest +import Version +@testable import XcodesKit + +final class VersionGemTests: XCTestCase { + func testInitGemVersion() { + XCTAssertEqual(Version(gemVersion: "9.2b3"), Version("9.2.0-Beta.3")) + XCTAssertEqual(Version(gemVersion: "9.1.2"), Version("9.1.2")) + XCTAssertEqual(Version(gemVersion: "9.2"), Version("9.2.0")) + XCTAssertEqual(Version(gemVersion: "9"), Version("9.0.0")) + } +} + diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionMatchingTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionMatchingTests.swift new file mode 100644 index 00000000..8c29790e --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionMatchingTests.swift @@ -0,0 +1,42 @@ +import XCTest +import Version +@testable import XcodesKit + +final class VersionMatchingTests: XCTestCase { + private struct Candidate: Equatable { + let version: Version + let name: String + } + + func testFindXcodePrefersEquivalentMatch() { + let candidates = [ + Candidate(version: Version("15.0.0+AAA")!, name: "release"), + Candidate(version: Version("15.0.0-beta+BBB")!, name: "beta") + ] + + XCTAssertEqual( + XcodeVersionMatcher.find(version: Version("15.0.0-beta")!, in: candidates, versionKeyPath: \.version), + Candidate(version: Version("15.0.0-beta+BBB")!, name: "beta") + ) + } + + func testFindXcodeFallsBackToSingleVersionMatchWithoutIdentifiers() { + let candidates = [ + Candidate(version: Version("15.0.0-rc+AAA")!, name: "rc") + ] + + XCTAssertEqual( + XcodeVersionMatcher.find(version: Version("15.0.0")!, in: candidates, versionKeyPath: \.version), + Candidate(version: Version("15.0.0-rc+AAA")!, name: "rc") + ) + } + + func testFindXcodeRejectsAmbiguousFallbackMatches() { + let candidates = [ + Candidate(version: Version("15.0.0-beta+AAA")!, name: "beta"), + Candidate(version: Version("15.0.0-rc+BBB")!, name: "rc") + ] + + XCTAssertNil(XcodeVersionMatcher.find(version: Version("15.0.0")!, in: candidates, versionKeyPath: \.version)) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionXcodeTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionXcodeTests.swift new file mode 100644 index 00000000..72423a3b --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionXcodeTests.swift @@ -0,0 +1,37 @@ +import XCTest +import Version +@testable import XcodesKit + +final class VersionXcodeTests: XCTestCase { + func testInitXcodeVersion() { + XCTAssertEqual(Version(xcodeVersion: "10.2"), Version(major: 10, minor: 2, patch: 0)) + XCTAssertEqual(Version(xcodeVersion: "10.2.1"), Version(major: 10, minor: 2, patch: 1)) + XCTAssertEqual(Version(xcodeVersion: "10.2 Beta 4"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["beta", "4"])) + XCTAssertEqual(Version(xcodeVersion: "10.2 GM"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm"])) + XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2"), Version(major: 10, minor: 2, patch: 0)) + XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2.1"), Version(major: 10, minor: 2, patch: 1)) + XCTAssertEqual(Version(xcodeVersion: "Xcode 11 beta"), Version(major: 11, minor: 0, patch: 0, prereleaseIdentifiers: ["beta"])) + XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 Beta 4"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["beta", "4"])) + XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 GM"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm"])) + XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 GM seed"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed"])) + XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 GM seed 1"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed", "1"])) + XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 GM seed 2"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed", "2"])) + XCTAssertEqual(Version(xcodeVersion: "Xcode 13.2 Release Candidate"), Version(major: 13, minor: 2, patch: 0, prereleaseIdentifiers: ["release", "candidate"])) + } + + func testAppleDescription() { + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0).appleDescription, "10.2") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 1).appleDescription, "10.2.1") + XCTAssertEqual(Version(major: 11, minor: 0, patch: 0, prereleaseIdentifiers: ["beta"]).appleDescription, "11.0 Beta") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["beta", "4"]).appleDescription, "10.2 Beta 4") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm"]).appleDescription, "10.2 GM") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed"]).appleDescription, "10.2 GM Seed") + XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed", "1"]).appleDescription, "10.2 GM Seed 1") + } + + func testEquivalence() { + XCTAssertTrue(Version("10.2.1")!.isEquivalent(to: Version("10.2.1+abcdef")!)) + XCTAssertFalse(Version("10.2.1-beta+qwerty")!.isEquivalent(to: Version("10.2.1-beta+abcdef")!)) + XCTAssertTrue(Version("10.2.1-beta+qwerty")!.isEquivalent(to: Version("10.2.1-beta+QWERTY")!)) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeArchiveDownloaderTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeArchiveDownloaderTests.swift new file mode 100644 index 00000000..86e495b4 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeArchiveDownloaderTests.swift @@ -0,0 +1,14 @@ +import XCTest +@testable import XcodesKit + +final class XcodeArchiveDownloaderTests: XCTestCase { + func testRawValuesMatchPersistedPreferenceValues() { + XCTAssertEqual(XcodeArchiveDownloader.aria2.rawValue, "aria2") + XCTAssertEqual(XcodeArchiveDownloader.urlSession.rawValue, "urlSession") + } + + func testDescriptionUsesAppDisplayNames() { + XCTAssertEqual(XcodeArchiveDownloader.aria2.description, "aria2") + XCTAssertEqual(XcodeArchiveDownloader.urlSession.description, "URLSession") + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeAutoInstallServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeAutoInstallServiceTests.swift new file mode 100644 index 00000000..a659e44c --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeAutoInstallServiceTests.swift @@ -0,0 +1,77 @@ +@preconcurrency import Path +import Version +import XCTest +@testable import XcodesKit + +final class XcodeAutoInstallServiceTests: XCTestCase { + func testDecisionIsDisabledWhenAutoInstallIsOff() { + let decision = XcodeAutoInstallService().decision( + autoInstallationType: .none, + xcodes: [ + xcode(version: "15.0.0", installState: .notInstalled) + ] + ) + + XCTAssertEqual(decision, .disabled) + } + + func testDecisionIsAlreadyInstalledWhenNewestXcodeIsInstalled() { + let path = Path("/Applications/Xcode-15.0.app")! + let decision = XcodeAutoInstallService().decision( + autoInstallationType: .newestVersion, + xcodes: [ + xcode(version: "15.0.0", installState: .installed(path)) + ] + ) + + XCTAssertEqual(decision, .alreadyInstalled) + } + + func testDecisionInstallsNewestBetaForNewestBetaPreference() { + let newestXcode = xcode(version: "16.0.0-beta.1", installState: .notInstalled) + let decision = XcodeAutoInstallService().decision( + autoInstallationType: .newestBeta, + xcodes: [newestXcode] + ) + + XCTAssertEqual(decision, .installNewestBeta(newestXcode.id)) + } + + func testDecisionInstallsNewestReleaseForNewestVersionPreference() { + let newestXcode = xcode(version: "15.0.0", installState: .notInstalled) + let decision = XcodeAutoInstallService().decision( + autoInstallationType: .newestVersion, + xcodes: [newestXcode] + ) + + XCTAssertEqual(decision, .installNewestVersion(newestXcode.id)) + } + + func testDecisionDoesNotInstallPrereleaseForNewestVersionPreference() { + let decision = XcodeAutoInstallService().decision( + autoInstallationType: .newestVersion, + xcodes: [ + xcode(version: "16.0.0-beta.1", installState: .notInstalled) + ] + ) + + XCTAssertEqual(decision, .noNewVersion) + } + + func testDecisionIsAlreadyInstalledWhenNoXcodesAreAvailable() { + let decision = XcodeAutoInstallService().decision( + autoInstallationType: .newestVersion, + xcodes: [] + ) + + XCTAssertEqual(decision, .alreadyInstalled) + } + + private func xcode(version: String, installState: XcodeInstallState) -> XcodeListItem { + XcodeListItem( + version: Version(version)!, + installState: installState, + selected: false + ) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeCompatibilityServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeCompatibilityServiceTests.swift new file mode 100644 index 00000000..8eb1398f --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeCompatibilityServiceTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Version +import XCTest +@testable import XcodesKit + +final class XcodeCompatibilityServiceTests: XCTestCase { + func testNilRequiredVersionIsSupported() { + XCTAssertTrue( + XcodeCompatibilityService().isSupported( + requiredMacOSVersion: nil, + currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0) + ) + ) + } + + func testCurrentVersionEqualToRequiredVersionIsSupported() { + XCTAssertTrue( + XcodeCompatibilityService().isSupported( + requiredMacOSVersion: "14.1.2", + currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 1, patchVersion: 2) + ) + ) + } + + func testCurrentVersionNewerThanRequiredVersionIsSupported() { + XCTAssertTrue( + XcodeCompatibilityService().isSupported( + requiredMacOSVersion: "14.1.2", + currentOSVersion: OperatingSystemVersion(majorVersion: 15, minorVersion: 0, patchVersion: 0) + ) + ) + } + + func testCurrentVersionOlderThanRequiredVersionIsUnsupported() { + XCTAssertTrue( + XcodeCompatibilityService().isUnsupported( + requiredMacOSVersion: "14.1.2", + currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 1, patchVersion: 1) + ) + ) + } + + func testStatusIncludesRequiredAndCurrentVersionsWhenUnsupported() { + XCTAssertEqual( + XcodeCompatibilityService().status( + requiredMacOSVersion: "14.1.2", + currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 1, patchVersion: 1) + ), + .unsupported(requiredMacOSVersion: "14.1.2", currentMacOSVersion: "14.1.1") + ) + } + + func testStatusForXcodeUsesRequiredMacOSVersion() { + let xcode = AvailableXcode( + version: Version("16.0.0")!, + url: URL(fileURLWithPath: "/Xcode.xip"), + filename: "Xcode.xip", + releaseDate: nil, + requiredMacOSVersion: "15.0" + ) + + XCTAssertEqual( + XcodeCompatibilityService().status( + for: xcode, + currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 6, patchVersion: 0) + ), + .unsupported(requiredMacOSVersion: "15.0", currentMacOSVersion: "14.6.0") + ) + } + + func testMissingMinorAndPatchDefaultToZero() { + let version = XcodeCompatibilityService().operatingSystemVersion(from: "14") + + XCTAssertEqual(version.majorVersion, 14) + XCTAssertEqual(version.minorVersion, 0) + XCTAssertEqual(version.patchVersion, 0) + } + + func testInvalidVersionComponentsDefaultToZero() { + let version = XcodeCompatibilityService().operatingSystemVersion(from: "14.beta.2") + + XCTAssertEqual(version.majorVersion, 14) + XCTAssertEqual(version.minorVersion, 2) + XCTAssertEqual(version.patchVersion, 0) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListItemTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListItemTests.swift new file mode 100644 index 00000000..bb6d6758 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListItemTests.swift @@ -0,0 +1,53 @@ +import XCTest +@preconcurrency import Path +import Version +@testable import XcodesKit + +final class XcodeListItemTests: XCTestCase { + func testInstalledPathReturnsPathFromInstallState() throws { + let path = try XCTUnwrap(Path("/Applications/Xcode.app")) + let item = XcodeListItem( + version: try XCTUnwrap(Version("15.0.0")), + installState: .installed(path), + selected: true + ) + + XCTAssertEqual(item.installedPath, path) + XCTAssertEqual(item.installState.installedPath, path) + } + + func testInstalledPathReturnsNilWhenNotInstalled() throws { + let item = XcodeListItem( + version: try XCTUnwrap(Version("15.0.0")), + installState: .notInstalled, + selected: false + ) + + XCTAssertNil(item.installedPath) + XCTAssertNil(item.installState.installedPath) + } + + func testDownloadFileSizeStringFormatsFileSize() throws { + let item = XcodeListItem( + version: try XCTUnwrap(Version("15.0.0")), + installState: .notInstalled, + selected: false, + downloadFileSize: 1_500_000_000 + ) + + XCTAssertEqual( + item.downloadFileSizeString, + ByteCountFormatter.string(fromByteCount: 1_500_000_000, countStyle: .file) + ) + } + + func testDownloadFileSizeStringReturnsNilWhenMissing() throws { + let item = XcodeListItem( + version: try XCTUnwrap(Version("15.0.0")), + installState: .notInstalled, + selected: false + ) + + XCTAssertNil(item.downloadFileSizeString) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListStoreTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListStoreTests.swift new file mode 100644 index 00000000..bc7d39e9 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListStoreTests.swift @@ -0,0 +1,104 @@ +import XCTest +@preconcurrency import Path +import Version +import os +@testable import XcodesKit + +final class XcodeListStoreTests: XCTestCase { + func testLoadsCachedXcodesAndUsesCacheDateForUpdatePolicy() throws { + let xcode = try makeXcode(version: "15.0.0") + let cacheDate = Date(timeIntervalSince1970: 1_000) + let cache = AvailableXcodeCache( + cacheFile: try XCTUnwrap(Path("/tmp/xcodes.json")), + contentsAtPath: { _ in try? JSONEncoder().encode([xcode]) }, + attributesOfItem: { _ in [.modificationDate: cacheDate] } + ) + var store = XcodeListStore( + cache: cache, + fetchAvailableXcodes: { _ in [] }, + updatePolicy: XcodeUpdatePolicy(now: { cacheDate.addingTimeInterval(60) }) + ) + + try store.loadCachedAvailableXcodes() + + XCTAssertEqual(store.availableXcodes, [xcode]) + XCTAssertEqual(store.lastUpdated, cacheDate) + XCTAssertFalse(store.shouldUpdateBeforeListingVersions) + } + + func testMissingCacheNeedsUpdate() throws { + let cache = AvailableXcodeCache( + cacheFile: try XCTUnwrap(Path("/tmp/xcodes.json")), + contentsAtPath: { _ in nil } + ) + var store = XcodeListStore(cache: cache, fetchAvailableXcodes: { _ in [] }) + + try store.loadCachedAvailableXcodes() + + XCTAssertTrue(store.shouldUpdateBeforeListingVersions) + } + + func testUpdateFetchesPostprocessesAndSavesXcodes() async throws { + let release = try makeRelease(version: "15.0.0-beta.1+15A1", architectures: nil) + let finalRelease = try makeRelease(version: "15.0.0+15A1", architectures: nil) + let expected = try makeXcode(version: "15.0.0+15A1") + let updateDate = Date(timeIntervalSince1970: 2_000) + let savedXcodes = XcodeCacheSaveRecorder() + let cache = AvailableXcodeCache( + cacheFile: try XCTUnwrap(Path("/tmp/xcodes.json")), + contentsAtPath: { _ in nil }, + writeData: { data, _ in + try savedXcodes.record(data) + }, + createDirectory: { _, _, _ in } + ) + var store = XcodeListStore( + cache: cache, + fetchAvailableXcodes: { dataSource in + XCTAssertEqual(dataSource, .xcodeReleases) + return [release, finalRelease] + }, + now: { updateDate } + ) + + let xcodes = try await store.updateAvailableXcodes(from: .xcodeReleases) + + XCTAssertEqual(xcodes, [expected]) + XCTAssertEqual(store.availableXcodes, [expected]) + XCTAssertEqual(store.lastUpdated, updateDate) + XCTAssertEqual(savedXcodes.value, [expected]) + } + + private func makeXcode(version: String, architectures: [Architecture]? = nil) throws -> AvailableXcode { + AvailableXcode( + version: try XCTUnwrap(Version(version)), + url: try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")), + filename: "Xcode.xip", + releaseDate: nil, + architectures: architectures + ) + } + + private func makeRelease(version: String, architectures: [Architecture]?) throws -> AvailableXcodeRelease { + AvailableXcodeRelease( + version: try XCTUnwrap(Version(version)), + url: try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")), + filename: "Xcode.xip", + releaseDate: nil, + architectures: architectures + ) + } +} + +private final class XcodeCacheSaveRecorder: Sendable { + private let storedValue = OSAllocatedUnfairLock<[AvailableXcode]?>(initialState: nil) + + var value: [AvailableXcode]? { + storedValue.withLock { $0 } + } + + func record(_ data: Data) throws { + let value = try JSONDecoder().decode([AvailableXcode].self, from: data) + storedValue.withLock { $0 = value } + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodePostInstallWorkflowServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodePostInstallWorkflowServiceTests.swift new file mode 100644 index 00000000..ba3557e6 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodePostInstallWorkflowServiceTests.swift @@ -0,0 +1,121 @@ +@preconcurrency import Path +import Version +import XCTest +@testable import XcodesKit + +final class XcodePostInstallWorkflowServiceTests: XCTestCase { + func testPerformPostInstallStepsRunsSharedSequence() async throws { + let xcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!, version: Version("0.0.0")!) + let recorder = StepRecorder() + + let service = XcodePostInstallWorkflowService( + preparationService: XcodePostInstallPreparationService( + enableDeveloperTools: { await recorder.append("enableDeveloperTools") }, + addStaffToDevelopersGroup: { await recorder.append("addStaffToDevelopersGroup") }, + acceptLicense: { receivedXcode in + await recorder.append("acceptLicense", receivedPath: receivedXcode.path) + } + ), + postInstallService: XcodePostInstallService( + runFirstLaunch: { receivedXcode in + await recorder.append("runFirstLaunch", receivedPath: receivedXcode.path) + }, + getUserCacheDirectory: { + await recorder.append("getUserCacheDirectory") + return ProcessOutput(status: 0, out: "cache", err: "") + }, + getMacOSBuildVersion: { + await recorder.append("getMacOSBuildVersion") + return ProcessOutput(status: 0, out: "macOS", err: "") + }, + getXcodeBuildVersion: { receivedXcode in + await recorder.append("getXcodeBuildVersion", receivedPath: receivedXcode.path) + return ProcessOutput(status: 0, out: "tools", err: "") + }, + touchInstallCheck: { cacheDirectory, macOSBuildVersion, toolsVersion in + XCTAssertEqual(cacheDirectory, "cache") + XCTAssertEqual(macOSBuildVersion, "macOS") + XCTAssertEqual(toolsVersion, "tools") + await recorder.append("touchInstallCheck") + return ProcessOutput(status: 0, out: "", err: "") + } + ) + ) + + try await service.performPostInstallSteps(for: xcode) + + let steps = await recorder.steps + XCTAssertEqual(Array(steps.prefix(3)), [ + "enableDeveloperTools", + "addStaffToDevelopersGroup", + "acceptLicense", + ]) + XCTAssertEqual(steps.last, "touchInstallCheck") + XCTAssertTrue(steps.contains("runFirstLaunch")) + XCTAssertTrue(steps.contains("getUserCacheDirectory")) + XCTAssertTrue(steps.contains("getMacOSBuildVersion")) + XCTAssertTrue(steps.contains("getXcodeBuildVersion")) + let receivedPaths = await recorder.receivedPaths + XCTAssertEqual(receivedPaths, [xcode.path, xcode.path, xcode.path]) + } + + func testPostInstallServiceStopsAfterFirstLaunchWhenCancelled() async throws { + let xcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!, version: Version("0.0.0")!) + let recorder = StepRecorder() + let firstLaunchContinuation = OneShotContinuation() + let service = XcodePostInstallService( + runFirstLaunch: { _ in + await recorder.append("runFirstLaunch") + try await withCheckedThrowingContinuation { continuation in + firstLaunchContinuation.setContinuation(continuation) + } + }, + getUserCacheDirectory: { + await recorder.append("getUserCacheDirectory") + return ProcessOutput(status: 0, out: "cache", err: "") + }, + getMacOSBuildVersion: { + await recorder.append("getMacOSBuildVersion") + return ProcessOutput(status: 0, out: "macOS", err: "") + }, + getXcodeBuildVersion: { _ in + await recorder.append("getXcodeBuildVersion") + return ProcessOutput(status: 0, out: "tools", err: "") + }, + touchInstallCheck: { _, _, _ in + await recorder.append("touchInstallCheck") + return ProcessOutput(status: 0, out: "", err: "") + } + ) + + let task = Task { + try await service.installComponents(for: xcode) + } + for _ in 0..<100 where await recorder.steps.isEmpty { + await Task.yield() + } + task.cancel() + firstLaunchContinuation.resume() + + do { + try await task.value + XCTFail("Expected cancellation") + } catch is CancellationError { + } + + let steps = await recorder.steps + XCTAssertEqual(steps, ["runFirstLaunch"]) + } +} + +private actor StepRecorder { + private(set) var steps = [String]() + private(set) var receivedPaths = [Path]() + + func append(_ step: String, receivedPath: Path? = nil) { + steps.append(step) + if let receivedPath { + receivedPaths.append(receivedPath) + } + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeVersionFileServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeVersionFileServiceTests.swift new file mode 100644 index 00000000..30b0154f --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeVersionFileServiceTests.swift @@ -0,0 +1,38 @@ +@preconcurrency import Path +import Version +import XCTest +@testable import XcodesKit + +final class XcodeVersionFileServiceTests: XCTestCase { + private let projectPath = Path("/tmp/project")! + + func testVersionParsesGemVersionFile() { + let service = XcodeVersionFileService( + fileExists: { path in path.hasSuffix(".xcode-version") }, + contentsAtPath: { _ in "9.2b3".data(using: .utf8) } + ) + + XCTAssertEqual( + service.version(inDirectory: projectPath), + Version("9.2.0-Beta.3") + ) + } + + func testVersionReturnsNilWhenFileDoesNotExist() { + let service = XcodeVersionFileService( + fileExists: { _ in false }, + contentsAtPath: { _ in XCTFail("Should not read a missing file"); return nil } + ) + + XCTAssertNil(service.version(inDirectory: projectPath)) + } + + func testVersionReturnsNilWhenFileContentsAreInvalid() { + let service = XcodeVersionFileService( + fileExists: { _ in true }, + contentsAtPath: { _ in Data([0xff]) } + ) + + XCTAssertNil(service.version(inDirectory: projectPath)) + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift index 98f0c961..b8c81b34 100644 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift @@ -1,11 +1,2323 @@ import XCTest +@preconcurrency import Path +import Version +import os @testable import XcodesKit final class XcodesKitTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(XcodesKit().text, "Hello, World!") + func testOneShotContinuationReturnsResultCompletedBeforeContinuationIsSet() async throws { + let oneShot = OneShotContinuation() + + oneShot.resume(with: .success("ready")) + let value = try await withCheckedThrowingContinuation { continuation in + oneShot.setContinuation(continuation) + } + + XCTAssertEqual(value, "ready") + } + + func testOneShotContinuationIgnoresRepeatedResume() async throws { + let oneShot = OneShotContinuation() + + oneShot.resume(with: .success("first")) + oneShot.resume(with: .success("second")) + let value = try await withCheckedThrowingContinuation { continuation in + oneShot.setContinuation(continuation) + } + + XCTAssertEqual(value, "first") + } + + func testXcodeInstallRetryServiceRetriesDamagedArchiveOnce() async throws { + let damagedURL = URL(fileURLWithPath: "/tmp/Xcode.xip") + let attempts = RetryRecorder() + let failures = RetryRecorder() + let retries = RetryRecorder() + let removals = URLListRecorder() + let installedXcode = InstalledXcode( + path: try XCTUnwrap(Path("/Applications/Xcode.app")), + version: try XCTUnwrap(Version("15.0.0")) + ) + let service = XcodeInstallRetryService( + damagedArchiveURL: { error in + guard case XcodeInstallRetryTestError.damagedArchive(let url) = error else { return nil } + return url + }, + removeDamagedArchive: { url in + removals.record(url) + } + ) + + let result = try await service.install( + attempt: { _ in + let attempt = await attempts.record(1) + if attempt == 1 { + throw XcodeInstallRetryTestError.damagedArchive(damagedURL) + } + return installedXcode + }, + onAttemptFailed: { error in + await failures.record(String(describing: error)) + }, + onRetryDamagedArchive: { _, url in + await retries.record(url) + } + ) + + let attemptCount = await attempts.count + let failureCount = await failures.count + let retriedURLs = await retries.values + + XCTAssertEqual(result, installedXcode) + XCTAssertEqual(attemptCount, 2) + XCTAssertEqual(failureCount, 1) + XCTAssertEqual(retriedURLs, [damagedURL]) + XCTAssertEqual(removals.paths, [damagedURL.path]) + } + + func testXcodeInstallRetryServiceDoesNotRetryWhenDisabled() async throws { + let damagedURL = URL(fileURLWithPath: "/tmp/Xcode.xip") + let attempts = RetryRecorder() + let removals = URLListRecorder() + let service = XcodeInstallRetryService( + damagedArchiveURL: { error in + guard case XcodeInstallRetryTestError.damagedArchive(let url) = error else { return nil } + return url + }, + removeDamagedArchive: { url in + removals.record(url) + } + ) + + do { + _ = try await service.install( + shouldRetryAfterDamagedArchive: false, + attempt: { _ in + await attempts.record(1) + throw XcodeInstallRetryTestError.damagedArchive(damagedURL) + } + ) + XCTFail("Expected damaged archive error") + } catch XcodeInstallRetryTestError.damagedArchive(let url) { + XCTAssertEqual(url, damagedURL) + } + + let attemptCount = await attempts.count + + XCTAssertEqual(attemptCount, 1) + XCTAssertEqual(removals.paths, []) + } + + func testXcodeSignatureVerifierParsesCertificateInfo() { + let sampleRawInfo = """ + Executable=/Applications/Xcode-10.1.app/Contents/MacOS/Xcode + Identifier=com.apple.dt.Xcode + Format=app bundle with Mach-O thin (x86_64) + CodeDirectory v=20200 size=434 flags=0x2000(library-validation) hashes=6+5 location=embedded + Signature size=4485 + Authority=Software Signing + Authority=Apple Code Signing Certification Authority + Authority=Apple Root CA + Info.plist entries=39 + TeamIdentifier=59GAB85EFG + Sealed Resources version=2 rules=13 files=253327 + Internal requirements count=1 size=68 + """ + + let signature = XcodeSignatureVerifier().parse(sampleRawInfo) + + XCTAssertEqual(signature.authority, XcodeSignatureVerifier.expectedCertificateAuthority) + XCTAssertEqual(signature.teamIdentifier, XcodeSignatureVerifier.expectedTeamIdentifier) + XCTAssertEqual(signature.bundleIdentifier, "com.apple.dt.Xcode") + } + + func testXcodeSignatureVerifierValidatesExpectedAppleSignature() { + let signature = XcodeSignature( + authority: XcodeSignatureVerifier.expectedCertificateAuthority, + teamIdentifier: XcodeSignatureVerifier.expectedTeamIdentifier, + bundleIdentifier: "com.apple.dt.Xcode" + ) + + XCTAssertTrue(XcodeSignatureVerifier().isValid(signature)) + } + + func testXcodeSignatureVerifierRejectsUnexpectedAppleSignature() { + let signature = XcodeSignature( + authority: XcodeSignatureVerifier.expectedCertificateAuthority, + teamIdentifier: "NOTAPPLE", + bundleIdentifier: "com.apple.dt.Xcode" + ) + + XCTAssertFalse(XcodeSignatureVerifier().isValid(signature)) + } + + func testXcodeValidationServiceMapsSecurityAssessmentProcessOutput() async throws { + let xcode = InstalledXcode( + path: try XCTUnwrap(Path("/Applications/Xcode.app")), + version: try XCTUnwrap(Version("15.0.0")) + ) + let service = XcodeValidationService( + assessSecurity: { _ in + throw ProcessExecutionError( + process: Process(), + terminationStatus: 1, + standardOutput: "assessment stdout", + standardError: "assessment stderr" + ) + }, + verifyCodesign: { _ in (0, "", "") } + ) + + do { + try await service.verifySecurityAssessment(of: xcode) + XCTFail("Expected validation to throw") + } catch let error as XcodeValidationError { + XCTAssertEqual(error, .failedSecurityAssessment(xcode: xcode, output: "assessment stdout\nassessment stderr")) + } + } + + func testXcodeValidationServiceMapsCodesignProcessOutput() async throws { + let service = XcodeValidationService( + assessSecurity: { _ in (0, "", "") }, + verifyCodesign: { _ in + throw ProcessExecutionError( + process: Process(), + terminationStatus: 1, + standardOutput: "", + standardError: "codesign stderr" + ) + } + ) + + do { + try await service.verifySigningCertificate(of: URL(fileURLWithPath: "/Applications/Xcode.app")) + XCTFail("Expected validation to throw") + } catch let error as XcodeValidationError { + XCTAssertEqual(error, .codesignVerifyFailed(output: "codesign stderr")) + } + } + + func testXcodeValidationServiceRejectsUnexpectedSigningIdentity() async throws { + let service = XcodeValidationService( + assessSecurity: { _ in (0, "", "") }, + verifyCodesign: { _ in + (0, "", """ + Identifier=com.apple.dt.Xcode + Authority=Software Signing + Authority=Apple Code Signing Certification Authority + Authority=Apple Root CA + TeamIdentifier=NOTAPPLE + """) + } + ) + + do { + try await service.verifySigningCertificate(of: URL(fileURLWithPath: "/Applications/Xcode.app")) + XCTFail("Expected validation to throw") + } catch let error as XcodeValidationError { + XCTAssertEqual(error, .unexpectedCodeSigningIdentity( + identifier: "NOTAPPLE", + certificateAuthority: XcodeSignatureVerifier.expectedCertificateAuthority + )) + } + } + + func testXcodeArchiveInstallServiceInstallsXIPAndValidatesXcode() async throws { + let archiveURL = URL(fileURLWithPath: "/tmp/Xcode.xip") + let destinationDirectory = try XCTUnwrap(Path("/Applications")) + let destinationPath = try XCTUnwrap(Path("/Applications/Xcode-15.0.0.app")) + let recorder = PathOperationRecorder() + let stepRecorder = XcodeArchiveInstallStepRecorder() + let xcode = AvailableXcode( + version: try XCTUnwrap(Version("15.0.0")), + url: archiveURL, + filename: "Xcode.xip", + releaseDate: nil + ) + + let service = XcodeArchiveInstallService( + destinationDirectory: destinationDirectory, + unarchiveService: XcodeUnarchiveService( + unarchive: { url in + XCTAssertEqual(url, archiveURL) + recorder.record("unarchive") + }, + fileExists: { path in + path == "/tmp/Xcode.app" || path == destinationPath.string + }, + moveItem: { source, destination in + XCTAssertEqual(source.path, "/tmp/Xcode.app") + XCTAssertEqual(destination.path, destinationPath.url.path) + recorder.record("move") + }, + removeItem: { _ in } + ), + validationService: XcodeValidationService( + assessSecurity: { url in + XCTAssertEqual(url.path, destinationPath.url.path) + return (0, "", "") + }, + verifyCodesign: { url in + XCTAssertEqual(url.path, destinationPath.url.path) + return (0, "", """ + Identifier=com.apple.dt.Xcode + Authority=Software Signing + Authority=Apple Code Signing Certification Authority + Authority=Apple Root CA + TeamIdentifier=59GAB85EFG + """) + } + ), + fileExists: { path in path == destinationPath.string }, + makeInstalledXcode: { path in + XCTAssertEqual(path, destinationPath) + return InstalledXcode(path: path, version: Version("15.0.0")!) + } + ) + + let installedXcode = try await service.installArchivedXcode( + xcode, + at: archiveURL, + cleanArchive: { url in + XCTAssertEqual(url, archiveURL) + recorder.record("clean") + }, + stepChanged: { step in + await stepRecorder.record(step) + } + ) + + XCTAssertEqual(installedXcode.path, destinationPath) + XCTAssertEqual(recorder.operations, ["unarchive", "move", "clean"]) + let steps = await stepRecorder.recordedSteps() + XCTAssertEqual(steps, [ + .unarchive(.unarchiving), + .unarchive(.moving(destination: destinationPath.url.path)), + .cleaningArchive(archiveName: "Xcode.xip"), + .checkingSecurity + ]) + } + + func testXcodeArchiveInstallServiceRejectsUnsupportedArchives() async throws { + let service = XcodeArchiveInstallService( + destinationDirectory: try XCTUnwrap(Path("/Applications")), + unarchiveService: XcodeUnarchiveService( + unarchive: { _ in XCTFail("Unsupported archives should not be unarchived") }, + fileExists: { _ in false }, + moveItem: { _, _ in }, + removeItem: { _ in } + ), + validationService: XcodeValidationService( + assessSecurity: { _ in (0, "", "") }, + verifyCodesign: { _ in (0, "", "") } + ), + fileExists: { _ in false }, + makeInstalledXcode: { _ in nil } + ) + let xcode = AvailableXcode( + version: try XCTUnwrap(Version("15.0.0")), + url: URL(fileURLWithPath: "/tmp/Xcode.dmg"), + filename: "Xcode.dmg", + releaseDate: nil + ) + + do { + _ = try await service.installArchivedXcode( + xcode, + at: URL(fileURLWithPath: "/tmp/Xcode.dmg"), + cleanArchive: { _ in XCTFail("Unsupported archives should not be cleaned") } + ) + XCTFail("Expected unsupported archive to throw") + } catch let error as XcodeArchiveInstallError { + XCTAssertEqual(error, .unsupportedFileFormat(extension: "dmg")) + } + } + + func testXcodeUpdatePolicyUsesFiveHourFreshnessWindow() { + let now = Date(timeIntervalSince1970: 10_000) + let policy = XcodeUpdatePolicy(now: { now }) + let cachedXcodes = [ + AvailableXcode( + version: Version("15.0.0")!, + url: URL(fileURLWithPath: "/tmp/Xcode.xip"), + filename: "Xcode.xip", + releaseDate: nil + ) + ] + + XCTAssertFalse(policy.shouldUpdate( + cachedXcodes: cachedXcodes, + lastUpdated: now.addingTimeInterval(-XcodeUpdatePolicy.defaultMaximumCacheAge + 1) + )) + XCTAssertTrue(policy.shouldUpdate( + cachedXcodes: cachedXcodes, + lastUpdated: now.addingTimeInterval(-XcodeUpdatePolicy.defaultMaximumCacheAge - 1) + )) + } + + func testXcodeUpdatePolicyUpdatesWhenCacheIsEmptyOrMissingDate() { + let policy = XcodeUpdatePolicy(now: { Date(timeIntervalSince1970: 10_000) }) + let cachedXcodes = [ + AvailableXcode( + version: Version("15.0.0")!, + url: URL(fileURLWithPath: "/tmp/Xcode.xip"), + filename: "Xcode.xip", + releaseDate: nil + ) + ] + + XCTAssertTrue(policy.shouldUpdate(cachedXcodes: [], lastUpdated: Date())) + XCTAssertTrue(policy.shouldUpdate(cachedXcodes: cachedXcodes, lastUpdated: nil)) + } + + func testXcodePostInstallServiceRunsFirstLaunchAndTouchesInstallCheck() async throws { + let xcode = InstalledXcode( + path: try XCTUnwrap(Path("/Applications/Xcode.app")), + version: try XCTUnwrap(Version("15.0.0+15A1")) + ) + let recorder = XcodePostInstallRecorder() + let service = XcodePostInstallService( + runFirstLaunch: { receivedXcode in + XCTAssertEqual(receivedXcode, xcode) + await recorder.recordFirstLaunch() + }, + getUserCacheDirectory: { (0, "/tmp/cache/", "") }, + getMacOSBuildVersion: { (0, "23A344", "") }, + getXcodeBuildVersion: { receivedXcode in + XCTAssertEqual(receivedXcode, xcode) + return (0, "15A1", "") + }, + touchInstallCheck: { cacheDirectory, macOSBuildVersion, toolsVersion in + await recorder.recordInstallCheck( + cacheDirectory: cacheDirectory, + macOSBuildVersion: macOSBuildVersion, + toolsVersion: toolsVersion + ) + return (0, "", "") + } + ) + + try await service.installComponents(for: xcode) + + let didRunFirstLaunch = await recorder.didRunFirstLaunch + let touchedInstallCheck = await recorder.touchedInstallCheck + XCTAssertTrue(didRunFirstLaunch) + XCTAssertEqual(touchedInstallCheck?.cacheDirectory, "/tmp/cache/") + XCTAssertEqual(touchedInstallCheck?.macOSBuildVersion, "23A344") + XCTAssertEqual(touchedInstallCheck?.toolsVersion, "15A1") + } + + func testXcodePostInstallPreparationServiceEnablesDeveloperModeAndApprovesLicense() async throws { + let xcode = InstalledXcode( + path: try XCTUnwrap(Path("/Applications/Xcode.app")), + version: try XCTUnwrap(Version("15.0.0")) + ) + let recorder = XcodePostInstallPreparationRecorder() + let service = XcodePostInstallPreparationService( + enableDeveloperTools: { + await recorder.record(.enableDeveloperTools) + }, + addStaffToDevelopersGroup: { + await recorder.record(.addStaffToDevelopersGroup) + }, + acceptLicense: { receivedXcode in + XCTAssertEqual(receivedXcode, xcode) + await recorder.record(.acceptLicense) + } + ) + + try await service.enableDeveloperMode() + try await service.approveLicense(for: xcode) + + let events = await recorder.events + XCTAssertEqual(events, [ + .enableDeveloperTools, + .addStaffToDevelopersGroup, + .acceptLicense + ]) + } + + func testXcodeUninstallServiceMovesXcodeToTrash() throws { + let xcode = InstalledXcode( + path: try XCTUnwrap(Path("/Applications/Xcode.app")), + version: try XCTUnwrap(Version("15.0.0")) + ) + let recorder = URLRecorder() + let service = XcodeUninstallService( + removeItem: { _ in XCTFail("Remove should not be called") }, + trashItem: { url in + recorder.record(url) + return URL(fileURLWithPath: "/Users/test/.Trash/Xcode.app") + } + ) + + let result = try service.uninstall(xcode, emptyTrash: false) + + XCTAssertEqual(recorder.url, xcode.path.url) + XCTAssertEqual(result.xcode, xcode) + XCTAssertEqual(result.trashURL?.path, "/Users/test/.Trash/Xcode.app") + XCTAssertFalse(result.didDeleteImmediately) + } + + func testXcodeUninstallServiceDeletesXcodeImmediately() throws { + let xcode = InstalledXcode( + path: try XCTUnwrap(Path("/Applications/Xcode.app")), + version: try XCTUnwrap(Version("15.0.0")) + ) + let recorder = URLRecorder() + let service = XcodeUninstallService( + removeItem: { url in recorder.record(url) }, + trashItem: { _ in + XCTFail("Trash should not be called") + return URL(fileURLWithPath: "/Users/test/.Trash/Xcode.app") + } + ) + + let result = try service.uninstall(xcode, emptyTrash: true) + + XCTAssertEqual(recorder.url, xcode.path.url) + XCTAssertEqual(result.xcode, xcode) + XCTAssertNil(result.trashURL) + XCTAssertTrue(result.didDeleteImmediately) + } + + func testXcodeSelectionFilesystemServiceCreatesSymlink() throws { + let recorder = PathOperationRecorder() + let service = XcodeSelectionFilesystemService( + fileExists: { _ in false }, + attributesOfItem: { _ in [:] }, + removeItem: { _ in XCTFail("Remove should not be called") }, + createSymbolicLink: { destination, source in + recorder.record("link:\(destination)->\(source)") + }, + installedXcode: { _ in nil } + ) + + let result = try service.createSymbolicLink( + to: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), + in: try XCTUnwrap(Path("/Applications")) + ) + + XCTAssertEqual(result.destinationPath.string, "/Applications/Xcode.app") + XCTAssertFalse(result.replacedExistingSymlink) + XCTAssertEqual(recorder.operations, ["link:/Applications/Xcode.app->/Applications/Xcode-15.0.app"]) + } + + func testXcodeSelectionFilesystemServiceReplacesExistingSymlink() throws { + let recorder = PathOperationRecorder() + let service = XcodeSelectionFilesystemService( + fileExists: { _ in true }, + attributesOfItem: { _ in [.type: FileAttributeType.typeSymbolicLink] }, + removeItem: { path in recorder.record("remove:\(path)") }, + createSymbolicLink: { destination, source in + recorder.record("link:\(destination)->\(source)") + }, + installedXcode: { _ in nil } + ) + + let result = try service.createSymbolicLink( + to: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), + in: try XCTUnwrap(Path("/Applications")), + isBeta: true + ) + + XCTAssertEqual(result.destinationPath.string, "/Applications/Xcode-Beta.app") + XCTAssertTrue(result.replacedExistingSymlink) + XCTAssertEqual(recorder.operations, [ + "remove:/Applications/Xcode-Beta.app", + "link:/Applications/Xcode-Beta.app->/Applications/Xcode-15.0.app" + ]) + } + + func testXcodeSelectionFilesystemServiceRejectsReplacingRealAppBundleWithSymlink() throws { + let service = XcodeSelectionFilesystemService( + fileExists: { _ in true }, + attributesOfItem: { _ in [.type: FileAttributeType.typeDirectory] }, + removeItem: { _ in XCTFail("Remove should not be called") }, + createSymbolicLink: { _, _ in XCTFail("Link should not be called") }, + installedXcode: { _ in nil } + ) + let expectedPath = try XCTUnwrap(Path("/Applications/Xcode.app")) + + XCTAssertThrowsError(try service.createSymbolicLink( + to: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), + in: try XCTUnwrap(Path("/Applications")) + )) { error in + XCTAssertEqual( + error as? XcodeSelectionFilesystemError, + .destinationExistsAndIsNotSymlink(expectedPath) + ) + } + } + + func testXcodeSelectionFilesystemServiceRenamesExistingXcodeAppBeforeSelectionRename() throws { + let recorder = PathOperationRecorder() + let destinationPath = try XCTUnwrap(Path("/Applications/Xcode.app")) + let selectedPath = try XCTUnwrap(Path("/Applications/Xcode-15.1.app")) + let service = XcodeSelectionFilesystemService( + fileExists: { $0 == destinationPath.string }, + installedXcode: { path in + XCTAssertEqual(path, destinationPath) + return InstalledXcode( + path: path, + version: try! XCTUnwrap(Version("14.3.1")) + ) + }, + rename: { path, newName in + recorder.record("rename:\(path.string)->\(newName)") + return path.parent/newName + } + ) + + let renamedPath = try service.renameForSelection( + installedXcodePath: selectedPath, + in: try XCTUnwrap(Path("/Applications")) + ) + + XCTAssertEqual(renamedPath.string, "/Applications/Xcode.app") + XCTAssertEqual(recorder.operations, [ + "rename:/Applications/Xcode.app->Xcode-14.3.1.app", + "rename:/Applications/Xcode-15.1.app->Xcode.app" + ]) + } + + func testXcodeSelectionServiceSelectsInstalledVersion() throws { + let first = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) + let second = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.1.app")), version: Version("15.1.0")!) + let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + let request = service.request( + pathOrVersion: "15.1", + installedXcodes: [first, second], + selectedXcodePath: "\(first.path.string)/Contents/Developer" + ) + + XCTAssertEqual(request, .selectInstalledXcode(second)) + } + + func testXcodeSelectionServiceDetectsAlreadySelectedVersion() throws { + let xcode = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) + let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + let request = service.request( + pathOrVersion: "15.0", + installedXcodes: [xcode], + selectedXcodePath: "\(xcode.path.string)/Contents/Developer" + ) + + XCTAssertEqual(request, .alreadySelectedVersion(Version("15.0.0")!)) + } + + func testXcodeSelectionServiceUsesVersionFileWhenNoArgumentIsProvided() throws { + let xcode = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) + let service = XcodeSelectionService(versionFile: XcodeVersionFileService( + fileExists: { $0 == "/project/.xcode-version" }, + contentsAtPath: { _ in Data("15.0\n".utf8) } + )) + + let request = service.request( + pathOrVersion: "", + installedXcodes: [xcode], + selectedXcodePath: "", + versionFileDirectory: try XCTUnwrap(Path("/project")) + ) + + XCTAssertEqual(request, .selectInstalledXcode(xcode)) + } + + func testXcodeSelectionServiceFallsBackToPathSelection() { + let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + let request = service.request( + pathOrVersion: " /Applications/Xcode.app\n", + installedXcodes: [], + selectedXcodePath: "" + ) + + XCTAssertEqual(request, .selectPath("/Applications/Xcode.app")) + } + + func testXcodeSelectionServiceChoosesInstalledXcodeBySelectionNumber() throws { + let first = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) + let second = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.1.app")), version: Version("15.1.0")!) + let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + let selected = try service.installedXcode(fromSelection: "2", installedXcodes: [second, first]) + + XCTAssertEqual(selected, second) + } + + func testXcodeSelectionServiceRejectsInvalidSelectionNumber() throws { + let xcode = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) + let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + XCTAssertThrowsError(try service.installedXcode(fromSelection: "3", installedXcodes: [xcode])) { error in + XCTAssertEqual(error as? XcodeSelectionError, .invalidIndex(min: 1, max: 1, given: "3")) + } + } + + func testXcodeInstallResolutionServiceSelectsLatestReleaseVersion() throws { + let release = AvailableXcode(version: Version("15.0.0")!, url: URL(fileURLWithPath: "/Xcode-15.xip"), filename: "Xcode-15.xip", releaseDate: nil) + let prerelease = AvailableXcode(version: Version("16.0.0-beta.1")!, url: URL(fileURLWithPath: "/Xcode-16-beta.xip"), filename: "Xcode-16-beta.xip", releaseDate: Date()) + let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + let resolution = try service.resolve( + .latest, + availableXcodes: [prerelease, release], + installedXcodes: [], + willInstall: true + ) + + XCTAssertEqual(resolution, .download(version: release.version, resolvedXcode: release)) + } + + func testXcodeInstallResolutionServiceSelectsLatestPrereleaseByReleaseDate() throws { + let older = AvailableXcode(version: Version("16.0.0-beta.2")!, url: URL(fileURLWithPath: "/older.xip"), filename: "older.xip", releaseDate: Date(timeIntervalSince1970: 1)) + let newer = AvailableXcode(version: Version("16.0.0-beta.1")!, url: URL(fileURLWithPath: "/newer.xip"), filename: "newer.xip", releaseDate: Date(timeIntervalSince1970: 2)) + let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + let resolution = try service.resolve( + .latestPrerelease, + availableXcodes: [newer, older], + installedXcodes: [], + willInstall: true + ) + + XCTAssertEqual(resolution, .download(version: newer.version, resolvedXcode: newer)) + } + + func testXcodeInstallResolutionServiceRejectsLatestPrereleaseWithoutReleaseDate() throws { + let prerelease = AvailableXcode(version: Version("16.0.0-beta.1")!, url: URL(fileURLWithPath: "/beta.xip"), filename: "beta.xip", releaseDate: nil) + let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + XCTAssertThrowsError(try service.resolve( + .latestPrerelease, + availableXcodes: [prerelease], + installedXcodes: [], + willInstall: true + )) { error in + XCTAssertEqual(error as? XcodeInstallResolutionError, .noPrereleaseVersionAvailable) + } + } + + func testXcodeInstallResolutionServiceRejectsInstalledVersionWhenInstalling() throws { + let installed = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) + let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + XCTAssertThrowsError(try service.resolve( + .version("15.0"), + availableXcodes: [], + installedXcodes: [installed], + willInstall: true + )) { error in + XCTAssertEqual(error as? XcodeInstallResolutionError, .versionAlreadyInstalled(installed)) + } + } + + func testXcodeInstallResolutionServiceRejectsInstalledAvailableXcodeWhenInstalling() throws { + let available = AvailableXcode(version: Version("15.0.0")!, url: URL(fileURLWithPath: "/Xcode-15.xip"), filename: "Xcode-15.xip", releaseDate: nil) + let installed = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) + let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + XCTAssertThrowsError(try service.resolve( + .availableXcode(available), + availableXcodes: [], + installedXcodes: [installed], + willInstall: true + )) { error in + XCTAssertEqual(error as? XcodeInstallResolutionError, .versionAlreadyInstalled(installed)) + } + } + + func testXcodeInstallResolutionServiceResolvesAvailableXcodeForInstall() throws { + let available = AvailableXcode(version: Version("15.0.0")!, url: URL(fileURLWithPath: "/Xcode-15.xip"), filename: "Xcode-15.xip", releaseDate: nil) + let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + let resolution = try service.resolve( + .availableXcode(available), + availableXcodes: [], + installedXcodes: [], + willInstall: true + ) + + XCTAssertEqual(resolution, .download(version: available.version, resolvedXcode: available)) + } + + func testXcodeInstallResolutionServiceAllowsInstalledVersionWhenOnlyDownloading() throws { + let installed = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) + let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) + + let resolution = try service.resolve( + .version("15.0"), + availableXcodes: [], + installedXcodes: [installed], + willInstall: false + ) + + XCTAssertEqual(resolution, .download(version: Version("15.0.0")!, resolvedXcode: nil)) + } + + func testXcodeInstallResolutionServiceUsesVersionFileForPathArchive() throws { + let archivePath = try XCTUnwrap(Path("/tmp/Xcode.xip")) + let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService( + fileExists: { $0 == "/project/.xcode-version" }, + contentsAtPath: { _ in Data("15.1\n".utf8) } + )) + + let resolution = try service.resolve( + .path(versionString: "", path: archivePath), + availableXcodes: [], + installedXcodes: [], + willInstall: true, + versionFileDirectory: try XCTUnwrap(Path("/project")) + ) + + XCTAssertEqual( + resolution, + .localArchive( + AvailableXcode(version: Version("15.1.0")!, url: archivePath.url, filename: "Xcode.xip", releaseDate: nil), + archivePath.url + ) + ) + } + + func testArchiveCancellationCleanupServiceRemovesXcodeArchiveAndAria2Metadata() throws { + let recorder = URLListRecorder() + let xcode = AvailableXcode( + version: try XCTUnwrap(Version("15.0.0")), + url: try XCTUnwrap(URL(string: "https://example.com/Xcode_15.xip")), + filename: "Xcode_15.xip", + releaseDate: nil + ) + let service = ArchiveCancellationCleanupService { url in + recorder.record(url) + } + + service.cleanupXcodeArchive( + for: xcode, + applicationSupportPath: try XCTUnwrap(Path("/tmp/xcodes")) + ) + + XCTAssertEqual(recorder.paths, [ + "/tmp/xcodes/Xcode-15.0.0.xip", + "/tmp/xcodes/Xcode-15.0.0.xip.aria2" + ]) + } + + func testArchiveCancellationCleanupServiceRemovesRuntimeArchiveAndAria2Metadata() throws { + let recorder = URLListRecorder() + let runtime = downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg") + let service = ArchiveCancellationCleanupService { url in + recorder.record(url) + } + + service.cleanupRuntimeArchive( + for: runtime, + destinationDirectory: try XCTUnwrap(Path("/tmp/xcodes")) + ) + + XCTAssertEqual(recorder.paths, [ + "/tmp/xcodes/iOS_16_Runtime.dmg", + "/tmp/xcodes/iOS_16_Runtime.dmg.aria2" + ]) + } + + func testAttemptResumableTaskRetriesWithResumeData() async throws { + let retryResumeData = Data("resume".utf8) + let recorder = RetryRecorder() + + let result = try await attemptResumableTask(delayBeforeRetry: .zero) { resumeData in + let attempts = await recorder.record(resumeData) + + if attempts == 1 { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNetworkConnectionLost, + userInfo: [NSURLSessionDownloadTaskResumeData: retryResumeData] + ) + } + + return "finished" + } + + XCTAssertEqual(result, "finished") + let receivedResumeData = await recorder.values + XCTAssertEqual(receivedResumeData, [nil, retryResumeData]) + } + + func testAttemptResumableTaskDoesNotRetryRejectedError() async throws { + let resumeData = Data("resume".utf8) + let recorder = RetryRecorder() + let expectedError = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorUserCancelledAuthentication, + userInfo: [NSURLSessionDownloadTaskResumeData: resumeData] + ) + + do { + _ = try await attemptResumableTask( + delayBeforeRetry: .zero, + shouldRetry: { _ in false }, + { _ in + await recorder.record(nil) + throw expectedError + } + ) as String + XCTFail("Expected rejected retry to throw") + } catch { + let attempts = await recorder.count + XCTAssertEqual(attempts, 1) + XCTAssertEqual(error as NSError, expectedError) + } + } + + func testAttemptRetryableTaskRetriesApprovedError() async throws { + let recorder = RetryRecorder() + + let result = try await attemptRetryableTask(delayBeforeRetry: .zero) { + let attempts = await recorder.record(()) + + if attempts == 1 { + throw URLError(.networkConnectionLost) + } + + return "finished" + } + + XCTAssertEqual(result, "finished") + let attempts = await recorder.count + XCTAssertEqual(attempts, 2) + } + + func testArchiveDownloadServiceURLSessionUsesPersistedResumeData() async throws { + let persistedResumeData = Data("persisted".utf8) + let recorder = DownloadRecorder() + let resumeDataPath = try XCTUnwrap(Path("/tmp/Xcode-15.resumedata")) + let destination = try XCTUnwrap(Path("/tmp/Xcode-15.xip")) + let downloadURL = try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")) + let service = ArchiveDownloadService( + aria2Download: { _, _, _, _ in + XCTFail("Aria2 should not be used") + return AsyncThrowingStream { $0.finish() } + }, + urlSessionDownload: { url, destination, resumeData in + recorder.recordURLSession(url: url, destination: destination, resumeData: resumeData) + return ( + Progress(totalUnitCount: 10), + Task { + ( + saveLocation: destination, + response: try XCTUnwrap(URLResponse(url: url, mimeType: nil, expectedContentLength: 0, textEncodingName: nil)) + ) + } + ) + }, + contentsAtPath: { path in + path == resumeDataPath.string ? persistedResumeData : nil + }, + createFile: { _, _ in XCTFail("Resume data should not be persisted on success") }, + removeItem: { url in recorder.recordRemovedURL(url) } + ) + + let url = try await service.downloadWithURLSession( + url: downloadURL, + destination: destination, + resumeDataPath: resumeDataPath, + progressChanged: { recorder.recordProgress($0) } + ) + + XCTAssertEqual(url, destination.url) + XCTAssertEqual(recorder.urlSessionResumeData, persistedResumeData) + XCTAssertEqual(recorder.removedURLs.map(\.path), [resumeDataPath.string]) + XCTAssertEqual(recorder.progressCount, 1) + } + + func testArchiveDownloadServiceURLSessionPersistsResumeDataOnFailure() async throws { + let failedResumeData = Data("failed".utf8) + let recorder = DownloadRecorder() + let resumeDataPath = try XCTUnwrap(Path("/tmp/Xcode-15.resumedata")) + let expectedError = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNetworkConnectionLost, + userInfo: [NSURLSessionDownloadTaskResumeData: failedResumeData] + ) + let service = ArchiveDownloadService( + aria2Download: { _, _, _, _ in + XCTFail("Aria2 should not be used") + return AsyncThrowingStream { $0.finish() } + }, + urlSessionDownload: { _, _, _ in + ( + Progress(totalUnitCount: 10), + Task { + throw expectedError + } + ) + }, + contentsAtPath: { _ in nil }, + createFile: { path, data in recorder.recordCreatedFile(path: path, data: data) }, + removeItem: { _ in XCTFail("Resume data should not be removed on failure") }, + shouldRetry: { _ in false } + ) + + do { + _ = try await service.downloadWithURLSession( + url: try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")), + destination: try XCTUnwrap(Path("/tmp/Xcode-15.xip")), + resumeDataPath: resumeDataPath, + progressChanged: { _ in } + ) + XCTFail("Expected URLSession failure") + } catch { + XCTAssertEqual(error as NSError, expectedError) + XCTAssertEqual(recorder.createdFiles.map(\.path), [resumeDataPath.string]) + XCTAssertEqual(recorder.createdFiles.map(\.data), [failedResumeData]) + } + } + + func testArchiveDownloadServiceAria2YieldsProgressAndReturnsDestination() async throws { + let recorder = DownloadRecorder() + let progress = Progress(totalUnitCount: 100) + progress.completedUnitCount = 42 + let destination = try XCTUnwrap(Path("/tmp/Xcode-15.xip")) + let service = ArchiveDownloadService( + aria2Download: { _, _, _, _ in + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) + continuation.yield(progress) + continuation.finish() + return stream + }, + urlSessionDownload: { _, _, _ in + XCTFail("URLSession should not be used") + return (Progress(), Task { throw URLError(.unknown) }) + }, + contentsAtPath: { _ in nil }, + createFile: { _, _ in XCTFail("Resume data should not be persisted") }, + removeItem: { _ in XCTFail("Resume data should not be removed") } + ) + + let url = try await service.downloadWithAria2( + aria2Path: try XCTUnwrap(Path("/usr/bin/aria2c")), + url: try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")), + destination: destination, + cookies: [], + progressChanged: { recorder.recordProgress($0) } + ) + + XCTAssertEqual(url, destination.url) + XCTAssertEqual(recorder.progressCount, 1) + } + + func testArchiveDownloadServiceValidatesDeveloperUnauthorizedRedirect() throws { + enum UnauthorizedTestError: Error, Equatable { + case notAuthorized + } + + let response = try XCTUnwrap(URLResponse( + url: try XCTUnwrap(URL(string: "https://developer.apple.com/unauthorized/")), + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil + )) + + do { + try ArchiveDownloadService.validateDeveloperDownloadResponse( + response, + unauthorizedError: { UnauthorizedTestError.notAuthorized } + ) + XCTFail("Expected unauthorized redirect to throw") + } catch let error as UnauthorizedTestError { + XCTAssertEqual(error, .notAuthorized) + } + } + + func testArchiveDownloadServiceAcceptsDeveloperDownloadResponse() throws { + let response = try XCTUnwrap(URLResponse( + url: try XCTUnwrap(URL(string: "https://download.developer.apple.com/Developer_Tools/Xcode_15/Xcode_15.xip")), + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil + )) + + try ArchiveDownloadService.validateDeveloperDownloadResponse(response) + } + + func testAvailableXcodeReleaseExposesDownloadPath() throws { + let release = AvailableXcodeRelease( + version: try XCTUnwrap(Version("15.0.0")), + url: try XCTUnwrap(URL(string: "https://example.com/Developer_Tools/Xcode_15/Xcode_15.xip")), + filename: "Xcode_15.xip", + releaseDate: nil + ) + + XCTAssertEqual(release.downloadPath, "/Developer_Tools/Xcode_15/Xcode_15.xip") + } + + func testXcodeListServiceFiltersPrereleasesWithDuplicateBuildMetadata() throws { + let release = AvailableXcode( + version: try XCTUnwrap(Version("12.4.0+12D4e")), + url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), + filename: "mock.xip", + releaseDate: nil + ) + let prerelease = AvailableXcode( + version: try XCTUnwrap(Version("12.4.0-RC+12D4e")), + url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), + filename: "mock.xip", + releaseDate: nil + ) + + let filtered = XcodeListService.filteringPrereleasesWithDuplicateBuildMetadata([release, prerelease]) + + XCTAssertEqual(filtered.map(\.version), [release.version]) + XCTAssertEqual(XcodeListService.identicalBuildIDs(for: release, in: [release, prerelease]), [ + release.xcodeID, + prerelease.xcodeID + ]) + } + + func testXcodeListServiceKeepsArchitectureSpecificPrereleaseWithDuplicateBuildMetadata() throws { + let release = AvailableXcode( + version: try XCTUnwrap(Version("16.0.0+16A1")), + url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), + filename: "mock.xip", + releaseDate: nil + ) + let architectureSpecificPrerelease = AvailableXcode( + version: try XCTUnwrap(Version("16.0.0-RC+16A1")), + url: try XCTUnwrap(URL(string: "https://apple.com/xcode-arm64.xip")), + filename: "mock-arm64.xip", + releaseDate: nil, + architectures: [.arm64] + ) + + let filtered = XcodeListService.filteringPrereleasesWithDuplicateBuildMetadata([ + release, + architectureSpecificPrerelease + ]) + + XCTAssertEqual(filtered.map(\.xcodeID), [ + release.xcodeID, + architectureSpecificPrerelease.xcodeID + ]) + } + + func testXcodeListServiceValidatesDeveloperDownloads() async throws { + let downloads = Downloads( + resultCode: 0, + resultsString: nil, + downloads: [ + Download( + name: "Xcode 15", + files: [Download.File(remotePath: "Developer_Tools/Xcode_15/Xcode_15.xip")], + dateModified: Date() + ) + ] + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) + let data = try encoder.encode(downloads) + let service = XcodeListService { request in + XCTAssertEqual(request.url, URLRequest.developerDownloads.url) + return ( + data, + try XCTUnwrap(HTTPURLResponse(url: try XCTUnwrap(request.url), statusCode: 200, httpVersion: nil, headerFields: nil)) + ) + } + + try await service.validateDeveloperDownloads() + } + + func testXcodeListServiceMapsDeveloperDownloadsErrorResult() async throws { + let downloads = Downloads(resultCode: 1, resultsString: "Access denied", downloads: nil) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) + let data = try encoder.encode(downloads) + let service = XcodeListService { request in + ( + data, + try XCTUnwrap(HTTPURLResponse(url: try XCTUnwrap(request.url), statusCode: 200, httpVersion: nil, headerFields: nil)) + ) + } + + do { + try await service.validateDeveloperDownloads() + XCTFail("Expected developer downloads validation to throw") + } catch { + XCTAssertEqual(error as? XcodeListService.Error, .invalidResult("Access denied")) + } + } + + func testXcodeListComposerPreservesInstallingState() throws { + let version = try XCTUnwrap(Version("15.0.0")) + let composer = XcodeListComposer() + + let items = composer.compose( + availableXcodes: [ + AvailableXcode( + version: version, + url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), + filename: "mock.xip", + releaseDate: nil + ) + ], + installedXcodes: [], + selectedXcodePath: nil, + existingXcodes: [ + XcodeListItem( + version: version, + installState: .installing(.unarchiving), + selected: false + ) + ], + dataSource: .xcodeReleases + ) + + XCTAssertEqual(items.map(\.installState), [.installing(.unarchiving)]) + } + + func testXcodeListComposerAdjustsAppleBuildMetadataUsingInstalledXcodes() throws { + let composer = XcodeListComposer() + let installedPath = try XCTUnwrap(Path("/Applications/Xcode.app")) + + let items = composer.compose( + availableXcodes: [ + AvailableXcode( + version: try XCTUnwrap(Version("15.0.0-GM+15A1")), + url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), + filename: "mock.xip", + releaseDate: nil + ) + ], + installedXcodes: [ + InstalledXcode( + path: installedPath, + version: try XCTUnwrap(Version("15.0.0+15A1")) + ) + ], + selectedXcodePath: "\(installedPath.string)/Contents/Developer", + existingXcodes: [], + dataSource: .apple + ) + + XCTAssertEqual(items.map(\.version), [try XCTUnwrap(Version("15.0.0+15A1"))]) + XCTAssertEqual(items.first?.installState, .installed(installedPath)) + XCTAssertEqual(items.first?.selected, true) + } + + func testXcodeListPresentationServiceBuildsAvailableRows() throws { + let selectedPath = try XCTUnwrap(Path("/Applications/Xcode-15.0.app")) + let installedPath = try XCTUnwrap(Path("/Applications/Xcode-14.0.app")) + let service = XcodeListPresentationService() + let newerXcode = AvailableXcode( + version: try XCTUnwrap(Version("15.0.0")), + url: try XCTUnwrap(URL(string: "https://apple.com/xcode-15.xip")), + filename: "xcode-15.xip", + releaseDate: nil + ) + let olderXcode = AvailableXcode( + version: try XCTUnwrap(Version("14.0.0")), + url: try XCTUnwrap(URL(string: "https://apple.com/xcode-14.xip")), + filename: "xcode-14.xip", + releaseDate: nil + ) + + let rows = service.availableRows( + availableXcodes: [newerXcode, olderXcode], + installedXcodes: [ + InstalledXcode(path: selectedPath, version: try XCTUnwrap(Version("15.0.0"))), + InstalledXcode(path: installedPath, version: try XCTUnwrap(Version("14.0.0"))) + ], + selectedXcodePath: "\(selectedPath.string)/Contents/Developer", + dataSource: .xcodeReleases + ) + + XCTAssertEqual(rows.map(\.version), [olderXcode.version, newerXcode.version]) + XCTAssertEqual(rows.map(\.isInstalled), [true, true]) + XCTAssertEqual(rows.map(\.isSelected), [false, true]) + } + + func testXcodeListPresentationServiceFormatsInstalledRows() throws { + let selectedPath = try XCTUnwrap(Path("/Applications/Xcode-15.0.app")) + let service = XcodeListPresentationService() + let rows = service.installedRows( + installedXcodes: [ + InstalledXcode(path: selectedPath, version: try XCTUnwrap(Version("15.0.0"))), + InstalledXcode( + path: try XCTUnwrap(Path("/Applications/Xcode-14.0.app")), + version: try XCTUnwrap(Version("14.0.0")) + ) + ], + selectedXcodePath: "\(selectedPath.string)/Contents/Developer" + ) + + XCTAssertEqual(rows.map(\.version), [ + try XCTUnwrap(Version("14.0.0")), + try XCTUnwrap(Version("15.0.0")) + ]) + XCTAssertEqual(service.installedLines(rows: rows, interactive: false), [ + "14.0\t/Applications/Xcode-14.0.app", + "15.0 (Selected)\t/Applications/Xcode-15.0.app" + ]) + XCTAssertEqual(service.installedLines(rows: rows, interactive: true), [ + "14.0 /Applications/Xcode-14.0.app", + "15.0 (Selected) /Applications/Xcode-15.0.app" + ]) + } + + + func testXcodeArchiveServiceUsesExistingArchiveWhenPresent() async throws { + let archive = XcodeArchive( + version: try XCTUnwrap(Version("15.0.0")), + downloadURL: try XCTUnwrap(URL(string: "https://apple.com/Xcode.xip")), + filename: "Xcode.xip" + ) + let service = XcodeArchiveService( + applicationSupportPath: try XCTUnwrap(Path("/tmp")), + fileExists: { $0.string == "/tmp/Xcode-15.0.0.xip" }, + download: { _, _, _, _ in + XCTFail("Expected existing archive to be reused") + return URL(fileURLWithPath: "/tmp/downloaded.xip") + } + ) + + let url = try await service.archiveURL(for: archive, downloader: .urlSession, progressChanged: { _ in }) + + XCTAssertEqual(url.path, "/tmp/Xcode-15.0.0.xip") + } + + func testXcodeArchiveServiceRedownloadsIncompleteAria2Archive() async throws { + let archive = XcodeArchive( + version: try XCTUnwrap(Version("15.0.0")), + downloadURL: try XCTUnwrap(URL(string: "https://apple.com/Xcode.xip")), + filename: "Xcode.xip" + ) + let service = XcodeArchiveService( + applicationSupportPath: try XCTUnwrap(Path("/tmp")), + fileExists: { path in + path.string == "/tmp/Xcode-15.0.0.xip" || path.string == "/tmp/Xcode-15.0.0.xip.aria2" + }, + download: { archive, destination, downloader, _ in + XCTAssertEqual(archive.version, Version("15.0.0")!) + XCTAssertEqual(destination.string, "/tmp/Xcode-15.0.0.xip") + XCTAssertEqual(downloader, .aria2) + return URL(fileURLWithPath: "/tmp/redownloaded.xip") + } + ) + + let url = try await service.archiveURL(for: archive, downloader: .aria2, progressChanged: { _ in }) + + XCTAssertEqual(url.path, "/tmp/redownloaded.xip") + } + + func testRuntimeServiceMountDMGParsesMountPoint() async throws { + let service = RuntimeService( + loadData: { _ in throw URLError(.badServerResponse) }, + installedRuntimesOutput: { XCTFail("Installed runtime loader should not be called"); return (0, "", "") }, + installRuntimeImageOutput: { _ in XCTFail("Install runtime image should not be called"); return (0, "", "") }, + mountDMGOutput: { url in + XCTAssertEqual(url.path, "/tmp/runtime.dmg") + return (0, """ + + system-entities + + + + + mount-point + /Volumes/Runtime + + + + """, "") + }, + unmountDMGOutput: { _ in XCTFail("Unmount should not be called"); return (0, "", "") } + ) + + let mountedURL = try await service.mountDMG(dmgUrl: URL(fileURLWithPath: "/tmp/runtime.dmg")) + + XCTAssertEqual(mountedURL.path, "/Volumes/Runtime") + } + + func testRuntimeServiceMountDMGThrowsWhenMountPointIsMissing() async throws { + let service = RuntimeService( + loadData: { _ in throw URLError(.badServerResponse) }, + installedRuntimesOutput: { XCTFail("Installed runtime loader should not be called"); return (0, "", "") }, + installRuntimeImageOutput: { _ in XCTFail("Install runtime image should not be called"); return (0, "", "") }, + mountDMGOutput: { _ in + return (0, """ + + system-entities + + + + + + """, "") + }, + unmountDMGOutput: { _ in XCTFail("Unmount should not be called"); return (0, "", "") } + ) + + do { + _ = try await service.mountDMG(dmgUrl: URL(fileURLWithPath: "/tmp/runtime.dmg")) + XCTFail("Expected missing mount point to throw") + } catch { + XCTAssertEqual(error as? RuntimeService.Error, .failedMountingDMG) + } + } + + func testRuntimeServiceUsesInjectedPackageOperations() async throws { + let packagePath = try XCTUnwrap(Path("/tmp/runtime.pkg")) + let expandedPackagePath = try XCTUnwrap(Path("/tmp/runtime-expanded.pkg")) + let recorder = PathOperationRecorder() + let service = RuntimeService( + loadData: { _ in throw URLError(.badServerResponse) }, + installedRuntimesOutput: { XCTFail("Installed runtime loader should not be called"); return (0, "", "") }, + installRuntimeImageOutput: { _ in XCTFail("Install runtime image should not be called"); return (0, "", "") }, + mountDMGOutput: { _ in XCTFail("Mount should not be called"); return (0, "", "") }, + unmountDMGOutput: { _ in XCTFail("Unmount should not be called"); return (0, "", "") }, + expandPkgOutput: { source, destination in + XCTAssertEqual(source.path, packagePath.url.path) + XCTAssertEqual(destination.path, expandedPackagePath.url.path) + recorder.record("expanded") + return (0, "", "") + }, + createPkgOutput: { source, destination in + XCTAssertEqual(source.path, packagePath.url.path) + XCTAssertEqual(destination.path, expandedPackagePath.url.path) + recorder.record("created") + return (0, "", "") + }, + installPkgOutput: { packageURL, target in + XCTAssertEqual(packageURL.path, packagePath.url.path) + XCTAssertEqual(target, expandedPackagePath.url.absoluteString) + recorder.record("installed") + return (0, "", "") + } + ) + + try await service.expand(pkgPath: packagePath, expandedPkgPath: expandedPackagePath) + try await service.createPkg(pkgPath: packagePath, expandedPkgPath: expandedPackagePath) + try await service.installPkg(pkgPath: packagePath, expandedPkgPath: expandedPackagePath) + + XCTAssertEqual(recorder.operations, ["expanded", "created", "installed"]) + } + + func testRuntimeServiceDeleteRuntimeMapsProcessError() async throws { + let process = Process() + let service = RuntimeService( + loadData: { _ in throw URLError(.badServerResponse) }, + installedRuntimesOutput: { XCTFail("Installed runtime loader should not be called"); return (0, "", "") }, + installRuntimeImageOutput: { _ in XCTFail("Install runtime image should not be called"); return (0, "", "") }, + mountDMGOutput: { _ in XCTFail("Mount should not be called"); return (0, "", "") }, + unmountDMGOutput: { _ in XCTFail("Unmount should not be called"); return (0, "", "") }, + deleteRuntimeOutput: { identifier in + XCTAssertEqual(identifier, "runtime-id") + throw ProcessExecutionError( + process: process, + terminationStatus: 1, + standardOutput: "", + standardError: "runtime delete failed" + ) + } + ) + + do { + try await service.deleteRuntime(identifier: "runtime-id") + XCTFail("Expected delete runtime to throw") + } catch { + XCTAssertEqual((error as? XcodesKitError)?.message, "runtime delete failed") + } + } + + func testRuntimePackageInstallServiceRewritesPackageInstallLocation() async throws { + let runtime = downloadableRuntime( + source: nil, + simulatorVersion: .init(buildUpdate: "19F70", version: "15.5"), + name: "iOS 15.5" + ) + let diskImageURL = URL(fileURLWithPath: "/tmp/iOS_15_5.dmg") + let mountedURL = URL(fileURLWithPath: "/Volumes/iOS 15.5") + let mountedPackagePath = Path("/Volumes/iOS 15.5/Runtime.pkg")! + let cachesDirectory = Path("/tmp/xcodes-cache")! + let expandedPackagePath = cachesDirectory/runtime.identifier + let repackagedPath = cachesDirectory/(runtime.identifier + ".pkg") + let packageInfo = """ + + + """ + let recorder = RuntimePackageInstallRecorder() + + let service = RuntimePackageInstallService( + mountDMG: { url in + XCTAssertEqual(url, diskImageURL) + recorder.append("mount") + return mountedURL + }, + unmountDMG: { url in + XCTAssertEqual(url, mountedURL) + recorder.append("unmount") + }, + packagePath: { url in + XCTAssertEqual(url, mountedURL) + return mountedPackagePath + }, + prepareDirectory: { path in + XCTAssertEqual(path, cachesDirectory) + recorder.append("prepare") + }, + expandPkg: { packageURL, expandedURL in + XCTAssertEqual(packageURL, mountedPackagePath.url) + XCTAssertEqual(expandedURL, expandedPackagePath.url) + recorder.append("expand") + return (0, "", "") + }, + createPkg: { expandedURL, packageURL in + XCTAssertEqual(expandedURL, expandedPackagePath.url) + XCTAssertEqual(packageURL, repackagedPath.url) + recorder.append("create") + return (0, "", "") + }, + installPkg: { packageURL, target in + XCTAssertEqual(packageURL, repackagedPath.url) + XCTAssertEqual(target, "/") + recorder.append("install") + return (0, "", "") + }, + contentsAtPath: { path in + XCTAssertEqual(path, (expandedPackagePath/"PackageInfo").string) + recorder.append("read") + return Data(packageInfo.utf8) + }, + writeData: { data, url in + XCTAssertEqual(url, (expandedPackagePath/"PackageInfo").url) + recorder.append("write") + recorder.rewrittenPackageInfo = String(data: data, encoding: .utf8) + }, + removeItem: { _ in } + ) + + try await service.installPackageRuntime( + from: diskImageURL, + runtime: runtime, + cachesDirectory: cachesDirectory + ) + + XCTAssertEqual(recorder.steps, ["mount", "prepare", "expand", "unmount", "read", "write", "create", "install"]) + XCTAssertTrue(recorder.rewrittenPackageInfo?.contains(#"install-location="/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 15.5.simruntime""#) == true) + } + + func testRuntimePackageInstallServiceUnmountsWhenExpandFails() async throws { + let runtime = downloadableRuntime( + source: nil, + simulatorVersion: .init(buildUpdate: "19F70", version: "15.5"), + name: "iOS 15.5" + ) + let diskImageURL = URL(fileURLWithPath: "/tmp/iOS_15_5.dmg") + let mountedURL = URL(fileURLWithPath: "/Volumes/iOS 15.5") + let mountedPackagePath = Path("/Volumes/iOS 15.5/Runtime.pkg")! + let cachesDirectory = Path("/tmp/xcodes-cache")! + let recorder = RuntimePackageInstallRecorder() + + let service = RuntimePackageInstallService( + mountDMG: { url in + XCTAssertEqual(url, diskImageURL) + recorder.append("mount") + return mountedURL + }, + unmountDMG: { url in + XCTAssertEqual(url, mountedURL) + recorder.append("unmount") + }, + packagePath: { url in + XCTAssertEqual(url, mountedURL) + return mountedPackagePath + }, + prepareDirectory: { path in + XCTAssertEqual(path, cachesDirectory) + recorder.append("prepare") + }, + expandPkg: { _, _ in + recorder.append("expand") + throw XcodesKitError("expand failed") + }, + createPkg: { _, _ in + XCTFail("Create should not be called") + return (0, "", "") + }, + installPkg: { _, _ in + XCTFail("Install should not be called") + return (0, "", "") + }, + contentsAtPath: { _ in + XCTFail("PackageInfo should not be read") + return nil + }, + writeData: { _, _ in + XCTFail("PackageInfo should not be written") + }, + removeItem: { _ in } + ) + + do { + try await service.installPackageRuntime( + from: diskImageURL, + runtime: runtime, + cachesDirectory: cachesDirectory + ) + XCTFail("Expected package install to fail") + } catch let error as XcodesKitError { + XCTAssertEqual(error.message, "expand failed") + } + + XCTAssertEqual(recorder.steps, ["mount", "prepare", "expand", "unmount"]) + } + + func testRuntimeArchiveInstallServiceInstallsDiskImageAndDeletesArchive() async throws { + let recorder = PathOperationRecorder() + let archiveURL = URL(fileURLWithPath: "/tmp/iOS_16_Runtime.dmg") + let service = RuntimeArchiveInstallService( + installDiskImage: { url in + recorder.record("install:\(url.path)") + }, + removeArchive: { url in + recorder.record("remove:\(url.path)") + } + ) + + try await service.install( + runtime: downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg"), + archiveURL: archiveURL, + stepChanged: { step in + recorder.record("step:\(step)") + } + ) + + XCTAssertEqual(recorder.operations, [ + "step:(2/3) Installing", + "install:/tmp/iOS_16_Runtime.dmg", + "step:(3/3) TrashingArchive", + "remove:/tmp/iOS_16_Runtime.dmg" + ]) + } + + func testRuntimeArchiveInstallServiceCanKeepArchive() async throws { + let recorder = PathOperationRecorder() + let archiveURL = URL(fileURLWithPath: "/tmp/iOS_16_Runtime.dmg") + let service = RuntimeArchiveInstallService( + installDiskImage: { _ in + recorder.record("install") + }, + removeArchive: { _ in + recorder.record("remove") + } + ) + + try await service.install( + runtime: downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg"), + archiveURL: archiveURL, + deleteArchive: false + ) + + XCTAssertEqual(recorder.operations, ["install"]) + } + + func testRuntimeArchiveInstallServiceRejectsUnsupportedArchiveTypes() async throws { + let archiveURL = URL(fileURLWithPath: "/tmp/iOS_15_Runtime.dmg") + let service = RuntimeArchiveInstallService( + installDiskImage: { _ in + XCTFail("Expected unsupported archive to skip disk image install") + }, + removeArchive: { _ in + XCTFail("Expected unsupported archive to skip cleanup") + } + ) + + do { + try await service.install( + runtime: downloadableRuntime( + source: "https://example.com/iOS_15_Runtime.dmg", + contentType: .package + ), + archiveURL: archiveURL + ) + XCTFail("Expected unsupported content type error") + } catch let error as RuntimeArchiveInstallError { + XCTAssertEqual(error, .unsupportedContentType(.package, archiveURL: archiveURL)) + } + } + + func testDownloadableRuntimeCacheLoadsAndSavesRuntimes() throws { + let runtime = downloadableRuntime(source: "https://example.com/iOS.dmg") + let cacheFile = try XCTUnwrap(Path("/tmp/downloadable-runtimes.json")) + let recorder = RuntimeCacheFileRecorder() + let cache = DownloadableRuntimeCache( + cacheFile: cacheFile, + contentsAtPath: { _ in recorder.storedData }, + writeData: { data, url in + recorder.recordWrite(data: data, url: url) + }, + createDirectory: { url, _, _ in + recorder.recordCreatedDirectory(url) + } + ) + + XCTAssertNil(try cache.load()) + + try cache.save([runtime]) + + let loadedRuntimes = try XCTUnwrap(try cache.load()) + XCTAssertEqual(loadedRuntimes, [runtime]) + XCTAssertEqual(recorder.createdDirectory?.path, "/tmp") + XCTAssertEqual(recorder.writtenURL?.path, cacheFile.url.path) + } + + func testDownloadableRuntimesResponseAddsSDKBuildUpdates() { + let response = DownloadableRuntimesResponse( + sdkToSimulatorMappings: [ + SDKToSimulatorMapping( + sdkBuildUpdate: "22A3362", + simulatorBuildUpdate: "20A360", + sdkIdentifier: "iphonesimulator", + downloadableIdentifiers: nil + ) + ], + sdkToSeedMappings: [], + refreshInterval: 3600, + downloadables: [downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg")], + version: "2" + ) + + XCTAssertEqual(response.downloadablesWithSDKBuildUpdates().first?.sdkBuildUpdate, ["22A3362"]) + } + + func testRuntimeListPresentationServiceBuildsInstalledRows() { + let response = DownloadableRuntimesResponse( + sdkToSimulatorMappings: [], + sdkToSeedMappings: [], + refreshInterval: 3600, + downloadables: [downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg")], + version: "2" + ) + let service = RuntimeListPresentationService() + + let rows = service.rows( + downloadableRuntimes: response, + installedRuntimes: [ + installedRuntime(build: "20A360", kind: .diskImage) + ], + includeBetas: false + ) + + XCTAssertEqual(rows.map(\.platform.shortName), ["iOS"]) + XCTAssertEqual(rows.first?.runtimes.map { service.line(for: $0) }, [ + "iOS 16.0 (Installed)" + ]) + } + + func testRuntimeListPresentationServiceHidesUnavailableBetas() { + let response = DownloadableRuntimesResponse( + sdkToSimulatorMappings: [], + sdkToSeedMappings: [], + refreshInterval: 3600, + downloadables: [ + downloadableRuntime( + source: "https://example.com/iOS_17_Runtime.dmg", + simulatorVersion: .init(buildUpdate: "21A1", version: "17.0"), + identifier: "com.apple.CoreSimulator.SimRuntime.iOS-17-0-b1", + name: "iOS 17.0 beta" + ) + ], + version: "2" + ) + + let rows = RuntimeListPresentationService().rows( + downloadableRuntimes: response, + installedRuntimes: [], + includeBetas: false + ) + + XCTAssertEqual(rows.first?.runtimes.map(\.visibleIdentifier), []) + } + + func testRuntimeInstallPolicyUsesArchiveForLegacyRuntime() throws { + let method = try RuntimeInstallPolicy().installMethod( + for: downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg"), + selectedXcodeVersion: nil + ) + + XCTAssertEqual(method, .archive) + } + + func testRuntimeInstallPolicyRequiresSelectedXcodeForCryptexRuntime() { + let runtime = downloadableRuntime( + source: nil, + contentType: .cryptexDiskImage + ) + + XCTAssertThrowsError(try RuntimeInstallPolicy().installMethod(for: runtime, selectedXcodeVersion: nil)) { error in + XCTAssertEqual(error as? RuntimeInstallPolicyError, .noSelectedXcode) + } + } + + func testRuntimeInstallPolicyRequiresXcode26ForAppleSiliconCryptexRuntime() { + let runtime = downloadableRuntime( + source: nil, + architectures: [.arm64], + contentType: .cryptexDiskImage + ) + + XCTAssertThrowsError(try RuntimeInstallPolicy().installMethod(for: runtime, selectedXcodeVersion: Version("16.4.0")!)) { error in + XCTAssertEqual(error as? RuntimeInstallPolicyError, .xcode26OrGreaterRequired(Version("16.4.0")!)) + } + } + + func testRuntimeInstallPolicyUsesXcodebuildForSupportedCryptexRuntime() throws { + let runtime = downloadableRuntime( + source: nil, + architectures: [.arm64], + contentType: .cryptexDiskImage + ) + + let method = try RuntimeInstallPolicy().installMethod( + for: runtime, + selectedXcodeVersion: Version("26.0.0")! + ) + + XCTAssertEqual(method, .xcodebuild(architecture: "arm64")) + } + + func testRuntimeInstallPolicyParsesXcodebuildVersionOutput() { + let version = RuntimeInstallPolicy().selectedXcodeVersion( + fromXcodebuildVersionOutput: """ + Xcode 16.4 + Build version 16F6 + """ + ) + + XCTAssertEqual(version, Version("16.4.0")!) + } + + func testRuntimeXcodebuildInstallServiceDownloadsRuntimeAndYieldsProgress() async throws { + let recorder = PathOperationRecorder() + let service = RuntimeXcodebuildInstallService { platform, buildVersion, architecture in + recorder.record("download:\(platform):\(buildVersion):\(architecture ?? "nil")") + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) + let firstProgress = Progress(totalUnitCount: 100) + firstProgress.completedUnitCount = 25 + let secondProgress = Progress(totalUnitCount: 100) + secondProgress.completedUnitCount = 100 + + continuation.yield(firstProgress) + continuation.yield(secondProgress) + continuation.finish() + + return stream + } + + try await service.downloadAndInstall( + runtime: downloadableRuntime(source: "https://example.com/runtime.dmg"), + architecture: "arm64" + ) { progress in + recorder.record("progress:\(progress.completedUnitCount)") + } + + XCTAssertEqual(recorder.operations, [ + "download:iOS:20A360:arm64", + "progress:25", + "progress:100" + ]) + } + + func testRuntimeXcodebuildInstallServiceStopsProgressWhenCancelled() async throws { + let recorder = PathOperationRecorder() + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) + let service = RuntimeXcodebuildInstallService { platform, buildVersion, architecture in + recorder.record("download:\(platform):\(buildVersion):\(architecture ?? "nil")") + return stream + } + let runtime = downloadableRuntime(source: "https://example.com/runtime.dmg") + + let task = Task { + try await service.downloadAndInstall( + runtime: runtime, + architecture: "arm64" + ) { progress in + recorder.record("progress:\(progress.completedUnitCount)") + } + } + + while recorder.operations.isEmpty { + await Task.yield() + } + + task.cancel() + let progress = Progress(totalUnitCount: 100) + progress.completedUnitCount = 25 + continuation.yield(progress) + continuation.finish() + + do { + try await task.value + XCTFail("Expected cancellation to be thrown") + } catch is CancellationError { + } + + XCTAssertEqual(recorder.operations, [ + "download:iOS:20A360:arm64" + ]) + } + + func testRuntimeArchiveServiceUsesExistingArchiveWhenPresent() async throws { + let runtime = downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg") + let service = RuntimeArchiveService( + fileExists: { $0.string == "/tmp/iOS_16_Runtime.dmg" }, + download: { _, _, _, _, _ in + XCTFail("Expected existing runtime archive to be reused") + return URL(fileURLWithPath: "/tmp/downloaded.dmg") + } + ) + + let url = try await service.archiveURL( + for: runtime, + destinationDirectory: try XCTUnwrap(Path("/tmp")), + downloader: .aria2, + progressChanged: { _ in } + ) + + XCTAssertEqual(url.path, "/tmp/iOS_16_Runtime.dmg") + } + + func testRuntimeArchiveServiceRedownloadsIncompleteAria2Archive() async throws { + let runtime = downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg") + let service = RuntimeArchiveService( + fileExists: { path in + path.string == "/tmp/iOS_16_Runtime.dmg" || path.string == "/tmp/iOS_16_Runtime.dmg.aria2" + }, + download: { runtime, url, destination, downloader, _ in + XCTAssertEqual(runtime.visibleIdentifier, "iOS 16.0") + XCTAssertEqual(url.absoluteString, "https://example.com/iOS_16_Runtime.dmg") + XCTAssertEqual(destination.string, "/tmp/iOS_16_Runtime.dmg") + XCTAssertEqual(downloader, .aria2) + return URL(fileURLWithPath: "/tmp/redownloaded.dmg") + } + ) + + let url = try await service.archiveURL( + for: runtime, + destinationDirectory: try XCTUnwrap(Path("/tmp")), + downloader: .aria2, + progressChanged: { _ in } + ) + + XCTAssertEqual(url.path, "/tmp/redownloaded.dmg") + } + + func testRuntimeInstallationLookupServiceFindsInstalledRuntimeByBuild() { + let runtime = downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg") + let installedRuntime = coreSimulatorImage( + build: runtime.simulatorVersion.buildUpdate, + path: "file:///Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime" + ) + + let service = RuntimeInstallationLookupService() + + XCTAssertEqual( + service.coreSimulatorImage(for: runtime, in: [installedRuntime])?.uuid, + installedRuntime.uuid + ) + XCTAssertEqual( + service.installPath(for: runtime, in: [installedRuntime])?.string, + "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime" + ) + } + + func testRuntimeInstallationLookupServiceMatchesArchitecturesWhenRuntimeRequiresThem() { + let runtime = downloadableRuntime( + source: "https://example.com/iOS_16_Runtime.dmg", + architectures: [.arm64] + ) + let x86Runtime = coreSimulatorImage( + build: runtime.simulatorVersion.buildUpdate, + supportedArchitectures: [.x86_64] + ) + let armRuntime = coreSimulatorImage( + build: runtime.simulatorVersion.buildUpdate, + supportedArchitectures: [.arm64] + ) + + let image = RuntimeInstallationLookupService().coreSimulatorImage( + for: runtime, + in: [x86Runtime, armRuntime] + ) + + XCTAssertEqual(image?.uuid, armRuntime.uuid) + } + + func testProcessProgressStreamRunnerYieldsOutputProgress() async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "printf 'Downloading iOS Simulator: 42.0%% (1.2 GB of 2.8 GB)'"] + let collector = ProcessOutputCollector() + let progress = Progress() + + let stream = ProcessProgressStreamRunner( + process: process, + progress: progress, + outputHandler: { string, progress in + collector.append(string) + progress.updateFromXcodebuild(text: string) + }, + failureHandler: { process in + ProcessExecutionError(process: process, standardOutput: "", standardError: "") + } + ).stream() + + var emittedProgress: [Progress] = [] + for try await progress in stream { + emittedProgress.append(progress) + } + + XCTAssertEqual(collector.output, "Downloading iOS Simulator: 42.0% (1.2 GB of 2.8 GB)") + XCTAssertFalse(emittedProgress.isEmpty) + XCTAssertEqual(progress.fractionCompleted, 0.42, accuracy: 0.001) + } + + func testProcessProgressStreamRunnerDrainsLargeOutputWhileProcessIsRunning() async throws { + let line = String(repeating: "0123456789abcdef", count: 16) + let lineCount = 20_000 + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = [ + "-c", + "yes '\(line)' | head -n \(lineCount)" + ] + + let collector = ProcessOutputCollector() + let stream = ProcessProgressStreamRunner( + process: process, + progress: Progress(), + outputHandler: { string, _ in + collector.append(string) + }, + failureHandler: { process in + ProcessExecutionError(process: process, standardOutput: "", standardError: "") + } + ).stream() + + for try await _ in stream {} + + let expectedOutput = String(repeating: "\(line)\n", count: lineCount) + XCTAssertEqual(collector.output, expectedOutput) + } + + func testProcessProgressStreamRunnerThrowsFailureHandlerError() async { + enum TestError: Error, Equatable { + case failed(Int32) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "exit 12"] + + let stream = ProcessProgressStreamRunner( + process: process, + progress: Progress(), + outputHandler: { _, _ in }, + failureHandler: { process in + TestError.failed(process.terminationStatus) + } + ).stream() + + do { + for try await _ in stream {} + XCTFail("Expected process failure to throw") + } catch { + XCTAssertEqual(error as? TestError, .failed(12)) + } + } + + func testAsyncProcessRunnerDrainsLargeOutputWhileProcessIsRunning() async throws { + let line = String(repeating: "0123456789abcdef", count: 16) + let lineCount = 20_000 + + let output = try await XcodesProcess.run( + URL(fileURLWithPath: "/bin/sh"), + [ + "-c", + "yes '\(line)' | head -n \(lineCount)" + ] + ) + + XCTAssertEqual(output.status, 0) + XCTAssertTrue(output.err.isEmpty) + XCTAssertEqual(output.out.split(separator: "\n").count, lineCount) + } + + private func downloadableRuntime( + source: String?, + architectures: [Architecture]? = nil, + contentType: DownloadableRuntime.ContentType = .diskImage, + simulatorVersion: DownloadableRuntime.SimulatorVersion = .init(buildUpdate: "20A360", version: "16.0"), + identifier: String = "com.apple.CoreSimulator.SimRuntime.iOS-16-0", + name: String = "iOS 16.0" + ) -> DownloadableRuntime { + DownloadableRuntime( + category: .simulator, + simulatorVersion: simulatorVersion, + source: source, + architectures: architectures, + dictionaryVersion: 1, + contentType: contentType, + platform: .iOS, + identifier: identifier, + version: simulatorVersion.version, + fileSize: 42, + hostRequirements: nil, + name: name, + authentication: nil + ) + } + + private func installedRuntime(build: String, kind: InstalledRuntime.Kind) -> InstalledRuntime { + InstalledRuntime( + build: build, + deletable: true, + identifier: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + kind: kind, + lastUsedAt: nil, + path: "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime", + platformIdentifier: .iOS, + runtimeBundlePath: "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-16-0", + signatureState: "Verified", + state: "Ready", + version: "16.0", + sizeBytes: 42, + supportedArchitectures: nil + ) + } + + private func coreSimulatorImage( + build: String, + path: String = "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime", + supportedArchitectures: [Architecture]? = nil + ) -> CoreSimulatorImage { + CoreSimulatorImage( + uuid: UUID().uuidString, + path: ["relative": path], + runtimeInfo: CoreSimulatorRuntimeInfo( + build: build, + supportedArchitectures: supportedArchitectures + ) + ) + } +} + +private actor XcodePostInstallRecorder { + private(set) var didRunFirstLaunch = false + private(set) var touchedInstallCheck: (cacheDirectory: String, macOSBuildVersion: String, toolsVersion: String)? + + func recordFirstLaunch() { + didRunFirstLaunch = true + } + + func recordInstallCheck(cacheDirectory: String, macOSBuildVersion: String, toolsVersion: String) { + touchedInstallCheck = (cacheDirectory, macOSBuildVersion, toolsVersion) + } +} + +private enum XcodeInstallRetryTestError: Error, Equatable { + case damagedArchive(URL) +} + +private actor XcodeArchiveInstallStepRecorder { + private var steps: [XcodeArchiveInstallStep] = [] + + func record(_ step: XcodeArchiveInstallStep) { + steps.append(step) + } + + func recordedSteps() -> [XcodeArchiveInstallStep] { + steps + } +} + +private enum XcodePostInstallPreparationEvent: Equatable { + case enableDeveloperTools + case addStaffToDevelopersGroup + case acceptLicense +} + +private actor XcodePostInstallPreparationRecorder { + private(set) var events: [XcodePostInstallPreparationEvent] = [] + + func record(_ event: XcodePostInstallPreparationEvent) { + events.append(event) + } +} + +private actor RetryRecorder { + private(set) var values: [Value] = [] + + var count: Int { + values.count + } + + @discardableResult + func record(_ value: Value) -> Int { + values.append(value) + return values.count + } +} + +private final class DownloadRecorder: Sendable { + private struct State: Sendable { + var urlSessionResumeData: Data? + var removedURLs: [URL] = [] + var createdFiles: [(path: String, data: Data)] = [] + var progressCount = 0 + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var urlSessionResumeData: Data? { + state.withLock { $0.urlSessionResumeData } + } + + var removedURLs: [URL] { + state.withLock { $0.removedURLs } + } + + var createdFiles: [(path: String, data: Data)] { + state.withLock { $0.createdFiles } + } + + var progressCount: Int { + state.withLock { $0.progressCount } + } + + func recordURLSession(url: URL, destination: URL, resumeData: Data?) { + state.withLock { $0.urlSessionResumeData = resumeData } + } + + func recordRemovedURL(_ url: URL) { + state.withLock { $0.removedURLs.append(url) } + } + + func recordCreatedFile(path: String, data: Data) { + state.withLock { $0.createdFiles.append((path, data)) } + } + + func recordProgress(_ progress: Progress) { + state.withLock { $0.progressCount += 1 } + } +} + +private final class ProcessOutputCollector: Sendable { + private let chunks = OSAllocatedUnfairLock(initialState: [String]()) + + var output: String { + chunks.withLock { $0.joined() } + } + + func append(_ chunk: String) { + chunks.withLock { $0.append(chunk) } + } +} + +private final class URLRecorder: Sendable { + private let storedURL = OSAllocatedUnfairLock(initialState: nil) + + var url: URL? { + storedURL.withLock { $0 } + } + + func record(_ url: URL) { + storedURL.withLock { $0 = url } + } +} + +private final class URLListRecorder: Sendable { + private let storedURLs = OSAllocatedUnfairLock(initialState: [URL]()) + + var paths: [String] { + storedURLs.withLock { $0.map(\.path) } + } + + func record(_ url: URL) { + storedURLs.withLock { $0.append(url) } + } +} + +private final class PathOperationRecorder: Sendable { + private let storedOperations = OSAllocatedUnfairLock(initialState: [String]()) + + var operations: [String] { + storedOperations.withLock { $0 } + } + + func record(_ operation: String) { + storedOperations.withLock { $0.append(operation) } + } +} + +private final class RuntimePackageInstallRecorder: Sendable { + private struct State: Sendable { + var steps: [String] = [] + var packageInfo: String? + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var steps: [String] { + state.withLock { $0.steps } + } + + var rewrittenPackageInfo: String? { + get { + state.withLock { $0.packageInfo } + } + set { + state.withLock { $0.packageInfo = newValue } + } + } + + func append(_ step: String) { + state.withLock { $0.steps.append(step) } + } +} + +private final class RuntimeCacheFileRecorder: Sendable { + private struct State: Sendable { + var data: Data? + var directory: URL? + var url: URL? + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var storedData: Data? { + state.withLock { $0.data } + } + + var createdDirectory: URL? { + state.withLock { $0.directory } + } + + var writtenURL: URL? { + state.withLock { $0.url } + } + + func recordWrite(data: Data, url: URL) { + state.withLock { + $0.data = data + $0.url = url + } + } + + func recordCreatedDirectory(_ url: URL) { + state.withLock { $0.directory = url } } } diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesPathResolverTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesPathResolverTests.swift new file mode 100644 index 00000000..f3faf4ff --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesPathResolverTests.swift @@ -0,0 +1,88 @@ +import XCTest +@preconcurrency import Path +@testable import XcodesKit + +final class XcodesPathResolverTests: XCTestCase { + func testAppApplicationSupportUsesSavedPathWhenValid() throws { + let path = try XCTUnwrap(Path("/tmp/custom-xcodes-support")) + + XCTAssertEqual( + XcodesPathResolver.appApplicationSupport(savedPath: path.string), + path + ) + } + + func testAppApplicationSupportFallsBackToAppDefault() { + XCTAssertEqual( + XcodesPathResolver.appApplicationSupport(savedPath: nil), + XcodesPathResolver.appDefaultApplicationSupport + ) + } + + func testAppInstallDirectoryUsesSavedPathWhenValid() throws { + let path = try XCTUnwrap(Path("/tmp/Xcodes")) + + XCTAssertEqual( + XcodesPathResolver.appInstallDirectory(savedPath: path.string), + path + ) + } + + func testAppInstallDirectoryFallsBackToAppDefault() { + XCTAssertEqual( + XcodesPathResolver.appInstallDirectory(savedPath: nil), + XcodesPathResolver.appDefaultInstallDirectory + ) + } + + func testCacheFilePathsAreDerivedFromApplicationSupport() throws { + let supportPath = try XCTUnwrap(Path("/tmp/xcodes-support")) + + XCTAssertEqual( + XcodesPathResolver.availableXcodesCacheFile(in: supportPath), + supportPath/"available-xcodes.json" + ) + XCTAssertEqual( + XcodesPathResolver.downloadableRuntimesCacheFile(in: supportPath), + supportPath/"downloadable-runtimes.json" + ) + } + + func testCLIPathsAreDerivedFromEnvironmentHome() throws { + let home = try XCTUnwrap(Path("/Users/example")) + + XCTAssertEqual( + XcodesPathResolver.cliHome(environment: ["HOME": home.string]), + home + ) + XCTAssertEqual( + XcodesPathResolver.cliApplicationSupport(home: home), + home/"Library/Application Support/com.robotsandpencils.xcodes" + ) + XCTAssertEqual( + XcodesPathResolver.cliOldApplicationSupport(home: home), + home/"Library/Application Support/ca.brandonevans.xcodes" + ) + XCTAssertEqual( + XcodesPathResolver.cliCaches(home: home), + home/"Library/Caches/com.robotsandpencils.xcodes" + ) + XCTAssertEqual( + XcodesPathResolver.cliDownloads(home: home), + home/"Downloads" + ) + } + + func testCLIConfigurationFileIsDerivedFromApplicationSupport() throws { + let supportPath = try XCTUnwrap(Path("/tmp/xcodes-support")) + + XCTAssertEqual( + XcodesPathResolver.cliAvailableXcodesCacheFile(applicationSupport: supportPath), + supportPath/"available-xcodes.json" + ) + XCTAssertEqual( + XcodesPathResolver.cliConfigurationFile(applicationSupport: supportPath), + supportPath/"configuration.json" + ) + } +} diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 4be9ca32..85a3c3da 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -1,18 +1,36 @@ -import AppleAPI import Combine -import CombineExpectations -import Path +@preconcurrency import Path import Version import XCTest +import XcodesLoginKit import XcodesKit +import os @testable import Xcodes +private final class TestLockedBox: Sendable { + private let storage: OSAllocatedUnfairLock + + init(_ value: Value) { + self.storage = OSAllocatedUnfairLock(initialState: value) + } + + func read(_ body: @Sendable (Value) -> Result) -> Result { + storage.withLock { body($0) } + } + + func withValue(_ body: @Sendable (inout Value) -> Result) -> Result { + storage.withLock { body(&$0) } + } +} + +@MainActor class AppStateTests: XCTestCase { var subject: AppState! override func setUpWithError() throws { Current = .mock + syncXcodesKitMocks() subject = AppState() } @@ -31,42 +49,478 @@ class AppStateTests: XCTestCase { Sealed Resources version=2 rules=13 files=253327 Internal requirements count=1 size=68 """ - let info = subject.parseCertificateInfo(sampleRawInfo) + let info = XcodeSignatureVerifier().parse(sampleRawInfo) XCTAssertEqual(info.authority, ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"]) XCTAssertEqual(info.teamIdentifier, "59GAB85EFG") XCTAssertEqual(info.bundleIdentifier, "com.apple.dt.Xcode") } - func test_VerifySecurityAssessment_Fails() throws { - Current.shell.spctlAssess = { _ in - Fail(error: ProcessExecutionError(process: Process(), standardOutput: "stdout", standardError: "stderr")) - .eraseToAnyPublisher() + func test_PrepareForHelperAction_OnlyRunsActionOnce() { + var responses = [Bool]() + subject.prepareForHelperAction { responses.append($0) } + + let helperAction = subject.isPreparingUserForActionRequiringHelper + helperAction?(true) + helperAction?(false) + + XCTAssertEqual(responses, [true]) + XCTAssertNil(subject.isPreparingUserForActionRequiringHelper) + } + + func test_PrepareForHelperAction_StaleActionDoesNotClearReplacementAction() { + var responses = [Bool]() + subject.prepareForHelperAction { responses.append($0) } + let staleHelperAction = subject.isPreparingUserForActionRequiringHelper + + subject.prepareForHelperAction { responses.append($0) } + let replacementHelperAction = subject.isPreparingUserForActionRequiringHelper + + staleHelperAction?(true) + XCTAssertTrue(responses.isEmpty) + XCTAssertNotNil(subject.isPreparingUserForActionRequiringHelper) + + replacementHelperAction?(false) + XCTAssertEqual(responses, [false]) + XCTAssertNil(subject.isPreparingUserForActionRequiringHelper) + XCTAssertNil(subject.helperActionPreparationID) + } + + func test_RespondToPreparedHelperAction_RunsActionAndClearsAlert() { + var responses = [Bool]() + subject.prepareForHelperAction { responses.append($0) } + + subject.respondToPreparedHelperAction(userConsented: true) + + XCTAssertEqual(responses, [true]) + XCTAssertNil(subject.isPreparingUserForActionRequiringHelper) + XCTAssertNil(subject.helperActionPreparationID) + XCTAssertNil(subject.presentedAlert) + } + + func test_CreateSymbolicLink_UsesProvidedInstalledPath() throws { + let installDirectory = try XCTUnwrap(Path( + NSTemporaryDirectory() + .appending("XcodesAppStateTests-") + .appending(UUID().uuidString) + )) + let installedXcodePath = installDirectory/"Xcode-15.1.app" + let symlinkPath = installDirectory/"Xcode.app" + try FileManager.default.createDirectory(at: installedXcodePath.url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: installDirectory.url) } + + Current.defaults.string = { key in + key == "installPath" ? installDirectory.string : nil + } + + subject.createSymbolicLink(to: installedXcodePath) + + let destination = try FileManager.default.destinationOfSymbolicLink(atPath: symlinkPath.string) + XCTAssertEqual(destination, installedXcodePath.string) + } + + func test_InstallHelperIfNecessary_OldTaskDoesNotClearReplacementTask() async throws { + subject.helperInstallState = .notInstalled + let continuations = TestLockedBox<[CheckedContinuation]>([]) + Current.helper.install = { } + Current.helper.checkIfLatestHelperIsInstalledAsync = { + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + + subject.installHelperIfNecessary(shouldPrepareUserForHelperInstallation: false) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.helperInstallTask) + XCTAssertEqual(continuations.read { $0.count }, 1) + + subject.installHelperIfNecessary(shouldPrepareUserForHelperInstallation: false) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.helperInstallTask) + XCTAssertEqual(continuations.read { $0.count }, 2) + + continuations.read { $0[0] }.resume(returning: false) + await firstTask.value + XCTAssertNotNil(subject.helperInstallTask) + + continuations.read { $0[1] }.resume(returning: true) + await replacementTask.value + XCTAssertNil(subject.helperInstallTask) + XCTAssertNil(subject.helperInstallTaskID) + XCTAssertEqual(subject.helperInstallState, .installed) + } + + func test_PerformPostInstallSteps_OldTaskDoesNotClearReplacementTask() async throws { + subject.helperInstallState = .installed + let firstXcode = InstalledXcode(path: Path("/Applications/Xcode-1.app")!, version: Version("1.0.0")!) + let secondXcode = InstalledXcode(path: Path("/Applications/Xcode-2.app")!, version: Version("2.0.0")!) + let firstLaunchPaths = TestLockedBox<[String]>([]) + let continuations = TestLockedBox<[CheckedContinuation]>([]) + + Current.helper.runFirstLaunchAsync = { path in + firstLaunchPaths.withValue { $0.append(path) } + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + + subject.performPostInstallSteps(for: firstXcode) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.postInstallTask) + XCTAssertEqual(firstLaunchPaths.read { $0 }, [firstXcode.path.string]) + + subject.performPostInstallSteps(for: secondXcode) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.postInstallTask) + XCTAssertEqual(firstLaunchPaths.read { $0 }, [firstXcode.path.string, secondXcode.path.string]) + + continuations.read { $0[0] }.resume() + await firstTask.value + XCTAssertNotNil(subject.postInstallTask) + + continuations.read { $0[1] }.resume() + await replacementTask.value + XCTAssertNil(subject.postInstallTask) + XCTAssertNil(subject.postInstallTaskID) + } + + func test_Select_OldTaskDoesNotClearReplacementTask() async throws { + subject.helperInstallState = .installed + let firstPath = try XCTUnwrap(Path("/Applications/Xcode-1.app")) + let secondPath = try XCTUnwrap(Path("/Applications/Xcode-2.app")) + let firstXcode = Xcode(version: Version("1.0.0")!, installState: .installed(firstPath), selected: false, icon: nil) + let secondXcode = Xcode(version: Version("2.0.0")!, installState: .installed(secondPath), selected: false, icon: nil) + let selectedPaths = TestLockedBox<[String]>([]) + let continuations = TestLockedBox<[CheckedContinuation]>([]) + + Current.helper.switchXcodePathAsync = { path in + selectedPaths.withValue { $0.append(path) } + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + Current.shell.xcodeSelectPrintPath = { + ProcessOutput(status: 0, out: secondPath.string, err: "") + } + + subject.select(xcode: firstXcode, shouldPrepareUserForHelperInstallation: false) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.selectTask) + XCTAssertEqual(selectedPaths.read { $0 }, [firstPath.string]) + + subject.select(xcode: secondXcode, shouldPrepareUserForHelperInstallation: false) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.selectTask) + XCTAssertEqual(selectedPaths.read { $0 }, [firstPath.string, secondPath.string]) + + continuations.read { $0[0] }.resume() + await firstTask.value + XCTAssertNotNil(subject.selectTask) + + continuations.read { $0[1] }.resume() + await replacementTask.value + XCTAssertNil(subject.selectTask) + XCTAssertNil(subject.selectTaskID) + XCTAssertEqual(subject.selectedXcodePath, secondPath.string) + } + + func test_Signout_RemovesCookiesFromDownloadSession() throws { + let session = URLSession(configuration: .ephemeral) + Current.network.session = session + let cookie = try HTTPCookie.xcodesTestCookie(name: "ADCDownloadAuth") + session.configuration.httpCookieStorage?.setCookie(cookie) + XCTAssertEqual(session.configuration.httpCookieStorage?.cookies?.contains(cookie), true) + + subject.signOut() + + XCTAssertEqual(session.configuration.httpCookieStorage?.cookies?.contains(cookie), false) + } + + func test_Signout_RemovesCookiesAfterDownloadSessionIsReplaced() throws { + let initialSession = URLSession(configuration: .ephemeral) + let replacementSession = URLSession(configuration: .ephemeral) + Current.network.session = initialSession + Current.network.session = replacementSession + let cookie = try HTTPCookie.xcodesTestCookie(name: "FASTLANE_SESSION") + replacementSession.configuration.httpCookieStorage?.setCookie(cookie) + XCTAssertEqual(replacementSession.configuration.httpCookieStorage?.cookies?.contains(cookie), true) + + subject.signOut() + + XCTAssertEqual(initialSession.configuration.httpCookieStorage?.cookies?.contains(cookie), false) + XCTAssertEqual(replacementSession.configuration.httpCookieStorage?.cookies?.contains(cookie), false) + } + + func test_NetworkSessionReplacementUpdatesLoginClientSession() { + let initialSession = URLSession(configuration: .ephemeral) + let replacementSession = URLSession(configuration: .ephemeral) + + Current.network.session = initialSession + XCTAssertTrue(Current.network.loginClient.urlSession === initialSession) + + Current.network.session = replacementSession + XCTAssertTrue(Current.network.loginClient.urlSession === replacementSession) + } + + func test_DownloadRuntimeViaXcodeBuild_ClearsRuntimeTaskWhenComplete() async throws { + let runtime = try Self.downloadableRuntime() + subject.downloadableRuntimes = [runtime] + Current.shell.downloadRuntime = { _, _, _ in + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) + continuation.finish() + return stream + } + + subject.downloadRuntimeViaXcodeBuild(runtime: runtime) + let task = try XCTUnwrap(subject.runtimeTasks[runtime.identifier]) + try await task.value + + XCTAssertNil(subject.runtimeTasks[runtime.identifier]) + XCTAssertNil(subject.runtimeTaskIDs[runtime.identifier]) + XCTAssertEqual(subject.downloadableRuntimes.first?.installState, .installed) + } + + func test_DownloadRuntimeViaXcodeBuild_OldTaskDoesNotClearReplacementTask() async throws { + let runtime = try Self.downloadableRuntime() + subject.downloadableRuntimes = [runtime] + let continuations = TestLockedBox<[AsyncThrowingStream.Continuation]>([]) + Current.shell.downloadRuntime = { _, _, _ in + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) + continuations.withValue { $0.append(continuation) } + return stream + } + + subject.downloadRuntimeViaXcodeBuild(runtime: runtime) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.runtimeTasks[runtime.identifier]) + XCTAssertEqual(continuations.read { $0.count }, 1) + + subject.downloadRuntimeViaXcodeBuild(runtime: runtime) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.runtimeTasks[runtime.identifier]) + XCTAssertEqual(continuations.read { $0.count }, 2) + + continuations.read { $0[0] }.finish() + try await firstTask.value + XCTAssertNotNil(subject.runtimeTasks[runtime.identifier]) + + continuations.read { $0[1] }.finish() + try await replacementTask.value + XCTAssertNil(subject.runtimeTasks[runtime.identifier]) + XCTAssertNil(subject.runtimeTaskIDs[runtime.identifier]) + } + + func test_ConfirmDeleteRuntime_OldTaskDoesNotClearReplacementTask() async throws { + let runtime = try Self.downloadableRuntime() + let installedRuntime = CoreSimulatorImage( + uuid: "runtime-uuid", + path: ["relative": "/Library/Developer/CoreSimulator/Images/runtime.dmg"], + runtimeInfo: CoreSimulatorRuntimeInfo(build: runtime.simulatorVersion.buildUpdate) + ) + let deletedIdentifiers = TestLockedBox<[String]>([]) + let continuations = TestLockedBox<[CheckedContinuation]>([]) + subject = AppState( + runtimeService: Self.runtimeService { identifier in + deletedIdentifiers.withValue { $0.append(identifier) } + return try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + ) + subject.installedRuntimes = [installedRuntime] + + subject.confirmDeleteRuntime(runtime: runtime) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.deleteRuntimeTask) + XCTAssertEqual(deletedIdentifiers.read { $0 }, [installedRuntime.uuid]) + + subject.confirmDeleteRuntime(runtime: runtime) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.deleteRuntimeTask) + XCTAssertEqual(deletedIdentifiers.read { $0 }, [installedRuntime.uuid, installedRuntime.uuid]) + + continuations.read { $0[0] }.resume(returning: ProcessOutput(status: 0, out: "", err: "")) + await firstTask.value + XCTAssertNotNil(subject.deleteRuntimeTask) + + continuations.read { $0[1] }.resume(returning: ProcessOutput(status: 0, out: "", err: "")) + await replacementTask.value + XCTAssertNil(subject.deleteRuntimeTask) + XCTAssertNil(subject.deleteRuntimeTaskID) + } + + func test_ConfirmDeleteRuntime_PresentsPreferenceAlertOnError() async throws { + let runtime = try Self.downloadableRuntime() + + subject.confirmDeleteRuntime(runtime: runtime) + let task = try XCTUnwrap(subject.deleteRuntimeTask) + await task.value + + guard case let .generic(title, message) = subject.presentedPreferenceAlert else { + return XCTFail("Expected generic preference alert") + } + XCTAssertEqual(title, "Error") + XCTAssertEqual(message, "No simulator found with \(runtime.identifier)") + XCTAssertNil(subject.deleteRuntimeTask) + XCTAssertNil(subject.deleteRuntimeTaskID) + } + + func test_InstallWithoutLogin_OldTaskDoesNotClearReplacementTask() async throws { + let version = Version("0.0.0")! + let availableXcode = AvailableXcode( + version: version, + url: URL(string: "https://apple.com/xcode.xip")!, + filename: "mock.xip", + releaseDate: nil + ) + subject.availableXcodes = [availableXcode] + subject.allXcodes = [ + .init(version: version, installState: .notInstalled, selected: false, icon: nil) + ] + subject.helperInstallState = .installed + + Current.defaults.string = { key in + key == "downloader" ? "urlSession" : nil + } + Current.files.fileExistsAtPath = { path in + path != (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string + } + Current.shell.codesignVerify = { _ in + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeTeamIdentifier) + Authority=\(XcodeCertificateAuthority[0]) + Authority=\(XcodeCertificateAuthority[1]) + Authority=\(XcodeCertificateAuthority[2]) + """ + ) } - let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - let recorder = subject.verifySecurityAssessment(of: installedXcode).record() - let completion = try wait(for: recorder.completion, timeout: 1, description: "Completion") + let continuations = TestLockedBox<[CheckedContinuation<(saveLocation: URL, response: URLResponse), Error>]>([]) + Current.network.downloadTaskAsync = { url, saveLocation, _ in + ( + Progress(), + Task { + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + ) + } - if case let .failure(error as InstallationError) = completion { - XCTAssertEqual(error, InstallationError.failedSecurityAssessment(xcode: installedXcode, output: "stdout\nstderr")) + subject.installWithoutLogin(id: availableXcode.xcodeID) + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() } - else { - XCTFail() + let firstTask = try XCTUnwrap(subject.installationTasks[availableXcode.xcodeID]) + XCTAssertEqual(continuations.read { $0.count }, 1) + + subject.installWithoutLogin(id: availableXcode.xcodeID) + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() } + let replacementTask = try XCTUnwrap(subject.installationTasks[availableXcode.xcodeID]) + XCTAssertEqual(continuations.read { $0.count }, 2) + + continuations.read { $0[0] }.resume(returning: Self.downloadResult(for: availableXcode)) + await firstTask.value + XCTAssertNotNil(subject.installationTasks[availableXcode.xcodeID]) + + continuations.read { $0[1] }.resume(returning: Self.downloadResult(for: availableXcode)) + await replacementTask.value + XCTAssertNil(subject.installationTasks[availableXcode.xcodeID]) + XCTAssertNil(subject.installationTaskIDs[availableXcode.xcodeID]) } - func test_VerifySecurityAssessment_Succeeds() throws { - Current.shell.spctlAssess = { _ in - Just((0, "", "")).setFailureType(to: Error.self).eraseToAnyPublisher() + func test_Install_RetryingDownloadDoesNotAttachSameProgressTwice() async throws { + let version = Version("0.0.0")! + let availableXcode = AvailableXcode( + version: version, + url: URL(string: "https://apple.com/xcode.xip")!, + filename: "mock.xip", + releaseDate: nil + ) + subject.allXcodes = [ + .init(version: version, installState: .notInstalled, selected: false, icon: nil) + ] + subject.helperInstallState = .installed + + Current.files.fileExistsAtPath = { path in + path != (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string + } + Current.shell.codesignVerify = { _ in + ProcessOutput( + status: 0, + out: "", + err: """ + TeamIdentifier=\(XcodeTeamIdentifier) + Authority=\(XcodeCertificateAuthority[0]) + Authority=\(XcodeCertificateAuthority[1]) + Authority=\(XcodeCertificateAuthority[2]) + """ + ) + } + + let progress = Progress(totalUnitCount: 100) + let attempts = TestLockedBox(0) + Current.network.downloadTaskAsync = { url, saveLocation, _ in + let attempt = attempts.withValue { + $0 += 1 + return $0 + } + return ( + progress, + Task { + await Task.yield() + if attempt == 1 { + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNetworkConnectionLost, + userInfo: [NSURLSessionDownloadTaskResumeData: Data("resume".utf8)] + ) + } + + return ( + saveLocation: saveLocation, + response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + ) } - let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - let recorder = subject.verifySecurityAssessment(of: installedXcode).record() - try wait(for: recorder.finished, timeout: 1, description: "Finished") + let installedXcode = try await subject.installAsync( + .version(availableXcode), + downloader: .urlSession, + attemptNumber: 0 + ) + + XCTAssertEqual(attempts.read { $0 }, 2) + XCTAssertTrue(installedXcode.version.isEquivalent(to: version)) } - func test_Install_FullHappyPath_Apple() throws { + func test_Install_FullHappyPath_Apple() async throws { // Available xcode doesn't necessarily have build identifier subject.allXcodes = [ .init(version: Version("0.0.0")!, installState: .notInstalled, selected: false, icon: nil), @@ -83,65 +537,46 @@ class AppStateTests: XCTestCase { return true } } - Xcodes.Current.network.validateSession = { - return Just(()) - .setFailureType(to: Error.self).eraseToAnyPublisher() - } - Xcodes.Current.network.dataTask = { urlRequest in - // Don't have a valid session - if urlRequest.url! == URLRequest.olympusSession.url! { - return Fail(error: AuthenticationError.invalidSession) - .eraseToAnyPublisher() - } - // It's an available release version - else if urlRequest.url! == URLRequest.downloads.url! { + Xcodes.Current.network.validateSessionAsync = { } + Xcodes.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(resultCode: 0, resultsString: nil, downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip", fileSize: 9484444)], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Just( - ( - data: downloadsData, - response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) + return ( + data: downloadsData, + response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } - return Just( - ( - data: Data(), - response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) + return ( + data: Data(), + response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } // It downloads and updates progress let progress = Progress(totalUnitCount: 100) - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) in + Current.network.downloadTaskAsync = { url, saveLocation, _ in return ( progress, - Deferred { - Future { promise in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - promise(.success((saveLocation: saveLocation, - response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!))) + Task { + await Task.yield() + await MainActor.run { + for i in 0...100 { + progress.completedUnitCount = Int64(i) } } + return ( + saveLocation: saveLocation, + response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) } - .eraseToAnyPublisher() ) } // It's a valid .app Current.shell.codesignVerify = { _ in - Just( - ProcessOutput( + ProcessOutput( status: 0, out: "", err: """ @@ -150,23 +585,20 @@ class AppStateTests: XCTestCase { Authority=\(XcodeCertificateAuthority[1]) Authority=\(XcodeCertificateAuthority[2]) """) - ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } // Helper is already installed subject.helperInstallState = .installed - let allXcodesRecorder = subject.$allXcodes.record() - let installRecorder = subject.install( - .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), - downloader: .urlSession - ).record() - try wait(for: installRecorder.finished, timeout: 1, description: "Finished") - - let allXcodesElements = try wait(for: allXcodesRecorder.availableElements, timeout: 1, description: "All Xcodes Elements") + let allXcodeInstallStates = try await recordAllXcodeInstallStates { + _ = try await subject.installAsync( + .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), + downloader: .urlSession, + attemptNumber: 0 + ) + } + XCTAssertEqual( - allXcodesElements.map { $0.map(\.installState) }, + allXcodeInstallStates, [ [XcodeInstallState.notInstalled, .notInstalled, .notInstalled], [.installing(.downloading(progress: progress)), .notInstalled, .notInstalled], @@ -179,8 +611,74 @@ class AppStateTests: XCTestCase { ] ) } + + private static func downloadableRuntime() throws -> DownloadableRuntime { + let json = """ + { + "category": "simulator", + "simulatorVersion": { + "buildUpdate": "20A360", + "version": "16.0" + }, + "source": "https://example.com/iOS_16_Runtime.dmg", + "architectures": null, + "dictionaryVersion": 1, + "contentType": "diskImage", + "platform": "com.apple.platform.iphoneos", + "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-16-0", + "version": "16.0", + "fileSize": 42, + "hostRequirements": null, + "name": "iOS 16.0", + "authentication": null + } + """ + return try JSONDecoder().decode(DownloadableRuntime.self, from: Data(json.utf8)) + } + + private static func downloadResult(for availableXcode: AvailableXcode) -> (saveLocation: URL, response: URLResponse) { + ( + saveLocation: (Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).xip").url, + response: HTTPURLResponse(url: availableXcode.url, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } + + private static func runtimeService( + deleteRuntimeOutput: @escaping @Sendable (String) async throws -> ProcessOutput + ) -> RuntimeService { + RuntimeService( + loadData: { request in + (Data(), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + }, + contentsAtPath: { _ in + Data(""" + + + + + images + + + + """.utf8) + }, + installedRuntimesOutput: { + ProcessOutput(status: 0, out: "{}", err: "") + }, + installRuntimeImageOutput: { _ in + ProcessOutput(status: 0, out: "", err: "") + }, + mountDMGOutput: { _ in + ProcessOutput(status: 0, out: "", err: "") + }, + unmountDMGOutput: { _ in + ProcessOutput(status: 0, out: "", err: "") + }, + deleteRuntimeOutput: deleteRuntimeOutput + ) + } - func test_Install_FullHappyPath_XcodeReleases() throws { + func test_Install_FullHappyPath_XcodeReleases() async throws { // Available xcode has build identifier subject.allXcodes = [ .init(version: Version("0.0.0+ABC123")!, installState: .notInstalled, selected: false, icon: nil), @@ -197,61 +695,45 @@ class AppStateTests: XCTestCase { return true } } - Xcodes.Current.network.dataTask = { urlRequest in - // Don't have a valid session - if urlRequest.url! == URLRequest.olympusSession.url! { - return Fail(error: AuthenticationError.invalidSession) - .eraseToAnyPublisher() - } - // It's an available release version - else if urlRequest.url! == URLRequest.downloads.url! { + Xcodes.Current.network.loadData = { urlRequest in + if urlRequest.url! == URLRequest.developerDownloads.url! { let downloads = Downloads(resultCode: 0, resultsString: nil, downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip", fileSize: 9494944)], dateModified: Date())]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) let downloadsData = try! encoder.encode(downloads) - return Just( - ( - data: downloadsData, - response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) + return ( + data: downloadsData, + response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } - return Just( - ( - data: Data(), - response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - ) + return ( + data: Data(), + response: HTTPURLResponse(url: urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } // It downloads and updates progress let progress = Progress(totalUnitCount: 100) - Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) in + Current.network.downloadTaskAsync = { url, saveLocation, _ in return ( progress, - Deferred { - Future { promise in - // Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation. - DispatchQueue.main.async { - for i in 0...100 { - progress.completedUnitCount = Int64(i) - } - promise(.success((saveLocation: saveLocation, - response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!))) + Task { + await Task.yield() + await MainActor.run { + for i in 0...100 { + progress.completedUnitCount = Int64(i) } } + return ( + saveLocation: saveLocation, + response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) } - .eraseToAnyPublisher() ) } // It's a valid .app Current.shell.codesignVerify = { _ in - Just( - ProcessOutput( + ProcessOutput( status: 0, out: "", err: """ @@ -260,23 +742,20 @@ class AppStateTests: XCTestCase { Authority=\(XcodeCertificateAuthority[1]) Authority=\(XcodeCertificateAuthority[2]) """) - ) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() } // Helper is already installed subject.helperInstallState = .installed - let allXcodesRecorder = subject.$allXcodes.record() - let installRecorder = subject.install( - .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), - downloader: .urlSession - ).record() - try wait(for: installRecorder.finished, timeout: 1, description: "Finished") - - let allXcodesElements = try wait(for: allXcodesRecorder.availableElements, timeout: 1, description: "All Xcodes Elements") + let allXcodeInstallStates = try await recordAllXcodeInstallStates { + _ = try await subject.installAsync( + .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), + downloader: .urlSession, + attemptNumber: 0 + ) + } + XCTAssertEqual( - allXcodesElements.map { $0.map(\.installState) }, + allXcodeInstallStates, [ [XcodeInstallState.notInstalled, .notInstalled, .notInstalled], [.installing(.downloading(progress: progress)), .notInstalled, .notInstalled], @@ -290,39 +769,60 @@ class AppStateTests: XCTestCase { ) } - func test_Install_NotEnoughFreeSpace() throws { + func test_Install_NotEnoughFreeSpace() async throws { Current.shell.unxip = { _ in - Fail(error: ProcessExecutionError( + throw ProcessExecutionError( process: Process(), standardOutput: "xip: signing certificate was \"Development Update\" (validation not attempted)", standardError: "xip: error: The archive “Xcode-12.4.0-Release.Candidate+12D4e.xip” can’t be expanded because the selected volume doesn’t have enough free space." - )) - .eraseToAnyPublisher() + ) } let archiveURL = URL(fileURLWithPath: "/Users/user/Library/Application Support/Xcode-0.0.0.xip") - let recorder = subject.unarchiveAndMoveXIP( - availableXcode: AvailableXcode( - version: Version("0.0.0")!, - url: URL(string: "https://developer.apple.com")!, - filename: "Xcode-0.0.0.xip", - releaseDate: nil - ), - at: archiveURL, - to: URL(string: "/Applications/Xcode-0.0.0.app")! - ).record() - - let completion = try wait(for: recorder.completion, timeout: 1, description: "Completion") - - if case let .failure(error as InstallationError) = completion { + do { + _ = try await subject.installArchivedXcodeAsync( + AvailableXcode( + version: Version("0.0.0")!, + url: URL(string: "https://developer.apple.com")!, + filename: "Xcode-0.0.0.xip", + releaseDate: nil + ), + at: archiveURL + ) + XCTFail() + } catch let error as InstallationError { XCTAssertEqual( error, InstallationError.notEnoughFreeSpaceToExpandArchive(archivePath: Path(url: archiveURL)!, version: Version("0.0.0")!) ) + } catch { + XCTFail("Unexpected error: \(error)") } - else { - XCTFail() - } + } + + private func recordAllXcodeInstallStates(during operation: () async throws -> Void) async throws -> [[XcodeInstallState]] { + var states: [[XcodeInstallState]] = [] + var cancellable: AnyCancellable? + cancellable = subject.$allXcodes.sink { xcodes in + states.append(xcodes.map(\.installState)) + } + defer { cancellable?.cancel() } + + try await operation() + return states + } +} + +private extension HTTPCookie { + static func xcodesTestCookie(name: String) throws -> HTTPCookie { + try XCTUnwrap(HTTPCookie(properties: [ + .domain: "developer.apple.com", + .path: "/", + .name: name, + .value: "test-cookie", + .secure: "TRUE", + .expires: Date.distantFuture + ])) } } diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index a2f501eb..299e4f01 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -1,86 +1,149 @@ import Path import CryptoKit import Version +import XcodesKit @testable import Xcodes import XCTest import CommonCrypto import BigNum import SRP +import os +private final class AppStateUpdateTestLockedBox: Sendable { + private let storage: OSAllocatedUnfairLock + + init(_ value: Value) { + self.storage = OSAllocatedUnfairLock(initialState: value) + } + + func read(_ body: @Sendable (Value) -> Result) -> Result { + storage.withLock { body($0) } + } + + func withValue(_ body: @Sendable (inout Value) -> Result) -> Result { + storage.withLock { body(&$0) } + } +} + +@MainActor class AppStateUpdateTests: XCTestCase { var subject: AppState! - + override func setUpWithError() throws { Current = .mock + syncXcodesKitMocks() subject = AppState() } + func test_UpdateIfNeeded_OldTaskDoesNotClearReplacementTask() async throws { + subject.availableXcodes = [ + AvailableXcode( + version: Version("0.0.0")!, + url: URL(string: "https://apple.com/xcode.xip")!, + filename: "mock.xip", + releaseDate: nil + ) + ] + Current.defaults.date = { _ in Date.mock() } + + let continuations = AppStateUpdateTestLockedBox<[CheckedContinuation]>([]) + Current.shell.xcodeSelectPrintPath = { + try await withCheckedThrowingContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + } + + subject.updateIfNeeded() + for _ in 0..<100 where continuations.read({ $0.count }) < 1 { + await Task.yield() + } + let firstTask = try XCTUnwrap(subject.updateTask) + XCTAssertEqual(continuations.read { $0.count }, 1) + + subject.updateIfNeeded() + for _ in 0..<100 where continuations.read({ $0.count }) < 2 { + await Task.yield() + } + let replacementTask = try XCTUnwrap(subject.updateTask) + XCTAssertEqual(continuations.read { $0.count }, 2) + + continuations.read { $0[0] }.resume(returning: (0, "/Applications/Xcode.app", "")) + await firstTask.value + XCTAssertNotNil(subject.updateTask) + + continuations.read { $0[1] }.resume(returning: (0, "/Applications/Xcode-Beta.app", "")) + await replacementTask.value + XCTAssertNil(subject.updateTask) + XCTAssertNil(subject.updateTaskID) + XCTAssertEqual(subject.selectedXcodePath, "/Applications/Xcode-Beta.app") + } + func testDoesNotReplaceInstallState() throws { subject.allXcodes = [ Xcode(version: Version("0.0.0")!, installState: .installing(.unarchiving), selected: false, icon: nil) ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes[0].installState, .installing(.unarchiving)) } - + func testRemovesUninstalledVersion() throws { subject.allXcodes = [ Xcode(version: Version("0.0.0")!, installState: .installed(Path("/Applications/Xcode-0.0.0.app")!), selected: true, icon: NSImage(systemSymbolName: "app.fill", accessibilityDescription: nil)) ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes[0].installState, .notInstalled) } - + func testDeterminesIfInstalledByBuildMetadataAlone() throws { Current.defaults.string = { key in if key == "dataSource" { - return "apple" + return "apple" } else { return nil } } - + subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ // Note "GM" prerelease identifier AvailableXcode(version: Version("0.0.0-GM+ABC123")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil) - ], + ], installedXcodes: [ InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - ], + ], selectedXcodePath: nil ) - - XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) + + XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) XCTAssertEqual(subject.allXcodes[0].installState, .installed(Path("/Applications/Xcode-0.0.0.app")!)) XCTAssertEqual(subject.allXcodes[0].selected, false) } - + func testAdjustedVersionsAreUsedToLookupAvailableXcode() throws { Current.defaults.string = { key in if key == "dataSource" { - return "apple" + return "apple" } else { return nil } @@ -88,19 +151,19 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ // Note "GM" prerelease identifier AvailableXcode(version: Version("0.0.0-GM+ABC123")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil, sdks: .init(iOS: .init("14.3"))) - ], + ], installedXcodes: [ InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - ], + ], selectedXcodePath: nil ) - - XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) + + XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) XCTAssertEqual(subject.allXcodes[0].installState, .installed(Path("/Applications/Xcode-0.0.0.app")!)) XCTAssertEqual(subject.allXcodes[0].selected, false) // XCModel types aren't equatable, so just check for non-nil for now @@ -110,26 +173,26 @@ class AppStateUpdateTests: XCTestCase { func testAppendingInstalledVersionThatIsNotAvailable() { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("1.2.3")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil, sdks: .init(iOS: .init("14.3"))) - ], + ], installedXcodes: [ // There's a version installed which for some reason isn't listed online InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - ], + ], selectedXcodePath: nil ) - - XCTAssertEqual(subject.allXcodes.map(\.version), [Version("1.2.3")!, Version("0.0.0+ABC123")!]) + + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("1.2.3")!, Version("0.0.0+ABC123")!]) } - - + + func testIdenticalBuilds_KeepsReleaseVersion_WithNeitherInstalled() { Current.defaults.string = { key in if key == "dataSource" { - return "xcodeReleases" + return "xcodeReleases" } else { return nil } @@ -137,25 +200,25 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("12.4.0+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), AvailableXcode(version: Version("12.4.0-RC+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0+12D4e")!]) XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[XcodeID(version: Version("12.4.0+12D4e")!), XcodeID(version: Version("12.4.0-RC+12D4e")!)]]) } - + func testIdenticalBuilds_DoNotMergeReleaseVersions() { Current.defaults.string = { key in if key == "dataSource" { - return "xcodeReleases" + return "xcodeReleases" } else { return nil } @@ -163,25 +226,25 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("3.2.3+10M2262")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), AvailableXcode(version: Version("3.2.3+10M2262")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("3.2.3+10M2262")!, Version("3.2.3+10M2262")!]) XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[], []]) } - + func testIdenticalBuilds_KeepsReleaseVersion_WithPrereleaseInstalled() { Current.defaults.string = { key in if key == "dataSource" { - return "xcodeReleases" + return "xcodeReleases" } else { return nil } @@ -189,7 +252,7 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + Current.files.contentsAtPath = { path in if path.contains("Info.plist") { return """ @@ -221,26 +284,26 @@ class AppStateUpdateTests: XCTestCase { return nil } } - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("12.4.0+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), AvailableXcode(version: Version("12.4.0-RC+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), - ], + ], installedXcodes: [ InstalledXcode(path: Path("/Applications/Xcode-12.4.0-RC.app")!)! - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0+12D4e")!]) XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[XcodeID(version: Version("12.4.0+12D4e")!), XcodeID(version: Version("12.4.0-RC+12D4e")!)]]) } - + func testIdenticalBuilds_AppleDataSource_DoNotMergeVersionsWithoutBuildIdentifiers() { Current.defaults.string = { key in if key == "dataSource" { - return "apple" + return "apple" } else { return nil } @@ -248,17 +311,17 @@ class AppStateUpdateTests: XCTestCase { subject.allXcodes = [ ] - + subject.updateAllXcodes( availableXcodes: [ AvailableXcode(version: Version("12.4.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), AvailableXcode(version: Version("12.3.0-RC")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), - ], + ], installedXcodes: [ - ], + ], selectedXcodePath: nil ) - + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0")!, Version("12.3.0-RC")!]) XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[], []]) } diff --git a/XcodesTests/Environment+Mock.swift b/XcodesTests/Environment+Mock.swift index f030d796..5a9e5d8b 100644 --- a/XcodesTests/Environment+Mock.swift +++ b/XcodesTests/Environment+Mock.swift @@ -1,113 +1,129 @@ -import Combine import Foundation +import XcodesKit @testable import Xcodes +func syncXcodesKitMocks() { + configureXcodesKitFileContents { Xcodes.Current.files.contents(atPath: $0) } + configureXcodesKitArchs { _ in Shell.processOutputMock } +} + extension Xcodes.Environment { - static var mock = Xcodes.Environment( - shell: .mock, - files: .mock, - network: .mock, - keychain: .mock, - defaults: .mock, - date: Date.mock, - helper: .mock - ) + static var mock: Xcodes.Environment { + Xcodes.Environment( + shell: .mock, + files: .mock, + network: .mock, + keychain: .mock, + defaults: .mock, + date: Date.mock, + helper: .mock + ) + } } extension Shell { - static var processOutputMock: ProcessOutput = (0, "", "") + static let processOutputMock: ProcessOutput = (0, "", "") - static var mock = Shell( - unxip: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - spctlAssess: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - codesignVerify: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - buildVersion: { return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - xcodeBuildVersion: { _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - getUserCacheDir: { return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - touchInstallCheck: { _, _, _ in return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() }, - xcodeSelectPrintPath: { return Just(Shell.processOutputMock).setFailureType(to: Error.self).eraseToAnyPublisher() } - ) + static var mock: Shell { + Shell( + unxip: { _ in Shell.processOutputMock }, + spctlAssess: { _ in Shell.processOutputMock }, + codesignVerify: { _ in Shell.processOutputMock }, + buildVersion: { Shell.processOutputMock }, + xcodeBuildVersion: { _ in Shell.processOutputMock }, + archs: { _ in Shell.processOutputMock }, + getUserCacheDir: { Shell.processOutputMock }, + touchInstallCheck: { _, _, _ in Shell.processOutputMock }, + xcodeSelectPrintPath: { Shell.processOutputMock } + ) + } } extension Files { - static var mock = Files( - fileExistsAtPath: { _ in return true }, - moveItem: { _, _ in return }, - contentsAtPath: { path in - if path.contains("Info.plist") { - let url = Bundle.xcodesTests.url(forResource: "Stub-0.0.0.Info", withExtension: "plist")! - return try? Data(contentsOf: url) - } - else if path.contains("version.plist") { - let url = Bundle.xcodesTests.url(forResource: "Stub-version", withExtension: "plist")! - return try? Data(contentsOf: url) - } - else { - return nil - } - }, - removeItem: { _ in }, - trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") }, - createFile: { _, _, _ in return true }, - createDirectory: { _, _, _ in }, - installedXcodes: { _ in [] } - ) + static var mock: Files { + Files( + fileExistsAtPath: { _ in return true }, + moveItem: { _, _ in return }, + contentsAtPath: { path in + if path.contains("Info.plist") { + let url = Bundle.xcodesTests.url(forResource: "Stub-0.0.0.Info", withExtension: "plist")! + return try? Data(contentsOf: url) + } + else if path.contains("version.plist") { + let url = Bundle.xcodesTests.url(forResource: "Stub-version", withExtension: "plist")! + return try? Data(contentsOf: url) + } + else { + return nil + } + }, + removeItem: { _ in }, + trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") }, + createFile: { _, _, _ in return true }, + createDirectory: { _, _, _ in }, + installedXcodes: { _ in [] } + ) + } } extension Network { - static var mock = Network( - dataTask: { url in - Just((data: Data(), response: HTTPURLResponse(url: url.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - }, - downloadTask: { url, saveLocation, _ in - return ( - Progress(), - Just((saveLocation, HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - ) - }, - validateSession: { - return Just(()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - ) + static var mock: Network { + Network( + session: URLSession.shared, + loadData: { request in + (Data(), HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! as URLResponse) + }, + downloadTaskAsync: { url, saveLocation, _ in + ( + Progress(), + Task { + (saveLocation, HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + ) + }, + validateSessionAsync: { }, + signout: { } + ) + } } extension Keychain { - static var mock = Keychain( - getString: { _ in return nil }, - set: { _, _ in }, - remove: { _ in } - ) + static var mock: Keychain { + Keychain( + getString: { _ in return nil }, + set: { _, _ in }, + remove: { _ in } + ) + } } extension Defaults { - static var mock = Defaults( - string: { _ in nil }, - date: { _ in nil }, - setDate: { _, _ in }, - set: { _, _ in }, - removeObject: { _ in } - ) + static var mock: Defaults { + Defaults( + string: { _ in nil }, + date: { _ in nil }, + setDate: { _, _ in }, + set: { _, _ in }, + removeObject: { _ in } + ) + } } extension Date { - static var mock = { Date(timeIntervalSince1970: 1609479735) } + static let mock: @Sendable () -> Date = { Date(timeIntervalSince1970: 1609479735) } } extension Helper { - static var mock = Helper( - install: { }, - checkIfLatestHelperIsInstalled: { Just(false).eraseToAnyPublisher() }, - getVersion: { Just("").setFailureType(to: Error.self).eraseToAnyPublisher() }, - switchXcodePath: { _ in Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() }, - devToolsSecurityEnable: { Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() }, - addStaffToDevelopersGroup: { Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() }, - acceptXcodeLicense: { _ in Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() }, - runFirstLaunch: { _ in Just(()).setFailureType(to: Error.self).eraseToAnyPublisher() } - ) + static var mock: Helper { + Helper( + install: { }, + checkIfLatestHelperIsInstalledAsync: { false }, + getVersionAsync: { "" }, + switchXcodePathAsync: { _ in }, + devToolsSecurityEnableAsync: { }, + addStaffToDevelopersGroupAsync: { }, + acceptXcodeLicenseAsync: { _ in }, + runFirstLaunchAsync: { _ in } + ) + } } diff --git a/com.xcodesorg.xcodesapp.Helper/Logger.swift b/com.xcodesorg.xcodesapp.Helper/Logger.swift index 3b39654d..d45482a4 100644 --- a/com.xcodesorg.xcodesapp.Helper/Logger.swift +++ b/com.xcodesorg.xcodesapp.Helper/Logger.swift @@ -2,7 +2,7 @@ import Foundation import os.log extension Logger { - private static var subsystem = Bundle.main.bundleIdentifier! + private static let subsystem = Bundle.main.bundleIdentifier! static let connectionVerifier = Logger(subsystem: subsystem, category: "connectionVerifier") static let xpcDelegate = Logger(subsystem: subsystem, category: "xpcDelegate") diff --git a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift index 86a33301..eb9fe069 100644 --- a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift +++ b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift @@ -1,7 +1,7 @@ import Foundation import os.log -class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { +final class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { // MARK: - NSXPCListenerDelegate From a0722be091d0ebc8ffc3c76fcc69467d565d8321 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Sun, 24 May 2026 13:44:15 -0500 Subject: [PATCH 04/18] Add architecture filters for Xcodes and runtimes Adapted from XcodesOrg/xcodes#470 by wmehanna. --- .../XcodesKit/Models/Runtimes/Runtimes.swift | 7 + .../Models/XcodeReleases/Architecture.swift | 4 + .../Models/Xcodes/AvailableXcode.swift | 35 +++++ .../RuntimeListPresentationService.swift | 35 ++--- .../XcodeListPresentationService.swift | 18 ++- .../ArchitectureFilteringTests.swift | 122 ++++++++++++++++++ 6 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift index ada7c174..4cb88a9b 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift @@ -178,6 +178,13 @@ public struct InstalledRuntime: Decodable, Sendable { public let supportedArchitectures: [Architecture]? } +public extension Array where Element == DownloadableRuntime { + func matchingArchitectures(_ architectures: [Architecture]) -> [DownloadableRuntime] { + guard !architectures.isEmpty else { return self } + return filter { $0.architectures?.containsAny(architectures) == true } + } +} + extension InstalledRuntime { public enum Kind: String, Decodable, Sendable { case bundled = "Bundled with Xcode" diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift index b66edc4a..16192ac6 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift @@ -68,4 +68,8 @@ extension Array where Element == Architecture { public var isUniversal: Bool { self.contains([.arm64, .x86_64]) } + + public func containsAny(_ architectures: [Architecture]) -> Bool { + !Set(self).isDisjoint(with: architectures) + } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift index ff93fa7e..fe7b57c0 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift @@ -90,4 +90,39 @@ public extension Array where Element == AvailableXcode { func first(withVersion version: Version) -> AvailableXcode? { XcodeVersionMatcher.find(version: version, in: self, versionKeyPath: \AvailableXcode.version) } + + func matchingArchitectures(_ architectures: [Architecture]) -> [AvailableXcode] { + guard !architectures.isEmpty else { return self } + return filter { $0.architectures?.containsAny(architectures) == true } + } + + /// Returns the best compatible Xcode for the given version and host architecture. + /// Adapted from XcodesOrg/xcodes#470 by wmehanna. + func firstCompatible(withVersion version: Version, hostArchitecture: Architecture) -> AvailableXcode? { + let matches = all(withVersion: version) + guard !matches.isEmpty else { return nil } + + if let universal = matches.first(where: { $0.architectures?.isUniversal == true }) { + return universal + } + + if let matching = matches.first(where: { $0.architectures?.contains(hostArchitecture) == true }) { + return matching + } + + return matches.first + } + + private func all(withVersion version: Version) -> [AvailableXcode] { + let equivalentMatches = filter { $0.version.isEquivalent(to: version) } + if !equivalentMatches.isEmpty { + return equivalentMatches + } + + if version.prereleaseIdentifiers.isEmpty && version.buildMetadataIdentifiers.isEmpty { + return filter { $0.version.isEqualWithoutAllIdentifiers(to: version) } + } + + return [] + } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift index d2b73b73..3b6baf26 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift @@ -44,13 +44,15 @@ public struct RuntimeListPresentationService: Sendable { public func rows( downloadableRuntimes: DownloadableRuntimesResponse, installedRuntimes: [InstalledRuntime], - includeBetas: Bool + includeBetas: Bool, + architectures: [Architecture] = [] ) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] { rows( downloadableRuntimes: downloadableRuntimes.downloadablesWithSDKBuildUpdates(), installedRuntimes: installedRuntimes, includeBetas: includeBetas, - sdkToSeedMappings: downloadableRuntimes.sdkToSeedMappings + sdkToSeedMappings: downloadableRuntimes.sdkToSeedMappings, + architectures: architectures ) } @@ -58,12 +60,13 @@ public struct RuntimeListPresentationService: Sendable { downloadableRuntimes: [DownloadableRuntime], installedRuntimes: [InstalledRuntime], includeBetas: Bool, - sdkToSeedMappings: [SDKToSeedMapping] = [] + sdkToSeedMappings: [SDKToSeedMapping] = [], + architectures: [Architecture] = [] ) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] { var unmatchedInstalledRuntimes = installedRuntimes var rows: [RuntimeRow] = [] - downloadableRuntimes.forEach { downloadable in + downloadableRuntimes.matchingArchitectures(architectures).forEach { downloadable in let matchingInstalledRuntimes = unmatchedInstalledRuntimes.removeAll { $0.build == downloadable.simulatorVersion.buildUpdate } @@ -77,18 +80,20 @@ public struct RuntimeListPresentationService: Sendable { } } - unmatchedInstalledRuntimes.forEach { installedRuntime in - let betaNumber = sdkToSeedMappings.first { - $0.buildUpdate == installedRuntime.build - }?.seedNumber - var row = RuntimeRow(installedRuntime, betaNumber: betaNumber) + if architectures.isEmpty { + unmatchedInstalledRuntimes.forEach { installedRuntime in + let betaNumber = sdkToSeedMappings.first { + $0.buildUpdate == installedRuntime.build + }?.seedNumber + var row = RuntimeRow(installedRuntime, betaNumber: betaNumber) - rows.indices.filter { row.visibleIdentifier == rows[$0].visibleIdentifier }.forEach { index in - row.hasDuplicateVersion = true - rows[index].hasDuplicateVersion = true - } + rows.indices.filter { row.visibleIdentifier == rows[$0].visibleIdentifier }.forEach { index in + row.hasDuplicateVersion = true + rows[index].hasDuplicateVersion = true + } - rows.append(row) + rows.append(row) + } } return Dictionary(grouping: rows, by: \.platform) @@ -161,7 +166,7 @@ private extension RuntimeListPresentationService.RuntimeRow { version: runtime.version, build: runtime.build, kind: runtime.kind, - architectures: nil + architectures: runtime.supportedArchitectures ) } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift index 767a8ac2..0ba6135e 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift @@ -23,25 +23,31 @@ public struct XcodeListPresentationService: Sendable { availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?, - dataSource: XcodeListDataSource + dataSource: XcodeListDataSource, + architectures: [Architecture] = [] ) -> [AvailableRow] { struct ReleasedVersion { let version: Version let releaseDate: Date? } - let adjustedAvailableXcodes = dataSource == .apple + let adjustedAvailableXcodes = (dataSource == .apple ? XcodeListComposer.adjustingAvailableXcodesForInstalledBuildMetadata( availableXcodes, installedXcodes: installedXcodes ) - : availableXcodes + : availableXcodes) + .matchingArchitectures(architectures) + + let adjustedInstalledXcodes = architectures.isEmpty + ? installedXcodes + : installedXcodes.filter { $0.xcodeID.architectures?.containsAny(architectures) == true } var releasedVersions = adjustedAvailableXcodes.map { ReleasedVersion(version: $0.version, releaseDate: $0.releaseDate) } - for installedXcode in installedXcodes { + for installedXcode in adjustedInstalledXcodes { if !releasedVersions.contains(where: { $0.version.isEquivalent(to: installedXcode.version) }) { releasedVersions.append(ReleasedVersion(version: installedXcode.version, releaseDate: nil)) } else if let index = releasedVersions.firstIndex(where: { @@ -53,7 +59,7 @@ public struct XcodeListPresentationService: Sendable { } let selectedInstalledXcode = Self.selectedInstalledXcode( - in: installedXcodes, + in: adjustedInstalledXcodes, selectedXcodePath: selectedXcodePath ) @@ -68,7 +74,7 @@ public struct XcodeListPresentationService: Sendable { return first.version < second.version } .map { releasedVersion in - let installedXcode = installedXcodes.first { + let installedXcode = adjustedInstalledXcodes.first { releasedVersion.version.isEquivalent(to: $0.version) } return AvailableRow( diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift new file mode 100644 index 00000000..788d59dd --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift @@ -0,0 +1,122 @@ +import XCTest +@preconcurrency import Version +@testable import XcodesKit + +final class ArchitectureFilteringTests: XCTestCase { + func testAvailableXcodesCanBeFilteredByArchitecture() throws { + let universal = availableXcode("15.0.0", filename: "Xcode-15.xip", architectures: [.arm64, .x86_64]) + let appleSilicon = availableXcode("16.0.0", filename: "Xcode-16-arm64.xip", architectures: [.arm64]) + let intel = availableXcode("14.0.0", filename: "Xcode-14-x86_64.xip", architectures: [.x86_64]) + let unknown = availableXcode("13.0.0", filename: "Xcode-13.xip") + + XCTAssertEqual( + [universal, appleSilicon, intel, unknown].matchingArchitectures([.arm64]), + [universal, appleSilicon] + ) + XCTAssertEqual( + [universal, appleSilicon, intel, unknown].matchingArchitectures([.x86_64]), + [universal, intel] + ) + XCTAssertEqual( + [universal, appleSilicon, intel, unknown].matchingArchitectures([]), + [universal, appleSilicon, intel, unknown] + ) + } + + func testAvailableXcodesFirstCompatiblePrefersUniversalThenHostArchitecture() throws { + let universal = availableXcode("26.2.0", filename: "Xcode-26-universal.xip", architectures: [.arm64, .x86_64]) + let appleSilicon = availableXcode("26.2.0", filename: "Xcode-26-arm64.xip", architectures: [.arm64]) + let intel = availableXcode("26.2.0", filename: "Xcode-26-x86_64.xip", architectures: [.x86_64]) + + XCTAssertEqual([appleSilicon, universal, intel].firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: .arm64), universal) + XCTAssertEqual([appleSilicon, intel].firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: .x86_64), intel) + } + + func testXcodeListPresentationServiceFiltersAvailableRowsByArchitecture() throws { + let universal = availableXcode("15.0.0", filename: "Xcode-15.xip", architectures: [.arm64, .x86_64]) + let appleSilicon = availableXcode("16.0.0", filename: "Xcode-16-arm64.xip", architectures: [.arm64]) + let intel = availableXcode("14.0.0", filename: "Xcode-14-x86_64.xip", architectures: [.x86_64]) + + let rows = XcodeListPresentationService().availableRows( + availableXcodes: [universal, appleSilicon, intel], + installedXcodes: [], + selectedXcodePath: nil, + dataSource: .xcodeReleases, + architectures: [.arm64] + ) + + XCTAssertEqual(rows.map(\.version), [universal.version, appleSilicon.version]) + } + + func testRuntimeListPresentationServiceFiltersRowsByArchitecture() { + let response = DownloadableRuntimesResponse( + sdkToSimulatorMappings: [], + sdkToSeedMappings: [], + refreshInterval: 3600, + downloadables: [ + downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg", architectures: [.arm64, .x86_64]), + downloadableRuntime( + source: "https://example.com/iOS_17_Runtime.dmg", + architectures: [.arm64], + simulatorVersion: .init(buildUpdate: "21A1", version: "17.0"), + identifier: "com.apple.CoreSimulator.SimRuntime.iOS-17-0", + name: "iOS 17.0" + ), + downloadableRuntime( + source: "https://example.com/iOS_15_Runtime.dmg", + architectures: [.x86_64], + simulatorVersion: .init(buildUpdate: "19A1", version: "15.0"), + identifier: "com.apple.CoreSimulator.SimRuntime.iOS-15-0", + name: "iOS 15.0" + ) + ], + version: "2" + ) + + let rows = RuntimeListPresentationService().rows( + downloadableRuntimes: response, + installedRuntimes: [], + includeBetas: false, + architectures: [.arm64] + ) + + XCTAssertEqual(rows.first?.runtimes.map(\.visibleIdentifier), [ + "iOS 16.0 arm64|x86_64", + "iOS 17.0 arm64" + ]) + } + + private func availableXcode(_ version: String, filename: String, architectures: [Architecture]? = nil) -> AvailableXcode { + AvailableXcode( + version: Version(version)!, + url: URL(fileURLWithPath: "/" + filename), + filename: filename, + releaseDate: nil, + architectures: architectures + ) + } + + private func downloadableRuntime( + source: String?, + architectures: [Architecture]? = nil, + simulatorVersion: DownloadableRuntime.SimulatorVersion = .init(buildUpdate: "20A360", version: "16.0"), + identifier: String = "com.apple.CoreSimulator.SimRuntime.iOS-16-0", + name: String = "iOS 16.0" + ) -> DownloadableRuntime { + DownloadableRuntime( + category: .simulator, + simulatorVersion: simulatorVersion, + source: source, + architectures: architectures, + dictionaryVersion: 1, + contentType: .diskImage, + platform: .iOS, + identifier: identifier, + version: simulatorVersion.version, + fileSize: 42, + hostRequirements: nil, + name: name, + authentication: nil + ) + } +} From 048946bdf616573456fe3541c210757ef870adda Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 25 May 2026 09:12:11 -0500 Subject: [PATCH 05/18] filter archtecture on xcodeskit --- Xcodes/Resources/Localizable.xcstrings | 1 + .../XcodesKit/Models/Runtimes/Runtimes.swift | 5 ++ .../Models/XcodeReleases/Architecture.swift | 51 +++++++++++++++++++ .../Models/Xcodes/AvailableXcode.swift | 5 ++ .../RuntimeListPresentationService.swift | 9 ++-- .../XcodeListPresentationService.swift | 25 ++++++--- .../ArchitectureFilteringTests.swift | 19 +++++-- 7 files changed, 98 insertions(+), 17 deletions(-) diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 2dec37ab..ee07f6c2 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -19067,6 +19067,7 @@ } }, "Perform post-install steps" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift index 4cb88a9b..c15b7357 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift @@ -183,6 +183,11 @@ public extension Array where Element == DownloadableRuntime { guard !architectures.isEmpty else { return self } return filter { $0.architectures?.containsAny(architectures) == true } } + + func matchingArchitectureFilters(_ filters: [ArchitectureFilter]) -> [DownloadableRuntime] { + guard !filters.isEmpty else { return self } + return filter { filters.matches($0.architectures) } + } } extension InstalledRuntime { diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift index 16192ac6..b4dae178 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift @@ -60,6 +60,39 @@ public enum ArchitectureVariant: String, Codable, Equatable, Hashable, Identifia } } +public enum ArchitectureFilter: Equatable, Hashable, Sendable { + case architecture(Architecture) + case variant(ArchitectureVariant) + + public init?(_ rawValue: String) { + switch rawValue { + case Architecture.arm64.rawValue: + self = .architecture(.arm64) + case Architecture.x86_64.rawValue: + self = .architecture(.x86_64) + case ArchitectureVariant.appleSilicon.rawValue, "apple-silicon", "apple_silicon": + self = .variant(.appleSilicon) + case ArchitectureVariant.universal.rawValue: + self = .variant(.universal) + default: + return nil + } + } + + public func matches(_ architectures: [Architecture]?) -> Bool { + guard let architectures, !architectures.isEmpty else { return false } + + switch self { + case .architecture(let architecture): + return architectures == [architecture] + case .variant(.appleSilicon): + return architectures.isAppleSilicon + case .variant(.universal): + return architectures.isUniversal + } + } +} + extension Array where Element == Architecture { public var isAppleSilicon: Bool { self == [.arm64] @@ -72,4 +105,22 @@ extension Array where Element == Architecture { public func containsAny(_ architectures: [Architecture]) -> Bool { !Set(self).isDisjoint(with: architectures) } + + var listOutputSuffix: String { + guard !isEmpty else { return "" } + if isUniversal { + return " [\(ArchitectureVariant.universal.displayString)]" + } + if isAppleSilicon { + return " [\(ArchitectureVariant.appleSilicon.displayString)]" + } + return " [\(map(\.displayString).joined(separator: "|"))]" + } +} + +extension Array where Element == ArchitectureFilter { + func matches(_ architectures: [Architecture]?) -> Bool { + guard !isEmpty else { return true } + return contains { $0.matches(architectures) } + } } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift index fe7b57c0..679a4417 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift @@ -96,6 +96,11 @@ public extension Array where Element == AvailableXcode { return filter { $0.architectures?.containsAny(architectures) == true } } + func matchingArchitectureFilters(_ filters: [ArchitectureFilter]) -> [AvailableXcode] { + guard !filters.isEmpty else { return self } + return filter { filters.matches($0.architectures) } + } + /// Returns the best compatible Xcode for the given version and host architecture. /// Adapted from XcodesOrg/xcodes#470 by wmehanna. func firstCompatible(withVersion version: Version, hostArchitecture: Architecture) -> AvailableXcode? { diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift index 3b6baf26..47e0f640 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift @@ -16,8 +16,7 @@ public struct RuntimeListPresentationService: Sendable { } public var visibleIdentifier: String { - let architectureDescription = architectures?.map(\.rawValue).joined(separator: "|") - return platform.shortName + " " + completeVersion + (architectureDescription != nil ? " \(architectureDescription!)" : "") + platform.shortName + " " + completeVersion + (architectures?.listOutputSuffix ?? "") } fileprivate init( @@ -45,7 +44,7 @@ public struct RuntimeListPresentationService: Sendable { downloadableRuntimes: DownloadableRuntimesResponse, installedRuntimes: [InstalledRuntime], includeBetas: Bool, - architectures: [Architecture] = [] + architectures: [ArchitectureFilter] = [] ) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] { rows( downloadableRuntimes: downloadableRuntimes.downloadablesWithSDKBuildUpdates(), @@ -61,12 +60,12 @@ public struct RuntimeListPresentationService: Sendable { installedRuntimes: [InstalledRuntime], includeBetas: Bool, sdkToSeedMappings: [SDKToSeedMapping] = [], - architectures: [Architecture] = [] + architectures: [ArchitectureFilter] = [] ) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] { var unmatchedInstalledRuntimes = installedRuntimes var rows: [RuntimeRow] = [] - downloadableRuntimes.matchingArchitectures(architectures).forEach { downloadable in + downloadableRuntimes.matchingArchitectureFilters(architectures).forEach { downloadable in let matchingInstalledRuntimes = unmatchedInstalledRuntimes.removeAll { $0.build == downloadable.simulatorVersion.buildUpdate } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift index 0ba6135e..290b850c 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift @@ -6,6 +6,7 @@ public struct XcodeListPresentationService: Sendable { public struct AvailableRow: Equatable, Sendable { public let version: Version public let versionDescription: String + public let architectures: [Architecture]? public let isInstalled: Bool public let isSelected: Bool } @@ -13,6 +14,7 @@ public struct XcodeListPresentationService: Sendable { public struct InstalledRow: Equatable, Sendable { public let version: Version public let versionDescription: String + public let architectures: [Architecture]? public let path: Path public let isSelected: Bool } @@ -24,11 +26,12 @@ public struct XcodeListPresentationService: Sendable { installedXcodes: [InstalledXcode], selectedXcodePath: String?, dataSource: XcodeListDataSource, - architectures: [Architecture] = [] + architectures: [ArchitectureFilter] = [] ) -> [AvailableRow] { struct ReleasedVersion { let version: Version let releaseDate: Date? + let architectures: [Architecture]? } let adjustedAvailableXcodes = (dataSource == .apple @@ -37,24 +40,28 @@ public struct XcodeListPresentationService: Sendable { installedXcodes: installedXcodes ) : availableXcodes) - .matchingArchitectures(architectures) + .matchingArchitectureFilters(architectures) let adjustedInstalledXcodes = architectures.isEmpty ? installedXcodes - : installedXcodes.filter { $0.xcodeID.architectures?.containsAny(architectures) == true } + : installedXcodes.filter { architectures.matches($0.xcodeID.architectures) } var releasedVersions = adjustedAvailableXcodes.map { - ReleasedVersion(version: $0.version, releaseDate: $0.releaseDate) + ReleasedVersion(version: $0.version, releaseDate: $0.releaseDate, architectures: $0.architectures) } for installedXcode in adjustedInstalledXcodes { if !releasedVersions.contains(where: { $0.version.isEquivalent(to: installedXcode.version) }) { - releasedVersions.append(ReleasedVersion(version: installedXcode.version, releaseDate: nil)) + releasedVersions.append(ReleasedVersion(version: installedXcode.version, releaseDate: nil, architectures: installedXcode.xcodeID.architectures)) } else if let index = releasedVersions.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) && $0.version.buildMetadataIdentifiers.isEmpty }) { - releasedVersions[index] = ReleasedVersion(version: installedXcode.version, releaseDate: nil) + releasedVersions[index] = ReleasedVersion( + version: installedXcode.version, + releaseDate: nil, + architectures: installedXcode.xcodeID.architectures ?? releasedVersions[index].architectures + ) } } @@ -79,7 +86,8 @@ public struct XcodeListPresentationService: Sendable { } return AvailableRow( version: releasedVersion.version, - versionDescription: releasedVersion.version.appleDescriptionWithBuildIdentifier, + versionDescription: releasedVersion.version.appleDescriptionWithBuildIdentifier + (releasedVersion.architectures?.listOutputSuffix ?? ""), + architectures: releasedVersion.architectures, isInstalled: installedXcode != nil, isSelected: installedXcode?.path == selectedInstalledXcode?.path ) @@ -95,7 +103,8 @@ public struct XcodeListPresentationService: Sendable { .map { installedXcode in InstalledRow( version: installedXcode.version, - versionDescription: installedXcode.version.appleDescriptionWithBuildIdentifier, + versionDescription: installedXcode.version.appleDescriptionWithBuildIdentifier + (installedXcode.xcodeID.architectures?.listOutputSuffix ?? ""), + architectures: installedXcode.xcodeID.architectures, path: installedXcode.path, isSelected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true ) diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift index 788d59dd..acb6825c 100644 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift @@ -42,10 +42,21 @@ final class ArchitectureFilteringTests: XCTestCase { installedXcodes: [], selectedXcodePath: nil, dataSource: .xcodeReleases, - architectures: [.arm64] + architectures: [.architecture(.arm64), .variant(.universal)] ) XCTAssertEqual(rows.map(\.version), [universal.version, appleSilicon.version]) + XCTAssertEqual(rows.map(\.versionDescription), [ + "15.0 [Universal]", + "16.0 [Apple Silicon]" + ]) + } + + func testArchitectureFiltersParseRawArchitecturesAndVariants() { + XCTAssertEqual(ArchitectureFilter("arm64"), .architecture(.arm64)) + XCTAssertEqual(ArchitectureFilter("x86_64"), .architecture(.x86_64)) + XCTAssertEqual(ArchitectureFilter("appleSilicon"), .variant(.appleSilicon)) + XCTAssertEqual(ArchitectureFilter("universal"), .variant(.universal)) } func testRuntimeListPresentationServiceFiltersRowsByArchitecture() { @@ -77,12 +88,12 @@ final class ArchitectureFilteringTests: XCTestCase { downloadableRuntimes: response, installedRuntimes: [], includeBetas: false, - architectures: [.arm64] + architectures: [.architecture(.arm64), .variant(.universal)] ) XCTAssertEqual(rows.first?.runtimes.map(\.visibleIdentifier), [ - "iOS 16.0 arm64|x86_64", - "iOS 17.0 arm64" + "iOS 16.0 [Universal]", + "iOS 17.0 [Apple Silicon]" ]) } From f2275cb40083080a3f1c23c8a9dd428ab56a21c9 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 25 May 2026 09:43:59 -0500 Subject: [PATCH 06/18] support list grouping --- Xcodes.xcodeproj/project.pbxproj | 12 +- Xcodes/Backend/AppState.swift | 10 + .../Preferences/GeneralPreferencePane.swift | 1 + .../XcodeList/XcodeListCategory.swift | 20 ++ Xcodes/Frontend/XcodeList/XcodeListView.swift | 288 +++++++++++++--- Xcodes/Resources/Localizable.xcstrings | 132 ++++++- .../Models/Xcodes/XcodeListGrouping.swift | 325 ++++++++++++++++++ .../XcodeListGroupingTests.swift | 128 +++++++ XcodesTests/AppStateTests.swift | 16 + XcodesTests/Environment+Mock.swift | 4 +- 10 files changed, 885 insertions(+), 51 deletions(-) create mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift create mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListGroupingTests.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 6c9cba56..74e7c527 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -107,13 +107,13 @@ E84E4F542B333864003F3959 /* PlatformsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E84E4F532B333864003F3959 /* PlatformsListView.swift */; }; E84E4F572B335094003F3959 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E84E4F562B335094003F3959 /* OrderedCollections */; }; E862D43B2CC8B26F00BAA376 /* SRP in Frameworks */ = {isa = PBXBuildFile; productRef = E862D43A2CC8B26F00BAA376 /* SRP */; }; - E89CBD382D5FAB950037ED95 /* XcodesLoginKit in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD372D5FAB950037ED95 /* XcodesLoginKit */; }; - E89CBD3A2D5FB8920037ED95 /* XcodesLoginKitSecurityKey in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD392D5FB8920037ED95 /* XcodesLoginKitSecurityKey */; }; E86671272B309D2F0048559A /* PlatformsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E86671262B309D2F0048559A /* PlatformsView.swift */; }; E87AB3C52939B65E00D72F43 /* Hardware.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87AB3C42939B65E00D72F43 /* Hardware.swift */; }; E87DD6EB25D053FA00D86808 /* Progress+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87DD6EA25D053FA00D86808 /* Progress+.swift */; }; E89342FA25EDCC17007CF557 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89342F925EDCC17007CF557 /* NotificationManager.swift */; }; E8977EA325C11E1500835F80 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8977EA225C11E1500835F80 /* PreferencesView.swift */; }; + E89CBD382D5FAB950037ED95 /* XcodesLoginKit in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD372D5FAB950037ED95 /* XcodesLoginKit */; }; + E89CBD3A2D5FB8920037ED95 /* XcodesLoginKitSecurityKey in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD392D5FB8920037ED95 /* XcodesLoginKitSecurityKey */; }; E8B20CBF2A2EDEC20057D816 /* SDKs+Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */; }; E8C0EB1A291EF43E0081528A /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = E8C0EB19291EF43E0081528A /* XcodesKit */; }; E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */; }; @@ -299,12 +299,12 @@ E84E4F512B323A5F003F3959 /* CornerRadiusModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerRadiusModifier.swift; sourceTree = ""; }; E84E4F532B333864003F3959 /* PlatformsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsListView.swift; sourceTree = ""; }; E856BB73291EDD3D00DC438B /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = ""; }; - E89CBD3B2D5FC0B10037ED95 /* XcodesLoginKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesLoginKit; path = ../XcodesLoginKit; sourceTree = ""; }; E86671262B309D2F0048559A /* PlatformsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsView.swift; sourceTree = ""; }; E87AB3C42939B65E00D72F43 /* Hardware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hardware.swift; sourceTree = ""; }; E87DD6EA25D053FA00D86808 /* Progress+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Progress+.swift"; sourceTree = ""; }; E89342F925EDCC17007CF557 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; E8977EA225C11E1500835F80 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; + E89CBD3B2D5FC0B10037ED95 /* XcodesLoginKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesLoginKit; path = ../XcodesLoginKit; sourceTree = ""; }; E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SDKs+Xcode.swift"; sourceTree = ""; }; E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Runtimes.swift"; sourceTree = ""; }; E8CBDB8627ADD92000B22292 /* unxip */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = unxip; sourceTree = ""; }; @@ -1073,7 +1073,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 3.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; @@ -1326,7 +1326,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 3.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; @@ -1355,7 +1355,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 3.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index e6bdd3ca..a587e5e9 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -26,6 +26,9 @@ enum PreferenceKey: String { case allowedMajorVersions case hideSupportXcodes case xcodeListArchitectures + case enableGroupedXcodeList + case expandedMajorXcodeVersions + case expandedMinorXcodeVersions func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) } } @@ -144,6 +147,12 @@ class AppState: ObservableObject { } } + @Published var enableGroupedXcodeList = true { + didSet { + Current.defaults.set(enableGroupedXcodeList, forKey: PreferenceKey.enableGroupedXcodeList.rawValue) + } + } + // MARK: - Runtimes @Published var downloadableRuntimes: [DownloadableRuntime] = [] @@ -235,6 +244,7 @@ class AppState: ObservableObject { installPath = Current.defaults.string(forKey: "installPath") ?? Path.defaultInstallDirectory.string showOpenInRosettaOption = Current.defaults.bool(forKey: "showOpenInRosettaOption") ?? false terminateAfterLastWindowClosed = Current.defaults.bool(forKey: "terminateAfterLastWindowClosed") ?? false + enableGroupedXcodeList = Current.defaults.get(forKey: PreferenceKey.enableGroupedXcodeList.rawValue) as? Bool ?? true } // MARK: Timer diff --git a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift index 8a1516ff..2ccfe346 100644 --- a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift @@ -23,6 +23,7 @@ struct GeneralPreferencePane: View { GroupBox(label: Text("Misc")) { Toggle("TerminateAfterLastWindowClosed", isOn: $appState.terminateAfterLastWindowClosed) + Toggle("GroupXcodeVersionsInList", isOn: $appState.enableGroupedXcodeList) } .groupBoxStyle(PreferencesGroupBoxStyle()) } diff --git a/Xcodes/Frontend/XcodeList/XcodeListCategory.swift b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift index 1d85da98..b64cbe61 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListCategory.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift @@ -17,6 +17,17 @@ enum XcodeListCategory: String, CaseIterable, Identifiable, CustomStringConverti } var isManaged: Bool { PreferenceKey.xcodeListCategory.isManaged() } + + var versionFilter: XcodeListVersionFilter { + switch self { + case .all: + return .all + case .release: + return .release + case .beta: + return .prerelease + } + } } enum XcodeListArchitecture: String, CaseIterable, Identifiable, CustomStringConvertible { @@ -33,4 +44,13 @@ enum XcodeListArchitecture: String, CaseIterable, Identifiable, CustomStringConv } var isManaged: Bool { PreferenceKey.xcodeListCategory.isManaged() } + + var architectureFilters: [ArchitectureFilter] { + switch self { + case .universal: + return [] + case .appleSilicon: + return [.variant(.appleSilicon)] + } + } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 8b6363ab..efef4085 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -20,52 +20,33 @@ struct XcodeListView: View { self.architecture = architecture } - var visibleXcodes: [Xcode] { - var xcodes: [Xcode] - switch category { - case .all: - xcodes = appState.allXcodes - case .release: - xcodes = appState.allXcodes.filter { $0.version.isNotPrerelease } - case .beta: - xcodes = appState.allXcodes.filter { $0.version.isPrerelease } - } - - if architecture == .appleSilicon { - xcodes = xcodes.filter { $0.architectures == [.arm64] } - } - - - let latestMajor = xcodes.sorted(\.version) - .filter { $0.version.isNotPrerelease } - .last? - .version - .major - - xcodes = xcodes.filter { - if $0.installState.notInstalled, - let latestMajor = latestMajor, - $0.version.major < (latestMajor - min(latestMajor,allowedMajorVersions)) { - return false - } - - return true - } - - if !searchText.isEmpty { - xcodes = xcodes.filter { $0.description.contains(searchText) } - } - - if isInstalledOnly { - xcodes = xcodes.filter { $0.installState.installed } - } - - return xcodes + private var visibleXcodes: [XcodeListEntry] { + appState.allXcodes + .enumerated() + .map { XcodeListEntry(index: $0.offset, xcode: $0.element) } + .applying(XcodeListFilters( + versionFilter: category.versionFilter, + architectureFilters: architecture.architectureFilters, + allowedMajorVersions: allowedMajorVersions, + searchText: searchText, + installedOnly: isInstalledOnly + ), item: \.listItem) } var body: some View { - List(visibleXcodes, selection: $selectedXcodeID) { xcode in - XcodeListViewRow(xcode: xcode, selected: selectedXcodeID == xcode.id, appState: appState) + List(selection: $selectedXcodeID) { + if appState.enableGroupedXcodeList { + GroupedXcodeListContent( + xcodes: visibleXcodes, + selectedXcodeID: $selectedXcodeID, + appState: appState + ) + } else { + ForEach(visibleXcodes) { entry in + XcodeListViewRow(xcode: entry.xcode, selected: selectedXcodeID == entry.xcode.id, appState: appState) + .tag(entry.xcode.id) + } + } } .listStyle(.sidebar) .safeAreaInset(edge: .bottom, spacing: 0) { @@ -77,6 +58,227 @@ struct XcodeListView: View { } } +private struct XcodeListEntry: Identifiable { + let index: Int + let xcode: Xcode + + var id: Int { + index + } + + var listItem: XcodeListItem { + xcode.listItem + } +} + +private struct GroupedXcodeListContent: View { + let xcodes: [XcodeListEntry] + @Binding var selectedXcodeID: Xcode.ID? + let appState: AppState + + @AppStorage(PreferenceKey.expandedMajorXcodeVersions.rawValue) private var expandedMajorVersionStorage = "" + @AppStorage(PreferenceKey.expandedMinorXcodeVersions.rawValue) private var expandedMinorVersionStorage = "" + + private var expandedMajorVersions: Set { + get { + Set(expandedMajorVersionStorage.split(separator: ",").compactMap { Int($0) }) + } + nonmutating set { + expandedMajorVersionStorage = newValue.sorted().map(String.init).joined(separator: ",") + } + } + + private var expandedMinorVersions: Set { + get { + Set(expandedMinorVersionStorage.split(separator: ",").map(String.init)) + } + nonmutating set { + expandedMinorVersionStorage = newValue.sorted().joined(separator: ",") + } + } + + private var majorVersionGroups: [XcodeListElementMajorVersionGroup] { + xcodes.groupedByMajorVersion(item: \.listItem) + } + + var body: some View { + ForEach(majorVersionGroups) { majorVersionGroup in + let isMajorExpanded = expandedMajorVersions.contains(majorVersionGroup.majorVersion) + let majorVersions = majorVersionGroup.versions.map(\.xcode) + + XcodeVersionGroupRow( + displayName: majorVersionGroup.displayName, + latestRelease: majorVersions.latestRelease, + selectedVersion: majorVersions.first { $0.selected }, + installingVersion: majorVersions.first { $0.installState.installing }, + isExpanded: isMajorExpanded, + indentation: 0, + appState: appState, + onToggleExpanded: { + var updatedExpandedMajorVersions = expandedMajorVersions + var updatedExpandedMinorVersions = expandedMinorVersions + + if isMajorExpanded { + updatedExpandedMajorVersions.remove(majorVersionGroup.majorVersion) + majorVersionGroup.minorVersionGroups.forEach { + updatedExpandedMinorVersions.remove($0.id) + } + } else { + updatedExpandedMajorVersions.insert(majorVersionGroup.majorVersion) + } + + self.expandedMajorVersions = updatedExpandedMajorVersions + self.expandedMinorVersions = updatedExpandedMinorVersions + } + ) + .tag(majorVersions.first { $0.selected }?.id) + + if isMajorExpanded { + ForEach(majorVersionGroup.minorVersionGroups) { minorVersionGroup in + let isMinorExpanded = expandedMinorVersions.contains(minorVersionGroup.id) + let minorVersions = minorVersionGroup.versions.map(\.xcode) + + XcodeVersionGroupRow( + displayName: minorVersionGroup.displayName, + latestRelease: minorVersions.latestRelease, + selectedVersion: minorVersions.first { $0.selected }, + installingVersion: minorVersions.first { $0.installState.installing }, + isExpanded: isMinorExpanded, + indentation: 20, + appState: appState, + onToggleExpanded: { + var updatedExpandedMinorVersions = expandedMinorVersions + + if isMinorExpanded { + updatedExpandedMinorVersions.remove(minorVersionGroup.id) + } else { + updatedExpandedMinorVersions.insert(minorVersionGroup.id) + } + + self.expandedMinorVersions = updatedExpandedMinorVersions + } + ) + .tag(minorVersions.first { $0.selected }?.id) + + if isMinorExpanded { + ForEach(minorVersionGroup.versions) { entry in + XcodeListViewRow(xcode: entry.xcode, selected: selectedXcodeID == entry.xcode.id, appState: appState) + .padding(.leading, 40) + .tag(entry.xcode.id) + } + } + } + } + } + } +} + +private struct XcodeVersionGroupRow: View { + let displayName: String + let latestRelease: Xcode? + let selectedVersion: Xcode? + let installingVersion: Xcode? + let isExpanded: Bool + let indentation: CGFloat + let appState: AppState + let onToggleExpanded: () -> Void + + var body: some View { + HStack { + Button(action: onToggleExpanded) { + HStack(spacing: 8) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + + icon + + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: "Xcode \(displayName)") + .font(.body.weight(indentation == 0 ? .medium : .regular)) + + if let latestRelease { + Text(verbatim: "Latest: \(latestRelease.description)") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + } + } + .buttonStyle(.plain) + + selectControl + .padding(.trailing, 16) + installControl + } + .padding(.leading, indentation) + .padding(.vertical, indentation == 0 ? 8 : 6) + .contentShape(Rectangle()) + } + + @ViewBuilder + private var icon: some View { + if let icon = latestRelease?.icon { + Image(nsImage: icon) + .resizable() + .frame(width: indentation == 0 ? 32 : 28, height: indentation == 0 ? 32 : 28) + } else { + Image(latestRelease?.version.isPrerelease == true ? "xcode-beta" : "xcode") + .resizable() + .frame(width: indentation == 0 ? 32 : 28, height: indentation == 0 ? 32 : 28) + .opacity(0.2) + } + } + + @ViewBuilder + private var selectControl: some View { + if selectedVersion?.selected == true { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .help("ActiveVersionDescription") + } + } + + @ViewBuilder + private var installControl: some View { + if let installingVersion, + case let .installing(installationStep) = installingVersion.installState { + InstallationStepRowView( + installationStep: installationStep, + highlighted: false, + cancel: { appState.presentedAlert = .cancelInstall(xcode: installingVersion) } + ) + } else if let latestRelease { + switch latestRelease.installState { + case .installed: + Button("Open") { appState.open(xcode: latestRelease) } + .textCase(.uppercase) + .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false)) + .help("OpenDescription") + case .notInstalled: + Button("Install") { + appState.checkMinVersionAndInstall(id: latestRelease.id) + } + .textCase(.uppercase) + .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) + .help("InstallDescription") + case .installing: + EmptyView() + } + } + } +} + +private extension Array where Element == Xcode { + var latestRelease: Xcode? { + filter { $0.version.isNotPrerelease } + .sorted { $0.version < $1.version } + .last + } +} + struct PlatformsPocket: View { @SwiftUI.Environment(\.openWindow) private var openWindow diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index ee07f6c2..11d1c3d8 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -23204,6 +23204,136 @@ } } }, + "GroupXcodeVersionsInList" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + } + } + }, "TerminateAfterLastWindowClosed" : { "localizations" : { "ar" : { @@ -24999,4 +25129,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift new file mode 100644 index 00000000..0ddf9051 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift @@ -0,0 +1,325 @@ +import Foundation +@preconcurrency import Version + +public enum XcodeListVersionFilter: Equatable, Sendable { + case all + case release + case prerelease +} + +public struct XcodeListFilters: Equatable, Sendable { + public let versionFilter: XcodeListVersionFilter + public let architectureFilters: [ArchitectureFilter] + public let allowedMajorVersions: Int? + public let searchText: String + public let installedOnly: Bool + + public init( + versionFilter: XcodeListVersionFilter = .all, + architectureFilters: [ArchitectureFilter] = [], + allowedMajorVersions: Int? = nil, + searchText: String = "", + installedOnly: Bool = false + ) { + self.versionFilter = versionFilter + self.architectureFilters = architectureFilters + self.allowedMajorVersions = allowedMajorVersions + self.searchText = searchText + self.installedOnly = installedOnly + } +} + +public struct XcodeMinorVersionGroup: Identifiable, Sendable { + public let majorVersion: Int + public let minorVersion: Int + public let versions: [XcodeListItem] + + public var id: String { + "\(majorVersion).\(minorVersion)" + } + + public var latestRelease: XcodeListItem? { + versions.latestRelease + } + + public var displayName: String { + "\(majorVersion).\(minorVersion)" + } + + public var hasInstalled: Bool { + versions.contains { $0.installState.installed } + } + + public var hasInstalling: Bool { + versions.contains { $0.installState.installing } + } + + public var selectedVersion: XcodeListItem? { + versions.first { $0.selected } + } + + public init(majorVersion: Int, minorVersion: Int, versions: [XcodeListItem]) { + self.majorVersion = majorVersion + self.minorVersion = minorVersion + self.versions = versions + } +} + +public struct XcodeMajorVersionGroup: Identifiable, Sendable { + public let majorVersion: Int + public let minorVersionGroups: [XcodeMinorVersionGroup] + + public var id: Int { + majorVersion + } + + public var versions: [XcodeListItem] { + minorVersionGroups.flatMap(\.versions) + } + + public var latestRelease: XcodeListItem? { + versions.latestRelease + } + + public var displayName: String { + "\(majorVersion)" + } + + public var hasInstalled: Bool { + minorVersionGroups.contains { $0.hasInstalled } + } + + public var hasInstalling: Bool { + minorVersionGroups.contains { $0.hasInstalling } + } + + public var selectedVersion: XcodeListItem? { + minorVersionGroups.compactMap(\.selectedVersion).first + } + + public init(majorVersion: Int, minorVersionGroups: [XcodeMinorVersionGroup]) { + self.majorVersion = majorVersion + self.minorVersionGroups = minorVersionGroups + } +} + +public struct XcodeListElementMinorVersionGroup: Identifiable { + public let majorVersion: Int + public let minorVersion: Int + public let versions: [Element] + + public var id: String { + "\(majorVersion).\(minorVersion)" + } + + public var displayName: String { + "\(majorVersion).\(minorVersion)" + } + + public init(majorVersion: Int, minorVersion: Int, versions: [Element]) { + self.majorVersion = majorVersion + self.minorVersion = minorVersion + self.versions = versions + } +} + +public struct XcodeListElementMajorVersionGroup: Identifiable { + public let majorVersion: Int + public let minorVersionGroups: [XcodeListElementMinorVersionGroup] + + public var id: Int { + majorVersion + } + + public var versions: [Element] { + minorVersionGroups.flatMap(\.versions) + } + + public var displayName: String { + "\(majorVersion)" + } + + public init(majorVersion: Int, minorVersionGroups: [XcodeListElementMinorVersionGroup]) { + self.majorVersion = majorVersion + self.minorVersionGroups = minorVersionGroups + } +} + +public extension Array { + func applying(_ filters: XcodeListFilters, item: (Element) -> XcodeListItem) -> [Element] { + let filteredItems = map { element in + XcodeListFilteredElement(element: element, item: item(element)) + } + .applying(filters) + + return filteredItems.map(\.element) + } + + func groupedByMajorVersion(item: (Element) -> XcodeListItem) -> [XcodeListElementMajorVersionGroup] { + Dictionary(grouping: self, by: { item($0).version.major }) + .map { majorVersion, elements in + let minorVersionGroups = Dictionary(grouping: elements, by: { item($0).version.minor }) + .map { minorVersion, minorElements in + XcodeListElementMinorVersionGroup( + majorVersion: majorVersion, + minorVersion: minorVersion, + versions: minorElements.sorted { item($0).version > item($1).version } + ) + } + .sorted { $0.minorVersion > $1.minorVersion } + + return XcodeListElementMajorVersionGroup( + majorVersion: majorVersion, + minorVersionGroups: minorVersionGroups + ) + } + .sorted { $0.majorVersion > $1.majorVersion } + } +} + +public extension Array where Element == XcodeListItem { + func applying(_ filters: XcodeListFilters) -> [XcodeListItem] { + var items = self + + switch filters.versionFilter { + case .all: + break + case .release: + items = items.filter { $0.version.isNotPrerelease } + case .prerelease: + items = items.filter { $0.version.isPrerelease } + } + + if !filters.architectureFilters.isEmpty { + items = items.filter { filters.architectureFilters.matches($0.architectures) } + } + + if let allowedMajorVersions = filters.allowedMajorVersions { + items = items.filteringUninstalledVersions(allowedMajorVersions: allowedMajorVersions) + } + + if !filters.searchText.isEmpty { + items = items.filter { $0.version.appleDescription.contains(filters.searchText) } + } + + if filters.installedOnly { + items = items.filter { $0.installState.installed } + } + + return items + } + + func groupedByMajorVersion() -> [XcodeMajorVersionGroup] { + Dictionary(grouping: self, by: { $0.version.major }) + .map { majorVersion, xcodes in + let minorVersionGroups = Dictionary(grouping: xcodes, by: { $0.version.minor }) + .map { minorVersion, minorXcodes in + XcodeMinorVersionGroup( + majorVersion: majorVersion, + minorVersion: minorVersion, + versions: minorXcodes.sorted { $0.version > $1.version } + ) + } + .sorted { $0.minorVersion > $1.minorVersion } + + return XcodeMajorVersionGroup( + majorVersion: majorVersion, + minorVersionGroups: minorVersionGroups + ) + } + .sorted { $0.majorVersion > $1.majorVersion } + } + + func filteringUninstalledVersions(allowedMajorVersions: Int) -> [XcodeListItem] { + guard + let latestMajor = sorted(by: { $0.version < $1.version }) + .filter({ $0.version.isNotPrerelease }) + .last? + .version + .major + else { return self } + + let oldestAllowedMajor = latestMajor - Swift.min(latestMajor, allowedMajorVersions) + return filter { item in + if item.installState.notInstalled, item.version.major < oldestAllowedMajor { + return false + } + return true + } + } + + var latestRelease: XcodeListItem? { + filter { $0.version.isNotPrerelease } + .sorted { $0.version < $1.version } + .last + } +} + +private struct XcodeListFilteredElement { + let element: Element + let item: XcodeListItem +} + +private extension Array { + func applying(_ filters: XcodeListFilters) -> [Element] where Element: XcodeListFilterable { + var elements = self + + switch filters.versionFilter { + case .all: + break + case .release: + elements = elements.filter { $0.listItem.version.isNotPrerelease } + case .prerelease: + elements = elements.filter { $0.listItem.version.isPrerelease } + } + + if !filters.architectureFilters.isEmpty { + elements = elements.filter { filters.architectureFilters.matches($0.listItem.architectures) } + } + + if let allowedMajorVersions = filters.allowedMajorVersions { + elements = elements.filteringUninstalledVersions(allowedMajorVersions: allowedMajorVersions) + } + + if !filters.searchText.isEmpty { + elements = elements.filter { $0.listItem.version.appleDescription.contains(filters.searchText) } + } + + if filters.installedOnly { + elements = elements.filter { $0.listItem.installState.installed } + } + + return elements + } + + func filteringUninstalledVersions(allowedMajorVersions: Int) -> [Element] where Element: XcodeListFilterable { + guard + let latestMajor = sorted(by: { $0.listItem.version < $1.listItem.version }) + .filter({ $0.listItem.version.isNotPrerelease }) + .last? + .listItem + .version + .major + else { return self } + + let oldestAllowedMajor = latestMajor - Swift.min(latestMajor, allowedMajorVersions) + return filter { element in + if element.listItem.installState.notInstalled, element.listItem.version.major < oldestAllowedMajor { + return false + } + return true + } + } +} + +private protocol XcodeListFilterable { + var listItem: XcodeListItem { get } +} + +extension XcodeListItem: XcodeListFilterable { + fileprivate var listItem: XcodeListItem { self } +} + +extension XcodeListFilteredElement: XcodeListFilterable { + fileprivate var listItem: XcodeListItem { item } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListGroupingTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListGroupingTests.swift new file mode 100644 index 00000000..001324d6 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListGroupingTests.swift @@ -0,0 +1,128 @@ +import XCTest +@preconcurrency import Path +import Version +@testable import XcodesKit + +final class XcodeListGroupingTests: XCTestCase { + func testGroupsVersionsByMajorAndMinorVersionDescending() throws { + let items = try [ + item("15.0.1"), + item("16.1.0"), + item("16.0.0"), + item("16.1.1"), + item("15.2.0") + ] + + let groups = items.groupedByMajorVersion() + + XCTAssertEqual(groups.map(\.majorVersion), [16, 15]) + XCTAssertEqual(groups[0].minorVersionGroups.map(\.displayName), ["16.1", "16.0"]) + XCTAssertEqual(groups[0].minorVersionGroups[0].versions.map(\.version), [ + try XCTUnwrap(Version("16.1.1")), + try XCTUnwrap(Version("16.1.0")) + ]) + XCTAssertEqual(groups[1].minorVersionGroups.map(\.displayName), ["15.2", "15.0"]) + } + + func testGroupRollupsUseLatestReleaseAndInstallationState() throws { + let installedPath = try XCTUnwrap(Path("/Applications/Xcode-16.0.app")) + let items = try [ + item("16.1.0-Beta", installState: .notInstalled), + item("16.0.1", installState: .notInstalled), + item("16.0.0", installState: .installed(installedPath), selected: true) + ] + + let group = try XCTUnwrap(items.groupedByMajorVersion().first) + let minorGroup = try XCTUnwrap(group.minorVersionGroups.first { $0.minorVersion == 0 }) + + XCTAssertEqual(group.latestRelease?.version, try XCTUnwrap(Version("16.0.1"))) + XCTAssertEqual(minorGroup.latestRelease?.version, try XCTUnwrap(Version("16.0.1"))) + XCTAssertTrue(group.hasInstalled) + XCTAssertTrue(minorGroup.hasInstalled) + XCTAssertEqual(group.selectedVersion?.version, try XCTUnwrap(Version("16.0.0"))) + XCTAssertEqual(minorGroup.selectedVersion?.version, try XCTUnwrap(Version("16.0.0"))) + } + + func testGroupRollupsTrackInstallingVersions() throws { + let progress = Progress(totalUnitCount: 100) + let items = try [ + item("16.0.0", installState: .installing(.downloading(progress: progress))), + item("16.0.1") + ] + + let group = try XCTUnwrap(items.groupedByMajorVersion().first) + let minorGroup = try XCTUnwrap(group.minorVersionGroups.first) + + XCTAssertTrue(group.hasInstalling) + XCTAssertTrue(minorGroup.hasInstalling) + } + + func testAppliesVersionArchitectureSearchAndInstalledFilters() throws { + let installedPath = try XCTUnwrap(Path("/Applications/Xcode-16.0.app")) + let items = try [ + item("16.1.0-Beta", architectures: [.arm64]), + item("16.0.0", installState: .installed(installedPath), architectures: [.arm64]), + item("15.0.0", architectures: [.arm64, .x86_64]) + ] + + let filtered = items.applying(XcodeListFilters( + versionFilter: .release, + architectureFilters: [.variant(.appleSilicon)], + searchText: "16", + installedOnly: true + )) + + XCTAssertEqual(filtered.map(\.version), [try XCTUnwrap(Version("16.0.0"))]) + } + + func testAllowedMajorVersionsFilterKeepsInstalledOlderVersions() throws { + let installedPath = try XCTUnwrap(Path("/Applications/Xcode-14.0.app")) + let items = try [ + item("16.0.0"), + item("15.0.0"), + item("14.0.0", installState: .installed(installedPath)), + item("13.0.0") + ] + + let filtered = items.filteringUninstalledVersions(allowedMajorVersions: 1) + + XCTAssertEqual(filtered.map(\.version), [ + try XCTUnwrap(Version("16.0.0")), + try XCTUnwrap(Version("15.0.0")), + try XCTUnwrap(Version("14.0.0")) + ]) + } + + func testGenericFilteringAndGroupingPreservesDuplicateIDs() throws { + let items = try [ + PositionedXcodeListItem(position: 0, item: item("3.2.3+10M2262")), + PositionedXcodeListItem(position: 1, item: item("3.2.3+10M2262")) + ] + + let filtered = items.applying(XcodeListFilters(searchText: "3.2.3"), item: \.item) + let groups = filtered.groupedByMajorVersion(item: \.item) + + XCTAssertEqual(filtered.map(\.position), [0, 1]) + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups.first?.versions.map(\.position), [0, 1]) + } + + private func item( + _ version: String, + installState: XcodeInstallState = .notInstalled, + selected: Bool = false, + architectures: [Architecture]? = nil + ) throws -> XcodeListItem { + XcodeListItem( + version: try XCTUnwrap(Version(version)), + installState: installState, + selected: selected, + architectures: architectures + ) + } +} + +private struct PositionedXcodeListItem { + let position: Int + let item: XcodeListItem +} diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 85a3c3da..88cc48e3 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -68,6 +68,22 @@ class AppStateTests: XCTestCase { XCTAssertNil(subject.isPreparingUserForActionRequiringHelper) } + func test_SetupDefaults_EnableGroupedXcodeListDefaultsToTrue() { + subject.setupDefaults() + + XCTAssertTrue(subject.enableGroupedXcodeList) + } + + func test_SetupDefaults_EnableGroupedXcodeListUsesStoredValue() { + Current.defaults.get = { key in + key == PreferenceKey.enableGroupedXcodeList.rawValue ? false : nil + } + + subject.setupDefaults() + + XCTAssertFalse(subject.enableGroupedXcodeList) + } + func test_PrepareForHelperAction_StaleActionDoesNotClearReplacementAction() { var responses = [Bool]() subject.prepareForHelperAction { responses.append($0) } diff --git a/XcodesTests/Environment+Mock.swift b/XcodesTests/Environment+Mock.swift index 5a9e5d8b..6f351ce1 100644 --- a/XcodesTests/Environment+Mock.swift +++ b/XcodesTests/Environment+Mock.swift @@ -104,7 +104,9 @@ extension Defaults { date: { _ in nil }, setDate: { _, _ in }, set: { _, _ in }, - removeObject: { _ in } + removeObject: { _ in }, + get: { _ in nil }, + bool: { _ in nil } ) } } From 6ff7398de5a80886b2bcd375b9ac48dd8b8b6238 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 25 May 2026 10:03:16 -0500 Subject: [PATCH 07/18] Default architecture filters to current machine --- Xcodes/Frontend/InfoPane/PlatformsView.swift | 8 +++++-- Xcodes/Frontend/MainWindow.swift | 2 +- Xcodes/Frontend/XcodeList/MainToolbar.swift | 10 ++++++--- .../XcodeList/XcodeListCategory.swift | 21 +++++++++++++++++-- .../Models/XcodeReleases/Architecture.swift | 10 ++++++++- .../ArchitectureFilteringTests.swift | 12 +++++++++++ 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index d71ffa6b..15e3e97b 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -11,7 +11,7 @@ import XcodesKit struct PlatformsView: View { @EnvironmentObject var appState: AppState - @AppStorage("selectedRuntimeArchitecture") private var selectedVariant: ArchitectureVariant = .universal + @AppStorage("selectedRuntimeArchitecture") private var selectedVariant: ArchitectureVariant = .defaultForMachine() let xcode: Xcode @@ -39,7 +39,7 @@ struct PlatformsView: View { Spacer() Picker("Architecture", selection: $selectedVariant) { ForEach(ArchitectureVariant.allCases, id: \.self) { arch in - Label(arch.displayString, systemImage: arch.iconName) + Label(variantLabel(for: arch), systemImage: arch.iconName) .tag(arch) } .labelStyle(.trailingIcon) @@ -64,6 +64,10 @@ struct PlatformsView: View { } + + private func variantLabel(for variant: ArchitectureVariant) -> String { + variant == .defaultForMachine() ? "\(variant.displayString) (\(localizeString("This Mac")))" : variant.displayString + } @ViewBuilder func runtimeView(runtime: DownloadableRuntime) -> some View { diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 8eab7308..11c52886 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -15,7 +15,7 @@ struct MainWindow: View { // FB8979533 SceneStorage doesn't restore value after app is quit by user @AppStorage("isShowingInfoPane") private var isShowingInfoPane = false @AppStorage("xcodeListCategory") private var category: XcodeListCategory = .all - @AppStorage("xcodeListArchitecture") private var architecture: XcodeListArchitecture = .universal + @AppStorage("xcodeListArchitecture") private var architecture: XcodeListArchitecture = .defaultForCurrentMachine @AppStorage("isInstalledOnly") private var isInstalledOnly = false var body: some View { diff --git a/Xcodes/Frontend/XcodeList/MainToolbar.swift b/Xcodes/Frontend/XcodeList/MainToolbar.swift index b0ad763e..f2f645f0 100644 --- a/Xcodes/Frontend/XcodeList/MainToolbar.swift +++ b/Xcodes/Frontend/XcodeList/MainToolbar.swift @@ -25,7 +25,7 @@ struct MainToolbarModifier: ViewModifier { Spacer() - let isFiltering = isInstalledOnly || category != .all || architectures != .universal + let isFiltering = isInstalledOnly || category != .all || !architectures.isCurrentMachineDefault Menu("Filter", systemImage: "line.horizontal.3.decrease.circle") { Section { Toggle("Installed Only", systemImage: "arrow.down.app", isOn: $isInstalledOnly) .labelStyle(.titleAndIcon) @@ -47,9 +47,9 @@ struct MainToolbarModifier: ViewModifier { Section { Picker("Architecture", selection: $architectures) { - Label("Universal", systemImage: "cpu.fill") + Label(architecturesLabel(for: .universal), systemImage: "cpu.fill") .tag(XcodeListArchitecture.universal) - Label("Apple Silicon", systemImage: "m4.button.horizontal") + Label(architecturesLabel(for: .appleSilicon), systemImage: "m4.button.horizontal") .foregroundColor(.accentColor) .tag(XcodeListArchitecture.appleSilicon) } @@ -62,6 +62,10 @@ struct MainToolbarModifier: ViewModifier { .symbolVariant(isFiltering ? .fill : .none) } } + + private func architecturesLabel(for architecture: XcodeListArchitecture) -> String { + architecture.menuDescription + } } extension View { diff --git a/Xcodes/Frontend/XcodeList/XcodeListCategory.swift b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift index b64cbe61..f1bf0e4d 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListCategory.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift @@ -35,6 +35,15 @@ enum XcodeListArchitecture: String, CaseIterable, Identifiable, CustomStringConv case appleSilicon var id: Self { self } + + static var defaultForCurrentMachine: Self { + switch ArchitectureVariant.defaultForMachine() { + case .universal: + return .universal + case .appleSilicon: + return .appleSilicon + } + } var description: String { switch self { @@ -42,13 +51,21 @@ enum XcodeListArchitecture: String, CaseIterable, Identifiable, CustomStringConv case .appleSilicon: return localizeString("Apple Silicon") } } + + var menuDescription: String { + isCurrentMachineDefault ? "\(description) (\(localizeString("This Mac")))" : description + } - var isManaged: Bool { PreferenceKey.xcodeListCategory.isManaged() } + var isCurrentMachineDefault: Bool { + self == Self.defaultForCurrentMachine + } + + var isManaged: Bool { PreferenceKey.xcodeListArchitectures.isManaged() } var architectureFilters: [ArchitectureFilter] { switch self { case .universal: - return [] + return [.variant(.universal)] case .appleSilicon: return [.variant(.appleSilicon)] } diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift index b4dae178..7958a8f5 100644 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift @@ -58,6 +58,10 @@ public enum ArchitectureVariant: String, Codable, Equatable, Hashable, Identifia return "cpu.fill" } } + + public static func defaultForMachine(machineHardwareName: String? = HostHardware.currentMachineHardwareName()) -> Self { + HostHardware.isAppleSilicon(machineHardwareName: machineHardwareName) ? .appleSilicon : .universal + } } public enum ArchitectureFilter: Equatable, Hashable, Sendable { @@ -80,7 +84,7 @@ public enum ArchitectureFilter: Equatable, Hashable, Sendable { } public func matches(_ architectures: [Architecture]?) -> Bool { - guard let architectures, !architectures.isEmpty else { return false } + guard let architectures, !architectures.isEmpty else { return true } switch self { case .architecture(let architecture): @@ -119,6 +123,10 @@ extension Array where Element == Architecture { } extension Array where Element == ArchitectureFilter { + public static func defaultForMachine(machineHardwareName: String? = HostHardware.currentMachineHardwareName()) -> [ArchitectureFilter] { + [.variant(.defaultForMachine(machineHardwareName: machineHardwareName))] + } + func matches(_ architectures: [Architecture]?) -> Bool { guard !isEmpty else { return true } return contains { $0.matches(architectures) } diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift index acb6825c..d99608e6 100644 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift @@ -59,6 +59,18 @@ final class ArchitectureFilteringTests: XCTestCase { XCTAssertEqual(ArchitectureFilter("universal"), .variant(.universal)) } + func testArchitectureFiltersKeepUnknownArchitectureEntriesVisible() { + XCTAssertTrue([ArchitectureFilter.variant(.appleSilicon)].matches(nil)) + XCTAssertTrue([ArchitectureFilter.variant(.universal)].matches([])) + } + + func testDefaultArchitectureFilterUsesMachineArchitecture() { + XCTAssertEqual(ArchitectureVariant.defaultForMachine(machineHardwareName: "arm64"), .appleSilicon) + XCTAssertEqual(ArchitectureVariant.defaultForMachine(machineHardwareName: "x86_64"), .universal) + XCTAssertEqual([ArchitectureFilter].defaultForMachine(machineHardwareName: "arm64"), [.variant(.appleSilicon)]) + XCTAssertEqual([ArchitectureFilter].defaultForMachine(machineHardwareName: "x86_64"), [.variant(.universal)]) + } + func testRuntimeListPresentationServiceFiltersRowsByArchitecture() { let response = DownloadableRuntimesResponse( sdkToSimulatorMappings: [], From abee8143e7ae6601c5eaf92559b16f347206dd73 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 25 May 2026 10:23:54 -0500 Subject: [PATCH 08/18] support federated login --- Xcodes.xcodeproj/project.pbxproj | 4 + Xcodes/Backend/AppState.swift | 22 ++++- Xcodes/Frontend/MainWindow.swift | 5 ++ .../SignIn/SignInCredentialsView.swift | 4 +- .../Frontend/SignIn/SignInFederatedView.swift | 88 +++++++++++++++++++ 5 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 Xcodes/Frontend/SignIn/SignInFederatedView.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 74e7c527..3da9eaf7 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ CA9FF8E625959BB800E47BAF /* AuditTokenHack.m in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8E525959BB800E47BAF /* AuditTokenHack.m */; }; CA9FF9362595B44700E47BAF /* HelperClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF9352595B44700E47BAF /* HelperClient.swift */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; + E89CBD402D6434E10037ED95 /* SignInFederatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */; }; CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */; }; CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */; }; CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */; }; @@ -241,6 +242,7 @@ CA9FF9252595A7EB00E47BAF /* Scripts */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Scripts; sourceTree = ""; }; CA9FF9352595B44700E47BAF /* HelperClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperClient.swift; sourceTree = ""; }; CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsView.swift; sourceTree = ""; }; + E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInFederatedView.swift; sourceTree = ""; }; CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn2FAView.swift; sourceTree = ""; }; CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSMSView.swift; sourceTree = ""; }; CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInPhoneListView.swift; sourceTree = ""; }; @@ -442,6 +444,7 @@ 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */, 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */, CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */, + E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */, CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */, CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */, CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */, @@ -922,6 +925,7 @@ CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, E8DA461125FAF7FB002E85EF /* NotificationsView.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, + E89CBD402D6434E10037ED95 /* SignInFederatedView.swift in Sources */, E81D7EA02805250100A205FC /* Collection+.swift in Sources */, E8B20CBF2A2EDEC20057D816 /* SDKs+Xcode.swift in Sources */, CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */, diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index a587e5e9..3d432196 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -290,19 +290,29 @@ class AppState: ObservableObject { } } - func signIn(username: String, password: String) { + func signIn(username: String, password: String?) { authError = nil startAuthenticationTask { _ = try await self.signInAsync(username: username.lowercased(), password: password) } } - func signInAsync(username: String, password: String) async throws -> AuthenticationState { - try? Current.keychain.set(password, key: username) + func signInAsync(username: String, password: String?) async throws -> AuthenticationState { + if let password, !password.isEmpty { + try? Current.keychain.set(password, key: username) + } Current.defaults.set(username, forKey: "username") return try await performAuthenticationRequest { - try await client.srpLogin(accountName: username, password: password) + try await client.authenticationState(accountName: username, password: password) + } + } + + func submitFederatedAuthenticationCallback(_ callbackURLString: String) { + startAuthenticationTask { + _ = try await self.performAuthenticationRequest { + try await self.client.validateFederatedCallbackURLString(callbackURLString) + } } } @@ -382,6 +392,8 @@ class AppState: ObservableObject { switch self.authenticationState { case .authenticated, .unauthenticated, .notAppleDeveloper: self.presentedSheet = nil + case .waitingForFederatedAuthentication: + break case let .waitingForSecondFactor(option, authOptions, sessionData): self.handleTwoFactorOption(option, authOptions: authOptions, serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt) } @@ -867,6 +879,8 @@ class AppState: ObservableObject { throw AuthenticationError.invalidSession case .notAppleDeveloper: throw AuthenticationError.notDeveloperAppleId + case .waitingForFederatedAuthentication: + return false case .waitingForSecondFactor: return false } diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 11c52886..6a826135 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -1,6 +1,7 @@ import ErrorHandling import SwiftUI import XcodesKit +import XcodesLoginKit import Path import Version @@ -130,6 +131,10 @@ struct MainWindow: View { } } .padding() + } else if case let .waitingForFederatedAuthentication(federationResponse) = appState.authenticationState { + SignInFederatedView(federationResponse: federationResponse) + .environmentObject(appState) + .frame(width: 400) } else { SignInCredentialsView() .frame(width: 400) diff --git a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift index 7c108e23..8102e61e 100644 --- a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift +++ b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift @@ -48,12 +48,12 @@ struct SignInCredentialsView: View { .keyboardShortcut(.cancelAction) ProgressButton( isInProgress: appState.isProcessingAuthRequest, - action: { appState.signIn(username: username, password: password) }, + action: { appState.signIn(username: username, password: password.isEmpty ? nil : password) }, label: { Text("Next") } ) - .disabled(username.isEmpty || password.isEmpty) + .disabled(username.isEmpty) .keyboardShortcut(.defaultAction) } .frame(height: 25) diff --git a/Xcodes/Frontend/SignIn/SignInFederatedView.swift b/Xcodes/Frontend/SignIn/SignInFederatedView.swift new file mode 100644 index 00000000..1c702e2f --- /dev/null +++ b/Xcodes/Frontend/SignIn/SignInFederatedView.swift @@ -0,0 +1,88 @@ +import SwiftUI +import XcodesLoginKit + +struct SignInFederatedView: View { + @EnvironmentObject var appState: AppState + let federationResponse: FederationResponse + @State private var callbackURLString = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("SignInWithApple") + .bold() + .padding(.vertical) + + Text("This Apple ID uses federated authentication via \(organizationName).") + .fixedSize(horizontal: false, vertical: true) + + Button("Open Browser") { + if let idpURL = federationResponse.idpURL { + NSWorkspace.shared.open(idpURL) + } + } + .disabled(federationResponse.idpURL == nil) + + TextField("Paste redirected URL", text: $callbackURLString) + + if appState.authError != nil { + Text(appState.authError?.legibleLocalizedDescription ?? "") + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.red) + } + + HStack { + Spacer() + Button("Cancel") { + appState.authError = nil + appState.presentedSheet = nil + } + .keyboardShortcut(.cancelAction) + + ProgressButton( + isInProgress: appState.isProcessingAuthRequest, + action: { appState.submitFederatedAuthenticationCallback(callbackURLString) }, + label: { + Text("Next") + } + ) + .disabled(callbackURLString.isEmpty) + .keyboardShortcut(.defaultAction) + } + .frame(height: 25) + } + .padding() + } + + private var organizationName: String { + let orgName = federationResponse.federatedAuthIntro?.orgName ?? "your organization" + if let idpName = federationResponse.federatedAuthIntro?.idpName { + return "\(orgName) (\(idpName))" + } + return orgName + } +} + +struct SignInFederatedView_Previews: PreviewProvider { + @MainActor + static var previews: some View { + SignInFederatedView( + federationResponse: FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["login_hint": "test@example.com"], + httpMethod: "GET" + ), + federatedAuthIntro: FederatedAuthIntro( + orgName: "Test Corp", + idpName: "Microsoft Entra", + idpUrl: nil, + orgType: nil, + accountManagementUrl: nil + ) + ) + ) + .environmentObject(AppState()) + .previewLayout(.sizeThatFits) + } +} From 4142d855d95f385ff86530464e5420b2d5411117 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 25 May 2026 11:00:36 -0500 Subject: [PATCH 09/18] cleanup xcodeskit dependency. remove errorhandling dependency --- Xcodes.xcodeproj/project.pbxproj | 33 +- .../xcshareddata/swiftpm/Package.resolved | 18 +- .../Frontend/Common/View+ErrorHandling.swift | 172 ++ Xcodes/Frontend/MainWindow.swift | 1 - Xcodes/Resources/Licenses.rtf | 31 +- Xcodes/Resources/Localizable.xcstrings | 281 +- Xcodes/XcodesKit/.gitignore | 9 - Xcodes/XcodesKit/Package.resolved | 32 - Xcodes/XcodesKit/Package.swift | 35 - Xcodes/XcodesKit/README.md | 3 - .../Concurrency/OneShotContinuation.swift | 74 - .../Extensions/DateFormatter+XcodeList.swift | 17 - .../Extensions/FileManager+Trash.swift | 17 - .../XcodesKit/Extensions/Foundation.swift | 39 - .../Sources/XcodesKit/Extensions/Logger.swift | 10 - .../OperatingSystemVersion+Xcodes.swift | 7 - .../XcodesKit/Extensions/Path+Ownership.swift | 11 - .../Extensions/Path+XcodeBundle.swift | 28 - .../Extensions/Progress+Xcodes.swift | 78 - .../XcodesKit/Extensions/Version+Gem.swift | 53 - .../Extensions/Version+Matching.swift | 23 - .../XcodesKit/Extensions/Version+Xcode.swift | 150 -- .../XcodesKit/Models/Aria2CError.swift | 125 - .../Models/Runtimes/CoreSimulatorImage.swift | 46 - .../Models/Runtimes/RuntimeInstallState.swift | 34 - .../Runtimes/RuntimeInstallationStep.swift | 39 - .../XcodesKit/Models/Runtimes/Runtimes.swift | 217 -- .../XcodesKit/Models/XcodeInstallState.swift | 41 - .../Models/XcodeInstallationStep.swift | 65 - .../Models/XcodeReleases/Architecture.swift | 134 - .../Models/XcodeReleases/Checksums.swift | 20 - .../Models/XcodeReleases/Compilers.swift | 33 - .../XcodesKit/Models/XcodeReleases/Link.swift | 32 - .../Models/XcodeReleases/Release.swift | 59 - .../XcodesKit/Models/XcodeReleases/SDKs.swift | 70 - .../Models/XcodeReleases/XcodeRelease.swift | 35 - .../Models/XcodeReleases/XcodeVersion.swift | 24 - .../XcodesKit/Models/XcodeReleases/YMD.swift | 23 - .../Models/Xcodes/AutoInstallationType.swift | 23 - .../Models/Xcodes/AvailableXcode.swift | 133 - .../Models/Xcodes/AvailableXcodeRelease.swift | 44 - .../XcodesKit/Models/Xcodes/Downloads.swift | 41 - .../Models/Xcodes/InstalledXcode.swift | 58 - .../Models/Xcodes/InstalledXcodeBundle.swift | 60 - .../Models/Xcodes/SelectedActionType.swift | 24 - .../XcodesKit/Models/Xcodes/XcodeID.swift | 17 - .../Models/Xcodes/XcodeListGrouping.swift | 325 --- .../Models/Xcodes/XcodeListItem.swift | 57 - .../XcodesKit/Models/XcodesKitError.swift | 13 - .../ApplicationSupportMigrationService.swift | 42 - .../ArchiveCancellationCleanupService.swift | 44 - .../Services/ArchiveDownloadService.swift | 100 - .../ArchiveDownloadStrategyService.swift | 63 - .../Services/Aria2DownloadService.swift | 70 - .../XcodesKit/Services/AsyncRetry.swift | 55 - .../Services/AvailableXcodeCache.swift | 52 - .../XcodesKit/Services/CodableFileStore.swift | 50 - .../Services/DownloadableRuntimeCache.swift | 43 - .../XcodesKit/Services/HostHardware.swift | 23 - .../InstalledXcodeDiscoveryService.swift | 39 - .../Services/ProgressObservation.swift | 100 - ...untimeArchiveDownloadStrategyService.swift | 69 - .../RuntimeArchiveInstallService.swift | 47 - .../Services/RuntimeArchiveService.swift | 69 - .../Services/RuntimeInstallPolicy.swift | 65 - .../RuntimeInstallationLookupService.swift | 42 - .../RuntimeListPresentationService.swift | 187 -- .../XcodesKit/Services/RuntimeListStore.swift | 67 - .../RuntimePackageInstallService.swift | 102 - .../XcodesKit/Services/RuntimeService.swift | 149 -- .../RuntimeXcodebuildInstallService.swift | 38 - .../Services/URLSession+DownloadTask.swift | 114 - .../Services/XcodeArchiveInstallService.swift | 76 - .../Services/XcodeArchiveService.swift | 82 - .../Services/XcodeAutoInstallService.swift | 38 - .../Services/XcodeCompatibilityService.swift | 98 - .../XcodeInstallResolutionService.swift | 123 - .../Services/XcodeInstallRetryService.swift | 50 - .../Services/XcodeListComposer.swift | 82 - .../XcodeListPresentationService.swift | 144 - .../XcodesKit/Services/XcodeListService.swift | 225 -- .../XcodesKit/Services/XcodeListStore.swift | 88 - .../XcodePostInstallPreparationService.swift | 30 - .../Services/XcodePostInstallService.swift | 39 - .../XcodePostInstallWorkflowService.swift | 40 - .../XcodeSelectionFilesystemService.swift | 88 - .../Services/XcodeSelectionService.swift | 80 - .../Services/XcodeSignatureVerifier.swift | 51 - .../Services/XcodeUnarchiveService.swift | 91 - .../Services/XcodeUninstallService.swift | 32 - .../Services/XcodeUpdatePolicy.swift | 27 - .../Services/XcodeValidationService.swift | 61 - .../Services/XcodeVersionFileService.swift | 34 - .../XcodebuildRuntimeDownloadService.swift | 42 - .../Services/XcodesPathResolver.swift | 59 - .../Sources/XcodesKit/Shell/Process.swift | 273 -- .../Shell/ProcessProgressStream.swift | 145 - .../Sources/XcodesKit/Shell/XcodesShell.swift | 62 - .../XcodesKit/XcodesKitEnvironment.swift | 47 - ...licationSupportMigrationServiceTests.swift | 89 - .../ArchitectureFilteringTests.swift | 145 - .../ArchiveDownloadStrategyServiceTests.swift | 128 - .../AutoInstallationTypeTests.swift | 36 - .../CodableFileStoreTests.swift | 99 - .../XcodesKitTests/HostHardwareTests.swift | 16 - .../XcodesKitTests/InstalledXcodeTests.swift | 177 -- .../OperatingSystemVersionXcodesTests.swift | 10 - .../ProgressObservationTests.swift | 55 - .../XcodesKitTests/ProgressXcodesTests.swift | 33 - ...eArchiveDownloadStrategyServiceTests.swift | 184 -- .../RuntimeListStoreTests.swift | 97 - .../Tests/XcodesKitTests/SDKsTests.swift | 34 - .../SelectedActionTypeTests.swift | 13 - .../XcodesKitTests/VersionGemTests.swift | 13 - .../XcodesKitTests/VersionMatchingTests.swift | 42 - .../XcodesKitTests/VersionXcodeTests.swift | 37 - .../XcodeArchiveDownloaderTests.swift | 14 - .../XcodeAutoInstallServiceTests.swift | 77 - .../XcodeCompatibilityServiceTests.swift | 86 - .../XcodeListGroupingTests.swift | 128 - .../XcodesKitTests/XcodeListItemTests.swift | 53 - .../XcodesKitTests/XcodeListStoreTests.swift | 104 - ...XcodePostInstallWorkflowServiceTests.swift | 121 - .../XcodeVersionFileServiceTests.swift | 38 - .../Tests/XcodesKitTests/XcodesKitTests.swift | 2323 ----------------- .../XcodesPathResolverTests.swift | 88 - 126 files changed, 347 insertions(+), 10644 deletions(-) create mode 100644 Xcodes/Frontend/Common/View+ErrorHandling.swift delete mode 100644 Xcodes/XcodesKit/.gitignore delete mode 100644 Xcodes/XcodesKit/Package.resolved delete mode 100644 Xcodes/XcodesKit/Package.swift delete mode 100644 Xcodes/XcodesKit/README.md delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Concurrency/OneShotContinuation.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/DateFormatter+XcodeList.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/FileManager+Trash.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/OperatingSystemVersion+Xcodes.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+Ownership.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+XcodeBundle.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Progress+Xcodes.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Gem.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Matching.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Xcode.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Aria2CError.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AutoInstallationType.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcodeRelease.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/Downloads.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcode.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/SelectedActionType.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeID.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListItem.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodesKitError.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ApplicationSupportMigrationService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveCancellationCleanupService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadStrategyService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/Aria2DownloadService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/AsyncRetry.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/AvailableXcodeCache.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/CodableFileStore.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/DownloadableRuntimeCache.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/HostHardware.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/InstalledXcodeDiscoveryService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/ProgressObservation.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveDownloadStrategyService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveInstallService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallPolicy.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallationLookupService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListStore.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimePackageInstallService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeXcodebuildInstallService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/URLSession+DownloadTask.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveInstallService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeAutoInstallService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeCompatibilityService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallResolutionService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallRetryService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListComposer.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListStore.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallPreparationService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallWorkflowService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionFilesystemService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSignatureVerifier.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUnarchiveService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUninstallService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUpdatePolicy.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeValidationService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeVersionFileService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodesPathResolver.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Shell/ProcessProgressStream.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift delete mode 100644 Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ApplicationSupportMigrationServiceTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ArchiveDownloadStrategyServiceTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/AutoInstallationTypeTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/CodableFileStoreTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/HostHardwareTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/InstalledXcodeTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/OperatingSystemVersionXcodesTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressObservationTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressXcodesTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeArchiveDownloadStrategyServiceTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeListStoreTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/SDKsTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/SelectedActionTypeTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/VersionGemTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/VersionMatchingTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/VersionXcodeTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeArchiveDownloaderTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeAutoInstallServiceTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeCompatibilityServiceTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListGroupingTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListItemTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListStoreTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodePostInstallWorkflowServiceTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeVersionFileServiceTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift delete mode 100644 Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesPathResolverTests.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 3da9eaf7..41535880 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */; }; CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8652595130600E47BAF /* View+IsHidden.swift */; }; CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF87A2595293E00E47BAF /* DataSource.swift */; }; + CAA858C025A2BE4E00ACF8C0 /* View+ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA858BF25A2BE4E00ACF8C0 /* View+ErrorHandling.swift */; }; CA9FF8B12595967A00E47BAF /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8B02595967A00E47BAF /* main.swift */; }; CA9FF8CF25959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; CA9FF8D025959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; @@ -62,7 +63,6 @@ CAA8587C25A2B37900ACF8C0 /* IsTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */; }; CAA8589325A2B77E00ACF8C0 /* aria2c in Copy aria2c */ = {isa = PBXBuildFile; fileRef = CAA8588025A2B63A00ACF8C0 /* aria2c */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; CAA858C425A2BE4E00ACF8C0 /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */; }; - CAA858CD25A3D8BC00ACF8C0 /* ErrorHandling in Frameworks */ = {isa = PBXBuildFile; productRef = CAA858CC25A3D8BC00ACF8C0 /* ErrorHandling */; }; CAA858DB25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz in Resources */ = {isa = PBXBuildFile; fileRef = CAA858DA25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz */; }; CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7B72449575100113D76 /* AppStateTests.swift */; }; CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A92592EEE900380FEE /* Environment.swift */; }; @@ -249,6 +249,7 @@ CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsTesting.swift; sourceTree = ""; }; CAA8588025A2B63A00ACF8C0 /* aria2c */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = aria2c; sourceTree = ""; }; CAA8588A25A2B69300ACF8C0 /* aria2c.LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = aria2c.LICENSE; sourceTree = ""; }; + CAA858BF25A2BE4E00ACF8C0 /* View+ErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ErrorHandling.swift"; sourceTree = ""; }; CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloader.swift; sourceTree = ""; }; CAA858DA25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz */ = {isa = PBXFileReference; lastKnownFileType = archive.gzip; path = "aria2-release-1.35.0.tar.gz"; sourceTree = ""; }; CABFA9A02592EAF500380FEE /* R&PLogo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "R&PLogo.png"; sourceTree = ""; }; @@ -300,7 +301,6 @@ E84B7D0C2B296A8900DBDA2B /* NavigationSplitViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSplitViewWrapper.swift; sourceTree = ""; }; E84E4F512B323A5F003F3959 /* CornerRadiusModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerRadiusModifier.swift; sourceTree = ""; }; E84E4F532B333864003F3959 /* PlatformsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsListView.swift; sourceTree = ""; }; - E856BB73291EDD3D00DC438B /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = ""; }; E86671262B309D2F0048559A /* PlatformsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformsView.swift; sourceTree = ""; }; E87AB3C42939B65E00D72F43 /* Hardware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hardware.swift; sourceTree = ""; }; E87DD6EA25D053FA00D86808 /* Progress+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Progress+.swift"; sourceTree = ""; }; @@ -337,7 +337,6 @@ CABFA9F82592F0F900380FEE /* KeychainAccess in Frameworks */, E83FDC442CBB649100679C6B /* Sparkle in Frameworks */, E862D43B2CC8B26F00BAA376 /* SRP in Frameworks */, - CAA858CD25A3D8BC00ACF8C0 /* ErrorHandling in Frameworks */, E89CBD3A2D5FB8920037ED95 /* XcodesLoginKitSecurityKey in Frameworks */, E89CBD382D5FAB950037ED95 /* XcodesLoginKit in Frameworks */, E8C0EB1A291EF43E0081528A /* XcodesKit in Frameworks */, @@ -369,6 +368,7 @@ 53CBAB2B263DCC9100410495 /* XcodesAlert.swift */, E84B7D0C2B296A8900DBDA2B /* NavigationSplitViewWrapper.swift */, BDBAB7442B9FF55800694B0B /* TrailingIconLabelStyle.swift */, + CAA858BF25A2BE4E00ACF8C0 /* View+ErrorHandling.swift */, ); path = Common; sourceTree = ""; @@ -539,7 +539,6 @@ CAD2E7952449574E00113D76 = { isa = PBXGroup; children = ( - E856BB73291EDD3D00DC438B /* XcodesKit */, E89CBD3B2D5FC0B10037ED95 /* XcodesLoginKit */, CA8FB5F8256E0F9400469DA5 /* README.md */, CABFA9D42592EF6300380FEE /* DECISIONS.md */, @@ -683,7 +682,6 @@ CABFA9ED2592F0CC00380FEE /* SwiftSoup */, CABFA9F72592F0F900380FEE /* KeychainAccess */, CABFA9FC2592F13300380FEE /* LegibleError */, - CAA858CC25A3D8BC00ACF8C0 /* ErrorHandling */, E689540225BE8C64000EBCEA /* DockProgress */, E8FD5726291EE4AC001E004C /* AsyncNetworkService */, E8C0EB19291EF43E0081528A /* XcodesKit */, @@ -773,10 +771,10 @@ CABFA9EC2592F0CC00380FEE /* XCRemoteSwiftPackageReference "SwiftSoup" */, CABFA9F62592F0F900380FEE /* XCRemoteSwiftPackageReference "KeychainAccess" */, CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */, - CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */, E689540125BE8C64000EBCEA /* XCRemoteSwiftPackageReference "DockProgress" */, E8FD5725291EE4AC001E004C /* XCRemoteSwiftPackageReference "AsyncHTTPNetworkService" */, E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */, + E856BB74291EDD3D00DC438B /* XCRemoteSwiftPackageReference "XcodesKit" */, E84E4F552B335094003F3959 /* XCRemoteSwiftPackageReference "swift-collections" */, E83FDC422CBB649100679C6B /* XCRemoteSwiftPackageReference "Sparkle" */, ); @@ -894,6 +892,7 @@ CAFE4AAC25B7D2C70064FE51 /* GeneralPreferencePane.swift in Sources */, CAFE4ABC25B7D54B0064FE51 /* UpdatesPreferencePane.swift in Sources */, CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */, + CAA858C025A2BE4E00ACF8C0 /* View+ErrorHandling.swift in Sources */, E8CBDB8B27AE02FF00B22292 /* ExperiementsPreferencePane.swift in Sources */, E8E98A9625D863D700EC89A0 /* InstallationStepDetailView.swift in Sources */, CA378F992466567600A58CE0 /* AppState.swift in Sources */, @@ -1458,14 +1457,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/RobotsAndPencils/ErrorHandling"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.1.0; - }; - }; CABFA9E22592F08E00380FEE /* XCRemoteSwiftPackageReference "Version" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mxcl/Version"; @@ -1538,14 +1529,17 @@ kind = branch; }; }; + E856BB74291EDD3D00DC438B /* XCRemoteSwiftPackageReference "XcodesKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/XcodesOrg/XcodesKit"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - CAA858CC25A3D8BC00ACF8C0 /* ErrorHandling */ = { - isa = XCSwiftPackageProductDependency; - package = CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */; - productName = ErrorHandling; - }; CABFA9E32592F08E00380FEE /* Version */ = { isa = XCSwiftPackageProductDependency; package = CABFA9E22592F08E00380FEE /* XCRemoteSwiftPackageReference "Version" */; @@ -1595,6 +1589,7 @@ }; E8C0EB19291EF43E0081528A /* XcodesKit */ = { isa = XCSwiftPackageProductDependency; + package = E856BB74291EDD3D00DC438B /* XCRemoteSwiftPackageReference "XcodesKit" */; productName = XcodesKit; }; E8F44A1D296B4CD7002D6592 /* Path */ = { diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 28600d14..6df19b1c 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -28,15 +28,6 @@ "version": "4.3.1" } }, - { - "package": "ErrorHandling", - "repositoryURL": "https://github.com/RobotsAndPencils/ErrorHandling", - "state": { - "branch": null, - "revision": "7be837fcb515447c0776805c3288fb7d5181ec68", - "version": "0.1.0" - } - }, { "package": "KeychainAccess", "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess", @@ -127,6 +118,15 @@ "version": "1.0.3" } }, + { + "package": "XcodesKit", + "repositoryURL": "https://github.com/XcodesOrg/XcodesKit", + "state": { + "branch": "main", + "revision": "5bc314e4f78590cf2b37487e648d290de8e3d1f2", + "version": null + } + }, { "package": "Yams", "repositoryURL": "https://github.com/jpsim/Yams", diff --git a/Xcodes/Frontend/Common/View+ErrorHandling.swift b/Xcodes/Frontend/Common/View+ErrorHandling.swift new file mode 100644 index 00000000..bd1a8ad5 --- /dev/null +++ b/Xcodes/Frontend/Common/View+ErrorHandling.swift @@ -0,0 +1,172 @@ +import SwiftUI + +enum ErrorCategory: Equatable { + case nonRecoverable + case recoverable(recoveryOption: RecoveryOption) + case requiresSignout +} + +protocol CategorizedError: Error { + var category: ErrorCategory { get } +} + +extension Error { + func resolveCategory() -> ErrorCategory { + guard let categorized = self as? CategorizedError else { + return .nonRecoverable + } + + return categorized.category + } +} + +struct RecoveryOption: Identifiable, Equatable, CustomStringConvertible { + let id: String + let description: String + + init(id: String, description: String) { + self.id = id + self.description = description + } +} + +extension RecoveryOption { + static let retry = RecoveryOption(id: "com.xcodesorg.Xcodes.retry", description: "Retry") +} + +@MainActor +protocol ErrorHandler: Sendable { + func handle( + _ error: Binding, + in view: T, + recoveryHandler: @escaping (RecoveryOption) -> Void, + signOutHandler: @escaping () -> Void + ) -> AnyView +} + +struct AlertErrorHandler: ErrorHandler { + private let id = UUID() + + func handle( + _ error: Binding, + in view: T, + recoveryHandler: @escaping (RecoveryOption) -> Void, + signOutHandler: @escaping () -> Void + ) -> AnyView { + guard error.wrappedValue?.resolveCategory() != .requiresSignout else { + signOutHandler() + return AnyView(view) + } + + let binding = Binding( + get: { + error.wrappedValue.map { + Presentation( + id: id, + error: $0, + recoveryHandler: { recoveryOption in + DispatchQueue.main.async { + recoveryHandler(recoveryOption) + } + } + ) + } + }, + set: { possiblePresentation in + if possiblePresentation == nil { + error.wrappedValue = nil + } + } + ) + + return AnyView(view.alert(item: binding, content: makeAlert)) + } +} + +private extension AlertErrorHandler { + struct Presentation: Identifiable { + let id: UUID + let error: Error + let recoveryHandler: (RecoveryOption) -> Void + } + + func makeAlert(for presentation: Presentation) -> Alert { + let error = presentation.error + + switch error.resolveCategory() { + case let .recoverable(recoveryOption): + return Alert( + title: Text("An error occurred"), + message: Text(error.localizedDescription), + primaryButton: .default(Text("Dismiss")), + secondaryButton: .default( + Text(recoveryOption.description), + action: { presentation.recoveryHandler(recoveryOption) } + ) + ) + case .nonRecoverable: + return Alert( + title: Text("An error occurred"), + message: Text(error.localizedDescription), + dismissButton: .default(Text("Dismiss")) + ) + case .requiresSignout: + assertionFailure("Should have signed out") + return Alert(title: Text("Signing out...")) + } + } +} + +private struct ErrorHandlerEnvironmentKey: EnvironmentKey { + static let defaultValue: any ErrorHandler = AlertErrorHandler() +} + +private struct SignOutHandlerEnvironmentKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable () -> Void = {} +} + +extension EnvironmentValues { + var errorHandler: any ErrorHandler { + get { self[ErrorHandlerEnvironmentKey.self] } + set { self[ErrorHandlerEnvironmentKey.self] = newValue } + } + + var signOutHandler: @MainActor @Sendable () -> Void { + get { self[SignOutHandlerEnvironmentKey.self] } + set { self[SignOutHandlerEnvironmentKey.self] = newValue } + } +} + +@MainActor +struct ErrorEmittingViewModifier: ViewModifier { + @SwiftUI.Environment(\.errorHandler) private var errorHandler: any ErrorHandler + @SwiftUI.Environment(\.signOutHandler) private var signOutHandler: @MainActor @Sendable () -> Void + + var error: Binding + var recoveryHandler: (RecoveryOption) -> Void + + func body(content: Content) -> some View { + errorHandler.handle( + error, + in: content, + recoveryHandler: recoveryHandler, + signOutHandler: signOutHandler + ) + } +} + +extension View { + func handlingErrors(using handler: any ErrorHandler) -> some View { + environment(\.errorHandler, handler) + } + + func emittingError( + _ error: Binding, + recoveryHandler: @escaping (RecoveryOption) -> Void + ) -> some View { + modifier(ErrorEmittingViewModifier( + error: error, + recoveryHandler: recoveryHandler + )) + } +} diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 6a826135..0de18b5c 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -1,4 +1,3 @@ -import ErrorHandling import SwiftUI import XcodesKit import XcodesLoginKit diff --git a/Xcodes/Resources/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index db57f9fe..1687a178 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -112,13 +112,12 @@ SOFTWARE.\ \ \ -\fs34 ErrorHandling\ +\fs34 XcodesKit\ \ \fs26 MIT License\ \ -Copyright (c) 2020 Robots and Pencils\ -Copyright (c) 2020 John Sundell\ +Copyright (c) 2025 XcodesOrg\ \ Permission is hereby granted, free of charge, to any person obtaining a copy\ of this software and associated documentation files (the "Software"), to deal\ @@ -1291,32 +1290,6 @@ For more information, please refer to <>\ \ \ -\fs34 CombineExpectations\ -\ - -\fs26 Copyright (C) 2019 Gwendal Rou\'e9\ -\ -Permission is hereby granted, free of charge, to any person obtaining a\ -copy of this software and associated documentation files (the\ -"Software"), to deal in the Software without restriction, including\ -without limitation the rights to use, copy, modify, merge, publish,\ -distribute, sublicense, and/or sell copies of the Software, and to\ -permit persons to whom the Software is furnished to do so, subject to\ -the following conditions:\ -\ -The above copyright notice and this permission notice shall be included\ -in all copies or substantial portions of the Software.\ -\ -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS\ -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\ -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\ -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\ -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,\ -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE\ -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ -\ -\ - \fs34 aria2c\ \ diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 11d1c3d8..4cf914c3 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -4669,6 +4669,9 @@ } } } + }, + "An error occurred" : { + }, "Apple Silicon" : { "localizations" : { @@ -7903,6 +7906,9 @@ } } } + }, + "Dismiss" : { + }, "Downloader" : { "localizations" : { @@ -10359,6 +10365,136 @@ } } }, + "GroupXcodeVersionsInList" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Xcode versions in the list" + } + } + } + }, "HelperClient.error" : { "extractionState" : "manual", "localizations" : { @@ -18681,6 +18817,9 @@ } } } + }, + "Open Browser" : { + }, "Open In Rosetta" : { "localizations" : { @@ -19065,6 +19204,9 @@ } } } + }, + "Paste redirected URL" : { + }, "Perform post-install steps" : { "extractionState" : "stale", @@ -22681,6 +22823,9 @@ } } } + }, + "Signing out..." : { + }, "SignInWithApple" : { "comment" : "SignIn", @@ -23204,136 +23349,6 @@ } } }, - "GroupXcodeVersionsInList" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "Group Xcode versions in the list" - } - } - } - }, "TerminateAfterLastWindowClosed" : { "localizations" : { "ar" : { @@ -23463,6 +23478,9 @@ } } } + }, + "This Apple ID uses federated authentication via %@." : { + }, "TrashingArchive" : { "extractionState" : "manual", @@ -23851,6 +23869,7 @@ } }, "Universal" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -25129,4 +25148,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Xcodes/XcodesKit/.gitignore b/Xcodes/XcodesKit/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/Xcodes/XcodesKit/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Xcodes/XcodesKit/Package.resolved b/Xcodes/XcodesKit/Package.resolved deleted file mode 100644 index 424ceaf5..00000000 --- a/Xcodes/XcodesKit/Package.resolved +++ /dev/null @@ -1,32 +0,0 @@ -{ - "pins" : [ - { - "identity" : "path.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mxcl/Path.swift", - "state" : { - "revision" : "8e355c28e9393c42e58b18c54cace2c42c98a616", - "version" : "1.4.1" - } - }, - { - "identity" : "swiftsoup", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scinfu/SwiftSoup", - "state" : { - "revision" : "aeb5b4249c273d1783a5299e05be1b26e061ea81", - "version" : "2.0.0" - } - }, - { - "identity" : "version", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mxcl/Version", - "state" : { - "revision" : "087c91fedc110f9f833b14ef4c32745dabca8913", - "version" : "1.0.3" - } - } - ], - "version" : 2 -} diff --git a/Xcodes/XcodesKit/Package.swift b/Xcodes/XcodesKit/Package.swift deleted file mode 100644 index aeaed308..00000000 --- a/Xcodes/XcodesKit/Package.swift +++ /dev/null @@ -1,35 +0,0 @@ -// swift-tools-version: 6.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "XcodesKit", - platforms: [.macOS(.v13)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "XcodesKit", - targets: ["XcodesKit"]), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/mxcl/Path.swift", from: "1.0.0"), - .package(url: "https://github.com/mxcl/Version", .upToNextMinor(from: "1.0.3")), - .package(url: "https://github.com/scinfu/SwiftSoup", .upToNextMinor(from: "2.0.0")), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "XcodesKit", - dependencies: [ - .product(name: "Path", package: "Path.swift"), - "SwiftSoup", - "Version", - ]), - .testTarget( - name: "XcodesKitTests", - dependencies: ["XcodesKit"]), - ] -) diff --git a/Xcodes/XcodesKit/README.md b/Xcodes/XcodesKit/README.md deleted file mode 100644 index 5312c492..00000000 --- a/Xcodes/XcodesKit/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# XcodesKit - -A description of this package. diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Concurrency/OneShotContinuation.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Concurrency/OneShotContinuation.swift deleted file mode 100644 index c07318de..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Concurrency/OneShotContinuation.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import os - -public final class OneShotContinuation: Sendable { - private enum State: Sendable { - case pending(CheckedContinuation?) - case completed(Result) - } - - private let state = OSAllocatedUnfairLock(initialState: State.pending(nil)) - - public init() {} - - public func value( - onCancel: @escaping @Sendable () -> Void = {}, - start: @Sendable () throws -> Void - ) async throws -> Value { - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - if setContinuation(continuation) { - do { - try start() - } catch { - resume(throwing: error) - } - } - } - } onCancel: { - onCancel() - resume(throwing: CancellationError()) - } - } - - @discardableResult - public func setContinuation(_ continuation: CheckedContinuation) -> Bool { - let result = state.withLock { - switch $0 { - case .pending: - $0 = .pending(continuation) - return nil as Result? - case .completed(let result): - return result - } - } - - guard let result else { return true } - continuation.resume(with: result) - return false - } - - public func resume(with result: Result) { - let continuation = state.withLock { - switch $0 { - case .pending(let continuation): - $0 = .completed(result) - return continuation - case .completed: - return nil - } - } - - continuation?.resume(with: result) - } - - public func resume(throwing error: Error) { - resume(with: .failure(error)) - } -} - -extension OneShotContinuation where Value == Void { - public func resume() { - resume(with: .success(())) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/DateFormatter+XcodeList.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/DateFormatter+XcodeList.swift deleted file mode 100644 index b7f8ef6c..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/DateFormatter+XcodeList.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public extension DateFormatter { - static let downloadsDateModified: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "MM/dd/yy HH:mm" - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.calendar = .init(identifier: .iso8601) - return formatter - }() - - static let downloadsReleaseDate: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "MMMM d, yyyy" - return formatter - }() -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/FileManager+Trash.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/FileManager+Trash.swift deleted file mode 100644 index 925f773d..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/FileManager+Trash.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public extension FileManager { - /** - Moves an item to the trash. - - This implementation exists only to make the existing method more idiomatic by returning the resulting URL instead of setting the value on an inout argument. - - FB6735133: FileManager.trashItem(at:resultingItemURL:) is not an idiomatic Swift API - */ - @discardableResult - func xcodesTrashItem(at url: URL) throws -> URL { - var resultingItemURL: NSURL? - try trashItem(at: url, resultingItemURL: &resultingItemURL) - return resultingItemURL as URL? ?? url - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift deleted file mode 100644 index a270ed99..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -public extension BidirectionalCollection where Element: Equatable { - func suffix(fromLast delimiter: Element) -> Self.SubSequence { - guard - let lastIndex = lastIndex(of: delimiter), - index(after: lastIndex) < endIndex - else { return suffix(0) } - return suffix(from: index(after: lastIndex)) - } -} - -public extension NumberFormatter { - convenience init(numberStyle: NumberFormatter.Style) { - self.init() - self.numberStyle = numberStyle - } - - func string(from number: N) -> String? { - string(from: number as! NSNumber) - } -} - -public extension Sequence { - func sorted(_ keyPath: KeyPath) -> [Element] { - sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] }) - } -} - -public extension NSRegularExpression { - func firstString(in string: String, options: NSRegularExpression.MatchingOptions = []) -> String? { - let range = NSRange(location: 0, length: string.utf16.count) - guard let firstMatch = firstMatch(in: string, options: options, range: range), - let resultRange = Range(firstMatch.range, in: string) else { - return nil - } - return String(string[resultRange]) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift deleted file mode 100644 index 5c059853..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation -import os.log - -extension Logger { - private static let subsystem = Bundle.main.bundleIdentifier ?? "com.robotsandpencils.XcodesKit" - - static public let appState = Logger(subsystem: subsystem, category: "appState") - static public let helperClient = Logger(subsystem: subsystem, category: "helperClient") - static public let subprocess = Logger(subsystem: subsystem, category: "subprocess") -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/OperatingSystemVersion+Xcodes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/OperatingSystemVersion+Xcodes.swift deleted file mode 100644 index 2790fe21..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/OperatingSystemVersion+Xcodes.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -public extension OperatingSystemVersion { - func versionString() -> String { - "\(majorVersion).\(minorVersion).\(patchVersion)" - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+Ownership.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+Ownership.swift deleted file mode 100644 index 81a7cd08..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+Ownership.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation -@preconcurrency import Path - -public extension Path { - @discardableResult - func setCurrentUserAsOwner() -> Path { - let user = ProcessInfo.processInfo.environment["SUDO_USER"] ?? NSUserName() - try? FileManager.default.setAttributes([.ownerAccountName: user], ofItemAtPath: string) - return self - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+XcodeBundle.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+XcodeBundle.swift deleted file mode 100644 index d4b0b08d..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Path+XcodeBundle.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation -@preconcurrency import Path - -public extension Path { - static func isAppBundle(path: Path) -> Bool { - path.isDirectory && - path.extension == "app" && - !path.isSymlink - } - - static func infoPlist(path: Path) -> InfoPlist? { - let infoPlistPath = path.join("Contents").join("Info.plist") - guard - let infoPlistData = try? Data(contentsOf: infoPlistPath.url), - let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData) - else { return nil } - - return infoPlist - } - - var isAppBundle: Bool { - Path.isAppBundle(path: self) - } - - var infoPlist: InfoPlist? { - Path.infoPlist(path: self) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Progress+Xcodes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Progress+Xcodes.swift deleted file mode 100644 index c15769af..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Progress+Xcodes.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -public extension Progress { - func updateFromAria2(string: String) { - let range = NSRange(location: 0, length: string.utf16.count) - - let regexTotalDownloaded = try! NSRegularExpression(pattern: #"(?<= )(.*)(?=\/)"#) - if let match = regexTotalDownloaded.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range(at: 0), in: string), - let totalDownloaded = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) - { - completedUnitCount = Int64(totalDownloaded) - } - - let regexTotalFileSize = try! NSRegularExpression(pattern: #"(?<=/)(.*)(?=\()"#) - if let match = regexTotalFileSize.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range(at: 0), in: string), - let totalFileSize = Int(string[matchRange].replacingOccurrences(of: "B", with: "")), - totalFileSize > 0 - { - totalUnitCount = Int64(totalFileSize) - } - - let regexSpeed = try! NSRegularExpression(pattern: #"(?<=DL:)(.*)(?= )"#) - if let match = regexSpeed.firstMatch(in: string, options: [], range: range), - let matchRange = Range(match.range(at: 0), in: string), - let speed = Int(string[matchRange].replacingOccurrences(of: "B", with: "")) - { - throughput = speed - } - - let regexETA = try! NSRegularExpression(pattern: #"(?<=ETA:)(?\d*h)?(?\d*m)?(?\d*s)?"#) - if let match = regexETA.firstMatch(in: string, options: [], range: range) { - var seconds = 0 - - if let matchRange = Range(match.range(withName: "hours"), in: string), - let hours = Int(string[matchRange].replacingOccurrences(of: "h", with: "")) - { - seconds += hours * 60 * 60 - } - - if let matchRange = Range(match.range(withName: "minutes"), in: string), - let minutes = Int(string[matchRange].replacingOccurrences(of: "m", with: "")) - { - seconds += minutes * 60 - } - - if let matchRange = Range(match.range(withName: "seconds"), in: string), - let second = Int(string[matchRange].replacingOccurrences(of: "s", with: "")) - { - seconds += second - } - - estimatedTimeRemaining = TimeInterval(seconds) - } - } - - func updateFromXcodebuild(text: String) { - totalUnitCount = 100 - completedUnitCount = 0 - localizedAdditionalDescription = "" - - let downloadPattern = #"(\d+\.\d+)% \(([\d.]+ (?:MB|GB)) of ([\d.]+ GB)\)"# - let downloadRegex = try! NSRegularExpression(pattern: downloadPattern) - - if let match = downloadRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), - let percentRange = Range(match.range(at: 1), in: text), - let percentDouble = Double(text[percentRange]) - { - completedUnitCount = Int64(percentDouble.rounded()) - } - - if text.range(of: "Installing") != nil { - totalUnitCount = 0 - completedUnitCount = 0 - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Gem.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Gem.swift deleted file mode 100644 index b88a60b1..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Gem.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import Version - -public extension Version { - /** - Attempts to parse Gem::Version representations. - - E.g.: - 9.2b3 - 9.1.2 - 9.2 - 9 - - Doesn't handle GM prerelease identifier - */ - init?(gemVersion: String) { - let nsrange = NSRange(gemVersion.startIndex.. String in - switch identifier.lowercased() { - case "a": return "Alpha" - case "b": return "Beta" - default: return identifier - } - } - - self = Version(major: major, minor: minor, patch: patch, prereleaseIdentifiers: prereleaseIdentifiers) - } -} - -private extension NSTextCheckingResult { - func groupNamed(_ name: String, in string: String) -> String? { - let nsrange = range(withName: name) - guard let range = Range(nsrange, in: string) else { return nil } - return String(string[range]) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Matching.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Matching.swift deleted file mode 100644 index 673eacd3..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Matching.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import Version - -public enum XcodeVersionMatcher: Sendable { - public static func find(version: Version, in xcodes: [XcodeType], versionKeyPath: KeyPath) -> XcodeType? { - if let equivalentXcode = xcodes.first(where: { $0[keyPath: versionKeyPath].isEquivalent(to: version) }) { - return equivalentXcode - } else if version.prereleaseIdentifiers.isEmpty && version.buildMetadataIdentifiers.isEmpty, - xcodes.filter({ $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) }).count == 1 { - return xcodes.first(where: { $0[keyPath: versionKeyPath].isEqualWithoutAllIdentifiers(to: version) }) - } else { - return nil - } - } -} - -public extension Version { - func isEqualWithoutAllIdentifiers(to other: Version) -> Bool { - major == other.major && - minor == other.minor && - patch == other.patch - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Xcode.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Xcode.swift deleted file mode 100644 index 950dc0af..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Version+Xcode.swift +++ /dev/null @@ -1,150 +0,0 @@ -import Foundation -import Version - -public extension Version { - init?(xcodeVersion: String, buildMetadataIdentifier: String? = nil) { - let nsrange = NSRange(xcodeVersion.startIndex.. 1 { - versionString += ".\(beta)" - } - case let .dp(dp): - versionString += "-DP" - if dp > 1 { - versionString += ".\(dp)" - } - case .gm: - break - case let .gmSeed(gmSeed): - versionString += "-GM.Seed" - if gmSeed > 1 { - versionString += ".\(gmSeed)" - } - case let .rc(rc): - versionString += "-Release.Candidate" - if rc > 1 { - versionString += ".\(rc)" - } - case .release: - break - } - - if let buildNumber = xcReleasesXcode.version.build { - versionString += "+\(buildNumber)" - } - - self.init(versionString) - } - - /// The intent here is to match Apple's marketing version. - var appleDescription: String { - var base = "\(major).\(minor)" - if patch != 0 { - base += ".\(patch)" - } - if !prereleaseIdentifiers.isEmpty { - base += " " + prereleaseIdentifiers - .map { identifier in - identifier - .replacingOccurrences(of: "-", with: " ") - .capitalized - .replacingOccurrences(of: "Gm", with: "GM") - .replacingOccurrences(of: "Rc", with: "RC") - } - .joined(separator: " ") - } - return base - } - - var appleDescriptionWithBuildIdentifier: String { - [appleDescription, buildMetadataIdentifiersDisplay].filter { !$0.isEmpty }.joined(separator: " ") - } - - var buildMetadataIdentifiersDisplay: String { - !buildMetadataIdentifiers.isEmpty ? "(\(buildMetadataIdentifiers.joined(separator: " ")))" : "" - } - - func isEquivalent(to other: Version) -> Bool { - if buildMetadataIdentifiers.isEmpty || other.buildMetadataIdentifiers.isEmpty { - return major == other.major && - minor == other.minor && - patch == other.patch && - prereleaseIdentifiers.map { $0.lowercased() } == other.prereleaseIdentifiers.map { $0.lowercased() } - } else { - return major == other.major && - minor == other.minor && - patch == other.patch && - buildMetadataIdentifiers.map { $0.lowercased() } == other.buildMetadataIdentifiers.map { $0.lowercased() } - } - } - - var descriptionWithoutBuildMetadata: String { - var base = "\(major).\(minor).\(patch)" - if !prereleaseIdentifiers.isEmpty { - base += "-" + prereleaseIdentifiers.joined(separator: ".") - } - return base - } - - var isPrerelease: Bool { prereleaseIdentifiers.isEmpty == false } - var isNotPrerelease: Bool { prereleaseIdentifiers.isEmpty == true } -} - -private extension NSTextCheckingResult { - func groupNamed(_ name: String, in string: String) -> String? { - let nsrange = range(withName: name) - guard let range = Range(nsrange, in: string) else { return nil } - return String(string[range]) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Aria2CError.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Aria2CError.swift deleted file mode 100644 index fc51e507..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Aria2CError.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Foundation - -/// A LocalizedError that represents a non-zero exit code from running aria2c. -public struct Aria2CError: LocalizedError, Sendable { - public var code: Code - - public init?(exitStatus: Int32) { - guard let code = Code(rawValue: exitStatus) else { return nil } - self.code = code - } - - public var errorDescription: String? { - "aria2c error: \(code.description)" - } - - // https://github.com/aria2/aria2/blob/master/src/error_code.h - public enum Code: Int32, CustomStringConvertible, Sendable { - case undefined = -1 - // Ignoring, not an error - // case finished = 0 - case unknownError = 1 - case timeOut - case resourceNotFound - case maxFileNotFound - case tooSlowDownloadSpeed - case networkProblem - case inProgress - case cannotResume - case notEnoughDiskSpace - case pieceLengthChanged - case duplicateDownload - case duplicateInfoHash - case fileAlreadyExists - case fileRenamingFailed - case fileOpenError - case fileCreateError - case fileIoError - case dirCreateError - case nameResolveError - case metalinkParseError - case ftpProtocolError - case httpProtocolError - case httpTooManyRedirects - case httpAuthFailed - case bencodeParseError - case bittorrentParseError - case magnetParseError - case optionError - case httpServiceUnavailable - case jsonParseError - case removed - case checksumError - - public var description: String { - switch self { - case .undefined: - return "Undefined" - case .unknownError: - return "Unknown error" - case .timeOut: - return "Timed out" - case .resourceNotFound: - return "Resource not found" - case .maxFileNotFound: - return "Maximum number of file not found errors reached" - case .tooSlowDownloadSpeed: - return "Download speed too slow" - case .networkProblem: - return "Network problem" - case .inProgress: - return "Unfinished downloads in progress" - case .cannotResume: - return "Remote server did not support resume when resume was required to complete download" - case .notEnoughDiskSpace: - return "Not enough disk space available" - case .pieceLengthChanged: - return "Piece length was different from one in .aria2 control file" - case .duplicateDownload: - return "Duplicate download" - case .duplicateInfoHash: - return "Duplicate info hash torrent" - case .fileAlreadyExists: - return "File already exists" - case .fileRenamingFailed: - return "Renaming file failed" - case .fileOpenError: - return "Could not open existing file" - case .fileCreateError: - return "Could not create new file or truncate existing file" - case .fileIoError: - return "File I/O error" - case .dirCreateError: - return "Could not create directory" - case .nameResolveError: - return "Name resolution failed" - case .metalinkParseError: - return "Could not parse Metalink document" - case .ftpProtocolError: - return "FTP command failed" - case .httpProtocolError: - return "HTTP response header was bad or unexpected" - case .httpTooManyRedirects: - return "Too many redirects occurred" - case .httpAuthFailed: - return "HTTP authorization failed" - case .bencodeParseError: - return "Could not parse bencoded file (usually \".torrent\" file)" - case .bittorrentParseError: - return "\".torrent\" file was corrupted or missing information" - case .magnetParseError: - return "Magnet URI was bad" - case .optionError: - return "Bad/unrecognized option was given or unexpected option argument was given" - case .httpServiceUnavailable: - return "HTTP service unavailable" - case .jsonParseError: - return "Could not parse JSON-RPC request" - case .removed: - return "Reserved. Not used." - case .checksumError: - return "Checksum validation failed" - } - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift deleted file mode 100644 index e994682f..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// CoreSimulatorImage.swift -// -// -// Created by Matt Kiazyk on 2023-01-08. -// - -import Foundation - -public struct CoreSimulatorPlist: Decodable, Sendable { - public let images: [CoreSimulatorImage] - - public init(images: [CoreSimulatorImage]) { - self.images = images - } -} - -public struct CoreSimulatorImage: Decodable, Identifiable, Equatable, Sendable { - public var id: String { - return uuid - } - - public let uuid: String - public let path: [String: String] - public let runtimeInfo: CoreSimulatorRuntimeInfo - - public init(uuid: String, path: [String : String], runtimeInfo: CoreSimulatorRuntimeInfo) { - self.uuid = uuid - self.path = path - self.runtimeInfo = runtimeInfo - } - - public static func == (lhs: CoreSimulatorImage, rhs: CoreSimulatorImage) -> Bool { - lhs.id == rhs.id - } -} - -public struct CoreSimulatorRuntimeInfo: Decodable, Sendable { - public let build: String - public let supportedArchitectures: [Architecture]? - - public init(build: String, supportedArchitectures: [Architecture]? = nil) { - self.build = build - self.supportedArchitectures = supportedArchitectures - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift deleted file mode 100644 index 9859c299..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallState.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// RuntimeInstallState.swift -// -// -// Created by Matt Kiazyk on 2023-11-23. -// - -import Foundation -@preconcurrency import Path - -public enum RuntimeInstallState: Equatable, Hashable, Sendable { - case notInstalled - case installing(RuntimeInstallationStep) - case installed - - var notInstalled: Bool { - switch self { - case .notInstalled: return true - default: return false - } - } - var installing: Bool { - switch self { - case .installing: return true - default: return false - } - } - var installed: Bool { - switch self { - case .installed: return true - default: return false - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift deleted file mode 100644 index 5d420625..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/RuntimeInstallationStep.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// RuntimeInstallationStep.swift -// -// -// Created by Matt Kiazyk on 2023-11-23. -// - -import Foundation - -public enum RuntimeInstallationStep: Equatable, CustomStringConvertible, Hashable, Sendable { - case downloading(progress: Progress) - case installing - case trashingArchive - - public var description: String { - "(\(stepNumber)/\(stepCount)) \(message)" - } - - public var message: String { - switch self { - case .downloading: - return localizeString("Downloading") - case .installing: - return localizeString("Installing") - case .trashingArchive: - return localizeString("TrashingArchive") - } - } - - public var stepNumber: Int { - switch self { - case .downloading: return 1 - case .installing: return 2 - case .trashingArchive: return 3 - } - } - - public var stepCount: Int { 3 } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift deleted file mode 100644 index c15b7357..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift +++ /dev/null @@ -1,217 +0,0 @@ -import Foundation - -public func makeRuntimeVersion(for osVersion: String, betaNumber: Int?) -> String { - let betaSuffix = betaNumber.flatMap { "-beta\($0)" } ?? "" - return osVersion + betaSuffix -} - -public struct DownloadableRuntimesResponse: Codable, Sendable { - public let sdkToSimulatorMappings: [SDKToSimulatorMapping] - public let sdkToSeedMappings: [SDKToSeedMapping] - public let refreshInterval: Int - public let downloadables: [DownloadableRuntime] - public let version: String -} - -public struct DownloadableRuntime: Codable, Identifiable, Hashable, Sendable { - public let category: Category - public let simulatorVersion: SimulatorVersion - public let source: String? - public let architectures: [Architecture]? - public let dictionaryVersion: Int - public let contentType: ContentType - public let platform: Platform - public let identifier: String - public let version: String - public let fileSize: Int - public let hostRequirements: HostRequirements? - public let name: String - public let authentication: Authentication? - public var url: URL? { - if let source { - return URL(string: source)! - } - return nil - } - public var downloadPath: String? { - url?.path - } - - // dynamically updated - not decoded - public var installState: RuntimeInstallState = .notInstalled - public var sdkBuildUpdate: [String]? - - enum CodingKeys: CodingKey { - case category - case simulatorVersion - case source - case dictionaryVersion - case contentType - case platform - case identifier - case version - case fileSize - case hostRequirements - case name - case authentication - case sdkBuildUpdate - case architectures - } - - public var betaNumber: Int? { - enum Regex { static let shared = try! NSRegularExpression(pattern: "b[0-9]+") } - guard var foundString = Regex.shared.firstString(in: identifier) else { return nil } - foundString.removeFirst() - return Int(foundString)! - } - - public var completeVersion: String { - makeRuntimeVersion(for: simulatorVersion.version, betaNumber: betaNumber) - } - - public var visibleIdentifier: String { - return platform.shortName + " " + completeVersion - } - - public func makeVersion(for osVersion: String, betaNumber: Int?) -> String { - makeRuntimeVersion(for: osVersion, betaNumber: betaNumber) - } - - public var downloadFileSizeString: String { - return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) - } - - public var id: String { - return visibleIdentifier - } - - public static func == (lhs: DownloadableRuntime, rhs: DownloadableRuntime) -> Bool { - return lhs.identifier == rhs.identifier - } -} - -public struct SDKToSeedMapping: Codable, Sendable { - public let buildUpdate: String - public let platform: DownloadableRuntime.Platform - public let seedNumber: Int -} - -public struct SDKToSimulatorMapping: Codable, Sendable { - public let sdkBuildUpdate: String - public let simulatorBuildUpdate: String - public let sdkIdentifier: String - public let downloadableIdentifiers: [String]? -} - -extension DownloadableRuntime { - public struct SimulatorVersion: Codable, Hashable, Sendable { - public let buildUpdate: String - public let version: String - } - - public struct HostRequirements: Codable, Hashable, Sendable { - public let maxHostVersion: String? - public let excludedHostArchitectures: [String]? - public let minHostVersion: String? - public let minXcodeVersion: String? - } - - public enum Authentication: String, Codable, Sendable { - case virtual = "virtual" - } - - public enum Category: String, Codable, Sendable { - case simulator = "simulator" - } - - public enum ContentType: String, Codable, Sendable { - case diskImage = "diskImage" - case package = "package" - case cryptexDiskImage = "cryptexDiskImage" - case patchableCryptexDiskImage = "patchableCryptexDiskImage" - } - - public enum Platform: String, Codable, Sendable { - case iOS = "com.apple.platform.iphoneos" - case macOS = "com.apple.platform.macosx" - case watchOS = "com.apple.platform.watchos" - case tvOS = "com.apple.platform.appletvos" - case visionOS = "com.apple.platform.xros" - - public var order: Int { - switch self { - case .iOS: return 1 - case .macOS: return 2 - case .watchOS: return 3 - case .tvOS: return 4 - case .visionOS: return 5 - } - } - - public var shortName: String { - switch self { - case .iOS: return "iOS" - case .macOS: return "macOS" - case .watchOS: return "watchOS" - case .tvOS: return "tvOS" - case .visionOS: return "visionOS" - } - } - - } -} - -public struct InstalledRuntime: Decodable, Sendable { - public let build: String - public let deletable: Bool - public let identifier: UUID - public let kind: Kind - public let lastUsedAt: Date? - public let path: String - public let platformIdentifier: Platform - public let runtimeBundlePath: String - public let runtimeIdentifier: String - public let signatureState: String - public let state: String - public let version: String - public let sizeBytes: Int? - public let supportedArchitectures: [Architecture]? -} - -public extension Array where Element == DownloadableRuntime { - func matchingArchitectures(_ architectures: [Architecture]) -> [DownloadableRuntime] { - guard !architectures.isEmpty else { return self } - return filter { $0.architectures?.containsAny(architectures) == true } - } - - func matchingArchitectureFilters(_ filters: [ArchitectureFilter]) -> [DownloadableRuntime] { - guard !filters.isEmpty else { return self } - return filter { filters.matches($0.architectures) } - } -} - -extension InstalledRuntime { - public enum Kind: String, Decodable, Sendable { - case bundled = "Bundled with Xcode" - case cryptexDiskImage = "Cryptex Disk Image" - case diskImage = "Disk Image" - case legacyDownload = "Legacy Download" - case patchableCryptexDiskImage = "Patchable Cryptex Disk Image" - } - - public enum Platform: String, Decodable, Sendable { - case tvOS = "com.apple.platform.appletvsimulator" - case iOS = "com.apple.platform.iphonesimulator" - case watchOS = "com.apple.platform.watchsimulator" - case visionOS = "com.apple.platform.xrsimulator" - - public var asPlatformOS: DownloadableRuntime.Platform { - switch self { - case .watchOS: return .watchOS - case .iOS: return .iOS - case .tvOS: return .tvOS - case .visionOS: return .visionOS - } - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift deleted file mode 100644 index 01f46f07..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallState.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// InstallState.swift -// -// -// Created by Matt Kiazyk on 2023-06-06. -// - -import Foundation -@preconcurrency import Path - -public enum XcodeInstallState: Equatable, Sendable { - case notInstalled - case installing(XcodeInstallationStep) - case installed(Path) - - public var notInstalled: Bool { - switch self { - case .notInstalled: return true - default: return false - } - } - public var installing: Bool { - switch self { - case .installing: return true - default: return false - } - } - public var installed: Bool { - switch self { - case .installed: return true - default: return false - } - } - - public var installedPath: Path? { - switch self { - case .installed(let path): return path - default: return nil - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift deleted file mode 100644 index 98b941c2..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeInstallationStep.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// InstallationStep.swift -// -// -// Created by Matt Kiazyk on 2023-06-06. -// - -import Foundation - -// A numbered step -public enum XcodeInstallationStep: Equatable, CustomStringConvertible, Sendable { - case authenticating - case downloading(progress: Progress) - case unarchiving - case moving(destination: String) - case trashingArchive - case checkingSecurity - case finishing - - public var description: String { - "(\(stepNumber)/\(stepCount)) \(message)" - } - - public var message: String { - switch self { - case .authenticating: - return localizeString("Authenticating") - case .downloading: - return localizeString("Downloading") - case .unarchiving: - return localizeString("Unarchiving") - case .moving(let destination): - return String(format: localizeString("Moving"), destination) - case .trashingArchive: - return localizeString("TrashingArchive") - case .checkingSecurity: - return localizeString("CheckingSecurity") - case .finishing: - return localizeString("Finishing") - } - } - - public var stepNumber: Int { - switch self { - case .authenticating: return 1 - case .downloading: return 2 - case .unarchiving: return 3 - case .moving: return 4 - case .trashingArchive: return 5 - case .checkingSecurity: return 6 - case .finishing: return 7 - } - } - - public var stepCount: Int { 7 } -} - -func localizeString(_ key: String, comment: String = "") -> String { - if #available(macOS 12, *) { - return String(localized: String.LocalizationValue(key)) - } else { - return NSLocalizedString(key, comment: comment) - } - -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift deleted file mode 100644 index 7958a8f5..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Architecture.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// Architecture.swift -// XcodesKit -// -// Created by Matt Kiazyk on 2025-08-23. -// - -import Foundation - -/// The name of an Architecture. -public enum Architecture: String, Codable, Equatable, Hashable, Identifiable, CaseIterable, Sendable { - public var id: Self { self } - - /// The Arm64 architecture (Apple Silicon) - case arm64 = "arm64" - /// The X86\_64 architecture (64-bit Intel) - case x86_64 = "x86_64" - - public var displayString: String { - switch self { - case .arm64: - return localizeString("Apple Silicon") - case .x86_64: - return localizeString("Intel") - } - } - - public var iconName: String { - switch self { - case .arm64: - return "m4.button.horizontal" - case .x86_64: - return "cpu.fill" - } - } -} - -public enum ArchitectureVariant: String, Codable, Equatable, Hashable, Identifiable, CaseIterable, Sendable { - public var id: Self { self } - - case universal - case appleSilicon - - public var displayString: String { - switch self { - case .appleSilicon: - return localizeString("Apple Silicon") - case .universal: - return localizeString("Universal") - } - } - - public var iconName: String { - switch self { - case .appleSilicon: - return "m4.button.horizontal" - case .universal: - return "cpu.fill" - } - } - - public static func defaultForMachine(machineHardwareName: String? = HostHardware.currentMachineHardwareName()) -> Self { - HostHardware.isAppleSilicon(machineHardwareName: machineHardwareName) ? .appleSilicon : .universal - } -} - -public enum ArchitectureFilter: Equatable, Hashable, Sendable { - case architecture(Architecture) - case variant(ArchitectureVariant) - - public init?(_ rawValue: String) { - switch rawValue { - case Architecture.arm64.rawValue: - self = .architecture(.arm64) - case Architecture.x86_64.rawValue: - self = .architecture(.x86_64) - case ArchitectureVariant.appleSilicon.rawValue, "apple-silicon", "apple_silicon": - self = .variant(.appleSilicon) - case ArchitectureVariant.universal.rawValue: - self = .variant(.universal) - default: - return nil - } - } - - public func matches(_ architectures: [Architecture]?) -> Bool { - guard let architectures, !architectures.isEmpty else { return true } - - switch self { - case .architecture(let architecture): - return architectures == [architecture] - case .variant(.appleSilicon): - return architectures.isAppleSilicon - case .variant(.universal): - return architectures.isUniversal - } - } -} - -extension Array where Element == Architecture { - public var isAppleSilicon: Bool { - self == [.arm64] - } - - public var isUniversal: Bool { - self.contains([.arm64, .x86_64]) - } - - public func containsAny(_ architectures: [Architecture]) -> Bool { - !Set(self).isDisjoint(with: architectures) - } - - var listOutputSuffix: String { - guard !isEmpty else { return "" } - if isUniversal { - return " [\(ArchitectureVariant.universal.displayString)]" - } - if isAppleSilicon { - return " [\(ArchitectureVariant.appleSilicon.displayString)]" - } - return " [\(map(\.displayString).joined(separator: "|"))]" - } -} - -extension Array where Element == ArchitectureFilter { - public static func defaultForMachine(machineHardwareName: String? = HostHardware.currentMachineHardwareName()) -> [ArchitectureFilter] { - [.variant(.defaultForMachine(machineHardwareName: machineHardwareName))] - } - - func matches(_ architectures: [Architecture]?) -> Bool { - guard !isEmpty else { return true } - return contains { $0.matches(architectures) } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift deleted file mode 100644 index 5612f77b..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Checksums.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Checksums.swift -// xcodereleases -// -// Created by Xcode Releases on 9/17/20. -// Copyright © 2020 Xcode Releases. All rights reserved. -// - - -import Foundation - -public struct Checksums: Codable, Sendable { - - public let sha1: String? - - public init(sha1: String? = nil) { - self.sha1 = sha1 - } - -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift deleted file mode 100644 index cb91782b..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Compilers.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Compiler.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct Compilers: Codable, Equatable, Sendable { - public let gcc: Array? - public let llvm_gcc: Array? - public let llvm: Array? - public let clang: Array? - public let swift: Array? - - public init(gcc: XcodeVersion? = nil, llvm_gcc: XcodeVersion? = nil, llvm: XcodeVersion? = nil, clang: XcodeVersion? = nil, swift: XcodeVersion? = nil) { - self.gcc = gcc.map { [$0] } - self.llvm_gcc = llvm_gcc.map { [$0] } - self.llvm = llvm.map { [$0] } - self.clang = clang.map { [$0] } - self.swift = swift.map { [$0] } - } - - public init(gcc: Array?, llvm_gcc: Array?, llvm: Array?, clang: Array?, swift: Array?) { - self.gcc = gcc?.isEmpty == true ? nil : gcc - self.llvm_gcc = llvm_gcc?.isEmpty == true ? nil : llvm_gcc - self.llvm = llvm?.isEmpty == true ? nil : llvm - self.clang = clang?.isEmpty == true ? nil : clang - self.swift = swift?.isEmpty == true ? nil : swift - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift deleted file mode 100644 index c01fb274..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Link.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Link.swift -// xcodereleases -// -// Created by Xcode Releases on 4/5/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct Link: Codable, Sendable { - public let url: URL - public let sizeMB: Int? - /// The platforms supported by this link, if applicable. - public var architectures: [Architecture]? - -// public init(_ string: String, _ size: Int? = nil, _ architectures: [Architecture]? = nil) { -// self.url = URL(string: string)! -// self.sizeMB = size -// self.architectures = architectures -// } -} - -public struct Links: Codable, Sendable { - public let download: Link? - public let notes: Link? - - public init(download: Link? = nil, notes: Link? = nil) { - self.download = download - self.notes = notes - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift deleted file mode 100644 index 63567c0e..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/Release.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Release.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public enum Release: Codable, Equatable, Sendable { - - public enum CodingKeys: String, CodingKey { - case gm, gmSeed, rc, beta, dp, release - } - - public var isGM: Bool { - guard case .gm = self else { return false } - return true - } - - case gm - case gmSeed(Int) - case rc(Int) - case beta(Int) - case dp(Int) - case release - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if let _ = try container.decodeIfPresent(Bool.self, forKey: .gm) { - self = .gm - } else if let v = try container.decodeIfPresent(Int.self, forKey: .gmSeed) { - self = .gmSeed(v) - } else if let v = try container.decodeIfPresent(Int.self, forKey: .rc) { - self = .rc(v) - } else if let v = try container.decodeIfPresent(Int.self, forKey: .beta) { - self = .beta(v) - } else if let v = try container.decodeIfPresent(Int.self, forKey: .dp) { - self = .dp(v) - } else if let _ = try container.decodeIfPresent(Bool.self, forKey: .release) { - self = .release - } else { - fatalError("Unreachable") - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .gm: try container.encode(true, forKey: .gm) - case .gmSeed(let v): try container.encode(v, forKey: .gmSeed) - case .rc(let v): try container.encode(v, forKey: .rc) - case .beta(let v): try container.encode(v, forKey: .beta) - case .dp(let v): try container.encode(v, forKey: .dp) - case .release: try container.encode(true, forKey: .release) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift deleted file mode 100644 index e3ef99c9..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/SDKs.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// SDKs.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct SDKs: Codable, Equatable, Sendable { - public let macOS: Array? - public let iOS: Array? - public let watchOS: Array? - public let tvOS: Array? - public let visionOS: Array? - - public init(macOS: XcodeVersion? = nil, iOS: XcodeVersion? = nil, watchOS: XcodeVersion? = nil, tvOS: XcodeVersion? = nil, visionOS: XcodeVersion? = nil) { - self.macOS = macOS.map { [$0] } - self.iOS = iOS.map { [$0] } - self.watchOS = watchOS.map { [$0] } - self.tvOS = tvOS.map { [$0] } - self.visionOS = visionOS.map { [$0] } - } - - public init(macOS: Array?, iOS: XcodeVersion? = nil, watchOS: XcodeVersion? = nil, tvOS: XcodeVersion? = nil, visionOS: XcodeVersion? = nil) { - self.macOS = macOS?.isEmpty == true ? nil : macOS - self.iOS = iOS.map { [$0] } - self.watchOS = watchOS.map { [$0] } - self.tvOS = tvOS.map { [$0] } - self.visionOS = visionOS.map { [$0] } - } - - public init(macOS: Array?, iOS: Array?, watchOS: XcodeVersion? = nil, tvOS: XcodeVersion? = nil, visionOS: XcodeVersion? = nil) { - self.macOS = macOS?.isEmpty == true ? nil : macOS - self.iOS = iOS?.isEmpty == true ? nil : iOS - self.watchOS = watchOS.map { [$0] } - self.tvOS = tvOS.map { [$0] } - self.visionOS = visionOS.map { [$0] } - } - - public init(macOS: Array?, iOS: Array?, watchOS: Array?, tvOS: XcodeVersion? = nil, visionOS: XcodeVersion? = nil) { - self.macOS = macOS?.isEmpty == true ? nil : macOS - self.iOS = iOS?.isEmpty == true ? nil : iOS - self.watchOS = watchOS?.isEmpty == true ? nil : watchOS - self.tvOS = tvOS.map { [$0] } - self.visionOS = visionOS.map { [$0] } - } - - public init(macOS: Array?, iOS: Array?, watchOS: Array?, tvOS: Array?, visionOS: Array?) { - self.macOS = macOS?.isEmpty == true ? nil : macOS - self.iOS = iOS?.isEmpty == true ? nil : iOS - self.watchOS = watchOS?.isEmpty == true ? nil : watchOS - self.tvOS = tvOS?.isEmpty == true ? nil : tvOS - self.visionOS = visionOS?.isEmpty == true ? nil : visionOS - } - - /// All SDK build numbers, used to correlate downloadable runtimes with Xcode releases. - public var allBuilds: [String] { - [ - iOS, - tvOS, - macOS, - watchOS, - visionOS, - ] - .compactMap { $0 } - .flatMap { versions in versions.compactMap(\.build) } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift deleted file mode 100644 index 7d17a011..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeRelease.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Xcode.swift -// xcodereleases -// -// Created by Xcode Releases on 4/3/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct XcodeRelease: Codable, Sendable { - public let name: String - public let version: XcodeVersion - public let date: YMD - public let requires: String - public let sdks: SDKs? - public let compilers: Compilers? - public let links: Links? - public let checksums: Checksums? - - public var architectures: [Architecture]? { - return links.flatMap { $0.download?.architectures } - } - - public init(name: String = "Xcode", version: XcodeVersion, date: (Int, Int, Int), requires: String, sdks: SDKs? = nil, compilers: Compilers? = nil, links: Links? = nil, checksums: Checksums? = nil) { - self.name = name - self.version = version; - self.date = YMD(date); - self.requires = requires; - self.sdks = sdks; - self.compilers = compilers - self.links = links - self.checksums = checksums - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift deleted file mode 100644 index 7899ca6b..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/XcodeVersion.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Version.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public typealias V = XcodeVersion -public struct XcodeVersion: Codable, Equatable, Sendable { - public let number: String? - public let build: String? - public let release: Release - - public init(_ build: String, _ number: String? = nil, _ release: Release = .release) { - self.number = number; self.build = build; self.release = release - } - - public init(number: String, _ build: String? = nil, _ release: Release = .release) { - self.number = number; self.build = build; self.release = release - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift deleted file mode 100644 index 396a57b2..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodeReleases/YMD.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// YMD.swift -// xcodereleases -// -// Created by Xcode Releases on 4/4/18. -// Copyright © 2018 Xcode Releases. All rights reserved. -// - -import Foundation - -public struct YMD: Codable, Sendable { - public let year: Int - public let month: Int - public let day: Int - - public init(_ ymd: (Int, Int, Int)) { - self.year = ymd.0; self.month = ymd.1; self.day = ymd.2 - } - - public init(_ year: Int, _ month: Int, _ day: Int) { - self.year = year; self.month = month; self.day = day - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AutoInstallationType.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AutoInstallationType.swift deleted file mode 100644 index b60d3de2..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AutoInstallationType.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -public enum AutoInstallationType: Int, Identifiable, Sendable { - case none = 0 - case newestVersion - case newestBeta - - public var id: Self { self } - - public var isAutoInstalling: Bool { - get { self != .none } - set { - self = newValue ? .newestVersion : .none - } - } - - public var isAutoInstallingBeta: Bool { - get { self == .newestBeta } - set { - self = newValue ? .newestBeta : (isAutoInstalling ? .newestVersion : .none) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift deleted file mode 100644 index 679a4417..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcode.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation -@preconcurrency import Version - -/// A version of Xcode that's available for installation. -public struct AvailableXcode: Codable, Equatable, Sendable { - public var version: Version { - xcodeID.version - } - - public let url: URL - public let filename: String - public let releaseDate: Date? - public let requiredMacOSVersion: String? - public let releaseNotesURL: URL? - public let sdks: SDKs? - public let compilers: Compilers? - public let fileSize: Int64? - public let architectures: [Architecture]? - public var xcodeID: XcodeID - - public var downloadPath: String { - url.path - } - - public init( - version: Version, - url: URL, - filename: String, - releaseDate: Date?, - requiredMacOSVersion: String? = nil, - releaseNotesURL: URL? = nil, - sdks: SDKs? = nil, - compilers: Compilers? = nil, - fileSize: Int64? = nil, - architectures: [Architecture]? = nil - ) { - self.url = url - self.filename = filename - self.releaseDate = releaseDate - self.requiredMacOSVersion = requiredMacOSVersion - self.releaseNotesURL = releaseNotesURL - self.sdks = sdks - self.compilers = compilers - self.fileSize = fileSize - self.architectures = architectures - self.xcodeID = XcodeID(version: version, architectures: architectures) - } - - public init(release: AvailableXcodeRelease) { - self.init( - version: release.version, - url: release.url, - filename: release.filename, - releaseDate: release.releaseDate, - requiredMacOSVersion: release.requiredMacOSVersion, - releaseNotesURL: release.releaseNotesURL, - sdks: release.sdks, - compilers: release.compilers, - fileSize: release.fileSize, - architectures: release.architectures - ) - } - - public init(_ archive: XcodeArchive) { - self.init( - version: archive.version, - url: archive.downloadURL, - filename: archive.filename, - releaseDate: nil - ) - } -} - -public extension XcodeArchive { - init(_ xcode: AvailableXcode) { - self.init( - version: xcode.version, - downloadURL: xcode.url, - filename: xcode.filename - ) - } -} - -public extension Array where Element == AvailableXcode { - /// Returns the first Xcode that unambiguously has the same version as `version`. - /// - /// If there's an exact match that takes prerelease identifiers into account, that's returned. - /// Otherwise, if a version without prerelease or build metadata identifiers is provided, and there's a single match based on only the major, minor and patch numbers, that's returned. - /// If there are multiple matches, or no matches, nil is returned. - func first(withVersion version: Version) -> AvailableXcode? { - XcodeVersionMatcher.find(version: version, in: self, versionKeyPath: \AvailableXcode.version) - } - - func matchingArchitectures(_ architectures: [Architecture]) -> [AvailableXcode] { - guard !architectures.isEmpty else { return self } - return filter { $0.architectures?.containsAny(architectures) == true } - } - - func matchingArchitectureFilters(_ filters: [ArchitectureFilter]) -> [AvailableXcode] { - guard !filters.isEmpty else { return self } - return filter { filters.matches($0.architectures) } - } - - /// Returns the best compatible Xcode for the given version and host architecture. - /// Adapted from XcodesOrg/xcodes#470 by wmehanna. - func firstCompatible(withVersion version: Version, hostArchitecture: Architecture) -> AvailableXcode? { - let matches = all(withVersion: version) - guard !matches.isEmpty else { return nil } - - if let universal = matches.first(where: { $0.architectures?.isUniversal == true }) { - return universal - } - - if let matching = matches.first(where: { $0.architectures?.contains(hostArchitecture) == true }) { - return matching - } - - return matches.first - } - - private func all(withVersion version: Version) -> [AvailableXcode] { - let equivalentMatches = filter { $0.version.isEquivalent(to: version) } - if !equivalentMatches.isEmpty { - return equivalentMatches - } - - if version.prereleaseIdentifiers.isEmpty && version.buildMetadataIdentifiers.isEmpty { - return filter { $0.version.isEqualWithoutAllIdentifiers(to: version) } - } - - return [] - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcodeRelease.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcodeRelease.swift deleted file mode 100644 index ccb29af2..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/AvailableXcodeRelease.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -@preconcurrency import Version - -/// A source-neutral Xcode release that can be mapped into app- or CLI-specific state. -public struct AvailableXcodeRelease: Codable, Sendable { - public let version: Version - public let url: URL - public let filename: String - public let releaseDate: Date? - public let requiredMacOSVersion: String? - public let releaseNotesURL: URL? - public let sdks: SDKs? - public let compilers: Compilers? - public let fileSize: Int64? - public let architectures: [Architecture]? - - public var downloadPath: String { - url.path - } - - public init( - version: Version, - url: URL, - filename: String, - releaseDate: Date?, - requiredMacOSVersion: String? = nil, - releaseNotesURL: URL? = nil, - sdks: SDKs? = nil, - compilers: Compilers? = nil, - fileSize: Int64? = nil, - architectures: [Architecture]? = nil - ) { - self.version = version - self.url = url - self.filename = filename - self.releaseDate = releaseDate - self.requiredMacOSVersion = requiredMacOSVersion - self.releaseNotesURL = releaseNotesURL - self.sdks = sdks - self.compilers = compilers - self.fileSize = fileSize - self.architectures = architectures - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/Downloads.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/Downloads.swift deleted file mode 100644 index a089f407..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/Downloads.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -public struct Downloads: Codable, Sendable { - public let resultCode: Int? - public let resultsString: String? - public let downloads: [Download]? - - public init(resultCode: Int? = nil, resultsString: String? = nil, downloads: [Download]?) { - self.resultCode = resultCode - self.resultsString = resultsString - self.downloads = downloads - } - - public var hasError: Bool { - (resultCode ?? 0) != 0 - } -} - -public typealias ByteCount = Int64 - -public struct Download: Codable, Sendable { - public let name: String - public let files: [File] - public let dateModified: Date - - public init(name: String, files: [File], dateModified: Date) { - self.name = name - self.files = files - self.dateModified = dateModified - } - - public struct File: Codable, Sendable { - public let remotePath: String - public let fileSize: ByteCount? - - public init(remotePath: String, fileSize: ByteCount? = nil) { - self.remotePath = remotePath - self.fileSize = fileSize - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcode.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcode.swift deleted file mode 100644 index 422588c9..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcode.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation -@preconcurrency import Path -@preconcurrency import Version - -/// A version of Xcode that's already installed. -public struct InstalledXcode: Equatable, Sendable { - public typealias ContentsAtPath = @Sendable (String) -> Data? - public typealias LoadArchitectures = @Sendable (URL) throws -> ProcessOutput - - public let path: Path - public let xcodeID: XcodeID - - /// Composed of the bundle short version from Info.plist and the product build version from version.plist. - public var version: Version { - xcodeID.version - } - - public init(path: Path, version: Version, architectures: [Architecture]? = nil) { - self.path = path - self.xcodeID = XcodeID(version: version, architectures: architectures) - } - - public init?(path: Path) { - self.init( - path: path, - contentsAtPath: { path in Current.files.contents(atPath: path) }, - loadArchitectures: Current.shell.archs - ) - } - - public init?( - path: Path, - contentsAtPath: ContentsAtPath, - loadArchitectures: LoadArchitectures - ) { - guard - let bundle = XcodeBundleInfo(path: path, contentsAtPath: contentsAtPath), - bundle.bundleID == "com.apple.dt.Xcode" - else { return nil } - self.path = bundle.path - - let xcodeBinaryURL = path.url.appending(path: "Contents/MacOS/Xcode") - let archsString = try? loadArchitectures(xcodeBinaryURL).out - let architectures = archsString? - .trimmingCharacters(in: .whitespacesAndNewlines) - .split(separator: " ") - .compactMap { Architecture(rawValue: String($0)) } - - self.xcodeID = XcodeID(version: bundle.version, architectures: architectures) - } -} - -public extension Array where Element == InstalledXcode { - /// Returns the first installed Xcode that unambiguously has the same version as `version`. - func first(withVersion version: Version) -> InstalledXcode? { - XcodeVersionMatcher.find(version: version, in: self, versionKeyPath: \InstalledXcode.version) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift deleted file mode 100644 index d1d0be5e..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/InstalledXcodeBundle.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation -@preconcurrency import Path -@preconcurrency import Version - -public struct XcodeBundleInfo: Equatable, Sendable { - public let path: Path - public let bundleID: String? - public let version: Version - - public init?(path: Path, contentsAtPath: @Sendable (String) -> Data?) { - let infoPlistPath = path.join("Contents").join("Info.plist") - let versionPlistPath = path.join("Contents").join("version.plist") - - guard - let infoPlistData = contentsAtPath(infoPlistPath.string), - let infoPlist = try? PropertyListDecoder().decode(InfoPlist.self, from: infoPlistData), - let bundleShortVersion = infoPlist.bundleShortVersion, - let bundleVersion = Version(tolerant: bundleShortVersion), - let versionPlistData = contentsAtPath(versionPlistPath.string), - let versionPlist = try? PropertyListDecoder().decode(VersionPlist.self, from: versionPlistData) - else { return nil } - - var prereleaseIdentifiers = bundleVersion.prereleaseIdentifiers - if let filenameVersion = Version(path.basename(dropExtension: true).replacingOccurrences(of: "Xcode-", with: "")) { - prereleaseIdentifiers = filenameVersion.prereleaseIdentifiers - } else if infoPlist.bundleIconName == "XcodeBeta", !prereleaseIdentifiers.contains("beta") { - prereleaseIdentifiers = ["beta"] - } - - self.path = path - self.bundleID = infoPlist.bundleID - self.version = Version( - major: bundleVersion.major, - minor: bundleVersion.minor, - patch: bundleVersion.patch, - prereleaseIdentifiers: prereleaseIdentifiers, - buildMetadataIdentifiers: [versionPlist.productBuildVersion].compactMap { $0 } - ) - } -} - -public struct InfoPlist: Decodable, Sendable { - public let bundleID: String? - public let bundleShortVersion: String? - public let bundleIconName: String? - - public enum CodingKeys: String, CodingKey { - case bundleID = "CFBundleIdentifier" - case bundleShortVersion = "CFBundleShortVersionString" - case bundleIconName = "CFBundleIconName" - } -} - -public struct VersionPlist: Decodable, Sendable { - public let productBuildVersion: String - - public enum CodingKeys: String, CodingKey { - case productBuildVersion = "ProductBuildVersion" - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/SelectedActionType.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/SelectedActionType.swift deleted file mode 100644 index c1a82ee4..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/SelectedActionType.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -public enum SelectedActionType: String, CaseIterable, CustomStringConvertible, Identifiable, Sendable { - case none - case rename - - public var id: Self { self } - - public static var `default`: SelectedActionType { .none } - - public var description: String { - switch self { - case .none: return localizeString("OnSelectDoNothing") - case .rename: return localizeString("OnSelectRenameXcode") - } - } - - public var detailedDescription: String { - switch self { - case .none: return localizeString("OnSelectDoNothingDescription") - case .rename: return localizeString("OnSelectRenameXcodeDescription") - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeID.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeID.swift deleted file mode 100644 index e49b0344..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeID.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation -@preconcurrency import Version - -public struct XcodeID: Codable, Hashable, Identifiable, Sendable { - public let version: Version - public let architectures: [Architecture]? - - public var id: String { - let architectures = architectures?.map(\.rawValue).joined() ?? "" - return version.description + architectures - } - - public init(version: Version, architectures: [Architecture]? = nil) { - self.version = version - self.architectures = architectures - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift deleted file mode 100644 index 0ddf9051..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift +++ /dev/null @@ -1,325 +0,0 @@ -import Foundation -@preconcurrency import Version - -public enum XcodeListVersionFilter: Equatable, Sendable { - case all - case release - case prerelease -} - -public struct XcodeListFilters: Equatable, Sendable { - public let versionFilter: XcodeListVersionFilter - public let architectureFilters: [ArchitectureFilter] - public let allowedMajorVersions: Int? - public let searchText: String - public let installedOnly: Bool - - public init( - versionFilter: XcodeListVersionFilter = .all, - architectureFilters: [ArchitectureFilter] = [], - allowedMajorVersions: Int? = nil, - searchText: String = "", - installedOnly: Bool = false - ) { - self.versionFilter = versionFilter - self.architectureFilters = architectureFilters - self.allowedMajorVersions = allowedMajorVersions - self.searchText = searchText - self.installedOnly = installedOnly - } -} - -public struct XcodeMinorVersionGroup: Identifiable, Sendable { - public let majorVersion: Int - public let minorVersion: Int - public let versions: [XcodeListItem] - - public var id: String { - "\(majorVersion).\(minorVersion)" - } - - public var latestRelease: XcodeListItem? { - versions.latestRelease - } - - public var displayName: String { - "\(majorVersion).\(minorVersion)" - } - - public var hasInstalled: Bool { - versions.contains { $0.installState.installed } - } - - public var hasInstalling: Bool { - versions.contains { $0.installState.installing } - } - - public var selectedVersion: XcodeListItem? { - versions.first { $0.selected } - } - - public init(majorVersion: Int, minorVersion: Int, versions: [XcodeListItem]) { - self.majorVersion = majorVersion - self.minorVersion = minorVersion - self.versions = versions - } -} - -public struct XcodeMajorVersionGroup: Identifiable, Sendable { - public let majorVersion: Int - public let minorVersionGroups: [XcodeMinorVersionGroup] - - public var id: Int { - majorVersion - } - - public var versions: [XcodeListItem] { - minorVersionGroups.flatMap(\.versions) - } - - public var latestRelease: XcodeListItem? { - versions.latestRelease - } - - public var displayName: String { - "\(majorVersion)" - } - - public var hasInstalled: Bool { - minorVersionGroups.contains { $0.hasInstalled } - } - - public var hasInstalling: Bool { - minorVersionGroups.contains { $0.hasInstalling } - } - - public var selectedVersion: XcodeListItem? { - minorVersionGroups.compactMap(\.selectedVersion).first - } - - public init(majorVersion: Int, minorVersionGroups: [XcodeMinorVersionGroup]) { - self.majorVersion = majorVersion - self.minorVersionGroups = minorVersionGroups - } -} - -public struct XcodeListElementMinorVersionGroup: Identifiable { - public let majorVersion: Int - public let minorVersion: Int - public let versions: [Element] - - public var id: String { - "\(majorVersion).\(minorVersion)" - } - - public var displayName: String { - "\(majorVersion).\(minorVersion)" - } - - public init(majorVersion: Int, minorVersion: Int, versions: [Element]) { - self.majorVersion = majorVersion - self.minorVersion = minorVersion - self.versions = versions - } -} - -public struct XcodeListElementMajorVersionGroup: Identifiable { - public let majorVersion: Int - public let minorVersionGroups: [XcodeListElementMinorVersionGroup] - - public var id: Int { - majorVersion - } - - public var versions: [Element] { - minorVersionGroups.flatMap(\.versions) - } - - public var displayName: String { - "\(majorVersion)" - } - - public init(majorVersion: Int, minorVersionGroups: [XcodeListElementMinorVersionGroup]) { - self.majorVersion = majorVersion - self.minorVersionGroups = minorVersionGroups - } -} - -public extension Array { - func applying(_ filters: XcodeListFilters, item: (Element) -> XcodeListItem) -> [Element] { - let filteredItems = map { element in - XcodeListFilteredElement(element: element, item: item(element)) - } - .applying(filters) - - return filteredItems.map(\.element) - } - - func groupedByMajorVersion(item: (Element) -> XcodeListItem) -> [XcodeListElementMajorVersionGroup] { - Dictionary(grouping: self, by: { item($0).version.major }) - .map { majorVersion, elements in - let minorVersionGroups = Dictionary(grouping: elements, by: { item($0).version.minor }) - .map { minorVersion, minorElements in - XcodeListElementMinorVersionGroup( - majorVersion: majorVersion, - minorVersion: minorVersion, - versions: minorElements.sorted { item($0).version > item($1).version } - ) - } - .sorted { $0.minorVersion > $1.minorVersion } - - return XcodeListElementMajorVersionGroup( - majorVersion: majorVersion, - minorVersionGroups: minorVersionGroups - ) - } - .sorted { $0.majorVersion > $1.majorVersion } - } -} - -public extension Array where Element == XcodeListItem { - func applying(_ filters: XcodeListFilters) -> [XcodeListItem] { - var items = self - - switch filters.versionFilter { - case .all: - break - case .release: - items = items.filter { $0.version.isNotPrerelease } - case .prerelease: - items = items.filter { $0.version.isPrerelease } - } - - if !filters.architectureFilters.isEmpty { - items = items.filter { filters.architectureFilters.matches($0.architectures) } - } - - if let allowedMajorVersions = filters.allowedMajorVersions { - items = items.filteringUninstalledVersions(allowedMajorVersions: allowedMajorVersions) - } - - if !filters.searchText.isEmpty { - items = items.filter { $0.version.appleDescription.contains(filters.searchText) } - } - - if filters.installedOnly { - items = items.filter { $0.installState.installed } - } - - return items - } - - func groupedByMajorVersion() -> [XcodeMajorVersionGroup] { - Dictionary(grouping: self, by: { $0.version.major }) - .map { majorVersion, xcodes in - let minorVersionGroups = Dictionary(grouping: xcodes, by: { $0.version.minor }) - .map { minorVersion, minorXcodes in - XcodeMinorVersionGroup( - majorVersion: majorVersion, - minorVersion: minorVersion, - versions: minorXcodes.sorted { $0.version > $1.version } - ) - } - .sorted { $0.minorVersion > $1.minorVersion } - - return XcodeMajorVersionGroup( - majorVersion: majorVersion, - minorVersionGroups: minorVersionGroups - ) - } - .sorted { $0.majorVersion > $1.majorVersion } - } - - func filteringUninstalledVersions(allowedMajorVersions: Int) -> [XcodeListItem] { - guard - let latestMajor = sorted(by: { $0.version < $1.version }) - .filter({ $0.version.isNotPrerelease }) - .last? - .version - .major - else { return self } - - let oldestAllowedMajor = latestMajor - Swift.min(latestMajor, allowedMajorVersions) - return filter { item in - if item.installState.notInstalled, item.version.major < oldestAllowedMajor { - return false - } - return true - } - } - - var latestRelease: XcodeListItem? { - filter { $0.version.isNotPrerelease } - .sorted { $0.version < $1.version } - .last - } -} - -private struct XcodeListFilteredElement { - let element: Element - let item: XcodeListItem -} - -private extension Array { - func applying(_ filters: XcodeListFilters) -> [Element] where Element: XcodeListFilterable { - var elements = self - - switch filters.versionFilter { - case .all: - break - case .release: - elements = elements.filter { $0.listItem.version.isNotPrerelease } - case .prerelease: - elements = elements.filter { $0.listItem.version.isPrerelease } - } - - if !filters.architectureFilters.isEmpty { - elements = elements.filter { filters.architectureFilters.matches($0.listItem.architectures) } - } - - if let allowedMajorVersions = filters.allowedMajorVersions { - elements = elements.filteringUninstalledVersions(allowedMajorVersions: allowedMajorVersions) - } - - if !filters.searchText.isEmpty { - elements = elements.filter { $0.listItem.version.appleDescription.contains(filters.searchText) } - } - - if filters.installedOnly { - elements = elements.filter { $0.listItem.installState.installed } - } - - return elements - } - - func filteringUninstalledVersions(allowedMajorVersions: Int) -> [Element] where Element: XcodeListFilterable { - guard - let latestMajor = sorted(by: { $0.listItem.version < $1.listItem.version }) - .filter({ $0.listItem.version.isNotPrerelease }) - .last? - .listItem - .version - .major - else { return self } - - let oldestAllowedMajor = latestMajor - Swift.min(latestMajor, allowedMajorVersions) - return filter { element in - if element.listItem.installState.notInstalled, element.listItem.version.major < oldestAllowedMajor { - return false - } - return true - } - } -} - -private protocol XcodeListFilterable { - var listItem: XcodeListItem { get } -} - -extension XcodeListItem: XcodeListFilterable { - fileprivate var listItem: XcodeListItem { self } -} - -extension XcodeListFilteredElement: XcodeListFilterable { - fileprivate var listItem: XcodeListItem { item } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListItem.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListItem.swift deleted file mode 100644 index 6c8e5878..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Xcodes/XcodeListItem.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -@preconcurrency import Path -@preconcurrency import Version - -public struct XcodeListItem: Identifiable, Sendable { - public var version: Version { - id.version - } - - public let identicalBuilds: [XcodeID] - public let installState: XcodeInstallState - public let selected: Bool - public let requiredMacOSVersion: String? - public let releaseNotesURL: URL? - public let releaseDate: Date? - public let sdks: SDKs? - public let compilers: Compilers? - public let downloadFileSize: Int64? - public let architectures: [Architecture]? - public let id: XcodeID - - public init( - version: Version, - identicalBuilds: [XcodeID] = [], - installState: XcodeInstallState, - selected: Bool, - requiredMacOSVersion: String? = nil, - releaseNotesURL: URL? = nil, - releaseDate: Date? = nil, - sdks: SDKs? = nil, - compilers: Compilers? = nil, - downloadFileSize: Int64? = nil, - architectures: [Architecture]? = nil - ) { - self.identicalBuilds = identicalBuilds - self.installState = installState - self.selected = selected - self.requiredMacOSVersion = requiredMacOSVersion - self.releaseNotesURL = releaseNotesURL - self.releaseDate = releaseDate - self.sdks = sdks - self.compilers = compilers - self.downloadFileSize = downloadFileSize - self.architectures = architectures - self.id = XcodeID(version: version, architectures: architectures) - } - - public var installedPath: Path? { - installState.installedPath - } - - public var downloadFileSizeString: String? { - downloadFileSize.map { - ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodesKitError.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodesKitError.swift deleted file mode 100644 index 48c91ec5..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Models/XcodesKitError.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -public struct XcodesKitError: LocalizedError, Equatable, Sendable { - public let message: String - - public init(_ message: String) { - self.message = message - } - - public var errorDescription: String? { - message - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ApplicationSupportMigrationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ApplicationSupportMigrationService.swift deleted file mode 100644 index c4667d82..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ApplicationSupportMigrationService.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct ApplicationSupportMigrationService: Sendable { - public enum Result: Equatable, Sendable { - case noMigrationNeeded - case migratedOldSupportFiles - case removedOldSupportFiles - } - - public typealias FileExists = @Sendable (String) -> Bool - public typealias MoveItem = @Sendable (URL, URL) throws -> Void - public typealias RemoveItem = @Sendable (URL) throws -> Void - - private let fileExists: FileExists - private let moveItem: MoveItem - private let removeItem: RemoveItem - - public init( - fileExists: @escaping FileExists = { path in FileManager.default.fileExists(atPath: path) }, - moveItem: @escaping MoveItem = { source, destination in try FileManager.default.moveItem(at: source, to: destination) }, - removeItem: @escaping RemoveItem = { url in try FileManager.default.removeItem(at: url) } - ) { - self.fileExists = fileExists - self.moveItem = moveItem - self.removeItem = removeItem - } - - public func migrate(oldSupportPath: Path, newSupportPath: Path) -> Result { - guard fileExists(oldSupportPath.string) else { - return .noMigrationNeeded - } - - if fileExists(newSupportPath.string) { - try? removeItem(oldSupportPath.url) - return .removedOldSupportFiles - } else { - try? moveItem(oldSupportPath.url, newSupportPath.url) - return .migratedOldSupportFiles - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveCancellationCleanupService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveCancellationCleanupService.swift deleted file mode 100644 index a5c983eb..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveCancellationCleanupService.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct ArchiveCancellationCleanupService: Sendable { - public typealias RemoveItem = @Sendable (URL) throws -> Void - - private let removeItem: RemoveItem - - public init(removeItem: @escaping RemoveItem = { url in try FileManager.default.removeItem(at: url) }) { - self.removeItem = removeItem - } - - public func cleanupXcodeArchive( - for xcode: AvailableXcode, - applicationSupportPath: Path - ) { - let service = XcodeArchiveService( - applicationSupportPath: applicationSupportPath, - fileExists: { _ in false }, - download: { _, _, _, _ in throw XcodesKitError("Archive cleanup does not download") } - ) - cleanupArchive(at: service.expectedArchivePath(for: XcodeArchive(xcode))) - } - - public func cleanupRuntimeArchive( - for runtime: DownloadableRuntime, - destinationDirectory: Path - ) { - let service = RuntimeArchiveService( - fileExists: { _ in false }, - download: { _, _, _, _, _ in throw XcodesKitError("Archive cleanup does not download") } - ) - cleanupArchive(at: service.expectedArchivePath(for: runtime, destinationDirectory: destinationDirectory)) - } - - public func cleanupArchive(at archivePath: Path) { - try? removeItem(archivePath.url) - try? removeItem(aria2MetadataPath(for: archivePath).url) - } - - public func aria2MetadataPath(for archivePath: Path) -> Path { - archivePath.parent/(archivePath.basename() + ".aria2") - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadService.swift deleted file mode 100644 index c135dc97..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadService.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct ArchiveDownloadService: Sendable { - public typealias Aria2Download = @Sendable (Path, URL, Path, [HTTPCookie]) -> AsyncThrowingStream - public typealias URLSessionDownload = @Sendable (URL, URL, Data?) -> (progress: Progress, task: Task<(saveLocation: URL, response: URLResponse), Error>) - public typealias ResponseValidator = @Sendable (URLResponse) throws -> Void - public typealias ErrorFactory = @Sendable () -> Error - - private let aria2Download: Aria2Download - private let urlSessionDownload: URLSessionDownload - private let contentsAtPath: @Sendable (String) -> Data? - private let createFile: @Sendable (String, Data) -> Void - private let removeItem: @Sendable (URL) throws -> Void - private let shouldRetry: @Sendable (Error) -> Bool - private let validateResponse: ResponseValidator - - public init( - aria2Download: @escaping Aria2Download, - urlSessionDownload: @escaping URLSessionDownload, - contentsAtPath: @escaping @Sendable (String) -> Data?, - createFile: @escaping @Sendable (String, Data) -> Void, - removeItem: @escaping @Sendable (URL) throws -> Void, - shouldRetry: @escaping @Sendable (Error) -> Bool = { _ in true }, - validateResponse: @escaping ResponseValidator = { _ in } - ) { - self.aria2Download = aria2Download - self.urlSessionDownload = urlSessionDownload - self.contentsAtPath = contentsAtPath - self.createFile = createFile - self.removeItem = removeItem - self.shouldRetry = shouldRetry - self.validateResponse = validateResponse - } - - public func downloadWithAria2( - aria2Path: Path, - url: URL, - destination: Path, - cookies: [HTTPCookie], - progressChanged: @escaping @Sendable (Progress) -> Void - ) async throws -> URL { - try await attemptRetryableTask(shouldRetry: shouldRetry) { - let progressStream = aria2Download(aria2Path, url, destination, cookies) - - for try await progress in progressStream { - progressChanged(progress) - } - - return destination.url - } - } - - public func downloadWithURLSession( - url: URL, - destination: Path, - resumeDataPath: Path, - progressChanged: @escaping @Sendable (Progress) -> Void - ) async throws -> URL { - let persistedResumeData = contentsAtPath(resumeDataPath.string) - - do { - let url = try await attemptResumableTask(shouldRetry: shouldRetry) { resumeData -> URL in - let (progress, task) = urlSessionDownload( - url, - destination.url, - resumeData ?? persistedResumeData - ) - progressChanged(progress) - - let result = try await task.value - try validateResponse(result.response) - return result.saveLocation - } - try? removeItem(resumeDataPath.url) - return url - } catch { - if let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data { - createFile(resumeDataPath.string, resumeData) - } - - throw error - } - } - - public static func resumeDataPath(for archive: XcodeArchive, in directory: Path) -> Path { - directory/"Xcode-\(archive.version).resumedata" - } - - /// Apple redirects unauthorized downloads to an HTML page with a 200 status. Treat that - /// as an authorization failure before the caller tries to unarchive the page as a XIP. - public static func validateDeveloperDownloadResponse( - _ response: URLResponse, - unauthorizedError: ErrorFactory = { XcodesKitError("Received 403: Unauthorized.") } - ) throws { - guard response.url?.lastPathComponent != "unauthorized" else { - throw unauthorizedError() - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadStrategyService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadStrategyService.swift deleted file mode 100644 index a85c1fc8..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ArchiveDownloadStrategyService.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct ArchiveDownloadStrategyService: Sendable { - public typealias Aria2Path = @Sendable () throws -> Path - public typealias CookiesForURL = @Sendable (URL) -> [HTTPCookie] - - private let archiveDownloadService: ArchiveDownloadService - private let aria2Path: Aria2Path - private let cookiesForURL: CookiesForURL - - public init( - archiveDownloadService: ArchiveDownloadService, - aria2Path: @escaping Aria2Path, - cookiesForURL: @escaping CookiesForURL = { _ in [] } - ) { - self.archiveDownloadService = archiveDownloadService - self.aria2Path = aria2Path - self.cookiesForURL = cookiesForURL - } - - public func download( - url: URL, - destination: Path, - downloader: XcodeArchiveDownloader, - resumeDataPath: Path, - progressChanged: @escaping @Sendable (Progress) -> Void - ) async throws -> URL { - switch downloader { - case .aria2: - return try await archiveDownloadService.downloadWithAria2( - aria2Path: aria2Path(), - url: url, - destination: destination, - cookies: cookiesForURL(url), - progressChanged: progressChanged - ) - case .urlSession: - return try await archiveDownloadService.downloadWithURLSession( - url: url, - destination: destination, - resumeDataPath: resumeDataPath, - progressChanged: progressChanged - ) - } - } - - public func download( - archive: XcodeArchive, - destination: Path, - downloader: XcodeArchiveDownloader, - applicationSupportPath: Path, - progressChanged: @escaping @Sendable (Progress) -> Void - ) async throws -> URL { - try await download( - url: archive.downloadURL, - destination: destination, - downloader: downloader, - resumeDataPath: ArchiveDownloadService.resumeDataPath(for: archive, in: applicationSupportPath), - progressChanged: progressChanged - ) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/Aria2DownloadService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/Aria2DownloadService.swift deleted file mode 100644 index 2b2f307a..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/Aria2DownloadService.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import os -@preconcurrency import Path - -public struct Aria2DownloadService: Sendable { - public init() {} - - public func download( - aria2Path: Path, - url: URL, - destination: Path, - cookies: [HTTPCookie], - progress: Progress = Progress(), - unauthorizedError: (@Sendable () -> Error)? = nil - ) -> AsyncThrowingStream { - let process = Process() - process.executableURL = aria2Path.url - process.arguments = [ - "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", - "--max-connection-per-server=16", - "--split=16", - "--summary-interval=1", - "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", - "--dir=\(destination.parent.string)", - "--out=\(destination.basename())", - "--human-readable=false", - url.absoluteString, - ] - - let state = UnauthorizedState() - let runner = ProcessProgressStreamRunner( - process: process, - progress: progress, - outputHandler: { string, progress in - if string.contains("Redirecting to https://developer.apple.com/unauthorized/") { - state.markUnauthorized() - } - - progress.updateFromAria2(string: string) - }, - failureHandler: { process in - if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { - return aria2cError - } else { - return ProcessExecutionError(process: process, standardOutput: "", standardError: "") - } - }, - successHandler: { - guard !state.isUnauthorized else { - return unauthorizedError?() ?? XcodesKitError("Received 403: Unauthorized.") - } - return nil - } - ) - - return runner.stream() - } -} - -private final class UnauthorizedState: Sendable { - private let unauthorized = OSAllocatedUnfairLock(initialState: false) - - var isUnauthorized: Bool { - unauthorized.withLock { $0 } - } - - func markUnauthorized() { - unauthorized.withLock { $0 = true } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/AsyncRetry.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/AsyncRetry.swift deleted file mode 100644 index 7518fe2b..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/AsyncRetry.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -/// Attempts a resumable async task and retries failures that include URLSession resume data. -public func attemptResumableTask( - maximumRetryCount: Int = 3, - delayBeforeRetry: Duration = .seconds(2), - shouldRetry: @escaping @Sendable (Error) -> Bool = { _ in true }, - _ body: @escaping @Sendable (Data?) async throws -> T -) async throws -> T { - var attempts = 0 - var resumeData: Data? - - while true { - attempts += 1 - - do { - return try await body(resumeData) - } catch { - guard - attempts < maximumRetryCount, - shouldRetry(error), - let nextResumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data - else { - throw error - } - - resumeData = nextResumeData - try await Task.sleep(for: delayBeforeRetry) - } - } -} - -/// Attempts an async task and retries caller-approved failures. -public func attemptRetryableTask( - maximumRetryCount: Int = 3, - delayBeforeRetry: Duration = .seconds(2), - shouldRetry: @escaping @Sendable (Error) -> Bool = { _ in true }, - _ body: @escaping @Sendable () async throws -> T -) async throws -> T { - var attempts = 0 - - while true { - attempts += 1 - - do { - return try await body() - } catch { - guard attempts < maximumRetryCount, shouldRetry(error) else { - throw error - } - - try await Task.sleep(for: delayBeforeRetry) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/AvailableXcodeCache.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/AvailableXcodeCache.swift deleted file mode 100644 index 35fa4a81..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/AvailableXcodeCache.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct AvailableXcodeCache: Sendable { - public typealias Attributes = [FileAttributeKey: Any] - public typealias ContentsAtPath = @Sendable (String) -> Data? - public typealias WriteData = @Sendable (Data, URL) throws -> Void - public typealias CreateDirectory = @Sendable (URL, Bool, Attributes?) throws -> Void - public typealias AttributesOfItem = @Sendable (String) throws -> Attributes - - public let cacheFile: Path - private var contentsAtPath: ContentsAtPath - private var writeData: WriteData - private var createDirectory: CreateDirectory - private var attributesOfItem: AttributesOfItem - - public init( - cacheFile: Path, - contentsAtPath: @escaping ContentsAtPath = { FileManager.default.contents(atPath: $0) }, - writeData: @escaping WriteData = { try $0.write(to: $1) }, - createDirectory: @escaping CreateDirectory = { url, createIntermediates, attributes in - try FileManager.default.createDirectory( - at: url, - withIntermediateDirectories: createIntermediates, - attributes: attributes - ) - }, - attributesOfItem: @escaping AttributesOfItem = { try FileManager.default.attributesOfItem(atPath: $0) } - ) { - self.cacheFile = cacheFile - self.contentsAtPath = contentsAtPath - self.writeData = writeData - self.createDirectory = createDirectory - self.attributesOfItem = attributesOfItem - } - - public func load() throws -> [AvailableXcode]? { - guard let data = contentsAtPath(cacheFile.string) else { return nil } - return try JSONDecoder().decode([AvailableXcode].self, from: data) - } - - public func lastModified() -> Date? { - let attributes = try? attributesOfItem(cacheFile.string) - return attributes?[.modificationDate] as? Date - } - - public func save(_ xcodes: [AvailableXcode]) throws { - let data = try JSONEncoder().encode(xcodes) - try createDirectory(cacheFile.url.deletingLastPathComponent(), true, nil) - try writeData(data, cacheFile.url) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/CodableFileStore.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/CodableFileStore.swift deleted file mode 100644 index 3d14469a..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/CodableFileStore.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct CodableFileStore: Sendable { - public typealias Attributes = [FileAttributeKey: Any] - public typealias ContentsAtPath = @Sendable (String) -> Data? - public typealias CreateDirectory = @Sendable (URL, Bool, Attributes?) throws -> Void - public typealias CreateFile = @Sendable (String, Data?, Attributes?) -> Bool - public typealias Decode = @Sendable (Data) throws -> Value - public typealias Encode = @Sendable (Value) throws -> Data - - private let contentsAtPath: ContentsAtPath - private let createDirectory: CreateDirectory - private let createFile: CreateFile - private let decode: Decode - private let encode: Encode - - public init( - contentsAtPath: @escaping ContentsAtPath = { path in FileManager.default.contents(atPath: path) }, - createDirectory: @escaping CreateDirectory = { url, createIntermediates, attributes in - try FileManager.default.createDirectory( - at: url, - withIntermediateDirectories: createIntermediates, - attributes: attributes - ) - }, - createFile: @escaping CreateFile = { path, data, attributes in - FileManager.default.createFile(atPath: path, contents: data, attributes: attributes) - }, - decode: @escaping Decode = { data in try JSONDecoder().decode(Value.self, from: data) }, - encode: @escaping Encode = { value in try JSONEncoder().encode(value) } - ) { - self.contentsAtPath = contentsAtPath - self.createDirectory = createDirectory - self.createFile = createFile - self.decode = decode - self.encode = encode - } - - public func load(from file: Path) throws -> Value? { - guard let data = contentsAtPath(file.string) else { return nil } - return try decode(data) - } - - public func save(_ value: Value, to file: Path) throws { - let data = try encode(value) - try createDirectory(file.url.deletingLastPathComponent(), true, nil) - _ = createFile(file.string, data, nil) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/DownloadableRuntimeCache.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/DownloadableRuntimeCache.swift deleted file mode 100644 index a8050c64..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/DownloadableRuntimeCache.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct DownloadableRuntimeCache: Sendable { - public typealias Attributes = [FileAttributeKey: Any] - public typealias ContentsAtPath = @Sendable (String) -> Data? - public typealias WriteData = @Sendable (Data, URL) throws -> Void - public typealias CreateDirectory = @Sendable (URL, Bool, Attributes?) throws -> Void - - public let cacheFile: Path - private let contentsAtPath: ContentsAtPath - private let writeData: WriteData - private let createDirectory: CreateDirectory - - public init( - cacheFile: Path, - contentsAtPath: @escaping ContentsAtPath = { FileManager.default.contents(atPath: $0) }, - writeData: @escaping WriteData = { try $0.write(to: $1) }, - createDirectory: @escaping CreateDirectory = { url, createIntermediates, attributes in - try FileManager.default.createDirectory( - at: url, - withIntermediateDirectories: createIntermediates, - attributes: attributes - ) - } - ) { - self.cacheFile = cacheFile - self.contentsAtPath = contentsAtPath - self.writeData = writeData - self.createDirectory = createDirectory - } - - public func load() throws -> [DownloadableRuntime]? { - guard let data = contentsAtPath(cacheFile.string) else { return nil } - return try JSONDecoder().decode([DownloadableRuntime].self, from: data) - } - - public func save(_ runtimes: [DownloadableRuntime]) throws { - let data = try JSONEncoder().encode(runtimes) - try createDirectory(cacheFile.url.deletingLastPathComponent(), true, nil) - try writeData(data, cacheFile.url) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/HostHardware.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/HostHardware.swift deleted file mode 100644 index 8d38e31a..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/HostHardware.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -public struct HostHardware: Sendable { - public init() {} - - /// Determines the architecture of the Mac on which we're running. - public static func currentMachineHardwareName() -> String? { - var sysInfo = utsname() - let result = uname(&sysInfo) - - guard result == EXIT_SUCCESS else { - return nil - } - - let bytes = Data(bytes: &sysInfo.machine, count: Int(_SYS_NAMELEN)) - return String(data: bytes, encoding: .utf8)? - .trimmingCharacters(in: CharacterSet(charactersIn: "\0")) - } - - public static func isAppleSilicon(machineHardwareName: String? = currentMachineHardwareName()) -> Bool { - machineHardwareName == Architecture.arm64.rawValue - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/InstalledXcodeDiscoveryService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/InstalledXcodeDiscoveryService.swift deleted file mode 100644 index b286b8a7..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/InstalledXcodeDiscoveryService.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct InstalledXcodeDiscoveryService: Sendable { - public typealias ListDirectory = @Sendable (Path) -> [Path] - public typealias IsAppBundle = @Sendable (Path) -> Bool - public typealias ContentsAtPath = InstalledXcode.ContentsAtPath - public typealias LoadArchitectures = InstalledXcode.LoadArchitectures - - private let listDirectory: ListDirectory - private let isAppBundle: IsAppBundle - private let contentsAtPath: ContentsAtPath - private let loadArchitectures: LoadArchitectures - - public init( - listDirectory: @escaping ListDirectory, - isAppBundle: @escaping IsAppBundle = { path in Path.isAppBundle(path: path) }, - contentsAtPath: @escaping ContentsAtPath, - loadArchitectures: @escaping LoadArchitectures - ) { - self.listDirectory = listDirectory - self.isAppBundle = isAppBundle - self.contentsAtPath = contentsAtPath - self.loadArchitectures = loadArchitectures - } - - public func installedXcodes(in directory: Path) -> [InstalledXcode] { - listDirectory(directory).compactMap(installedXcode(at:)) - } - - public func installedXcode(at path: Path) -> InstalledXcode? { - guard isAppBundle(path) else { return nil } - return InstalledXcode( - path: path, - contentsAtPath: contentsAtPath, - loadArchitectures: loadArchitectures - ) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ProgressObservation.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/ProgressObservation.swift deleted file mode 100644 index 4047fbdc..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/ProgressObservation.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Foundation -import os - -public enum ProgressObservedProperty: Sendable, Hashable { - case fractionCompleted - case localizedAdditionalDescription - case isIndeterminate -} - -public final class ProgressObservation: Sendable { - private let observations = OSAllocatedUnfairLock(initialState: [NSKeyValueObservation]()) - - public init() {} - - deinit { - invalidate() - } - - public func observe(_ progress: Progress, onChange: @escaping @Sendable (Progress) -> Void) { - observe(progress, observing: [.fractionCompleted], onChange: onChange) - } - - public func observe(_ progress: Progress, observing properties: Set, onChange: @escaping @Sendable (Progress) -> Void) { - let observations = properties.sortedForObservation().map { property in - switch property { - case .fractionCompleted: - progress.observe(\.fractionCompleted) { progress, _ in - onChange(progress) - } - case .localizedAdditionalDescription: - progress.observe(\.localizedAdditionalDescription) { progress, _ in - onChange(progress) - } - case .isIndeterminate: - progress.observe(\.isIndeterminate) { progress, _ in - onChange(progress) - } - } - } - - let previousObservations = self.observations.withLock { - let previousObservations = $0 - $0 = observations - return previousObservations - } - - for observation in previousObservations { - observation.invalidate() - } - } - - public static func changes( - for progress: Progress, - observing properties: Set = [.fractionCompleted] - ) -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: Void.self, bufferingPolicy: .bufferingNewest(1)) - let observation = ProgressObservation() - - observation.observe(progress, observing: properties) { _ in - continuation.yield() - } - - continuation.onTermination = { _ in - observation.invalidate() - } - - return stream - } - - public func invalidate() { - let observations = self.observations.withLock { - let observations = $0 - $0 = [] - return observations - } - - for observation in observations { - observation.invalidate() - } - } -} - -private extension ProgressObservedProperty { - var sortOrder: Int { - switch self { - case .fractionCompleted: - 0 - case .localizedAdditionalDescription: - 1 - case .isIndeterminate: - 2 - } - } -} - -private extension Set where Element == ProgressObservedProperty { - func sortedForObservation() -> [ProgressObservedProperty] { - sorted { $0.sortOrder < $1.sortOrder } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveDownloadStrategyService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveDownloadStrategyService.swift deleted file mode 100644 index 63fe0b5d..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveDownloadStrategyService.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct RuntimeArchiveDownloadStrategyService: Sendable { - public typealias ValidateDownloadPath = @Sendable (String) async throws -> Void - public typealias Aria2Path = @Sendable () throws -> Path - public typealias Aria2Download = @Sendable (DownloadableRuntime, Path, Path, @escaping @Sendable (URL) -> [HTTPCookie]) -> AsyncThrowingStream - public typealias CookiesForURL = @Sendable (URL) -> [HTTPCookie] - public typealias URLSessionDownload = @Sendable (URL, Path, @escaping @Sendable (Progress) -> Void) async throws -> URL - public typealias MissingDownloadPathError = @Sendable (DownloadableRuntime) -> Error - - private let validateDownloadPath: ValidateDownloadPath - private let aria2Path: Aria2Path - private let aria2Download: Aria2Download - private let cookiesForURL: CookiesForURL - private let urlSessionDownload: URLSessionDownload? - private let missingDownloadPathError: MissingDownloadPathError - - public init( - validateDownloadPath: @escaping ValidateDownloadPath, - aria2Path: @escaping Aria2Path, - aria2Download: @escaping Aria2Download = { runtime, destination, aria2Path, cookiesForURL in - RuntimeArchiveService.downloadWithAria2( - runtime: runtime, - to: destination, - aria2Path: aria2Path, - cookiesForURL: cookiesForURL - ) - }, - cookiesForURL: @escaping CookiesForURL = { _ in [] }, - urlSessionDownload: URLSessionDownload? = nil, - missingDownloadPathError: @escaping MissingDownloadPathError = { _ in XcodesKitError("Invalid runtime downloadPath") } - ) { - self.validateDownloadPath = validateDownloadPath - self.aria2Path = aria2Path - self.aria2Download = aria2Download - self.cookiesForURL = cookiesForURL - self.urlSessionDownload = urlSessionDownload - self.missingDownloadPathError = missingDownloadPathError - } - - public func download( - runtime: DownloadableRuntime, - url: URL, - destination: Path, - downloader: XcodeArchiveDownloader, - progressChanged: @escaping @Sendable (Progress) -> Void - ) async throws -> URL { - guard let downloadPath = runtime.downloadPath else { - throw missingDownloadPathError(runtime) - } - - try await validateDownloadPath(downloadPath) - - switch downloader { - case .aria2: - for try await progress in aria2Download(runtime, destination, try aria2Path(), cookiesForURL) { - progressChanged(progress) - } - return destination.url - case .urlSession: - guard let urlSessionDownload else { - throw XcodesKitError("Downloading runtimes with URLSession is not supported. Please use aria2") - } - - return try await urlSessionDownload(url, destination, progressChanged) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveInstallService.swift deleted file mode 100644 index 962eaca8..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveInstallService.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation - -public enum RuntimeArchiveInstallError: LocalizedError, Equatable, Sendable { - case unsupportedContentType(DownloadableRuntime.ContentType, archiveURL: URL) - - public var errorDescription: String? { - switch self { - case let .unsupportedContentType(contentType, archiveURL): - return "Installing via \(contentType.rawValue) not support - please install manually from \(archiveURL.description)" - } - } -} - -public struct RuntimeArchiveInstallService: Sendable { - public typealias StepChanged = @Sendable (RuntimeInstallationStep) async -> Void - - private let installDiskImage: @Sendable (URL) async throws -> Void - private let removeArchive: @Sendable (URL) throws -> Void - - public init( - installDiskImage: @escaping @Sendable (URL) async throws -> Void, - removeArchive: @escaping @Sendable (URL) throws -> Void - ) { - self.installDiskImage = installDiskImage - self.removeArchive = removeArchive - } - - public func install( - runtime: DownloadableRuntime, - archiveURL: URL, - deleteArchive: Bool = true, - stepChanged: StepChanged = { _ in } - ) async throws { - switch runtime.contentType { - case .diskImage: - await stepChanged(.installing) - try await installDiskImage(archiveURL) - try Task.checkCancellation() - - guard deleteArchive else { return } - await stepChanged(.trashingArchive) - try removeArchive(archiveURL) - case .package, .cryptexDiskImage, .patchableCryptexDiskImage: - throw RuntimeArchiveInstallError.unsupportedContentType(runtime.contentType, archiveURL: archiveURL) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveService.swift deleted file mode 100644 index 2f0f3be0..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeArchiveService.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct RuntimeArchiveService: Sendable { - public typealias Download = @Sendable (DownloadableRuntime, URL, Path, XcodeArchiveDownloader, @escaping @Sendable (Progress) -> Void) async throws -> URL - - private let fileExists: @Sendable (Path) -> Bool - private let download: Download - - public init( - fileExists: @escaping @Sendable (Path) -> Bool, - download: @escaping Download - ) { - self.fileExists = fileExists - self.download = download - } - - public func archiveURL( - for runtime: DownloadableRuntime, - destinationDirectory: Path, - downloader: XcodeArchiveDownloader, - progressChanged: @escaping @Sendable (Progress) -> Void - ) async throws -> URL { - guard let url = runtime.url else { - throw XcodesKitError("Invalid or non existent runtime url") - } - - let destination = expectedArchivePath(for: runtime, destinationDirectory: destinationDirectory) - let metadataPath = aria2MetadataPath(for: destination) - let aria2DownloadIsIncomplete = downloader == .aria2 && fileExists(metadataPath) - - if fileExists(destination), aria2DownloadIsIncomplete == false { - return destination.url - } - - return try await download(runtime, url, destination, downloader, progressChanged) - } - - public func expectedArchivePath(for runtime: DownloadableRuntime, destinationDirectory: Path) -> Path { - guard let url = runtime.url else { - return destinationDirectory/runtime.identifier - } - return destinationDirectory/url.lastPathComponent - } - - public func aria2MetadataPath(for archivePath: Path) -> Path { - archivePath.parent/(archivePath.basename() + ".aria2") - } - - public static func downloadWithAria2( - runtime: DownloadableRuntime, - to destination: Path, - aria2Path: Path, - cookiesForURL: @escaping @Sendable (URL) -> [HTTPCookie] - ) -> AsyncThrowingStream { - guard let url = runtime.url else { - let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) - continuation.finish(throwing: XcodesKitError("Invalid or non existent runtime url")) - return stream - } - - return Aria2DownloadService().download( - aria2Path: aria2Path, - url: url, - destination: destination, - cookies: cookiesForURL(url) - ) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallPolicy.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallPolicy.swift deleted file mode 100644 index e7da4598..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallPolicy.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation -@preconcurrency import Version - -public enum RuntimeInstallMethod: Equatable, Sendable { - case archive - case xcodebuild(architecture: String?) -} - -public enum RuntimeInstallPolicyError: LocalizedError, Equatable, Sendable { - case noSelectedXcode - case xcode16_1OrGreaterRequired(Version) - case xcode26OrGreaterRequired(Version) - - public var errorDescription: String? { - switch self { - case .noSelectedXcode: - return "No Xcode is currently selected, please make sure that you have one selected and installed before trying to install this runtime" - case let .xcode16_1OrGreaterRequired(version): - return "Installing this runtime requires Xcode 16.1 or greater to be selected, but is currently \(version.description)" - case let .xcode26OrGreaterRequired(version): - return "Installing this runtime for Apple Silicon requires Xcode 26 or greater to be selected, but is currently \(version.description)" - } - } -} - -public struct RuntimeInstallPolicy: Sendable { - public init() {} - - public func installMethod( - for runtime: DownloadableRuntime, - selectedXcodeVersion: Version? - ) throws -> RuntimeInstallMethod { - guard runtime.contentType == .cryptexDiskImage else { - return .archive - } - - guard let selectedXcodeVersion else { - throw RuntimeInstallPolicyError.noSelectedXcode - } - - guard selectedXcodeVersion > Version(major: 16, minor: 0, patch: 0) else { - throw RuntimeInstallPolicyError.xcode16_1OrGreaterRequired(selectedXcodeVersion) - } - - if runtime.architectures?.isAppleSilicon == true { - guard selectedXcodeVersion > Version(major: 25, minor: 0, patch: 0) else { - throw RuntimeInstallPolicyError.xcode26OrGreaterRequired(selectedXcodeVersion) - } - return .xcodebuild(architecture: Architecture.arm64.rawValue) - } - - return .xcodebuild(architecture: nil) - } - - public func selectedXcodeVersion(fromXcodebuildVersionOutput output: String) -> Version? { - let versionPattern = #"Xcode (\d+\.\d+)"# - guard let versionRegex = try? NSRegularExpression(pattern: versionPattern), - let match = versionRegex.firstMatch(in: output, range: NSRange(output.startIndex..., in: output)), - let versionRange = Range(match.range(at: 1), in: output) else { - return nil - } - - return Version(tolerant: String(output[versionRange])) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallationLookupService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallationLookupService.swift deleted file mode 100644 index aa004a39..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeInstallationLookupService.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct RuntimeInstallationLookupService: Sendable { - public init() {} - - public func coreSimulatorImage( - for runtime: DownloadableRuntime, - in installedRuntimes: [CoreSimulatorImage] - ) -> CoreSimulatorImage? { - installedRuntimes.first { - $0.runtimeInfo.build == runtime.simulatorVersion.buildUpdate && - runtimeArchitectureMatches(runtime, installedRuntime: $0) - } - } - - public func installPath( - for runtime: DownloadableRuntime, - in installedRuntimes: [CoreSimulatorImage] - ) -> Path? { - guard - let image = coreSimulatorImage(for: runtime, in: installedRuntimes), - let relativePath = image.path["relative"] - else { - return nil - } - - let path = relativePath.replacingOccurrences(of: "file://", with: "") - return Path(url: URL(fileURLWithPath: path)) - } - - private func runtimeArchitectureMatches( - _ runtime: DownloadableRuntime, - installedRuntime: CoreSimulatorImage - ) -> Bool { - guard let architectures = runtime.architectures, architectures.isEmpty == false else { - return true - } - - return installedRuntime.runtimeInfo.supportedArchitectures == architectures - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift deleted file mode 100644 index 47e0f640..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListPresentationService.swift +++ /dev/null @@ -1,187 +0,0 @@ -import Foundation -@preconcurrency import Version - -public struct RuntimeListPresentationService: Sendable { - public struct RuntimeRow: Sendable { - public let platform: DownloadableRuntime.Platform - public let betaNumber: Int? - public let version: String - public let build: String - public let kind: InstalledRuntime.Kind? - public var hasDuplicateVersion: Bool - public let architectures: [Architecture]? - - public var completeVersion: String { - makeRuntimeVersion(for: version, betaNumber: betaNumber) - } - - public var visibleIdentifier: String { - platform.shortName + " " + completeVersion + (architectures?.listOutputSuffix ?? "") - } - - fileprivate init( - platform: DownloadableRuntime.Platform, - betaNumber: Int?, - version: String, - build: String, - kind: InstalledRuntime.Kind? = nil, - hasDuplicateVersion: Bool = false, - architectures: [Architecture]? - ) { - self.platform = platform - self.betaNumber = betaNumber - self.version = version - self.build = build - self.kind = kind - self.hasDuplicateVersion = hasDuplicateVersion - self.architectures = architectures - } - } - - public init() {} - - public func rows( - downloadableRuntimes: DownloadableRuntimesResponse, - installedRuntimes: [InstalledRuntime], - includeBetas: Bool, - architectures: [ArchitectureFilter] = [] - ) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] { - rows( - downloadableRuntimes: downloadableRuntimes.downloadablesWithSDKBuildUpdates(), - installedRuntimes: installedRuntimes, - includeBetas: includeBetas, - sdkToSeedMappings: downloadableRuntimes.sdkToSeedMappings, - architectures: architectures - ) - } - - public func rows( - downloadableRuntimes: [DownloadableRuntime], - installedRuntimes: [InstalledRuntime], - includeBetas: Bool, - sdkToSeedMappings: [SDKToSeedMapping] = [], - architectures: [ArchitectureFilter] = [] - ) -> [(platform: DownloadableRuntime.Platform, runtimes: [RuntimeRow])] { - var unmatchedInstalledRuntimes = installedRuntimes - var rows: [RuntimeRow] = [] - - downloadableRuntimes.matchingArchitectureFilters(architectures).forEach { downloadable in - let matchingInstalledRuntimes = unmatchedInstalledRuntimes.removeAll { - $0.build == downloadable.simulatorVersion.buildUpdate - } - - if matchingInstalledRuntimes.isEmpty { - rows.append(RuntimeRow(downloadable)) - } else { - matchingInstalledRuntimes.forEach { installedRuntime in - rows.append(RuntimeRow(downloadable, kind: installedRuntime.kind)) - } - } - } - - if architectures.isEmpty { - unmatchedInstalledRuntimes.forEach { installedRuntime in - let betaNumber = sdkToSeedMappings.first { - $0.buildUpdate == installedRuntime.build - }?.seedNumber - var row = RuntimeRow(installedRuntime, betaNumber: betaNumber) - - rows.indices.filter { row.visibleIdentifier == rows[$0].visibleIdentifier }.forEach { index in - row.hasDuplicateVersion = true - rows[index].hasDuplicateVersion = true - } - - rows.append(row) - } - } - - return Dictionary(grouping: rows, by: \.platform) - .sorted(\.key.order) - .map { platform, runtimes in - ( - platform: platform, - runtimes: runtimes - .filter { includeBetas || $0.betaNumber == nil || $0.kind != nil } - .sorted(by: sortRuntimes) - ) - } - } - - public func line(for row: RuntimeRow) -> String { - var string = row.visibleIdentifier - if row.hasDuplicateVersion { - string += " (\(row.build))" - } - if let kind = row.kind { - switch kind { - case .bundled: - string += " (Bundled with selected Xcode)" - case .legacyDownload, .diskImage, .cryptexDiskImage, .patchableCryptexDiskImage: - string += " (Installed)" - } - } - return string - } - - private func sortRuntimes(_ first: RuntimeRow, _ second: RuntimeRow) -> Bool { - let firstVersion = Version(tolerant: first.completeVersion)! - let secondVersion = Version(tolerant: second.completeVersion)! - if firstVersion == secondVersion { - return first.build.compare(second.build, options: .numeric) == .orderedAscending - } - return firstVersion < secondVersion - } -} - -public extension DownloadableRuntimesResponse { - func downloadablesWithSDKBuildUpdates() -> [DownloadableRuntime] { - downloadables.map { runtime in - var updatedRuntime = runtime - let mappings = sdkToSimulatorMappings.filter { - $0.simulatorBuildUpdate == runtime.simulatorVersion.buildUpdate - } - updatedRuntime.sdkBuildUpdate = mappings.map(\.sdkBuildUpdate) - return updatedRuntime - } - } -} - -private extension RuntimeListPresentationService.RuntimeRow { - init(_ runtime: DownloadableRuntime, kind: InstalledRuntime.Kind? = nil) { - self.init( - platform: runtime.platform, - betaNumber: runtime.betaNumber, - version: runtime.simulatorVersion.version, - build: runtime.simulatorVersion.buildUpdate, - kind: kind, - architectures: runtime.architectures - ) - } - - init(_ runtime: InstalledRuntime, betaNumber: Int?) { - self.init( - platform: runtime.platformIdentifier.asPlatformOS, - betaNumber: betaNumber, - version: runtime.version, - build: runtime.build, - kind: runtime.kind, - architectures: runtime.supportedArchitectures - ) - } -} - -private extension Array { - mutating func removeAll(where predicate: (Element) -> Bool) -> [Element] { - guard !isEmpty else { return [] } - var removed: [Element] = [] - self = filter { current in - let satisfy = predicate(current) - if satisfy { - removed.append(current) - } - return !satisfy - } - return removed - } - -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListStore.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListStore.swift deleted file mode 100644 index fc5efcea..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeListStore.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation - -public struct RuntimeListStore: Sendable { - public typealias FetchDownloadableRuntimes = @Sendable () async throws -> DownloadableRuntimesResponse - - public struct UpdateResult: Sendable { - public let runtimes: [DownloadableRuntime] - public let sdkToSeedMappings: [SDKToSeedMapping] - } - - public private(set) var downloadableRuntimes: [DownloadableRuntime] - - private var cache: DownloadableRuntimeCache - private var fetchDownloadableRuntimes: FetchDownloadableRuntimes - - public init( - downloadableRuntimes: [DownloadableRuntime] = [], - cache: DownloadableRuntimeCache, - fetchDownloadableRuntimes: @escaping FetchDownloadableRuntimes - ) { - self.downloadableRuntimes = downloadableRuntimes - self.cache = cache - self.fetchDownloadableRuntimes = fetchDownloadableRuntimes - } - - public init( - downloadableRuntimes: [DownloadableRuntime] = [], - cache: DownloadableRuntimeCache, - service: RuntimeService - ) { - self.init( - downloadableRuntimes: downloadableRuntimes, - cache: cache, - fetchDownloadableRuntimes: { - try await service.downloadableRuntimes() - } - ) - } - - public mutating func loadCachedDownloadableRuntimes() throws { - guard let runtimes = try cache.load() else { return } - - downloadableRuntimes = runtimes - } - - @discardableResult - public mutating func updateDownloadableRuntimes() async throws -> [DownloadableRuntime] { - try await updateDownloadableRuntimeList().runtimes - } - - @discardableResult - public mutating func updateDownloadableRuntimeList() async throws -> UpdateResult { - let response = try await fetchDownloadableRuntimes() - let runtimes = response.downloadablesWithSDKBuildUpdates() - - downloadableRuntimes = runtimes - try? cache.save(runtimes) - return UpdateResult( - runtimes: runtimes, - sdkToSeedMappings: response.sdkToSeedMappings - ) - } - - public func saveDownloadableRuntimes(_ runtimes: [DownloadableRuntime]) throws { - try cache.save(runtimes) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimePackageInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimePackageInstallService.swift deleted file mode 100644 index 1cf0453e..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimePackageInstallService.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct RuntimePackageInstallService: Sendable { - public typealias ProcessOperation = @Sendable (URL, URL) async throws -> ProcessOutput - public typealias InstallPackage = @Sendable (URL, String) async throws -> ProcessOutput - - private let mountDMG: @Sendable (URL) async throws -> URL - private let unmountDMG: @Sendable (URL) async throws -> Void - private let packagePath: @Sendable (URL) throws -> Path - private let prepareDirectory: @Sendable (Path) throws -> Void - private let expandPkg: ProcessOperation - private let createPkg: ProcessOperation - private let installPkg: InstallPackage - private let contentsAtPath: @Sendable (String) -> Data? - private let writeData: @Sendable (Data, URL) throws -> Void - private let removeItem: @Sendable (URL) throws -> Void - - public init( - mountDMG: @escaping @Sendable (URL) async throws -> URL, - unmountDMG: @escaping @Sendable (URL) async throws -> Void, - packagePath: @escaping @Sendable (URL) throws -> Path = { mountedURL in - guard let mountedPath = Path(url: mountedURL), let packagePath = mountedPath.ls().first else { - throw XcodesKitError("Could not find runtime package in mounted disk image.") - } - return packagePath - }, - prepareDirectory: @escaping @Sendable (Path) throws -> Void = { path in - try path.mkdir().setCurrentUserAsOwner() - }, - expandPkg: @escaping ProcessOperation, - createPkg: @escaping ProcessOperation, - installPkg: @escaping InstallPackage, - contentsAtPath: @escaping @Sendable (String) -> Data?, - writeData: @escaping @Sendable (Data, URL) throws -> Void, - removeItem: @escaping @Sendable (URL) throws -> Void - ) { - self.mountDMG = mountDMG - self.unmountDMG = unmountDMG - self.packagePath = packagePath - self.prepareDirectory = prepareDirectory - self.expandPkg = expandPkg - self.createPkg = createPkg - self.installPkg = installPkg - self.contentsAtPath = contentsAtPath - self.writeData = writeData - self.removeItem = removeItem - } - - public func installPackageRuntime( - from diskImageURL: URL, - runtime: DownloadableRuntime, - cachesDirectory: Path - ) async throws { - let mountedURL = try await mountDMG(diskImageURL) - var didUnmount = false - - do { - let mountedPackagePath = try packagePath(mountedURL) - - try prepareDirectory(cachesDirectory) - - let expandedPkgPath = cachesDirectory/runtime.identifier - try? removeItem(expandedPkgPath.url) - _ = try await expandPkg(mountedPackagePath.url, expandedPkgPath.url) - - try await unmountDMG(mountedURL) - didUnmount = true - - let packageInfoPath = expandedPkgPath/"PackageInfo" - guard let packageInfoData = contentsAtPath(packageInfoPath.string), - var packageInfoContents = String(data: packageInfoData, encoding: .utf8) else { - throw XcodesKitError("Could not read PackageInfo for \(runtime.visibleIdentifier).") - } - - let runtimeDestination = runtimeDestinationPath(for: runtime) - packageInfoContents = packageInfoContents.replacingOccurrences( - of: " Path { - let runtimeFileName = "\(runtime.visibleIdentifier).simruntime" - return Path("/Library/Developer/CoreSimulator/Profiles/Runtimes/\(runtimeFileName)")! - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift deleted file mode 100644 index 02711574..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation -@preconcurrency import Path - -public extension URL { - static let downloadableRuntimes = URL(string: "https://devimages-cdn.apple.com/downloads/xcode/simulators/index2.dvtdownloadableindex")! -} - -public struct RuntimeService: Sendable { - public typealias LoadData = @Sendable (URLRequest) async throws -> (Data, URLResponse) - public typealias ContentsAtPath = @Sendable (String) -> Data? - public typealias LoadShellOutput = @Sendable () async throws -> ProcessOutput - public typealias RuntimeURLShellOutput = @Sendable (URL) async throws -> ProcessOutput - public typealias ProcessURLShellOutput = @Sendable (URL, URL) async throws -> ProcessOutput - public typealias InstallPackageOutput = @Sendable (URL, String) async throws -> ProcessOutput - public typealias DeleteRuntimeOutput = @Sendable (String) async throws -> ProcessOutput - - private var loadData: LoadData - private var contentsAtPath: ContentsAtPath - private var installedRuntimesOutput: LoadShellOutput - private var installRuntimeImageOutput: RuntimeURLShellOutput - private var mountDMGOutput: RuntimeURLShellOutput - private var unmountDMGOutput: RuntimeURLShellOutput - private var expandPkgOutput: ProcessURLShellOutput - private var createPkgOutput: ProcessURLShellOutput - private var installPkgOutput: InstallPackageOutput - private var deleteRuntimeOutput: DeleteRuntimeOutput - - public enum Error: LocalizedError, Equatable, Sendable { - case unavailableRuntime(String) - case failedMountingDMG - } - - public init(urlSession: URLSession = URLSession(configuration: .ephemeral)) { - let shell = XcodesShell() - self.init( - loadData: { try await urlSession.data(for: $0) }, - contentsAtPath: { path in FileManager.default.contents(atPath: path) }, - installedRuntimesOutput: { try await shell.installedRuntimes() }, - installRuntimeImageOutput: { url in try await shell.installRuntimeImage(url) }, - mountDMGOutput: { url in try await shell.mountDmg(url) }, - unmountDMGOutput: { url in try await shell.unmountDmg(url) }, - expandPkgOutput: { packageURL, destinationURL in try await shell.expandPkg(packageURL, destinationURL) }, - createPkgOutput: { packageURL, destinationURL in try await shell.createPkg(packageURL, destinationURL) }, - installPkgOutput: { packageURL, target in try await shell.installPkg(packageURL, target) }, - deleteRuntimeOutput: { identifier in try await shell.deleteRuntime(identifier) } - ) - } - - public init( - loadData: @escaping LoadData, - contentsAtPath: @escaping ContentsAtPath = { path in FileManager.default.contents(atPath: path) }, - installedRuntimesOutput: @escaping LoadShellOutput, - installRuntimeImageOutput: @escaping RuntimeURLShellOutput, - mountDMGOutput: @escaping RuntimeURLShellOutput, - unmountDMGOutput: @escaping RuntimeURLShellOutput, - expandPkgOutput: @escaping ProcessURLShellOutput = { packageURL, destinationURL in try await XcodesShell().expandPkg(packageURL, destinationURL) }, - createPkgOutput: @escaping ProcessURLShellOutput = { packageURL, destinationURL in try await XcodesShell().createPkg(packageURL, destinationURL) }, - installPkgOutput: @escaping InstallPackageOutput = { packageURL, target in try await XcodesShell().installPkg(packageURL, target) }, - deleteRuntimeOutput: @escaping DeleteRuntimeOutput = { identifier in try await XcodesShell().deleteRuntime(identifier) } - ) { - self.loadData = loadData - self.contentsAtPath = contentsAtPath - self.installedRuntimesOutput = installedRuntimesOutput - self.installRuntimeImageOutput = installRuntimeImageOutput - self.mountDMGOutput = mountDMGOutput - self.unmountDMGOutput = unmountDMGOutput - self.expandPkgOutput = expandPkgOutput - self.createPkgOutput = createPkgOutput - self.installPkgOutput = installPkgOutput - self.deleteRuntimeOutput = deleteRuntimeOutput - } - - public func downloadableRuntimes() async throws -> DownloadableRuntimesResponse { - let urlRequest = URLRequest(url: .downloadableRuntimes) - - // Apple gives a plist for download - let (data, _) = try await loadData(urlRequest) - return try PropertyListDecoder().decode(DownloadableRuntimesResponse.self, from: data) - } - - public func installedRuntimes() async throws -> [InstalledRuntime] { - // This only uses the Selected Xcode, so we don't know what other SDK's have been installed in previous versions - let output = try await installedRuntimesOutput() - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let outputDictionary = try decoder.decode([String: InstalledRuntime].self, from: output.out.data(using: .utf8)!) - - return outputDictionary.values.sorted { first, second in - return first.identifier.uuidString.compare(second.identifier.uuidString, options: .numeric) == .orderedAscending - } - } - - /// Loops through `/Library/Developer/CoreSimulator/images/images.plist` which contains a list of downloaded Simuator Runtimes - /// This is different then using `simctl` (`installedRuntimes()`) which only returns the installed runtimes for the selected xcode version. - public func localInstalledRuntimes() async throws -> [CoreSimulatorImage] { - guard let path = Path("/Library/Developer/CoreSimulator/images/images.plist") else { throw XcodesKitError("Could not find images.plist for CoreSimulators") } - guard let infoPlistData = contentsAtPath(path.string) else { throw XcodesKitError("Could not get data from \(path.string)") } - - do { - let infoPlist: CoreSimulatorPlist = try PropertyListDecoder().decode(CoreSimulatorPlist.self, from: infoPlistData) - return infoPlist.images - } catch { - throw error - } - } - - public func installRuntimeImage(dmgURL: URL) async throws { - _ = try await installRuntimeImageOutput(dmgURL) - } - - public func mountDMG(dmgUrl: URL) async throws -> URL { - let resultPlist = try await mountDMGOutput(dmgUrl) - - let dict = try? (PropertyListSerialization.propertyList(from: resultPlist.out.data(using: .utf8)!, format: nil) as? NSDictionary) - let systemEntities = dict?["system-entities"] as? NSArray - guard let path = systemEntities?.compactMap ({ ($0 as? NSDictionary)?["mount-point"] as? String }).first else { - throw Error.failedMountingDMG - } - return URL(fileURLWithPath: path) - } - - public func unmountDMG(mountedURL: URL) async throws { - _ = try await unmountDMGOutput(mountedURL) - } - - public func expand(pkgPath: Path, expandedPkgPath: Path) async throws { - _ = try await expandPkgOutput(pkgPath.url, expandedPkgPath.url) - } - - public func createPkg(pkgPath: Path, expandedPkgPath: Path) async throws { - _ = try await createPkgOutput(pkgPath.url, expandedPkgPath.url) - } - - public func installPkg(pkgPath: Path, expandedPkgPath: Path) async throws { - _ = try await installPkgOutput(pkgPath.url, expandedPkgPath.url.absoluteString) - } - - public func deleteRuntime(identifier: String) async throws { - do { - _ = try await deleteRuntimeOutput(identifier) - } catch { - if let executionError = error as? ProcessExecutionError { - throw XcodesKitError(executionError.standardError) - } - throw error - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeXcodebuildInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeXcodebuildInstallService.swift deleted file mode 100644 index 6ec949de..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeXcodebuildInstallService.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -public struct RuntimeXcodebuildInstallService: Sendable { - public typealias Download = @Sendable (String, String, String?) -> AsyncThrowingStream - public typealias ProgressChanged = @Sendable (Progress) -> Void - - private let download: Download - - public init( - download: @escaping Download = { platform, buildVersion, architecture in - XcodebuildRuntimeDownloadService().download( - platform: platform, - buildVersion: buildVersion, - architecture: architecture - ) - } - ) { - self.download = download - } - - public func downloadAndInstall( - runtime: DownloadableRuntime, - architecture: String? = nil, - progressChanged: ProgressChanged - ) async throws { - let stream = download( - runtime.platform.shortName, - runtime.simulatorVersion.buildUpdate, - architecture - ) - - for try await progress in stream { - try Task.checkCancellation() - progressChanged(progress) - } - try Task.checkCancellation() - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/URLSession+DownloadTask.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/URLSession+DownloadTask.swift deleted file mode 100644 index df9dfb53..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/URLSession+DownloadTask.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Foundation -import os - -extension URLSession { - /** - - Parameter request: The URL request to download. - - Parameter saveLocation: A URL to move the downloaded file to after it completes. Apple deletes the temporary file immediately after the underlying completion handler returns. - - Parameter resumeData: Data describing the state of a previously cancelled or failed download task. See the Discussion section for `downloadTask(withResumeData:completionHandler:)` https://developer.apple.com/documentation/foundation/urlsession/1411598-downloadtask# - - - Returns: Tuple containing a Progress object for the task and a task containing the save location and response. - - - Note: We do not create the destination directory for you, because we move the file with FileManager.moveItem which changes its behavior depending on the directory status of the URL you provide. So create your own directory first. - */ - public func downloadTask( - with request: URLRequest, - to saveLocation: URL, - resumingWith resumeData: Data? - ) -> (progress: Progress, task: Task<(saveLocation: URL, response: URLResponse), Error>) { - let runner = URLSessionDownloadTaskRunner( - session: self, - request: request, - saveLocation: saveLocation, - resumeData: resumeData - ) - let task = Task { - try await runner.resume() - } - return (runner.progress, task) - } - - public func downloadTaskAsync( - with url: URL, - to saveLocation: URL, - resumingWith resumeData: Data? - ) -> (progress: Progress, task: Task<(saveLocation: URL, response: URLResponse), Error>) { - downloadTask(with: URLRequest(url: url), to: saveLocation, resumingWith: resumeData) - } -} - -private final class URLSessionDownloadTaskRunner: Sendable { - let progress: Progress - - private let task: URLSessionDownloadTask - private let saveLocation: URL - private let request = OneShotContinuation<(temporaryURL: URL, response: URLResponse)>() - - init(session: URLSession, request: URLRequest, saveLocation: URL, resumeData: Data?) { - self.saveLocation = saveLocation - - let callbackBox = URLSessionDownloadTaskCallbackBox() - let createdTask: URLSessionDownloadTask - if let resumeData { - createdTask = session.downloadTask(withResumeData: resumeData) { temporaryURL, response, error in - callbackBox.complete(temporaryURL: temporaryURL, response: response, error: error) - } - } else { - createdTask = session.downloadTask(with: request) { temporaryURL, response, error in - callbackBox.complete(temporaryURL: temporaryURL, response: response, error: error) - } - } - - self.task = createdTask - self.progress = createdTask.progress - callbackBox.handler = { [weak self] temporaryURL, response, error in - self?.complete(temporaryURL: temporaryURL, response: response, error: error) - } - } - - func resume() async throws -> (saveLocation: URL, response: URLResponse) { - let output = try await request.value(onCancel: { [task] in - task.cancel() - }) { - task.resume() - } - try FileManager.default.moveItem(at: output.temporaryURL, to: saveLocation) - return (saveLocation, output.response) - } - - private func complete(temporaryURL: URL?, response: URLResponse?, error: Error?) { - let result: Result<(temporaryURL: URL, response: URLResponse), Error> - if let error { - result = .failure(error) - } else if let temporaryURL, let response { - result = .success((temporaryURL, response)) - } else { - result = .failure(URLError(.badServerResponse)) - } - - finish(result) - } - - private func finish(_ result: Result<(temporaryURL: URL, response: URLResponse), Error>) { - request.resume(with: result) - } -} - -private final class URLSessionDownloadTaskCallbackBox: Sendable { - typealias Handler = @Sendable (URL?, URLResponse?, Error?) -> Void - - private let storedHandler = OSAllocatedUnfairLock(initialState: nil) - - var handler: Handler? { - get { - storedHandler.withLock { $0 } - } - set { - storedHandler.withLock { $0 = newValue } - } - } - - func complete(temporaryURL: URL?, response: URLResponse?, error: Error?) { - handler?(temporaryURL, response, error) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveInstallService.swift deleted file mode 100644 index 58b2e320..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveInstallService.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -@preconcurrency import Path - -public enum XcodeArchiveInstallError: Error, Equatable, Sendable { - case failedToMoveXcodeToDestination(Path) - case unsupportedFileFormat(extension: String) -} - -public enum XcodeArchiveInstallStep: Equatable, Sendable { - case unarchive(XcodeUnarchiveStep) - case cleaningArchive(archiveName: String) - case checkingSecurity -} - -public struct XcodeArchiveInstallService: Sendable { - public typealias CleanArchive = @Sendable (URL) throws -> Void - public typealias StepChanged = @Sendable (XcodeArchiveInstallStep) async -> Void - public typealias MakeInstalledXcode = @Sendable (Path) -> InstalledXcode? - - private let destinationDirectory: Path - private let unarchiveService: XcodeUnarchiveService - private let validationService: XcodeValidationService - private let fileExists: @Sendable (String) -> Bool - private let makeInstalledXcode: MakeInstalledXcode - - public init( - destinationDirectory: Path, - unarchiveService: XcodeUnarchiveService, - validationService: XcodeValidationService, - fileExists: @escaping @Sendable (String) -> Bool, - makeInstalledXcode: @escaping MakeInstalledXcode - ) { - self.destinationDirectory = destinationDirectory - self.unarchiveService = unarchiveService - self.validationService = validationService - self.fileExists = fileExists - self.makeInstalledXcode = makeInstalledXcode - } - - public func installArchivedXcode( - _ xcode: AvailableXcode, - at archiveURL: URL, - cleanArchive: @escaping CleanArchive, - stepChanged: @escaping StepChanged = { _ in } - ) async throws -> InstalledXcode { - guard archiveURL.pathExtension == "xip" else { - throw XcodeArchiveInstallError.unsupportedFileFormat(extension: archiveURL.pathExtension) - } - - let destinationURL = destinationDirectory - .join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app") - .url - - let xcodeURL = try await unarchiveService.unarchiveAndMoveXIP(at: archiveURL, to: destinationURL) { step in - await stepChanged(.unarchive(step)) - } - - guard - let path = Path(url: xcodeURL), - fileExists(path.string), - let installedXcode = makeInstalledXcode(path) - else { - throw XcodeArchiveInstallError.failedToMoveXcodeToDestination(destinationDirectory) - } - - await stepChanged(.cleaningArchive(archiveName: archiveURL.lastPathComponent)) - try cleanArchive(archiveURL) - - await stepChanged(.checkingSecurity) - async let securityAssessment: Void = validationService.verifySecurityAssessment(of: installedXcode) - async let signingCertificate: Void = validationService.verifySigningCertificate(of: installedXcode.path.url) - _ = try await (securityAssessment, signingCertificate) - - return installedXcode - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveService.swift deleted file mode 100644 index 9df87348..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeArchiveService.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation -@preconcurrency import Path -@preconcurrency import Version - -public struct XcodeArchive: Sendable { - public let version: Version - public let downloadURL: URL - public let filename: String - - public init(version: Version, downloadURL: URL, filename: String) { - self.version = version - self.downloadURL = downloadURL - self.filename = filename - } -} - -public enum XcodeArchiveDownloader: String, CaseIterable, Identifiable, CustomStringConvertible, Sendable { - case urlSession - case aria2 - - public var id: Self { self } - - public var description: String { - switch self { - case .urlSession: return "URLSession" - case .aria2: return "aria2" - } - } -} - -public struct XcodeArchiveService: Sendable { - public typealias Download = @Sendable (XcodeArchive, Path, XcodeArchiveDownloader, @escaping @Sendable (Progress) -> Void) async throws -> URL - - private let applicationSupportPath: Path - private let fileExists: @Sendable (Path) -> Bool - private let download: Download - - public init( - applicationSupportPath: Path, - fileExists: @escaping @Sendable (Path) -> Bool, - download: @escaping Download - ) { - self.applicationSupportPath = applicationSupportPath - self.fileExists = fileExists - self.download = download - } - - public func archiveURL( - for archive: XcodeArchive, - downloader: XcodeArchiveDownloader, - progressChanged: @escaping @Sendable (Progress) -> Void - ) async throws -> URL { - if let existingArchiveURL = existingArchiveURL(for: archive, downloader: downloader) { - return existingArchiveURL - } - - return try await download(archive, expectedArchivePath(for: archive), downloader, progressChanged) - } - - public func existingArchiveURL( - for archive: XcodeArchive, - downloader: XcodeArchiveDownloader - ) -> URL? { - let destination = expectedArchivePath(for: archive) - let metadataPath = aria2MetadataPath(for: destination) - let aria2DownloadIsIncomplete = downloader == .aria2 && fileExists(metadataPath) - - if fileExists(destination), aria2DownloadIsIncomplete == false { - return destination.url - } - - return nil - } - - public func expectedArchivePath(for archive: XcodeArchive) -> Path { - applicationSupportPath/"Xcode-\(archive.version).\(archive.filename.suffix(fromLast: "."))" - } - - public func aria2MetadataPath(for archivePath: Path) -> Path { - archivePath.parent/(archivePath.basename() + ".aria2") - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeAutoInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeAutoInstallService.swift deleted file mode 100644 index ea466429..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeAutoInstallService.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -public enum XcodeAutoInstallDecision: Equatable, Sendable { - case disabled - case alreadyInstalled - case installNewestBeta(XcodeID) - case installNewestVersion(XcodeID) - case noNewVersion -} - -public struct XcodeAutoInstallService: Sendable { - public init() {} - - public func decision( - autoInstallationType: AutoInstallationType, - xcodes: [XcodeListItem] - ) -> XcodeAutoInstallDecision { - guard autoInstallationType != .none else { - return .disabled - } - - guard let newestXcode = xcodes.first, newestXcode.installState == .notInstalled else { - return .alreadyInstalled - } - - switch autoInstallationType { - case .none: - return .disabled - case .newestBeta: - return .installNewestBeta(newestXcode.id) - case .newestVersion: - if newestXcode.version.isNotPrerelease { - return .installNewestVersion(newestXcode.id) - } - return .noNewVersion - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeCompatibilityService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeCompatibilityService.swift deleted file mode 100644 index b075848b..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeCompatibilityService.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation - -public enum XcodeCompatibilityStatus: Equatable, Sendable { - case supported - case unsupported(requiredMacOSVersion: String, currentMacOSVersion: String) - - public var isSupported: Bool { - switch self { - case .supported: - true - case .unsupported: - false - } - } - - public var isUnsupported: Bool { - !isSupported - } -} - -public struct XcodeCompatibilityService: Sendable { - public init() {} - - public func status( - for xcode: AvailableXcode, - currentOSVersion: OperatingSystemVersion - ) -> XcodeCompatibilityStatus { - status( - requiredMacOSVersion: xcode.requiredMacOSVersion, - currentOSVersion: currentOSVersion - ) - } - - public func status( - requiredMacOSVersion: String?, - currentOSVersion: OperatingSystemVersion - ) -> XcodeCompatibilityStatus { - guard let requiredMacOSVersion else { - return .supported - } - - let requiredVersion = operatingSystemVersion(from: requiredMacOSVersion) - if currentOSVersion >= requiredVersion { - return .supported - } - - return .unsupported( - requiredMacOSVersion: requiredMacOSVersion, - currentMacOSVersion: currentOSVersion.versionString() - ) - } - - public func isSupported( - requiredMacOSVersion: String?, - currentOSVersion: OperatingSystemVersion - ) -> Bool { - status( - requiredMacOSVersion: requiredMacOSVersion, - currentOSVersion: currentOSVersion - ).isSupported - } - - public func isUnsupported( - requiredMacOSVersion: String?, - currentOSVersion: OperatingSystemVersion - ) -> Bool { - !isSupported( - requiredMacOSVersion: requiredMacOSVersion, - currentOSVersion: currentOSVersion - ) - } - - public func operatingSystemVersion(from versionString: String) -> OperatingSystemVersion { - let components = versionString - .components(separatedBy: ".") - .compactMap { Int($0) } - - return OperatingSystemVersion( - majorVersion: components.count > 0 ? components[0] : 0, - minorVersion: components.count > 1 ? components[1] : 0, - patchVersion: components.count > 2 ? components[2] : 0 - ) - } -} - -private extension OperatingSystemVersion { - static func >= (lhs: OperatingSystemVersion, rhs: OperatingSystemVersion) -> Bool { - if lhs.majorVersion != rhs.majorVersion { - return lhs.majorVersion > rhs.majorVersion - } - - if lhs.minorVersion != rhs.minorVersion { - return lhs.minorVersion > rhs.minorVersion - } - - return lhs.patchVersion >= rhs.patchVersion - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallResolutionService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallResolutionService.swift deleted file mode 100644 index 22a264cb..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallResolutionService.swift +++ /dev/null @@ -1,123 +0,0 @@ -import Foundation -@preconcurrency import Path -@preconcurrency import Version - -public enum XcodeInstallRequest: Equatable, Sendable { - case latest - case latestPrerelease - case availableXcode(AvailableXcode) - case version(String) - case path(versionString: String, path: Path) -} - -public enum XcodeInstallResolution: Equatable, Sendable { - case download(version: Version, resolvedXcode: AvailableXcode?) - case localArchive(AvailableXcode, URL) -} - -public enum XcodeInstallResolutionError: LocalizedError, Equatable, Sendable { - case invalidVersion(String) - case noReleaseVersionAvailable - case noPrereleaseVersionAvailable - case versionAlreadyInstalled(InstalledXcode) - - public var errorDescription: String? { - switch self { - case let .invalidVersion(version): - return "\(version) is not a valid version number." - case .noReleaseVersionAvailable: - return "No release versions available." - case .noPrereleaseVersionAvailable: - return "No prerelease versions available." - case let .versionAlreadyInstalled(installedXcode): - return "\(installedXcode.version.appleDescription) is already installed at \(installedXcode.path)" - } - } -} - -public struct XcodeInstallResolutionService: Sendable { - private let versionFile: XcodeVersionFileService - - public init(versionFile: XcodeVersionFileService = XcodeVersionFileService()) { - self.versionFile = versionFile - } - - public func resolve( - _ request: XcodeInstallRequest, - availableXcodes: [AvailableXcode], - installedXcodes: [InstalledXcode], - willInstall: Bool, - versionFileDirectory: Path = Path(.cwd) - ) throws -> XcodeInstallResolution { - switch request { - case .latest: - guard let xcode = latestRelease(in: availableXcodes) else { - throw XcodeInstallResolutionError.noReleaseVersionAvailable - } - try ensureNotInstalled(xcode.version, installedXcodes: installedXcodes, willInstall: willInstall) - return .download(version: xcode.version, resolvedXcode: xcode) - - case .latestPrerelease: - guard let xcode = latestPrerelease(in: availableXcodes) else { - throw XcodeInstallResolutionError.noPrereleaseVersionAvailable - } - try ensureNotInstalled(xcode.version, installedXcodes: installedXcodes, willInstall: willInstall) - return .download(version: xcode.version, resolvedXcode: xcode) - - case let .availableXcode(xcode): - try ensureNotInstalled(xcode.version, installedXcodes: installedXcodes, willInstall: willInstall) - return .download(version: xcode.version, resolvedXcode: xcode) - - case let .version(versionString): - let version = try parsedVersion(versionString, versionFileDirectory: versionFileDirectory) - try ensureNotInstalled(version, installedXcodes: installedXcodes, willInstall: willInstall) - return .download(version: version, resolvedXcode: nil) - - case let .path(versionString, path): - let version = try parsedVersion(versionString, versionFileDirectory: versionFileDirectory) - let xcode = AvailableXcode( - version: version, - url: path.url, - filename: String(path.string.suffix(fromLast: "/")), - releaseDate: nil - ) - return .localArchive(xcode, path.url) - } - } - - public func latestRelease(in availableXcodes: [AvailableXcode]) -> AvailableXcode? { - availableXcodes - .filter(\.version.isNotPrerelease) - .sorted(\.version) - .last - } - - public func latestPrerelease(in availableXcodes: [AvailableXcode]) -> AvailableXcode? { - availableXcodes - .filter { $0.version.isPrerelease } - .filter { $0.releaseDate != nil } - .sorted { $0.releaseDate! < $1.releaseDate! } - .last - } - - private func parsedVersion( - _ versionString: String, - versionFileDirectory: Path - ) throws -> Version { - if let version = Version(xcodeVersion: versionString) ?? versionFile.version(inDirectory: versionFileDirectory) { - return version - } - throw XcodeInstallResolutionError.invalidVersion(versionString) - } - - private func ensureNotInstalled( - _ version: Version, - installedXcodes: [InstalledXcode], - willInstall: Bool - ) throws { - guard willInstall else { return } - if let installedXcode = installedXcodes.first(where: { $0.version.isEquivalent(to: version) }) { - throw XcodeInstallResolutionError.versionAlreadyInstalled(installedXcode) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallRetryService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallRetryService.swift deleted file mode 100644 index 444b4d58..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeInstallRetryService.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -public struct XcodeInstallRetryService: Sendable { - public typealias Attempt = @Sendable (Int) async throws -> InstalledXcode - public typealias AttemptFailed = @Sendable (Error) async -> Void - public typealias RetryDamagedArchive = @Sendable (Error, URL) async -> Void - - private let damagedArchiveURL: @Sendable (Error) -> URL? - private let removeDamagedArchive: @Sendable (URL) throws -> Void - - public init( - damagedArchiveURL: @escaping @Sendable (Error) -> URL?, - removeDamagedArchive: @escaping @Sendable (URL) throws -> Void - ) { - self.damagedArchiveURL = damagedArchiveURL - self.removeDamagedArchive = removeDamagedArchive - } - - public func install( - attemptNumber: Int = 0, - shouldRetryAfterDamagedArchive: Bool = true, - attempt: Attempt, - onAttemptFailed: AttemptFailed = { _ in }, - onRetryDamagedArchive: RetryDamagedArchive = { _, _ in } - ) async throws -> InstalledXcode { - do { - return try await attempt(attemptNumber) - } catch { - await onAttemptFailed(error) - - guard - let damagedArchiveURL = damagedArchiveURL(error), - attemptNumber < 1, - shouldRetryAfterDamagedArchive - else { - throw error - } - - await onRetryDamagedArchive(error, damagedArchiveURL) - try removeDamagedArchive(damagedArchiveURL) - return try await install( - attemptNumber: attemptNumber + 1, - shouldRetryAfterDamagedArchive: shouldRetryAfterDamagedArchive, - attempt: attempt, - onAttemptFailed: onAttemptFailed, - onRetryDamagedArchive: onRetryDamagedArchive - ) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListComposer.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListComposer.swift deleted file mode 100644 index 37f2b7d3..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListComposer.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -public struct XcodeListComposer: Sendable { - public init() {} - - public func compose( - availableXcodes: [AvailableXcode], - installedXcodes: [InstalledXcode], - selectedXcodePath: String?, - existingXcodes: [XcodeListItem], - dataSource: XcodeListDataSource - ) -> [XcodeListItem] { - var adjustedAvailableXcodes = availableXcodes - - if dataSource == .apple { - adjustedAvailableXcodes = Self.adjustingAvailableXcodesForInstalledBuildMetadata( - availableXcodes, - installedXcodes: installedXcodes - ) - } - - var newAllXcodes = XcodeListService.filteringPrereleasesWithDuplicateBuildMetadata(adjustedAvailableXcodes) - .map { availableXcode -> XcodeListItem in - let installedXcode = installedXcodes.first { installedXcode in - availableXcode.version.isEquivalent(to: installedXcode.version) - } - let identicalBuilds = XcodeListService.identicalBuildIDs(for: availableXcode, in: availableXcodes) - let existingXcodeInstallState = existingXcodes - .first { $0.id == availableXcode.xcodeID && $0.installState.installing }? - .installState - let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled - - return XcodeListItem( - version: availableXcode.version, - identicalBuilds: identicalBuilds, - installState: existingXcodeInstallState ?? defaultXcodeInstallState, - selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true, - requiredMacOSVersion: availableXcode.requiredMacOSVersion, - releaseNotesURL: availableXcode.releaseNotesURL, - releaseDate: availableXcode.releaseDate, - sdks: availableXcode.sdks, - compilers: availableXcode.compilers, - downloadFileSize: availableXcode.fileSize, - architectures: availableXcode.architectures - ) - } - - for installedXcode in installedXcodes { - if !newAllXcodes.contains(where: { xcode in xcode.version.isEquivalent(to: installedXcode.version) }) { - newAllXcodes.append( - XcodeListItem( - version: installedXcode.version, - installState: .installed(installedXcode.path), - selected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true - ) - ) - } - } - - return newAllXcodes.sorted { $0.version > $1.version } - } - - public static func adjustingAvailableXcodesForInstalledBuildMetadata( - _ availableXcodes: [AvailableXcode], - installedXcodes: [InstalledXcode] - ) -> [AvailableXcode] { - var adjustedAvailableXcodes = availableXcodes - - for installedXcode in installedXcodes { - if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) { - adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID - } else if let index = adjustedAvailableXcodes.firstIndex(where: { availableXcode in - availableXcode.version.isEquivalent(to: installedXcode.version) && - availableXcode.version.buildMetadataIdentifiers.isEmpty - }) { - adjustedAvailableXcodes[index].xcodeID = installedXcode.xcodeID - } - } - - return adjustedAvailableXcodes - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift deleted file mode 100644 index 290b850c..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListPresentationService.swift +++ /dev/null @@ -1,144 +0,0 @@ -import Foundation -@preconcurrency import Path -@preconcurrency import Version - -public struct XcodeListPresentationService: Sendable { - public struct AvailableRow: Equatable, Sendable { - public let version: Version - public let versionDescription: String - public let architectures: [Architecture]? - public let isInstalled: Bool - public let isSelected: Bool - } - - public struct InstalledRow: Equatable, Sendable { - public let version: Version - public let versionDescription: String - public let architectures: [Architecture]? - public let path: Path - public let isSelected: Bool - } - - public init() {} - - public func availableRows( - availableXcodes: [AvailableXcode], - installedXcodes: [InstalledXcode], - selectedXcodePath: String?, - dataSource: XcodeListDataSource, - architectures: [ArchitectureFilter] = [] - ) -> [AvailableRow] { - struct ReleasedVersion { - let version: Version - let releaseDate: Date? - let architectures: [Architecture]? - } - - let adjustedAvailableXcodes = (dataSource == .apple - ? XcodeListComposer.adjustingAvailableXcodesForInstalledBuildMetadata( - availableXcodes, - installedXcodes: installedXcodes - ) - : availableXcodes) - .matchingArchitectureFilters(architectures) - - let adjustedInstalledXcodes = architectures.isEmpty - ? installedXcodes - : installedXcodes.filter { architectures.matches($0.xcodeID.architectures) } - - var releasedVersions = adjustedAvailableXcodes.map { - ReleasedVersion(version: $0.version, releaseDate: $0.releaseDate, architectures: $0.architectures) - } - - for installedXcode in adjustedInstalledXcodes { - if !releasedVersions.contains(where: { $0.version.isEquivalent(to: installedXcode.version) }) { - releasedVersions.append(ReleasedVersion(version: installedXcode.version, releaseDate: nil, architectures: installedXcode.xcodeID.architectures)) - } else if let index = releasedVersions.firstIndex(where: { - $0.version.isEquivalent(to: installedXcode.version) && - $0.version.buildMetadataIdentifiers.isEmpty - }) { - releasedVersions[index] = ReleasedVersion( - version: installedXcode.version, - releaseDate: nil, - architectures: installedXcode.xcodeID.architectures ?? releasedVersions[index].architectures - ) - } - } - - let selectedInstalledXcode = Self.selectedInstalledXcode( - in: adjustedInstalledXcodes, - selectedXcodePath: selectedXcodePath - ) - - return releasedVersions - .sorted { first, second -> Bool in - if first.version.isPrerelease, - second.version.isPrerelease, - let firstDate = first.releaseDate, - let secondDate = second.releaseDate { - return firstDate < secondDate - } - return first.version < second.version - } - .map { releasedVersion in - let installedXcode = adjustedInstalledXcodes.first { - releasedVersion.version.isEquivalent(to: $0.version) - } - return AvailableRow( - version: releasedVersion.version, - versionDescription: releasedVersion.version.appleDescriptionWithBuildIdentifier + (releasedVersion.architectures?.listOutputSuffix ?? ""), - architectures: releasedVersion.architectures, - isInstalled: installedXcode != nil, - isSelected: installedXcode?.path == selectedInstalledXcode?.path - ) - } - } - - public func installedRows( - installedXcodes: [InstalledXcode], - selectedXcodePath: String? - ) -> [InstalledRow] { - installedXcodes - .sorted { $0.version < $1.version } - .map { installedXcode in - InstalledRow( - version: installedXcode.version, - versionDescription: installedXcode.version.appleDescriptionWithBuildIdentifier + (installedXcode.xcodeID.architectures?.listOutputSuffix ?? ""), - architectures: installedXcode.xcodeID.architectures, - path: installedXcode.path, - isSelected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true - ) - } - } - - public func installedLines( - rows: [InstalledRow], - interactive: Bool, - selectedMarker: String = "(Selected)" - ) -> [String] { - let firstColumns = rows.map { row in - row.versionDescription + (row.isSelected ? " \(selectedMarker)" : "") - } - let maxWidthOfFirstColumn = (firstColumns.map(\.count).max() ?? 0) + 1 - - return rows.enumerated().map { index, row in - let firstColumn = firstColumns[index] - if interactive { - let spaceBetweenColumns = maxWidthOfFirstColumn - firstColumn.count - return firstColumn + - String(repeating: " ", count: max(spaceBetweenColumns, 0)) + - row.path.string - } else { - return "\(firstColumn)\t\(row.path.string)" - } - } - } - - public static func selectedInstalledXcode( - in installedXcodes: [InstalledXcode], - selectedXcodePath: String? - ) -> InstalledXcode? { - guard let selectedXcodePath else { return nil } - return installedXcodes.first { selectedXcodePath.hasPrefix($0.path.string) } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListService.swift deleted file mode 100644 index eaf8c6ce..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListService.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation -import SwiftSoup -import Version - -extension URL { - static let developerDownload = URL(string: "https://developer.apple.com/download")! - static let developerDownloads = URL(string: "https://developer.apple.com/services-account/QH65B2/downloadws/listDownloads.action")! - static let xcodeReleasesData = URL(string: "https://xcodereleases.com/data.json")! -} - -public extension URLRequest { - static var developerDownload: URLRequest { - URLRequest(url: .developerDownload) - } - - static var developerDownloads: URLRequest { - var request = URLRequest(url: .developerDownloads) - request.httpMethod = "POST" - return request - } - - static var xcodeReleasesData: URLRequest { - URLRequest(url: .xcodeReleasesData) - } -} - -public enum DataSource: String, CaseIterable, Identifiable, CustomStringConvertible, Sendable { - case apple - case xcodeReleases - - public var id: Self { self } - - public static var `default`: Self { .xcodeReleases } - - public var description: String { - switch self { - case .apple: - return "Apple" - case .xcodeReleases: - return "Xcode Releases" - } - } -} - -public typealias XcodeListDataSource = DataSource - -public struct XcodeListService: Sendable { - public typealias LoadData = @Sendable (URLRequest) async throws -> (Data, URLResponse) - - public enum Error: LocalizedError, Equatable, Sendable { - case invalidResult(String?) - - public var errorDescription: String? { - switch self { - case let .invalidResult(result): - return result ?? "Downloading error" - } - } - } - - private var loadData: LoadData - - public init(urlSession: URLSession = URLSession(configuration: .ephemeral)) { - self.loadData = { request in - try await urlSession.data(for: request) - } - } - - public init(loadData: @escaping LoadData) { - self.loadData = loadData - } - - public func availableXcodes(from dataSource: XcodeListDataSource) async throws -> [AvailableXcodeRelease] { - switch dataSource { - case .apple: - async let released = releasedXcodes() - async let prerelease = prereleaseXcodes() - let (releasedXcodes, prereleaseXcodes) = try await (released, prerelease) - - return releasedXcodes.filter { releasedXcode in - prereleaseXcodes.contains { $0.version.isEquivalent(to: releasedXcode.version) } == false - } + prereleaseXcodes - case .xcodeReleases: - return try await xcodeReleases() - } - } - - public func releasedXcodes() async throws -> [AvailableXcodeRelease] { - let downloads = try await developerDownloads() - let downloadList = try validate(downloads, missingDownloadsMessage: "Downloading error") - - let urlPrefix = URL(string: "https://download.developer.apple.com/")! - return downloadList - .filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil } - .compactMap { download -> AvailableXcodeRelease? in - guard - let xcodeFile = download.files.first(where: { $0.remotePath.hasSuffix("dmg") || $0.remotePath.hasSuffix("xip") }), - let version = Version(xcodeVersion: download.name) - else { return nil } - - let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath) - return AvailableXcodeRelease( - version: version, - url: url, - filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), - releaseDate: download.dateModified, - fileSize: xcodeFile.fileSize - ) - } - } - - public func validateDeveloperDownloads(missingDownloadsMessage: String = "Downloading error") async throws { - let downloads = try await developerDownloads() - _ = try validate(downloads, missingDownloadsMessage: missingDownloadsMessage) - } - - public func developerDownloads() async throws -> Downloads { - let (data, _) = try await loadData(.developerDownloads) - return try JSONDecoder.downloads.decode(Downloads.self, from: data) - } - - private func validate(_ downloads: Downloads, missingDownloadsMessage: String) throws -> [Download] { - if downloads.hasError { - throw Error.invalidResult(downloads.resultsString) - } - guard let downloadList = downloads.downloads else { - throw Error.invalidResult(missingDownloadsMessage) - } - return downloadList - } - - public func prereleaseXcodes() async throws -> [AvailableXcodeRelease] { - let (data, _) = try await loadData(.developerDownload) - return try Self.parsePrereleaseXcodes(from: data) - } - - public func xcodeReleases() async throws -> [AvailableXcodeRelease] { - let (data, _) = try await loadData(.xcodeReleasesData) - let releases = try JSONDecoder().decode([XcodeRelease].self, from: data) - - return releases.compactMap { release -> AvailableXcodeRelease? in - guard - let downloadURL = release.links?.download?.url, - let version = Version(xcReleasesXcode: release) - else { return nil } - - let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents( - year: release.date.year, - month: release.date.month, - day: release.date.day - )) - - return AvailableXcodeRelease( - version: version, - url: downloadURL, - filename: String(downloadURL.path.suffix(fromLast: "/")), - releaseDate: releaseDate, - requiredMacOSVersion: release.requires, - releaseNotesURL: release.links?.notes?.url, - sdks: release.sdks, - compilers: release.compilers, - architectures: release.architectures - ) - } - } - - public static func parsePrereleaseXcodes(from data: Data) throws -> [AvailableXcodeRelease] { - let body = String(data: data, encoding: .utf8)! - let document = try SwiftSoup.parse(body) - - guard - let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(), - let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""), - let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""), - let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion), - let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"), - let url = URL(string: "https://developer.apple.com" + path) - else { return [] } - - return [ - AvailableXcodeRelease( - version: version, - url: url, - filename: String(path.suffix(fromLast: "/")), - releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString) - ) - ] - } - - public static func filteringPrereleasesWithDuplicateBuildMetadata(_ xcodes: [AvailableXcode]) -> [AvailableXcode] { - xcodes.filter { availableXcode in - guard !availableXcode.version.buildMetadataIdentifiers.isEmpty else { return true } - - let availableXcodesWithIdenticalBuildIdentifiers = xcodes.filter { - $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers - } - - return availableXcodesWithIdenticalBuildIdentifiers.count == 1 || - availableXcodesWithIdenticalBuildIdentifiers.count > 1 && - (availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.architectures?.isEmpty == false) - } - } - - public static func identicalBuildIDs(for xcode: AvailableXcode, in xcodes: [AvailableXcode]) -> [XcodeID] { - let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = xcodes.filter { - $0.version.buildMetadataIdentifiers == xcode.version.buildMetadataIdentifiers && - !$0.version.prereleaseIdentifiers.isEmpty && - !$0.version.buildMetadataIdentifiers.isEmpty - } - - guard !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, - xcode.version.prereleaseIdentifiers.isEmpty - else { return [] } - - return [xcode.xcodeID] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.xcodeID) - } -} - -private extension JSONDecoder { - static var downloads: JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(.downloadsDateModified) - return decoder - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListStore.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListStore.swift deleted file mode 100644 index 1cdb3458..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeListStore.swift +++ /dev/null @@ -1,88 +0,0 @@ -import Foundation -import Version - -public struct XcodeListStore: Sendable { - public typealias FetchAvailableXcodes = @Sendable (XcodeListDataSource) async throws -> [AvailableXcodeRelease] - public typealias Now = @Sendable () -> Date - - public private(set) var availableXcodes: [AvailableXcode] - public private(set) var lastUpdated: Date? - - private var cache: AvailableXcodeCache - private var fetchAvailableXcodes: FetchAvailableXcodes - private var updatePolicy: XcodeUpdatePolicy - private var now: Now - - public init( - availableXcodes: [AvailableXcode] = [], - lastUpdated: Date? = nil, - cache: AvailableXcodeCache, - fetchAvailableXcodes: @escaping FetchAvailableXcodes, - updatePolicy: XcodeUpdatePolicy = XcodeUpdatePolicy(), - now: @escaping Now = { Date() } - ) { - self.availableXcodes = availableXcodes - self.lastUpdated = lastUpdated - self.cache = cache - self.fetchAvailableXcodes = fetchAvailableXcodes - self.updatePolicy = updatePolicy - self.now = now - } - - public init( - availableXcodes: [AvailableXcode] = [], - lastUpdated: Date? = nil, - cache: AvailableXcodeCache, - service: XcodeListService, - updatePolicy: XcodeUpdatePolicy = XcodeUpdatePolicy(), - now: @escaping Now = { Date() } - ) { - self.init( - availableXcodes: availableXcodes, - lastUpdated: lastUpdated, - cache: cache, - fetchAvailableXcodes: { dataSource in - try await service.availableXcodes(from: dataSource) - }, - updatePolicy: updatePolicy, - now: now - ) - } - - public var shouldUpdateBeforeListingVersions: Bool { - updatePolicy.shouldUpdate( - cachedXcodes: availableXcodes, - lastUpdated: lastUpdated - ) - } - - public func shouldUpdateBeforeDownloading(version: Version) -> Bool { - availableXcodes.first(withVersion: version) == nil - } - - public mutating func loadCachedAvailableXcodes() throws { - guard let xcodes = try cache.load() else { return } - - availableXcodes = xcodes - lastUpdated = cache.lastModified() - } - - @discardableResult - public mutating func updateAvailableXcodes(from dataSource: XcodeListDataSource) async throws -> [AvailableXcode] { - let releases = try await fetchAvailableXcodes(dataSource) - let xcodes = Self.postprocess(releases.map(AvailableXcode.init)) - - availableXcodes = xcodes - lastUpdated = now() - try? cache.save(xcodes) - return xcodes - } - - public func saveAvailableXcodes(_ xcodes: [AvailableXcode]) throws { - try cache.save(xcodes) - } - - public static func postprocess(_ xcodes: [AvailableXcode]) -> [AvailableXcode] { - XcodeListService.filteringPrereleasesWithDuplicateBuildMetadata(xcodes) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallPreparationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallPreparationService.swift deleted file mode 100644 index 9368cc8b..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallPreparationService.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -public struct XcodePostInstallPreparationService: Sendable { - public typealias EnableDeveloperTools = @Sendable () async throws -> Void - public typealias AddStaffToDevelopersGroup = @Sendable () async throws -> Void - public typealias AcceptLicense = @Sendable (InstalledXcode) async throws -> Void - - private let enableDeveloperTools: EnableDeveloperTools - private let addStaffToDevelopersGroup: AddStaffToDevelopersGroup - private let acceptLicense: AcceptLicense - - public init( - enableDeveloperTools: @escaping EnableDeveloperTools, - addStaffToDevelopersGroup: @escaping AddStaffToDevelopersGroup, - acceptLicense: @escaping AcceptLicense - ) { - self.enableDeveloperTools = enableDeveloperTools - self.addStaffToDevelopersGroup = addStaffToDevelopersGroup - self.acceptLicense = acceptLicense - } - - public func enableDeveloperMode() async throws { - try await enableDeveloperTools() - try await addStaffToDevelopersGroup() - } - - public func approveLicense(for xcode: InstalledXcode) async throws { - try await acceptLicense(xcode) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallService.swift deleted file mode 100644 index 09d5f96a..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallService.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -public struct XcodePostInstallService: Sendable { - public typealias RunFirstLaunch = @Sendable (InstalledXcode) async throws -> Void - public typealias LoadShellOutput = @Sendable () async throws -> ProcessOutput - public typealias LoadXcodeBuildVersion = @Sendable (InstalledXcode) async throws -> ProcessOutput - public typealias TouchInstallCheck = @Sendable (String, String, String) async throws -> ProcessOutput - - private let runFirstLaunch: RunFirstLaunch - private let getUserCacheDirectory: LoadShellOutput - private let getMacOSBuildVersion: LoadShellOutput - private let getXcodeBuildVersion: LoadXcodeBuildVersion - private let touchInstallCheck: TouchInstallCheck - - public init( - runFirstLaunch: @escaping RunFirstLaunch, - getUserCacheDirectory: @escaping LoadShellOutput, - getMacOSBuildVersion: @escaping LoadShellOutput, - getXcodeBuildVersion: @escaping LoadXcodeBuildVersion, - touchInstallCheck: @escaping TouchInstallCheck - ) { - self.runFirstLaunch = runFirstLaunch - self.getUserCacheDirectory = getUserCacheDirectory - self.getMacOSBuildVersion = getMacOSBuildVersion - self.getXcodeBuildVersion = getXcodeBuildVersion - self.touchInstallCheck = touchInstallCheck - } - - public func installComponents(for xcode: InstalledXcode) async throws { - try await runFirstLaunch(xcode) - try Task.checkCancellation() - - async let cacheDirectory = getUserCacheDirectory().out - async let macOSBuildVersion = getMacOSBuildVersion().out - async let toolsVersion = getXcodeBuildVersion(xcode).out - - _ = try await touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallWorkflowService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallWorkflowService.swift deleted file mode 100644 index 9c0a0189..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodePostInstallWorkflowService.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -public struct XcodePostInstallWorkflowService: Sendable { - public typealias EnableDeveloperMode = @Sendable () async throws -> Void - public typealias ApproveLicense = @Sendable (InstalledXcode) async throws -> Void - public typealias InstallComponents = @Sendable (InstalledXcode) async throws -> Void - - private let enableDeveloperMode: EnableDeveloperMode - private let approveLicense: ApproveLicense - private let installComponents: InstallComponents - - public init( - preparationService: XcodePostInstallPreparationService, - postInstallService: XcodePostInstallService - ) { - self.init( - enableDeveloperMode: { try await preparationService.enableDeveloperMode() }, - approveLicense: { try await preparationService.approveLicense(for: $0) }, - installComponents: { try await postInstallService.installComponents(for: $0) } - ) - } - - public init( - enableDeveloperMode: @escaping EnableDeveloperMode, - approveLicense: @escaping ApproveLicense, - installComponents: @escaping InstallComponents - ) { - self.enableDeveloperMode = enableDeveloperMode - self.approveLicense = approveLicense - self.installComponents = installComponents - } - - public func performPostInstallSteps(for xcode: InstalledXcode) async throws { - try await enableDeveloperMode() - try Task.checkCancellation() - try await approveLicense(xcode) - try Task.checkCancellation() - try await installComponents(xcode) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionFilesystemService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionFilesystemService.swift deleted file mode 100644 index f28d6403..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionFilesystemService.swift +++ /dev/null @@ -1,88 +0,0 @@ -import Foundation -@preconcurrency import Path - -public enum XcodeSelectionFilesystemError: LocalizedError, Equatable, Sendable { - case destinationExistsAndIsNotSymlink(Path) - - public var errorDescription: String? { - switch self { - case let .destinationExistsAndIsNotSymlink(path): - return "A non-symbolic-link item already exists at \(path.string)." - } - } -} - -public struct XcodeSelectionFilesystemService: Sendable { - public typealias FileExists = @Sendable (String) -> Bool - public typealias AttributesOfItem = @Sendable (String) throws -> [FileAttributeKey: Any] - public typealias RemoveItem = @Sendable (String) throws -> Void - public typealias CreateSymbolicLink = @Sendable (String, String) throws -> Void - public typealias InstalledXcodeAtPath = @Sendable (Path) -> InstalledXcode? - public typealias Rename = @Sendable (Path, String) throws -> Path - - public struct SymbolicLinkResult: Equatable, Sendable { - public let destinationPath: Path - public let replacedExistingSymlink: Bool - } - - private let fileExists: FileExists - private let attributesOfItem: AttributesOfItem - private let removeItem: RemoveItem - private let createSymbolicLink: CreateSymbolicLink - private let installedXcode: InstalledXcodeAtPath - private let rename: Rename - - public init( - fileExists: @escaping FileExists = { path in FileManager.default.fileExists(atPath: path) }, - attributesOfItem: @escaping AttributesOfItem = { path in try FileManager.default.attributesOfItem(atPath: path) }, - removeItem: @escaping RemoveItem = { path in try FileManager.default.removeItem(atPath: path) }, - createSymbolicLink: @escaping CreateSymbolicLink = { path, destination in - try FileManager.default.createSymbolicLink(atPath: path, withDestinationPath: destination) - }, - installedXcode: @escaping InstalledXcodeAtPath, - rename: @escaping Rename = { try $0.rename(to: $1) } - ) { - self.fileExists = fileExists - self.attributesOfItem = attributesOfItem - self.removeItem = removeItem - self.createSymbolicLink = createSymbolicLink - self.installedXcode = installedXcode - self.rename = rename - } - - public func createSymbolicLink( - to installedXcodePath: Path, - in installDirectory: Path, - isBeta: Bool = false - ) throws -> SymbolicLinkResult { - let destinationPath = installDirectory/"Xcode\(isBeta ? "-Beta" : "").app" - var replacedExistingSymlink = false - - if fileExists(destinationPath.string) { - let attributes = try attributesOfItem(destinationPath.string) - if attributes[.type] as? FileAttributeType == .typeSymbolicLink { - try removeItem(destinationPath.string) - replacedExistingSymlink = true - } else { - throw XcodeSelectionFilesystemError.destinationExistsAndIsNotSymlink(destinationPath) - } - } - - try createSymbolicLink(destinationPath.string, installedXcodePath.string) - return SymbolicLinkResult(destinationPath: destinationPath, replacedExistingSymlink: replacedExistingSymlink) - } - - public func renameForSelection( - installedXcodePath: Path, - in installDirectory: Path - ) throws -> Path { - let destinationPath = installDirectory/"Xcode.app" - - if fileExists(destinationPath.string), let originalXcode = installedXcode(destinationPath) { - let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app" - _ = try rename(destinationPath, newName) - } - - return try rename(installedXcodePath, "Xcode.app") - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionService.swift deleted file mode 100644 index 374312a9..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSelectionService.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation -@preconcurrency import Path -@preconcurrency import Version - -public enum XcodeSelectionError: LocalizedError, Equatable, Sendable { - case invalidIndex(min: Int, max: Int, given: String?) - - public var errorDescription: String? { - switch self { - case let .invalidIndex(min, max, given): - return "Not a valid number. Expecting a whole number between \(min)-\(max), but given \(given ?? "nothing")." - } - } -} - -public enum XcodeSelectionRequest: Equatable, Sendable { - case alreadySelectedVersion(Version) - case alreadySelectedPath(String) - case selectInstalledXcode(InstalledXcode) - case selectPath(String) -} - -public struct XcodeSelectionService: Sendable { - private let versionFile: XcodeVersionFileService - - public init(versionFile: XcodeVersionFileService = XcodeVersionFileService()) { - self.versionFile = versionFile - } - - public func request( - pathOrVersion: String, - installedXcodes: [InstalledXcode], - selectedXcodePath: String, - versionFileDirectory: Path = Path(.cwd) - ) -> XcodeSelectionRequest { - let versionToSelect = pathOrVersion.isEmpty - ? versionFile.version(inDirectory: versionFileDirectory) - : Version(xcodeVersion: pathOrVersion) - - if let version = versionToSelect, - let installedXcode = installedXcodes.first(withVersion: version) { - let selectedInstalledXcode = XcodeListPresentationService.selectedInstalledXcode( - in: installedXcodes, - selectedXcodePath: selectedXcodePath - ) - - if installedXcode.version == selectedInstalledXcode?.version { - return .alreadySelectedVersion(version) - } - - return .selectInstalledXcode(installedXcode) - } - - let pathToSelect = pathOrVersion.trimmingCharacters(in: .whitespacesAndNewlines) - let currentPath = selectedXcodePath.trimmingCharacters(in: .whitespacesAndNewlines) - - if pathToSelect == currentPath { - return .alreadySelectedPath(pathOrVersion) - } - - return .selectPath(pathToSelect) - } - - public func installedXcode( - fromSelection selection: String?, - installedXcodes: [InstalledXcode] - ) throws -> InstalledXcode { - let sortedInstalledXcodes = installedXcodes.sorted { $0.version < $1.version } - - guard - let selection, - let selectionNumber = Int(selection), - sortedInstalledXcodes.indices.contains(selectionNumber - 1) - else { - throw XcodeSelectionError.invalidIndex(min: 1, max: sortedInstalledXcodes.count, given: selection) - } - - return sortedInstalledXcodes[selectionNumber - 1] - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSignatureVerifier.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSignatureVerifier.swift deleted file mode 100644 index bc8a6897..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeSignatureVerifier.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation - -public struct XcodeSignature: Equatable, Sendable { - public var authority: [String] - public var teamIdentifier: String - public var bundleIdentifier: String - - public init(authority: [String] = [], teamIdentifier: String = "", bundleIdentifier: String = "") { - self.authority = authority - self.teamIdentifier = teamIdentifier - self.bundleIdentifier = bundleIdentifier - } -} - -public struct XcodeSignatureVerifier: Sendable { - public static let expectedTeamIdentifier = "59GAB85EFG" - public static let expectedCertificateAuthority = [ - "Software Signing", - "Apple Code Signing Certification Authority", - "Apple Root CA", - ] - - public init() {} - - public func parse(_ rawInfo: String) -> XcodeSignature { - var signature = XcodeSignature() - - for line in rawInfo.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) { - let parts = line.trimmingCharacters(in: .whitespaces).split(separator: "=", maxSplits: 1) - guard parts.count == 2 else { continue } - - switch parts[0] { - case "Authority": - signature.authority.append(String(parts[1])) - case "TeamIdentifier": - signature.teamIdentifier = String(parts[1]) - case "Identifier": - signature.bundleIdentifier = String(parts[1]) - default: - continue - } - } - - return signature - } - - public func isValid(_ signature: XcodeSignature) -> Bool { - signature.teamIdentifier == Self.expectedTeamIdentifier && - signature.authority == Self.expectedCertificateAuthority - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUnarchiveService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUnarchiveService.swift deleted file mode 100644 index 6843ab24..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUnarchiveService.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation - -public enum XcodeUnarchiveError: Error, Equatable, Sendable { - case damagedXIP(url: URL) - case notEnoughFreeSpaceToExpandArchive(url: URL) -} - -public enum XcodeUnarchiveStep: Equatable, Sendable { - case unarchiving - case moving(destination: String) -} - -public struct XcodeUnarchiveService: Sendable { - public typealias Unarchive = @Sendable (URL) async throws -> Void - public typealias FileExists = @Sendable (String) -> Bool - public typealias MoveItem = @Sendable (URL, URL) throws -> Void - public typealias RemoveItem = @Sendable (URL) throws -> Void - public typealias StepChanged = @Sendable (XcodeUnarchiveStep) async -> Void - - private let unarchive: Unarchive - private let fileExists: FileExists - private let moveItem: MoveItem - private let removeItem: RemoveItem - - public init( - unarchive: @escaping Unarchive, - fileExists: @escaping FileExists, - moveItem: @escaping MoveItem, - removeItem: @escaping RemoveItem - ) { - self.unarchive = unarchive - self.fileExists = fileExists - self.moveItem = moveItem - self.removeItem = removeItem - } - - public func unarchiveAndMoveXIP( - at source: URL, - to destination: URL, - stepChanged: @escaping StepChanged = { _ in } - ) async throws -> URL { - try await withTaskCancellationHandler { - try await unarchiveAndMoveXIPWithoutCancellationHandler( - at: source, - to: destination, - stepChanged: stepChanged - ) - } onCancel: { - if fileExists(source.path) { - try? removeItem(source) - } - if fileExists(destination.path) { - try? removeItem(destination) - } - } - } - - private func unarchiveAndMoveXIPWithoutCancellationHandler( - at source: URL, - to destination: URL, - stepChanged: StepChanged - ) async throws -> URL { - await stepChanged(.unarchiving) - - do { - try await unarchive(source) - } catch { - if let executionError = error as? ProcessExecutionError { - if executionError.standardError.contains("damaged and can’t be expanded") { - throw XcodeUnarchiveError.damagedXIP(url: source) - } - if executionError.standardError.contains("can’t be expanded because the selected volume doesn’t have enough free space.") { - throw XcodeUnarchiveError.notEnoughFreeSpaceToExpandArchive(url: source) - } - } - throw error - } - - await stepChanged(.moving(destination: destination.path)) - - let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") - let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") - if fileExists(xcodeURL.path) { - try moveItem(xcodeURL, destination) - } else if fileExists(xcodeBetaURL.path) { - try moveItem(xcodeBetaURL, destination) - } - - return destination - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUninstallService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUninstallService.swift deleted file mode 100644 index cf0cde68..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUninstallService.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -public struct XcodeUninstallService: Sendable { - public struct Result: Equatable, Sendable { - public let xcode: InstalledXcode - public let trashURL: URL? - - public var didDeleteImmediately: Bool { - trashURL == nil - } - } - - private let removeItem: @Sendable (URL) throws -> Void - private let trashItem: @Sendable (URL) throws -> URL - - public init( - removeItem: @escaping @Sendable (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }, - trashItem: @escaping @Sendable (URL) throws -> URL = { try FileManager.default.xcodesTrashItem(at: $0) } - ) { - self.removeItem = removeItem - self.trashItem = trashItem - } - - public func uninstall(_ xcode: InstalledXcode, emptyTrash: Bool) throws -> Result { - if emptyTrash { - try removeItem(xcode.path.url) - return Result(xcode: xcode, trashURL: nil) - } - - return Result(xcode: xcode, trashURL: try trashItem(xcode.path.url)) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUpdatePolicy.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUpdatePolicy.swift deleted file mode 100644 index b246a451..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeUpdatePolicy.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -public struct XcodeUpdatePolicy: Sendable { - public static let defaultMaximumCacheAge = TimeInterval(60 * 60 * 5) - - private let maximumCacheAge: TimeInterval - private let now: @Sendable () -> Date - - public init( - maximumCacheAge: TimeInterval = Self.defaultMaximumCacheAge, - now: @escaping @Sendable () -> Date = { Date() } - ) { - self.maximumCacheAge = maximumCacheAge - self.now = now - } - - public func shouldUpdate( - cachedXcodes: [AvailableXcode], - lastUpdated: Date? - ) -> Bool { - guard cachedXcodes.isEmpty == false, let lastUpdated else { - return true - } - - return lastUpdated < now().addingTimeInterval(-maximumCacheAge) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeValidationService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeValidationService.swift deleted file mode 100644 index 40404c34..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeValidationService.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -public enum XcodeValidationError: Error, Equatable, Sendable { - case failedSecurityAssessment(xcode: InstalledXcode, output: String) - case codesignVerifyFailed(output: String) - case unexpectedCodeSigningIdentity(identifier: String, certificateAuthority: [String]) -} - -public struct XcodeValidationService: Sendable { - public typealias AssessSecurity = @Sendable (URL) async throws -> ProcessOutput - public typealias VerifyCodesign = @Sendable (URL) async throws -> ProcessOutput - - private let assessSecurity: AssessSecurity - private let verifyCodesign: VerifyCodesign - private let signatureVerifier: XcodeSignatureVerifier - - public init( - assessSecurity: @escaping AssessSecurity, - verifyCodesign: @escaping VerifyCodesign, - signatureVerifier: XcodeSignatureVerifier = XcodeSignatureVerifier() - ) { - self.assessSecurity = assessSecurity - self.verifyCodesign = verifyCodesign - self.signatureVerifier = signatureVerifier - } - - public func verifySecurityAssessment(of xcode: InstalledXcode) async throws { - do { - _ = try await assessSecurity(xcode.path.url) - } catch { - throw XcodeValidationError.failedSecurityAssessment( - xcode: xcode, - output: Self.processOutput(from: error) - ) - } - } - - public func verifySigningCertificate(of url: URL) async throws { - let output: ProcessOutput - do { - output = try await verifyCodesign(url) - } catch { - throw XcodeValidationError.codesignVerifyFailed(output: Self.processOutput(from: error)) - } - - let signature = signatureVerifier.parse(output.err) - guard signatureVerifier.isValid(signature) else { - throw XcodeValidationError.unexpectedCodeSigningIdentity( - identifier: signature.teamIdentifier, - certificateAuthority: signature.authority - ) - } - } - - private static func processOutput(from error: Error) -> String { - guard let executionError = error as? ProcessExecutionError else { return "" } - return [executionError.standardOutput, executionError.standardError] - .filter { !$0.isEmpty } - .joined(separator: "\n") - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeVersionFileService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeVersionFileService.swift deleted file mode 100644 index cc9bf8ab..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodeVersionFileService.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -@preconcurrency import Path -@preconcurrency import Version - -public struct XcodeVersionFileService: Sendable { - public typealias FileExists = @Sendable (String) -> Bool - public typealias ContentsAtPath = @Sendable (String) -> Data? - - private let fileExists: FileExists - private let contentsAtPath: ContentsAtPath - - public init( - fileExists: @escaping FileExists = { path in FileManager.default.fileExists(atPath: path) }, - contentsAtPath: @escaping ContentsAtPath = { path in FileManager.default.contents(atPath: path) } - ) { - self.fileExists = fileExists - self.contentsAtPath = contentsAtPath - } - - /// Attempts to parse the `.xcode-version` file in the provided directory. - public func version(inDirectory directory: Path = Path(.cwd)) -> Version? { - let xcodeVersionFilePath = directory.join(".xcode-version") - - guard - fileExists(xcodeVersionFilePath.string), - let contents = contentsAtPath(xcodeVersionFilePath.string), - let versionString = String(data: contents, encoding: .utf8) - else { - return nil - } - - return Version(gemVersion: versionString) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift deleted file mode 100644 index f8faf416..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct XcodebuildRuntimeDownloadService: Sendable { - public init() {} - - public func download( - platform: String, - buildVersion: String, - architecture: String? = nil - ) -> AsyncThrowingStream { - let progress = Progress() - let process = Process() - let xcodebuildPath = Path.root.usr.bin.join("xcodebuild").url - - process.executableURL = xcodebuildPath - process.arguments = [ - "-downloadPlatform", - platform, - "-buildVersion", - buildVersion - ] - - if let architecture { - process.arguments?.append(contentsOf: [ - "-architectureVariant", - architecture - ]) - } - - return ProcessProgressStreamRunner( - process: process, - progress: progress, - outputHandler: { string, progress in - progress.updateFromXcodebuild(text: string) - }, - failureHandler: { process in - ProcessExecutionError(process: process, standardOutput: "", standardError: "") - } - ).stream() - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodesPathResolver.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodesPathResolver.swift deleted file mode 100644 index d1555a2a..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Services/XcodesPathResolver.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct XcodesPathResolver: Sendable { - public static let appDefaultApplicationSupport = Path.applicationSupport/"com.robotsandpencils.XcodesApp" - public static let appDefaultInstallDirectory = Path.root/"Applications" - - public static func appApplicationSupport(savedPath: String?) -> Path { - path(from: savedPath) ?? appDefaultApplicationSupport - } - - public static func appInstallDirectory(savedPath: String?) -> Path { - path(from: savedPath) ?? appDefaultInstallDirectory - } - - public static func appCaches() -> Path { - Path.caches/"com.xcodesorg.xcodesapp" - } - - public static func availableXcodesCacheFile(in applicationSupport: Path) -> Path { - applicationSupport/"available-xcodes.json" - } - - public static func downloadableRuntimesCacheFile(in applicationSupport: Path) -> Path { - applicationSupport/"downloadable-runtimes.json" - } - - public static func cliHome(environment: [String: String] = ProcessInfo.processInfo.environment) -> Path { - environment["HOME"].flatMap(Path.init) ?? Path(.home) - } - - public static func cliApplicationSupport(home: Path) -> Path { - home/"Library/Application Support/com.robotsandpencils.xcodes" - } - - public static func cliOldApplicationSupport(home: Path) -> Path { - home/"Library/Application Support/ca.brandonevans.xcodes" - } - - public static func cliCaches(home: Path) -> Path { - home/"Library/Caches/com.robotsandpencils.xcodes" - } - - public static func cliDownloads(home: Path) -> Path { - home/"Downloads" - } - - public static func cliAvailableXcodesCacheFile(applicationSupport: Path) -> Path { - availableXcodesCacheFile(in: applicationSupport) - } - - public static func cliConfigurationFile(applicationSupport: Path) -> Path { - applicationSupport/"configuration.json" - } - - private static func path(from savedPath: String?) -> Path? { - savedPath.flatMap(Path.init) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift deleted file mode 100644 index a506a50f..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift +++ /dev/null @@ -1,273 +0,0 @@ -import Foundation -@preconcurrency import Path -import os -import os.log - -public typealias ProcessOutput = (status: Int32, out: String, err: String) - -public enum XcodesProcess: Sendable { - public static func sudo(password: String? = nil, _ executable: P, workingDirectory: URL? = nil, _ arguments: String...) async throws -> ProcessOutput { - try await sudo(password: password, executable, workingDirectory: workingDirectory, arguments) - } - - public static func sudo(password: String? = nil, _ executable: P, workingDirectory: URL? = nil, _ arguments: [String]) async throws -> ProcessOutput { - var arguments = [executable.string] + arguments - if password != nil { - arguments.insert("-S", at: 0) - } - return try await run(Path.root.usr.bin.sudo.url, workingDirectory: workingDirectory, input: password, arguments) - } - - public static func run(_ executable: P, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { - try await run(executable, workingDirectory: workingDirectory, input: input, arguments) - } - - public static func run(_ executable: P, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { - try await run(executable.url, workingDirectory: workingDirectory, input: input, arguments) - } - - public static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { - try await Process.run(executable.url, workingDirectory: workingDirectory, input: input, arguments) - } - - public static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { - try await Process.run(executable, workingDirectory: workingDirectory, input: input, arguments) - } -} - -public extension Process { - @discardableResult - static func sudoAsync(password: String? = nil, _ executable: P, workingDirectory: URL? = nil, _ arguments: String...) async throws -> ProcessOutput { - try await XcodesProcess.sudo(password: password, executable, workingDirectory: workingDirectory, arguments) - } - - @discardableResult - static func runAsync(_ executable: P, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { - try await XcodesProcess.run(executable, workingDirectory: workingDirectory, input: input, arguments) - } - - @discardableResult - static func runAsync(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { - try await XcodesProcess.run(executable, workingDirectory: workingDirectory, input: input, arguments) - } -} - -extension Process { - static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) async throws -> ProcessOutput { - return try await run(executable.url, workingDirectory: workingDirectory, input: input, arguments) - } - - static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) throws -> ProcessOutput { - return try run(executable.url, workingDirectory: workingDirectory, input: input, arguments) - } - - static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) throws -> ProcessOutput { - - let process = Process() - process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent() - process.executableURL = executable - process.arguments = arguments - - let (stdout, stderr) = (Pipe(), Pipe()) - process.standardOutput = stdout - process.standardError = stderr - - if let input = input { - let inputPipe = Pipe() - process.standardInput = inputPipe.fileHandleForReading - inputPipe.fileHandleForWriting.write(Data(input.utf8)) - inputPipe.fileHandleForWriting.closeFile() - } - - do { - Logger.subprocess.info("Process.run executable: \(executable), input: \(input ?? ""), arguments: \(arguments.joined(separator: ", "))") - - try process.run() - process.waitUntilExit() - - let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - - Logger.subprocess.info("Process.run output: \(output)") - if !error.isEmpty { - Logger.subprocess.error("Process.run error: \(error)") - } - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - throw ProcessExecutionError(process: process, terminationStatus: process.terminationStatus, standardOutput: output, standardError: error) - } - - return (process.terminationStatus, output, error) - } catch { - throw error - } - } - - static func run(_ executable: URL, workingDirectory: URL? = nil, input: String? = nil, _ arguments: [String]) async throws -> ProcessOutput { - let process = Process() - process.currentDirectoryURL = workingDirectory ?? executable.deletingLastPathComponent() - process.executableURL = executable - process.arguments = arguments - - let (stdout, stderr) = (Pipe(), Pipe()) - process.standardOutput = stdout - process.standardError = stderr - - if let input = input { - let inputPipe = Pipe() - process.standardInput = inputPipe.fileHandleForReading - inputPipe.fileHandleForWriting.write(Data(input.utf8)) - inputPipe.fileHandleForWriting.closeFile() - } - - Logger.subprocess.info("Process.run executable: \(executable), input: \(input ?? ""), arguments: \(arguments.joined(separator: ", "))") - - let runner = AsyncProcessRunner(process: process, stdout: stdout, stderr: stderr) - return try await withTaskCancellationHandler { - try await runner.run() - } onCancel: { - runner.cancel() - } - } - -} - -private final class AsyncProcessRunner: Sendable { - private let process: Process - private let stdout: Pipe - private let stderr: Pipe - private let request = OneShotContinuation() - private let output = OSAllocatedUnfairLock(initialState: OutputStorage()) - - init(process: Process, stdout: Pipe, stderr: Pipe) { - self.process = process - self.stdout = stdout - self.stderr = stderr - } - - func run() async throws -> ProcessOutput { - try await request.value { - startReadingOutput() - - process.terminationHandler = { [weak self] process in - self?.finish(process: process) - } - - do { - try process.run() - } catch { - clearReadabilityHandlers() - throw error - } - } - } - - func cancel() { - if process.isRunning { - process.terminate() - } - clearReadabilityHandlers() - request.resume(throwing: CancellationError()) - } - - private func finish(process: Process) { - clearReadabilityHandlers() - appendRemainingOutput() - - let data = output.withLock { $0 } - let output = string(from: data.stdout) - let error = string(from: data.stderr) - - Logger.subprocess.info("Process.run output: \(output)") - if !error.isEmpty { - Logger.subprocess.error("Process.run error: \(error)") - } - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - resume(throwing: ProcessExecutionError(process: process, terminationStatus: process.terminationStatus, standardOutput: output, standardError: error)) - return - } - - resume(returning: (process.terminationStatus, output, error)) - } - - private func resume(returning output: ProcessOutput) { - request.resume(with: .success(output)) - } - - private func resume(throwing error: Swift.Error) { - request.resume(throwing: error) - } - - private func startReadingOutput() { - stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in - self?.appendAvailableData(from: handle, stream: .stdout) - } - stderr.fileHandleForReading.readabilityHandler = { [weak self] handle in - self?.appendAvailableData(from: handle, stream: .stderr) - } - } - - private func appendAvailableData(from handle: FileHandle, stream: OutputStream) { - let data = handle.availableData - guard data.isEmpty == false else { return } - - output.withLock { - append(data, to: stream, storage: &$0) - } - } - - private func appendRemainingOutput() { - let remainingStdout = stdout.fileHandleForReading.readDataToEndOfFile() - let remainingStderr = stderr.fileHandleForReading.readDataToEndOfFile() - - output.withLock { - append(remainingStdout, to: .stdout, storage: &$0) - append(remainingStderr, to: .stderr, storage: &$0) - } - } - - private func append(_ data: Data, to stream: OutputStream, storage: inout OutputStorage) { - guard data.isEmpty == false else { return } - - switch stream { - case .stdout: - storage.stdout.append(data) - case .stderr: - storage.stderr.append(data) - } - } - - private func clearReadabilityHandlers() { - stdout.fileHandleForReading.readabilityHandler = nil - stderr.fileHandleForReading.readabilityHandler = nil - } - - private func string(from data: Data) -> String { - String(data: data, encoding: .utf8) ?? "" - } - - private enum OutputStream { - case stdout - case stderr - } - - private struct OutputStorage: Sendable { - var stdout = Data() - var stderr = Data() - } -} - -public struct ProcessExecutionError: Error, Sendable { - public let processDescription: String - public let terminationStatus: Int32 - public let standardOutput: String - public let standardError: String - - public init(process: Process, terminationStatus: Int32 = 0, standardOutput: String?, standardError: String?) { - self.processDescription = process.description - self.terminationStatus = terminationStatus - self.standardOutput = standardOutput ?? "" - self.standardError = standardError ?? "" - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/ProcessProgressStream.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/ProcessProgressStream.swift deleted file mode 100644 index e499ba75..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/ProcessProgressStream.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Foundation -import os - -final class ProcessProgressStreamRunner: Sendable { - typealias OutputHandler = @Sendable (String, Progress) -> Void - typealias FailureHandler = @Sendable (Process) -> Error - typealias SuccessHandler = @Sendable () -> Error? - - private let process: Process - private let progress: Progress - private let outputHandler: OutputHandler - private let failureHandler: FailureHandler - private let successHandler: SuccessHandler - private let continuation = OSAllocatedUnfairLock.Continuation?>(initialState: nil) - - init( - process: Process, - progress: Progress, - outputHandler: @escaping OutputHandler, - failureHandler: @escaping FailureHandler, - successHandler: @escaping SuccessHandler = { nil } - ) { - self.process = process - self.progress = progress - self.outputHandler = outputHandler - self.failureHandler = failureHandler - self.successHandler = successHandler - } - - func stream() -> AsyncThrowingStream { - let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) - self.continuation.withLock { - $0 = continuation - } - - continuation.onTermination = { _ in - self.cancel() - } - - start() - - return stream - } - - private func start() { - progress.kind = .file - progress.fileOperationKind = .downloading - continuation.withLock { - _ = $0?.yield(progress) - } - - let stdOutPipe = Pipe() - process.standardOutput = stdOutPipe - let stdErrPipe = Pipe() - process.standardError = stdErrPipe - - let handleData: @Sendable (FileHandle) -> Void = { [weak self] handle in - guard let self else { return } - let data = handle.availableData - guard data.isEmpty == false else { return } - - let string = String(decoding: data, as: UTF8.self) - self.continuation.withLock { - self.outputHandler(string, self.progress) - _ = $0?.yield(self.progress) - } - } - - stdOutPipe.fileHandleForReading.readabilityHandler = handleData - stdErrPipe.fileHandleForReading.readabilityHandler = handleData - - process.terminationHandler = { [weak self] process in - self?.finish(process: process) - } - - do { - try process.run() - } catch { - finish(throwing: error) - } - } - - func cancel() { - if process.isRunning { - process.terminate() - } - clearHandlers() - continuation.withLock { - $0 = nil - } - } - - private func finish(process: Process) { - clearHandlers() - consumeRemainingOutput() - - guard process.terminationReason == .exit, process.terminationStatus == 0 else { - finish(throwing: failureHandler(process)) - return - } - - if let error = successHandler() { - finish(throwing: error) - return - } - - takeContinuation()?.finish() - } - - private func finish(throwing error: Error) { - clearHandlers() - takeContinuation()?.finish(throwing: error) - } - - private func takeContinuation() -> AsyncThrowingStream.Continuation? { - continuation.withLock { - let continuation = $0 - $0 = nil - return continuation - } - } - - private func clearHandlers() { - (process.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil - (process.standardError as? Pipe)?.fileHandleForReading.readabilityHandler = nil - } - - private func consumeRemainingOutput() { - consumeRemainingOutput(from: process.standardOutput as? Pipe) - consumeRemainingOutput(from: process.standardError as? Pipe) - } - - private func consumeRemainingOutput(from pipe: Pipe?) { - guard let pipe else { return } - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard data.isEmpty == false else { return } - - let string = String(decoding: data, as: UTF8.self) - continuation.withLock { - outputHandler(string, progress) - _ = $0?.yield(progress) - } - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift deleted file mode 100644 index 878eb64d..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/XcodesShell.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -@preconcurrency import Path - -public struct XcodesShell: Sendable { - public init() {} - - public var unxip: @Sendable (URL) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", $0.path) - } - public var spctlAssess: @Sendable (URL) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", $0.path) - } - public var codesignVerify: @Sendable (URL) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.codesign, "-vv", "-d", $0.path) - } - public var buildVersion: @Sendable () async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.sw_vers, "-buildVersion") - } - public var xcodeBuildVersion: @Sendable (InstalledXcode) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.libexec.PlistBuddy, "-c", "Print :ProductBuildVersion", "\($0.path.string)/Contents/version.plist") - } - public var getUserCacheDir: @Sendable () async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.getconf, "DARWIN_USER_CACHE_DIR") - } - public var touchInstallCheck: @Sendable (String, String, String) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin/"touch", "\($0)com.apple.dt.Xcode.InstallCheckCache_\($1)_\($2)") - } - public var installedRuntimes: @Sendable () async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j") - } - public var mountDmg: @Sendable (URL) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path) - } - public var unmountDmg: @Sendable (URL) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) - } - public var expandPkg: @Sendable (URL, URL) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.sbin.join("pkgutil"), "--verbose", "--expand", $0.path, $1.path) - } - public var createPkg: @Sendable (URL, URL) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path) - } - public var installPkg: @Sendable (URL, String) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) - } - public var installRuntimeImage: @Sendable (URL) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $0.path) - } - public var deleteRuntime: @Sendable (String) async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "delete", $0) - } - public var xcodeSelectPrintPath: @Sendable () async throws -> ProcessOutput = { - try await XcodesProcess.run(Path.root.usr.bin.join("xcode-select"), "-p") - } - public var xcodeSelectSwitch: @Sendable (String?, String) async throws -> ProcessOutput = { - try await XcodesProcess.sudo(password: $0, Path.root.usr.bin.join("xcode-select"), "-s", $1) - } - - public var archs: @Sendable (URL) throws -> ProcessOutput = { - try Process.run(Path.root.usr.bin.join("lipo"), "-archs", $0.path) - } -} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift b/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift deleted file mode 100644 index 4f65a73c..00000000 --- a/Xcodes/XcodesKit/Sources/XcodesKit/XcodesKitEnvironment.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation -import os - -public final class XcodesKitEnvironment: Sendable { - private let environment = OSAllocatedUnfairLock(initialState: Storage()) - - public var files: XcodesKitFiles { - get { environment.withLock { $0.files } } - set { environment.withLock { $0.files = newValue } } - } - - public var shell: XcodesShell { - get { environment.withLock { $0.shell } } - set { environment.withLock { $0.shell = newValue } } - } - - public init() {} - - private struct Storage: Sendable { - var files = XcodesKitFiles() - var shell = XcodesShell() - } -} - -let Current = XcodesKitEnvironment() - -public struct XcodesKitFiles: Sendable { - public var contentsAtPath: @Sendable (String) -> Data? = { - try? Data(contentsOf: URL(fileURLWithPath: $0)) - } - - public func contents(atPath path: String) -> Data? { - contentsAtPath(path) - } -} - -public func configureXcodesKitFileContents(_ contentsAtPath: @escaping @Sendable (String) -> Data?) { - var files = Current.files - files.contentsAtPath = contentsAtPath - Current.files = files -} - -public func configureXcodesKitArchs(_ archs: @escaping @Sendable (URL) throws -> ProcessOutput) { - var shell = Current.shell - shell.archs = archs - Current.shell = shell -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ApplicationSupportMigrationServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ApplicationSupportMigrationServiceTests.swift deleted file mode 100644 index 3861d12e..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/ApplicationSupportMigrationServiceTests.swift +++ /dev/null @@ -1,89 +0,0 @@ -@preconcurrency import Path -import XCTest -import os -@testable import XcodesKit - -final class ApplicationSupportMigrationServiceTests: XCTestCase { - private let oldSupportPath = Path("/tmp/old-support")! - private let newSupportPath = Path("/tmp/new-support")! - - func testMigrationDoesNothingWhenOldSupportFilesDoNotExist() { - let service = ApplicationSupportMigrationService( - fileExists: { _ in false }, - moveItem: { _, _ in XCTFail("Should not move support files") }, - removeItem: { _ in XCTFail("Should not remove support files") } - ) - - XCTAssertEqual( - service.migrate(oldSupportPath: oldSupportPath, newSupportPath: newSupportPath), - .noMigrationNeeded - ) - } - - func testMigrationMovesOldSupportFilesWhenNewSupportFilesDoNotExist() { - let recorder = MigrationRecorder() - let oldSupportPathString = oldSupportPath.string - let service = ApplicationSupportMigrationService( - fileExists: { $0 == oldSupportPathString }, - moveItem: { source, destination in - recorder.recordMove(source: source, destination: destination) - }, - removeItem: { _ in XCTFail("Should not remove support files") } - ) - - XCTAssertEqual( - service.migrate(oldSupportPath: oldSupportPath, newSupportPath: newSupportPath), - .migratedOldSupportFiles - ) - XCTAssertEqual(recorder.movedSource, oldSupportPath.url) - XCTAssertEqual(recorder.movedDestination, newSupportPath.url) - } - - func testMigrationRemovesOldSupportFilesWhenNewSupportFilesAlreadyExist() { - let recorder = MigrationRecorder() - let service = ApplicationSupportMigrationService( - fileExists: { _ in true }, - moveItem: { _, _ in XCTFail("Should not move support files") }, - removeItem: { recorder.recordRemoval($0) } - ) - - XCTAssertEqual( - service.migrate(oldSupportPath: oldSupportPath, newSupportPath: newSupportPath), - .removedOldSupportFiles - ) - XCTAssertEqual(recorder.removedURL, oldSupportPath.url) - } -} - -private final class MigrationRecorder: Sendable { - private struct State: Sendable { - var source: URL? - var destination: URL? - var removed: URL? - } - - private let state = OSAllocatedUnfairLock(initialState: State()) - - var movedSource: URL? { - state.withLock { $0.source } - } - - var movedDestination: URL? { - state.withLock { $0.destination } - } - - var removedURL: URL? { - state.withLock { $0.removed } - } - - func recordMove(source: URL, destination: URL) { - state.withLock { - $0.source = source - $0.destination = destination - } - } - - func recordRemoval(_ url: URL) { - state.withLock { $0.removed = url } - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift deleted file mode 100644 index d99608e6..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchitectureFilteringTests.swift +++ /dev/null @@ -1,145 +0,0 @@ -import XCTest -@preconcurrency import Version -@testable import XcodesKit - -final class ArchitectureFilteringTests: XCTestCase { - func testAvailableXcodesCanBeFilteredByArchitecture() throws { - let universal = availableXcode("15.0.0", filename: "Xcode-15.xip", architectures: [.arm64, .x86_64]) - let appleSilicon = availableXcode("16.0.0", filename: "Xcode-16-arm64.xip", architectures: [.arm64]) - let intel = availableXcode("14.0.0", filename: "Xcode-14-x86_64.xip", architectures: [.x86_64]) - let unknown = availableXcode("13.0.0", filename: "Xcode-13.xip") - - XCTAssertEqual( - [universal, appleSilicon, intel, unknown].matchingArchitectures([.arm64]), - [universal, appleSilicon] - ) - XCTAssertEqual( - [universal, appleSilicon, intel, unknown].matchingArchitectures([.x86_64]), - [universal, intel] - ) - XCTAssertEqual( - [universal, appleSilicon, intel, unknown].matchingArchitectures([]), - [universal, appleSilicon, intel, unknown] - ) - } - - func testAvailableXcodesFirstCompatiblePrefersUniversalThenHostArchitecture() throws { - let universal = availableXcode("26.2.0", filename: "Xcode-26-universal.xip", architectures: [.arm64, .x86_64]) - let appleSilicon = availableXcode("26.2.0", filename: "Xcode-26-arm64.xip", architectures: [.arm64]) - let intel = availableXcode("26.2.0", filename: "Xcode-26-x86_64.xip", architectures: [.x86_64]) - - XCTAssertEqual([appleSilicon, universal, intel].firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: .arm64), universal) - XCTAssertEqual([appleSilicon, intel].firstCompatible(withVersion: Version("26.2.0")!, hostArchitecture: .x86_64), intel) - } - - func testXcodeListPresentationServiceFiltersAvailableRowsByArchitecture() throws { - let universal = availableXcode("15.0.0", filename: "Xcode-15.xip", architectures: [.arm64, .x86_64]) - let appleSilicon = availableXcode("16.0.0", filename: "Xcode-16-arm64.xip", architectures: [.arm64]) - let intel = availableXcode("14.0.0", filename: "Xcode-14-x86_64.xip", architectures: [.x86_64]) - - let rows = XcodeListPresentationService().availableRows( - availableXcodes: [universal, appleSilicon, intel], - installedXcodes: [], - selectedXcodePath: nil, - dataSource: .xcodeReleases, - architectures: [.architecture(.arm64), .variant(.universal)] - ) - - XCTAssertEqual(rows.map(\.version), [universal.version, appleSilicon.version]) - XCTAssertEqual(rows.map(\.versionDescription), [ - "15.0 [Universal]", - "16.0 [Apple Silicon]" - ]) - } - - func testArchitectureFiltersParseRawArchitecturesAndVariants() { - XCTAssertEqual(ArchitectureFilter("arm64"), .architecture(.arm64)) - XCTAssertEqual(ArchitectureFilter("x86_64"), .architecture(.x86_64)) - XCTAssertEqual(ArchitectureFilter("appleSilicon"), .variant(.appleSilicon)) - XCTAssertEqual(ArchitectureFilter("universal"), .variant(.universal)) - } - - func testArchitectureFiltersKeepUnknownArchitectureEntriesVisible() { - XCTAssertTrue([ArchitectureFilter.variant(.appleSilicon)].matches(nil)) - XCTAssertTrue([ArchitectureFilter.variant(.universal)].matches([])) - } - - func testDefaultArchitectureFilterUsesMachineArchitecture() { - XCTAssertEqual(ArchitectureVariant.defaultForMachine(machineHardwareName: "arm64"), .appleSilicon) - XCTAssertEqual(ArchitectureVariant.defaultForMachine(machineHardwareName: "x86_64"), .universal) - XCTAssertEqual([ArchitectureFilter].defaultForMachine(machineHardwareName: "arm64"), [.variant(.appleSilicon)]) - XCTAssertEqual([ArchitectureFilter].defaultForMachine(machineHardwareName: "x86_64"), [.variant(.universal)]) - } - - func testRuntimeListPresentationServiceFiltersRowsByArchitecture() { - let response = DownloadableRuntimesResponse( - sdkToSimulatorMappings: [], - sdkToSeedMappings: [], - refreshInterval: 3600, - downloadables: [ - downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg", architectures: [.arm64, .x86_64]), - downloadableRuntime( - source: "https://example.com/iOS_17_Runtime.dmg", - architectures: [.arm64], - simulatorVersion: .init(buildUpdate: "21A1", version: "17.0"), - identifier: "com.apple.CoreSimulator.SimRuntime.iOS-17-0", - name: "iOS 17.0" - ), - downloadableRuntime( - source: "https://example.com/iOS_15_Runtime.dmg", - architectures: [.x86_64], - simulatorVersion: .init(buildUpdate: "19A1", version: "15.0"), - identifier: "com.apple.CoreSimulator.SimRuntime.iOS-15-0", - name: "iOS 15.0" - ) - ], - version: "2" - ) - - let rows = RuntimeListPresentationService().rows( - downloadableRuntimes: response, - installedRuntimes: [], - includeBetas: false, - architectures: [.architecture(.arm64), .variant(.universal)] - ) - - XCTAssertEqual(rows.first?.runtimes.map(\.visibleIdentifier), [ - "iOS 16.0 [Universal]", - "iOS 17.0 [Apple Silicon]" - ]) - } - - private func availableXcode(_ version: String, filename: String, architectures: [Architecture]? = nil) -> AvailableXcode { - AvailableXcode( - version: Version(version)!, - url: URL(fileURLWithPath: "/" + filename), - filename: filename, - releaseDate: nil, - architectures: architectures - ) - } - - private func downloadableRuntime( - source: String?, - architectures: [Architecture]? = nil, - simulatorVersion: DownloadableRuntime.SimulatorVersion = .init(buildUpdate: "20A360", version: "16.0"), - identifier: String = "com.apple.CoreSimulator.SimRuntime.iOS-16-0", - name: String = "iOS 16.0" - ) -> DownloadableRuntime { - DownloadableRuntime( - category: .simulator, - simulatorVersion: simulatorVersion, - source: source, - architectures: architectures, - dictionaryVersion: 1, - contentType: .diskImage, - platform: .iOS, - identifier: identifier, - version: simulatorVersion.version, - fileSize: 42, - hostRequirements: nil, - name: name, - authentication: nil - ) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchiveDownloadStrategyServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchiveDownloadStrategyServiceTests.swift deleted file mode 100644 index dd5826e2..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/ArchiveDownloadStrategyServiceTests.swift +++ /dev/null @@ -1,128 +0,0 @@ -import XCTest -@preconcurrency import Path -@testable import XcodesKit - -final class ArchiveDownloadStrategyServiceTests: XCTestCase { - func testAria2StrategyUsesAria2PathCookiesAndDestination() async throws { - let aria2Path = try XCTUnwrap(Path("/usr/local/bin/aria2c")) - let url = try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")) - let destination = try XCTUnwrap(Path("/tmp/Xcode.xip")) - let cookie = try XCTUnwrap(HTTPCookie(properties: [ - .domain: "example.com", - .path: "/", - .name: "session", - .value: "cookie" - ])) - let recorder = Aria2Recorder() - let service = ArchiveDownloadStrategyService( - archiveDownloadService: ArchiveDownloadService( - aria2Download: { path, url, destination, cookies in - return AsyncThrowingStream { continuation in - Task { - await recorder.record(path: path, url: url, destination: destination, cookies: cookies) - continuation.finish() - } - } - }, - urlSessionDownload: { _, _, _ in - XCTFail("URLSession should not be used for aria2 downloads") - return (Progress(), Task { throw URLError(.unknown) }) - }, - contentsAtPath: { _ in nil }, - createFile: { _, _ in }, - removeItem: { _ in } - ), - aria2Path: { aria2Path }, - cookiesForURL: { _ in [cookie] } - ) - - let result = try await service.download( - url: url, - destination: destination, - downloader: .aria2, - resumeDataPath: try XCTUnwrap(Path("/tmp/Xcode.xip.resumedata")), - progressChanged: { _ in } - ) - - XCTAssertEqual(result, destination.url) - let recorded = await recorder.values - XCTAssertEqual(recorded.path, aria2Path.string) - XCTAssertEqual(recorded.url, url) - XCTAssertEqual(recorded.destination, destination.string) - XCTAssertEqual(recorded.cookies, [cookie]) - } - - func testURLSessionStrategyUsesResumeDataPathAndSkipsAria2() async throws { - let url = try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")) - let destination = try XCTUnwrap(Path("/tmp/Xcode.xip")) - let resumeDataPath = try XCTUnwrap(Path("/tmp/Xcode.xip.resumedata")) - let resumeData = Data("resume".utf8) - let recorder = URLSessionRecorder() - let service = ArchiveDownloadStrategyService( - archiveDownloadService: ArchiveDownloadService( - aria2Download: { _, _, _, _ in - XCTFail("aria2 should not be used for URLSession downloads") - return AsyncThrowingStream { continuation in - continuation.finish(throwing: URLError(.unknown)) - } - }, - urlSessionDownload: { url, destination, resumeData in - return ( - Progress(), - Task { - await recorder.record(url: url, destination: destination, resumeData: resumeData) - return ( - saveLocation: destination, - response: URLResponse( - url: url, - mimeType: nil, - expectedContentLength: 0, - textEncodingName: nil - ) - ) - } - ) - }, - contentsAtPath: { path in - path == resumeDataPath.string ? resumeData : nil - }, - createFile: { _, _ in }, - removeItem: { _ in } - ), - aria2Path: { - XCTFail("aria2 path should not be requested for URLSession downloads") - return try XCTUnwrap(Path("/usr/local/bin/aria2c")) - } - ) - - let result = try await service.download( - url: url, - destination: destination, - downloader: .urlSession, - resumeDataPath: resumeDataPath, - progressChanged: { _ in } - ) - - XCTAssertEqual(result, destination.url) - let recorded = await recorder.values - XCTAssertEqual(recorded.url, url) - XCTAssertEqual(recorded.destination, destination.url) - XCTAssertEqual(recorded.resumeData, resumeData) - } -} - -private actor Aria2Recorder { - private(set) var values: (path: String?, url: URL?, destination: String?, cookies: [HTTPCookie]?) = (nil, nil, nil, nil) - - func record(path: Path, url: URL, destination: Path, cookies: [HTTPCookie]) { - values = (path.string, url, destination.string, cookies) - } -} - -private actor URLSessionRecorder { - private(set) var values: (url: URL?, destination: URL?, resumeData: Data?) = (nil, nil, nil) - - func record(url: URL, destination: URL, resumeData: Data?) { - values = (url, destination, resumeData) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/AutoInstallationTypeTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/AutoInstallationTypeTests.swift deleted file mode 100644 index e642c374..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/AutoInstallationTypeTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -import XCTest -@testable import XcodesKit - -final class AutoInstallationTypeTests: XCTestCase { - func testAutoInstallingSetterEnablesNewestVersion() { - var type = AutoInstallationType.none - - type.isAutoInstalling = true - - XCTAssertEqual(type, .newestVersion) - } - - func testAutoInstallingSetterDisablesInstallation() { - var type = AutoInstallationType.newestBeta - - type.isAutoInstalling = false - - XCTAssertEqual(type, .none) - } - - func testAutoInstallingBetaSetterPreservesEnabledReleaseStateWhenDisabled() { - var type = AutoInstallationType.newestVersion - - type.isAutoInstallingBeta = false - - XCTAssertEqual(type, .newestVersion) - } - - func testAutoInstallingBetaSetterEnablesNewestBeta() { - var type = AutoInstallationType.none - - type.isAutoInstallingBeta = true - - XCTAssertEqual(type, .newestBeta) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/CodableFileStoreTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/CodableFileStoreTests.swift deleted file mode 100644 index 2bc122fb..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/CodableFileStoreTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -@preconcurrency import Path -import XCTest -import os -@testable import XcodesKit - -final class CodableFileStoreTests: XCTestCase { - private struct Fixture: Codable, Equatable { - var name: String - } - - private let file = Path("/tmp/configuration.json")! - - func testLoadReturnsNilWhenFileIsMissing() throws { - let store = CodableFileStore( - contentsAtPath: { _ in nil } - ) - - XCTAssertNil(try store.load(from: file)) - } - - func testLoadDecodesValueFromFile() throws { - let data = try JSONEncoder().encode(Fixture(name: "Xcodes")) - let store = CodableFileStore( - contentsAtPath: { _ in data } - ) - - XCTAssertEqual(try store.load(from: file), Fixture(name: "Xcodes")) - } - - func testLoadThrowsDecodeErrorForInvalidFile() { - let store = CodableFileStore( - contentsAtPath: { _ in Data("not json".utf8) } - ) - - XCTAssertThrowsError(try store.load(from: file)) - } - - func testSaveCreatesParentDirectoryAndWritesEncodedValue() throws { - let recorder = CodableFileStoreRecorder() - - let store = CodableFileStore( - createDirectory: { url, createIntermediates, _ in - recorder.recordDirectory(url, createIntermediates: createIntermediates) - }, - createFile: { path, data, _ in - recorder.recordFile(path: path, data: data) - return true - } - ) - - try store.save(Fixture(name: "Xcodes"), to: file) - - XCTAssertEqual(recorder.createdDirectory, file.url.deletingLastPathComponent()) - XCTAssertEqual(recorder.createdIntermediates, true) - XCTAssertEqual(recorder.writtenPath, file.string) - XCTAssertEqual(try JSONDecoder().decode(Fixture.self, from: XCTUnwrap(recorder.writtenData)), Fixture(name: "Xcodes")) - } -} - -private final class CodableFileStoreRecorder: Sendable { - private struct State: Sendable { - var directory: URL? - var createIntermediates: Bool? - var path: String? - var data: Data? - } - - private let state = OSAllocatedUnfairLock(initialState: State()) - - var createdDirectory: URL? { - state.withLock { $0.directory } - } - - var createdIntermediates: Bool? { - state.withLock { $0.createIntermediates } - } - - var writtenPath: String? { - state.withLock { $0.path } - } - - var writtenData: Data? { - state.withLock { $0.data } - } - - func recordDirectory(_ url: URL, createIntermediates: Bool) { - state.withLock { - $0.directory = url - $0.createIntermediates = createIntermediates - } - } - - func recordFile(path: String, data: Data?) { - state.withLock { - $0.path = path - $0.data = data - } - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/HostHardwareTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/HostHardwareTests.swift deleted file mode 100644 index 7e9be9ff..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/HostHardwareTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import XCTest -@testable import XcodesKit - -final class HostHardwareTests: XCTestCase { - func testIsAppleSiliconReturnsTrueForArm64() { - XCTAssertTrue(HostHardware.isAppleSilicon(machineHardwareName: "arm64")) - } - - func testIsAppleSiliconReturnsFalseForIntel() { - XCTAssertFalse(HostHardware.isAppleSilicon(machineHardwareName: "x86_64")) - } - - func testIsAppleSiliconReturnsFalseForUnknownHardware() { - XCTAssertFalse(HostHardware.isAppleSilicon(machineHardwareName: nil)) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/InstalledXcodeTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/InstalledXcodeTests.swift deleted file mode 100644 index 40af37ab..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/InstalledXcodeTests.swift +++ /dev/null @@ -1,177 +0,0 @@ -import XCTest -@preconcurrency import Path -import Version -import os -@testable import XcodesKit - -final class InstalledXcodeTests: XCTestCase { - func testInitParsesBundleInfoAndArchitecturesFromInjectedLoaders() throws { - let path = try XCTUnwrap(Path("/Applications/Xcode-15.0.0.app")) - let architectureURL = URLRecorder() - - let xcode = try XCTUnwrap(InstalledXcode( - path: path, - contentsAtPath: { requestedPath in - switch requestedPath { - case "/Applications/Xcode-15.0.0.app/Contents/Info.plist": - return Self.plistData(""" - - CFBundleIdentifier - com.apple.dt.Xcode - CFBundleShortVersionString - 15.0 - - """) - case "/Applications/Xcode-15.0.0.app/Contents/version.plist": - return Self.plistData(""" - - ProductBuildVersion - 15A240d - - """) - default: - XCTFail("Unexpected path \(requestedPath)") - return nil - } - }, - loadArchitectures: { url in - architectureURL.record(url) - return (0, "x86_64 arm64\n", "") - } - )) - - XCTAssertEqual(xcode.path, path) - XCTAssertEqual(xcode.version, Version("15.0.0+15A240d")) - XCTAssertEqual(xcode.xcodeID.architectures, [.x86_64, .arm64]) - XCTAssertEqual(architectureURL.url?.path, "/Applications/Xcode-15.0.0.app/Contents/MacOS/Xcode") - } - - func testInitReturnsNilForNonXcodeBundle() throws { - let path = try XCTUnwrap(Path("/Applications/Other.app")) - - let xcode = InstalledXcode( - path: path, - contentsAtPath: { requestedPath in - switch requestedPath { - case "/Applications/Other.app/Contents/Info.plist": - return Self.plistData(""" - - CFBundleIdentifier - com.example.Other - CFBundleShortVersionString - 15.0 - - """) - case "/Applications/Other.app/Contents/version.plist": - return Self.plistData(""" - - ProductBuildVersion - 15A240d - - """) - default: - return nil - } - }, - loadArchitectures: { _ in (0, "arm64\n", "") } - ) - - XCTAssertNil(xcode) - } - - func testInitReturnsNilWhenBundleInfoCannotBeLoaded() throws { - let path = try XCTUnwrap(Path("/Applications/Xcode.app")) - - let xcode = InstalledXcode( - path: path, - contentsAtPath: { _ in nil }, - loadArchitectures: { _ in - XCTFail("Architectures should not be loaded without bundle info") - return (0, "", "") - } - ) - - XCTAssertNil(xcode) - } - - func testDiscoveryServiceLoadsInstalledXcodesFromInjectedDirectoryListing() throws { - let xcodePath = try XCTUnwrap(Path("/Applications/Xcode.app")) - let otherPath = try XCTUnwrap(Path("/Applications/Other.app")) - let ignoredPath = try XCTUnwrap(Path("/Applications/README.txt")) - - let service = InstalledXcodeDiscoveryService( - listDirectory: { directory in - XCTAssertEqual(directory, try! XCTUnwrap(Path("/Applications"))) - return [xcodePath, otherPath, ignoredPath] - }, - isAppBundle: { $0.extension == "app" }, - contentsAtPath: { requestedPath in - switch requestedPath { - case "/Applications/Xcode.app/Contents/Info.plist": - return Self.plistData(""" - - CFBundleIdentifier - com.apple.dt.Xcode - CFBundleShortVersionString - 15.0 - - """) - case "/Applications/Xcode.app/Contents/version.plist": - return Self.plistData(""" - - ProductBuildVersion - 15A240d - - """) - case "/Applications/Other.app/Contents/Info.plist": - return Self.plistData(""" - - CFBundleIdentifier - com.example.Other - CFBundleShortVersionString - 1.0 - - """) - case "/Applications/Other.app/Contents/version.plist": - return Self.plistData(""" - - ProductBuildVersion - 1A1 - - """) - default: - return nil - } - }, - loadArchitectures: { _ in (0, "arm64\n", "") } - ) - - let xcodes = service.installedXcodes(in: try XCTUnwrap(Path("/Applications"))) - - XCTAssertEqual(xcodes, [ - InstalledXcode(path: xcodePath, version: Version("15.0.0+15A240d")!, architectures: [.arm64]) - ]) - } - - private static func plistData(_ body: String) -> Data { - Data(""" - - - - \(body) - - """.utf8) - } -} - -private final class URLRecorder: Sendable { - private let storedURL = OSAllocatedUnfairLock(initialState: nil) - - var url: URL? { - storedURL.withLock { $0 } - } - - func record(_ url: URL) { - storedURL.withLock { $0 = url } - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/OperatingSystemVersionXcodesTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/OperatingSystemVersionXcodesTests.swift deleted file mode 100644 index f45d81db..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/OperatingSystemVersionXcodesTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import XCTest -@testable import XcodesKit - -final class OperatingSystemVersionXcodesTests: XCTestCase { - func testVersionStringIncludesMajorMinorAndPatchVersions() { - let version = OperatingSystemVersion(majorVersion: 14, minorVersion: 6, patchVersion: 1) - - XCTAssertEqual(version.versionString(), "14.6.1") - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressObservationTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressObservationTests.swift deleted file mode 100644 index 58d2bb4a..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressObservationTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -import XCTest -@testable import XcodesKit - -final class ProgressObservationTests: XCTestCase { - func testObserveDefaultsToFractionCompletedChanges() { - let progress = Progress(totalUnitCount: 10) - let observation = ProgressObservation() - let expectation = expectation(description: "progress changed") - - observation.observe(progress) { changedProgress in - XCTAssertEqual(changedProgress.completedUnitCount, 1) - expectation.fulfill() - } - - progress.completedUnitCount = 1 - - wait(for: [expectation], timeout: 1) - } - - func testChangesYieldsObservedPropertyChanges() async throws { - let progress = Progress(totalUnitCount: 10) - let stream = ProgressObservation.changes( - for: progress, - observing: [.fractionCompleted, .localizedAdditionalDescription, .isIndeterminate] - ) - - progress.completedUnitCount = 1 - - try await waitForNextValue(in: stream) - } - - private func waitForNextValue(in stream: AsyncStream) async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - var iterator = stream.makeAsyncIterator() - guard await iterator.next() != nil else { - throw ProgressObservationTestError.streamFinished - } - } - - group.addTask { - try await Task.sleep(nanoseconds: 1_000_000_000) - throw ProgressObservationTestError.timedOut - } - - try await group.next() - group.cancelAll() - } - } -} - -private enum ProgressObservationTestError: Error { - case streamFinished - case timedOut -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressXcodesTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressXcodesTests.swift deleted file mode 100644 index bcc13b05..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/ProgressXcodesTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import XCTest - -final class ProgressXcodesTests: XCTestCase { - func testUpdateFromAria2Output() { - let progress = Progress() - - progress.updateFromAria2(string: "[#123abc 1024B/4096B(25%) CN:4 DL:512B ETA:1m2s]") - - XCTAssertEqual(progress.completedUnitCount, 1024) - XCTAssertEqual(progress.totalUnitCount, 4096) - XCTAssertEqual(progress.throughput, 512) - XCTAssertEqual(progress.estimatedTimeRemaining, 62) - } - - func testUpdateFromXcodebuildDownloadOutput() { - let progress = Progress() - - progress.updateFromXcodebuild(text: "Downloading iOS 18.1 Simulator (22B83): 42.6% (1.2 GB of 2.8 GB)") - - XCTAssertEqual(progress.totalUnitCount, 100) - XCTAssertEqual(progress.completedUnitCount, 43) - } - - func testUpdateFromXcodebuildInstallingOutputIsIndeterminate() { - let progress = Progress(totalUnitCount: 100) - progress.completedUnitCount = 50 - - progress.updateFromXcodebuild(text: "Downloading tvOS 18.1 Simulator (22J5567a): Installing...") - - XCTAssertEqual(progress.totalUnitCount, 0) - XCTAssertEqual(progress.completedUnitCount, 0) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeArchiveDownloadStrategyServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeArchiveDownloadStrategyServiceTests.swift deleted file mode 100644 index 041040f0..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeArchiveDownloadStrategyServiceTests.swift +++ /dev/null @@ -1,184 +0,0 @@ -import XCTest -@preconcurrency import Path -@testable import XcodesKit - -final class RuntimeArchiveDownloadStrategyServiceTests: XCTestCase { - func testAria2StrategyValidatesDownloadPathAndForwardsProgress() async throws { - let runtime = downloadableRuntime(source: "https://example.com/runtimes/iOS.dmg") - let destination = try XCTUnwrap(Path("/tmp/iOS.dmg")) - let aria2Path = try XCTUnwrap(Path("/usr/local/bin/aria2c")) - let recorder = RuntimeArchiveDownloadRecorder() - let progress = Progress(totalUnitCount: 10) - progress.completedUnitCount = 4 - - let service = RuntimeArchiveDownloadStrategyService( - validateDownloadPath: { path in - await recorder.recordValidation(path) - }, - aria2Path: { aria2Path }, - aria2Download: { runtime, destination, aria2Path, _ in - AsyncThrowingStream { continuation in - Task { - await recorder.recordAria2(runtime: runtime, destination: destination, aria2Path: aria2Path) - continuation.yield(progress) - continuation.finish() - } - } - } - ) - - let result = try await service.download( - runtime: runtime, - url: try XCTUnwrap(runtime.url), - destination: destination, - downloader: .aria2, - progressChanged: { progress in - Task { await recorder.recordProgress(progress.fractionCompleted) } - } - ) - - XCTAssertEqual(result, destination.url) - await recorder.waitForProgress(count: 1) - let recorded = await recorder.snapshot() - XCTAssertEqual(recorded.validatedPath, "/runtimes/iOS.dmg") - XCTAssertEqual(recorded.aria2Runtime, runtime) - XCTAssertEqual(recorded.aria2Destination, destination) - XCTAssertEqual(recorded.aria2Path, aria2Path) - XCTAssertEqual(recorded.progressFractions, [0.4]) - } - - func testURLSessionStrategyUsesProvidedDownload() async throws { - let runtime = downloadableRuntime(source: "https://example.com/runtimes/iOS.dmg") - let destination = try XCTUnwrap(Path("/tmp/iOS.dmg")) - let recorder = RuntimeArchiveDownloadRecorder() - - let service = RuntimeArchiveDownloadStrategyService( - validateDownloadPath: { path in - await recorder.recordValidation(path) - }, - aria2Path: { - XCTFail("aria2 path should not be needed for URLSession downloads") - throw URLError(.unknown) - }, - urlSessionDownload: { url, destination, _ in - await recorder.recordURLSession(url: url, destination: destination) - return destination.url - } - ) - - let result = try await service.download( - runtime: runtime, - url: try XCTUnwrap(runtime.url), - destination: destination, - downloader: .urlSession, - progressChanged: { _ in } - ) - - XCTAssertEqual(result, destination.url) - let recorded = await recorder.snapshot() - XCTAssertEqual(recorded.validatedPath, "/runtimes/iOS.dmg") - XCTAssertEqual(recorded.urlSessionURL, runtime.url) - XCTAssertEqual(recorded.urlSessionDestination, destination) - } - - func testURLSessionStrategyThrowsWhenUnavailable() async throws { - let runtime = downloadableRuntime(source: "https://example.com/runtimes/iOS.dmg") - let destination = try XCTUnwrap(Path("/tmp/iOS.dmg")) - let service = RuntimeArchiveDownloadStrategyService( - validateDownloadPath: { _ in }, - aria2Path: { try XCTUnwrap(Path("/usr/local/bin/aria2c")) } - ) - - do { - _ = try await service.download( - runtime: runtime, - url: try XCTUnwrap(runtime.url), - destination: destination, - downloader: .urlSession, - progressChanged: { _ in } - ) - XCTFail("Expected URLSession runtime downloads to throw when no URLSession download is supplied") - } catch { - XCTAssertEqual(error.localizedDescription, "Downloading runtimes with URLSession is not supported. Please use aria2") - } - } - - private func downloadableRuntime(source: String?) -> DownloadableRuntime { - DownloadableRuntime( - category: .simulator, - simulatorVersion: .init(buildUpdate: "20A360", version: "16.0"), - source: source, - architectures: nil, - dictionaryVersion: 1, - contentType: .diskImage, - platform: .iOS, - identifier: "com.apple.CoreSimulator.SimRuntime.iOS-16-0", - version: "16.0", - fileSize: 42, - hostRequirements: nil, - name: "iOS 16.0", - authentication: .virtual - ) - } -} - -private actor RuntimeArchiveDownloadRecorder { - private(set) var validatedPath: String? - private(set) var aria2Runtime: DownloadableRuntime? - private(set) var aria2Destination: Path? - private(set) var aria2Path: Path? - private(set) var urlSessionURL: URL? - private(set) var urlSessionDestination: Path? - private(set) var progressFractions: [Double] = [] - private var progressWaiters: [CheckedContinuation] = [] - - func recordValidation(_ path: String) { - validatedPath = path - } - - func recordAria2(runtime: DownloadableRuntime, destination: Path, aria2Path: Path) { - aria2Runtime = runtime - aria2Destination = destination - self.aria2Path = aria2Path - } - - func recordURLSession(url: URL, destination: Path) { - urlSessionURL = url - urlSessionDestination = destination - } - - func recordProgress(_ fraction: Double) { - progressFractions.append(fraction) - progressWaiters.forEach { $0.resume() } - progressWaiters.removeAll() - } - - func waitForProgress(count: Int) async { - if progressFractions.count >= count { return } - await withCheckedContinuation { continuation in - progressWaiters.append(continuation) - } - } - - func snapshot() -> RuntimeArchiveDownloadRecord { - RuntimeArchiveDownloadRecord( - validatedPath: validatedPath, - aria2Runtime: aria2Runtime, - aria2Destination: aria2Destination, - aria2Path: aria2Path, - urlSessionURL: urlSessionURL, - urlSessionDestination: urlSessionDestination, - progressFractions: progressFractions - ) - } -} - -private struct RuntimeArchiveDownloadRecord { - let validatedPath: String? - let aria2Runtime: DownloadableRuntime? - let aria2Destination: Path? - let aria2Path: Path? - let urlSessionURL: URL? - let urlSessionDestination: Path? - let progressFractions: [Double] -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeListStoreTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeListStoreTests.swift deleted file mode 100644 index 17419bd4..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/RuntimeListStoreTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import XCTest -@preconcurrency import Path -import os -@testable import XcodesKit - -final class RuntimeListStoreTests: XCTestCase { - func testLoadsCachedDownloadableRuntimes() throws { - let runtime = Self.downloadableRuntime(buildUpdate: "20A360") - let cache = DownloadableRuntimeCache( - cacheFile: try XCTUnwrap(Path("/tmp/downloadable-runtimes.json")), - contentsAtPath: { _ in try? JSONEncoder().encode([runtime]) } - ) - var store = RuntimeListStore(cache: cache, fetchDownloadableRuntimes: { - XCTFail("Cache load should not fetch runtimes") - return Self.downloadableResponse(downloadables: []) - }) - - try store.loadCachedDownloadableRuntimes() - - XCTAssertEqual(store.downloadableRuntimes, [runtime]) - } - - func testUpdateFetchesAddsSDKBuildUpdatesAndSavesRuntimes() async throws { - let runtime = Self.downloadableRuntime(buildUpdate: "20A360") - let response = Self.downloadableResponse( - downloadables: [runtime], - sdkToSimulatorMappings: [ - SDKToSimulatorMapping( - sdkBuildUpdate: "20A361", - simulatorBuildUpdate: "20A360", - sdkIdentifier: "com.apple.platform.iphonesimulator", - downloadableIdentifiers: nil - ) - ] - ) - let savedRuntimes = RuntimeCacheSaveRecorder() - let cache = DownloadableRuntimeCache( - cacheFile: try XCTUnwrap(Path("/tmp/downloadable-runtimes.json")), - contentsAtPath: { _ in nil }, - writeData: { data, _ in - try savedRuntimes.record(data) - }, - createDirectory: { _, _, _ in } - ) - var store = RuntimeListStore(cache: cache, fetchDownloadableRuntimes: { response }) - - let runtimes = try await store.updateDownloadableRuntimes() - - XCTAssertEqual(runtimes.map(\.sdkBuildUpdate), [["20A361"]]) - XCTAssertEqual(store.downloadableRuntimes, runtimes) - XCTAssertEqual(savedRuntimes.value, runtimes) - } - - private static func downloadableRuntime(buildUpdate: String) -> DownloadableRuntime { - DownloadableRuntime( - category: .simulator, - simulatorVersion: .init(buildUpdate: buildUpdate, version: "16.0"), - source: "https://example.com/iOS.dmg", - architectures: nil, - dictionaryVersion: 1, - contentType: .diskImage, - platform: .iOS, - identifier: "com.apple.CoreSimulator.SimRuntime.iOS-16-0", - version: "16.0", - fileSize: 42, - hostRequirements: nil, - name: "iOS 16.0", - authentication: .virtual - ) - } - - private static func downloadableResponse( - downloadables: [DownloadableRuntime], - sdkToSimulatorMappings: [SDKToSimulatorMapping] = [] - ) -> DownloadableRuntimesResponse { - DownloadableRuntimesResponse( - sdkToSimulatorMappings: sdkToSimulatorMappings, - sdkToSeedMappings: [], - refreshInterval: 0, - downloadables: downloadables, - version: "1" - ) - } -} - -private final class RuntimeCacheSaveRecorder: Sendable { - private let storedValue = OSAllocatedUnfairLock<[DownloadableRuntime]?>(initialState: nil) - - var value: [DownloadableRuntime]? { - storedValue.withLock { $0 } - } - - func record(_ data: Data) throws { - let value = try JSONDecoder().decode([DownloadableRuntime].self, from: data) - storedValue.withLock { $0 = value } - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/SDKsTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/SDKsTests.swift deleted file mode 100644 index e587dd7f..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/SDKsTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import XCTest -@testable import XcodesKit - -final class SDKsTests: XCTestCase { - func testAllBuildsReturnsBuildsAcrossAllPlatformsInAppOrder() { - let sdks = SDKs( - macOS: [XcodeVersion("24A335")], - iOS: [XcodeVersion("22A336"), XcodeVersion(number: "18.0")], - watchOS: [XcodeVersion("22R349")], - tvOS: [XcodeVersion("22J357")], - visionOS: [XcodeVersion("22N320")] - ) - - XCTAssertEqual(sdks.allBuilds, [ - "22A336", - "22J357", - "24A335", - "22R349", - "22N320", - ]) - } - - func testAllBuildsSkipsMissingPlatformAndBuildValues() { - let sdks = SDKs( - macOS: nil, - iOS: [XcodeVersion(number: "18.0")], - watchOS: nil, - tvOS: [XcodeVersion("22J357")], - visionOS: nil - ) - - XCTAssertEqual(sdks.allBuilds, ["22J357"]) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/SelectedActionTypeTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/SelectedActionTypeTests.swift deleted file mode 100644 index b954f8a2..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/SelectedActionTypeTests.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -@testable import XcodesKit - -final class SelectedActionTypeTests: XCTestCase { - func testRawValuesMatchStoredPreferenceValues() { - XCTAssertEqual(SelectedActionType.none.rawValue, "none") - XCTAssertEqual(SelectedActionType.rename.rawValue, "rename") - } - - func testDefaultDoesNothing() { - XCTAssertEqual(SelectedActionType.default, .none) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionGemTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionGemTests.swift deleted file mode 100644 index 1ec6582b..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionGemTests.swift +++ /dev/null @@ -1,13 +0,0 @@ -import XCTest -import Version -@testable import XcodesKit - -final class VersionGemTests: XCTestCase { - func testInitGemVersion() { - XCTAssertEqual(Version(gemVersion: "9.2b3"), Version("9.2.0-Beta.3")) - XCTAssertEqual(Version(gemVersion: "9.1.2"), Version("9.1.2")) - XCTAssertEqual(Version(gemVersion: "9.2"), Version("9.2.0")) - XCTAssertEqual(Version(gemVersion: "9"), Version("9.0.0")) - } -} - diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionMatchingTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionMatchingTests.swift deleted file mode 100644 index 8c29790e..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionMatchingTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -import XCTest -import Version -@testable import XcodesKit - -final class VersionMatchingTests: XCTestCase { - private struct Candidate: Equatable { - let version: Version - let name: String - } - - func testFindXcodePrefersEquivalentMatch() { - let candidates = [ - Candidate(version: Version("15.0.0+AAA")!, name: "release"), - Candidate(version: Version("15.0.0-beta+BBB")!, name: "beta") - ] - - XCTAssertEqual( - XcodeVersionMatcher.find(version: Version("15.0.0-beta")!, in: candidates, versionKeyPath: \.version), - Candidate(version: Version("15.0.0-beta+BBB")!, name: "beta") - ) - } - - func testFindXcodeFallsBackToSingleVersionMatchWithoutIdentifiers() { - let candidates = [ - Candidate(version: Version("15.0.0-rc+AAA")!, name: "rc") - ] - - XCTAssertEqual( - XcodeVersionMatcher.find(version: Version("15.0.0")!, in: candidates, versionKeyPath: \.version), - Candidate(version: Version("15.0.0-rc+AAA")!, name: "rc") - ) - } - - func testFindXcodeRejectsAmbiguousFallbackMatches() { - let candidates = [ - Candidate(version: Version("15.0.0-beta+AAA")!, name: "beta"), - Candidate(version: Version("15.0.0-rc+BBB")!, name: "rc") - ] - - XCTAssertNil(XcodeVersionMatcher.find(version: Version("15.0.0")!, in: candidates, versionKeyPath: \.version)) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionXcodeTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionXcodeTests.swift deleted file mode 100644 index 72423a3b..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/VersionXcodeTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -import XCTest -import Version -@testable import XcodesKit - -final class VersionXcodeTests: XCTestCase { - func testInitXcodeVersion() { - XCTAssertEqual(Version(xcodeVersion: "10.2"), Version(major: 10, minor: 2, patch: 0)) - XCTAssertEqual(Version(xcodeVersion: "10.2.1"), Version(major: 10, minor: 2, patch: 1)) - XCTAssertEqual(Version(xcodeVersion: "10.2 Beta 4"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["beta", "4"])) - XCTAssertEqual(Version(xcodeVersion: "10.2 GM"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm"])) - XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2"), Version(major: 10, minor: 2, patch: 0)) - XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2.1"), Version(major: 10, minor: 2, patch: 1)) - XCTAssertEqual(Version(xcodeVersion: "Xcode 11 beta"), Version(major: 11, minor: 0, patch: 0, prereleaseIdentifiers: ["beta"])) - XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 Beta 4"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["beta", "4"])) - XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 GM"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm"])) - XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 GM seed"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed"])) - XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 GM seed 1"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed", "1"])) - XCTAssertEqual(Version(xcodeVersion: "Xcode 10.2 GM seed 2"), Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed", "2"])) - XCTAssertEqual(Version(xcodeVersion: "Xcode 13.2 Release Candidate"), Version(major: 13, minor: 2, patch: 0, prereleaseIdentifiers: ["release", "candidate"])) - } - - func testAppleDescription() { - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0).appleDescription, "10.2") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 1).appleDescription, "10.2.1") - XCTAssertEqual(Version(major: 11, minor: 0, patch: 0, prereleaseIdentifiers: ["beta"]).appleDescription, "11.0 Beta") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["beta", "4"]).appleDescription, "10.2 Beta 4") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm"]).appleDescription, "10.2 GM") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed"]).appleDescription, "10.2 GM Seed") - XCTAssertEqual(Version(major: 10, minor: 2, patch: 0, prereleaseIdentifiers: ["gm-seed", "1"]).appleDescription, "10.2 GM Seed 1") - } - - func testEquivalence() { - XCTAssertTrue(Version("10.2.1")!.isEquivalent(to: Version("10.2.1+abcdef")!)) - XCTAssertFalse(Version("10.2.1-beta+qwerty")!.isEquivalent(to: Version("10.2.1-beta+abcdef")!)) - XCTAssertTrue(Version("10.2.1-beta+qwerty")!.isEquivalent(to: Version("10.2.1-beta+QWERTY")!)) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeArchiveDownloaderTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeArchiveDownloaderTests.swift deleted file mode 100644 index 86e495b4..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeArchiveDownloaderTests.swift +++ /dev/null @@ -1,14 +0,0 @@ -import XCTest -@testable import XcodesKit - -final class XcodeArchiveDownloaderTests: XCTestCase { - func testRawValuesMatchPersistedPreferenceValues() { - XCTAssertEqual(XcodeArchiveDownloader.aria2.rawValue, "aria2") - XCTAssertEqual(XcodeArchiveDownloader.urlSession.rawValue, "urlSession") - } - - func testDescriptionUsesAppDisplayNames() { - XCTAssertEqual(XcodeArchiveDownloader.aria2.description, "aria2") - XCTAssertEqual(XcodeArchiveDownloader.urlSession.description, "URLSession") - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeAutoInstallServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeAutoInstallServiceTests.swift deleted file mode 100644 index a659e44c..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeAutoInstallServiceTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -@preconcurrency import Path -import Version -import XCTest -@testable import XcodesKit - -final class XcodeAutoInstallServiceTests: XCTestCase { - func testDecisionIsDisabledWhenAutoInstallIsOff() { - let decision = XcodeAutoInstallService().decision( - autoInstallationType: .none, - xcodes: [ - xcode(version: "15.0.0", installState: .notInstalled) - ] - ) - - XCTAssertEqual(decision, .disabled) - } - - func testDecisionIsAlreadyInstalledWhenNewestXcodeIsInstalled() { - let path = Path("/Applications/Xcode-15.0.app")! - let decision = XcodeAutoInstallService().decision( - autoInstallationType: .newestVersion, - xcodes: [ - xcode(version: "15.0.0", installState: .installed(path)) - ] - ) - - XCTAssertEqual(decision, .alreadyInstalled) - } - - func testDecisionInstallsNewestBetaForNewestBetaPreference() { - let newestXcode = xcode(version: "16.0.0-beta.1", installState: .notInstalled) - let decision = XcodeAutoInstallService().decision( - autoInstallationType: .newestBeta, - xcodes: [newestXcode] - ) - - XCTAssertEqual(decision, .installNewestBeta(newestXcode.id)) - } - - func testDecisionInstallsNewestReleaseForNewestVersionPreference() { - let newestXcode = xcode(version: "15.0.0", installState: .notInstalled) - let decision = XcodeAutoInstallService().decision( - autoInstallationType: .newestVersion, - xcodes: [newestXcode] - ) - - XCTAssertEqual(decision, .installNewestVersion(newestXcode.id)) - } - - func testDecisionDoesNotInstallPrereleaseForNewestVersionPreference() { - let decision = XcodeAutoInstallService().decision( - autoInstallationType: .newestVersion, - xcodes: [ - xcode(version: "16.0.0-beta.1", installState: .notInstalled) - ] - ) - - XCTAssertEqual(decision, .noNewVersion) - } - - func testDecisionIsAlreadyInstalledWhenNoXcodesAreAvailable() { - let decision = XcodeAutoInstallService().decision( - autoInstallationType: .newestVersion, - xcodes: [] - ) - - XCTAssertEqual(decision, .alreadyInstalled) - } - - private func xcode(version: String, installState: XcodeInstallState) -> XcodeListItem { - XcodeListItem( - version: Version(version)!, - installState: installState, - selected: false - ) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeCompatibilityServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeCompatibilityServiceTests.swift deleted file mode 100644 index 8eb1398f..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeCompatibilityServiceTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import Version -import XCTest -@testable import XcodesKit - -final class XcodeCompatibilityServiceTests: XCTestCase { - func testNilRequiredVersionIsSupported() { - XCTAssertTrue( - XcodeCompatibilityService().isSupported( - requiredMacOSVersion: nil, - currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0) - ) - ) - } - - func testCurrentVersionEqualToRequiredVersionIsSupported() { - XCTAssertTrue( - XcodeCompatibilityService().isSupported( - requiredMacOSVersion: "14.1.2", - currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 1, patchVersion: 2) - ) - ) - } - - func testCurrentVersionNewerThanRequiredVersionIsSupported() { - XCTAssertTrue( - XcodeCompatibilityService().isSupported( - requiredMacOSVersion: "14.1.2", - currentOSVersion: OperatingSystemVersion(majorVersion: 15, minorVersion: 0, patchVersion: 0) - ) - ) - } - - func testCurrentVersionOlderThanRequiredVersionIsUnsupported() { - XCTAssertTrue( - XcodeCompatibilityService().isUnsupported( - requiredMacOSVersion: "14.1.2", - currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 1, patchVersion: 1) - ) - ) - } - - func testStatusIncludesRequiredAndCurrentVersionsWhenUnsupported() { - XCTAssertEqual( - XcodeCompatibilityService().status( - requiredMacOSVersion: "14.1.2", - currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 1, patchVersion: 1) - ), - .unsupported(requiredMacOSVersion: "14.1.2", currentMacOSVersion: "14.1.1") - ) - } - - func testStatusForXcodeUsesRequiredMacOSVersion() { - let xcode = AvailableXcode( - version: Version("16.0.0")!, - url: URL(fileURLWithPath: "/Xcode.xip"), - filename: "Xcode.xip", - releaseDate: nil, - requiredMacOSVersion: "15.0" - ) - - XCTAssertEqual( - XcodeCompatibilityService().status( - for: xcode, - currentOSVersion: OperatingSystemVersion(majorVersion: 14, minorVersion: 6, patchVersion: 0) - ), - .unsupported(requiredMacOSVersion: "15.0", currentMacOSVersion: "14.6.0") - ) - } - - func testMissingMinorAndPatchDefaultToZero() { - let version = XcodeCompatibilityService().operatingSystemVersion(from: "14") - - XCTAssertEqual(version.majorVersion, 14) - XCTAssertEqual(version.minorVersion, 0) - XCTAssertEqual(version.patchVersion, 0) - } - - func testInvalidVersionComponentsDefaultToZero() { - let version = XcodeCompatibilityService().operatingSystemVersion(from: "14.beta.2") - - XCTAssertEqual(version.majorVersion, 14) - XCTAssertEqual(version.minorVersion, 2) - XCTAssertEqual(version.patchVersion, 0) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListGroupingTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListGroupingTests.swift deleted file mode 100644 index 001324d6..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListGroupingTests.swift +++ /dev/null @@ -1,128 +0,0 @@ -import XCTest -@preconcurrency import Path -import Version -@testable import XcodesKit - -final class XcodeListGroupingTests: XCTestCase { - func testGroupsVersionsByMajorAndMinorVersionDescending() throws { - let items = try [ - item("15.0.1"), - item("16.1.0"), - item("16.0.0"), - item("16.1.1"), - item("15.2.0") - ] - - let groups = items.groupedByMajorVersion() - - XCTAssertEqual(groups.map(\.majorVersion), [16, 15]) - XCTAssertEqual(groups[0].minorVersionGroups.map(\.displayName), ["16.1", "16.0"]) - XCTAssertEqual(groups[0].minorVersionGroups[0].versions.map(\.version), [ - try XCTUnwrap(Version("16.1.1")), - try XCTUnwrap(Version("16.1.0")) - ]) - XCTAssertEqual(groups[1].minorVersionGroups.map(\.displayName), ["15.2", "15.0"]) - } - - func testGroupRollupsUseLatestReleaseAndInstallationState() throws { - let installedPath = try XCTUnwrap(Path("/Applications/Xcode-16.0.app")) - let items = try [ - item("16.1.0-Beta", installState: .notInstalled), - item("16.0.1", installState: .notInstalled), - item("16.0.0", installState: .installed(installedPath), selected: true) - ] - - let group = try XCTUnwrap(items.groupedByMajorVersion().first) - let minorGroup = try XCTUnwrap(group.minorVersionGroups.first { $0.minorVersion == 0 }) - - XCTAssertEqual(group.latestRelease?.version, try XCTUnwrap(Version("16.0.1"))) - XCTAssertEqual(minorGroup.latestRelease?.version, try XCTUnwrap(Version("16.0.1"))) - XCTAssertTrue(group.hasInstalled) - XCTAssertTrue(minorGroup.hasInstalled) - XCTAssertEqual(group.selectedVersion?.version, try XCTUnwrap(Version("16.0.0"))) - XCTAssertEqual(minorGroup.selectedVersion?.version, try XCTUnwrap(Version("16.0.0"))) - } - - func testGroupRollupsTrackInstallingVersions() throws { - let progress = Progress(totalUnitCount: 100) - let items = try [ - item("16.0.0", installState: .installing(.downloading(progress: progress))), - item("16.0.1") - ] - - let group = try XCTUnwrap(items.groupedByMajorVersion().first) - let minorGroup = try XCTUnwrap(group.minorVersionGroups.first) - - XCTAssertTrue(group.hasInstalling) - XCTAssertTrue(minorGroup.hasInstalling) - } - - func testAppliesVersionArchitectureSearchAndInstalledFilters() throws { - let installedPath = try XCTUnwrap(Path("/Applications/Xcode-16.0.app")) - let items = try [ - item("16.1.0-Beta", architectures: [.arm64]), - item("16.0.0", installState: .installed(installedPath), architectures: [.arm64]), - item("15.0.0", architectures: [.arm64, .x86_64]) - ] - - let filtered = items.applying(XcodeListFilters( - versionFilter: .release, - architectureFilters: [.variant(.appleSilicon)], - searchText: "16", - installedOnly: true - )) - - XCTAssertEqual(filtered.map(\.version), [try XCTUnwrap(Version("16.0.0"))]) - } - - func testAllowedMajorVersionsFilterKeepsInstalledOlderVersions() throws { - let installedPath = try XCTUnwrap(Path("/Applications/Xcode-14.0.app")) - let items = try [ - item("16.0.0"), - item("15.0.0"), - item("14.0.0", installState: .installed(installedPath)), - item("13.0.0") - ] - - let filtered = items.filteringUninstalledVersions(allowedMajorVersions: 1) - - XCTAssertEqual(filtered.map(\.version), [ - try XCTUnwrap(Version("16.0.0")), - try XCTUnwrap(Version("15.0.0")), - try XCTUnwrap(Version("14.0.0")) - ]) - } - - func testGenericFilteringAndGroupingPreservesDuplicateIDs() throws { - let items = try [ - PositionedXcodeListItem(position: 0, item: item("3.2.3+10M2262")), - PositionedXcodeListItem(position: 1, item: item("3.2.3+10M2262")) - ] - - let filtered = items.applying(XcodeListFilters(searchText: "3.2.3"), item: \.item) - let groups = filtered.groupedByMajorVersion(item: \.item) - - XCTAssertEqual(filtered.map(\.position), [0, 1]) - XCTAssertEqual(groups.count, 1) - XCTAssertEqual(groups.first?.versions.map(\.position), [0, 1]) - } - - private func item( - _ version: String, - installState: XcodeInstallState = .notInstalled, - selected: Bool = false, - architectures: [Architecture]? = nil - ) throws -> XcodeListItem { - XcodeListItem( - version: try XCTUnwrap(Version(version)), - installState: installState, - selected: selected, - architectures: architectures - ) - } -} - -private struct PositionedXcodeListItem { - let position: Int - let item: XcodeListItem -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListItemTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListItemTests.swift deleted file mode 100644 index bb6d6758..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListItemTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import XCTest -@preconcurrency import Path -import Version -@testable import XcodesKit - -final class XcodeListItemTests: XCTestCase { - func testInstalledPathReturnsPathFromInstallState() throws { - let path = try XCTUnwrap(Path("/Applications/Xcode.app")) - let item = XcodeListItem( - version: try XCTUnwrap(Version("15.0.0")), - installState: .installed(path), - selected: true - ) - - XCTAssertEqual(item.installedPath, path) - XCTAssertEqual(item.installState.installedPath, path) - } - - func testInstalledPathReturnsNilWhenNotInstalled() throws { - let item = XcodeListItem( - version: try XCTUnwrap(Version("15.0.0")), - installState: .notInstalled, - selected: false - ) - - XCTAssertNil(item.installedPath) - XCTAssertNil(item.installState.installedPath) - } - - func testDownloadFileSizeStringFormatsFileSize() throws { - let item = XcodeListItem( - version: try XCTUnwrap(Version("15.0.0")), - installState: .notInstalled, - selected: false, - downloadFileSize: 1_500_000_000 - ) - - XCTAssertEqual( - item.downloadFileSizeString, - ByteCountFormatter.string(fromByteCount: 1_500_000_000, countStyle: .file) - ) - } - - func testDownloadFileSizeStringReturnsNilWhenMissing() throws { - let item = XcodeListItem( - version: try XCTUnwrap(Version("15.0.0")), - installState: .notInstalled, - selected: false - ) - - XCTAssertNil(item.downloadFileSizeString) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListStoreTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListStoreTests.swift deleted file mode 100644 index bc7d39e9..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeListStoreTests.swift +++ /dev/null @@ -1,104 +0,0 @@ -import XCTest -@preconcurrency import Path -import Version -import os -@testable import XcodesKit - -final class XcodeListStoreTests: XCTestCase { - func testLoadsCachedXcodesAndUsesCacheDateForUpdatePolicy() throws { - let xcode = try makeXcode(version: "15.0.0") - let cacheDate = Date(timeIntervalSince1970: 1_000) - let cache = AvailableXcodeCache( - cacheFile: try XCTUnwrap(Path("/tmp/xcodes.json")), - contentsAtPath: { _ in try? JSONEncoder().encode([xcode]) }, - attributesOfItem: { _ in [.modificationDate: cacheDate] } - ) - var store = XcodeListStore( - cache: cache, - fetchAvailableXcodes: { _ in [] }, - updatePolicy: XcodeUpdatePolicy(now: { cacheDate.addingTimeInterval(60) }) - ) - - try store.loadCachedAvailableXcodes() - - XCTAssertEqual(store.availableXcodes, [xcode]) - XCTAssertEqual(store.lastUpdated, cacheDate) - XCTAssertFalse(store.shouldUpdateBeforeListingVersions) - } - - func testMissingCacheNeedsUpdate() throws { - let cache = AvailableXcodeCache( - cacheFile: try XCTUnwrap(Path("/tmp/xcodes.json")), - contentsAtPath: { _ in nil } - ) - var store = XcodeListStore(cache: cache, fetchAvailableXcodes: { _ in [] }) - - try store.loadCachedAvailableXcodes() - - XCTAssertTrue(store.shouldUpdateBeforeListingVersions) - } - - func testUpdateFetchesPostprocessesAndSavesXcodes() async throws { - let release = try makeRelease(version: "15.0.0-beta.1+15A1", architectures: nil) - let finalRelease = try makeRelease(version: "15.0.0+15A1", architectures: nil) - let expected = try makeXcode(version: "15.0.0+15A1") - let updateDate = Date(timeIntervalSince1970: 2_000) - let savedXcodes = XcodeCacheSaveRecorder() - let cache = AvailableXcodeCache( - cacheFile: try XCTUnwrap(Path("/tmp/xcodes.json")), - contentsAtPath: { _ in nil }, - writeData: { data, _ in - try savedXcodes.record(data) - }, - createDirectory: { _, _, _ in } - ) - var store = XcodeListStore( - cache: cache, - fetchAvailableXcodes: { dataSource in - XCTAssertEqual(dataSource, .xcodeReleases) - return [release, finalRelease] - }, - now: { updateDate } - ) - - let xcodes = try await store.updateAvailableXcodes(from: .xcodeReleases) - - XCTAssertEqual(xcodes, [expected]) - XCTAssertEqual(store.availableXcodes, [expected]) - XCTAssertEqual(store.lastUpdated, updateDate) - XCTAssertEqual(savedXcodes.value, [expected]) - } - - private func makeXcode(version: String, architectures: [Architecture]? = nil) throws -> AvailableXcode { - AvailableXcode( - version: try XCTUnwrap(Version(version)), - url: try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")), - filename: "Xcode.xip", - releaseDate: nil, - architectures: architectures - ) - } - - private func makeRelease(version: String, architectures: [Architecture]?) throws -> AvailableXcodeRelease { - AvailableXcodeRelease( - version: try XCTUnwrap(Version(version)), - url: try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")), - filename: "Xcode.xip", - releaseDate: nil, - architectures: architectures - ) - } -} - -private final class XcodeCacheSaveRecorder: Sendable { - private let storedValue = OSAllocatedUnfairLock<[AvailableXcode]?>(initialState: nil) - - var value: [AvailableXcode]? { - storedValue.withLock { $0 } - } - - func record(_ data: Data) throws { - let value = try JSONDecoder().decode([AvailableXcode].self, from: data) - storedValue.withLock { $0 = value } - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodePostInstallWorkflowServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodePostInstallWorkflowServiceTests.swift deleted file mode 100644 index ba3557e6..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodePostInstallWorkflowServiceTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -@preconcurrency import Path -import Version -import XCTest -@testable import XcodesKit - -final class XcodePostInstallWorkflowServiceTests: XCTestCase { - func testPerformPostInstallStepsRunsSharedSequence() async throws { - let xcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!, version: Version("0.0.0")!) - let recorder = StepRecorder() - - let service = XcodePostInstallWorkflowService( - preparationService: XcodePostInstallPreparationService( - enableDeveloperTools: { await recorder.append("enableDeveloperTools") }, - addStaffToDevelopersGroup: { await recorder.append("addStaffToDevelopersGroup") }, - acceptLicense: { receivedXcode in - await recorder.append("acceptLicense", receivedPath: receivedXcode.path) - } - ), - postInstallService: XcodePostInstallService( - runFirstLaunch: { receivedXcode in - await recorder.append("runFirstLaunch", receivedPath: receivedXcode.path) - }, - getUserCacheDirectory: { - await recorder.append("getUserCacheDirectory") - return ProcessOutput(status: 0, out: "cache", err: "") - }, - getMacOSBuildVersion: { - await recorder.append("getMacOSBuildVersion") - return ProcessOutput(status: 0, out: "macOS", err: "") - }, - getXcodeBuildVersion: { receivedXcode in - await recorder.append("getXcodeBuildVersion", receivedPath: receivedXcode.path) - return ProcessOutput(status: 0, out: "tools", err: "") - }, - touchInstallCheck: { cacheDirectory, macOSBuildVersion, toolsVersion in - XCTAssertEqual(cacheDirectory, "cache") - XCTAssertEqual(macOSBuildVersion, "macOS") - XCTAssertEqual(toolsVersion, "tools") - await recorder.append("touchInstallCheck") - return ProcessOutput(status: 0, out: "", err: "") - } - ) - ) - - try await service.performPostInstallSteps(for: xcode) - - let steps = await recorder.steps - XCTAssertEqual(Array(steps.prefix(3)), [ - "enableDeveloperTools", - "addStaffToDevelopersGroup", - "acceptLicense", - ]) - XCTAssertEqual(steps.last, "touchInstallCheck") - XCTAssertTrue(steps.contains("runFirstLaunch")) - XCTAssertTrue(steps.contains("getUserCacheDirectory")) - XCTAssertTrue(steps.contains("getMacOSBuildVersion")) - XCTAssertTrue(steps.contains("getXcodeBuildVersion")) - let receivedPaths = await recorder.receivedPaths - XCTAssertEqual(receivedPaths, [xcode.path, xcode.path, xcode.path]) - } - - func testPostInstallServiceStopsAfterFirstLaunchWhenCancelled() async throws { - let xcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!, version: Version("0.0.0")!) - let recorder = StepRecorder() - let firstLaunchContinuation = OneShotContinuation() - let service = XcodePostInstallService( - runFirstLaunch: { _ in - await recorder.append("runFirstLaunch") - try await withCheckedThrowingContinuation { continuation in - firstLaunchContinuation.setContinuation(continuation) - } - }, - getUserCacheDirectory: { - await recorder.append("getUserCacheDirectory") - return ProcessOutput(status: 0, out: "cache", err: "") - }, - getMacOSBuildVersion: { - await recorder.append("getMacOSBuildVersion") - return ProcessOutput(status: 0, out: "macOS", err: "") - }, - getXcodeBuildVersion: { _ in - await recorder.append("getXcodeBuildVersion") - return ProcessOutput(status: 0, out: "tools", err: "") - }, - touchInstallCheck: { _, _, _ in - await recorder.append("touchInstallCheck") - return ProcessOutput(status: 0, out: "", err: "") - } - ) - - let task = Task { - try await service.installComponents(for: xcode) - } - for _ in 0..<100 where await recorder.steps.isEmpty { - await Task.yield() - } - task.cancel() - firstLaunchContinuation.resume() - - do { - try await task.value - XCTFail("Expected cancellation") - } catch is CancellationError { - } - - let steps = await recorder.steps - XCTAssertEqual(steps, ["runFirstLaunch"]) - } -} - -private actor StepRecorder { - private(set) var steps = [String]() - private(set) var receivedPaths = [Path]() - - func append(_ step: String, receivedPath: Path? = nil) { - steps.append(step) - if let receivedPath { - receivedPaths.append(receivedPath) - } - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeVersionFileServiceTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeVersionFileServiceTests.swift deleted file mode 100644 index 30b0154f..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodeVersionFileServiceTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -@preconcurrency import Path -import Version -import XCTest -@testable import XcodesKit - -final class XcodeVersionFileServiceTests: XCTestCase { - private let projectPath = Path("/tmp/project")! - - func testVersionParsesGemVersionFile() { - let service = XcodeVersionFileService( - fileExists: { path in path.hasSuffix(".xcode-version") }, - contentsAtPath: { _ in "9.2b3".data(using: .utf8) } - ) - - XCTAssertEqual( - service.version(inDirectory: projectPath), - Version("9.2.0-Beta.3") - ) - } - - func testVersionReturnsNilWhenFileDoesNotExist() { - let service = XcodeVersionFileService( - fileExists: { _ in false }, - contentsAtPath: { _ in XCTFail("Should not read a missing file"); return nil } - ) - - XCTAssertNil(service.version(inDirectory: projectPath)) - } - - func testVersionReturnsNilWhenFileContentsAreInvalid() { - let service = XcodeVersionFileService( - fileExists: { _ in true }, - contentsAtPath: { _ in Data([0xff]) } - ) - - XCTAssertNil(service.version(inDirectory: projectPath)) - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift deleted file mode 100644 index b8c81b34..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift +++ /dev/null @@ -1,2323 +0,0 @@ -import XCTest -@preconcurrency import Path -import Version -import os -@testable import XcodesKit - -final class XcodesKitTests: XCTestCase { - func testOneShotContinuationReturnsResultCompletedBeforeContinuationIsSet() async throws { - let oneShot = OneShotContinuation() - - oneShot.resume(with: .success("ready")) - let value = try await withCheckedThrowingContinuation { continuation in - oneShot.setContinuation(continuation) - } - - XCTAssertEqual(value, "ready") - } - - func testOneShotContinuationIgnoresRepeatedResume() async throws { - let oneShot = OneShotContinuation() - - oneShot.resume(with: .success("first")) - oneShot.resume(with: .success("second")) - let value = try await withCheckedThrowingContinuation { continuation in - oneShot.setContinuation(continuation) - } - - XCTAssertEqual(value, "first") - } - - func testXcodeInstallRetryServiceRetriesDamagedArchiveOnce() async throws { - let damagedURL = URL(fileURLWithPath: "/tmp/Xcode.xip") - let attempts = RetryRecorder() - let failures = RetryRecorder() - let retries = RetryRecorder() - let removals = URLListRecorder() - let installedXcode = InstalledXcode( - path: try XCTUnwrap(Path("/Applications/Xcode.app")), - version: try XCTUnwrap(Version("15.0.0")) - ) - let service = XcodeInstallRetryService( - damagedArchiveURL: { error in - guard case XcodeInstallRetryTestError.damagedArchive(let url) = error else { return nil } - return url - }, - removeDamagedArchive: { url in - removals.record(url) - } - ) - - let result = try await service.install( - attempt: { _ in - let attempt = await attempts.record(1) - if attempt == 1 { - throw XcodeInstallRetryTestError.damagedArchive(damagedURL) - } - return installedXcode - }, - onAttemptFailed: { error in - await failures.record(String(describing: error)) - }, - onRetryDamagedArchive: { _, url in - await retries.record(url) - } - ) - - let attemptCount = await attempts.count - let failureCount = await failures.count - let retriedURLs = await retries.values - - XCTAssertEqual(result, installedXcode) - XCTAssertEqual(attemptCount, 2) - XCTAssertEqual(failureCount, 1) - XCTAssertEqual(retriedURLs, [damagedURL]) - XCTAssertEqual(removals.paths, [damagedURL.path]) - } - - func testXcodeInstallRetryServiceDoesNotRetryWhenDisabled() async throws { - let damagedURL = URL(fileURLWithPath: "/tmp/Xcode.xip") - let attempts = RetryRecorder() - let removals = URLListRecorder() - let service = XcodeInstallRetryService( - damagedArchiveURL: { error in - guard case XcodeInstallRetryTestError.damagedArchive(let url) = error else { return nil } - return url - }, - removeDamagedArchive: { url in - removals.record(url) - } - ) - - do { - _ = try await service.install( - shouldRetryAfterDamagedArchive: false, - attempt: { _ in - await attempts.record(1) - throw XcodeInstallRetryTestError.damagedArchive(damagedURL) - } - ) - XCTFail("Expected damaged archive error") - } catch XcodeInstallRetryTestError.damagedArchive(let url) { - XCTAssertEqual(url, damagedURL) - } - - let attemptCount = await attempts.count - - XCTAssertEqual(attemptCount, 1) - XCTAssertEqual(removals.paths, []) - } - - func testXcodeSignatureVerifierParsesCertificateInfo() { - let sampleRawInfo = """ - Executable=/Applications/Xcode-10.1.app/Contents/MacOS/Xcode - Identifier=com.apple.dt.Xcode - Format=app bundle with Mach-O thin (x86_64) - CodeDirectory v=20200 size=434 flags=0x2000(library-validation) hashes=6+5 location=embedded - Signature size=4485 - Authority=Software Signing - Authority=Apple Code Signing Certification Authority - Authority=Apple Root CA - Info.plist entries=39 - TeamIdentifier=59GAB85EFG - Sealed Resources version=2 rules=13 files=253327 - Internal requirements count=1 size=68 - """ - - let signature = XcodeSignatureVerifier().parse(sampleRawInfo) - - XCTAssertEqual(signature.authority, XcodeSignatureVerifier.expectedCertificateAuthority) - XCTAssertEqual(signature.teamIdentifier, XcodeSignatureVerifier.expectedTeamIdentifier) - XCTAssertEqual(signature.bundleIdentifier, "com.apple.dt.Xcode") - } - - func testXcodeSignatureVerifierValidatesExpectedAppleSignature() { - let signature = XcodeSignature( - authority: XcodeSignatureVerifier.expectedCertificateAuthority, - teamIdentifier: XcodeSignatureVerifier.expectedTeamIdentifier, - bundleIdentifier: "com.apple.dt.Xcode" - ) - - XCTAssertTrue(XcodeSignatureVerifier().isValid(signature)) - } - - func testXcodeSignatureVerifierRejectsUnexpectedAppleSignature() { - let signature = XcodeSignature( - authority: XcodeSignatureVerifier.expectedCertificateAuthority, - teamIdentifier: "NOTAPPLE", - bundleIdentifier: "com.apple.dt.Xcode" - ) - - XCTAssertFalse(XcodeSignatureVerifier().isValid(signature)) - } - - func testXcodeValidationServiceMapsSecurityAssessmentProcessOutput() async throws { - let xcode = InstalledXcode( - path: try XCTUnwrap(Path("/Applications/Xcode.app")), - version: try XCTUnwrap(Version("15.0.0")) - ) - let service = XcodeValidationService( - assessSecurity: { _ in - throw ProcessExecutionError( - process: Process(), - terminationStatus: 1, - standardOutput: "assessment stdout", - standardError: "assessment stderr" - ) - }, - verifyCodesign: { _ in (0, "", "") } - ) - - do { - try await service.verifySecurityAssessment(of: xcode) - XCTFail("Expected validation to throw") - } catch let error as XcodeValidationError { - XCTAssertEqual(error, .failedSecurityAssessment(xcode: xcode, output: "assessment stdout\nassessment stderr")) - } - } - - func testXcodeValidationServiceMapsCodesignProcessOutput() async throws { - let service = XcodeValidationService( - assessSecurity: { _ in (0, "", "") }, - verifyCodesign: { _ in - throw ProcessExecutionError( - process: Process(), - terminationStatus: 1, - standardOutput: "", - standardError: "codesign stderr" - ) - } - ) - - do { - try await service.verifySigningCertificate(of: URL(fileURLWithPath: "/Applications/Xcode.app")) - XCTFail("Expected validation to throw") - } catch let error as XcodeValidationError { - XCTAssertEqual(error, .codesignVerifyFailed(output: "codesign stderr")) - } - } - - func testXcodeValidationServiceRejectsUnexpectedSigningIdentity() async throws { - let service = XcodeValidationService( - assessSecurity: { _ in (0, "", "") }, - verifyCodesign: { _ in - (0, "", """ - Identifier=com.apple.dt.Xcode - Authority=Software Signing - Authority=Apple Code Signing Certification Authority - Authority=Apple Root CA - TeamIdentifier=NOTAPPLE - """) - } - ) - - do { - try await service.verifySigningCertificate(of: URL(fileURLWithPath: "/Applications/Xcode.app")) - XCTFail("Expected validation to throw") - } catch let error as XcodeValidationError { - XCTAssertEqual(error, .unexpectedCodeSigningIdentity( - identifier: "NOTAPPLE", - certificateAuthority: XcodeSignatureVerifier.expectedCertificateAuthority - )) - } - } - - func testXcodeArchiveInstallServiceInstallsXIPAndValidatesXcode() async throws { - let archiveURL = URL(fileURLWithPath: "/tmp/Xcode.xip") - let destinationDirectory = try XCTUnwrap(Path("/Applications")) - let destinationPath = try XCTUnwrap(Path("/Applications/Xcode-15.0.0.app")) - let recorder = PathOperationRecorder() - let stepRecorder = XcodeArchiveInstallStepRecorder() - let xcode = AvailableXcode( - version: try XCTUnwrap(Version("15.0.0")), - url: archiveURL, - filename: "Xcode.xip", - releaseDate: nil - ) - - let service = XcodeArchiveInstallService( - destinationDirectory: destinationDirectory, - unarchiveService: XcodeUnarchiveService( - unarchive: { url in - XCTAssertEqual(url, archiveURL) - recorder.record("unarchive") - }, - fileExists: { path in - path == "/tmp/Xcode.app" || path == destinationPath.string - }, - moveItem: { source, destination in - XCTAssertEqual(source.path, "/tmp/Xcode.app") - XCTAssertEqual(destination.path, destinationPath.url.path) - recorder.record("move") - }, - removeItem: { _ in } - ), - validationService: XcodeValidationService( - assessSecurity: { url in - XCTAssertEqual(url.path, destinationPath.url.path) - return (0, "", "") - }, - verifyCodesign: { url in - XCTAssertEqual(url.path, destinationPath.url.path) - return (0, "", """ - Identifier=com.apple.dt.Xcode - Authority=Software Signing - Authority=Apple Code Signing Certification Authority - Authority=Apple Root CA - TeamIdentifier=59GAB85EFG - """) - } - ), - fileExists: { path in path == destinationPath.string }, - makeInstalledXcode: { path in - XCTAssertEqual(path, destinationPath) - return InstalledXcode(path: path, version: Version("15.0.0")!) - } - ) - - let installedXcode = try await service.installArchivedXcode( - xcode, - at: archiveURL, - cleanArchive: { url in - XCTAssertEqual(url, archiveURL) - recorder.record("clean") - }, - stepChanged: { step in - await stepRecorder.record(step) - } - ) - - XCTAssertEqual(installedXcode.path, destinationPath) - XCTAssertEqual(recorder.operations, ["unarchive", "move", "clean"]) - let steps = await stepRecorder.recordedSteps() - XCTAssertEqual(steps, [ - .unarchive(.unarchiving), - .unarchive(.moving(destination: destinationPath.url.path)), - .cleaningArchive(archiveName: "Xcode.xip"), - .checkingSecurity - ]) - } - - func testXcodeArchiveInstallServiceRejectsUnsupportedArchives() async throws { - let service = XcodeArchiveInstallService( - destinationDirectory: try XCTUnwrap(Path("/Applications")), - unarchiveService: XcodeUnarchiveService( - unarchive: { _ in XCTFail("Unsupported archives should not be unarchived") }, - fileExists: { _ in false }, - moveItem: { _, _ in }, - removeItem: { _ in } - ), - validationService: XcodeValidationService( - assessSecurity: { _ in (0, "", "") }, - verifyCodesign: { _ in (0, "", "") } - ), - fileExists: { _ in false }, - makeInstalledXcode: { _ in nil } - ) - let xcode = AvailableXcode( - version: try XCTUnwrap(Version("15.0.0")), - url: URL(fileURLWithPath: "/tmp/Xcode.dmg"), - filename: "Xcode.dmg", - releaseDate: nil - ) - - do { - _ = try await service.installArchivedXcode( - xcode, - at: URL(fileURLWithPath: "/tmp/Xcode.dmg"), - cleanArchive: { _ in XCTFail("Unsupported archives should not be cleaned") } - ) - XCTFail("Expected unsupported archive to throw") - } catch let error as XcodeArchiveInstallError { - XCTAssertEqual(error, .unsupportedFileFormat(extension: "dmg")) - } - } - - func testXcodeUpdatePolicyUsesFiveHourFreshnessWindow() { - let now = Date(timeIntervalSince1970: 10_000) - let policy = XcodeUpdatePolicy(now: { now }) - let cachedXcodes = [ - AvailableXcode( - version: Version("15.0.0")!, - url: URL(fileURLWithPath: "/tmp/Xcode.xip"), - filename: "Xcode.xip", - releaseDate: nil - ) - ] - - XCTAssertFalse(policy.shouldUpdate( - cachedXcodes: cachedXcodes, - lastUpdated: now.addingTimeInterval(-XcodeUpdatePolicy.defaultMaximumCacheAge + 1) - )) - XCTAssertTrue(policy.shouldUpdate( - cachedXcodes: cachedXcodes, - lastUpdated: now.addingTimeInterval(-XcodeUpdatePolicy.defaultMaximumCacheAge - 1) - )) - } - - func testXcodeUpdatePolicyUpdatesWhenCacheIsEmptyOrMissingDate() { - let policy = XcodeUpdatePolicy(now: { Date(timeIntervalSince1970: 10_000) }) - let cachedXcodes = [ - AvailableXcode( - version: Version("15.0.0")!, - url: URL(fileURLWithPath: "/tmp/Xcode.xip"), - filename: "Xcode.xip", - releaseDate: nil - ) - ] - - XCTAssertTrue(policy.shouldUpdate(cachedXcodes: [], lastUpdated: Date())) - XCTAssertTrue(policy.shouldUpdate(cachedXcodes: cachedXcodes, lastUpdated: nil)) - } - - func testXcodePostInstallServiceRunsFirstLaunchAndTouchesInstallCheck() async throws { - let xcode = InstalledXcode( - path: try XCTUnwrap(Path("/Applications/Xcode.app")), - version: try XCTUnwrap(Version("15.0.0+15A1")) - ) - let recorder = XcodePostInstallRecorder() - let service = XcodePostInstallService( - runFirstLaunch: { receivedXcode in - XCTAssertEqual(receivedXcode, xcode) - await recorder.recordFirstLaunch() - }, - getUserCacheDirectory: { (0, "/tmp/cache/", "") }, - getMacOSBuildVersion: { (0, "23A344", "") }, - getXcodeBuildVersion: { receivedXcode in - XCTAssertEqual(receivedXcode, xcode) - return (0, "15A1", "") - }, - touchInstallCheck: { cacheDirectory, macOSBuildVersion, toolsVersion in - await recorder.recordInstallCheck( - cacheDirectory: cacheDirectory, - macOSBuildVersion: macOSBuildVersion, - toolsVersion: toolsVersion - ) - return (0, "", "") - } - ) - - try await service.installComponents(for: xcode) - - let didRunFirstLaunch = await recorder.didRunFirstLaunch - let touchedInstallCheck = await recorder.touchedInstallCheck - XCTAssertTrue(didRunFirstLaunch) - XCTAssertEqual(touchedInstallCheck?.cacheDirectory, "/tmp/cache/") - XCTAssertEqual(touchedInstallCheck?.macOSBuildVersion, "23A344") - XCTAssertEqual(touchedInstallCheck?.toolsVersion, "15A1") - } - - func testXcodePostInstallPreparationServiceEnablesDeveloperModeAndApprovesLicense() async throws { - let xcode = InstalledXcode( - path: try XCTUnwrap(Path("/Applications/Xcode.app")), - version: try XCTUnwrap(Version("15.0.0")) - ) - let recorder = XcodePostInstallPreparationRecorder() - let service = XcodePostInstallPreparationService( - enableDeveloperTools: { - await recorder.record(.enableDeveloperTools) - }, - addStaffToDevelopersGroup: { - await recorder.record(.addStaffToDevelopersGroup) - }, - acceptLicense: { receivedXcode in - XCTAssertEqual(receivedXcode, xcode) - await recorder.record(.acceptLicense) - } - ) - - try await service.enableDeveloperMode() - try await service.approveLicense(for: xcode) - - let events = await recorder.events - XCTAssertEqual(events, [ - .enableDeveloperTools, - .addStaffToDevelopersGroup, - .acceptLicense - ]) - } - - func testXcodeUninstallServiceMovesXcodeToTrash() throws { - let xcode = InstalledXcode( - path: try XCTUnwrap(Path("/Applications/Xcode.app")), - version: try XCTUnwrap(Version("15.0.0")) - ) - let recorder = URLRecorder() - let service = XcodeUninstallService( - removeItem: { _ in XCTFail("Remove should not be called") }, - trashItem: { url in - recorder.record(url) - return URL(fileURLWithPath: "/Users/test/.Trash/Xcode.app") - } - ) - - let result = try service.uninstall(xcode, emptyTrash: false) - - XCTAssertEqual(recorder.url, xcode.path.url) - XCTAssertEqual(result.xcode, xcode) - XCTAssertEqual(result.trashURL?.path, "/Users/test/.Trash/Xcode.app") - XCTAssertFalse(result.didDeleteImmediately) - } - - func testXcodeUninstallServiceDeletesXcodeImmediately() throws { - let xcode = InstalledXcode( - path: try XCTUnwrap(Path("/Applications/Xcode.app")), - version: try XCTUnwrap(Version("15.0.0")) - ) - let recorder = URLRecorder() - let service = XcodeUninstallService( - removeItem: { url in recorder.record(url) }, - trashItem: { _ in - XCTFail("Trash should not be called") - return URL(fileURLWithPath: "/Users/test/.Trash/Xcode.app") - } - ) - - let result = try service.uninstall(xcode, emptyTrash: true) - - XCTAssertEqual(recorder.url, xcode.path.url) - XCTAssertEqual(result.xcode, xcode) - XCTAssertNil(result.trashURL) - XCTAssertTrue(result.didDeleteImmediately) - } - - func testXcodeSelectionFilesystemServiceCreatesSymlink() throws { - let recorder = PathOperationRecorder() - let service = XcodeSelectionFilesystemService( - fileExists: { _ in false }, - attributesOfItem: { _ in [:] }, - removeItem: { _ in XCTFail("Remove should not be called") }, - createSymbolicLink: { destination, source in - recorder.record("link:\(destination)->\(source)") - }, - installedXcode: { _ in nil } - ) - - let result = try service.createSymbolicLink( - to: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), - in: try XCTUnwrap(Path("/Applications")) - ) - - XCTAssertEqual(result.destinationPath.string, "/Applications/Xcode.app") - XCTAssertFalse(result.replacedExistingSymlink) - XCTAssertEqual(recorder.operations, ["link:/Applications/Xcode.app->/Applications/Xcode-15.0.app"]) - } - - func testXcodeSelectionFilesystemServiceReplacesExistingSymlink() throws { - let recorder = PathOperationRecorder() - let service = XcodeSelectionFilesystemService( - fileExists: { _ in true }, - attributesOfItem: { _ in [.type: FileAttributeType.typeSymbolicLink] }, - removeItem: { path in recorder.record("remove:\(path)") }, - createSymbolicLink: { destination, source in - recorder.record("link:\(destination)->\(source)") - }, - installedXcode: { _ in nil } - ) - - let result = try service.createSymbolicLink( - to: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), - in: try XCTUnwrap(Path("/Applications")), - isBeta: true - ) - - XCTAssertEqual(result.destinationPath.string, "/Applications/Xcode-Beta.app") - XCTAssertTrue(result.replacedExistingSymlink) - XCTAssertEqual(recorder.operations, [ - "remove:/Applications/Xcode-Beta.app", - "link:/Applications/Xcode-Beta.app->/Applications/Xcode-15.0.app" - ]) - } - - func testXcodeSelectionFilesystemServiceRejectsReplacingRealAppBundleWithSymlink() throws { - let service = XcodeSelectionFilesystemService( - fileExists: { _ in true }, - attributesOfItem: { _ in [.type: FileAttributeType.typeDirectory] }, - removeItem: { _ in XCTFail("Remove should not be called") }, - createSymbolicLink: { _, _ in XCTFail("Link should not be called") }, - installedXcode: { _ in nil } - ) - let expectedPath = try XCTUnwrap(Path("/Applications/Xcode.app")) - - XCTAssertThrowsError(try service.createSymbolicLink( - to: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), - in: try XCTUnwrap(Path("/Applications")) - )) { error in - XCTAssertEqual( - error as? XcodeSelectionFilesystemError, - .destinationExistsAndIsNotSymlink(expectedPath) - ) - } - } - - func testXcodeSelectionFilesystemServiceRenamesExistingXcodeAppBeforeSelectionRename() throws { - let recorder = PathOperationRecorder() - let destinationPath = try XCTUnwrap(Path("/Applications/Xcode.app")) - let selectedPath = try XCTUnwrap(Path("/Applications/Xcode-15.1.app")) - let service = XcodeSelectionFilesystemService( - fileExists: { $0 == destinationPath.string }, - installedXcode: { path in - XCTAssertEqual(path, destinationPath) - return InstalledXcode( - path: path, - version: try! XCTUnwrap(Version("14.3.1")) - ) - }, - rename: { path, newName in - recorder.record("rename:\(path.string)->\(newName)") - return path.parent/newName - } - ) - - let renamedPath = try service.renameForSelection( - installedXcodePath: selectedPath, - in: try XCTUnwrap(Path("/Applications")) - ) - - XCTAssertEqual(renamedPath.string, "/Applications/Xcode.app") - XCTAssertEqual(recorder.operations, [ - "rename:/Applications/Xcode.app->Xcode-14.3.1.app", - "rename:/Applications/Xcode-15.1.app->Xcode.app" - ]) - } - - func testXcodeSelectionServiceSelectsInstalledVersion() throws { - let first = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) - let second = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.1.app")), version: Version("15.1.0")!) - let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - let request = service.request( - pathOrVersion: "15.1", - installedXcodes: [first, second], - selectedXcodePath: "\(first.path.string)/Contents/Developer" - ) - - XCTAssertEqual(request, .selectInstalledXcode(second)) - } - - func testXcodeSelectionServiceDetectsAlreadySelectedVersion() throws { - let xcode = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) - let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - let request = service.request( - pathOrVersion: "15.0", - installedXcodes: [xcode], - selectedXcodePath: "\(xcode.path.string)/Contents/Developer" - ) - - XCTAssertEqual(request, .alreadySelectedVersion(Version("15.0.0")!)) - } - - func testXcodeSelectionServiceUsesVersionFileWhenNoArgumentIsProvided() throws { - let xcode = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) - let service = XcodeSelectionService(versionFile: XcodeVersionFileService( - fileExists: { $0 == "/project/.xcode-version" }, - contentsAtPath: { _ in Data("15.0\n".utf8) } - )) - - let request = service.request( - pathOrVersion: "", - installedXcodes: [xcode], - selectedXcodePath: "", - versionFileDirectory: try XCTUnwrap(Path("/project")) - ) - - XCTAssertEqual(request, .selectInstalledXcode(xcode)) - } - - func testXcodeSelectionServiceFallsBackToPathSelection() { - let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - let request = service.request( - pathOrVersion: " /Applications/Xcode.app\n", - installedXcodes: [], - selectedXcodePath: "" - ) - - XCTAssertEqual(request, .selectPath("/Applications/Xcode.app")) - } - - func testXcodeSelectionServiceChoosesInstalledXcodeBySelectionNumber() throws { - let first = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) - let second = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.1.app")), version: Version("15.1.0")!) - let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - let selected = try service.installedXcode(fromSelection: "2", installedXcodes: [second, first]) - - XCTAssertEqual(selected, second) - } - - func testXcodeSelectionServiceRejectsInvalidSelectionNumber() throws { - let xcode = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) - let service = XcodeSelectionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - XCTAssertThrowsError(try service.installedXcode(fromSelection: "3", installedXcodes: [xcode])) { error in - XCTAssertEqual(error as? XcodeSelectionError, .invalidIndex(min: 1, max: 1, given: "3")) - } - } - - func testXcodeInstallResolutionServiceSelectsLatestReleaseVersion() throws { - let release = AvailableXcode(version: Version("15.0.0")!, url: URL(fileURLWithPath: "/Xcode-15.xip"), filename: "Xcode-15.xip", releaseDate: nil) - let prerelease = AvailableXcode(version: Version("16.0.0-beta.1")!, url: URL(fileURLWithPath: "/Xcode-16-beta.xip"), filename: "Xcode-16-beta.xip", releaseDate: Date()) - let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - let resolution = try service.resolve( - .latest, - availableXcodes: [prerelease, release], - installedXcodes: [], - willInstall: true - ) - - XCTAssertEqual(resolution, .download(version: release.version, resolvedXcode: release)) - } - - func testXcodeInstallResolutionServiceSelectsLatestPrereleaseByReleaseDate() throws { - let older = AvailableXcode(version: Version("16.0.0-beta.2")!, url: URL(fileURLWithPath: "/older.xip"), filename: "older.xip", releaseDate: Date(timeIntervalSince1970: 1)) - let newer = AvailableXcode(version: Version("16.0.0-beta.1")!, url: URL(fileURLWithPath: "/newer.xip"), filename: "newer.xip", releaseDate: Date(timeIntervalSince1970: 2)) - let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - let resolution = try service.resolve( - .latestPrerelease, - availableXcodes: [newer, older], - installedXcodes: [], - willInstall: true - ) - - XCTAssertEqual(resolution, .download(version: newer.version, resolvedXcode: newer)) - } - - func testXcodeInstallResolutionServiceRejectsLatestPrereleaseWithoutReleaseDate() throws { - let prerelease = AvailableXcode(version: Version("16.0.0-beta.1")!, url: URL(fileURLWithPath: "/beta.xip"), filename: "beta.xip", releaseDate: nil) - let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - XCTAssertThrowsError(try service.resolve( - .latestPrerelease, - availableXcodes: [prerelease], - installedXcodes: [], - willInstall: true - )) { error in - XCTAssertEqual(error as? XcodeInstallResolutionError, .noPrereleaseVersionAvailable) - } - } - - func testXcodeInstallResolutionServiceRejectsInstalledVersionWhenInstalling() throws { - let installed = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) - let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - XCTAssertThrowsError(try service.resolve( - .version("15.0"), - availableXcodes: [], - installedXcodes: [installed], - willInstall: true - )) { error in - XCTAssertEqual(error as? XcodeInstallResolutionError, .versionAlreadyInstalled(installed)) - } - } - - func testXcodeInstallResolutionServiceRejectsInstalledAvailableXcodeWhenInstalling() throws { - let available = AvailableXcode(version: Version("15.0.0")!, url: URL(fileURLWithPath: "/Xcode-15.xip"), filename: "Xcode-15.xip", releaseDate: nil) - let installed = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) - let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - XCTAssertThrowsError(try service.resolve( - .availableXcode(available), - availableXcodes: [], - installedXcodes: [installed], - willInstall: true - )) { error in - XCTAssertEqual(error as? XcodeInstallResolutionError, .versionAlreadyInstalled(installed)) - } - } - - func testXcodeInstallResolutionServiceResolvesAvailableXcodeForInstall() throws { - let available = AvailableXcode(version: Version("15.0.0")!, url: URL(fileURLWithPath: "/Xcode-15.xip"), filename: "Xcode-15.xip", releaseDate: nil) - let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - let resolution = try service.resolve( - .availableXcode(available), - availableXcodes: [], - installedXcodes: [], - willInstall: true - ) - - XCTAssertEqual(resolution, .download(version: available.version, resolvedXcode: available)) - } - - func testXcodeInstallResolutionServiceAllowsInstalledVersionWhenOnlyDownloading() throws { - let installed = InstalledXcode(path: try XCTUnwrap(Path("/Applications/Xcode-15.0.app")), version: Version("15.0.0")!) - let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService(fileExists: { _ in false }, contentsAtPath: { _ in nil })) - - let resolution = try service.resolve( - .version("15.0"), - availableXcodes: [], - installedXcodes: [installed], - willInstall: false - ) - - XCTAssertEqual(resolution, .download(version: Version("15.0.0")!, resolvedXcode: nil)) - } - - func testXcodeInstallResolutionServiceUsesVersionFileForPathArchive() throws { - let archivePath = try XCTUnwrap(Path("/tmp/Xcode.xip")) - let service = XcodeInstallResolutionService(versionFile: XcodeVersionFileService( - fileExists: { $0 == "/project/.xcode-version" }, - contentsAtPath: { _ in Data("15.1\n".utf8) } - )) - - let resolution = try service.resolve( - .path(versionString: "", path: archivePath), - availableXcodes: [], - installedXcodes: [], - willInstall: true, - versionFileDirectory: try XCTUnwrap(Path("/project")) - ) - - XCTAssertEqual( - resolution, - .localArchive( - AvailableXcode(version: Version("15.1.0")!, url: archivePath.url, filename: "Xcode.xip", releaseDate: nil), - archivePath.url - ) - ) - } - - func testArchiveCancellationCleanupServiceRemovesXcodeArchiveAndAria2Metadata() throws { - let recorder = URLListRecorder() - let xcode = AvailableXcode( - version: try XCTUnwrap(Version("15.0.0")), - url: try XCTUnwrap(URL(string: "https://example.com/Xcode_15.xip")), - filename: "Xcode_15.xip", - releaseDate: nil - ) - let service = ArchiveCancellationCleanupService { url in - recorder.record(url) - } - - service.cleanupXcodeArchive( - for: xcode, - applicationSupportPath: try XCTUnwrap(Path("/tmp/xcodes")) - ) - - XCTAssertEqual(recorder.paths, [ - "/tmp/xcodes/Xcode-15.0.0.xip", - "/tmp/xcodes/Xcode-15.0.0.xip.aria2" - ]) - } - - func testArchiveCancellationCleanupServiceRemovesRuntimeArchiveAndAria2Metadata() throws { - let recorder = URLListRecorder() - let runtime = downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg") - let service = ArchiveCancellationCleanupService { url in - recorder.record(url) - } - - service.cleanupRuntimeArchive( - for: runtime, - destinationDirectory: try XCTUnwrap(Path("/tmp/xcodes")) - ) - - XCTAssertEqual(recorder.paths, [ - "/tmp/xcodes/iOS_16_Runtime.dmg", - "/tmp/xcodes/iOS_16_Runtime.dmg.aria2" - ]) - } - - func testAttemptResumableTaskRetriesWithResumeData() async throws { - let retryResumeData = Data("resume".utf8) - let recorder = RetryRecorder() - - let result = try await attemptResumableTask(delayBeforeRetry: .zero) { resumeData in - let attempts = await recorder.record(resumeData) - - if attempts == 1 { - throw NSError( - domain: NSURLErrorDomain, - code: NSURLErrorNetworkConnectionLost, - userInfo: [NSURLSessionDownloadTaskResumeData: retryResumeData] - ) - } - - return "finished" - } - - XCTAssertEqual(result, "finished") - let receivedResumeData = await recorder.values - XCTAssertEqual(receivedResumeData, [nil, retryResumeData]) - } - - func testAttemptResumableTaskDoesNotRetryRejectedError() async throws { - let resumeData = Data("resume".utf8) - let recorder = RetryRecorder() - let expectedError = NSError( - domain: NSURLErrorDomain, - code: NSURLErrorUserCancelledAuthentication, - userInfo: [NSURLSessionDownloadTaskResumeData: resumeData] - ) - - do { - _ = try await attemptResumableTask( - delayBeforeRetry: .zero, - shouldRetry: { _ in false }, - { _ in - await recorder.record(nil) - throw expectedError - } - ) as String - XCTFail("Expected rejected retry to throw") - } catch { - let attempts = await recorder.count - XCTAssertEqual(attempts, 1) - XCTAssertEqual(error as NSError, expectedError) - } - } - - func testAttemptRetryableTaskRetriesApprovedError() async throws { - let recorder = RetryRecorder() - - let result = try await attemptRetryableTask(delayBeforeRetry: .zero) { - let attempts = await recorder.record(()) - - if attempts == 1 { - throw URLError(.networkConnectionLost) - } - - return "finished" - } - - XCTAssertEqual(result, "finished") - let attempts = await recorder.count - XCTAssertEqual(attempts, 2) - } - - func testArchiveDownloadServiceURLSessionUsesPersistedResumeData() async throws { - let persistedResumeData = Data("persisted".utf8) - let recorder = DownloadRecorder() - let resumeDataPath = try XCTUnwrap(Path("/tmp/Xcode-15.resumedata")) - let destination = try XCTUnwrap(Path("/tmp/Xcode-15.xip")) - let downloadURL = try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")) - let service = ArchiveDownloadService( - aria2Download: { _, _, _, _ in - XCTFail("Aria2 should not be used") - return AsyncThrowingStream { $0.finish() } - }, - urlSessionDownload: { url, destination, resumeData in - recorder.recordURLSession(url: url, destination: destination, resumeData: resumeData) - return ( - Progress(totalUnitCount: 10), - Task { - ( - saveLocation: destination, - response: try XCTUnwrap(URLResponse(url: url, mimeType: nil, expectedContentLength: 0, textEncodingName: nil)) - ) - } - ) - }, - contentsAtPath: { path in - path == resumeDataPath.string ? persistedResumeData : nil - }, - createFile: { _, _ in XCTFail("Resume data should not be persisted on success") }, - removeItem: { url in recorder.recordRemovedURL(url) } - ) - - let url = try await service.downloadWithURLSession( - url: downloadURL, - destination: destination, - resumeDataPath: resumeDataPath, - progressChanged: { recorder.recordProgress($0) } - ) - - XCTAssertEqual(url, destination.url) - XCTAssertEqual(recorder.urlSessionResumeData, persistedResumeData) - XCTAssertEqual(recorder.removedURLs.map(\.path), [resumeDataPath.string]) - XCTAssertEqual(recorder.progressCount, 1) - } - - func testArchiveDownloadServiceURLSessionPersistsResumeDataOnFailure() async throws { - let failedResumeData = Data("failed".utf8) - let recorder = DownloadRecorder() - let resumeDataPath = try XCTUnwrap(Path("/tmp/Xcode-15.resumedata")) - let expectedError = NSError( - domain: NSURLErrorDomain, - code: NSURLErrorNetworkConnectionLost, - userInfo: [NSURLSessionDownloadTaskResumeData: failedResumeData] - ) - let service = ArchiveDownloadService( - aria2Download: { _, _, _, _ in - XCTFail("Aria2 should not be used") - return AsyncThrowingStream { $0.finish() } - }, - urlSessionDownload: { _, _, _ in - ( - Progress(totalUnitCount: 10), - Task { - throw expectedError - } - ) - }, - contentsAtPath: { _ in nil }, - createFile: { path, data in recorder.recordCreatedFile(path: path, data: data) }, - removeItem: { _ in XCTFail("Resume data should not be removed on failure") }, - shouldRetry: { _ in false } - ) - - do { - _ = try await service.downloadWithURLSession( - url: try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")), - destination: try XCTUnwrap(Path("/tmp/Xcode-15.xip")), - resumeDataPath: resumeDataPath, - progressChanged: { _ in } - ) - XCTFail("Expected URLSession failure") - } catch { - XCTAssertEqual(error as NSError, expectedError) - XCTAssertEqual(recorder.createdFiles.map(\.path), [resumeDataPath.string]) - XCTAssertEqual(recorder.createdFiles.map(\.data), [failedResumeData]) - } - } - - func testArchiveDownloadServiceAria2YieldsProgressAndReturnsDestination() async throws { - let recorder = DownloadRecorder() - let progress = Progress(totalUnitCount: 100) - progress.completedUnitCount = 42 - let destination = try XCTUnwrap(Path("/tmp/Xcode-15.xip")) - let service = ArchiveDownloadService( - aria2Download: { _, _, _, _ in - let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) - continuation.yield(progress) - continuation.finish() - return stream - }, - urlSessionDownload: { _, _, _ in - XCTFail("URLSession should not be used") - return (Progress(), Task { throw URLError(.unknown) }) - }, - contentsAtPath: { _ in nil }, - createFile: { _, _ in XCTFail("Resume data should not be persisted") }, - removeItem: { _ in XCTFail("Resume data should not be removed") } - ) - - let url = try await service.downloadWithAria2( - aria2Path: try XCTUnwrap(Path("/usr/bin/aria2c")), - url: try XCTUnwrap(URL(string: "https://example.com/Xcode.xip")), - destination: destination, - cookies: [], - progressChanged: { recorder.recordProgress($0) } - ) - - XCTAssertEqual(url, destination.url) - XCTAssertEqual(recorder.progressCount, 1) - } - - func testArchiveDownloadServiceValidatesDeveloperUnauthorizedRedirect() throws { - enum UnauthorizedTestError: Error, Equatable { - case notAuthorized - } - - let response = try XCTUnwrap(URLResponse( - url: try XCTUnwrap(URL(string: "https://developer.apple.com/unauthorized/")), - mimeType: nil, - expectedContentLength: 0, - textEncodingName: nil - )) - - do { - try ArchiveDownloadService.validateDeveloperDownloadResponse( - response, - unauthorizedError: { UnauthorizedTestError.notAuthorized } - ) - XCTFail("Expected unauthorized redirect to throw") - } catch let error as UnauthorizedTestError { - XCTAssertEqual(error, .notAuthorized) - } - } - - func testArchiveDownloadServiceAcceptsDeveloperDownloadResponse() throws { - let response = try XCTUnwrap(URLResponse( - url: try XCTUnwrap(URL(string: "https://download.developer.apple.com/Developer_Tools/Xcode_15/Xcode_15.xip")), - mimeType: nil, - expectedContentLength: 0, - textEncodingName: nil - )) - - try ArchiveDownloadService.validateDeveloperDownloadResponse(response) - } - - func testAvailableXcodeReleaseExposesDownloadPath() throws { - let release = AvailableXcodeRelease( - version: try XCTUnwrap(Version("15.0.0")), - url: try XCTUnwrap(URL(string: "https://example.com/Developer_Tools/Xcode_15/Xcode_15.xip")), - filename: "Xcode_15.xip", - releaseDate: nil - ) - - XCTAssertEqual(release.downloadPath, "/Developer_Tools/Xcode_15/Xcode_15.xip") - } - - func testXcodeListServiceFiltersPrereleasesWithDuplicateBuildMetadata() throws { - let release = AvailableXcode( - version: try XCTUnwrap(Version("12.4.0+12D4e")), - url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), - filename: "mock.xip", - releaseDate: nil - ) - let prerelease = AvailableXcode( - version: try XCTUnwrap(Version("12.4.0-RC+12D4e")), - url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), - filename: "mock.xip", - releaseDate: nil - ) - - let filtered = XcodeListService.filteringPrereleasesWithDuplicateBuildMetadata([release, prerelease]) - - XCTAssertEqual(filtered.map(\.version), [release.version]) - XCTAssertEqual(XcodeListService.identicalBuildIDs(for: release, in: [release, prerelease]), [ - release.xcodeID, - prerelease.xcodeID - ]) - } - - func testXcodeListServiceKeepsArchitectureSpecificPrereleaseWithDuplicateBuildMetadata() throws { - let release = AvailableXcode( - version: try XCTUnwrap(Version("16.0.0+16A1")), - url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), - filename: "mock.xip", - releaseDate: nil - ) - let architectureSpecificPrerelease = AvailableXcode( - version: try XCTUnwrap(Version("16.0.0-RC+16A1")), - url: try XCTUnwrap(URL(string: "https://apple.com/xcode-arm64.xip")), - filename: "mock-arm64.xip", - releaseDate: nil, - architectures: [.arm64] - ) - - let filtered = XcodeListService.filteringPrereleasesWithDuplicateBuildMetadata([ - release, - architectureSpecificPrerelease - ]) - - XCTAssertEqual(filtered.map(\.xcodeID), [ - release.xcodeID, - architectureSpecificPrerelease.xcodeID - ]) - } - - func testXcodeListServiceValidatesDeveloperDownloads() async throws { - let downloads = Downloads( - resultCode: 0, - resultsString: nil, - downloads: [ - Download( - name: "Xcode 15", - files: [Download.File(remotePath: "Developer_Tools/Xcode_15/Xcode_15.xip")], - dateModified: Date() - ) - ] - ) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) - let data = try encoder.encode(downloads) - let service = XcodeListService { request in - XCTAssertEqual(request.url, URLRequest.developerDownloads.url) - return ( - data, - try XCTUnwrap(HTTPURLResponse(url: try XCTUnwrap(request.url), statusCode: 200, httpVersion: nil, headerFields: nil)) - ) - } - - try await service.validateDeveloperDownloads() - } - - func testXcodeListServiceMapsDeveloperDownloadsErrorResult() async throws { - let downloads = Downloads(resultCode: 1, resultsString: "Access denied", downloads: nil) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) - let data = try encoder.encode(downloads) - let service = XcodeListService { request in - ( - data, - try XCTUnwrap(HTTPURLResponse(url: try XCTUnwrap(request.url), statusCode: 200, httpVersion: nil, headerFields: nil)) - ) - } - - do { - try await service.validateDeveloperDownloads() - XCTFail("Expected developer downloads validation to throw") - } catch { - XCTAssertEqual(error as? XcodeListService.Error, .invalidResult("Access denied")) - } - } - - func testXcodeListComposerPreservesInstallingState() throws { - let version = try XCTUnwrap(Version("15.0.0")) - let composer = XcodeListComposer() - - let items = composer.compose( - availableXcodes: [ - AvailableXcode( - version: version, - url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), - filename: "mock.xip", - releaseDate: nil - ) - ], - installedXcodes: [], - selectedXcodePath: nil, - existingXcodes: [ - XcodeListItem( - version: version, - installState: .installing(.unarchiving), - selected: false - ) - ], - dataSource: .xcodeReleases - ) - - XCTAssertEqual(items.map(\.installState), [.installing(.unarchiving)]) - } - - func testXcodeListComposerAdjustsAppleBuildMetadataUsingInstalledXcodes() throws { - let composer = XcodeListComposer() - let installedPath = try XCTUnwrap(Path("/Applications/Xcode.app")) - - let items = composer.compose( - availableXcodes: [ - AvailableXcode( - version: try XCTUnwrap(Version("15.0.0-GM+15A1")), - url: try XCTUnwrap(URL(string: "https://apple.com/xcode.xip")), - filename: "mock.xip", - releaseDate: nil - ) - ], - installedXcodes: [ - InstalledXcode( - path: installedPath, - version: try XCTUnwrap(Version("15.0.0+15A1")) - ) - ], - selectedXcodePath: "\(installedPath.string)/Contents/Developer", - existingXcodes: [], - dataSource: .apple - ) - - XCTAssertEqual(items.map(\.version), [try XCTUnwrap(Version("15.0.0+15A1"))]) - XCTAssertEqual(items.first?.installState, .installed(installedPath)) - XCTAssertEqual(items.first?.selected, true) - } - - func testXcodeListPresentationServiceBuildsAvailableRows() throws { - let selectedPath = try XCTUnwrap(Path("/Applications/Xcode-15.0.app")) - let installedPath = try XCTUnwrap(Path("/Applications/Xcode-14.0.app")) - let service = XcodeListPresentationService() - let newerXcode = AvailableXcode( - version: try XCTUnwrap(Version("15.0.0")), - url: try XCTUnwrap(URL(string: "https://apple.com/xcode-15.xip")), - filename: "xcode-15.xip", - releaseDate: nil - ) - let olderXcode = AvailableXcode( - version: try XCTUnwrap(Version("14.0.0")), - url: try XCTUnwrap(URL(string: "https://apple.com/xcode-14.xip")), - filename: "xcode-14.xip", - releaseDate: nil - ) - - let rows = service.availableRows( - availableXcodes: [newerXcode, olderXcode], - installedXcodes: [ - InstalledXcode(path: selectedPath, version: try XCTUnwrap(Version("15.0.0"))), - InstalledXcode(path: installedPath, version: try XCTUnwrap(Version("14.0.0"))) - ], - selectedXcodePath: "\(selectedPath.string)/Contents/Developer", - dataSource: .xcodeReleases - ) - - XCTAssertEqual(rows.map(\.version), [olderXcode.version, newerXcode.version]) - XCTAssertEqual(rows.map(\.isInstalled), [true, true]) - XCTAssertEqual(rows.map(\.isSelected), [false, true]) - } - - func testXcodeListPresentationServiceFormatsInstalledRows() throws { - let selectedPath = try XCTUnwrap(Path("/Applications/Xcode-15.0.app")) - let service = XcodeListPresentationService() - let rows = service.installedRows( - installedXcodes: [ - InstalledXcode(path: selectedPath, version: try XCTUnwrap(Version("15.0.0"))), - InstalledXcode( - path: try XCTUnwrap(Path("/Applications/Xcode-14.0.app")), - version: try XCTUnwrap(Version("14.0.0")) - ) - ], - selectedXcodePath: "\(selectedPath.string)/Contents/Developer" - ) - - XCTAssertEqual(rows.map(\.version), [ - try XCTUnwrap(Version("14.0.0")), - try XCTUnwrap(Version("15.0.0")) - ]) - XCTAssertEqual(service.installedLines(rows: rows, interactive: false), [ - "14.0\t/Applications/Xcode-14.0.app", - "15.0 (Selected)\t/Applications/Xcode-15.0.app" - ]) - XCTAssertEqual(service.installedLines(rows: rows, interactive: true), [ - "14.0 /Applications/Xcode-14.0.app", - "15.0 (Selected) /Applications/Xcode-15.0.app" - ]) - } - - - func testXcodeArchiveServiceUsesExistingArchiveWhenPresent() async throws { - let archive = XcodeArchive( - version: try XCTUnwrap(Version("15.0.0")), - downloadURL: try XCTUnwrap(URL(string: "https://apple.com/Xcode.xip")), - filename: "Xcode.xip" - ) - let service = XcodeArchiveService( - applicationSupportPath: try XCTUnwrap(Path("/tmp")), - fileExists: { $0.string == "/tmp/Xcode-15.0.0.xip" }, - download: { _, _, _, _ in - XCTFail("Expected existing archive to be reused") - return URL(fileURLWithPath: "/tmp/downloaded.xip") - } - ) - - let url = try await service.archiveURL(for: archive, downloader: .urlSession, progressChanged: { _ in }) - - XCTAssertEqual(url.path, "/tmp/Xcode-15.0.0.xip") - } - - func testXcodeArchiveServiceRedownloadsIncompleteAria2Archive() async throws { - let archive = XcodeArchive( - version: try XCTUnwrap(Version("15.0.0")), - downloadURL: try XCTUnwrap(URL(string: "https://apple.com/Xcode.xip")), - filename: "Xcode.xip" - ) - let service = XcodeArchiveService( - applicationSupportPath: try XCTUnwrap(Path("/tmp")), - fileExists: { path in - path.string == "/tmp/Xcode-15.0.0.xip" || path.string == "/tmp/Xcode-15.0.0.xip.aria2" - }, - download: { archive, destination, downloader, _ in - XCTAssertEqual(archive.version, Version("15.0.0")!) - XCTAssertEqual(destination.string, "/tmp/Xcode-15.0.0.xip") - XCTAssertEqual(downloader, .aria2) - return URL(fileURLWithPath: "/tmp/redownloaded.xip") - } - ) - - let url = try await service.archiveURL(for: archive, downloader: .aria2, progressChanged: { _ in }) - - XCTAssertEqual(url.path, "/tmp/redownloaded.xip") - } - - func testRuntimeServiceMountDMGParsesMountPoint() async throws { - let service = RuntimeService( - loadData: { _ in throw URLError(.badServerResponse) }, - installedRuntimesOutput: { XCTFail("Installed runtime loader should not be called"); return (0, "", "") }, - installRuntimeImageOutput: { _ in XCTFail("Install runtime image should not be called"); return (0, "", "") }, - mountDMGOutput: { url in - XCTAssertEqual(url.path, "/tmp/runtime.dmg") - return (0, """ - - system-entities - - - - - mount-point - /Volumes/Runtime - - - - """, "") - }, - unmountDMGOutput: { _ in XCTFail("Unmount should not be called"); return (0, "", "") } - ) - - let mountedURL = try await service.mountDMG(dmgUrl: URL(fileURLWithPath: "/tmp/runtime.dmg")) - - XCTAssertEqual(mountedURL.path, "/Volumes/Runtime") - } - - func testRuntimeServiceMountDMGThrowsWhenMountPointIsMissing() async throws { - let service = RuntimeService( - loadData: { _ in throw URLError(.badServerResponse) }, - installedRuntimesOutput: { XCTFail("Installed runtime loader should not be called"); return (0, "", "") }, - installRuntimeImageOutput: { _ in XCTFail("Install runtime image should not be called"); return (0, "", "") }, - mountDMGOutput: { _ in - return (0, """ - - system-entities - - - - - - """, "") - }, - unmountDMGOutput: { _ in XCTFail("Unmount should not be called"); return (0, "", "") } - ) - - do { - _ = try await service.mountDMG(dmgUrl: URL(fileURLWithPath: "/tmp/runtime.dmg")) - XCTFail("Expected missing mount point to throw") - } catch { - XCTAssertEqual(error as? RuntimeService.Error, .failedMountingDMG) - } - } - - func testRuntimeServiceUsesInjectedPackageOperations() async throws { - let packagePath = try XCTUnwrap(Path("/tmp/runtime.pkg")) - let expandedPackagePath = try XCTUnwrap(Path("/tmp/runtime-expanded.pkg")) - let recorder = PathOperationRecorder() - let service = RuntimeService( - loadData: { _ in throw URLError(.badServerResponse) }, - installedRuntimesOutput: { XCTFail("Installed runtime loader should not be called"); return (0, "", "") }, - installRuntimeImageOutput: { _ in XCTFail("Install runtime image should not be called"); return (0, "", "") }, - mountDMGOutput: { _ in XCTFail("Mount should not be called"); return (0, "", "") }, - unmountDMGOutput: { _ in XCTFail("Unmount should not be called"); return (0, "", "") }, - expandPkgOutput: { source, destination in - XCTAssertEqual(source.path, packagePath.url.path) - XCTAssertEqual(destination.path, expandedPackagePath.url.path) - recorder.record("expanded") - return (0, "", "") - }, - createPkgOutput: { source, destination in - XCTAssertEqual(source.path, packagePath.url.path) - XCTAssertEqual(destination.path, expandedPackagePath.url.path) - recorder.record("created") - return (0, "", "") - }, - installPkgOutput: { packageURL, target in - XCTAssertEqual(packageURL.path, packagePath.url.path) - XCTAssertEqual(target, expandedPackagePath.url.absoluteString) - recorder.record("installed") - return (0, "", "") - } - ) - - try await service.expand(pkgPath: packagePath, expandedPkgPath: expandedPackagePath) - try await service.createPkg(pkgPath: packagePath, expandedPkgPath: expandedPackagePath) - try await service.installPkg(pkgPath: packagePath, expandedPkgPath: expandedPackagePath) - - XCTAssertEqual(recorder.operations, ["expanded", "created", "installed"]) - } - - func testRuntimeServiceDeleteRuntimeMapsProcessError() async throws { - let process = Process() - let service = RuntimeService( - loadData: { _ in throw URLError(.badServerResponse) }, - installedRuntimesOutput: { XCTFail("Installed runtime loader should not be called"); return (0, "", "") }, - installRuntimeImageOutput: { _ in XCTFail("Install runtime image should not be called"); return (0, "", "") }, - mountDMGOutput: { _ in XCTFail("Mount should not be called"); return (0, "", "") }, - unmountDMGOutput: { _ in XCTFail("Unmount should not be called"); return (0, "", "") }, - deleteRuntimeOutput: { identifier in - XCTAssertEqual(identifier, "runtime-id") - throw ProcessExecutionError( - process: process, - terminationStatus: 1, - standardOutput: "", - standardError: "runtime delete failed" - ) - } - ) - - do { - try await service.deleteRuntime(identifier: "runtime-id") - XCTFail("Expected delete runtime to throw") - } catch { - XCTAssertEqual((error as? XcodesKitError)?.message, "runtime delete failed") - } - } - - func testRuntimePackageInstallServiceRewritesPackageInstallLocation() async throws { - let runtime = downloadableRuntime( - source: nil, - simulatorVersion: .init(buildUpdate: "19F70", version: "15.5"), - name: "iOS 15.5" - ) - let diskImageURL = URL(fileURLWithPath: "/tmp/iOS_15_5.dmg") - let mountedURL = URL(fileURLWithPath: "/Volumes/iOS 15.5") - let mountedPackagePath = Path("/Volumes/iOS 15.5/Runtime.pkg")! - let cachesDirectory = Path("/tmp/xcodes-cache")! - let expandedPackagePath = cachesDirectory/runtime.identifier - let repackagedPath = cachesDirectory/(runtime.identifier + ".pkg") - let packageInfo = """ - - - """ - let recorder = RuntimePackageInstallRecorder() - - let service = RuntimePackageInstallService( - mountDMG: { url in - XCTAssertEqual(url, diskImageURL) - recorder.append("mount") - return mountedURL - }, - unmountDMG: { url in - XCTAssertEqual(url, mountedURL) - recorder.append("unmount") - }, - packagePath: { url in - XCTAssertEqual(url, mountedURL) - return mountedPackagePath - }, - prepareDirectory: { path in - XCTAssertEqual(path, cachesDirectory) - recorder.append("prepare") - }, - expandPkg: { packageURL, expandedURL in - XCTAssertEqual(packageURL, mountedPackagePath.url) - XCTAssertEqual(expandedURL, expandedPackagePath.url) - recorder.append("expand") - return (0, "", "") - }, - createPkg: { expandedURL, packageURL in - XCTAssertEqual(expandedURL, expandedPackagePath.url) - XCTAssertEqual(packageURL, repackagedPath.url) - recorder.append("create") - return (0, "", "") - }, - installPkg: { packageURL, target in - XCTAssertEqual(packageURL, repackagedPath.url) - XCTAssertEqual(target, "/") - recorder.append("install") - return (0, "", "") - }, - contentsAtPath: { path in - XCTAssertEqual(path, (expandedPackagePath/"PackageInfo").string) - recorder.append("read") - return Data(packageInfo.utf8) - }, - writeData: { data, url in - XCTAssertEqual(url, (expandedPackagePath/"PackageInfo").url) - recorder.append("write") - recorder.rewrittenPackageInfo = String(data: data, encoding: .utf8) - }, - removeItem: { _ in } - ) - - try await service.installPackageRuntime( - from: diskImageURL, - runtime: runtime, - cachesDirectory: cachesDirectory - ) - - XCTAssertEqual(recorder.steps, ["mount", "prepare", "expand", "unmount", "read", "write", "create", "install"]) - XCTAssertTrue(recorder.rewrittenPackageInfo?.contains(#"install-location="/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 15.5.simruntime""#) == true) - } - - func testRuntimePackageInstallServiceUnmountsWhenExpandFails() async throws { - let runtime = downloadableRuntime( - source: nil, - simulatorVersion: .init(buildUpdate: "19F70", version: "15.5"), - name: "iOS 15.5" - ) - let diskImageURL = URL(fileURLWithPath: "/tmp/iOS_15_5.dmg") - let mountedURL = URL(fileURLWithPath: "/Volumes/iOS 15.5") - let mountedPackagePath = Path("/Volumes/iOS 15.5/Runtime.pkg")! - let cachesDirectory = Path("/tmp/xcodes-cache")! - let recorder = RuntimePackageInstallRecorder() - - let service = RuntimePackageInstallService( - mountDMG: { url in - XCTAssertEqual(url, diskImageURL) - recorder.append("mount") - return mountedURL - }, - unmountDMG: { url in - XCTAssertEqual(url, mountedURL) - recorder.append("unmount") - }, - packagePath: { url in - XCTAssertEqual(url, mountedURL) - return mountedPackagePath - }, - prepareDirectory: { path in - XCTAssertEqual(path, cachesDirectory) - recorder.append("prepare") - }, - expandPkg: { _, _ in - recorder.append("expand") - throw XcodesKitError("expand failed") - }, - createPkg: { _, _ in - XCTFail("Create should not be called") - return (0, "", "") - }, - installPkg: { _, _ in - XCTFail("Install should not be called") - return (0, "", "") - }, - contentsAtPath: { _ in - XCTFail("PackageInfo should not be read") - return nil - }, - writeData: { _, _ in - XCTFail("PackageInfo should not be written") - }, - removeItem: { _ in } - ) - - do { - try await service.installPackageRuntime( - from: diskImageURL, - runtime: runtime, - cachesDirectory: cachesDirectory - ) - XCTFail("Expected package install to fail") - } catch let error as XcodesKitError { - XCTAssertEqual(error.message, "expand failed") - } - - XCTAssertEqual(recorder.steps, ["mount", "prepare", "expand", "unmount"]) - } - - func testRuntimeArchiveInstallServiceInstallsDiskImageAndDeletesArchive() async throws { - let recorder = PathOperationRecorder() - let archiveURL = URL(fileURLWithPath: "/tmp/iOS_16_Runtime.dmg") - let service = RuntimeArchiveInstallService( - installDiskImage: { url in - recorder.record("install:\(url.path)") - }, - removeArchive: { url in - recorder.record("remove:\(url.path)") - } - ) - - try await service.install( - runtime: downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg"), - archiveURL: archiveURL, - stepChanged: { step in - recorder.record("step:\(step)") - } - ) - - XCTAssertEqual(recorder.operations, [ - "step:(2/3) Installing", - "install:/tmp/iOS_16_Runtime.dmg", - "step:(3/3) TrashingArchive", - "remove:/tmp/iOS_16_Runtime.dmg" - ]) - } - - func testRuntimeArchiveInstallServiceCanKeepArchive() async throws { - let recorder = PathOperationRecorder() - let archiveURL = URL(fileURLWithPath: "/tmp/iOS_16_Runtime.dmg") - let service = RuntimeArchiveInstallService( - installDiskImage: { _ in - recorder.record("install") - }, - removeArchive: { _ in - recorder.record("remove") - } - ) - - try await service.install( - runtime: downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg"), - archiveURL: archiveURL, - deleteArchive: false - ) - - XCTAssertEqual(recorder.operations, ["install"]) - } - - func testRuntimeArchiveInstallServiceRejectsUnsupportedArchiveTypes() async throws { - let archiveURL = URL(fileURLWithPath: "/tmp/iOS_15_Runtime.dmg") - let service = RuntimeArchiveInstallService( - installDiskImage: { _ in - XCTFail("Expected unsupported archive to skip disk image install") - }, - removeArchive: { _ in - XCTFail("Expected unsupported archive to skip cleanup") - } - ) - - do { - try await service.install( - runtime: downloadableRuntime( - source: "https://example.com/iOS_15_Runtime.dmg", - contentType: .package - ), - archiveURL: archiveURL - ) - XCTFail("Expected unsupported content type error") - } catch let error as RuntimeArchiveInstallError { - XCTAssertEqual(error, .unsupportedContentType(.package, archiveURL: archiveURL)) - } - } - - func testDownloadableRuntimeCacheLoadsAndSavesRuntimes() throws { - let runtime = downloadableRuntime(source: "https://example.com/iOS.dmg") - let cacheFile = try XCTUnwrap(Path("/tmp/downloadable-runtimes.json")) - let recorder = RuntimeCacheFileRecorder() - let cache = DownloadableRuntimeCache( - cacheFile: cacheFile, - contentsAtPath: { _ in recorder.storedData }, - writeData: { data, url in - recorder.recordWrite(data: data, url: url) - }, - createDirectory: { url, _, _ in - recorder.recordCreatedDirectory(url) - } - ) - - XCTAssertNil(try cache.load()) - - try cache.save([runtime]) - - let loadedRuntimes = try XCTUnwrap(try cache.load()) - XCTAssertEqual(loadedRuntimes, [runtime]) - XCTAssertEqual(recorder.createdDirectory?.path, "/tmp") - XCTAssertEqual(recorder.writtenURL?.path, cacheFile.url.path) - } - - func testDownloadableRuntimesResponseAddsSDKBuildUpdates() { - let response = DownloadableRuntimesResponse( - sdkToSimulatorMappings: [ - SDKToSimulatorMapping( - sdkBuildUpdate: "22A3362", - simulatorBuildUpdate: "20A360", - sdkIdentifier: "iphonesimulator", - downloadableIdentifiers: nil - ) - ], - sdkToSeedMappings: [], - refreshInterval: 3600, - downloadables: [downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg")], - version: "2" - ) - - XCTAssertEqual(response.downloadablesWithSDKBuildUpdates().first?.sdkBuildUpdate, ["22A3362"]) - } - - func testRuntimeListPresentationServiceBuildsInstalledRows() { - let response = DownloadableRuntimesResponse( - sdkToSimulatorMappings: [], - sdkToSeedMappings: [], - refreshInterval: 3600, - downloadables: [downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg")], - version: "2" - ) - let service = RuntimeListPresentationService() - - let rows = service.rows( - downloadableRuntimes: response, - installedRuntimes: [ - installedRuntime(build: "20A360", kind: .diskImage) - ], - includeBetas: false - ) - - XCTAssertEqual(rows.map(\.platform.shortName), ["iOS"]) - XCTAssertEqual(rows.first?.runtimes.map { service.line(for: $0) }, [ - "iOS 16.0 (Installed)" - ]) - } - - func testRuntimeListPresentationServiceHidesUnavailableBetas() { - let response = DownloadableRuntimesResponse( - sdkToSimulatorMappings: [], - sdkToSeedMappings: [], - refreshInterval: 3600, - downloadables: [ - downloadableRuntime( - source: "https://example.com/iOS_17_Runtime.dmg", - simulatorVersion: .init(buildUpdate: "21A1", version: "17.0"), - identifier: "com.apple.CoreSimulator.SimRuntime.iOS-17-0-b1", - name: "iOS 17.0 beta" - ) - ], - version: "2" - ) - - let rows = RuntimeListPresentationService().rows( - downloadableRuntimes: response, - installedRuntimes: [], - includeBetas: false - ) - - XCTAssertEqual(rows.first?.runtimes.map(\.visibleIdentifier), []) - } - - func testRuntimeInstallPolicyUsesArchiveForLegacyRuntime() throws { - let method = try RuntimeInstallPolicy().installMethod( - for: downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg"), - selectedXcodeVersion: nil - ) - - XCTAssertEqual(method, .archive) - } - - func testRuntimeInstallPolicyRequiresSelectedXcodeForCryptexRuntime() { - let runtime = downloadableRuntime( - source: nil, - contentType: .cryptexDiskImage - ) - - XCTAssertThrowsError(try RuntimeInstallPolicy().installMethod(for: runtime, selectedXcodeVersion: nil)) { error in - XCTAssertEqual(error as? RuntimeInstallPolicyError, .noSelectedXcode) - } - } - - func testRuntimeInstallPolicyRequiresXcode26ForAppleSiliconCryptexRuntime() { - let runtime = downloadableRuntime( - source: nil, - architectures: [.arm64], - contentType: .cryptexDiskImage - ) - - XCTAssertThrowsError(try RuntimeInstallPolicy().installMethod(for: runtime, selectedXcodeVersion: Version("16.4.0")!)) { error in - XCTAssertEqual(error as? RuntimeInstallPolicyError, .xcode26OrGreaterRequired(Version("16.4.0")!)) - } - } - - func testRuntimeInstallPolicyUsesXcodebuildForSupportedCryptexRuntime() throws { - let runtime = downloadableRuntime( - source: nil, - architectures: [.arm64], - contentType: .cryptexDiskImage - ) - - let method = try RuntimeInstallPolicy().installMethod( - for: runtime, - selectedXcodeVersion: Version("26.0.0")! - ) - - XCTAssertEqual(method, .xcodebuild(architecture: "arm64")) - } - - func testRuntimeInstallPolicyParsesXcodebuildVersionOutput() { - let version = RuntimeInstallPolicy().selectedXcodeVersion( - fromXcodebuildVersionOutput: """ - Xcode 16.4 - Build version 16F6 - """ - ) - - XCTAssertEqual(version, Version("16.4.0")!) - } - - func testRuntimeXcodebuildInstallServiceDownloadsRuntimeAndYieldsProgress() async throws { - let recorder = PathOperationRecorder() - let service = RuntimeXcodebuildInstallService { platform, buildVersion, architecture in - recorder.record("download:\(platform):\(buildVersion):\(architecture ?? "nil")") - let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) - let firstProgress = Progress(totalUnitCount: 100) - firstProgress.completedUnitCount = 25 - let secondProgress = Progress(totalUnitCount: 100) - secondProgress.completedUnitCount = 100 - - continuation.yield(firstProgress) - continuation.yield(secondProgress) - continuation.finish() - - return stream - } - - try await service.downloadAndInstall( - runtime: downloadableRuntime(source: "https://example.com/runtime.dmg"), - architecture: "arm64" - ) { progress in - recorder.record("progress:\(progress.completedUnitCount)") - } - - XCTAssertEqual(recorder.operations, [ - "download:iOS:20A360:arm64", - "progress:25", - "progress:100" - ]) - } - - func testRuntimeXcodebuildInstallServiceStopsProgressWhenCancelled() async throws { - let recorder = PathOperationRecorder() - let (stream, continuation) = AsyncThrowingStream.makeStream(of: Progress.self, throwing: Error.self) - let service = RuntimeXcodebuildInstallService { platform, buildVersion, architecture in - recorder.record("download:\(platform):\(buildVersion):\(architecture ?? "nil")") - return stream - } - let runtime = downloadableRuntime(source: "https://example.com/runtime.dmg") - - let task = Task { - try await service.downloadAndInstall( - runtime: runtime, - architecture: "arm64" - ) { progress in - recorder.record("progress:\(progress.completedUnitCount)") - } - } - - while recorder.operations.isEmpty { - await Task.yield() - } - - task.cancel() - let progress = Progress(totalUnitCount: 100) - progress.completedUnitCount = 25 - continuation.yield(progress) - continuation.finish() - - do { - try await task.value - XCTFail("Expected cancellation to be thrown") - } catch is CancellationError { - } - - XCTAssertEqual(recorder.operations, [ - "download:iOS:20A360:arm64" - ]) - } - - func testRuntimeArchiveServiceUsesExistingArchiveWhenPresent() async throws { - let runtime = downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg") - let service = RuntimeArchiveService( - fileExists: { $0.string == "/tmp/iOS_16_Runtime.dmg" }, - download: { _, _, _, _, _ in - XCTFail("Expected existing runtime archive to be reused") - return URL(fileURLWithPath: "/tmp/downloaded.dmg") - } - ) - - let url = try await service.archiveURL( - for: runtime, - destinationDirectory: try XCTUnwrap(Path("/tmp")), - downloader: .aria2, - progressChanged: { _ in } - ) - - XCTAssertEqual(url.path, "/tmp/iOS_16_Runtime.dmg") - } - - func testRuntimeArchiveServiceRedownloadsIncompleteAria2Archive() async throws { - let runtime = downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg") - let service = RuntimeArchiveService( - fileExists: { path in - path.string == "/tmp/iOS_16_Runtime.dmg" || path.string == "/tmp/iOS_16_Runtime.dmg.aria2" - }, - download: { runtime, url, destination, downloader, _ in - XCTAssertEqual(runtime.visibleIdentifier, "iOS 16.0") - XCTAssertEqual(url.absoluteString, "https://example.com/iOS_16_Runtime.dmg") - XCTAssertEqual(destination.string, "/tmp/iOS_16_Runtime.dmg") - XCTAssertEqual(downloader, .aria2) - return URL(fileURLWithPath: "/tmp/redownloaded.dmg") - } - ) - - let url = try await service.archiveURL( - for: runtime, - destinationDirectory: try XCTUnwrap(Path("/tmp")), - downloader: .aria2, - progressChanged: { _ in } - ) - - XCTAssertEqual(url.path, "/tmp/redownloaded.dmg") - } - - func testRuntimeInstallationLookupServiceFindsInstalledRuntimeByBuild() { - let runtime = downloadableRuntime(source: "https://example.com/iOS_16_Runtime.dmg") - let installedRuntime = coreSimulatorImage( - build: runtime.simulatorVersion.buildUpdate, - path: "file:///Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime" - ) - - let service = RuntimeInstallationLookupService() - - XCTAssertEqual( - service.coreSimulatorImage(for: runtime, in: [installedRuntime])?.uuid, - installedRuntime.uuid - ) - XCTAssertEqual( - service.installPath(for: runtime, in: [installedRuntime])?.string, - "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime" - ) - } - - func testRuntimeInstallationLookupServiceMatchesArchitecturesWhenRuntimeRequiresThem() { - let runtime = downloadableRuntime( - source: "https://example.com/iOS_16_Runtime.dmg", - architectures: [.arm64] - ) - let x86Runtime = coreSimulatorImage( - build: runtime.simulatorVersion.buildUpdate, - supportedArchitectures: [.x86_64] - ) - let armRuntime = coreSimulatorImage( - build: runtime.simulatorVersion.buildUpdate, - supportedArchitectures: [.arm64] - ) - - let image = RuntimeInstallationLookupService().coreSimulatorImage( - for: runtime, - in: [x86Runtime, armRuntime] - ) - - XCTAssertEqual(image?.uuid, armRuntime.uuid) - } - - func testProcessProgressStreamRunnerYieldsOutputProgress() async throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "printf 'Downloading iOS Simulator: 42.0%% (1.2 GB of 2.8 GB)'"] - let collector = ProcessOutputCollector() - let progress = Progress() - - let stream = ProcessProgressStreamRunner( - process: process, - progress: progress, - outputHandler: { string, progress in - collector.append(string) - progress.updateFromXcodebuild(text: string) - }, - failureHandler: { process in - ProcessExecutionError(process: process, standardOutput: "", standardError: "") - } - ).stream() - - var emittedProgress: [Progress] = [] - for try await progress in stream { - emittedProgress.append(progress) - } - - XCTAssertEqual(collector.output, "Downloading iOS Simulator: 42.0% (1.2 GB of 2.8 GB)") - XCTAssertFalse(emittedProgress.isEmpty) - XCTAssertEqual(progress.fractionCompleted, 0.42, accuracy: 0.001) - } - - func testProcessProgressStreamRunnerDrainsLargeOutputWhileProcessIsRunning() async throws { - let line = String(repeating: "0123456789abcdef", count: 16) - let lineCount = 20_000 - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = [ - "-c", - "yes '\(line)' | head -n \(lineCount)" - ] - - let collector = ProcessOutputCollector() - let stream = ProcessProgressStreamRunner( - process: process, - progress: Progress(), - outputHandler: { string, _ in - collector.append(string) - }, - failureHandler: { process in - ProcessExecutionError(process: process, standardOutput: "", standardError: "") - } - ).stream() - - for try await _ in stream {} - - let expectedOutput = String(repeating: "\(line)\n", count: lineCount) - XCTAssertEqual(collector.output, expectedOutput) - } - - func testProcessProgressStreamRunnerThrowsFailureHandlerError() async { - enum TestError: Error, Equatable { - case failed(Int32) - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "exit 12"] - - let stream = ProcessProgressStreamRunner( - process: process, - progress: Progress(), - outputHandler: { _, _ in }, - failureHandler: { process in - TestError.failed(process.terminationStatus) - } - ).stream() - - do { - for try await _ in stream {} - XCTFail("Expected process failure to throw") - } catch { - XCTAssertEqual(error as? TestError, .failed(12)) - } - } - - func testAsyncProcessRunnerDrainsLargeOutputWhileProcessIsRunning() async throws { - let line = String(repeating: "0123456789abcdef", count: 16) - let lineCount = 20_000 - - let output = try await XcodesProcess.run( - URL(fileURLWithPath: "/bin/sh"), - [ - "-c", - "yes '\(line)' | head -n \(lineCount)" - ] - ) - - XCTAssertEqual(output.status, 0) - XCTAssertTrue(output.err.isEmpty) - XCTAssertEqual(output.out.split(separator: "\n").count, lineCount) - } - - private func downloadableRuntime( - source: String?, - architectures: [Architecture]? = nil, - contentType: DownloadableRuntime.ContentType = .diskImage, - simulatorVersion: DownloadableRuntime.SimulatorVersion = .init(buildUpdate: "20A360", version: "16.0"), - identifier: String = "com.apple.CoreSimulator.SimRuntime.iOS-16-0", - name: String = "iOS 16.0" - ) -> DownloadableRuntime { - DownloadableRuntime( - category: .simulator, - simulatorVersion: simulatorVersion, - source: source, - architectures: architectures, - dictionaryVersion: 1, - contentType: contentType, - platform: .iOS, - identifier: identifier, - version: simulatorVersion.version, - fileSize: 42, - hostRequirements: nil, - name: name, - authentication: nil - ) - } - - private func installedRuntime(build: String, kind: InstalledRuntime.Kind) -> InstalledRuntime { - InstalledRuntime( - build: build, - deletable: true, - identifier: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, - kind: kind, - lastUsedAt: nil, - path: "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime", - platformIdentifier: .iOS, - runtimeBundlePath: "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime", - runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-16-0", - signatureState: "Verified", - state: "Ready", - version: "16.0", - sizeBytes: 42, - supportedArchitectures: nil - ) - } - - private func coreSimulatorImage( - build: String, - path: String = "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 16.simruntime", - supportedArchitectures: [Architecture]? = nil - ) -> CoreSimulatorImage { - CoreSimulatorImage( - uuid: UUID().uuidString, - path: ["relative": path], - runtimeInfo: CoreSimulatorRuntimeInfo( - build: build, - supportedArchitectures: supportedArchitectures - ) - ) - } -} - -private actor XcodePostInstallRecorder { - private(set) var didRunFirstLaunch = false - private(set) var touchedInstallCheck: (cacheDirectory: String, macOSBuildVersion: String, toolsVersion: String)? - - func recordFirstLaunch() { - didRunFirstLaunch = true - } - - func recordInstallCheck(cacheDirectory: String, macOSBuildVersion: String, toolsVersion: String) { - touchedInstallCheck = (cacheDirectory, macOSBuildVersion, toolsVersion) - } -} - -private enum XcodeInstallRetryTestError: Error, Equatable { - case damagedArchive(URL) -} - -private actor XcodeArchiveInstallStepRecorder { - private var steps: [XcodeArchiveInstallStep] = [] - - func record(_ step: XcodeArchiveInstallStep) { - steps.append(step) - } - - func recordedSteps() -> [XcodeArchiveInstallStep] { - steps - } -} - -private enum XcodePostInstallPreparationEvent: Equatable { - case enableDeveloperTools - case addStaffToDevelopersGroup - case acceptLicense -} - -private actor XcodePostInstallPreparationRecorder { - private(set) var events: [XcodePostInstallPreparationEvent] = [] - - func record(_ event: XcodePostInstallPreparationEvent) { - events.append(event) - } -} - -private actor RetryRecorder { - private(set) var values: [Value] = [] - - var count: Int { - values.count - } - - @discardableResult - func record(_ value: Value) -> Int { - values.append(value) - return values.count - } -} - -private final class DownloadRecorder: Sendable { - private struct State: Sendable { - var urlSessionResumeData: Data? - var removedURLs: [URL] = [] - var createdFiles: [(path: String, data: Data)] = [] - var progressCount = 0 - } - - private let state = OSAllocatedUnfairLock(initialState: State()) - - var urlSessionResumeData: Data? { - state.withLock { $0.urlSessionResumeData } - } - - var removedURLs: [URL] { - state.withLock { $0.removedURLs } - } - - var createdFiles: [(path: String, data: Data)] { - state.withLock { $0.createdFiles } - } - - var progressCount: Int { - state.withLock { $0.progressCount } - } - - func recordURLSession(url: URL, destination: URL, resumeData: Data?) { - state.withLock { $0.urlSessionResumeData = resumeData } - } - - func recordRemovedURL(_ url: URL) { - state.withLock { $0.removedURLs.append(url) } - } - - func recordCreatedFile(path: String, data: Data) { - state.withLock { $0.createdFiles.append((path, data)) } - } - - func recordProgress(_ progress: Progress) { - state.withLock { $0.progressCount += 1 } - } -} - -private final class ProcessOutputCollector: Sendable { - private let chunks = OSAllocatedUnfairLock(initialState: [String]()) - - var output: String { - chunks.withLock { $0.joined() } - } - - func append(_ chunk: String) { - chunks.withLock { $0.append(chunk) } - } -} - -private final class URLRecorder: Sendable { - private let storedURL = OSAllocatedUnfairLock(initialState: nil) - - var url: URL? { - storedURL.withLock { $0 } - } - - func record(_ url: URL) { - storedURL.withLock { $0 = url } - } -} - -private final class URLListRecorder: Sendable { - private let storedURLs = OSAllocatedUnfairLock(initialState: [URL]()) - - var paths: [String] { - storedURLs.withLock { $0.map(\.path) } - } - - func record(_ url: URL) { - storedURLs.withLock { $0.append(url) } - } -} - -private final class PathOperationRecorder: Sendable { - private let storedOperations = OSAllocatedUnfairLock(initialState: [String]()) - - var operations: [String] { - storedOperations.withLock { $0 } - } - - func record(_ operation: String) { - storedOperations.withLock { $0.append(operation) } - } -} - -private final class RuntimePackageInstallRecorder: Sendable { - private struct State: Sendable { - var steps: [String] = [] - var packageInfo: String? - } - - private let state = OSAllocatedUnfairLock(initialState: State()) - - var steps: [String] { - state.withLock { $0.steps } - } - - var rewrittenPackageInfo: String? { - get { - state.withLock { $0.packageInfo } - } - set { - state.withLock { $0.packageInfo = newValue } - } - } - - func append(_ step: String) { - state.withLock { $0.steps.append(step) } - } -} - -private final class RuntimeCacheFileRecorder: Sendable { - private struct State: Sendable { - var data: Data? - var directory: URL? - var url: URL? - } - - private let state = OSAllocatedUnfairLock(initialState: State()) - - var storedData: Data? { - state.withLock { $0.data } - } - - var createdDirectory: URL? { - state.withLock { $0.directory } - } - - var writtenURL: URL? { - state.withLock { $0.url } - } - - func recordWrite(data: Data, url: URL) { - state.withLock { - $0.data = data - $0.url = url - } - } - - func recordCreatedDirectory(_ url: URL) { - state.withLock { $0.directory = url } - } -} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesPathResolverTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesPathResolverTests.swift deleted file mode 100644 index f3faf4ff..00000000 --- a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesPathResolverTests.swift +++ /dev/null @@ -1,88 +0,0 @@ -import XCTest -@preconcurrency import Path -@testable import XcodesKit - -final class XcodesPathResolverTests: XCTestCase { - func testAppApplicationSupportUsesSavedPathWhenValid() throws { - let path = try XCTUnwrap(Path("/tmp/custom-xcodes-support")) - - XCTAssertEqual( - XcodesPathResolver.appApplicationSupport(savedPath: path.string), - path - ) - } - - func testAppApplicationSupportFallsBackToAppDefault() { - XCTAssertEqual( - XcodesPathResolver.appApplicationSupport(savedPath: nil), - XcodesPathResolver.appDefaultApplicationSupport - ) - } - - func testAppInstallDirectoryUsesSavedPathWhenValid() throws { - let path = try XCTUnwrap(Path("/tmp/Xcodes")) - - XCTAssertEqual( - XcodesPathResolver.appInstallDirectory(savedPath: path.string), - path - ) - } - - func testAppInstallDirectoryFallsBackToAppDefault() { - XCTAssertEqual( - XcodesPathResolver.appInstallDirectory(savedPath: nil), - XcodesPathResolver.appDefaultInstallDirectory - ) - } - - func testCacheFilePathsAreDerivedFromApplicationSupport() throws { - let supportPath = try XCTUnwrap(Path("/tmp/xcodes-support")) - - XCTAssertEqual( - XcodesPathResolver.availableXcodesCacheFile(in: supportPath), - supportPath/"available-xcodes.json" - ) - XCTAssertEqual( - XcodesPathResolver.downloadableRuntimesCacheFile(in: supportPath), - supportPath/"downloadable-runtimes.json" - ) - } - - func testCLIPathsAreDerivedFromEnvironmentHome() throws { - let home = try XCTUnwrap(Path("/Users/example")) - - XCTAssertEqual( - XcodesPathResolver.cliHome(environment: ["HOME": home.string]), - home - ) - XCTAssertEqual( - XcodesPathResolver.cliApplicationSupport(home: home), - home/"Library/Application Support/com.robotsandpencils.xcodes" - ) - XCTAssertEqual( - XcodesPathResolver.cliOldApplicationSupport(home: home), - home/"Library/Application Support/ca.brandonevans.xcodes" - ) - XCTAssertEqual( - XcodesPathResolver.cliCaches(home: home), - home/"Library/Caches/com.robotsandpencils.xcodes" - ) - XCTAssertEqual( - XcodesPathResolver.cliDownloads(home: home), - home/"Downloads" - ) - } - - func testCLIConfigurationFileIsDerivedFromApplicationSupport() throws { - let supportPath = try XCTUnwrap(Path("/tmp/xcodes-support")) - - XCTAssertEqual( - XcodesPathResolver.cliAvailableXcodesCacheFile(applicationSupport: supportPath), - supportPath/"available-xcodes.json" - ) - XCTAssertEqual( - XcodesPathResolver.cliConfigurationFile(applicationSupport: supportPath), - supportPath/"configuration.json" - ) - } -} From c3ba75be7bdcaa202ab1559188ac623fce08da12 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 25 May 2026 11:53:07 -0500 Subject: [PATCH 10/18] duplicate version updates --- Xcodes.xcodeproj/project.pbxproj | 22 +++++++++---------- Xcodes/Backend/Xcode.swift | 4 ++++ .../InfoPane/IdenticalBuildView.swift | 15 ++++++++----- Xcodes/Frontend/InfoPane/InfoPane.swift | 2 +- .../Frontend/XcodeList/XcodeListViewRow.swift | 4 ++-- 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 41535880..b5b9cf3a 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -47,7 +47,6 @@ CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */; }; CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8652595130600E47BAF /* View+IsHidden.swift */; }; CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF87A2595293E00E47BAF /* DataSource.swift */; }; - CAA858C025A2BE4E00ACF8C0 /* View+ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA858BF25A2BE4E00ACF8C0 /* View+ErrorHandling.swift */; }; CA9FF8B12595967A00E47BAF /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8B02595967A00E47BAF /* main.swift */; }; CA9FF8CF25959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; CA9FF8D025959A9700E47BAF /* HelperXPCShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */; }; @@ -56,12 +55,12 @@ CA9FF8E625959BB800E47BAF /* AuditTokenHack.m in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8E525959BB800E47BAF /* AuditTokenHack.m */; }; CA9FF9362595B44700E47BAF /* HelperClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF9352595B44700E47BAF /* HelperClient.swift */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; - E89CBD402D6434E10037ED95 /* SignInFederatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */; }; CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */; }; CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */; }; CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */; }; CAA8587C25A2B37900ACF8C0 /* IsTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */; }; CAA8589325A2B77E00ACF8C0 /* aria2c in Copy aria2c */ = {isa = PBXBuildFile; fileRef = CAA8588025A2B63A00ACF8C0 /* aria2c */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + CAA858C025A2BE4E00ACF8C0 /* View+ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA858BF25A2BE4E00ACF8C0 /* View+ErrorHandling.swift */; }; CAA858C425A2BE4E00ACF8C0 /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA858C325A2BE4E00ACF8C0 /* Downloader.swift */; }; CAA858DB25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz in Resources */ = {isa = PBXBuildFile; fileRef = CAA858DA25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz */; }; CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7B72449575100113D76 /* AppStateTests.swift */; }; @@ -115,6 +114,7 @@ E8977EA325C11E1500835F80 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8977EA225C11E1500835F80 /* PreferencesView.swift */; }; E89CBD382D5FAB950037ED95 /* XcodesLoginKit in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD372D5FAB950037ED95 /* XcodesLoginKit */; }; E89CBD3A2D5FB8920037ED95 /* XcodesLoginKitSecurityKey in Frameworks */ = {isa = PBXBuildFile; productRef = E89CBD392D5FB8920037ED95 /* XcodesLoginKitSecurityKey */; }; + E89CBD402D6434E10037ED95 /* SignInFederatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */; }; E8B20CBF2A2EDEC20057D816 /* SDKs+Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */; }; E8C0EB1A291EF43E0081528A /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = E8C0EB19291EF43E0081528A /* XcodesKit */; }; E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */; }; @@ -242,7 +242,6 @@ CA9FF9252595A7EB00E47BAF /* Scripts */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Scripts; sourceTree = ""; }; CA9FF9352595B44700E47BAF /* HelperClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperClient.swift; sourceTree = ""; }; CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsView.swift; sourceTree = ""; }; - E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInFederatedView.swift; sourceTree = ""; }; CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn2FAView.swift; sourceTree = ""; }; CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSMSView.swift; sourceTree = ""; }; CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInPhoneListView.swift; sourceTree = ""; }; @@ -307,6 +306,7 @@ E89342F925EDCC17007CF557 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; E8977EA225C11E1500835F80 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; E89CBD3B2D5FC0B10037ED95 /* XcodesLoginKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesLoginKit; path = ../XcodesLoginKit; sourceTree = ""; }; + E89CBD3F2D6434E10037ED95 /* SignInFederatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInFederatedView.swift; sourceTree = ""; }; E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SDKs+Xcode.swift"; sourceTree = ""; }; E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Runtimes.swift"; sourceTree = ""; }; E8CBDB8627ADD92000B22292 /* unxip */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = unxip; sourceTree = ""; }; @@ -1513,6 +1513,14 @@ minimumVersion = 1.0.5; }; }; + E856BB74291EDD3D00DC438B /* XCRemoteSwiftPackageReference "XcodesKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/XcodesOrg/XcodesKit"; + requirement = { + branch = main; + kind = branch; + }; + }; E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mxcl/Path.swift"; @@ -1529,14 +1537,6 @@ kind = branch; }; }; - E856BB74291EDD3D00DC438B /* XCRemoteSwiftPackageReference "XcodesKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/XcodesOrg/XcodesKit"; - requirement = { - branch = main; - kind = branch; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index 21f6ea99..5d65313b 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -84,6 +84,10 @@ struct Xcode: Identifiable, CustomStringConvertible { var description: String { version.appleDescription } + + var identicalBuildsForCurrentVariant: [XcodeID] { + identicalBuilds.filter { $0.architectures == architectures } + } var downloadFileSizeString: String? { listItem.downloadFileSizeString diff --git a/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift b/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift index 47f38c73..6d76d5ed 100644 --- a/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift +++ b/Xcodes/Frontend/InfoPane/IdenticalBuildView.swift @@ -11,7 +11,7 @@ import Version import XcodesKit struct IdenticalBuildsView: View { - let builds: [Version] + let builds: [XcodeID] private let isEmpty: Bool private let accessibilityDescription: String @@ -29,8 +29,8 @@ struct IdenticalBuildsView: View { } .font(.headline) - ForEach(builds, id: \.description) { version in - Text(verbatim: "• \(version.appleDescription)") + ForEach(builds) { build in + Text(verbatim: "• \(build.version.appleDescription)") .font(.subheadline) } } @@ -42,17 +42,20 @@ struct IdenticalBuildsView: View { } } - init(builds: [Version]) { + init(builds: [XcodeID]) { self.builds = builds self.isEmpty = builds.isEmpty self.accessibilityDescription = builds - .map(\.appleDescription) + .map(\.version.appleDescription) .joined(separator: ", ") } } @MainActor -private let previewBuilds: [Version] = [.init(xcodeVersion: "15.0")!, .init(xcodeVersion: "15.1")!] +private let previewBuilds: [XcodeID] = [ + .init(version: .init(xcodeVersion: "15.0")!), + .init(version: .init(xcodeVersion: "15.1")!) +] #Preview("Has Some Builds") { IdenticalBuildsView(builds: previewBuilds) diff --git a/Xcodes/Frontend/InfoPane/InfoPane.swift b/Xcodes/Frontend/InfoPane/InfoPane.swift index 4a5a14f6..b557e748 100644 --- a/Xcodes/Frontend/InfoPane/InfoPane.swift +++ b/Xcodes/Frontend/InfoPane/InfoPane.swift @@ -40,7 +40,7 @@ struct InfoPane: View { VStack(alignment: .leading) { ReleaseDateView(date: xcode.releaseDate, url: xcode.releaseNotesURL) CompatibilityView(requiredMacOSVersion: xcode.requiredMacOSVersion) - IdenticalBuildsView(builds: xcode.identicalBuilds.map { $0.version }) + IdenticalBuildsView(builds: xcode.identicalBuildsForCurrentVariant) SDKandCompilers } .frame(width: 200) diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index d2a350b2..f110a1c2 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -17,12 +17,12 @@ struct XcodeListViewRow: View { Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") .font(.body) - if !xcode.identicalBuilds.isEmpty { + if !xcode.identicalBuildsForCurrentVariant.isEmpty { Image(systemName: "square.fill.on.square.fill") .font(.subheadline) .foregroundColor(.secondary) .accessibility(label: Text("IdenticalBuilds")) - .accessibility(value: Text(xcode.identicalBuilds.map(\.version.appleDescription).joined(separator: ", "))) + .accessibility(value: Text(xcode.identicalBuildsForCurrentVariant.map(\.version.appleDescription).joined(separator: ", "))) .help("IdenticalBuilds.help") } From ca0141dc278b9c82668a93edba3c8f3ca18dddca Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 25 May 2026 12:48:10 -0500 Subject: [PATCH 11/18] v1.0.0 xcodeskit --- Xcodes.xcodeproj/project.pbxproj | 4 ++-- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index b5b9cf3a..1c1518f6 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -1517,8 +1517,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/XcodesOrg/XcodesKit"; requirement = { - branch = main; - kind = branch; + kind = upToNextMinorVersion; + minimumVersion = 1.0.0; }; }; E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */ = { diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6df19b1c..702dc0fa 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -122,9 +122,9 @@ "package": "XcodesKit", "repositoryURL": "https://github.com/XcodesOrg/XcodesKit", "state": { - "branch": "main", - "revision": "5bc314e4f78590cf2b37487e648d290de8e3d1f2", - "version": null + "branch": null, + "revision": "6bce2b7f587af55321f060ab29e5b81dc4fc6d15", + "version": "1.0.0" } }, { From 1fd505e1ad9073280d35ffeec2b501ce1308cefe Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 25 May 2026 13:29:01 -0500 Subject: [PATCH 12/18] fix missing runtimes state --- .../xcshareddata/swiftpm/Package.resolved | 4 +- Xcodes/Frontend/InfoPane/PlatformsView.swift | 67 +++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 702dc0fa..3dd30238 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -123,8 +123,8 @@ "repositoryURL": "https://github.com/XcodesOrg/XcodesKit", "state": { "branch": null, - "revision": "6bce2b7f587af55321f060ab29e5b81dc4fc6d15", - "version": "1.0.0" + "revision": "5bee2a2e39bf5a6e1ab2dc2ec90a45213db1e68b", + "version": "1.0.1" } }, { diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index 15e3e97b..8f277999 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -18,7 +18,7 @@ struct PlatformsView: View { var body: some View { let builds = xcode.sdks?.allBuilds - let runtimes = builds?.flatMap { sdkBuild in + let runtimes = (builds?.flatMap { sdkBuild in appState.downloadableRuntimes.filter { $0.sdkBuildUpdate?.contains(sdkBuild) ?? false && ($0.architectures?.isEmpty ?? true || @@ -26,9 +26,9 @@ struct PlatformsView: View { ($0.architectures?.isAppleSilicon ?? false && selectedVariant == .appleSilicon) ) } - } + } ?? []).removingReleaseCandidateDisplayDuplicates(installedRuntimes: appState.installedRuntimes) - let architectures = Set((runtimes ?? []).flatMap { $0.architectures ?? [] }) + let architectures = Set(runtimes.flatMap { $0.architectures ?? [] }) VStack { HStack { @@ -52,7 +52,7 @@ struct PlatformsView: View { } } - ForEach(runtimes ?? [], id: \.identifier) { runtime in + ForEach(runtimes, id: \.identifier) { runtime in runtimeView(runtime: runtime) .frame(minWidth: 200) .padding() @@ -125,6 +125,65 @@ struct PlatformsView: View { } } +private struct RuntimeDisplayKey: Hashable { + let platform: DownloadableRuntime.Platform + let version: String + let architectures: [String] + + init(_ runtime: DownloadableRuntime) { + platform = runtime.platform + version = runtime.completeVersion + architectures = (runtime.architectures ?? []).map(\.rawValue).sorted() + } +} + +private extension DownloadableRuntime { + var isReleaseCandidate: Bool { + name.localizedCaseInsensitiveContains("Release Candidate") || + identifier.localizedCaseInsensitiveContains("_rc") + } + + func shouldReplace(_ other: DownloadableRuntime, installedRuntimes: [CoreSimulatorImage]) -> Bool { + let isInstalled = RuntimeInstallationLookupService() + .coreSimulatorImage(for: self, in: installedRuntimes) != nil + let otherIsInstalled = RuntimeInstallationLookupService() + .coreSimulatorImage(for: other, in: installedRuntimes) != nil + + if isInstalled != otherIsInstalled { + return isInstalled + } + + if isReleaseCandidate != other.isReleaseCandidate { + return !isReleaseCandidate + } + + return simulatorVersion.buildUpdate.localizedStandardCompare(other.simulatorVersion.buildUpdate) == .orderedDescending + } +} + +private extension Array where Element == DownloadableRuntime { + func removingReleaseCandidateDisplayDuplicates(installedRuntimes: [CoreSimulatorImage]) -> [DownloadableRuntime] { + var runtimesByKey: [RuntimeDisplayKey: DownloadableRuntime] = [:] + var keys: [RuntimeDisplayKey] = [] + + for runtime in self { + let key = RuntimeDisplayKey(runtime) + + guard let existingRuntime = runtimesByKey[key] else { + runtimesByKey[key] = runtime + keys.append(key) + continue + } + + if runtime.shouldReplace(existingRuntime, installedRuntimes: installedRuntimes) { + runtimesByKey[key] = runtime + } + } + + return keys.compactMap { runtimesByKey[$0] } + } +} + #Preview(XcodePreviewName.allCases[0].rawValue) { @MainActor in makePreviewContent(for: 0) } @MainActor From e3b3b2171aabebbd5295fe27ff5a73d6f7e877fb Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Wed, 3 Jun 2026 21:19:15 -0500 Subject: [PATCH 13/18] v 4.0.0 bump --- Xcodes.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 1c1518f6..1013c71c 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -1065,7 +1065,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; @@ -1077,7 +1077,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 3.0.2; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1318,7 +1318,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; @@ -1330,7 +1330,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 3.0.2; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; SWIFT_VERSION = 6.0; @@ -1347,7 +1347,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; @@ -1359,7 +1359,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 3.0.2; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.xcodesorg.xcodesapp; PRODUCT_NAME = Xcodes; SWIFT_VERSION = 6.0; From d905ae707c31960633d352eae628f439c48f8d98 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Wed, 3 Jun 2026 21:44:49 -0500 Subject: [PATCH 14/18] on grouped list, keep the same size of icons --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 5 +++-- Xcodes/Frontend/XcodeList/XcodeListViewRow.swift | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index efef4085..6fc3e20f 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -190,6 +190,7 @@ private struct XcodeVersionGroupRow: View { Image(systemName: isExpanded ? "chevron.down" : "chevron.right") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) + .frame(width: 12, height: 12) icon @@ -223,11 +224,11 @@ private struct XcodeVersionGroupRow: View { if let icon = latestRelease?.icon { Image(nsImage: icon) .resizable() - .frame(width: indentation == 0 ? 32 : 28, height: indentation == 0 ? 32 : 28) + .frame(width: 32, height: 32) } else { Image(latestRelease?.version.isPrerelease == true ? "xcode-beta" : "xcode") .resizable() - .frame(width: indentation == 0 ? 32 : 28, height: indentation == 0 ? 32 : 28) + .frame(width: 32, height: 32) .opacity(0.2) } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index f110a1c2..a19806b2 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -85,6 +85,8 @@ struct XcodeListViewRow: View { func appIconView(for xcode: Xcode) -> some View { if let icon = xcode.icon { Image(nsImage: icon) + .resizable() + .frame(width: 32, height: 32) } else { Image(xcode.version.isPrerelease ? "xcode-beta" : "xcode") .resizable() From 0ffb4bbd27b3f83cb51c124b6f453255463387bc Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Wed, 3 Jun 2026 22:09:42 -0500 Subject: [PATCH 15/18] better selected state or list rows --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 105 +++++++++++++++++- .../Frontend/XcodeList/XcodeListViewRow.swift | 53 ++++++++- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 6fc3e20f..3966edde 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -32,18 +32,28 @@ struct XcodeListView: View { installedOnly: isInstalledOnly ), item: \.listItem) } + + private func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? { + appState.allXcodes.latestReleaseForSelectedPrerelease(xcode) + } var body: some View { List(selection: $selectedXcodeID) { if appState.enableGroupedXcodeList { GroupedXcodeListContent( xcodes: visibleXcodes, + allXcodes: appState.allXcodes, selectedXcodeID: $selectedXcodeID, appState: appState ) } else { ForEach(visibleXcodes) { entry in - XcodeListViewRow(xcode: entry.xcode, selected: selectedXcodeID == entry.xcode.id, appState: appState) + XcodeListViewRow( + xcode: entry.xcode, + selected: selectedXcodeID == entry.xcode.id, + appState: appState, + latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + ) .tag(entry.xcode.id) } } @@ -73,6 +83,7 @@ private struct XcodeListEntry: Identifiable { private struct GroupedXcodeListContent: View { let xcodes: [XcodeListEntry] + let allXcodes: [Xcode] @Binding var selectedXcodeID: Xcode.ID? let appState: AppState @@ -101,14 +112,22 @@ private struct GroupedXcodeListContent: View { xcodes.groupedByMajorVersion(item: \.listItem) } + private func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? { + allXcodes.latestReleaseForSelectedPrerelease(xcode) + } + var body: some View { ForEach(majorVersionGroups) { majorVersionGroup in let isMajorExpanded = expandedMajorVersions.contains(majorVersionGroup.majorVersion) let majorVersions = majorVersionGroup.versions.map(\.xcode) + let latestMajorRelease = majorVersions.latestRelease + let latestInstalledMajorVersion = majorVersions.latestInstalledVersion XcodeVersionGroupRow( displayName: majorVersionGroup.displayName, - latestRelease: majorVersions.latestRelease, + latestRelease: latestMajorRelease, + latestSelectableRelease: latestMajorRelease, + latestSelectionTarget: latestInstalledMajorVersion, selectedVersion: majorVersions.first { $0.selected }, installingVersion: majorVersions.first { $0.installState.installing }, isExpanded: isMajorExpanded, @@ -137,10 +156,13 @@ private struct GroupedXcodeListContent: View { ForEach(majorVersionGroup.minorVersionGroups) { minorVersionGroup in let isMinorExpanded = expandedMinorVersions.contains(minorVersionGroup.id) let minorVersions = minorVersionGroup.versions.map(\.xcode) + let latestInstalledMinorVersion = minorVersions.latestInstalledVersion XcodeVersionGroupRow( displayName: minorVersionGroup.displayName, latestRelease: minorVersions.latestRelease, + latestSelectableRelease: latestMajorRelease, + latestSelectionTarget: latestInstalledMinorVersion, selectedVersion: minorVersions.first { $0.selected }, installingVersion: minorVersions.first { $0.installState.installing }, isExpanded: isMinorExpanded, @@ -162,7 +184,12 @@ private struct GroupedXcodeListContent: View { if isMinorExpanded { ForEach(minorVersionGroup.versions) { entry in - XcodeListViewRow(xcode: entry.xcode, selected: selectedXcodeID == entry.xcode.id, appState: appState) + XcodeListViewRow( + xcode: entry.xcode, + selected: selectedXcodeID == entry.xcode.id, + appState: appState, + latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + ) .padding(.leading, 40) .tag(entry.xcode.id) } @@ -176,6 +203,8 @@ private struct GroupedXcodeListContent: View { private struct XcodeVersionGroupRow: View { let displayName: String let latestRelease: Xcode? + let latestSelectableRelease: Xcode? + let latestSelectionTarget: Xcode? let selectedVersion: Xcode? let installingVersion: Xcode? let isExpanded: Bool @@ -235,10 +264,58 @@ private struct XcodeVersionGroupRow: View { @ViewBuilder private var selectControl: some View { - if selectedVersion?.selected == true { + if let selectedVersion, selectedVersion.selected, let latestSelectableRelease, latestSelectableRelease.id != selectedVersion.id { + switch latestSelectionTarget?.installState { + case .installed: + if let latestSelectionTarget, latestSelectionTarget.id != selectedVersion.id { + Button(action: { appState.select(xcode: latestSelectionTarget) }) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + } + .buttonStyle(PlainButtonStyle()) + .help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget)) + } else { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + .help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget)) + } + case .notInstalled: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + .help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget)) + case .installing, .none: + EmptyView() + } + } else if selectedVersion?.selected == true { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) .help("ActiveVersionDescription") + } else if let latestSelectionTarget { + Button(action: { appState.select(xcode: latestSelectionTarget) }) { + Image(systemName: "checkmark.circle") + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + .help("MakeActiveVersionDescription") + } + } + + private func staleSelectedHelpText(selectedVersion: Xcode, latestRelease: Xcode, selectionTarget: Xcode?) -> Text { + switch selectionTarget?.installState { + case .installed: + if let selectionTarget, selectionTarget.id != selectedVersion.id { + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available. Click to select \(selectionTarget.description).") + } else { + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") + } + case .notInstalled: + if let selectionTarget { + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available. Install \(selectionTarget.description) to select it.") + } else { + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") + } + case .installing, .none: + return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") } } @@ -278,6 +355,26 @@ private extension Array where Element == Xcode { .sorted { $0.version < $1.version } .last } + + var latestInstalledVersion: Xcode? { + filter(\.installState.installed) + .sorted { $0.version < $1.version } + .last + } + + func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? { + guard xcode.selected, xcode.version.isPrerelease else { return nil } + + return first { candidate in + candidate.id != xcode.id && + candidate.architectures == xcode.architectures && + candidate.version.major == xcode.version.major && + candidate.version.minor == xcode.version.minor && + candidate.version.patch == xcode.version.patch && + candidate.version.isNotPrerelease && + candidate.installState.installing == false + } + } } struct PlatformsPocket: View { diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index a19806b2..166b9d09 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -7,6 +7,14 @@ struct XcodeListViewRow: View { let xcode: Xcode let selected: Bool let appState: AppState + let latestReleaseForSelectedPrerelease: Xcode? + + init(xcode: Xcode, selected: Bool, appState: AppState, latestReleaseForSelectedPrerelease: Xcode? = nil) { + self.xcode = xcode + self.selected = selected + self.appState = appState + self.latestReleaseForSelectedPrerelease = latestReleaseForSelectedPrerelease + } var body: some View { HStack { @@ -98,7 +106,23 @@ struct XcodeListViewRow: View { @ViewBuilder private func selectControl(for xcode: Xcode) -> some View { if xcode.installState.installed { - if xcode.selected { + if let latestReleaseForSelectedPrerelease, xcode.selected { + switch latestReleaseForSelectedPrerelease.installState { + case .installed: + Button(action: { appState.select(xcode: latestReleaseForSelectedPrerelease) }) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + } + .buttonStyle(PlainButtonStyle()) + .help(staleSelectedHelpText) + case .notInstalled: + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.yellow) + .help(staleSelectedHelpText) + case .installing: + EmptyView() + } + } else if xcode.selected { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) .help("ActiveVersionDescription") @@ -117,6 +141,19 @@ struct XcodeListViewRow: View { @ViewBuilder private func installControl(for xcode: Xcode) -> some View { + if let latestReleaseForSelectedPrerelease, + xcode.selected, + latestReleaseForSelectedPrerelease.installState == .notInstalled { + InstallButton(xcode: latestReleaseForSelectedPrerelease) + .textCase(.uppercase) + .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) + } else { + installStateControl(for: xcode) + } + } + + @ViewBuilder + private func installStateControl(for xcode: Xcode) -> some View { switch xcode.installState { case .installed: Button("Open") { appState.open(xcode: xcode) } @@ -135,6 +172,20 @@ struct XcodeListViewRow: View { ) } } + + private var staleSelectedHelpText: Text { + let selectedVersion = xcode.version.appleDescription + let latestVersion = latestReleaseForSelectedPrerelease?.version.appleDescription ?? "" + + switch latestReleaseForSelectedPrerelease?.installState { + case .installed: + return Text(verbatim: "\(selectedVersion) selected, \(latestVersion) available. Click to select \(latestVersion).") + case .notInstalled: + return Text(verbatim: "\(selectedVersion) selected, \(latestVersion) available. Install \(latestVersion) to select it.") + case .installing, .none: + return Text("ActiveVersionDescription") + } + } } struct XcodeListViewRow_Previews: PreviewProvider { From 1191e6e8d34f0f25c575f32c47dc62bbcb1f382a Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Wed, 3 Jun 2026 22:15:55 -0500 Subject: [PATCH 16/18] fix platform label tap content shape --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 3966edde..563f99c9 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -408,6 +408,7 @@ struct PlatformsPocket: View { .font(.body.weight(.medium)) .padding(.horizontal) .padding(.vertical, 12) + .contentShape(Rectangle()) } } From dcb8f32e0eddf89abd534c3507b13d3f54e8be81 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Wed, 3 Jun 2026 22:18:55 -0500 Subject: [PATCH 17/18] updated tap area of xcode list rows --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 563f99c9..ce75938e 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -236,6 +236,8 @@ private struct XcodeVersionGroupRow: View { Spacer() } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } .buttonStyle(.plain) From c31ac5f0f2af928a9c5b9fec8557e78a7aff81d9 Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Thu, 4 Jun 2026 22:58:55 -0500 Subject: [PATCH 18/18] bump to xcodeskit 1.0.3 --- Xcodes.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 1013c71c..e0763bb3 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -1518,7 +1518,7 @@ repositoryURL = "https://github.com/XcodesOrg/XcodesKit"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 1.0.0; + minimumVersion = 1.0.3; }; }; E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */ = { diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3dd30238..3c48feea 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -123,8 +123,8 @@ "repositoryURL": "https://github.com/XcodesOrg/XcodesKit", "state": { "branch": null, - "revision": "5bee2a2e39bf5a6e1ab2dc2ec90a45213db1e68b", - "version": "1.0.1" + "revision": "5bff14052f7664f75a4837547804e07c3c2dfe47", + "version": "1.0.3" } }, {