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.
This commit is contained in:
Anand Biligiri 2024-11-20 22:44:22 -08:00
parent 17f3d365b8
commit b77830cff0
14 changed files with 487 additions and 129 deletions

2
App.xcconfig Normal file
View file

@ -0,0 +1,2 @@
DEVELOPMENT_TEAM=ZU6GR6B2FY
CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT=ZU6GR6B2FY

2
Helper.xcconfig Normal file
View file

@ -0,0 +1,2 @@
DEVELOPMENT_TEAM=ZU6GR6B2FY
CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT=ZU6GR6B2FY

View file

@ -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)
}
}
}

View file

@ -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."
]
}
}
}

2
Tests.xcconfig Normal file
View file

@ -0,0 +1,2 @@
DEVELOPMENT_TEAM=ZU6GR6B2FY
CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT=ZU6GR6B2FY

View file

@ -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 = "<group>"; };
1542A3022CEF05AE00DB71B0 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = App.xcconfig; sourceTree = "<group>"; };
1542A3032CEF05B800DB71B0 /* Tests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tests.xcconfig; sourceTree = "<group>"; };
1542A3042CEF05C900DB71B0 /* Helper.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Helper.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyTouchView.swift; sourceTree = "<group>"; };
@ -456,6 +462,7 @@
isa = PBXGroup;
children = (
CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */,
150235A02CED5E2200F6ECBF /* FileOperations.swift */,
);
path = HelperXPCShared;
sourceTree = "<group>";
@ -593,6 +600,9 @@
CAD2E79F2449574E00113D76 /* Products */,
CA538A12255A4F7C00E64DD7 /* Frameworks */,
CA452BE025A2354D0072DFA4 /* Recovered References */,
1542A3022CEF05AE00DB71B0 /* App.xcconfig */,
1542A3032CEF05B800DB71B0 /* Tests.xcconfig */,
1542A3042CEF05C900DB71B0 /* Helper.xcconfig */,
);
sourceTree = "<group>";
};
@ -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)",

View file

@ -266,18 +266,27 @@ extension AppState {
return Fail(error: error)
.eraseToAnyPublisher()
}
.tryMap { output -> URL in
.flatMap { output -> AnyPublisher<URL, Swift.Error> 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)
}
else if Current.files.fileExists(atPath: xcodeBetaURL.path) {
try Current.files.moveItem(at: xcodeBetaURL, to: destination)
return Current.helper.moveApp(xcodeURL.path, destination.path)
.map { _ in destination }
.eraseToAnyPublisher()
}
else if Current.files.fileExists(atPath: xcodeBetaURL.path) {
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: {

View file

@ -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<String, Error> 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<String, Error> {
guard let installedXcodePath = xcode.installedPath else {
return Fail(error: RenameXcodeError.xcodeInstallPathIsNil)
.eraseToAnyPublisher()
}
var cancellables: Set<AnyCancellable> = []
return Future<String, Error> { 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<String, Error> { 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<Void, Error> {
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

View file

@ -422,6 +422,7 @@ public struct Defaults {
}
private let helperClient = HelperClient()
public struct Helper {
var install: () throws -> Void = helperClient.install
var checkIfLatestHelperIsInstalled: () -> AnyPublisher<Bool, Never> = helperClient.checkIfLatestHelperIsInstalled
@ -431,4 +432,8 @@ public struct Helper {
var addStaffToDevelopersGroup: () -> AnyPublisher<Void, Error> = helperClient.addStaffToDevelopersGroup
var acceptXcodeLicense: (_ absoluteXcodePath: String) -> AnyPublisher<Void, Error> = helperClient.acceptXcodeLicense
var runFirstLaunch: (_ absoluteXcodePath: String) -> AnyPublisher<Void, Error> = helperClient.runFirstLaunch
var moveApp: (_ absoluteSourcePath: String, _ absoluteDestinationPath: String) -> AnyPublisher<Void, Error> = helperClient.moveApp(at:to:)
var createSymbolicLink: (_ absoluteSourcePath: String, _ absoluteDestinationPath: String) -> AnyPublisher<Void, Error> = helperClient.createSymbolicLink(source:destination:)
var rename: (_ absoluteSourcePath: String, _ absoluteDestinationPath: String) -> AnyPublisher<Void, Error> = helperClient.rename(source:destination:)
var remove: (_ absolutePath: String) -> AnyPublisher<Void, Error> = helperClient.remove(path:)
}

View file

@ -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<Void, Error> {
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<String, Error>()
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<Void, Error> {
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<String, Error>()
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<Void, Error> {
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<String, Error>()
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<Void, Error> {
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<String, Error>()
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/

View file

@ -151,6 +151,9 @@ struct AdvancedPreferencePane: View {
.fixedSize(horizontal: false, vertical: true)
Spacer()
Toggle("UsePrivilegedHelperForFileOperations", isOn: $appState.usePrivilegedHelperForFileOperations)
.disabled(PreferenceKey.usePrivilegeHelperForFileOperations.isManaged())
}
}
.groupBoxStyle(PreferencesGroupBoxStyle())

View file

@ -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" : {

View file

@ -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)),

View file

@ -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."
]
}
}
}