diff --git a/HelperXPCShared/FileOperations.swift b/HelperXPCShared/FileOperations.swift new file mode 100644 index 00000000..e0937581 --- /dev/null +++ b/HelperXPCShared/FileOperations.swift @@ -0,0 +1,61 @@ +import Foundation +import os.log + +enum FileOperations { + private static let subsystem = Bundle.main.bundleIdentifier! + static let fileOperations = Logger(subsystem: subsystem, category: "fileOperations") + + static func moveApp(at source: String, to destination: String, completion: @escaping ((any Error)?) -> Void) { + do { + guard URL(fileURLWithPath: source).hasDirectoryPath else { throw XPCDelegateError(.invalidSourcePath)} + + guard URL(fileURLWithPath: destination).deletingLastPathComponent().hasDirectoryPath else { throw + XPCDelegateError(.invalidDestinationPath)} + + try FileManager.default.moveItem(at: URL(fileURLWithPath: source), to: URL(fileURLWithPath: destination)) + completion(nil) + } catch { + completion(error) + } + } + + // does an Xcode.app file exist? + static func createSymbolicLink(source: String, destination: String, completion: @escaping ((any Error)?) -> Void) { + do { + if FileManager.default.fileExists(atPath: destination) { + let attributes: [FileAttributeKey : Any]? = try? FileManager.default.attributesOfItem(atPath: destination) + + if attributes?[.type] as? FileAttributeType == FileAttributeType.typeSymbolicLink { + try FileManager.default.removeItem(atPath: destination) + Self.fileOperations.info("Successfully deleted old symlink") + } else { + throw XPCDelegateError(.destinationIsNotASymbolicLink) + } + } + + try FileManager.default.createSymbolicLink(atPath: destination, withDestinationPath: source) + Self.fileOperations.info("Successfully created symbolic link with \(destination)") + completion(nil) + } catch { + completion(error) + } + } + + static func rename(source: String, destination: String, completion: @escaping ((any Error)?) -> Void) { + do { + try FileManager.default.moveItem(at: URL(fileURLWithPath: source), to: URL(fileURLWithPath: destination)) + completion(nil) + } catch { + completion(error) + } + } + + static func remove(path: String, completion: @escaping ((any Error)?) -> Void) { + do { + try FileManager.default.removeItem(atPath: path) + completion(nil) + } catch { + completion(error) + } + } +} diff --git a/HelperXPCShared/HelperXPCShared.swift b/HelperXPCShared/HelperXPCShared.swift index d72d7be9..b5a6613e 100644 --- a/HelperXPCShared/HelperXPCShared.swift +++ b/HelperXPCShared/HelperXPCShared.swift @@ -12,4 +12,54 @@ protocol HelperXPCProtocol: Sendable { func addStaffToDevelopersGroup(completion: @escaping (Error?) -> Void) func acceptXcodeLicense(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) func runFirstLaunch(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) + func moveApp(at source: String, to destination: String, completion: @escaping (Error?) -> Void) + func createSymbolicLink(source: String, destination: String, completion: @escaping (Error?) -> Void) + func rename(source: String, destination: String, completion: @escaping (Error?) -> Void) + func remove(path: String, completion: @escaping (Error?) -> Void) +} + +struct XPCDelegateError: CustomNSError { + enum Code: Int { + case invalidXcodePath + case invalidSourcePath + case invalidDestinationPath + case destinationIsNotASymbolicLink + } + + let code: Code + + init(_ code: Code) { + self.code = code + } + + // MARK: - CustomNSError + + static var errorDomain: String { "XPCDelegateError" } + + var errorCode: Int { code.rawValue } + + var errorUserInfo: [String : Any] { + switch code { + case .invalidXcodePath: + return [ + NSLocalizedDescriptionKey: "Invalid Xcode path.", + NSLocalizedFailureReasonErrorKey: "Xcode path must be absolute." + ] + case .invalidSourcePath: + return [ + NSLocalizedDescriptionKey: "Invalid source path.", + NSLocalizedFailureReasonErrorKey: "Source path must be absolute and must be a directory." + ] + case .invalidDestinationPath: + return [ + NSLocalizedDescriptionKey: "Invalid destination path.", + NSLocalizedFailureReasonErrorKey: "Destination path must be absolute and must be a directory." + ] + case .destinationIsNotASymbolicLink: + return [ + NSLocalizedDescriptionKey: "Invalid destination path.", + NSLocalizedFailureReasonErrorKey: "Destination path must be a symbolic link." + ] + } + } } diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 6fdb3076..f0ba8c54 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 1596C2913043765600178C86 /* FileOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1596C2903043765600178C86 /* FileOperations.swift */; }; + 1596C2923043765600178C86 /* FileOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1596C2903043765600178C86 /* FileOperations.swift */; }; 15F5B8902CCF09B900705E2F /* CryptoKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15F5B88F2CCF09B900705E2F /* CryptoKit.framework */; }; 3328073F2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */; }; 332807412CA5EA820036F691 /* SignInSecurityKeyTouchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */; }; @@ -184,6 +186,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1596C2903043765600178C86 /* FileOperations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileOperations.swift; sourceTree = ""; }; 15F5B88F2CCF09B900705E2F /* CryptoKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoKit.framework; path = System/Library/Frameworks/CryptoKit.framework; sourceTree = SDKROOT; }; 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyPinView.swift; sourceTree = ""; }; 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyTouchView.swift; sourceTree = ""; }; @@ -430,6 +433,7 @@ isa = PBXGroup; children = ( CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */, + 1596C2903043765600178C86 /* FileOperations.swift */, ); path = HelperXPCShared; sourceTree = ""; @@ -860,6 +864,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1596C2913043765600178C86 /* FileOperations.swift in Sources */, CA9FF8D025959A9700E47BAF /* HelperXPCShared.swift in Sources */, CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */, CA9FF8DB25959B4000E47BAF /* XPCDelegate.swift in Sources */, @@ -908,6 +913,7 @@ 332807412CA5EA820036F691 /* SignInSecurityKeyTouchView.swift in Sources */, CA61A6E0259835580008926E /* Xcode.swift in Sources */, CAE4247F259A666100B8B246 /* MainWindow.swift in Sources */, + 1596C2923043765600178C86 /* FileOperations.swift in Sources */, CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */, B0403CF02AD92D7B00137C09 /* ReleaseNotesView.swift in Sources */, CAFE4AB425B7D3AF0064FE51 /* AdvancedPreferencePane.swift in Sources */, @@ -1514,10 +1520,10 @@ }; E899297E2FFDFA9A0019DB31 /* XCRemoteSwiftPackageReference "XcodesKit" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/XcodesOrg/XcodesKit"; + repositoryURL = "https://github.com/abiligiri/XcodesKit"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 1.0.4; + branch = "async-move-item-for-helper"; + kind = branch; }; }; E89CBD3B2D5FC0B10037ED95 /* XCRemoteSwiftPackageReference "XcodesLoginKit" */ = { diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fa02b2c6..ee91e293 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "repositoryURL": "https://github.com/mxcl/LegibleError", "state": { "branch": null, - "revision": "909e9bab3ded97350b28a5ab41dd745dd8aa9710", - "version": "1.0.4" + "revision": "bc596702d7ff618c3f90ba480eeb48b3e83a2fbe", + "version": "1.0.6" } }, { @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/kinoroy/LibFido2Swift", "state": { "branch": null, - "revision": "b87a93300c5b35307c9f26ae490963196bd927f1", - "version": "0.1.5" + "revision": "ac8596a852e2b008c5902a521cf4d9c7f0f05fba", + "version": "0.1.6" } }, { @@ -60,17 +60,17 @@ "repositoryURL": "https://github.com/mxcl/Path.swift", "state": { "branch": null, - "revision": "8e355c28e9393c42e58b18c54cace2c42c98a616", - "version": "1.4.1" + "revision": "74ec90bbe50a3376e399286fed48b60db9b91bb1", + "version": "1.6.0" } }, { "package": "Sparkle", - "repositoryURL": "https://github.com/sparkle-project/Sparkle/", + "repositoryURL": "https://github.com/sparkle-project/Sparkle", "state": { "branch": null, - "revision": "0ef1ee0220239b3776f433314515fd849025673f", - "version": "2.6.4" + "revision": "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", + "version": "2.9.6" } }, { @@ -78,8 +78,8 @@ "repositoryURL": "https://github.com/apple/swift-collections.git", "state": { "branch": null, - "revision": "a902f1823a7ff3c9ab2fba0f992396b948eda307", - "version": "1.0.5" + "revision": "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version": "1.6.0" } }, { @@ -120,11 +120,11 @@ }, { "package": "XcodesKit", - "repositoryURL": "https://github.com/XcodesOrg/XcodesKit", + "repositoryURL": "https://github.com/abiligiri/XcodesKit", "state": { - "branch": null, - "revision": "a9e5d7d701f20f1385071851319cdaecccc9f1e8", - "version": "1.0.4" + "branch": "async-move-item-for-helper", + "revision": "fbee0cd75d4ad6da77b2b2c6cf1a752deb80ff56", + "version": null } }, { diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index c0b9e59a..1b25950d 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -250,7 +250,14 @@ extension AppState { XcodeUnarchiveService( unarchive: { _ = try await self.unxipOrUnxipExperimentAsync($0) }, fileExists: { path in Current.files.fileExists(atPath: path) }, - moveItem: { source, destination in try Current.files.moveItem(at: source, to: destination) }, + moveItem: { source, destination in + if Current.helper.usePrivilegedHelperForFileOperations { + try await self.installHelperIfNecessaryAsync() + try await Current.helper.moveAppAsync(source.path, destination.path) + } else { + try Current.files.moveItem(at: source, to: destination) + } + }, removeItem: { url in try Current.files.removeItem(at: url) } ) } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6897bb20..6b028f1a 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -29,6 +29,7 @@ enum PreferenceKey: String { case enableGroupedXcodeList case expandedMajorXcodeVersions case expandedMinorXcodeVersions + case usePrivilegeHelperForFileOperations func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) } } @@ -136,6 +137,12 @@ class AppState: ObservableObject { var onSelectActionTypeDisabled: Bool { PreferenceKey.onSelectActionType.isManaged() } + @Published var usePrivilegedHelperForFileOperations = false { + didSet { + Current.defaults.set(usePrivilegedHelperForFileOperations, forKey: PreferenceKey.usePrivilegeHelperForFileOperations.rawValue) + } + } + @Published var showOpenInRosettaOption = false { didSet { Current.defaults.set(showOpenInRosettaOption, forKey: "showOpenInRosettaOption") @@ -249,6 +256,7 @@ class AppState: ObservableObject { showOpenInRosettaOption = Current.defaults.bool(forKey: "showOpenInRosettaOption") ?? false terminateAfterLastWindowClosed = Current.defaults.bool(forKey: "terminateAfterLastWindowClosed") ?? false enableGroupedXcodeList = Current.defaults.get(forKey: PreferenceKey.enableGroupedXcodeList.rawValue) as? Bool ?? true + usePrivilegedHelperForFileOperations = Current.defaults.bool(forKey: PreferenceKey.usePrivilegeHelperForFileOperations.rawValue) ?? false } // MARK: Timer @@ -670,6 +678,10 @@ class AppState: ObservableObject { func uninstall(xcode: Xcode) { guard let installedXcodePath = xcode.installedPath else { return } + if let index = allXcodes.firstIndex(where: { $0.id == xcode.id }) { + allXcodes[index].installState = .uninstalling(installedXcodePath) + } + uninstallTask?.cancel() let taskID = UUID() uninstallTaskID = taskID @@ -688,6 +700,9 @@ class AppState: ObservableObject { await updateInstalledXcodesAsync() } catch is CancellationError { } catch { + if let index = allXcodes.firstIndex(where: { $0.id == xcode.id }) { + allXcodes[index].installState = .installed(installedXcodePath) + } self.error = error self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription) } @@ -727,14 +742,9 @@ class AppState: ObservableObject { } guard - var installedXcodePath = xcode.installedPath + let installedXcodePath = xcode.installedPath else { return } - if onSelectActionType == .rename { - guard let newDestinationXcodePath = renameToXcode(xcode: xcode) else { return } - installedXcodePath = newDestinationXcodePath - } - selectTask?.cancel() let taskID = UUID() selectTaskID = taskID @@ -746,13 +756,20 @@ class AppState: ObservableObject { } } do { + var installedXcodePath = installedXcodePath try await installHelperIfNecessaryAsync() try Task.checkCancellation() + + if onSelectActionType == .rename { + guard let newDestinationXcodePath = await renameToXcode(xcode: xcode) else { return } + installedXcodePath = newDestinationXcodePath + } + try await Current.helper.switchXcodePathAsync(installedXcodePath.string) try Task.checkCancellation() await updateSelectedXcodePathAsync() if createSymLinkOnSelect && onSelectActionType != .rename { - createSymbolicLink(to: installedXcodePath) + await createSymbolicLink(to: installedXcodePath) } } catch is CancellationError { } catch { @@ -794,25 +811,39 @@ class AppState: ObservableObject { func createSymbolicLink(xcode: Xcode, isBeta: Bool = false) { guard let installedXcodePath = xcode.installedPath else { return } - createSymbolicLink(to: installedXcodePath, isBeta: isBeta) + Task { @MainActor in + await createSymbolicLink(to: installedXcodePath, isBeta: isBeta) + } } - func createSymbolicLink(to installedXcodePath: Path, isBeta: Bool = false) { + func createSymbolicLink(to installedXcodePath: Path, isBeta: Bool = false) async { let destinationPath = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app" do { - let service = XcodeSelectionFilesystemService( - installedXcode: { Current.files.installedXcode(destination: $0) } - ) - let result = try service.createSymbolicLink( - to: installedXcodePath, - in: Path.installDirectory, - isBeta: isBeta - ) - if result.replacedExistingSymlink { - Logger.appState.info("Successfully deleted old symlink") + if Current.helper.usePrivilegedHelperForFileOperations { + if Current.files.fileExists(atPath: destinationPath.string) { + let attributes = try FileManager.default.attributesOfItem(atPath: destinationPath.string) + guard attributes[.type] as? FileAttributeType == .typeSymbolicLink else { + throw XcodeSelectionFilesystemError.destinationExistsAndIsNotSymlink(destinationPath) + } + } + // The helper's createSymbolicLink deletes an existing symlink at the destination before creating the new one. + try await Current.helper.createSymbolicLinkAsync(installedXcodePath.string, destinationPath.string) + Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app") + } else { + let service = XcodeSelectionFilesystemService( + installedXcode: { Current.files.installedXcode(destination: $0) } + ) + let result = try service.createSymbolicLink( + to: installedXcodePath, + in: Path.installDirectory, + isBeta: isBeta + ) + if result.replacedExistingSymlink { + Logger.appState.info("Successfully deleted old symlink") + } + Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app") } - Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app") } catch { Logger.appState.error("Unable to create symbolic Link") self.error = error @@ -823,19 +854,31 @@ class AppState: ObservableObject { } } - func renameToXcode(xcode: Xcode) -> Path? { + func renameToXcode(xcode: Xcode) async -> Path? { guard let installedXcodePath = xcode.installedPath else { return nil } do { - let service = XcodeSelectionFilesystemService( - installedXcode: { Current.files.installedXcode(destination: $0) } - ) - let renamedPath = try service.renameForSelection( - installedXcodePath: installedXcodePath, - in: Path.installDirectory - ) - Logger.appState.debug("Renamed selected Xcode to Xcode.app") - return renamedPath + if Current.helper.usePrivilegedHelperForFileOperations { + let destinationPath = Path.installDirectory/"Xcode.app" + if Current.files.fileExists(atPath: destinationPath.string), + let originalXcode = Current.files.installedXcode(destination: destinationPath) { + let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app" + try await Current.helper.renameAsync(destinationPath.string, "\(Path.installDirectory)/\(newName)") + } + try await Current.helper.renameAsync(installedXcodePath.string, destinationPath.string) + Logger.appState.debug("Renamed selected Xcode to Xcode.app") + return destinationPath + } else { + let service = XcodeSelectionFilesystemService( + installedXcode: { Current.files.installedXcode(destination: $0) } + ) + let renamedPath = try service.renameForSelection( + installedXcodePath: installedXcodePath, + in: Path.installDirectory + ) + Logger.appState.debug("Renamed selected Xcode to Xcode.app") + return renamedPath + } } catch { Logger.appState.error("Unable to create rename Xcode.app back to original") self.error = error @@ -889,10 +932,16 @@ class AppState: ObservableObject { ) else { throw FileError.fileNotFound(path.string) } - _ = try XcodeUninstallService( - removeItem: { url in try Current.files.removeItem(at: url) }, - trashItem: { url in try Current.files.trashItem(at: url) } - ).uninstall(xcode, emptyTrash: false) + + if Current.helper.usePrivilegedHelperForFileOperations { + try await installHelperIfNecessaryAsync() + try await Current.helper.removeAsync(xcode.path.string) + } else { + _ = try XcodeUninstallService( + removeItem: { url in try Current.files.removeItem(at: url) }, + trashItem: { url in try Current.files.trashItem(at: url) } + ).uninstall(xcode, emptyTrash: false) + } } private func waitForAuthenticationTerminalState() async throws { diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 47fb9420..650b9123 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -285,4 +285,11 @@ public struct Helper: Sendable { var addStaffToDevelopersGroupAsync: @Sendable () async throws -> Void = { try await helperClient.addStaffToDevelopersGroupAsync() } var acceptXcodeLicenseAsync: @Sendable (_ absoluteXcodePath: String) async throws -> Void = { try await helperClient.acceptXcodeLicenseAsync(absoluteXcodePath: $0) } var runFirstLaunchAsync: @Sendable (_ absoluteXcodePath: String) async throws -> Void = { try await helperClient.runFirstLaunchAsync(absoluteXcodePath: $0) } + var moveAppAsync: @Sendable (_ source: String, _ destination: String) async throws -> Void = { try await helperClient.moveAppAsync(at: $0, to: $1) } + var createSymbolicLinkAsync: @Sendable (_ source: String, _ destination: String) async throws -> Void = { try await helperClient.createSymbolicLinkAsync(source: $0, destination: $1) } + var renameAsync: @Sendable (_ source: String, _ destination: String) async throws -> Void = { try await helperClient.renameAsync(source: $0, destination: $1) } + var removeAsync: @Sendable (_ path: String) async throws -> Void = { try await helperClient.removeAsync(path: $0) } + var usePrivilegedHelperForFileOperations: Bool { + Current.defaults.bool(forKey: PreferenceKey.usePrivilegeHelperForFileOperations.rawValue) ?? false + } } diff --git a/Xcodes/Backend/HelperClient.swift b/Xcodes/Backend/HelperClient.swift index f7f8df2e..1fb73d8b 100644 --- a/Xcodes/Backend/HelperClient.swift +++ b/Xcodes/Backend/HelperClient.swift @@ -119,6 +119,86 @@ final class HelperClient { Logger.helperClient.info("\(#function): finished") } + func moveAppAsync(at source: String, to destination: String) async throws { + Logger.helperClient.info(#function) + + guard Current.helper.usePrivilegedHelperForFileOperations else { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + FileOperations.moveApp(at: source, to: destination) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + return + } + + try await performVoidHelperRequest { helper, finish in + helper.moveApp(at: source, to: destination) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + + func createSymbolicLinkAsync(source: String, destination: String) async throws { + Logger.helperClient.info(#function) + + guard Current.helper.usePrivilegedHelperForFileOperations else { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + FileOperations.createSymbolicLink(source: source, destination: destination) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + return + } + + try await performVoidHelperRequest { helper, finish in + helper.createSymbolicLink(source: source, destination: destination) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + + func renameAsync(source: String, destination: String) async throws { + Logger.helperClient.info(#function) + + guard Current.helper.usePrivilegedHelperForFileOperations else { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + FileOperations.rename(source: source, destination: destination) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + return + } + + try await performVoidHelperRequest { helper, finish in + helper.rename(source: source, destination: destination) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + + func removeAsync(path: String) async throws { + Logger.helperClient.info(#function) + + guard Current.helper.usePrivilegedHelperForFileOperations else { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + FileOperations.remove(path: path) { error in + if let error { continuation.resume(throwing: error) } else { continuation.resume() } + } + } + return + } + + try await performVoidHelperRequest { helper, finish in + helper.remove(path: path) { possibleError in + finish(possibleError.map(Result.failure) ?? .success(())) + } + } + Logger.helperClient.info("\(#function): finished") + } + private func performVoidHelperRequest(_ operation: @escaping @Sendable (HelperXPCProtocol, @escaping @Sendable (Result) -> Void) -> Void) async throws { try await performHelperRequest(operation) } diff --git a/Xcodes/Frontend/InfoPane/InfoPaneControls.swift b/Xcodes/Frontend/InfoPane/InfoPaneControls.swift index de1356ed..75275b60 100644 --- a/Xcodes/Frontend/InfoPane/InfoPaneControls.swift +++ b/Xcodes/Frontend/InfoPane/InfoPaneControls.swift @@ -30,6 +30,15 @@ struct InfoPaneControls: View { } case .installed(_): InstalledStateButtons(xcode: xcode) + case .uninstalling: + HStack { + Spacer() + ProgressView() + .scaleEffect(0.5) + Text("Uninstalling") + .font(.caption) + .foregroundColor(.secondary) + } } } } diff --git a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift index c938bb3e..201b1b93 100644 --- a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift @@ -149,7 +149,10 @@ struct AdvancedPreferencePane: View { .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - + + Toggle("UsePrivilegedHelperForFileOperations", isOn: $appState.usePrivilegedHelperForFileOperations) + .disabled(PreferenceKey.usePrivilegeHelperForFileOperations.isManaged()) + Spacer() } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index ce75938e..12cfdb05 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -285,7 +285,7 @@ private struct XcodeVersionGroupRow: View { Image(systemName: "checkmark.circle.fill") .foregroundColor(.yellow) .help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget)) - case .installing, .none: + case .installing, .uninstalling, .none: EmptyView() } } else if selectedVersion?.selected == true { @@ -316,7 +316,7 @@ private struct XcodeVersionGroupRow: View { } else { return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") } - case .installing, .none: + case .installing, .uninstalling, .none: return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.") } } @@ -344,7 +344,7 @@ private struct XcodeVersionGroupRow: View { .textCase(.uppercase) .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) .help("InstallDescription") - case .installing: + case .installing, .uninstalling: EmptyView() } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 166b9d09..52ed67b3 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -63,6 +63,8 @@ struct XcodeListViewRow: View { InstallButton(xcode: xcode) case .installing: CancelInstallButton(xcode: xcode) + case .uninstalling: + EmptyView() case let .installed(path): SelectButton(xcode: xcode) OpenButton(xcode: xcode) @@ -119,7 +121,7 @@ struct XcodeListViewRow: View { Image(systemName: "checkmark.circle.fill") .foregroundColor(.yellow) .help(staleSelectedHelpText) - case .installing: + case .installing, .uninstalling: EmptyView() } } else if xcode.selected { @@ -170,6 +172,14 @@ struct XcodeListViewRow: View { highlighted: selected, cancel: { appState.presentedAlert = .cancelInstall(xcode: xcode) } ) + case .uninstalling: + HStack(spacing: 4) { + ProgressView() + .scaleEffect(0.5) + Text("Uninstalling") + .font(.caption) + .foregroundColor(.secondary) + } } } @@ -182,7 +192,7 @@ struct XcodeListViewRow: View { return Text(verbatim: "\(selectedVersion) selected, \(latestVersion) available. Click to select \(latestVersion).") case .notInstalled: return Text(verbatim: "\(selectedVersion) selected, \(latestVersion) available. Install \(latestVersion) to select it.") - case .installing, .none: + case .installing, .uninstalling, .none: return Text("ActiveVersionDescription") } } diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 0716fee2..cad3415a 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -24937,6 +24937,124 @@ } } }, + "Uninstalling" : { + "localizations" : { + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desinstal·lant" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deinstallieren" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Απεγκατάσταση" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uninstalling" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desinstalando" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poistetaan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désinstallation en cours" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनइंस्टॉल हो रहा है" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disinstallazione in corso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アンインストール中" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제거 중" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bezig met verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odinstalowywanie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desinstalando" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаление" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaldırılıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Видалення" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在卸载" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在解除安裝" + } + } + } + }, "Universal" : { "extractionState" : "stale", "localizations" : { @@ -25449,6 +25567,124 @@ } } }, + "UsePrivilegedHelperForFileOperations" : { + "localizations" : { + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Realitza operacions de fitxers mitjançant l'ajudant privilegiat" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dateivorgänge über den privilegierten Helfer ausführen" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Εκτέλεση λειτουργιών αρχείων μέσω του προνομιούχου βοηθού" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perform file operations using Privileged Helper" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Realizar operaciones de archivos mediante el ayudante con privilegios" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suorita tiedostotoiminnot etuoikeutetun apuohjelman kautta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effectuer les opérations sur les fichiers via l'assistant privilégié" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "विशेषाधिकार प्राप्त हेल्पर का उपयोग करके फ़ाइल संचालन करें" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esegui operazioni sui file tramite l'helper privilegiato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "特権ヘルパーを使用してファイル操作を実行" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "권한이 부여된 헬퍼를 사용하여 파일 작업 수행" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bestandsbewerkingen uitvoeren via de geprivilegieerde helper" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wykonuj operacje na plikach za pomocą uprzywilejowanego pomocnika" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Executar operações de arquivo usando o Auxiliar Privilegiado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выполнять файловые операции с помощью привилегированного помощника" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosya işlemlerini Yetkili Yardımcı ile gerçekleştir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виконувати файлові операції за допомогою привілейованого помічника" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用特权助手执行文件操作" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用特權輔助程式執行檔案操作" + } + } + } + }, "UseUnxipExperiment" : { "localizations" : { "ar" : { diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index e31ee200..293617b1 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -114,7 +114,7 @@ class AppStateTests: XCTestCase { XCTAssertNil(subject.presentedAlert) } - func test_CreateSymbolicLink_UsesProvidedInstalledPath() throws { + func test_CreateSymbolicLink_UsesProvidedInstalledPath() async throws { let installDirectory = try XCTUnwrap(Path( NSTemporaryDirectory() .appending("XcodesAppStateTests-") @@ -129,7 +129,7 @@ class AppStateTests: XCTestCase { key == "installPath" ? installDirectory.string : nil } - subject.createSymbolicLink(to: installedXcodePath) + await subject.createSymbolicLink(to: installedXcodePath) let destination = try FileManager.default.destinationOfSymbolicLink(atPath: symlinkPath.string) XCTAssertEqual(destination, installedXcodePath.string) diff --git a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift index eb9fe069..2e5a4213 100644 --- a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift +++ b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift @@ -51,6 +51,26 @@ final class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { func runFirstLaunch(absoluteXcodePath: String, completion: @escaping (Error?) -> Void) { run(url: URL(fileURLWithPath: absoluteXcodePath + "/Contents/Developer/usr/bin/xcodebuild"), arguments: ["-runFirstLaunch"], completion: completion) } + + func moveApp(at source: String, to destination: String, completion: @escaping (Error?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.moveApp(at: source, to: destination, completion: completion) + } + + func createSymbolicLink(source: String, destination: String, completion: @escaping (Error?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.createSymbolicLink(source: source, destination: destination, completion: completion) + } + + func rename(source: String, destination: String, completion: @escaping (Error?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.rename(source: source, destination: destination, completion: completion) + } + + func remove(path: String, completion: @escaping (Error?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.remove(path: path, completion: completion) + } } // MARK: - Run @@ -69,34 +89,3 @@ private func run(url: URL, arguments: [String], completion: @escaping (Error?) - completion(error) } } - - -// MARK: - Errors - -struct XPCDelegateError: CustomNSError { - enum Code: Int { - case invalidXcodePath - } - - let code: Code - - init(_ code: Code) { - self.code = code - } - - // MARK: - CustomNSError - - static var errorDomain: String { "XPCDelegateError" } - - var errorCode: Int { code.rawValue } - - var errorUserInfo: [String : Any] { - switch code { - case .invalidXcodePath: - return [ - NSLocalizedDescriptionKey: "Invalid Xcode path.", - NSLocalizedFailureReasonErrorKey: "Xcode path must be absolute." - ] - } - } -}