From b77830cff09de409f854d81bb4b52a8ece1d1b9c Mon Sep 17 00:00:00 2001 From: Anand Biligiri Date: Wed, 20 Nov 2024 22:44:22 -0800 Subject: [PATCH] Use Privileged Helper for file operations Enable Xcodes.app to be used as a Standard (ie non Admin) user in managed environments. Install, rename/symbolic link to Xcode.app during activate/switch, uninstall are performed using the helper. An options in the Advanced Preferences pane added to enable/disable this feature. --- App.xcconfig | 2 + Helper.xcconfig | 2 + HelperXPCShared/FileOperations.swift | 61 ++++++ HelperXPCShared/HelperXPCShared.swift | 50 +++++ Tests.xcconfig | 2 + Xcodes.xcodeproj/project.pbxproj | 36 ++-- Xcodes/Backend/AppState+Install.swift | 15 +- Xcodes/Backend/AppState.swift | 187 +++++++++++------- Xcodes/Backend/Environment.swift | 5 + Xcodes/Backend/HelperClient.swift | 159 ++++++++++++++- .../Preferences/AdvancedPreferencePane.swift | 3 + Xcodes/Resources/Localizable.xcstrings | 20 ++ XcodesTests/AppStateTests.swift | 18 ++ .../XPCDelegate.swift | 52 ++--- 14 files changed, 485 insertions(+), 127 deletions(-) create mode 100644 App.xcconfig create mode 100644 Helper.xcconfig create mode 100644 HelperXPCShared/FileOperations.swift create mode 100644 Tests.xcconfig diff --git a/App.xcconfig b/App.xcconfig new file mode 100644 index 00000000..b633344a --- /dev/null +++ b/App.xcconfig @@ -0,0 +1,2 @@ +DEVELOPMENT_TEAM=ZU6GR6B2FY +CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT=ZU6GR6B2FY diff --git a/Helper.xcconfig b/Helper.xcconfig new file mode 100644 index 00000000..b633344a --- /dev/null +++ b/Helper.xcconfig @@ -0,0 +1,2 @@ +DEVELOPMENT_TEAM=ZU6GR6B2FY +CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT=ZU6GR6B2FY diff --git a/HelperXPCShared/FileOperations.swift b/HelperXPCShared/FileOperations.swift new file mode 100644 index 00000000..1dc8e2be --- /dev/null +++ b/HelperXPCShared/FileOperations.swift @@ -0,0 +1,61 @@ +import Foundation +import os.log + +enum FileOperations { + private static var 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 80f89b86..6448be10 100644 --- a/HelperXPCShared/HelperXPCShared.swift +++ b/HelperXPCShared/HelperXPCShared.swift @@ -12,4 +12,54 @@ protocol HelperXPCProtocol { 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/Tests.xcconfig b/Tests.xcconfig new file mode 100644 index 00000000..b633344a --- /dev/null +++ b/Tests.xcconfig @@ -0,0 +1,2 @@ +DEVELOPMENT_TEAM=ZU6GR6B2FY +CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT=ZU6GR6B2FY diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 4b79c55c..dbdde32c 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 150235A12CED5E2200F6ECBF /* FileOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 150235A02CED5E2200F6ECBF /* FileOperations.swift */; }; + 150235A22CED5E2200F6ECBF /* FileOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 150235A02CED5E2200F6ECBF /* FileOperations.swift */; }; 15F5B8902CCF09B900705E2F /* CryptoKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15F5B88F2CCF09B900705E2F /* CryptoKit.framework */; }; 33027E342CA8C18800CB387C /* LibFido2Swift in Frameworks */ = {isa = PBXBuildFile; productRef = 334A932B2CA885A400A5E079 /* LibFido2Swift */; }; 3328073F2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */; }; @@ -197,6 +199,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 150235A02CED5E2200F6ECBF /* FileOperations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileOperations.swift; sourceTree = ""; }; + 1542A3022CEF05AE00DB71B0 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = App.xcconfig; sourceTree = ""; }; + 1542A3032CEF05B800DB71B0 /* Tests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tests.xcconfig; sourceTree = ""; }; + 1542A3042CEF05C900DB71B0 /* Helper.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Helper.xcconfig; 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 = ""; }; @@ -456,6 +462,7 @@ isa = PBXGroup; children = ( CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */, + 150235A02CED5E2200F6ECBF /* FileOperations.swift */, ); path = HelperXPCShared; sourceTree = ""; @@ -593,6 +600,9 @@ CAD2E79F2449574E00113D76 /* Products */, CA538A12255A4F7C00E64DD7 /* Frameworks */, CA452BE025A2354D0072DFA4 /* Recovered References */, + 1542A3022CEF05AE00DB71B0 /* App.xcconfig */, + 1542A3032CEF05B800DB71B0 /* Tests.xcconfig */, + 1542A3042CEF05C900DB71B0 /* Helper.xcconfig */, ); sourceTree = ""; }; @@ -884,6 +894,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 150235A22CED5E2200F6ECBF /* FileOperations.swift in Sources */, CA9FF8D025959A9700E47BAF /* HelperXPCShared.swift in Sources */, CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */, CA9FF8DB25959B4000E47BAF /* XPCDelegate.swift in Sources */, @@ -910,6 +921,7 @@ B0C6AD0D2AD91D7900E64698 /* IconView.swift in Sources */, CA9FF9362595B44700E47BAF /* HelperClient.swift in Sources */, B0C6AD042AD6E65700E64698 /* ReleaseDateView.swift in Sources */, + 150235A12CED5E2200F6ECBF /* FileOperations.swift in Sources */, CAA8587C25A2B37900ACF8C0 /* IsTesting.swift in Sources */, CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */, CA44901F2463AD34003D8213 /* Tag.swift in Sources */, @@ -1058,7 +1070,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = ZU6GR6B2FY; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1091,9 +1102,9 @@ }; CA8FB636256E154800469DA5 /* Test */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1542A3022CEF05AE00DB71B0 /* App.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = ZU6GR6B2FY; CODE_SIGN_ENTITLEMENTS = Xcodes/Resources/XcodesTest.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; @@ -1120,6 +1131,7 @@ }; CA8FB637256E154800469DA5 /* Test */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1542A3032CEF05B800DB71B0 /* Tests.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; @@ -1144,12 +1156,11 @@ }; CA9FF8B22595967A00E47BAF /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1542A3042CEF05C900DB71B0 /* Helper.xcconfig */; buildSettings = { CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = ZU6GR6B2FY; CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; MARKETING_VERSION = 2.0.0; @@ -1169,9 +1180,9 @@ }; CA9FF8B32595967A00E47BAF /* Test */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1542A3042CEF05C900DB71B0 /* Helper.xcconfig */; buildSettings = { CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = ZU6GR6B2FY; CODE_SIGN_ENTITLEMENTS = com.xcodesorg.xcodesapp.Helper/com.xcodesorg.xcodesapp.HelperTest.entitlements; CODE_SIGN_STYLE = Manual; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; @@ -1196,13 +1207,12 @@ }; CA9FF8B42595967A00E47BAF /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1542A3042CEF05C900DB71B0 /* Helper.xcconfig */; buildSettings = { CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = ZU6GR6B2FY; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; MARKETING_VERSION = 2.0.0; @@ -1253,7 +1263,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = ZU6GR6B2FY; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1317,7 +1326,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = ZU6GR6B2FY; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; @@ -1343,16 +1351,15 @@ }; CAD2E7BD2449575100113D76 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1542A3022CEF05AE00DB71B0 /* App.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = ZU6GR6B2FY; CODE_SIGN_ENTITLEMENTS = Xcodes/Resources/Xcodes.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; - DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Xcodes/Resources/Info.plist; @@ -1371,16 +1378,15 @@ }; CAD2E7BE2449575100113D76 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1542A3022CEF05AE00DB71B0 /* App.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT = ZU6GR6B2FY; CODE_SIGN_ENTITLEMENTS = Xcodes/Resources/Xcodes.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; - DEVELOPMENT_TEAM = ZU6GR6B2FY; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Xcodes/Resources/Info.plist; @@ -1399,12 +1405,12 @@ }; CAD2E7C02449575100113D76 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1542A3032CEF05B800DB71B0 /* Tests.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ZU6GR6B2FY; INFOPLIST_FILE = XcodesTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1421,13 +1427,13 @@ }; CAD2E7C12449575100113D76 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1542A3032CEF05B800DB71B0 /* Tests.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - DEVELOPMENT_TEAM = ZU6GR6B2FY; INFOPLIST_FILE = XcodesTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 2c9fc846..cfe05d54 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -266,18 +266,27 @@ extension AppState { return Fail(error: error) .eraseToAnyPublisher() } - .tryMap { output -> URL in + .flatMap { output -> AnyPublisher in self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination.path)) let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") if Current.files.fileExists(atPath: xcodeURL.path) { - try Current.files.moveItem(at: xcodeURL, to: destination) + return Current.helper.moveApp(xcodeURL.path, destination.path) + .map { _ in destination } + .eraseToAnyPublisher() } + else if Current.files.fileExists(atPath: xcodeBetaURL.path) { - try Current.files.moveItem(at: xcodeBetaURL, to: destination) + return Current.helper.moveApp(xcodeBetaURL.path, destination.path) + .map { _ in destination } + .eraseToAnyPublisher() } + return Fail(error: InstallationError.failedToMoveXcodeToApplications) + .eraseToAnyPublisher() + } + .tryMap { output -> URL in return destination } .handleEvents(receiveCancel: { diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 475602fe..b8bdb47c 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -26,6 +26,7 @@ enum PreferenceKey: String { case xcodeListCategory case allowedMajorVersions case hideSupportXcodes + case usePrivilegeHelperForFileOperations func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) } } @@ -69,7 +70,14 @@ class AppState: ObservableObject { @Published var xcodeBeingConfirmedForUninstallation: Xcode? @Published var presentedAlert: XcodesAlert? @Published var presentedPreferenceAlert: XcodesPreferencesAlert? + @Published var helperInstallState: HelperInstallState = .notInstalled + @Published var usePrivilegedHelperForFileOperations: Bool = false { + didSet { + Current.defaults.set(usePrivilegedHelperForFileOperations, forKey: PreferenceKey.usePrivilegeHelperForFileOperations.rawValue) + } + } + /// Whether the user is being prepared for the helper installation alert with an explanation. /// This closure will be performed after the user chooses whether or not to proceed. @Published var isPreparingUserForActionRequiringHelper: ((Bool) -> Void)? @@ -150,6 +158,7 @@ class AppState: ObservableObject { internal var runtimePublishers: [String: Task<(), any Error>] = [:] private var selectPublisher: AnyCancellable? private var uninstallPublisher: AnyCancellable? + private var createSymLinkPublisher: AnyCancellable? private var autoInstallTimer: Timer? // MARK: - Dock Progress Tracking @@ -702,23 +711,26 @@ class AppState: ObservableObject { return } - guard - var installedXcodePath = xcode.installedPath, - selectPublisher == nil - else { return } - - if onSelectActionType == .rename { - guard let newDestinationXcodePath = renameToXcode(xcode: xcode) else { return } - installedXcodePath = newDestinationXcodePath - } + guard selectPublisher == nil else { return } selectPublisher = installHelperIfNecessary() - .flatMap { - Current.helper.switchXcodePath(installedXcodePath.string) + .flatMap { [unowned self] _ -> AnyPublisher in + if onSelectActionType == .rename { + return self.renameToXcode(xcode: xcode) + } + + return Future { promise in + promise(.success(xcode.installedPath!.string)) + } + .eraseToAnyPublisher() + } + .flatMap { path in + Current.helper.switchXcodePath(path) } .flatMap { [unowned self] _ in self.updateSelectedXcodePath() } + .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [unowned self] completion in if case let .failure(error) = completion { @@ -769,66 +781,97 @@ class AppState: ObservableObject { guard let installedXcodePath = xcode.installedPath else { return } let destinationPath: Path = Path.installDirectory/"Xcode\(isBeta ? "-Beta" : "").app" - - // does an Xcode.app file exist? - if FileManager.default.fileExists(atPath: destinationPath.string) { - do { - // if it's not a symlink, error because we don't want to delete an actual xcode.app file - let attributes: [FileAttributeKey : Any]? = try? FileManager.default.attributesOfItem(atPath: destinationPath.string) - - if attributes?[.type] as? FileAttributeType == FileAttributeType.typeSymbolicLink { - try FileManager.default.removeItem(atPath: destinationPath.string) - Logger.appState.info("Successfully deleted old symlink") - } else { - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: localizeString("Alert.SymLink.Message")) - return - } - } catch { - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.localizedDescription) + + createSymLinkPublisher = installHelperIfNecessary() + .flatMap { + Current.helper.createSymbolicLink(installedXcodePath.string, destinationPath.string) } - } - - do { - try FileManager.default.createSymbolicLink(atPath: destinationPath.string, withDestinationPath: installedXcodePath.string) - Logger.appState.info("Successfully created symbolic link with Xcode\(isBeta ? "-Beta": "").app") - } catch { - Logger.appState.error("Unable to create symbolic Link") - self.error = error - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) - } + .sink( + receiveCompletion: { [unowned self] completion in + if case let .failure(error) = completion { + if let error = error as? CustomNSError { + switch error.errorCode { + case XPCDelegateError.Code.destinationIsNotASymbolicLink.rawValue: + self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: localizeString("Alert.SymLink.Message")) + default: + self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) + } + } else { + self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: + error.legibleLocalizedDescription) + } + } + + self.createSymLinkPublisher = nil + }, + receiveValue: { _ in } + ) } - - func renameToXcode(xcode: Xcode) -> Path? { - guard let installedXcodePath = xcode.installedPath else { return nil } - - let destinationPath: Path = Path.installDirectory/"Xcode.app" - - // rename any old named `Xcode.app` to the Xcodes versioned named files - if FileManager.default.fileExists(atPath: destinationPath.string) { - if let originalXcode = Current.files.installedXcode(destination: destinationPath) { - let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app" - Logger.appState.debug("Found Xcode.app - renaming back to \(newName)") - do { - try destinationPath.rename(to: newName) - } catch { - Logger.appState.error("Unable to create rename Xcode.app back to original") - self.error = error - // TODO UPDATE MY ERROR STRING - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) + + enum RenameXcodeError: Error { + case xcodeInstallPathIsNil + case xcodeAppNotFound + case originalXcodeNameNotFound + case renameFailure(Error) + } + + func renameToXcode(xcode: Xcode) -> AnyPublisher { + guard let installedXcodePath = xcode.installedPath else { + return Fail(error: RenameXcodeError.xcodeInstallPathIsNil) + .eraseToAnyPublisher() + } + + var cancellables: Set = [] + + return Future { promise in + let destinationPath: Path = Path.installDirectory/"Xcode.app" + // rename any old named `Xcode.app` to the Xcodes versioned named files + guard FileManager.default.fileExists(atPath: destinationPath.string) else { + promise(.success(destinationPath.string)) + return + } + + guard let originalXcode = Current.files.installedXcode(destination: destinationPath) else { + promise(.failure(RenameXcodeError.xcodeAppNotFound)) + return + } + + let newName = "Xcode-\(originalXcode.version.descriptionWithoutBuildMetadata).app" + Logger.appState.debug("Found Xcode.app - renaming back to \(newName)") + + Current.helper.rename(destinationPath.string, "\(Path.installDirectory)/\(newName)") + .sink { completion in + if case let .failure(error) = completion { + promise(.failure(error)) + return + } + + promise(.success(destinationPath.string)) + } receiveValue: { _ in + Void() } + .store(in: &cancellables) + } + .flatMap { destinationPath in + return Future { promise in + Current.helper.rename(installedXcodePath.string, destinationPath) + .sink { completion in + if case let .failure(error) = completion { + promise(.failure(error)) + return + } + + promise(.success(destinationPath)) + } receiveValue: { _ in + Void() + } + .store(in: &cancellables) } } - // rename passed in xcode to xcode.app - Logger.appState.debug("Found Xcode.app - renaming back to Xcode.app") - do { - return try installedXcodePath.rename(to: "Xcode.app") - } catch { - Logger.appState.error("Unable to create rename Xcode.app back to original") - self.error = error - // TODO UPDATE MY ERROR STRING - self.presentedAlert = .generic(title: localizeString("Alert.SymLink.Title"), message: error.legibleLocalizedDescription) + .mapError { error in + return RenameXcodeError.renameFailure(error) } - return nil + .eraseToAnyPublisher() } func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) { @@ -931,17 +974,11 @@ class AppState: ObservableObject { // MARK: - Private private func uninstallXcode(path: Path) -> AnyPublisher { - return Deferred { - Future { promise in - do { - try Current.files.trashItem(at: path.url) - promise(.success(())) - } catch { - promise(.failure(error)) - } + return installHelperIfNecessary() + .flatMap { _ in + Current.helper.remove(path.string) } - } - .eraseToAnyPublisher() + .eraseToAnyPublisher() } /// removes saved username and credentials stored in keychain diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 61574ce5..22269939 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -422,6 +422,7 @@ public struct Defaults { } private let helperClient = HelperClient() + public struct Helper { var install: () throws -> Void = helperClient.install var checkIfLatestHelperIsInstalled: () -> AnyPublisher = helperClient.checkIfLatestHelperIsInstalled @@ -431,4 +432,8 @@ public struct Helper { var addStaffToDevelopersGroup: () -> AnyPublisher = helperClient.addStaffToDevelopersGroup var acceptXcodeLicense: (_ absoluteXcodePath: String) -> AnyPublisher = helperClient.acceptXcodeLicense var runFirstLaunch: (_ absoluteXcodePath: String) -> AnyPublisher = helperClient.runFirstLaunch + var moveApp: (_ absoluteSourcePath: String, _ absoluteDestinationPath: String) -> AnyPublisher = helperClient.moveApp(at:to:) + var createSymbolicLink: (_ absoluteSourcePath: String, _ absoluteDestinationPath: String) -> AnyPublisher = helperClient.createSymbolicLink(source:destination:) + var rename: (_ absoluteSourcePath: String, _ absoluteDestinationPath: String) -> AnyPublisher = helperClient.rename(source:destination:) + var remove: (_ absolutePath: String) -> AnyPublisher = helperClient.remove(path:) } diff --git a/Xcodes/Backend/HelperClient.swift b/Xcodes/Backend/HelperClient.swift index 284d23da..189351be 100644 --- a/Xcodes/Backend/HelperClient.swift +++ b/Xcodes/Backend/HelperClient.swift @@ -302,8 +302,163 @@ final class HelperClient { } }) .eraseToAnyPublisher() - } - + } + + var usePrivilegedHelperForFileOperations: Bool { + Current.defaults.bool(forKey: PreferenceKey.usePrivilegeHelperForFileOperations.rawValue) ?? false + } + + func moveApp(at source:String, to destination: String) -> AnyPublisher { + if !usePrivilegedHelperForFileOperations { + return Deferred { + Future { promise in + FileOperations.moveApp(at: source, to: destination) { error in + if let error = error { + promise(.failure(error)) + } + promise(.success(())) + } + } + } + .eraseToAnyPublisher() + } + + let connectionErrorSubject = PassthroughSubject() + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) + .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.moveApp(at: source, to: destination) { error in + if let error = error { + promise(.failure(error)) + } + promise(.success(())) + } + } + } + .eraseToAnyPublisher() + } + + func createSymbolicLink(source: String, destination: String) -> AnyPublisher { + if !usePrivilegedHelperForFileOperations { + return Deferred { + Future { promise in + FileOperations.createSymbolicLink(source: source, destination: destination) { error in + if let error = error { + promise(.failure(error)) + } + promise(.success(())) + } + } + } + .eraseToAnyPublisher() + } + + let connectionErrorSubject = PassthroughSubject() + + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) + .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.createSymbolicLink(source: source, destination: destination) { error in + if let error = error { + promise(.failure(error)) + } + promise(.success(())) + } + } + } + .eraseToAnyPublisher() + } + + func rename(source: String, destination: String) -> AnyPublisher { + if !usePrivilegedHelperForFileOperations { + return Deferred { + Future { promise in + FileOperations.rename(source: source, destination: destination) { error in + if let error = error { + promise(.failure(error)) + } + promise(.success(())) + } + } + } + .eraseToAnyPublisher() + } + + let connectionErrorSubject = PassthroughSubject() + + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) + .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.rename(source: source, destination: destination) { error in + if let error = error { + promise(.failure(error)) + } + promise(.success(())) + } + } + } + .eraseToAnyPublisher() + } + + func remove(path: String) -> AnyPublisher { + if !usePrivilegedHelperForFileOperations { + return Deferred { + Future { promise in + FileOperations.remove(path: path) { error in + if let error = error { + promise(.failure(error)) + } + promise(.success(())) + } + } + } + .eraseToAnyPublisher() + } + + let connectionErrorSubject = PassthroughSubject() + + guard + let helper = self.helper(errorSubject: connectionErrorSubject) + else { + return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) + .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) + .eraseToAnyPublisher() + } + + return Deferred { + Future { promise in + helper.remove(path: path) { error in + if let error = error { + promise(.failure(error)) + } + promise(.success(())) + } + } + } + .eraseToAnyPublisher() + } + // MARK: - Install // From https://github.com/securing/SimpleXPCApp/ diff --git a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift index 13ab718e..96598c9e 100644 --- a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift @@ -151,6 +151,9 @@ struct AdvancedPreferencePane: View { .fixedSize(horizontal: false, vertical: true) Spacer() + + Toggle("UsePrivilegedHelperForFileOperations", isOn: $appState.usePrivilegedHelperForFileOperations) + .disabled(PreferenceKey.usePrivilegeHelperForFileOperations.isManaged()) } } .groupBoxStyle(PreferencesGroupBoxStyle()) diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 24fc217b..52bc0b7d 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -237,6 +237,16 @@ } } }, + "%@ (%@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ (%2$@)" + } + } + } + }, "%@ %@ %@" : { "localizations" : { "ar" : { @@ -22330,6 +22340,16 @@ } } }, + "UsePrivilegedHelperForFileOperations" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perform file operations using Privileged Helper" + } + } + } + }, "UseUnxipExperiment" : { "localizations" : { "ar" : { diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index 4be9ca32..bc54cd88 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -157,6 +157,15 @@ class AppStateTests: XCTestCase { // Helper is already installed subject.helperInstallState = .installed + Current.helper.moveApp = { _,_ in + return Deferred { + Future { promise in + promise(.success(())) + } + } + .eraseToAnyPublisher() + } + let allXcodesRecorder = subject.$allXcodes.record() let installRecorder = subject.install( .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), @@ -267,6 +276,15 @@ class AppStateTests: XCTestCase { // Helper is already installed subject.helperInstallState = .installed + Current.helper.moveApp = { _,_ in + return Deferred { + Future { promise in + promise(.success(())) + } + } + .eraseToAnyPublisher() + } + let allXcodesRecorder = subject.$allXcodes.record() let installRecorder = subject.install( .version(AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)), diff --git a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift index 86a33301..a59025f1 100644 --- a/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift +++ b/com.xcodesorg.xcodesapp.Helper/XPCDelegate.swift @@ -2,7 +2,6 @@ import Foundation import os.log class XPCDelegate: NSObject, NSXPCListenerDelegate, HelperXPCProtocol { - // MARK: - NSXPCListenerDelegate func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { @@ -51,6 +50,26 @@ 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 ((any Error)?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.moveApp(at: source, to: destination, completion: completion) + } + + func createSymbolicLink(source: String, destination: String, completion: @escaping ((any Error)?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.createSymbolicLink(source: source, destination: destination, completion: completion) + } + + func rename(source: String, destination: String, completion: @escaping ((any Error)?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.rename(source: source, destination: destination, completion: completion) + } + + func remove(path: String, completion: @escaping ((any Error)?) -> Void) { + Logger.xpcDelegate.info("\(#function)") + FileOperations.remove(path: path, completion: completion) + } } // MARK: - Run @@ -69,34 +88,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." - ] - } - } -}