diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 5fd75d5b..143c767d 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 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 */; }; + D10000010000000000000001 /* PinCodeInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000001 /* PinCodeInputTests.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 */; }; @@ -119,6 +120,8 @@ E8B20CBF2A2EDEC20057D816 /* SDKs+Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B20CBE2A2EDEC20057D816 /* SDKs+Xcode.swift */; }; 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, ); }; }; + E8CBDB902EE5010000B22292 /* unxip-3.3.tar.gz in Resources */ = {isa = PBXBuildFile; fileRef = E8CBDB8E2EE5010000B22292 /* unxip-3.3.tar.gz */; }; + E8CBDB912EE5010000B22292 /* unxip-3.3.provenance.json in Resources */ = {isa = PBXBuildFile; fileRef = E8CBDB8F2EE5010000B22292 /* unxip-3.3.provenance.json */; }; E8CBDB8B27AE02FF00B22292 /* ExperiementsPreferencePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */; }; E8D655C0288DD04700A139C2 /* SelectedActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D655BF288DD04700A139C2 /* SelectedActionType.swift */; }; E8DA461125FAF7FB002E85EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8DA461025FAF7FB002E85EF /* NotificationsView.swift */; }; @@ -207,6 +210,7 @@ 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 = ""; }; + D10000020000000000000001 /* PinCodeInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeInputTests.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 = ""; }; @@ -308,6 +312,8 @@ 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 = ""; }; + E8CBDB8E2EE5010000B22292 /* unxip-3.3.tar.gz */ = {isa = PBXFileReference; lastKnownFileType = archive.gzip; path = "unxip-3.3.tar.gz"; sourceTree = ""; }; + E8CBDB8F2EE5010000B22292 /* unxip-3.3.provenance.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "unxip-3.3.provenance.json"; sourceTree = ""; }; E8CBDB8A27AE02FF00B22292 /* ExperiementsPreferencePane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperiementsPreferencePane.swift; sourceTree = ""; }; E8D655BF288DD04700A139C2 /* SelectedActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedActionType.swift; sourceTree = ""; }; E8DA461025FAF7FB002E85EF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; @@ -527,6 +533,8 @@ CAD2E7AE2449575000113D76 /* Xcodes.entitlements */, CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */, E8CBDB8627ADD92000B22292 /* unxip */, + E8CBDB8E2EE5010000B22292 /* unxip-3.3.tar.gz */, + E8CBDB8F2EE5010000B22292 /* unxip-3.3.provenance.json */, 9DD4FFCA2B13EC1800C974F1 /* Localizable.xcstrings */, ); path = Resources; @@ -591,6 +599,7 @@ CAC281E6259FA45A00B8AB0B /* Environment+Mock.swift */, CAD2E7B72449575100113D76 /* AppStateTests.swift */, CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */, + D10000020000000000000001 /* PinCodeInputTests.swift */, CAD2E7B92449575100113D76 /* Info.plist */, ); path = XcodesTests; @@ -795,6 +804,8 @@ E8EEAD1D2E79174F00BE67E8 /* XcodesIcon.icon in Resources */, CA9FF83F2594FBC000E47BAF /* Licenses.rtf in Resources */, CAA858DB25A3E11F00ACF8C0 /* aria2-release-1.35.0.tar.gz in Resources */, + E8CBDB902EE5010000B22292 /* unxip-3.3.tar.gz in Resources */, + E8CBDB912EE5010000B22292 /* unxip-3.3.provenance.json in Resources */, CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -966,6 +977,7 @@ CAC281E7259FA45A00B8AB0B /* Environment+Mock.swift in Sources */, CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */, CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */, + D10000010000000000000001 /* PinCodeInputTests.swift in Sources */, CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index c0b9e59a..bcb7a0d3 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -7,9 +7,105 @@ import DockProgress import XcodesKit import XcodesLoginKit +struct XcodeExtractionWorkspace: Sendable { + private static let directoryPrefix = ".xcodes-extract-" + + let directoryURL: URL + let stagedArchiveURL: URL + private let parentURL: URL + private let parentIdentity: FileSystemIdentity + private let directoryIdentity: FileSystemIdentity + private let files: Files + + private init( + directoryURL: URL, + stagedArchiveURL: URL, + parentURL: URL, + parentIdentity: FileSystemIdentity, + directoryIdentity: FileSystemIdentity, + files: Files + ) { + self.directoryURL = directoryURL + self.stagedArchiveURL = stagedArchiveURL + self.parentURL = parentURL + self.parentIdentity = parentIdentity + self.directoryIdentity = directoryIdentity + self.files = files + } + + static func create(for archiveURL: URL) throws -> Self { + let files = Current.files + let archiveURL = archiveURL.standardizedFileURL + let parentURL = files.canonicalURL(archiveURL.deletingLastPathComponent()) + let parentIdentity = try files.fileSystemIdentity(parentURL) + guard parentIdentity.isDirectory, parentIdentity.isSymbolicLink == false else { + throw CocoaError(.fileWriteInvalidFileName) + } + + let directoryURL = parentURL.appendingPathComponent(directoryPrefix + UUID().uuidString, isDirectory: true) + let stagedArchiveURL = directoryURL.appendingPathComponent(archiveURL.lastPathComponent) + + try files.createDirectory( + at: directoryURL, + withIntermediateDirectories: false + ) + + let directoryIdentity = try files.fileSystemIdentity(directoryURL) + guard directoryIdentity.isDirectory, + directoryIdentity.isSymbolicLink == false, + try files.fileSystemIdentity(parentURL) == parentIdentity + else { + throw CocoaError(.fileWriteInvalidFileName) + } + + let workspace = Self( + directoryURL: directoryURL, + stagedArchiveURL: stagedArchiveURL, + parentURL: parentURL, + parentIdentity: parentIdentity, + directoryIdentity: directoryIdentity, + files: files + ) + + do { + try files.linkItem(at: archiveURL, to: stagedArchiveURL) + } catch { + try? workspace.remove() + throw error + } + + return workspace + } + + func remove() throws { + let currentParentURL = files.canonicalURL(directoryURL.deletingLastPathComponent()) + let currentDirectoryURL = files.canonicalURL(directoryURL) + guard currentParentURL == parentURL, + currentDirectoryURL == directoryURL, + directoryURL.lastPathComponent.hasPrefix(Self.directoryPrefix), + stagedArchiveURL.standardizedFileURL.deletingLastPathComponent() == directoryURL, + try files.fileSystemIdentity(parentURL) == parentIdentity, + try files.fileSystemIdentity(directoryURL) == directoryIdentity + else { + throw CocoaError(.fileWriteInvalidFileName) + } + try files.quarantineAndRemoveOwnedDirectory( + parentURL, + parentIdentity, + directoryURL, + directoryIdentity, + files.beforeOwnedDirectoryQuarantine + ) + } +} + /// Downloads and installs Xcodes extension AppState { + static func installNotificationTitle(for version: Version) -> String { + version.appleDescription + } + // 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 } @@ -48,7 +144,7 @@ extension AppState { try Task.checkCancellation() let installedXcode = try await installArchivedXcodeAsync(xcode, at: url) - guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { + guard let index = xcodeIndex(for: xcode) else { return installedXcode } allXcodes[index].installState = .installed(installedXcode.path) @@ -131,15 +227,15 @@ extension AppState { } let isAlreadyDownloading: Bool - if let xcode = self.allXcodes.first(where: { $0.version.isEquivalent(to: availableXcode.version) }), - case .installing(.downloading) = xcode.installState + if let index = self.xcodeIndex(for: availableXcode), + case .installing(.downloading) = self.allXcodes[index].installState { isAlreadyDownloading = true } else { isAlreadyDownloading = false } - self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress), postNotification: !isAlreadyDownloading) + self.setInstallationStep(of: availableXcode, to: .downloading(progress: progress), postNotification: !isAlreadyDownloading) self.addDockProgressChildIfNeeded(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight) } }) @@ -212,29 +308,32 @@ extension AppState { unxipProgress.completedUnitCount = 0 addDockProgressChildIfNeeded(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight) + let workspace = try XcodeExtractionWorkspace.create(for: archiveURL) + defer { try? workspace.remove() } + let installedXcode: InstalledXcode do { installedXcode = try await xcodeArchiveInstallService.installArchivedXcode( availableXcode, - at: archiveURL, - cleanArchive: { try Current.files.trashItem(at: $0) } + at: workspace.stagedArchiveURL, + cleanArchive: { _ in try Current.files.trashItem(at: archiveURL) } ) { step in switch step { case .unarchive(.unarchiving): - await self.setInstallationStep(of: availableXcode.version, to: .unarchiving) + await self.setInstallationStep(of: availableXcode, to: .unarchiving) case let .unarchive(.moving(destination)): - await self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination)) + await self.setInstallationStep(of: availableXcode, to: .moving(destination: destination)) case .cleaningArchive: - await self.setInstallationStep(of: availableXcode.version, to: .trashingArchive) + await self.setInstallationStep(of: availableXcode, to: .trashingArchive) case .checkingSecurity: - await self.setInstallationStep(of: availableXcode.version, to: .checkingSecurity) + await self.setInstallationStep(of: availableXcode, to: .checkingSecurity) } } } catch { - throw mapXcodeArchiveInstallError(error, availableXcode: availableXcode) + throw mapXcodeArchiveInstallError(error, availableXcode: availableXcode, archiveURL: archiveURL) } - setInstallationStep(of: availableXcode.version, to: .finishing) + setInstallationStep(of: availableXcode, to: .finishing) do { try await performPostInstallStepsAsync(for: installedXcode) } catch { @@ -248,7 +347,12 @@ extension AppState { private var xcodeUnarchiveService: XcodeUnarchiveService { XcodeUnarchiveService( - unarchive: { _ = try await self.unxipOrUnxipExperimentAsync($0) }, + unarchive: { + _ = try await self.unxipOrUnxipExperimentAsync( + $0, + workingDirectory: $0.deletingLastPathComponent() + ) + }, 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) } @@ -271,7 +375,11 @@ extension AppState { ) } - private func mapXcodeArchiveInstallError(_ error: Error, availableXcode: AvailableXcode) -> Error { + private func mapXcodeArchiveInstallError( + _ error: Error, + availableXcode: AvailableXcode, + archiveURL: URL + ) -> Error { switch error { case let error as XcodeArchiveInstallError: switch error { @@ -282,11 +390,11 @@ extension AppState { } case let error as XcodeUnarchiveError: switch error { - case let .damagedXIP(url): - return InstallationError.damagedXIP(url: url) - case let .notEnoughFreeSpaceToExpandArchive(url): + case .damagedXIP: + return InstallationError.damagedXIP(url: archiveURL) + case .notEnoughFreeSpaceToExpandArchive: return InstallationError.notEnoughFreeSpaceToExpandArchive( - archivePath: Path(url: url)!, + archivePath: Path(url: archiveURL)!, version: availableXcode.version ) } @@ -307,13 +415,13 @@ extension AppState { } } - func unxipOrUnxipExperimentAsync(_ source: URL) async throws -> ProcessOutput { + func unxipOrUnxipExperimentAsync(_ source: URL, workingDirectory: 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 try await Current.shell.unxipExperiment(source) + return try await Current.shell.unxipExperiment(source, workingDirectory) } else { - return try await Current.shell.unxip(source) + return try await Current.shell.unxip(source, workingDirectory) } } @@ -465,13 +573,21 @@ extension AppState { // MARK: - - func setInstallationStep(of version: Version, to step: XcodeInstallationStep, postNotification: Bool = true) { - guard let index = allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return } + private func xcodeIndex(for availableXcode: AvailableXcode) -> Int? { + allXcodes.firstIndex { + $0.id == availableXcode.xcodeID || + ($0.version.isEquivalent(to: availableXcode.version) && + $0.id.architectures == availableXcode.xcodeID.architectures) + } + } + + func setInstallationStep(of availableXcode: AvailableXcode, to step: XcodeInstallationStep, postNotification: Bool = true) { + guard let index = xcodeIndex(for: availableXcode) else { return } allXcodes[index].installState = .installing(step) let xcode = allXcodes[index] if postNotification { - Current.notificationManager.scheduleNotification(title: xcode.version.major.description + "." + xcode.version.appleDescription, body: step.description, category: .normal) + Current.notificationManager.scheduleNotification(title: Self.installNotificationTitle(for: xcode.version), body: step.description, category: .normal) } } diff --git a/Xcodes/Backend/AppState+Runtimes.swift b/Xcodes/Backend/AppState+Runtimes.swift index 3349ddcf..0305c422 100644 --- a/Xcodes/Backend/AppState+Runtimes.swift +++ b/Xcodes/Backend/AppState+Runtimes.swift @@ -4,6 +4,13 @@ import OSLog import Path import Version +struct InstalledPlatformRuntime: Identifiable, Hashable { + let runtime: DownloadableRuntime + let installedRuntimeUUID: String + + var id: String { installedRuntimeUUID } +} + extension AppState { func updateDownloadableRuntimes() { downloadableRuntimesTask?.cancel() @@ -276,29 +283,59 @@ extension AppState { } func runtimeInstallPath(xcode: Xcode, runtime: DownloadableRuntime) -> Path? { - RuntimeInstallationLookupService() - .installPath(for: runtime, in: installedRuntimes) + guard let relativePath = coreSimulatorInfo(runtime: runtime)?.path["relative"] else { + return nil + } + + return Path(url: URL(fileURLWithPath: relativePath.replacingOccurrences(of: "file://", with: ""))) } func coreSimulatorInfo(runtime: DownloadableRuntime) -> CoreSimulatorImage? { - RuntimeInstallationLookupService() - .coreSimulatorImage(for: runtime, in: installedRuntimes) + let runtimeIdentity = RuntimeIdentity(runtime) + return installedRuntimes.first { installedRuntime in + let installedIdentity = RuntimeIdentity(installedRuntime) + return installedIdentity.build == runtimeIdentity.build && + (runtimeIdentity.architectures.isEmpty || + installedIdentity.architectures == runtimeIdentity.architectures) + } } - func installedPlatformRuntimes() -> [DownloadableRuntime] { - downloadableRuntimes.filter { coreSimulatorInfo(runtime: $0) != nil } - } + func installedPlatformRuntimes() -> [InstalledPlatformRuntime] { + var seenExactRuntimes = Set() + var seenGenericBuilds = Set() + return installedRuntimes.compactMap { installedRuntime in + let identity = RuntimeIdentity(installedRuntime) + if let runtime = downloadableRuntimes.first(where: { + RuntimeIdentity($0) == identity + }), identity.architectures.isEmpty == false { + guard seenExactRuntimes.insert(identity).inserted else { return nil } + return InstalledPlatformRuntime( + runtime: runtime, + installedRuntimeUUID: installedRuntime.uuid + ) + } - func deleteRuntime(runtime: DownloadableRuntime) async throws { - if let info = coreSimulatorInfo(runtime: runtime) { - try await runtimeService.deleteRuntime(identifier: info.uuid) - try await refreshInstalledRuntimes() - } else { - throw XcodesKitError("No simulator found with \(runtime.identifier)") + guard let runtime = downloadableRuntimes.first(where: { + $0.simulatorVersion.buildUpdate == identity.build && + ($0.architectures == nil || $0.architectures?.isEmpty == true) + }), seenGenericBuilds.insert(identity.build).inserted + else { + return nil + } + + return InstalledPlatformRuntime( + runtime: runtime, + installedRuntimeUUID: installedRuntime.uuid + ) } } - func confirmDeleteRuntime(runtime: DownloadableRuntime) { + func deleteRuntime(runtime: InstalledPlatformRuntime) async throws { + try await runtimeService.deleteRuntime(identifier: runtime.installedRuntimeUUID) + try await refreshInstalledRuntimes() + } + + func confirmDeleteRuntime(runtime: InstalledPlatformRuntime) { deleteRuntimeTask?.cancel() let taskID = UUID() deleteRuntimeTaskID = taskID @@ -341,6 +378,21 @@ extension AppState { } } +private struct RuntimeIdentity: Hashable { + let build: String + let architectures: [String] + + init(_ runtime: DownloadableRuntime) { + build = runtime.simulatorVersion.buildUpdate + architectures = (runtime.architectures ?? []).map(\.rawValue).sorted() + } + + init(_ runtime: CoreSimulatorImage) { + build = runtime.runtimeInfo.build + architectures = (runtime.runtimeInfo.supportedArchitectures ?? []).map(\.rawValue).sorted() + } +} + private extension CoreSimulatorImage { init(_ runtime: InstalledRuntime) { self.init( diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 970da3c5..a1e5bdbb 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -170,7 +170,7 @@ class AppState: ObservableObject { @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { - if newValue.count > availableXcodes.count && availableXcodes.count != 0 { + if Self.newlyAvailableXcodes(old: availableXcodes, new: newValue).isEmpty == false { Current.notificationManager.scheduleNotification(title: localizeString("Notification.NewXcodeVersion.Title"), body: localizeString("Notification.NewXcodeVersion.Body"), category: .normal) } updateAllXcodes( @@ -185,6 +185,16 @@ class AppState: ObservableObject { } } } + + static func newlyAvailableXcodes( + old oldXcodes: [AvailableXcode], + new newXcodes: [AvailableXcode] + ) -> [AvailableXcode] { + guard oldXcodes.isEmpty == false else { return [] } + let existingIDs = Set(oldXcodes.map(\.xcodeID)) + return newXcodes.filter { existingIDs.contains($0.xcodeID) == false } + } + @Published var allXcodes: [Xcode] = [] @Published var selectedXcodePath: String? { willSet { @@ -740,7 +750,7 @@ class AppState: ObservableObject { } } do { - setInstallationStep(of: availableXcode.version, to: .authenticating) + setInstallationStep(of: availableXcode, to: .authenticating) try await signInIfNeededAsync() try await waitForAuthenticationTerminalState() try await validateDeveloperDownloads() @@ -911,11 +921,11 @@ class AppState: ObservableObject { } } - func open(xcode: Xcode, openInRosetta: Bool? = false) { + func open(xcode: Xcode, openInRosetta: Bool = false) { switch xcode.installState { case let .installed(path): let config = NSWorkspace.OpenConfiguration.init() - if (openInRosetta ?? false) { + if openInRosetta { config.architecture = CPU_TYPE_X86_64 } config.allowsRunningApplicationSubstitution = false @@ -926,12 +936,11 @@ class AppState: ObservableObject { } } - func copyPath(xcode: Xcode) { + func copyPath(xcode: Xcode, pasteboard: NSPasteboard = .general) { 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) + pasteboard.clearContents() + pasteboard.setString(installedXcodePath.string, forType: .string) } func copyReleaseNote(from url: URL?) { diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 49973225..fc6b89c2 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import Path import KeychainAccess @@ -45,7 +46,13 @@ private final class CurrentEnvironmentStorage: Sendable { public struct Shell: Sendable { private static let shared = XcodesShell() - public var unxip = Shell.shared.unxip + public var unxip: @Sendable (URL, URL) async throws -> ProcessOutput = { source, workingDirectory in + try await Process.runAsync( + URL(fileURLWithPath: "/usr/bin/xip"), + workingDirectory: workingDirectory, + ["--expand", source.path] + ) + } public var spctlAssess = Shell.shared.spctlAssess public var codesignVerify = Shell.shared.codesignVerify public var buildVersion = Shell.shared.buildVersion @@ -61,9 +68,9 @@ public struct Shell: Sendable { } - public var unxipExperiment: @Sendable (URL) async throws -> ProcessOutput = { url in + public var unxipExperiment: @Sendable (URL, URL) async throws -> ProcessOutput = { url, workingDirectory in let unxipPath = Path(url: Bundle.main.url(forAuxiliaryExecutable: "unxip")!)! - return try await Process.runAsync(unxipPath.url, workingDirectory: url.deletingLastPathComponent(), ["\(url.path)"]) + return try await Process.runAsync(unxipPath.url, workingDirectory: workingDirectory, ["\(url.path)"]) } public var downloadRuntime: @Sendable (String, String, String?) -> AsyncThrowingStream = { platform, version, architecture in @@ -84,6 +91,43 @@ public struct Files: Sendable { try moveItem(srcURL, dstURL) } + public var linkItem: @Sendable (URL, URL) throws -> Void = { try FileManager.default.linkItem(at: $0, to: $1) } + + public func linkItem(at srcURL: URL, to dstURL: URL) throws { + try linkItem(srcURL, dstURL) + } + + var canonicalURL: @Sendable (URL) -> URL = { + $0.resolvingSymlinksInPath().standardizedFileURL + } + + var fileSystemIdentity: @Sendable (URL) throws -> FileSystemIdentity = { url in + var fileStatus = stat() + guard lstat(url.path, &fileStatus) == 0 else { + throw currentPOSIXError() + } + + return FileSystemIdentity(fileStatus) + } + + var beforeOwnedDirectoryQuarantine: @Sendable () throws -> Void = {} + + var quarantineAndRemoveOwnedDirectory: @Sendable ( + URL, + FileSystemIdentity, + URL, + FileSystemIdentity, + @Sendable () throws -> Void + ) throws -> Void = { parentURL, parentIdentity, directoryURL, directoryIdentity, beforeQuarantine in + try securelyRemoveOwnedDirectory( + parentURL: parentURL, + parentIdentity: parentIdentity, + directoryURL: directoryURL, + directoryIdentity: directoryIdentity, + beforeQuarantine: beforeQuarantine + ) + } + public var contentsAtPath: @Sendable (String) -> Data? = { FileManager.default.contents(atPath: $0) } public func contents(atPath path: String) -> Data? { @@ -136,6 +180,206 @@ public struct Files: Sendable { } } +struct FileSystemIdentity: Equatable, Sendable { + let deviceID: UInt64 + let inode: UInt64 + let isDirectory: Bool + let isSymbolicLink: Bool + + init(_ fileStatus: stat) { + let fileType = fileStatus.st_mode & S_IFMT + deviceID = UInt64(bitPattern: Int64(fileStatus.st_dev)) + inode = UInt64(fileStatus.st_ino) + isDirectory = fileType == S_IFDIR + isSymbolicLink = fileType == S_IFLNK + } + + init(deviceID: UInt64, inode: UInt64, isDirectory: Bool, isSymbolicLink: Bool) { + self.deviceID = deviceID + self.inode = inode + self.isDirectory = isDirectory + self.isSymbolicLink = isSymbolicLink + } +} + +private func securelyRemoveOwnedDirectory( + parentURL: URL, + parentIdentity: FileSystemIdentity, + directoryURL: URL, + directoryIdentity: FileSystemIdentity, + beforeQuarantine: @Sendable () throws -> Void +) throws { + guard directoryURL.deletingLastPathComponent() == parentURL, + directoryURL.lastPathComponent.contains("/") == false + else { + throw CocoaError(.fileWriteInvalidFileName) + } + + let parentDescriptor = open( + parentURL.path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard parentDescriptor >= 0 else { throw currentPOSIXError() } + defer { close(parentDescriptor) } + + var currentParentStatus = stat() + guard fstat(parentDescriptor, ¤tParentStatus) == 0 else { + throw currentPOSIXError() + } + guard FileSystemIdentity(currentParentStatus) == parentIdentity else { + throw CocoaError(.fileWriteInvalidFileName) + } + + let directoryName = directoryURL.lastPathComponent + var currentDirectoryStatus = stat() + guard fstatat( + parentDescriptor, + directoryName, + ¤tDirectoryStatus, + AT_SYMLINK_NOFOLLOW + ) == 0 else { + throw currentPOSIXError() + } + guard FileSystemIdentity(currentDirectoryStatus) == directoryIdentity else { + throw CocoaError(.fileWriteInvalidFileName) + } + + let quarantineName = ".xcodes-cleanup-" + UUID().uuidString + guard mkdirat(parentDescriptor, quarantineName, 0o700) == 0 else { + throw currentPOSIXError() + } + + let quarantineDescriptor = openat( + parentDescriptor, + quarantineName, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard quarantineDescriptor >= 0 else { + let error = currentPOSIXError() + unlinkat(parentDescriptor, quarantineName, AT_REMOVEDIR) + throw error + } + defer { + close(quarantineDescriptor) + unlinkat(parentDescriptor, quarantineName, AT_REMOVEDIR) + } + + try beforeQuarantine() + + let quarantinedDirectoryName = "workspace" + guard renameat( + parentDescriptor, + directoryName, + quarantineDescriptor, + quarantinedDirectoryName + ) == 0 else { + throw currentPOSIXError() + } + + var quarantinedDirectoryStatus = stat() + guard fstatat( + quarantineDescriptor, + quarantinedDirectoryName, + &quarantinedDirectoryStatus, + AT_SYMLINK_NOFOLLOW + ) == 0 else { + throw currentPOSIXError() + } + guard FileSystemIdentity(quarantinedDirectoryStatus) == directoryIdentity else { + throw CocoaError(.fileWriteInvalidFileName) + } + + let workspaceDescriptor = openat( + quarantineDescriptor, + quarantinedDirectoryName, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard workspaceDescriptor >= 0 else { throw currentPOSIXError() } + defer { close(workspaceDescriptor) } + + try removeDirectoryContents(at: workspaceDescriptor) + guard unlinkat( + quarantineDescriptor, + quarantinedDirectoryName, + AT_REMOVEDIR + ) == 0 else { + throw currentPOSIXError() + } +} + +private func removeDirectoryContents(at directoryDescriptor: Int32) throws { + let iterationDescriptor = dup(directoryDescriptor) + guard iterationDescriptor >= 0 else { throw currentPOSIXError() } + guard let directory = fdopendir(iterationDescriptor) else { + let error = currentPOSIXError() + close(iterationDescriptor) + throw error + } + defer { closedir(directory) } + + while true { + errno = 0 + guard let entry = readdir(directory) else { + if errno != 0 { throw currentPOSIXError() } + return + } + + let name = withUnsafePointer(to: entry.pointee.d_name) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXNAMLEN) + 1) { + String(cString: $0) + } + } + guard name != ".", name != ".." else { continue } + + var entryStatus = stat() + guard fstatat( + directoryDescriptor, + name, + &entryStatus, + AT_SYMLINK_NOFOLLOW + ) == 0 else { + throw currentPOSIXError() + } + + if entryStatus.st_mode & S_IFMT == S_IFDIR { + let childDescriptor = openat( + directoryDescriptor, + name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard childDescriptor >= 0 else { throw currentPOSIXError() } + + do { + var openedStatus = stat() + guard fstat(childDescriptor, &openedStatus) == 0 else { + throw currentPOSIXError() + } + guard FileSystemIdentity(openedStatus) == FileSystemIdentity(entryStatus) else { + throw CocoaError(.fileWriteInvalidFileName) + } + + try removeDirectoryContents(at: childDescriptor) + close(childDescriptor) + } catch { + close(childDescriptor) + throw error + } + + guard unlinkat(directoryDescriptor, name, AT_REMOVEDIR) == 0 else { + throw currentPOSIXError() + } + } else { + guard unlinkat(directoryDescriptor, name, 0) == 0 else { + throw currentPOSIXError() + } + } + } +} + +private func currentPOSIXError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) +} + private func _installedXcodes(destination: Path) -> [InstalledXcode] { InstalledXcodeDiscoveryService( listDirectory: { $0.ls() }, diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index 344c7ebd..fc40395a 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -114,18 +114,18 @@ struct OpenButton: View { @EnvironmentObject var appState: AppState let xcode: Xcode? - var openInRosetta: Bool { + var showsOpenInRosettaOption: Bool { appState.showOpenInRosettaOption && HostHardware.isAppleSilicon() } var body: some View { - if openInRosetta { + if showsOpenInRosettaOption { Menu("Open") { Button(action: open) { Text("Open") } .help("Open") - Button(action: open) { + Button(action: openInRosetta) { Text("Open In Rosetta") } .help("Open In Rosetta") @@ -141,7 +141,12 @@ struct OpenButton: View { private func open() { guard let xcode = xcode else { return } - appState.open(xcode: xcode, openInRosetta: openInRosetta) + appState.open(xcode: xcode) + } + + private func openInRosetta() { + guard let xcode = xcode else { return } + appState.open(xcode: xcode, openInRosetta: true) } } @@ -264,6 +269,17 @@ struct CreateSymbolicBetaLinkButton: View { } } +enum XcodeCommandShortcuts { + static let makeActive = KeyboardShortcut("s", modifiers: [.command, .option]) + static let open = KeyboardShortcut(.downArrow, modifiers: .command) + static let reveal = KeyboardShortcut("r", modifiers: [.command, .option]) + static let copyPath = KeyboardShortcut("c", modifiers: [.command, .option]) + static let uninstall = KeyboardShortcut("u", modifiers: [.command, .option]) + static let createSymbolicLink = KeyboardShortcut("l", modifiers: [.command, .option]) + + static let all = [makeActive, open, reveal, copyPath, uninstall, createSymbolicLink] +} + // MARK: - Commands struct InstallCommand: View { @@ -288,7 +304,7 @@ struct SelectCommand: View { var body: some View { SelectButton(xcode: selectedXcode.unwrapped) - .keyboardShortcut("s", modifiers: [.command, .option]) + .keyboardShortcut(XcodeCommandShortcuts.makeActive) .disabled(selectedXcode.unwrapped?.installState.installed != true) } } @@ -299,7 +315,7 @@ struct OpenCommand: View { var body: some View { OpenButton(xcode: selectedXcode.unwrapped) - .keyboardShortcut(KeyboardShortcut(.downArrow, modifiers: .command)) + .keyboardShortcut(XcodeCommandShortcuts.open) .disabled(selectedXcode.unwrapped?.installState.installed != true) } } @@ -310,7 +326,7 @@ struct RevealCommand: View { var body: some View { RevealButton(xcode: selectedXcode.unwrapped) - .keyboardShortcut("r", modifiers: [.command, .option]) + .keyboardShortcut(XcodeCommandShortcuts.reveal) .disabled(selectedXcode.unwrapped?.installState.installed != true) } } @@ -321,7 +337,7 @@ struct CopyPathCommand: View { var body: some View { CopyPathButton(xcode: selectedXcode.unwrapped) - .keyboardShortcut("c", modifiers: [.command, .option]) + .keyboardShortcut(XcodeCommandShortcuts.copyPath) .disabled(selectedXcode.unwrapped?.installState.installed != true) } } @@ -332,7 +348,7 @@ struct UninstallCommand: View { var body: some View { UninstallButton(xcode: selectedXcode.unwrapped) - .keyboardShortcut("u", modifiers: [.command, .option]) + .keyboardShortcut(XcodeCommandShortcuts.uninstall) .disabled(selectedXcode.unwrapped?.installState.installed != true) } } @@ -343,7 +359,7 @@ struct CreateSymbolicLinkCommand: View { var body: some View { CreateSymbolicLinkButton(xcode: selectedXcode.unwrapped) - .keyboardShortcut("s", modifiers: [.command, .option]) + .keyboardShortcut(XcodeCommandShortcuts.createSymbolicLink) .disabled(selectedXcode.unwrapped?.installState.installed != true) } } diff --git a/Xcodes/Frontend/Common/XcodesAlert.swift b/Xcodes/Frontend/Common/XcodesAlert.swift index 89be7d58..11a50df1 100644 --- a/Xcodes/Frontend/Common/XcodesAlert.swift +++ b/Xcodes/Frontend/Common/XcodesAlert.swift @@ -22,7 +22,7 @@ enum XcodesAlert: Identifiable { } enum XcodesPlatformAlert: Identifiable { - case deletePlatform(runtime: DownloadableRuntime) + case deletePlatform(runtime: InstalledPlatformRuntime) case generic(title: String, message: String) var id: Int { diff --git a/Xcodes/Frontend/InfoPane/InfoPane.swift b/Xcodes/Frontend/InfoPane/InfoPane.swift index b557e748..11f6a8f8 100644 --- a/Xcodes/Frontend/InfoPane/InfoPane.swift +++ b/Xcodes/Frontend/InfoPane/InfoPane.swift @@ -6,59 +6,59 @@ import Version struct InfoPane: View { let xcode: Xcode + var body: some View { - if #available(macOS 14.0, *) { - mainContent - .contentMargins(10, for: .scrollContent) - } else { - mainContent - .padding() - } - } - - private var mainContent: some View { ScrollView(.vertical) { - HStack(alignment: .top) { - VStack { - VStack(spacing: 5) { - HStack { - IconView(xcode: xcode) - - Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") - .font(.title) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - } - InfoPaneControls(xcode: xcode) - } - .xcodesBackground() - - PlatformsView(xcode: xcode) + ViewThatFits(in: .horizontal) { + HStack(alignment: .top) { + primaryColumn + .frame(minWidth: 380) + secondaryColumn + .frame(width: 200) } - .frame(minWidth: 380) - + VStack(alignment: .leading) { - ReleaseDateView(date: xcode.releaseDate, url: xcode.releaseNotesURL) - CompatibilityView(requiredMacOSVersion: xcode.requiredMacOSVersion) - IdenticalBuildsView(builds: xcode.identicalBuildsForCurrentVariant) - SDKandCompilers + primaryColumn + secondaryColumn } - .frame(width: 200) - } } + .contentMargins(10, for: .scrollContent) } - - @ViewBuilder - var SDKandCompilers: some View { - VStack(alignment: .leading, spacing: 16) { - SDKsView(sdks: xcode.sdks) - CompilersView(compilers: xcode.compilers) + + private var primaryColumn: some View { + VStack { + VStack(spacing: 5) { + HStack { + IconView(xcode: xcode) + Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") + .font(.title) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + InfoPaneControls(xcode: xcode) + } + .xcodesBackground() + + PlatformsView(xcode: xcode) + } + } + + private var secondaryColumn: some View { + VStack(alignment: .leading) { + ReleaseDateView(date: xcode.releaseDate, url: xcode.releaseNotesURL) + CompatibilityView(requiredMacOSVersion: xcode.requiredMacOSVersion) + IdenticalBuildsView(builds: xcode.identicalBuildsForCurrentVariant) + VStack(alignment: .leading, spacing: 16) { + SDKsView(sdks: xcode.sdks) + CompilersView(compilers: xcode.compilers) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.background) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(.background) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) } } diff --git a/Xcodes/Frontend/InfoPane/ReleaseDateView.swift b/Xcodes/Frontend/InfoPane/ReleaseDateView.swift index 838c3659..5bf426da 100644 --- a/Xcodes/Frontend/InfoPane/ReleaseDateView.swift +++ b/Xcodes/Frontend/InfoPane/ReleaseDateView.swift @@ -11,31 +11,25 @@ import SwiftUI struct ReleaseDateView: View { let date: Date? let url: URL? + var body: some View { - if let date = date { - - VStack(alignment: .leading) { - HStack { - Text("ReleaseDate") - .font(.headline) - Spacer() - if let url { - ReleaseNotesView(url: url) - } - } - - Text("\(date, style: .date)") - .font(.subheadline) - + if let date { + VStack(alignment: .leading) { + Text("ReleaseDate") + .font(.headline) + + Text("\(date, style: .date)") + .font(.subheadline) + + if let url { + ReleaseNotesView(url: url) + .padding(.top, 2) } - - + } .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(.background) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) - } else { - EmptyView() } } } diff --git a/Xcodes/Frontend/InfoPane/ReleaseNotesView.swift b/Xcodes/Frontend/InfoPane/ReleaseNotesView.swift index 8f16e3a1..4e4fe91d 100644 --- a/Xcodes/Frontend/InfoPane/ReleaseNotesView.swift +++ b/Xcodes/Frontend/InfoPane/ReleaseNotesView.swift @@ -14,12 +14,14 @@ struct ReleaseNotesView: View { @SwiftUI.Environment(\.openURL) var openURL: OpenURLAction var body: some View { - if let url = url { + if let url { Button(action: { openURL(url) }) { - Image(systemName: "link.circle.fill") - .font(.title) + Label("ReleaseNotes.help", systemImage: "doc.text") + .font(.callout) + .lineLimit(2) + .multilineTextAlignment(.leading) } - .buttonStyle(.plain) + .buttonStyle(.link) .contextMenu(menuItems: { CopyReleaseNoteButton(url: url) }) diff --git a/Xcodes/Frontend/Preferences/PlatformsListView.swift b/Xcodes/Frontend/Preferences/PlatformsListView.swift index cee4a659..093d80b3 100644 --- a/Xcodes/Frontend/Preferences/PlatformsListView.swift +++ b/Xcodes/Frontend/Preferences/PlatformsListView.swift @@ -13,8 +13,8 @@ import OrderedCollections struct PlatformsListView: View { @EnvironmentObject var appState: AppState - @State private var runtimes: OrderedDictionary = [:] - @State private var selectedRuntime: DownloadableRuntime? + @State private var runtimes: OrderedDictionary = [:] + @State private var selectedRuntime: InstalledPlatformRuntime? var body: some View { List(selection: $selectedRuntime) { @@ -22,13 +22,14 @@ struct PlatformsListView: View { .font(.body) ForEach(runtimes.elements.sorted(\.key.order), id: \.key) { platform, runtimeList in Section { - ForEach(runtimeList, id: \.self) { runtime in + ForEach(runtimeList) { installedRuntime in + let runtime = installedRuntime.runtime HStack { Text(runtime.name) Spacer() Text(runtime.downloadFileSizeString) Button { - deleteRuntime(runtime: runtime) + deleteRuntime(runtime: installedRuntime) } label: { Image(systemName: "trash") } @@ -40,7 +41,7 @@ struct PlatformsListView: View { } header: { HStack { - runtimeList.first!.icon() + runtimeList.first!.runtime.icon() .aspectRatio(contentMode: .fit) .frame(width: 20) Text(platform.shortName) @@ -62,10 +63,10 @@ struct PlatformsListView: View { func loadRuntimes() { let filteredRuntimes = appState.installedPlatformRuntimes() - runtimes = OrderedDictionary(grouping: filteredRuntimes, by: { $0.platform }) + runtimes = OrderedDictionary(grouping: filteredRuntimes, by: { $0.runtime.platform }) } - func deleteRuntime(runtime: DownloadableRuntime) { + func deleteRuntime(runtime: InstalledPlatformRuntime) { appState.presentedPlatformAlert = .deletePlatform(runtime: runtime) } } diff --git a/Xcodes/Frontend/SignIn/PinCodeTextView.swift b/Xcodes/Frontend/SignIn/PinCodeTextView.swift index 6140cdd1..b3cc287b 100644 --- a/Xcodes/Frontend/SignIn/PinCodeTextView.swift +++ b/Xcodes/Frontend/SignIn/PinCodeTextView.swift @@ -1,80 +1,115 @@ import Cocoa import SwiftUI +enum PinCodeInput { + static func normalise( + _ input: String, + replacing previousCode: String, + numberOfDigits: Int + ) -> String { + guard numberOfDigits > 0 else { return "" } + + if let insertedText = insertedText(from: previousCode, to: input) { + let insertedCode = sanitise(insertedText, numberOfDigits: numberOfDigits) + if insertedText.count > 1, insertedCode.count == numberOfDigits { + return insertedCode + } + } + + return sanitise(input, numberOfDigits: numberOfDigits) + } + + static func isComplete(_ code: String, numberOfDigits: Int) -> Bool { + code.count == numberOfDigits + } + + static func sanitise(_ input: String, numberOfDigits: Int) -> String { + guard numberOfDigits > 0 else { return "" } + return String(input.filter { $0.isLetter || $0.isNumber }.prefix(numberOfDigits)) + } + + private static func insertedText(from oldValue: String, to newValue: String) -> String? { + guard newValue.count > oldValue.count else { return nil } + + let oldCharacters = Array(oldValue) + let newCharacters = Array(newValue) + var prefixCount = 0 + while prefixCount < oldCharacters.count, + oldCharacters[prefixCount] == newCharacters[prefixCount] { + prefixCount += 1 + } + + var suffixCount = 0 + while suffixCount < oldCharacters.count - prefixCount, + oldCharacters[oldCharacters.count - suffixCount - 1] + == newCharacters[newCharacters.count - suffixCount - 1] { + suffixCount += 1 + } + + return String(newCharacters[prefixCount..<(newCharacters.count - suffixCount)]) + } +} + struct PinCodeTextField: NSViewRepresentable { typealias NSViewType = PinCodeTextView @Binding var code: String let numberOfDigits: Int + let accessibilityLabel: String let complete: (String) -> Void func makeNSView(context: Context) -> NSViewType { - let view = PinCodeTextView(numberOfDigits: numberOfDigits, itemSpacing: 10) - view.codeDidChange = { c in code = c } - view.codeDidComplete = { complete($0) } + let view = PinCodeTextView( + numberOfDigits: numberOfDigits, + itemSpacing: 10, + accessibilityLabel: accessibilityLabel + ) + view.codeDidChange = { code = $0 } + view.codeDidComplete = complete return view } - + func updateNSView(_ nsView: NSViewType, context: Context) { - nsView.code = (0.. Void)? + var codeDidComplete: ((String) -> Void)? -class PinCodeTextView: NSControl, NSTextFieldDelegate { - var code: [Character?] = [] { - didSet { - guard code != oldValue else { return } - - if let handler = codeDidChange { - handler(String(code.compactMap { $0 })) - } - updateText() - - if code.compactMap({ $0 }).count == numberOfDigits, - let handler = codeDidComplete { - handler(String(code.compactMap { $0 })) - } - } - } - var codeDidChange: ((String) -> Void)? = nil - var codeDidComplete: ((String) -> Void)? = nil + private(set) var currentCode = "" private let numberOfDigits: Int - private let stackView: NSStackView = .init(frame: .zero) - private var characterViews: [PinCodeCharacterTextField] = [] - - // MARK: - Initializers - - init( - numberOfDigits: Int, - itemSpacing: CGFloat - ) { + private let stackView = NSStackView(frame: .zero) + private var characterBoxes: [PinCodeCharacterBox] = [] + private let inputField = PinCodeInputField() + private var firstResponderObservation: NSKeyValueObservation? + + init(numberOfDigits: Int, itemSpacing: CGFloat, accessibilityLabel: String) { + precondition(numberOfDigits > 0) self.numberOfDigits = numberOfDigits super.init(frame: .zero) @@ -84,174 +119,195 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate { stackView.distribution = .fillEqually stackView.alignment = .centerY addSubview(stackView) + + characterBoxes = (0.. Bool { - if commandSelector == #selector(deleteBackward(_:)) { - // If empty, move to previous or first character view - if textView.string.isEmpty { - if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) { - window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter]) - } else { - window?.makeFirstResponder(characterViews[0]) - } - - return true - } - } - - // Perform default behaviour - return false + + func updateAccessibilityLabel(_ accessibilityLabel: String) { + inputField.setAccessibilityLabel(accessibilityLabel) } - - func controlTextDidChange(_ obj: Notification) { - guard - let field = obj.object as? NSTextField, - isEnabled, - let fieldIndex = characterViews.firstIndex(where: { $0 === field }) - else { return } - - let newFieldText = field.stringValue - - // Handle pasting multiple characters (e.g., pasting "123456" from clipboard) - if newFieldText.count > 1 { - // Filter to alphanumeric characters only - let validCharacters = newFieldText.filter { $0.isLetter || $0.isNumber } - - // Always start from the first field and clear previous content - var newCode = Array(repeating: Character?.none, count: numberOfDigits) - for (offset, character) in validCharacters.enumerated() { - if offset < numberOfDigits { - newCode[offset] = character - } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + firstResponderObservation = window?.observe(\.firstResponder) { [weak self] _, _ in + DispatchQueue.main.async { + self?.updateBoxes() } - - // Update all fields at once to avoid triggering didSet multiple times - code = newCode - - // Move focus to next empty field or the last field if all are filled - let nextEmptyIndex = code.firstIndex(where: { $0 == nil }) ?? numberOfDigits - 1 - if nextEmptyIndex < characterViews.count { - window?.makeFirstResponder(characterViews[nextEmptyIndex]) - } else { - resignFirstResponder() + } + DispatchQueue.main.async { [weak self] in + guard let self, let window = self.window, window.firstResponder === window else { + return } - - return + window.makeFirstResponder(self.inputField) } - - // Handle single character input - let lastCharacter: Character? - if newFieldText.isEmpty { - lastCharacter = nil - } else { - lastCharacter = newFieldText[newFieldText.index(before: newFieldText.endIndex)] + } + + func controlTextDidChange(_ notification: Notification) { + guard notification.object as? NSTextField === inputField, isEnabled else { return } + + let normalisedCode = PinCodeInput.normalise( + inputField.stringValue, + replacing: currentCode, + numberOfDigits: numberOfDigits + ) + inputField.replaceText(with: normalisedCode) + guard normalisedCode != currentCode else { return } + + currentCode = normalisedCode + updateBoxes() + codeDidChange?(normalisedCode) + + if PinCodeInput.isComplete(normalisedCode, numberOfDigits: numberOfDigits) { + codeDidComplete?(normalisedCode) } + } - code[fieldIndex] = lastCharacter - - if lastCharacter != nil { - if fieldIndex >= characterViews.count - 1 { - resignFirstResponder() - } else { - window?.makeFirstResponder(characterViews[fieldIndex + 1]) - } - } else { - if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) { - window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter]) - } else { - window?.makeFirstResponder(characterViews[0]) - } + override var acceptsFirstResponder: Bool { true } + + override func becomeFirstResponder() -> Bool { + window?.makeFirstResponder(inputField) ?? inputField.becomeFirstResponder() + } + + override var isEnabled: Bool { + didSet { inputField.isEnabled = isEnabled } + } + + private func updateBoxes() { + let characters = Array(currentCode) + let activeIndex = isInputFocused ? min(characters.count, numberOfDigits - 1) : nil + + for (index, box) in characterBoxes.enumerated() { + box.character = index < characters.count ? characters[index] : nil + box.isActive = index == activeIndex } } - - // MARK: NSResponder - - override var acceptsFirstResponder: Bool { - true + + private var isInputFocused: Bool { + guard let responder = window?.firstResponder else { return false } + if responder === inputField { return true } + return (responder as? NSTextView)?.delegate === inputField + } +} + +private final class PinCodeInputField: NSTextField { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + isBordered = false + drawsBackground = false + focusRingType = .none + textColor = .clear + usesSingleLineMode = true + cell?.isScrollable = true + contentType = .oneTimeCode } - + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func becomeFirstResponder() -> Bool { - characterViews.first?.becomeFirstResponder() ?? false + let didBecomeFirstResponder = super.becomeFirstResponder() + if didBecomeFirstResponder { + updateFieldEditor() + } + return didBecomeFirstResponder + } + + func replaceText(with text: String) { + guard stringValue != text else { return } + stringValue = text + updateFieldEditor() + } + + private func updateFieldEditor() { + guard let editor = currentEditor() as? NSTextView else { return } + editor.insertionPointColor = .clear + editor.selectedTextAttributes = [ + .backgroundColor: NSColor.clear, + .foregroundColor: NSColor.clear, + ] + editor.setSelectedRange(NSRange(location: stringValue.utf16.count, length: 0)) } } -// MARK: - PinCodeCharacterTextField +private final class PinCodeCharacterBox: NSTextField { + var character: Character? { + didSet { stringValue = character.map(String.init) ?? "" } + } -class PinCodeCharacterTextField: NSTextField { - var character: Character? = nil { + var isActive = false { didSet { - stringValue = character.map(String.init) ?? "" + layer?.borderWidth = isActive ? 2 : 0 + layer?.borderColor = NSColor.controlAccentColor.cgColor + layer?.cornerRadius = isActive ? 3 : 0 } } - private var lastSize: NSSize? - - init() { - super.init(frame: .zero) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) wantsLayer = true + isEditable = false + isSelectable = false alignment = .center maximumNumberOfLines = 1 font = .boldSystemFont(ofSize: 48) - + setAccessibilityElement(false) setContentHuggingPriority(.required, for: .vertical) setContentHuggingPriority(.required, for: .horizontal) } - - required init?(coder aDecoder: NSCoder) { + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func textDidChange(_ notification: Notification) { - super.textDidChange(notification) - self.invalidateIntrinsicContentSize() - } - - // This is kinda cheating - // Assuming that 0 is the widest and tallest character in 0-9 + override var intrinsicContentSize: NSSize { var size = NSAttributedString( string: "0", - attributes: [ .font : self.font! ] - ) - .size() - // I guess the cell should probably be doing this sizing in order to take into account everything outside of simply the text's frame, but for some reason I can't find a way to do that which works... + attributes: [.font: font as Any] + ).size() size.width += 16 size.height += 8 return size diff --git a/Xcodes/Frontend/SignIn/SignIn2FAView.swift b/Xcodes/Frontend/SignIn/SignIn2FAView.swift index cbc385a7..1cc88701 100644 --- a/Xcodes/Frontend/SignIn/SignIn2FAView.swift +++ b/Xcodes/Frontend/SignIn/SignIn2FAView.swift @@ -15,7 +15,14 @@ struct SignIn2FAView: View { HStack { Spacer() - PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode!.length) { + PinCodeTextField( + code: $code, + numberOfDigits: authOptions.securityCode!.length, + accessibilityLabel: String( + format: localizeString("DigitCodeDescription"), + authOptions.securityCode!.length + ) + ) { appState.submitSecurityCode(.device(code: $0), sessionData: sessionData) } Spacer() diff --git a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift index 8102e61e..15f2ac21 100644 --- a/Xcodes/Frontend/SignIn/SignInCredentialsView.swift +++ b/Xcodes/Frontend/SignIn/SignInCredentialsView.swift @@ -21,12 +21,14 @@ struct SignInCredentialsView: View { TextField(text: $username) { Text(verbatim: "example@icloud.com") } + .textContentType(.username) .focused($focusedField, equals: .username) } HStack { Text("Password") .frame(minWidth: 100, alignment: .trailing) SecureField("Required", text: $password) + .textContentType(.password) .focused($focusedField, equals: .password) } if appState.authError != nil { diff --git a/Xcodes/Frontend/SignIn/SignInSMSView.swift b/Xcodes/Frontend/SignIn/SignInSMSView.swift index 8a1cd918..e298a8b5 100644 --- a/Xcodes/Frontend/SignIn/SignInSMSView.swift +++ b/Xcodes/Frontend/SignIn/SignInSMSView.swift @@ -15,7 +15,15 @@ struct SignInSMSView: View { HStack { Spacer() - PinCodeTextField(code: $code, numberOfDigits: authOptions.securityCode!.length) { + PinCodeTextField( + code: $code, + numberOfDigits: authOptions.securityCode!.length, + accessibilityLabel: String( + format: localizeString("EnterDigitCodeDescription"), + authOptions.securityCode!.length, + trustedPhoneNumber.numberWithDialCode + ) + ) { appState.submitSecurityCode(.sms(code: $0, phoneNumberId: trustedPhoneNumber.id), sessionData: sessionData) } Spacer() diff --git a/Xcodes/Frontend/XcodeList/AppStoreButtonStyle.swift b/Xcodes/Frontend/XcodeList/AppStoreButtonStyle.swift index 4bad83d6..b500a429 100644 --- a/Xcodes/Frontend/XcodeList/AppStoreButtonStyle.swift +++ b/Xcodes/Frontend/XcodeList/AppStoreButtonStyle.swift @@ -98,21 +98,21 @@ struct AppStoreButtonStyle_Previews: PreviewProvider { ForEach([ColorScheme.light, .dark], id: \.self) { colorScheme in Group { Button{ } label: { - Text(verbatim: "OPEN") + Text(verbatim: "Open") } .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false)) .padding() .background(Color(.textBackgroundColor)) .previewDisplayName("Primary") Button{ } label: { - Text(verbatim: "OPEN") + Text(verbatim: "Open") } .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: true)) .padding() .background(Color(.controlAccentColor)) .previewDisplayName("Primary, Highlighted") Button{ } label: { - Text(verbatim: "OPEN") + Text(verbatim: "Open") } .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false)) .padding() @@ -120,21 +120,21 @@ struct AppStoreButtonStyle_Previews: PreviewProvider { .background(Color(.textBackgroundColor)) .previewDisplayName("Primary, Disabled") Button{ } label: { - Text(verbatim: "INSTALL") + Text(verbatim: "Install") } .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) .padding() .background(Color(.textBackgroundColor)) .previewDisplayName("Secondary") Button{ } label: { - Text(verbatim: "INSTALL") + Text(verbatim: "Install") } .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: true)) .padding() .background(Color(.controlAccentColor)) .previewDisplayName("Secondary, Highlighted") Button{ } label: { - Text(verbatim: "INSTALL") + Text(verbatim: "Install") } .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) .padding() diff --git a/Xcodes/Frontend/XcodeList/MainToolbar.swift b/Xcodes/Frontend/XcodeList/MainToolbar.swift index f2f645f0..c04156b0 100644 --- a/Xcodes/Frontend/XcodeList/MainToolbar.swift +++ b/Xcodes/Frontend/XcodeList/MainToolbar.swift @@ -40,6 +40,17 @@ struct MainToolbarModifier: ViewModifier { .tag(XcodeListCategory.release) Label("BetaOnly", systemImage: "line.horizontal.3.decrease.circle.fill") .tag(XcodeListCategory.beta) + Label { + HStack(spacing: 4) { + Text("Release") + Text(verbatim: "+") + Text("Beta") + } + .accessibilityElement(children: .combine) + } icon: { + Image(systemName: "line.horizontal.3.decrease.circle.fill") + } + .tag(XcodeListCategory.releasePlusNewBetas) } } .help("FilterAvailableDescription") diff --git a/Xcodes/Frontend/XcodeList/XcodeListCategory.swift b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift index f1bf0e4d..fe38c779 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListCategory.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListCategory.swift @@ -1,10 +1,12 @@ import Foundation +import Version import XcodesKit enum XcodeListCategory: String, CaseIterable, Identifiable, CustomStringConvertible { case all case release case beta + case releasePlusNewBetas var id: Self { self } @@ -13,6 +15,8 @@ enum XcodeListCategory: String, CaseIterable, Identifiable, CustomStringConverti case .all: return localizeString("All") case .release: return localizeString("Release") case .beta: return localizeString("Beta") + case .releasePlusNewBetas: + return "\(localizeString("Release")) + \(localizeString("Beta"))" } } @@ -20,7 +24,7 @@ enum XcodeListCategory: String, CaseIterable, Identifiable, CustomStringConverti var versionFilter: XcodeListVersionFilter { switch self { - case .all: + case .all, .releasePlusNewBetas: return .all case .release: return .release @@ -28,6 +32,60 @@ enum XcodeListCategory: String, CaseIterable, Identifiable, CustomStringConverti return .prerelease } } + + func applying( + to elements: [Element], + architectureFilters: [ArchitectureFilter], + allowedMajorVersions: Int?, + searchText: String, + installedOnly: Bool, + item: (Element) -> XcodeListItem + ) -> [Element] { + let categoryElements: [Element] + + if self == .releasePlusNewBetas { + let architectureElements = elements.applying( + XcodeListFilters(architectureFilters: architectureFilters), + item: item + ) + let releasedVersions = Set( + architectureElements + .map(item) + .filter { $0.version.isNotPrerelease } + .map { ReleaseVersion($0.version) } + ) + + categoryElements = elements.filter { element in + let version = item(element).version + return version.isNotPrerelease || releasedVersions.contains(ReleaseVersion(version)) == false + } + } else { + categoryElements = elements + } + + return categoryElements.applying( + XcodeListFilters( + versionFilter: versionFilter, + architectureFilters: architectureFilters, + allowedMajorVersions: allowedMajorVersions, + searchText: searchText, + installedOnly: installedOnly + ), + item: item + ) + } +} + +private struct ReleaseVersion: Hashable { + let major: Int + let minor: Int + let patch: Int + + init(_ version: Version) { + major = version.major + minor = version.minor + patch = version.patch + } } enum XcodeListArchitecture: String, CaseIterable, Identifiable, CustomStringConvertible { diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index ce75938e..b0d16613 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -21,16 +21,18 @@ struct XcodeListView: View { } private var visibleXcodes: [XcodeListEntry] { - appState.allXcodes + let entries = 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) + + return category.applying( + to: entries, + architectureFilters: architecture.architectureFilters, + allowedMajorVersions: allowedMajorVersions, + searchText: searchText, + installedOnly: isInstalledOnly, + item: \.listItem + ) } private func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? { diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 166b9d09..9153d0a4 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -157,12 +157,10 @@ struct XcodeListViewRow: View { switch xcode.installState { case .installed: Button("Open") { appState.open(xcode: xcode) } - .textCase(.uppercase) .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: selected)) .help("OpenDescription") case .notInstalled: InstallButton(xcode: xcode) - .textCase(.uppercase) .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) case let .installing(installationStep): InstallationStepRowView( diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index f568bca4..ce6c49fc 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -3329,7 +3329,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Xcodes использует отдельный привилегированный помощник для выполнения задач от имени root-пользователя. Это команды, которые потребуют sudo в командной строке, включая шаги после установки и переключение версий Xcode с помощью xcode-select.\n\nВам будет предложено указать пароль от вашей учетной записи macOS для его установки." + "value" : "Xcodes использует отдельный привилегированный помощник для выполнения задач от имени root-пользователя. Это команды, которые потребуют sudo в командной строке, включая шаги после установки и переключение версий Xcode с помощью xcode-select.\n\nВам будет предложено ввести пароль от вашей учетной записи macOS для его установки." } }, "th" : { @@ -8073,7 +8073,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Источник данных Apple применяет технологию \"веб-скрейпинга\" к веб-сайту Apple для разработчиков. Он всегда показывает последние доступные выпуски, но является менее стабильным источником данных.\n\n[Xcode Releases](https://xcodereleases.com) — это неофициальный список выпусков Xcode. Он предоставляется в виде удобно структурированных данных, содержит дополнительную информацию, которую не всегда можно получить от Apple и который с меньшей вероятностью перестанет работать, если Apple изменит дизайн своего веб-сайта для разработчиков." + "value" : "Источник данных Apple применяет технологию \"веб-скрейпинга\" к веб-сайту Apple для разработчиков. Он всегда показывает последние доступные сборки, но является менее стабильным источником данных.\n\n[Xcode Releases](https://xcodereleases.com) — это неофициальный список сборок Xcode. Он предоставляется в виде удобно структурированных данных, содержит дополнительную информацию, которую не всегда можно получить от Apple и который с меньшей вероятностью перестанет работать, если Apple изменит дизайн своего веб-сайта для разработчиков." } }, "th" : { @@ -8097,7 +8097,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Apple数据源是从Apple开发者网站爬取的。它会及时反应最新版本,但较为脆弱。\n\n[Xcode Releases](https://xcodereleases.com)是一个非官方的Xcode版本列表。它提供了良好格式化的数据,包含了不便从Apple直接获得的附加信息,且更不容易在Apple开发者网站无法访问时出现问题。" + "value" : "Apple数据源通过对苹果开发者网站进行抓取来获取数据。它始终显示最新的可用版本,但更容易受到影响,不稳定。\n\n[Xcode Releases](https://xcodereleases.com)是一个非官方的Xcode版本列表。它提供了良好格式化的数据,包含了不便从Apple直接获得的附加信息,且更不容易在Apple开发者网站无法访问时出现问题。" } }, "zh-Hant" : { @@ -8572,7 +8572,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "[aria2](https://github.com/aria2/aria2) использует до 16 подключений для загрузки Xcode в 3-5 раз быстрее, чем URLSession. Он поставляется в виде исполняемого файла вместе с исходным кодом в Xcodes, чтобы соответствовать лицензии GPLv2.\n\nURLSession — это API Apple по умолчанию для выполнения запросов по сети." + "value" : "[aria2](https://github.com/aria2/aria2) использует до 16 подключений для загрузки Xcode в 3-5 раз быстрее, чем URLSession. Он поставляется в виде исполняемого файла вместе с исходным кодом Xcodes, чтобы соответствовать лицензии GPLv2.\n\nURLSession — это API Apple по умолчанию для выполнения запросов по сети." } }, "th" : { @@ -9969,13 +9969,13 @@ "ja" : { "stringUnit" : { "state" : "translated", - "value" : "[@_saagarjha](https://twitter.com/_saagarjha) さんの方法で、一部のシステムで Unxip の速度が最大70%向上します。\n\n方法の詳細については、 unxipのリポジトリ (https://github.com/saagarjha/unxip)をご覧ください。" + "value" : "[@_saagarjha](https://twitter.com/_saagarjha) さんの方法で、一部のシステムで Unxip の速度が最大70%向上します。\n\n方法の詳細については、[unxip のリポジトリ](https://github.com/saagarjha/unxip)をご覧ください。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "[@_saagarjha](https://twitter.com/_saagarjha)님 덕분에 이 실험 기능을 이용하면 일부 시스템에서 압축 해제 속도를 최대 70%까지 향상시킬 수 있습니다.\n\n이를 수행하는 방법에 대한 자세한 내용은 unxip 저장소 (https://github.com/saagarjha/unxip)에서 확인할 수 있습니다." + "value" : "[@_saagarjha](https://twitter.com/_saagarjha)님 덕분에 이 실험 기능을 이용하면 일부 시스템에서 압축 해제 속도를 최대 70%까지 향상시킬 수 있습니다.\n\n이를 수행하는 방법에 대한 자세한 내용은 [unxip 저장소](https://github.com/saagarjha/unxip)에서 확인할 수 있습니다." } }, "nl" : { @@ -11533,7 +11533,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Идентичные выпуски" + "value" : "Сборки совпадают" } }, "th" : { @@ -11663,7 +11663,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Иногда предварительная и релизная версии представляют собой один и тот же выпуск. Xcodes автоматически отображает эти версии вместе." + "value" : "Иногда предварительная и релизная версии представляют собой одну и ту же сборку. Xcodes автоматически отображает эти версии вместе." } }, "th" : { @@ -15164,7 +15164,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Xcodes использует единый путь для поиска и установки выпусков Xcode. По умолчанию, рекомендуется оставить /Applications. Любые изменения в то, где находится Xcode, могут привести к тому, что другие приложения/сервисы могут перестать работать. " + "value" : "Xcodes использует единый путь для поиска и установки сборок Xcode. По умолчанию, рекомендуется оставить /Applications. Любые изменения в то, где находится Xcode, могут привести к тому, что другие приложения/сервисы могут перестать работать. " } }, "th" : { @@ -17237,7 +17237,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Misc" + "value" : "Другое" } }, "th" : { @@ -21340,7 +21340,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Xcodes использует отдельный привилегированный помощник для выполнения задач от имени root-пользователя. Это команды, которые потребуют sudo в командной строке, включая шаги после установки и переключение версий Xcode с помощью xcode-select.\n\nВам будет предложено указать пароль от вашей учетной записи macOS для его установки." + "value" : "Xcodes использует отдельный привилегированный помощник для выполнения задач от имени root-пользователя. Это команды, которые потребуют sudo в командной строке, включая шаги после установки и переключение версий Xcode с помощью xcode-select.\n\nВам будет предложено ввести пароль от вашей учетной записи macOS для его установки." } }, "th" : { @@ -24424,7 +24424,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Terminate App after last window is closed" + "value" : "Завершить приложение после того, как будет закрыто последнее окно" } }, "th" : { diff --git a/Xcodes/Resources/unxip b/Xcodes/Resources/unxip index 46a7f314..ec7b6e01 100755 Binary files a/Xcodes/Resources/unxip and b/Xcodes/Resources/unxip differ diff --git a/Xcodes/Resources/unxip-3.3.provenance.json b/Xcodes/Resources/unxip-3.3.provenance.json new file mode 100644 index 00000000..3311f1e9 --- /dev/null +++ b/Xcodes/Resources/unxip-3.3.provenance.json @@ -0,0 +1,18 @@ +{ + "project": "saagarjha/unxip", + "version": "3.3", + "tag": "v3.3", + "commit": "6c3990517fcc4c1db6952fccf4c562fb14097601", + "sourceArchiveURL": "https://github.com/saagarjha/unxip/archive/6c3990517fcc4c1db6952fccf4c562fb14097601.tar.gz", + "sourceArchiveSHA256": "eca2c807d4a221610e80ec80649624aa04669bc0b76d3c7a341a540b935a2d1a", + "releaseAssetURL": "https://github.com/saagarjha/unxip/releases/download/v3.3/unxip", + "releaseAssetSHA256": "475dbffffd70e06715f966b8788f8dd36772f330b3360ff6ef6ff1fccd2f14fe", + "releaseAssetArchitectures": [ + "arm64", + "x86_64" + ], + "releaseAssetSigningTeam": "X4A9GH5NN2", + "releaseAssetSigningIdentity": "Developer ID Application: Saagar Jha (X4A9GH5NN2)", + "releasePublishedAt": "2026-01-09T02:10:54Z", + "tagSignature": "unsigned" +} diff --git a/Xcodes/Resources/unxip-3.3.tar.gz b/Xcodes/Resources/unxip-3.3.tar.gz new file mode 100644 index 00000000..4dd13539 Binary files /dev/null and b/Xcodes/Resources/unxip-3.3.tar.gz differ diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index a9ef25d3..9a7583d5 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -90,7 +90,7 @@ struct XcodesApp: App { switch alertType { case let .deletePlatform(runtime): return Alert( - title: Text(String(format: localizeString("Alert.DeletePlatform.Title"), runtime.name)), + title: Text(String(format: localizeString("Alert.DeletePlatform.Title"), runtime.runtime.name)), primaryButton: .destructive( Text("Alert.DeletePlatform.PrimaryButton"), action: { diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index f928c4de..5ae3d249 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -1,6 +1,8 @@ +import AppKit import Combine import AsyncNetworkService @preconcurrency import Path +import struct SwiftUI.KeyboardShortcut import Version import XCTest import XcodesLoginKit @@ -35,10 +37,99 @@ class AppStateTests: XCTestCase { subject = AppState() } + func test_NewlyAvailableXcodes_IgnoresInitialLoad() { + let initial = makeAvailableXcode(version: "27.0.0") + + XCTAssertTrue(AppState.newlyAvailableXcodes(old: [], new: [initial]).isEmpty) + } + + func test_NewlyAvailableXcodes_DetectsIdentityWhenCountDoesNotGrow() { + let removed = makeAvailableXcode(version: "26.4.0") + let retained = makeAvailableXcode(version: "26.5.0") + let added = makeAvailableXcode(version: "27.0.0") + + let result = AppState.newlyAvailableXcodes(old: [removed, retained], new: [retained, added]) + + XCTAssertEqual(result.map(\.xcodeID), [added.xcodeID]) + } + + func test_NewlyAvailableXcodes_IgnoresDuplicateIdentity() { + let existing = makeAvailableXcode(version: "27.0.0", filename: "Xcode.xip") + let duplicate = makeAvailableXcode(version: "27.0.0", filename: "Xcode-copy.xip") + + XCTAssertTrue(AppState.newlyAvailableXcodes(old: [existing], new: [existing, duplicate]).isEmpty) + } + + func test_NewlyAvailableXcodes_TreatsArchitectureAsIdentity() { + let universal = makeAvailableXcode( + version: "27.0.0", + architectures: [.arm64, .x86_64] + ) + let appleSilicon = makeAvailableXcode( + version: "27.0.0", + architectures: [.arm64] + ) + + let result = AppState.newlyAvailableXcodes(old: [universal], new: [universal, appleSilicon]) + + XCTAssertEqual(result.map(\.xcodeID), [appleSilicon.xcodeID]) + } + + func test_CommandShortcuts_AreDistinctAndLinkUsesL() { + XCTAssertEqual( + Set(XcodeCommandShortcuts.all).count, + XcodeCommandShortcuts.all.count + ) + XCTAssertEqual( + XcodeCommandShortcuts.createSymbolicLink, + KeyboardShortcut("l", modifiers: [.command, .option]) + ) + } + + func test_InstallNotificationTitle_DoesNotDuplicateMajorVersion() { + XCTAssertEqual( + AppState.installNotificationTitle(for: Version("27.0.0-Beta.4")!), + "27.0 Beta 4" + ) + XCTAssertEqual(AppState.installNotificationTitle(for: Version("26.5.0")!), "26.5") + } + + func test_CopyPath_WritesOnlyPlainText() throws { + let path = try XCTUnwrap(Path("/Applications/Xcode 27.app")) + let xcode = Xcode( + version: Version("27.0.0")!, + installState: .installed(path), + selected: false, + icon: nil + ) + let pasteboard = NSPasteboard(name: .init("AppStateTests-\(UUID().uuidString)")) + defer { pasteboard.releaseGlobally() } + + subject.copyPath(xcode: xcode, pasteboard: pasteboard) + + XCTAssertEqual(pasteboard.string(forType: .string), path.string) + XCTAssertNil(pasteboard.string(forType: .URL)) + XCTAssertEqual(pasteboard.readObjects(forClasses: [NSURL.self])?.isEmpty, true) + } + func test_KeychainUsesPurposeSpecificAppleAccountService() { XCTAssertEqual(Keychain.service, "dev.jacobcx.Xcodes.apple-account") } + private func makeAvailableXcode( + version: String, + filename: String = "Xcode.xip", + architectures: [Architecture]? = nil + ) -> AvailableXcode { + AvailableXcode( + version: Version(version)!, + url: URL(string: "https://example.com/\(filename)")!, + filename: filename, + releaseDate: nil, + architectures: architectures + ) + } + func test_AutoInstallWaitsForInitialInstalledXcodeScan() { Current.defaults.get = { key in key == "autoInstallation" ? AutoInstallationType.newestBeta.rawValue : nil @@ -756,7 +847,341 @@ class AppStateTests: XCTestCase { let runtimes = subject.installedPlatformRuntimes() XCTAssertEqual(runtimes.count, 1) - XCTAssertEqual(runtimes.first?.architectures, [.arm64]) + XCTAssertEqual(runtimes.first?.runtime.architectures, [.arm64]) + } + + func test_InstalledPlatformRuntimes_CollapsesDownloadVariantsForInstalledImage() async throws { + let universalRuntime = try Self.downloadableRuntime( + identifier: "com.apple.dmg.iPhoneSimulatorSDK26_5", + build: "23F72", + version: "26.5", + fileSize: 10_600_000_000, + architectures: [.arm64, .x86_64] + ) + let armRuntime = try Self.downloadableRuntime( + identifier: "com.apple.dmg.iPhoneSimulatorSDK26_5_arm64", + build: "23F72", + version: "26.5", + fileSize: 8_520_000_000, + architectures: [.arm64] + ) + let installedRuntime = CoreSimulatorImage( + uuid: "97772E90-7BD1-4882-9C51-782E62E0AF4F", + path: ["relative": "/Library/Developer/CoreSimulator/Images/iOS_26_5.dmg"], + runtimeInfo: CoreSimulatorRuntimeInfo( + build: "23F72", + supportedArchitectures: [.arm64] + ) + ) + subject.downloadableRuntimes = [universalRuntime, armRuntime] + subject.installedRuntimes = [installedRuntime] + + let runtimes = subject.installedPlatformRuntimes() + + XCTAssertEqual(runtimes.count, 1) + XCTAssertEqual(runtimes.first?.runtime.identifier, armRuntime.identifier) + let deletedIdentifiers = TestLockedBox<[String]>([]) + subject.runtimeService = Self.runtimeService(deleteRuntimeOutput: { identifier in + deletedIdentifiers.withValue { $0.append(identifier) } + return ProcessOutput(status: 0, out: "", err: "") + }) + try await subject.deleteRuntime(runtime: try XCTUnwrap(runtimes.first)) + XCTAssertEqual(deletedIdentifiers.read { $0 }, [installedRuntime.uuid]) + } + + func test_DeleteRuntime_MatchesArchitecturesRegardlessOfOrder() async throws { + let runtime = try Self.downloadableRuntime( + identifier: "com.apple.dmg.iPhoneSimulatorSDK26_5", + build: "23F72", + version: "26.5", + architectures: [.arm64, .x86_64] + ) + let installedRuntime = CoreSimulatorImage( + uuid: "97772E90-7BD1-4882-9C51-782E62E0AF4F", + path: ["relative": "/Library/Developer/CoreSimulator/Images/iOS_26_5.dmg"], + runtimeInfo: CoreSimulatorRuntimeInfo( + build: "23F72", + supportedArchitectures: [.x86_64, .arm64] + ) + ) + let deletedIdentifiers = TestLockedBox<[String]>([]) + subject.downloadableRuntimes = [runtime] + subject.installedRuntimes = [installedRuntime] + subject.runtimeService = Self.runtimeService(deleteRuntimeOutput: { identifier in + deletedIdentifiers.withValue { $0.append(identifier) } + return ProcessOutput(status: 0, out: "", err: "") + }) + + let displayedRuntime = try XCTUnwrap(subject.installedPlatformRuntimes().first) + try await subject.deleteRuntime(runtime: displayedRuntime) + + XCTAssertEqual(deletedIdentifiers.read { $0 }, [installedRuntime.uuid]) + } + + func test_InstalledPlatformRuntimes_CollapsesArchitecturelessBuildToExactUUID() async throws { + let runtime = try Self.downloadableRuntime( + identifier: "com.apple.dmg.iPhoneSimulatorSDK26_5", + build: "23F72", + version: "26.5", + architectures: nil + ) + let x86Runtime = CoreSimulatorImage( + uuid: "97772E90-7BD1-4882-9C51-782E62E0AF4F", + path: ["relative": "/Library/Developer/CoreSimulator/Images/iOS_26_5_x86_64.dmg"], + runtimeInfo: CoreSimulatorRuntimeInfo( + build: "23F72", + supportedArchitectures: [.x86_64] + ) + ) + let armRuntime = CoreSimulatorImage( + uuid: "F42510E4-2C1B-411A-B7CE-E0CA68E5F1E5", + path: ["relative": "/Library/Developer/CoreSimulator/Images/iOS_26_5_arm64.dmg"], + runtimeInfo: CoreSimulatorRuntimeInfo( + build: "23F72", + supportedArchitectures: [.arm64] + ) + ) + let deletedIdentifiers = TestLockedBox<[String]>([]) + subject.downloadableRuntimes = [runtime] + subject.installedRuntimes = [x86Runtime, armRuntime] + subject.runtimeService = Self.runtimeService(deleteRuntimeOutput: { identifier in + deletedIdentifiers.withValue { $0.append(identifier) } + return ProcessOutput(status: 0, out: "", err: "") + }) + + let rows = subject.installedPlatformRuntimes() + + XCTAssertEqual(rows.count, 1) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row.id, x86Runtime.uuid) + try await subject.deleteRuntime(runtime: row) + XCTAssertEqual(deletedIdentifiers.read { $0 }, [x86Runtime.uuid]) + } + + func test_SetInstallationStep_UpdatesExactArchitectureVariant() { + let version = Version("27.0.0")! + let armXcode = Xcode( + version: version, + installState: .notInstalled, + selected: false, + icon: nil, + architectures: [.arm64] + ) + let universalXcode = Xcode( + version: version, + installState: .notInstalled, + selected: false, + icon: nil, + architectures: [.arm64, .x86_64] + ) + let universalDownload = AvailableXcode( + version: version, + url: URL(string: "https://example.com/Xcode-27-universal.xip")!, + filename: "Xcode-27-universal.xip", + releaseDate: nil, + architectures: [.arm64, .x86_64] + ) + subject.allXcodes = [armXcode, universalXcode] + + subject.setInstallationStep(of: universalDownload, to: .unarchiving, postNotification: false) + + XCTAssertEqual(subject.allXcodes[0].installState, .notInstalled) + XCTAssertEqual(subject.allXcodes[1].installState, .installing(.unarchiving)) + } + + func test_ConcurrentXcodeExtractionWorkspacesAreUniqueAndOwned() async throws { + let archive = URL(fileURLWithPath: "/tmp/xcodes/Xcode-27.xip") + let createdDirectories = TestLockedBox<[URL]>([]) + let linkedItems = TestLockedBox<[(URL, URL)]>([]) + Current.files.createDirectory = { url, _, _ in + createdDirectories.withValue { $0.append(url) } + } + Current.files.linkItem = { source, destination in + linkedItems.withValue { $0.append((source, destination)) } + } + + async let firstWorkspace = XcodeExtractionWorkspace.create(for: archive) + async let secondWorkspace = XcodeExtractionWorkspace.create(for: archive) + let (first, second) = try await (firstWorkspace, secondWorkspace) + + XCTAssertNotEqual(first.directoryURL, second.directoryURL) + XCTAssertEqual(first.directoryURL.deletingLastPathComponent(), archive.deletingLastPathComponent()) + XCTAssertEqual(second.directoryURL.deletingLastPathComponent(), archive.deletingLastPathComponent()) + XCTAssertTrue(first.stagedArchiveURL.path.hasPrefix(first.directoryURL.path + "/")) + XCTAssertTrue(second.stagedArchiveURL.path.hasPrefix(second.directoryURL.path + "/")) + XCTAssertEqual(createdDirectories.read { $0.count }, 2) + XCTAssertEqual(linkedItems.read { $0.map(\.0) }, [archive, archive]) + } + + func test_CancelledXcodeExtractionCleansOnlyOwnedWorkspace() async throws { + let archive = URL(fileURLWithPath: "/tmp/xcodes/Xcode-27.xip") + let workingDirectory = TestLockedBox(nil) + let continuation = TestLockedBox?>(nil) + let removedURLs = TestLockedBox<[URL]>([]) + Current.files.createDirectory = { _, _, _ in } + Current.files.linkItem = { _, _ in } + Current.files.fileExistsAtPath = { path in + guard let directory = workingDirectory.read({ $0 }) else { return false } + return path.hasPrefix(directory.path + "/") + } + Current.files.removeItem = { url in + removedURLs.withValue { $0.append(url) } + } + Current.files.quarantineAndRemoveOwnedDirectory = { _, _, directory, _, beforeQuarantine in + try beforeQuarantine() + removedURLs.withValue { $0.append(directory) } + } + Current.shell.unxip = { _, directory in + workingDirectory.withValue { $0 = directory } + return try await withCheckedThrowingContinuation { pending in + continuation.withValue { $0 = pending } + } + } + + let installation = Task { @MainActor in + try await subject.installArchivedXcodeAsync( + AvailableXcode( + version: Version("27.0.0")!, + url: URL(string: "https://example.com/Xcode-27.xip")!, + filename: "Xcode-27.xip", + releaseDate: nil + ), + at: archive + ) + } + for _ in 0..<100 where continuation.read({ $0 == nil }) { + await Task.yield() + } + let extractionDirectory = try XCTUnwrap(workingDirectory.read { $0 }) + + installation.cancel() + continuation.read { $0 }?.resume(throwing: CancellationError()) + do { + _ = try await installation.value + XCTFail("Expected cancellation") + } catch is CancellationError { + } + + XCTAssertFalse(removedURLs.read { $0 }.contains(archive)) + XCTAssertTrue(removedURLs.read { $0 }.allSatisfy { + $0 == extractionDirectory || $0.path.hasPrefix(extractionDirectory.path + "/") + }) + XCTAssertTrue(removedURLs.read { $0 }.contains(extractionDirectory)) + } + + func test_XcodeExtractionWorkspaceRefusesCleanupThroughReplacedParentSymlink() throws { + let previousFiles = Current.files + Current.files = Files() + defer { Current.files = previousFiles } + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let parent = root.appendingPathComponent("parent", isDirectory: true) + let movedParent = root.appendingPathComponent("moved-parent", isDirectory: true) + let outside = root.appendingPathComponent("outside", isDirectory: true) + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + try fileManager.createDirectory(at: outside, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let raceRan = TestLockedBox(false) + let sentinelURL = TestLockedBox(nil) + Current.files.beforeOwnedDirectoryQuarantine = { + let raceFileManager = FileManager.default + let workspaceURL = try XCTUnwrap( + raceFileManager.contentsOfDirectory( + at: parent, + includingPropertiesForKeys: nil + ).first { $0.lastPathComponent.hasPrefix(".xcodes-extract-") } + ) + let outsideWorkspace = outside.appendingPathComponent( + workspaceURL.lastPathComponent, + isDirectory: true + ) + try raceFileManager.createDirectory(at: outsideWorkspace, withIntermediateDirectories: false) + let sentinel = outsideWorkspace.appendingPathComponent("sentinel") + try Data("preserve".utf8).write(to: sentinel) + sentinelURL.withValue { $0 = sentinel } + + try raceFileManager.moveItem(at: parent, to: movedParent) + try raceFileManager.createSymbolicLink(at: parent, withDestinationURL: outside) + raceRan.withValue { $0 = true } + } + + let archive = parent.appendingPathComponent("Xcode-27.xip") + try Data("archive".utf8).write(to: archive) + let workspace = try XcodeExtractionWorkspace.create(for: archive) + + try workspace.remove() + + XCTAssertTrue(raceRan.read { $0 }) + XCTAssertTrue(fileManager.fileExists(atPath: try XCTUnwrap(sentinelURL.read { $0 }).path)) + XCTAssertFalse( + fileManager.fileExists( + atPath: movedParent.appendingPathComponent(workspace.directoryURL.lastPathComponent).path + ) + ) + } + + func test_XcodeExtractionWorkspaceRefusesCleanupAfterDirectoryReplacement() throws { + let previousFiles = Current.files + Current.files = Files() + defer { Current.files = previousFiles } + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let outside = root.appendingPathComponent("outside", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try fileManager.createDirectory(at: outside, withIntermediateDirectories: false) + defer { try? fileManager.removeItem(at: root) } + + let archive = root.appendingPathComponent("Xcode-27.xip") + try Data("archive".utf8).write(to: archive) + let displacedWorkspace = root.appendingPathComponent("displaced-workspace", isDirectory: true) + let sentinel = outside.appendingPathComponent("sentinel") + try Data("preserve".utf8).write(to: sentinel) + let raceRan = TestLockedBox(false) + Current.files.beforeOwnedDirectoryQuarantine = { + let raceFileManager = FileManager.default + let workspaceURL = try XCTUnwrap( + raceFileManager.contentsOfDirectory( + at: root, + includingPropertiesForKeys: nil + ).first { $0.lastPathComponent.hasPrefix(".xcodes-extract-") } + ) + try raceFileManager.moveItem(at: workspaceURL, to: displacedWorkspace) + try raceFileManager.createSymbolicLink(at: workspaceURL, withDestinationURL: outside) + raceRan.withValue { $0 = true } + } + let workspace = try XcodeExtractionWorkspace.create(for: archive) + + XCTAssertThrowsError(try workspace.remove()) + XCTAssertTrue(raceRan.read { $0 }) + XCTAssertTrue(fileManager.fileExists(atPath: sentinel.path)) + XCTAssertTrue(fileManager.fileExists(atPath: displacedWorkspace.path)) + } + + func test_XcodeExtractionWorkspaceRemovesUnchangedOwnedDirectory() throws { + let previousFiles = Current.files + Current.files = Files() + defer { Current.files = previousFiles } + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let archive = root.appendingPathComponent("Xcode-27.xip") + try Data("archive".utf8).write(to: archive) + let workspace = try XcodeExtractionWorkspace.create(for: archive) + let nestedDirectory = workspace.directoryURL + .appendingPathComponent("nested", isDirectory: true) + .appendingPathComponent("directory", isDirectory: true) + try fileManager.createDirectory(at: nestedDirectory, withIntermediateDirectories: true) + try Data("temporary".utf8).write( + to: nestedDirectory.appendingPathComponent("contents") + ) + + try workspace.remove() + + XCTAssertFalse(fileManager.fileExists(atPath: workspace.directoryURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: archive.path)) } func test_DownloadRuntimeViaXcodeBuild_ClearsRuntimeTaskWhenComplete() async throws { @@ -829,15 +1254,19 @@ class AppStateTests: XCTestCase { }) ) subject.installedRuntimes = [installedRuntime] + let installedPlatformRuntime = InstalledPlatformRuntime( + runtime: runtime, + installedRuntimeUUID: installedRuntime.uuid + ) - subject.confirmDeleteRuntime(runtime: runtime) + subject.confirmDeleteRuntime(runtime: installedPlatformRuntime) 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) + subject.confirmDeleteRuntime(runtime: installedPlatformRuntime) for _ in 0..<100 where continuations.read({ $0.count }) < 2 { await Task.yield() } @@ -856,8 +1285,15 @@ class AppStateTests: XCTestCase { func test_ConfirmDeleteRuntime_PresentsPlatformAlertOnError() async throws { let runtime = try Self.downloadableRuntime() + let installedPlatformRuntime = InstalledPlatformRuntime( + runtime: runtime, + installedRuntimeUUID: "missing-runtime-uuid" + ) + subject.runtimeService = Self.runtimeService(deleteRuntimeOutput: { _ in + throw XcodesKitError("No simulator found with \(runtime.identifier)") + }) - subject.confirmDeleteRuntime(runtime: runtime) + subject.confirmDeleteRuntime(runtime: installedPlatformRuntime) let task = try XCTUnwrap(subject.deleteRuntimeTask) await task.value @@ -879,8 +1315,12 @@ class AppStateTests: XCTestCase { ) subject = AppState(runtimeService: Self.runtimeService()) subject.installedRuntimes = [installedRuntime] + let installedPlatformRuntime = InstalledPlatformRuntime( + runtime: runtime, + installedRuntimeUUID: installedRuntime.uuid + ) - try await subject.deleteRuntime(runtime: runtime) + try await subject.deleteRuntime(runtime: installedPlatformRuntime) XCTAssertTrue(subject.installedRuntimes.isEmpty) } @@ -898,8 +1338,12 @@ class AppStateTests: XCTestCase { }) ) subject.installedRuntimes = [installedRuntime] + let installedPlatformRuntime = InstalledPlatformRuntime( + runtime: runtime, + installedRuntimeUUID: installedRuntime.uuid + ) - subject.confirmDeleteRuntime(runtime: runtime) + subject.confirmDeleteRuntime(runtime: installedPlatformRuntime) let task = try XCTUnwrap(subject.deleteRuntimeTask) await task.value @@ -1139,6 +1583,10 @@ class AppStateTests: XCTestCase { } private static func downloadableRuntime( + identifier: String = "com.apple.CoreSimulator.SimRuntime.iOS-16-0", + build: String = "20A360", + version: String = "16.0", + fileSize: Int64 = 42, architectures: [Architecture]? = nil ) throws -> DownloadableRuntime { let encodedArchitectures: String @@ -1151,17 +1599,17 @@ class AppStateTests: XCTestCase { { "category": "simulator", "simulatorVersion": { - "buildUpdate": "20A360", - "version": "16.0" + "buildUpdate": "\(build)", + "version": "\(version)" }, "source": "https://example.com/iOS_16_Runtime.dmg", "architectures": \(encodedArchitectures), "dictionaryVersion": 1, "contentType": "diskImage", "platform": "com.apple.platform.iphoneos", - "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-16-0", - "version": "16.0", - "fileSize": 42, + "identifier": "\(identifier)", + "version": "\(version)", + "fileSize": \(fileSize), "hostRequirements": null, "name": "iOS 16.0", "authentication": null @@ -1311,7 +1759,7 @@ class AppStateTests: XCTestCase { } func test_Install_NotEnoughFreeSpace() async throws { - Current.shell.unxip = { _ in + Current.shell.unxip = { _, _ in throw ProcessExecutionError( process: Process(), standardOutput: "xip: signing certificate was \"Development Update\" (validation not attempted)", diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index 299e4f01..b714a851 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -403,3 +403,103 @@ class AppStateUpdateTests: XCTestCase { XCTAssertEqual(Data(m2).base64EncodedString(), "R2rgqC9cMAtWiXUImOrvs4oF+ccibf8KaFsZQ22WokM=") } } + +final class XcodeListCategoryTests: XCTestCase { + func testReleasePlusNewBetasKeepsReleasesAndUnreleasedBetas() { + let xcodes = [ + makeXcode("17.0.0-Beta.1"), + makeXcode("16.0.0"), + makeXcode("16.0.0-Beta.3"), + makeXcode("15.4.0"), + ] + + let result = visibleXcodes(xcodes) + + XCTAssertEqual( + result.map(\.version), + [Version("17.0.0-Beta.1")!, Version("16.0.0")!, Version("15.4.0")!] + ) + } + + func testReleasePlusNewBetasUsesSelectedArchitectureForReleaseBaseline() { + let xcodes = [ + makeXcode("16.0.0", architectures: [.arm64]), + makeXcode("16.0.0-Beta.3", architectures: [.arm64, .x86_64]), + ] + + let result = visibleXcodes(xcodes, architecture: .universal) + + XCTAssertEqual(result.map(\.version), [Version("16.0.0-Beta.3")!]) + } + + func testReleasePlusNewBetasSuppressesBetaBeforeSearchFiltering() { + let xcodes = [ + makeXcode("16.0.0"), + makeXcode("16.0.0-Beta.3"), + ] + + let result = visibleXcodes(xcodes, searchText: "Beta") + + XCTAssertTrue(result.isEmpty) + } + + func testReleasePlusNewBetasSuppressesBetaBeforeInstalledFiltering() { + let xcodes = [ + makeXcode("16.0.0"), + makeXcode( + "16.0.0-Beta.3", + installState: .installed(Path("/Applications/Xcode-16.0-Beta.3.app")!) + ), + ] + + let result = visibleXcodes(xcodes, installedOnly: true) + + XCTAssertTrue(result.isEmpty) + } + + func testReleasePlusNewBetasKeepsStableRepresentativeForIdenticalBuild() { + let stableID = XcodeID(version: Version("16.0.0+16A100")!, architectures: [.arm64]) + let betaID = XcodeID(version: Version("16.0.0-RC+16A100")!, architectures: [.arm64]) + let identicalBuilds = [stableID, betaID] + let xcodes = [ + makeXcode("16.0.0+16A100", identicalBuilds: identicalBuilds), + makeXcode("16.0.0-RC+16A100", identicalBuilds: identicalBuilds), + ] + + let result = visibleXcodes(xcodes) + + XCTAssertEqual(result.map(\.version), [Version("16.0.0+16A100")!]) + } + + private func visibleXcodes( + _ xcodes: [Xcode], + architecture: XcodeListArchitecture = .appleSilicon, + searchText: String = "", + installedOnly: Bool = false + ) -> [Xcode] { + XcodeListCategory.releasePlusNewBetas.applying( + to: xcodes, + architectureFilters: architecture.architectureFilters, + allowedMajorVersions: Int.max, + searchText: searchText, + installedOnly: installedOnly, + item: \.listItem + ) + } + + private func makeXcode( + _ version: String, + identicalBuilds: [XcodeID] = [], + installState: XcodeInstallState = .notInstalled, + architectures: [Architecture] = [.arm64] + ) -> Xcode { + Xcode( + version: Version(version)!, + identicalBuilds: identicalBuilds, + installState: installState, + selected: false, + icon: nil, + architectures: architectures + ) + } +} diff --git a/XcodesTests/Environment+Mock.swift b/XcodesTests/Environment+Mock.swift index 6f351ce1..b097b0b8 100644 --- a/XcodesTests/Environment+Mock.swift +++ b/XcodesTests/Environment+Mock.swift @@ -26,7 +26,7 @@ extension Shell { static var mock: Shell { Shell( - unxip: { _ in Shell.processOutputMock }, + unxip: { _, _ in Shell.processOutputMock }, spctlAssess: { _ in Shell.processOutputMock }, codesignVerify: { _ in Shell.processOutputMock }, buildVersion: { Shell.processOutputMock }, @@ -44,6 +44,16 @@ extension Files { Files( fileExistsAtPath: { _ in return true }, moveItem: { _, _ in return }, + linkItem: { _, _ in return }, + canonicalURL: { $0.standardizedFileURL }, + fileSystemIdentity: { url in + FileSystemIdentity( + deviceID: 0, + inode: UInt64(bitPattern: Int64(url.path.hashValue)), + isDirectory: true, + isSymbolicLink: false + ) + }, contentsAtPath: { path in if path.contains("Info.plist") { let url = Bundle.xcodesTests.url(forResource: "Stub-0.0.0.Info", withExtension: "plist")! diff --git a/XcodesTests/PinCodeInputTests.swift b/XcodesTests/PinCodeInputTests.swift new file mode 100644 index 00000000..7acbd0ff --- /dev/null +++ b/XcodesTests/PinCodeInputTests.swift @@ -0,0 +1,113 @@ +import AppKit +import Testing + +@testable import Xcodes + +struct PinCodeInputTests { + @Test( + "Normalises verification-code input", + arguments: [ + (input: "1", previous: "", expected: "1"), + (input: "12", previous: "1", expected: "12"), + (input: "123 456", previous: "", expected: "123456"), + (input: "12987654", previous: "12", expected: "987654"), + (input: "12345", previous: "123456", expected: "12345"), + (input: "1234567", previous: "", expected: "123456"), + (input: "12-AB 34", previous: "", expected: "12AB34"), + ] + ) + func normalisesInput(_ testCase: (input: String, previous: String, expected: String)) { + #expect( + PinCodeInput.normalise( + testCase.input, + replacing: testCase.previous, + numberOfDigits: 6 + ) == testCase.expected + ) + } + + @Test("Reports completion only at required length") + func reportsCompletion() { + #expect(PinCodeInput.isComplete("12345", numberOfDigits: 6) == false) + #expect(PinCodeInput.isComplete("123456", numberOfDigits: 6)) + } + + @Test("Publishes edits and completion from the input") + @MainActor + func publishesInput() throws { + let view = PinCodeTextView( + numberOfDigits: 6, + itemSpacing: 10, + accessibilityLabel: "Verification code" + ) + let inputField = try #require(textFields(in: view).first { $0.isEditable }) + var changes: [String] = [] + var completions: [String] = [] + view.codeDidChange = { changes.append($0) } + view.codeDidComplete = { completions.append($0) } + + inputField.stringValue = "12" + view.controlTextDidChange(textDidChangeNotification(for: inputField)) + inputField.stringValue = "12987654" + view.controlTextDidChange(textDidChangeNotification(for: inputField)) + inputField.stringValue = "98765" + view.controlTextDidChange(textDidChangeNotification(for: inputField)) + + #expect(changes == ["12", "987654", "98765"]) + #expect(completions == ["987654"]) + #expect(view.currentCode == "98765") + } + + @Test("Configures one accessible autofill input") + @MainActor + func configuresAccessibleInput() throws { + let view = PinCodeTextView( + numberOfDigits: 6, + itemSpacing: 10, + accessibilityLabel: "Verification code" + ) + let textFields = textFields(in: view) + let inputField = try #require(textFields.first { $0.isEditable }) + + #expect(textFields.filter(\.isEditable).count == 1) + #expect(textFields.filter { !$0.isEditable }.count == 6) + #expect(inputField.contentType == .oneTimeCode) + #expect(inputField.accessibilityLabel() == "Verification code") + #expect(textFields.filter { !$0.isEditable }.allSatisfy { !$0.isAccessibilityElement() }) + } + + @Test("Forwards focus to the single input") + @MainActor + func forwardsFocus() throws { + let view = PinCodeTextView( + numberOfDigits: 6, + itemSpacing: 10, + accessibilityLabel: "Verification code" + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 100), + styleMask: [], + backing: .buffered, + defer: false + ) + window.contentView = view + let inputField = try #require(textFields(in: view).first { $0.isEditable }) + + #expect(view.becomeFirstResponder()) + let responder = window.firstResponder + let fieldEditor = responder as? NSTextView + #expect(responder === inputField || fieldEditor?.delegate === inputField) + } + + @MainActor + private func textFields(in view: NSView) -> [NSTextField] { + view.subviews.flatMap { subview in + let field = (subview as? NSTextField).map { [$0] } ?? [] + return field + textFields(in: subview) + } + } + + private func textDidChangeNotification(for textField: NSTextField) -> Notification { + Notification(name: NSControl.textDidChangeNotification, object: textField) + } +}