diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 3f9e7d7..56d06e7 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -18,7 +18,6 @@ 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 */; }; - CA42DD6E25AEA8B200BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD6D25AEA8B200BC0B0C /* Logger.swift */; }; CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; }; CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; }; CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */; }; @@ -71,7 +70,6 @@ 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 */; }; - CABFA9DF2592F07A00380FEE /* Path in Frameworks */ = {isa = PBXBuildFile; productRef = CABFA9DE2592F07A00380FEE /* Path */; }; CABFA9E42592F08E00380FEE /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = CABFA9E32592F08E00380FEE /* Version */; }; CABFA9EE2592F0CC00380FEE /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = CABFA9ED2592F0CC00380FEE /* SwiftSoup */; }; CABFA9F82592F0F900380FEE /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = CABFA9F72592F0F900380FEE /* KeychainAccess */; }; @@ -108,6 +106,9 @@ 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 */; }; + 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 */; }; E8CBDB8927ADE32300B22292 /* unxip in Copy aria2c */ = {isa = PBXBuildFile; fileRef = E8CBDB8627ADD92000B22292 /* unxip */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; E8CBDB8B27AE02FF00B22292 /* ExperiementsPreferencePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */; }; E8D0296F284B029800647641 /* BottomStatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D0296E284B029800647641 /* BottomStatusBar.swift */; }; @@ -115,7 +116,9 @@ E8DA461125FAF7FB002E85EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8DA461025FAF7FB002E85EF /* NotificationsView.swift */; }; E8E98A9025D8631800EC89A0 /* InstallationStepRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBC3FF259AC17F00E2A3D8 /* InstallationStepRowView.swift */; }; E8E98A9625D863D700EC89A0 /* InstallationStepDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E98A9525D863D700EC89A0 /* InstallationStepDetailView.swift */; }; + E8F44A1E296B4CD7002D6592 /* Path in Frameworks */ = {isa = PBXBuildFile; productRef = E8F44A1D296B4CD7002D6592 /* Path */; }; E8F81FC4282D8A17006CBD0F /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E8F81FC3282D8A17006CBD0F /* Sparkle */; }; + E8FD5727291EE4AC001E004C /* AsyncNetworkService in Frameworks */ = {isa = PBXBuildFile; productRef = E8FD5726291EE4AC001E004C /* AsyncNetworkService */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -196,7 +199,6 @@ 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 = ""; }; - CA42DD6D25AEA8B200BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; CA452BAF259FD9770072DFA4 /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; @@ -297,11 +299,14 @@ CAFFFEEE259CEAC400903F81 /* RingProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RingProgressViewStyle.swift; sourceTree = ""; }; E2AFDCCA28F024D000864ADD /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; E81D7E9F2805250100A205FC /* Collection+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+.swift"; sourceTree = ""; }; + E856BB73291EDD3D00DC438B /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = ""; }; E872EE4F2808D4F100D3DD8B /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; 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 = ""; }; + 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 = ""; }; E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperiementsPreferencePane.swift; sourceTree = ""; }; E8D0296E284B029800647641 /* BottomStatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomStatusBar.swift; sourceTree = ""; }; @@ -328,9 +333,11 @@ CA9FF86D25951C6E00E47BAF /* XCModel in Frameworks */, CABFA9F82592F0F900380FEE /* KeychainAccess in Frameworks */, CAA858CD25A3D8BC00ACF8C0 /* ErrorHandling in Frameworks */, + E8C0EB1A291EF43E0081528A /* XcodesKit in Frameworks */, + E8FD5727291EE4AC001E004C /* AsyncNetworkService in Frameworks */, CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */, - CABFA9DF2592F07A00380FEE /* Path in Frameworks */, CABFA9EE2592F0CC00380FEE /* SwiftSoup in Frameworks */, + E8F44A1E296B4CD7002D6592 /* Path in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -452,6 +459,7 @@ isa = PBXGroup; children = ( CA378F982466567600A58CE0 /* AppState.swift */, + E8C0EB1B291EF9A10081528A /* AppState+Runtimes.swift */, CAE424B3259A764700B8B246 /* AppState+Install.swift */, CABFA9A72592EEE900380FEE /* AppState+Update.swift */, CAA8589A25A2B83000ACF8C0 /* Aria2CError.swift */, @@ -473,7 +481,6 @@ CAC281D9259F985100B8AB0B /* InstallationStep.swift */, CA9FF8862595607900E47BAF /* InstalledXcode.swift */, CAA8587B25A2B37900ACF8C0 /* IsTesting.swift */, - CA42DD6D25AEA8B200BC0B0C /* Logger.swift */, E89342F925EDCC17007CF557 /* NotificationManager.swift */, CAE4248B259A68B800B8B246 /* Optional+IsNotNil.swift */, CABFA9AE2592EEE900380FEE /* Path+.swift */, @@ -492,6 +499,7 @@ E81D7E9F2805250100A205FC /* Collection+.swift */, E8D655BF288DD04700A139C2 /* SelectedActionType.swift */, E87AB3C42939B65E00D72F43 /* Hardware.swift */, + E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */, ); path = Backend; sourceTree = ""; @@ -532,6 +540,7 @@ CAD2E7952449574E00113D76 = { isa = PBXGroup; children = ( + E856BB73291EDD3D00DC438B /* XcodesKit */, CA8FB5F8256E0F9400469DA5 /* README.md */, CABFA9D42592EF6300380FEE /* DECISIONS.md */, CABFA9A02592EAF500380FEE /* R&PLogo.png */, @@ -656,7 +665,6 @@ name = Xcodes; packageProductDependencies = ( CAA1CB2C255A5262003FD669 /* AppleAPI */, - CABFA9DE2592F07A00380FEE /* Path */, CABFA9E32592F08E00380FEE /* Version */, CABFA9ED2592F0CC00380FEE /* SwiftSoup */, CABFA9F72592F0F900380FEE /* KeychainAccess */, @@ -664,6 +672,9 @@ CA9FF86C25951C6E00E47BAF /* XCModel */, CAA858CC25A3D8BC00ACF8C0 /* ErrorHandling */, E8F81FC3282D8A17006CBD0F /* Sparkle */, + E8FD5726291EE4AC001E004C /* AsyncNetworkService */, + E8C0EB19291EF43E0081528A /* XcodesKit */, + E8F44A1D296B4CD7002D6592 /* Path */, ); productName = XcodesMac; productReference = CAD2E79E2449574E00113D76 /* Xcodes.app */; @@ -737,7 +748,6 @@ ); mainGroup = CAD2E7952449574E00113D76; packageReferences = ( - CABFA9DD2592F07A00380FEE /* XCRemoteSwiftPackageReference "Path" */, CABFA9E22592F08E00380FEE /* XCRemoteSwiftPackageReference "Version" */, CABFA9EC2592F0CC00380FEE /* XCRemoteSwiftPackageReference "SwiftSoup" */, CABFA9F62592F0F900380FEE /* XCRemoteSwiftPackageReference "KeychainAccess" */, @@ -746,6 +756,8 @@ CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */, CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */, E8F81FC2282D8A17006CBD0F /* XCRemoteSwiftPackageReference "Sparkle" */, + E8FD5725291EE4AC001E004C /* XCRemoteSwiftPackageReference "AsyncHTTPNetworkService" */, + E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */, ); productRefGroup = CAD2E79F2449574E00113D76 /* Products */; projectDirPath = ""; @@ -854,7 +866,6 @@ 36741BFF291E50F500A85AAE /* FileError.swift in Sources */, CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */, 53CBAB2C263DCC9100410495 /* XcodesAlert.swift in Sources */, - CA42DD6E25AEA8B200BC0B0C /* Logger.swift in Sources */, CA61A6E0259835580008926E /* Xcode.swift in Sources */, CAE4247F259A666100B8B246 /* MainWindow.swift in Sources */, CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */, @@ -871,6 +882,7 @@ 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 */, CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, @@ -894,6 +906,7 @@ CABFA9C92592EEEA00380FEE /* URLRequest+Apple.swift in Sources */, CABFAA432593104F00380FEE /* AboutView.swift in Sources */, E8D0296F284B029800647641 /* BottomStatusBar.swift in Sources */, + E8C0EB1C291EF9A10081528A /* AppState+Runtimes.swift in Sources */, E8E98A9025D8631800EC89A0 /* InstallationStepRowView.swift in Sources */, CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */, CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */, @@ -1416,14 +1429,6 @@ minimumVersion = 0.1.0; }; }; - CABFA9DD2592F07A00380FEE /* XCRemoteSwiftPackageReference "Path" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/mxcl/Path.swift"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.16.0; - }; - }; CABFA9E22592F08E00380FEE /* XCRemoteSwiftPackageReference "Version" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mxcl/Version"; @@ -1464,6 +1469,14 @@ minimumVersion = 0.6.0; }; }; + E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/mxcl/Path.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; E8F81FC2282D8A17006CBD0F /* XCRemoteSwiftPackageReference "Sparkle" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sparkle-project/Sparkle"; @@ -1472,6 +1485,14 @@ minimumVersion = 2.0.0; }; }; + E8FD5725291EE4AC001E004C /* XCRemoteSwiftPackageReference "AsyncHTTPNetworkService" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1489,11 +1510,6 @@ package = CAA858CB25A3D8BC00ACF8C0 /* XCRemoteSwiftPackageReference "ErrorHandling" */; productName = ErrorHandling; }; - CABFA9DE2592F07A00380FEE /* Path */ = { - isa = XCSwiftPackageProductDependency; - package = CABFA9DD2592F07A00380FEE /* XCRemoteSwiftPackageReference "Path" */; - productName = Path; - }; CABFA9E32592F08E00380FEE /* Version */ = { isa = XCSwiftPackageProductDependency; package = CABFA9E22592F08E00380FEE /* XCRemoteSwiftPackageReference "Version" */; @@ -1519,11 +1535,25 @@ package = CAC28186259EE27200B8AB0B /* XCRemoteSwiftPackageReference "CombineExpectations" */; productName = CombineExpectations; }; + E8C0EB19291EF43E0081528A /* XcodesKit */ = { + isa = XCSwiftPackageProductDependency; + productName = XcodesKit; + }; + E8F44A1D296B4CD7002D6592 /* Path */ = { + isa = XCSwiftPackageProductDependency; + package = E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */; + productName = Path; + }; E8F81FC3282D8A17006CBD0F /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = E8F81FC2282D8A17006CBD0F /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + E8FD5726291EE4AC001E004C /* AsyncNetworkService */ = { + isa = XCSwiftPackageProductDependency; + package = E8FD5725291EE4AC001E004C /* XCRemoteSwiftPackageReference "AsyncHTTPNetworkService" */; + productName = AsyncNetworkService; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CAD2E7962449574E00113D76 /* Project object */; diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 58f4e08..abfd4fe 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "AsyncNetworkService", + "repositoryURL": "https://github.com/RobotsAndPencils/AsyncHTTPNetworkService", + "state": { + "branch": "main", + "revision": "97770856c4e429f880d4b4dd68cfaf286dc00c30", + "version": null + } + }, { "package": "CombineExpectations", "repositoryURL": "https://github.com/groue/CombineExpectations", @@ -51,8 +60,8 @@ "repositoryURL": "https://github.com/mxcl/Path.swift", "state": { "branch": null, - "revision": "dac007e907a4f4c565cfdc55a9ce148a761a11d5", - "version": "0.16.3" + "revision": "9c6f807b0a76be0e27aecc908bc6f173400d839e", + "version": "1.4.0" } }, { diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index ccf637d..a889e69 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -5,6 +5,7 @@ import AppleAPI import Version import LegibleError import os.log +import XcodesKit /// Downloads and installs Xcodes extension AppState { diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift new file mode 100644 index 0000000..a07f798 --- /dev/null +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -0,0 +1,32 @@ +import Foundation +import XcodesKit +import OSLog + +extension AppState { + func updateDownloadableRuntimes() { + Task { + do { + let runtimes = try await self.runtimeService.downloadableRuntimes().downloadables + DispatchQueue.main.async { + self.downloadableRuntimes = runtimes + } + try? cacheDownloadableRuntimes(runtimes) + } catch { + Logger.appState.error("Error downloading runtimes: \(error.localizedDescription)") + } + } + } + + func updateInstalledRuntimes() { + Task { + do { + let runtimes = try await self.runtimeService.localInstalledRuntimes() + DispatchQueue.main.async { + self.installedRuntimes = runtimes + } + } catch { + Logger.appState.error("Error loading installed runtimes: \(error.localizedDescription)") + } + } + } +} diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index 8dad172..90f531f 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -5,6 +5,7 @@ import Version import SwiftSoup import struct XCModel.Xcode import AppleAPI +import XcodesKit extension AppState { @@ -36,6 +37,8 @@ extension AppState { func update() { guard !isUpdating else { return } + updateDownloadableRuntimes() + updateInstalledRuntimes() updatePublisher = updateSelectedXcodePath() .flatMap { _ in self.updateAvailableXcodes(from: self.dataSource) @@ -125,6 +128,21 @@ extension AppState { withIntermediateDirectories: true) try data.write(to: Path.cacheFile.url) } + + // 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 + } + + 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) + } } extension AppState { diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index ebd44b5..2745ec9 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -7,9 +7,11 @@ import KeychainAccess import Path import Version import os.log +import XcodesKit class AppState: ObservableObject { private let client = AppleAPI.Client() + internal let runtimeService = RuntimeService() // MARK: - Published Properties @@ -99,6 +101,12 @@ class AppState: ObservableObject { Current.defaults.set(showOpenInRosettaOption, forKey: "showOpenInRosettaOption") } } + + // MARK: - Runtimes + + @Published var downloadableRuntimes: [DownloadableRuntime] = [] + @Published var installedRuntimes: [CoreSimulatorRuntimeInfo] = [] + // MARK: - Publisher Cancellables var cancellables = Set() @@ -136,6 +144,7 @@ class AppState: ObservableObject { init() { guard !isTesting else { return } try? loadCachedAvailableXcodes() + try? loadCacheDownloadableRuntimes() checkIfHelperIsInstalled() setupAutoInstallTimer() setupDefaults() @@ -783,6 +792,21 @@ class AppState: ObservableObject { self.allXcodes = newAllXcodes.sorted { $0.version > $1.version } } + // MARK: Runtimes + func getRunTimes(xcode: Xcode) -> [DownloadableRuntime] { + + let builds = xcode.sdks?.allBuilds() + + let runtime = builds?.flatMap { sdkBuild in + downloadableRuntimes.filter { + $0.simulatorVersion.buildUpdate == sdkBuild + } + } + // appState.installedRuntimes has a list of builds that user has installed. + + return runtime ?? [] + } + // MARK: - Private private func uninstallXcode(path: Path) -> AnyPublisher { diff --git a/Xcodes/Backend/Entry+.swift b/Xcodes/Backend/Entry+.swift index c6e8c11..b195fb0 100644 --- a/Xcodes/Backend/Entry+.swift +++ b/Xcodes/Backend/Entry+.swift @@ -1,13 +1,13 @@ import Foundation import Path -extension Entry { - static func isAppBundle(kind: Kind, path: Path) -> Bool { - kind == .directory && +extension Path { + static func isAppBundle(path: Path) -> Bool { + path.isDirectory && path.extension == "app" && !path.isSymlink } - static func infoPlist(kind: Kind, path: Path) -> InfoPlist? { + static func infoPlist(path: Path) -> InfoPlist? { let infoPlistPath = path.join("Contents").join("Info.plist") guard let infoPlistData = try? Data(contentsOf: infoPlistPath.url), @@ -18,10 +18,10 @@ extension Entry { } var isAppBundle: Bool { - Entry.isAppBundle(kind: kind, path: path) + Path.isAppBundle(path: self) } var infoPlist: InfoPlist? { - Entry.infoPlist(kind: kind, path: path) + Path.infoPlist(path: self) } } diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 9a43ec8..2c93381 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -3,7 +3,7 @@ import Foundation import Path import AppleAPI import KeychainAccess - +import XcodesKit /** Lightweight dependency injection using global mutable state :P @@ -166,7 +166,7 @@ public struct Files { public var installedXcodes = _installedXcodes public func installedXcode(destination: Path) -> InstalledXcode? { - if Entry.isAppBundle(kind: destination.isDirectory ? .directory : .file, path: destination) && Entry.infoPlist(kind: destination.isDirectory ? .directory : .file, path: destination)?.bundleID == "com.apple.dt.Xcode" { + if Path.isAppBundle(path: destination) && Path.infoPlist(path: destination)?.bundleID == "com.apple.dt.Xcode" { return InstalledXcode.init(path: destination) } else { return nil @@ -175,9 +175,9 @@ public struct Files { } private func _installedXcodes(destination: Path) -> [InstalledXcode] { - ((try? destination.ls()) ?? []) + destination.ls() .filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" } - .map { $0.path } + .map { $0 } .compactMap(InstalledXcode.init) } diff --git a/Xcodes/Backend/Logger.swift b/Xcodes/Backend/Logger.swift deleted file mode 100644 index 57540b8..0000000 --- a/Xcodes/Backend/Logger.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation -import os.log - -extension Logger { - private static var subsystem = Bundle.main.bundleIdentifier! - - static let appState = Logger(subsystem: subsystem, category: "appState") - static let helperClient = Logger(subsystem: subsystem, category: "helperClient") - static let subprocess = Logger(subsystem: subsystem, category: "subprocess") -} diff --git a/Xcodes/Backend/Path+.swift b/Xcodes/Backend/Path+.swift index 36e0042..8bcc59c 100644 --- a/Xcodes/Backend/Path+.swift +++ b/Xcodes/Backend/Path+.swift @@ -28,4 +28,8 @@ extension Path { } return path } + + static var runtimeCacheFile: Path { + return xcodesApplicationSupport/"downloadable-runtimes.json" + } } diff --git a/Xcodes/Backend/Process.swift b/Xcodes/Backend/Process.swift index fe96001..77935df 100644 --- a/Xcodes/Backend/Process.swift +++ b/Xcodes/Backend/Process.swift @@ -2,12 +2,11 @@ import Combine import Foundation import os.log import Path - -public typealias ProcessOutput = (status: Int32, out: String, err: String) +import XcodesKit extension Process { @discardableResult - static func run(_ executable: Path, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher { + static func run(_ executable: any Pathish, workingDirectory: URL? = nil, input: String? = nil, _ arguments: String...) -> AnyPublisher { return run(executable.url, workingDirectory: workingDirectory, input: input, arguments) } @@ -67,9 +66,3 @@ extension Process { .eraseToAnyPublisher() } } - -struct ProcessExecutionError: Error { - let process: Process - let standardOutput: String - let standardError: String -} diff --git a/Xcodes/Backend/SDKs+Xcode.swift b/Xcodes/Backend/SDKs+Xcode.swift new file mode 100644 index 0000000..9aeca4a --- /dev/null +++ b/Xcodes/Backend/SDKs+Xcode.swift @@ -0,0 +1,32 @@ +// +// SDKs+Xcode.swift +// Xcodes +// +// Created by Matt Kiazyk on 2023-06-05. +// Copyright © 2023 Robots and Pencils. All rights reserved. +// + +import Foundation +import struct XCModel.SDKs + +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 + } + + return buildNumbers + } +} diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index 603e35d..4003ac2 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -67,4 +67,5 @@ struct Xcode: Identifiable, CustomStringConvertible { return nil } } + } diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index 76e9924..0f74a92 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -1,4 +1,5 @@ import SwiftUI +import XcodesKit // MARK: - CommandMenu @@ -207,6 +208,23 @@ struct CreateSymbolicLinkButton: View { } } +struct DownloadRuntimeButton: View { + @EnvironmentObject var appState: AppState + let runtime: DownloadableRuntime? + + var body: some View { + Button(action: install) { + Text("Install") + .help("Install") + } + } + + private func install() { + guard let runtime = runtime else { return } + // appState.checkMinVersionAndInstall(id: xcode.id) + } +} + struct CreateSymbolicBetaLinkButton: View { @EnvironmentObject var appState: AppState let xcode: Xcode? diff --git a/Xcodes/Frontend/InfoPane/InfoPane.swift b/Xcodes/Frontend/InfoPane/InfoPane.swift index 88195da..9b616a6 100644 --- a/Xcodes/Frontend/InfoPane/InfoPane.swift +++ b/Xcodes/Frontend/InfoPane/InfoPane.swift @@ -54,6 +54,7 @@ struct InfoPane: View { Divider() Group{ + runtimes(for: xcode) releaseNotes(for: xcode) releaseDate(for: xcode) identicalBuilds(for: xcode) @@ -245,6 +246,29 @@ struct InfoPane: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() } + + @ViewBuilder + private func runtimes(for xcode: Xcode) -> some View { + let runtimes = appState.getRunTimes(xcode: xcode) + + VStack(alignment: .leading) { + Text("Platforms") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach(runtimes, id: \.simulatorVersion.buildUpdate) { runtime in + HStack { + Text("\(runtime.visibleIdentifier)") + .font(.subheadline) + Spacer() + Text(runtime.downloadFileSizeString) + .font(.subheadline) + DownloadRuntimeButton(runtime: runtime) + } + + } + } + } } struct InfoPane_Previews: PreviewProvider { @@ -329,6 +353,7 @@ struct InfoPane_Previews: PreviewProvider { ), downloadFileSize: 242342424) ] + }) .previewDisplayName("Populated, Uninstalled") diff --git a/Xcodes/Resources/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index 93bc0ff..9d4ab70 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -1,10 +1,37 @@ -{\rtf1\ansi\ansicpg1252\cocoartf2639 +{\rtf1\ansi\ansicpg1252\cocoartf2709 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 .SFNS-Regular;} {\colortbl;\red255\green255\blue255;} {\*\expandedcolortbl;;} \pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0 -\f0\fs34 \cf0 SwiftSoup\ +\f0\fs34 \cf0 AsyncHTTPNetworkService\ +\ + +\fs26 MIT License\ +\ +Copyright (c) 2022 Robots and Pencils\ +\ +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 SwiftSoup\ \ \fs26 MIT License\ diff --git a/Xcodes/Resources/en.lproj/Localizable.strings b/Xcodes/Resources/en.lproj/Localizable.strings index 59a8676..2ae234e 100644 --- a/Xcodes/Resources/en.lproj/Localizable.strings +++ b/Xcodes/Resources/en.lproj/Localizable.strings @@ -40,6 +40,7 @@ "Compilers" = "Compilers"; "DownloadSize" = "Download Size"; "NoXcodeSelected" = "No Xcode Selected"; +"Platforms" = "Platforms"; // Installation Steps // When localizing. Items will be replaced in order. ie "Step 1 of 6: Downloading" diff --git a/Xcodes/XcodesKit/.gitignore b/Xcodes/XcodesKit/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/Xcodes/XcodesKit/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Xcodes/XcodesKit/Package.swift b/Xcodes/XcodesKit/Package.swift new file mode 100644 index 0000000..81447ab --- /dev/null +++ b/Xcodes/XcodesKit/Package.swift @@ -0,0 +1,33 @@ +// 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: "XcodesKit", + platforms: [.macOS(.v11)], + 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/RobotsAndPencils/AsyncHTTPNetworkService", branch: "main"), + .package(url: "https://github.com/mxcl/Path.swift", from: "1.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: "AsyncNetworkService", package: "AsyncHTTPNetworkService"), + .product(name: "Path", package: "Path.swift") + ]), + .testTarget( + name: "XcodesKitTests", + dependencies: ["XcodesKit"]), + ] +) diff --git a/Xcodes/XcodesKit/README.md b/Xcodes/XcodesKit/README.md new file mode 100644 index 0000000..5312c49 --- /dev/null +++ b/Xcodes/XcodesKit/README.md @@ -0,0 +1,3 @@ +# XcodesKit + +A description of this package. diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift new file mode 100644 index 0000000..a988ce9 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Environment.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct Environment { + public var shell = Shell() +} + +public var Current = Environment() diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift new file mode 100644 index 0000000..02b1e02 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Foundation.swift @@ -0,0 +1,12 @@ +import Foundation + +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 new file mode 100644 index 0000000..222e908 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Extensions/Logger.swift @@ -0,0 +1,10 @@ +import Foundation +import os.log + +extension Logger { + private static var subsystem = Bundle.main.bundleIdentifier! + + 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/Models/InstallState.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/InstallState.swift new file mode 100644 index 0000000..8aa6b1e --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/InstallState.swift @@ -0,0 +1,34 @@ +// +// InstallState.swift +// +// +// Created by Matt Kiazyk on 2023-06-06. +// + +import Foundation +import Path + +public enum InstallState: Equatable { + case notInstalled + case installing(InstallationStep) + 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/XcodesKit/Sources/XcodesKit/Models/InstallationStep.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/InstallationStep.swift new file mode 100644 index 0000000..fa54998 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/InstallationStep.swift @@ -0,0 +1,60 @@ +// +// InstallationStep.swift +// +// +// Created by Matt Kiazyk on 2023-06-06. +// + +import Foundation + +// A numbered step +public enum InstallationStep: Equatable, CustomStringConvertible { + case downloading(progress: Progress) + case unarchiving + case moving(destination: String) + case trashingArchive + case checkingSecurity + case finishing + + public var description: String { + "(\(stepNumber)/\(stepCount)) \(message)" + } + + var message: String { + switch self { + 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") + } + } + + var stepNumber: Int { + switch self { + case .downloading: return 1 + case .unarchiving: return 2 + case .moving: return 3 + case .trashingArchive: return 4 + case .checkingSecurity: return 5 + case .finishing: return 6 + } + } + + var stepCount: Int { 6 } +} +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/Runtimes/CoreSimulatorImage.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift new file mode 100644 index 0000000..69dd9da --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/CoreSimulatorImage.swift @@ -0,0 +1,21 @@ +// +// CoreSimulatorImage.swift +// +// +// Created by Matt Kiazyk on 2023-01-08. +// + +import Foundation + +public struct CoreSimulatorPlist: Decodable { + public let images: [CoreSimulatorImage] +} + +public struct CoreSimulatorImage: Decodable { + public let uuid: String + public let runtimeInfo: CoreSimulatorRuntimeInfo +} + +public struct CoreSimulatorRuntimeInfo: Decodable { + public let build: String +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift new file mode 100644 index 0000000..0e57b9a --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Models/Runtimes/Runtimes.swift @@ -0,0 +1,161 @@ +import Foundation + +public struct DownloadableRuntimesResponse: Codable { + public let sdkToSimulatorMappings: [SDKToSimulatorMapping] + public let sdkToSeedMappings: [SDKToSeedMapping] + public let refreshInterval: Int + public let downloadables: [DownloadableRuntime] + public let version: String +} + +public struct DownloadableRuntime: Codable { + public let category: Category + public let simulatorVersion: SimulatorVersion + public let source: String + 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? + + // dynamically updated - not decoded + public var installState: InstallState = .notInstalled + + 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 + } + + 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 visibleIdentifier: String { + return platform.shortName + " " + completeVersion + } + + func makeVersion(for osVersion: String, betaNumber: Int?) -> String { + let betaSuffix = betaNumber.flatMap { "-beta\($0)" } ?? "" + return osVersion + betaSuffix + } + + public var downloadFileSizeString: String { + return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) + } +} + +public struct SDKToSeedMapping: Codable { + public let buildUpdate: String + public let platform: DownloadableRuntime.Platform + public let seedNumber: Int +} + +public struct SDKToSimulatorMapping: Codable { + public let sdkBuildUpdate: String + public let simulatorBuildUpdate: String + public let sdkIdentifier: String +} + +extension DownloadableRuntime { + public struct SimulatorVersion: Codable { + public let buildUpdate: String + public let version: String + } + + public struct HostRequirements: Codable { + let maxHostVersion: String? + let excludedHostArchitectures: [String]? + let minHostVersion: String? + let minXcodeVersion: String? + } + + public enum Authentication: String, Codable { + case virtual = "virtual" + } + + public enum Category: String, Codable { + case simulator = "simulator" + } + + public enum ContentType: String, Codable { + case diskImage = "diskImage" + case package = "package" + } + + public enum Platform: String, Codable { + case iOS = "com.apple.platform.iphoneos" + case macOS = "com.apple.platform.macosx" + case watchOS = "com.apple.platform.watchos" + case tvOS = "com.apple.platform.appletvos" + + var order: Int { + switch self { + case .iOS: return 1 + case .macOS: return 2 + case .watchOS: return 3 + case .tvOS: return 4 + } + } + + var shortName: String { + switch self { + case .iOS: return "iOS" + case .macOS: return "macOS" + case .watchOS: return "watchOS" + case .tvOS: return "tvOS" + } + } + } +} + +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? +} + +extension InstalledRuntime { + enum Kind: String, Decodable { + case diskImage = "Disk Image" + case bundled = "Bundled with Xcode" + case legacyDownload = "Legacy Download" + } + + enum Platform: String, Decodable { + case tvOS = "com.apple.platform.appletvsimulator" + case iOS = "com.apple.platform.iphonesimulator" + case watchOS = "com.apple.platform.watchsimulator" + } +} + diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift new file mode 100644 index 0000000..429fc48 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Services/RuntimeService.swift @@ -0,0 +1,55 @@ +import Foundation +import AsyncNetworkService +import Path + +extension URL { + static let downloadableRuntimes = URL(string: "https://devimages-cdn.apple.com/downloads/xcode/simulators/index2.dvtdownloadableindex")! +} + +public struct RuntimeService { + var networkService: AsyncHTTPNetworkService + + public init() { + networkService = AsyncHTTPNetworkService() + } + + 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: []) + let decodedResponse = try PropertyListDecoder().decode(DownloadableRuntimesResponse.self, from: data) + + return decodedResponse + } + + 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 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 -> [CoreSimulatorRuntimeInfo] { + 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)" } + + do { + let infoPlist: CoreSimulatorPlist = try PropertyListDecoder().decode(CoreSimulatorPlist.self, from: infoPlistData) + return infoPlist.images.map { $0.runtimeInfo } + } catch { + throw error + } + } + +} + +extension String: Error {} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift new file mode 100644 index 0000000..b83a079 --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Process.swift @@ -0,0 +1,66 @@ +import Foundation +import Path +import os.log + +public typealias ProcessOutput = (status: Int32, out: String, err: String) + +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: 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() + } + + 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, standardOutput: output, standardError: error) + } + + return (process.terminationStatus, output, error) + } catch { + throw error + } + } + +} + +public struct ProcessExecutionError: Error { + public let process: Process + 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 + } +} diff --git a/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Shell.swift b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Shell.swift new file mode 100644 index 0000000..520ad0a --- /dev/null +++ b/Xcodes/XcodesKit/Sources/XcodesKit/Shell/Shell.swift @@ -0,0 +1,8 @@ +import Foundation +import Path + +public struct Shell { + public var installedRuntimes: () async throws -> ProcessOutput = { + try await Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "list", "-j") + } +} diff --git a/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift new file mode 100644 index 0000000..98f0c96 --- /dev/null +++ b/Xcodes/XcodesKit/Tests/XcodesKitTests/XcodesKitTests.swift @@ -0,0 +1,11 @@ +import XCTest +@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!") + } +}