diff --git a/HelperXPCShared/FileOperations.swift b/HelperXPCShared/FileOperations.swift new file mode 100644 index 00000000..ddf6a288 --- /dev/null +++ b/HelperXPCShared/FileOperations.swift @@ -0,0 +1,76 @@ +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 (source as NSString).isAbsolutePath, + URL(fileURLWithPath: source).hasDirectoryPath else { throw XPCDelegateError(.invalidSourcePath)} + + guard (destination as NSString).isAbsolutePath, + 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 { + guard (source as NSString).isAbsolutePath else { throw XPCDelegateError(.invalidSourcePath) } + guard (destination as NSString).isAbsolutePath else { throw XPCDelegateError(.invalidDestinationPath) } + + let attributes: [FileAttributeKey: Any]? + do { + // Read the link itself, including links whose target no longer exists. + attributes = try FileManager.default.attributesOfItem(atPath: destination) + } catch CocoaError.fileReadNoSuchFile { + attributes = nil + } + + if let attributes { + 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 { + guard (source as NSString).isAbsolutePath else { throw XPCDelegateError(.invalidSourcePath) } + guard (destination as NSString).isAbsolutePath else { throw XPCDelegateError(.invalidDestinationPath) } + 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 { + guard (path as NSString).isAbsolutePath, + URL(fileURLWithPath: path).standardizedFileURL.path != "/" else { throw XPCDelegateError(.invalidSourcePath) } + try FileManager.default.removeItem(atPath: path) + completion(nil) + } catch { + completion(error) + } + } +} diff --git a/HelperXPCShared/HelperXPCShared.swift b/HelperXPCShared/HelperXPCShared.swift index 4fc3c186..bf06e523 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/README.md b/README.md index 88b91dca..47822f77 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The easiest way to install and switch between multiple versions of Xcode. -This repository is a maintained fork of [XcodesOrg/XcodesApp](https://github.com/XcodesOrg/XcodesApp). Fork history includes upstream release `v4.0.5b40`. It keeps the visible `Xcodes.app` name and workflow as a drop-in replacement while using fork-owned bundle, helper, update, signing, and release identities. +This repository is a maintained fork of [XcodesOrg/XcodesApp](https://github.com/XcodesOrg/XcodesApp). Fork history includes upstream release `v4.1.1b41`. It keeps the visible `Xcodes.app` name and workflow as a drop-in replacement while using fork-owned bundle, helper, update, signing, and release identities. If you need a command-line tool, see the upstream [`xcodes`](https://github.com/XcodesOrg/xcodes) project. @@ -34,6 +34,8 @@ Fork releases use the same `Xcodes.app` application name as upstream. The fork h The release process distributes fork assets only after Developer ID signing and notarization. GitHub Releases is the supported binary distribution path. This fork does not publish a Homebrew cask. +If you installed upstream through Homebrew, run `brew uninstall --cask xcodes-app` before installing this fork. Otherwise, `brew upgrade` can replace the fork with upstream because both use `/Applications/Xcodes.app`. Do not use `--zap`; keep your existing settings. After installation, use **Xcodes → Check for Updates…** for fork updates. + On first launch, non-secret preferences can migrate from upstream Xcodes. Apple credentials, cookies, Keychain items, and the privileged helper do not migrate. Sign in again. When Xcodes prompts you, approve helper installation. ## Build from source diff --git a/Scripts/check_ci_release_workflows.rb b/Scripts/check_ci_release_workflows.rb index 4ef5499a..15a1041e 100755 --- a/Scripts/check_ci_release_workflows.rb +++ b/Scripts/check_ci_release_workflows.rb @@ -475,9 +475,9 @@ releasing_docs = File.file?(releasing_docs_path) ? File.read(releasing_docs_path) : "" check.call(releasing_docs.include?("environment protection rule must allow only protected tags matching `v*`"), "Release guide must require exact environment tag restrictions") -check.call(releasing_docs.include?("`workflow_dispatch` reruns must use `--ref v4.0.5b48`"), "Release guide must document tag-ref manual dispatch") +check.call(releasing_docs.include?("`workflow_dispatch` reruns must use `--ref v4.1.1b49`"), "Release guide must document tag-ref manual dispatch") check.call( - releasing_docs.include?("gh workflow run appcast.yml --ref v4.0.5b48 -f tag=v4.0.5b48"), + releasing_docs.include?("gh workflow run appcast.yml --ref v4.1.1b49 -f tag=v4.1.1b49"), "Release guide must document exact tag-bound appcast dispatch" ) check.call( diff --git a/Scripts/check_fork_identity.sh b/Scripts/check_fork_identity.sh index 4f5b4098..3e7c9203 100755 --- a/Scripts/check_fork_identity.sh +++ b/Scripts/check_fork_identity.sh @@ -43,8 +43,8 @@ readonly app_id="dev.jacobcx.Xcodes" readonly tests_id="dev.jacobcx.Xcodes.Tests" readonly helper_id="dev.jacobcx.Xcodes.Helper" readonly team_id="K2648T24P4" -readonly marketing_version="4.0.5" -readonly build_number="48" +readonly marketing_version="4.1.1" +readonly build_number="49" readonly app_copyright="Fork contributions © 2026 JacobCXDev. Upstream contributors retain their copyrights." # shellcheck disable=SC2016 # Xcode expands this build-setting literal, not the shell. readonly app_requirement='identifier "dev.jacobcx.Xcodes" and info [CFBundleShortVersionString] >= "1.0.0" and anchor apple generic and certificate leaf[subject.OU] = "$(CODE_SIGNING_SUBJECT_ORGANIZATIONAL_UNIT)"' @@ -154,7 +154,7 @@ done require_literal "maintained fork" "$readme" require_literal "[JacobCXDev](https://github.com/jacobcxdev) maintains this fork" "$readme" # shellcheck disable=SC2016 # Markdown backticks are literal README content. -require_literal 'upstream release `v4.0.5b40`' "$readme" +require_literal 'upstream release `v4.1.1b41`' "$readme" require_literal "https://github.com/XcodesOrg/XcodesApp" "$readme" require_literal "git clone https://github.com/jacobcxdev/XcodesApp.git" "$readme" require_literal "https://github.com/jacobcxdev/XcodesApp/releases/latest" "$readme" @@ -183,7 +183,7 @@ if [[ -e "$repo_root/Xcodes/Frontend/XcodeList/BottomStatusBar.swift" ]] \ fail "Obsolete main-window footer remains" fi require_literal "DEVELOPER_ID_APPLICATION_P12_BASE64" "$release_documentation" -require_literal "v4.0.5b48" "$release_documentation" +require_literal "v4.1.1b49" "$release_documentation" if grep -n -F -- 'Jacob Clayden' "$readme" "$about_source" "$app_info_plist"; then fail "Legal name leaked into public-facing fork branding" diff --git a/Scripts/test_ci_release_workflows.sh b/Scripts/test_ci_release_workflows.sh index ef6415f6..76c87f9c 100755 --- a/Scripts/test_ci_release_workflows.sh +++ b/Scripts/test_ci_release_workflows.sh @@ -135,7 +135,7 @@ mutate_and_reject publish_unexpected_control \ mutate_and_reject missing_release_drafter_timeout \ 'path = File.join(ARGV.fetch(0), ".github/workflows/release-drafter.yml"); data = YAML.safe_load_file(path, aliases: false); data["jobs"]["update_release_draft"].delete("timeout-minutes"); File.write(path, YAML.dump(data) + "# bounded timeout\n")' mutate_and_reject missing_appcast_dispatch_docs \ - 'path = File.join(ARGV.fetch(0), "docs/RELEASING.md"); text = File.read(path).sub("gh workflow run appcast.yml --ref v4.0.5b48 -f tag=v4.0.5b48", "gh workflow run appcast.yml --ref main -f tag=latest"); File.write(path, text)' + 'path = File.join(ARGV.fetch(0), "docs/RELEASING.md"); text = File.read(path).sub("gh workflow run appcast.yml --ref v4.1.1b49 -f tag=v4.1.1b49", "gh workflow run appcast.yml --ref main -f tag=latest"); File.write(path, text)' # shellcheck disable=SC2016 # Documentation code spans must remain literal in the mutation. mutate_and_reject missing_reusable_ref_docs \ 'path = File.join(ARGV.fetch(0), "docs/RELEASING.md"); text = File.read(path).sub("Reusable workflows receive the caller'\''s `github.ref`; the appcast build requires that ref to equal `refs/tags/`", "Reusable workflows are called after release publication"); File.write(path, text)' diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index ab12b1d3..fb1c4357 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + E10000010000000000000001 /* FileOperationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10000020000000000000001 /* FileOperationsTests.swift */; }; + 14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */; }; + 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 */; }; @@ -32,6 +36,7 @@ CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; }; D10000010000000000000001 /* PinCodeInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000020000000000000001 /* PinCodeInputTests.swift */; }; + CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */; }; CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA42DD7325AEB04300BC0B0C /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA42DD7225AEB04300BC0B0C /* Logger.swift */; }; @@ -186,6 +191,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + E10000020000000000000001 /* FileOperationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileOperationsTests.swift; sourceTree = ""; }; + 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 = ""; }; @@ -195,6 +202,7 @@ 536CFDD3263C9A8000026CE0 /* XcodesSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesSheet.swift; sourceTree = ""; }; 53CBAB2B263DCC9100410495 /* XcodesAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesAlert.swift; sourceTree = ""; }; 63EAA4EA259944450046AB8F /* ProgressButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressButton.swift; sourceTree = ""; }; + 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewVersionNotificationTests.swift; sourceTree = ""; }; 9DD4FFCA2B13EC1800C974F1 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; B0403CEF2AD92D7B00137C09 /* ReleaseNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseNotesView.swift; sourceTree = ""; }; B0403CF12AD934B600137C09 /* CompatibilityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilityView.swift; sourceTree = ""; }; @@ -211,6 +219,7 @@ CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; D10000020000000000000001 /* PinCodeInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeInputTests.swift; sourceTree = ""; }; + CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsViewTests.swift; sourceTree = ""; }; CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA42DD7225AEB04300BC0B0C /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; @@ -434,6 +443,7 @@ isa = PBXGroup; children = ( CA9FF8CE25959A9700E47BAF /* HelperXPCShared.swift */, + 1596C2903043765600178C86 /* FileOperations.swift */, ); path = HelperXPCShared; sourceTree = ""; @@ -600,6 +610,9 @@ CAD2E7B72449575100113D76 /* AppStateTests.swift */, CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */, D10000020000000000000001 /* PinCodeInputTests.swift */, + E10000020000000000000001 /* FileOperationsTests.swift */, + CA2518ED25A7FF2B00F08414 /* SignInCredentialsViewTests.swift */, + 884f01aed2f43048ab4d3323 /* NewVersionNotificationTests.swift */, CAD2E7B92449575100113D76 /* Info.plist */, ); path = XcodesTests; @@ -868,6 +881,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 */, @@ -916,6 +930,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 */, @@ -978,7 +993,10 @@ CAC281E2259FA44600B8AB0B /* Bundle+XcodesTests.swift in Sources */, CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */, D10000010000000000000001 /* PinCodeInputTests.swift in Sources */, + E10000010000000000000001 /* FileOperationsTests.swift in Sources */, + 14d2f5a1273f6c350cad4406 /* NewVersionNotificationTests.swift in Sources */, CAB3AB0E25BCA6C200BF1B04 /* AppStateTests.swift in Sources */, + CA2518EE25A7FF2B00F08414 /* SignInCredentialsViewTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1072,7 +1090,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = K2648T24P4; ENABLE_HARDENED_RUNTIME = NO; @@ -1084,7 +1102,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 4.0.5; + MARKETING_VERSION = 4.1.1; PRODUCT_BUNDLE_IDENTIFIER = dev.jacobcx.Xcodes; PRODUCT_NAME = Xcodes; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1126,7 +1144,7 @@ DEVELOPMENT_TEAM = K2648T24P4; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-sectcreate", __TEXT, @@ -1152,7 +1170,7 @@ DEVELOPMENT_TEAM = K2648T24P4; ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-sectcreate", __TEXT, @@ -1179,7 +1197,7 @@ DEVELOPMENT_TEAM = K2648T24P4; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "$(SRCROOT)/$(TARGET_NAME)/Info.plist"; - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; OTHER_LDFLAGS = ( "-sectcreate", __TEXT, @@ -1325,7 +1343,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = K2648T24P4; ENABLE_HARDENED_RUNTIME = YES; @@ -1337,7 +1355,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 4.0.5; + MARKETING_VERSION = 4.1.1; PRODUCT_BUNDLE_IDENTIFIER = dev.jacobcx.Xcodes; PRODUCT_NAME = Xcodes; SWIFT_VERSION = 6.0; @@ -1354,7 +1372,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_ASSET_PATHS = "\"Xcodes/Preview Content\""; DEVELOPMENT_TEAM = K2648T24P4; ENABLE_HARDENED_RUNTIME = YES; @@ -1366,7 +1384,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 4.0.5; + MARKETING_VERSION = 4.1.1; PRODUCT_BUNDLE_IDENTIFIER = dev.jacobcx.Xcodes; PRODUCT_NAME = Xcodes; SWIFT_VERSION = 6.0; @@ -1525,7 +1543,7 @@ repositoryURL = "https://github.com/XcodesOrg/XcodesKit"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 1.0.4; + minimumVersion = 1.1.0; }; }; 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..5445e6ff 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" } }, { @@ -123,8 +123,8 @@ "repositoryURL": "https://github.com/XcodesOrg/XcodesKit", "state": { "branch": null, - "revision": "a9e5d7d701f20f1385071851319cdaecccc9f1e8", - "version": "1.0.4" + "revision": "4d3c6093980978b74c3fa42fc1b90cd1c34ac911", + "version": "1.1.0" } }, { @@ -132,7 +132,7 @@ "repositoryURL": "https://github.com/XcodesOrg/XcodesLoginKit", "state": { "branch": "main", - "revision": "9bece1ada36006b18b84caec62d14dc91b47ae2b", + "revision": "929f9aac3140caf7b64cbb5385f4f645c5f9913d", "version": null } }, diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index bcb7a0d3..1acacddf 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -354,7 +354,14 @@ extension AppState { ) }, 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 1ca20e76..63e56408 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -70,9 +70,11 @@ struct AuthenticationRequestPolicy: Sendable { shouldRetry: Self.isTransient, operation ) - } catch let NetworkError.non200StatusCode(statusCode, _) - where [502, 503, 504].contains(statusCode) { - throw AuthenticationRequestError.serviceTemporarilyUnavailable(statusCode: statusCode) + } catch { + if let statusCode = Self.transientStatusCode(error) { + throw AuthenticationRequestError.serviceTemporarilyUnavailable(statusCode: statusCode) + } + throw error } } @@ -103,13 +105,27 @@ struct AuthenticationRequestPolicy: Sendable { } private static func isTransient(_ error: Error) -> Bool { - guard - let networkError = error as? NetworkError, - case let .non200StatusCode(statusCode, _) = networkError - else { - return false + transientStatusCode(error) != nil + } + + private static func transientStatusCode(_ error: Error) -> Int? { + if let networkError = error as? NetworkError, + case let .non200StatusCode(statusCode, _) = networkError { + return [502, 503, 504].contains(statusCode) ? statusCode : nil + } + if let authenticationError = error as? AuthenticationError, + case let .badStatusCode(statusCode, _, _) = authenticationError { + return [502, 503, 504].contains(statusCode) ? statusCode : nil + } + if let authenticationError = error as? AuthenticationError, + case let .serviceKeyResolutionFailed(attempts) = authenticationError { + return attempts.compactMap { attempt -> Int? in + guard case let .httpStatus(code, _) = attempt.failure, + [502, 503, 504].contains(code) else { return nil } + return code + }.first } - return [502, 503, 504].contains(statusCode) + return nil } } @@ -133,6 +149,7 @@ enum PreferenceKey: String { case enableGroupedXcodeList case expandedMajorXcodeVersions case expandedMinorXcodeVersions + case usePrivilegeHelperForFileOperations func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) } } @@ -211,7 +228,7 @@ class AppState: ObservableObject { @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { - if Self.newlyAvailableXcodes(old: availableXcodes, new: newValue).isEmpty == false { + if Self.newlyAvailableXcodes(oldXcodes: availableXcodes, newXcodes: newValue).isEmpty == false { Current.notificationManager.scheduleNotification(title: localizeString("Notification.NewXcodeVersion.Title"), body: localizeString("Notification.NewXcodeVersion.Body"), category: .normal) } updateAllXcodes( @@ -228,8 +245,8 @@ class AppState: ObservableObject { } static func newlyAvailableXcodes( - old oldXcodes: [AvailableXcode], - new newXcodes: [AvailableXcode] + oldXcodes: [AvailableXcode], + newXcodes: [AvailableXcode] ) -> [AvailableXcode] { guard oldXcodes.isEmpty == false else { return [] } let existingIDs = Set(oldXcodes.map(\.xcodeID)) @@ -333,6 +350,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") @@ -378,6 +401,7 @@ class AppState: ObservableObject { internal var selectTaskID: UUID? internal var uninstallTask: Task? internal var uninstallTaskID: UUID? + internal var uninstallXcodeID: XcodeID? private var autoInstallTimer: Timer? // MARK: - Dock Progress Tracking @@ -453,6 +477,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 @@ -677,6 +702,11 @@ class AppState: ObservableObject { } func choosePhoneNumberForSMS(authOptions: AuthOptionsResponse, sessionData: AppleSessionData) { + if authOptions.trustedPhoneNumbers?.count == 1, let trustedPhoneNumber = authOptions.trustedPhoneNumbers?.first { + requestSMS(to: trustedPhoneNumber, authOptions: authOptions, sessionData: sessionData) + return + } + self.presentedSheet = .twoFactor(.init( option: .smsPendingChoice, authOptions: authOptions, @@ -1039,14 +1069,20 @@ 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 + uninstallXcodeID = xcode.id uninstallTask = Task { @MainActor in defer { if uninstallTaskID == taskID { uninstallTask = nil uninstallTaskID = nil + uninstallXcodeID = nil } } do { @@ -1056,7 +1092,17 @@ class AppState: ObservableObject { await updateSelectedXcodePathAsync() await updateInstalledXcodesAsync() } catch is CancellationError { + if uninstallTaskID == taskID || uninstallXcodeID != xcode.id, + let index = allXcodes.firstIndex(where: { $0.id == xcode.id }) { + allXcodes[index].installState = Current.files.installedXcode(destination: installedXcodePath) == nil + ? .notInstalled : .installed(installedXcodePath) + } } catch { + if uninstallTaskID == taskID || uninstallXcodeID != xcode.id, + let index = allXcodes.firstIndex(where: { $0.id == xcode.id }) { + allXcodes[index].installState = .installed(installedXcodePath) + } + guard uninstallTaskID == taskID else { return } self.error = error self.presentedAlert = .generic(title: localizeString("Alert.Uninstall.Error.Title"), message: error.legibleLocalizedDescription) } @@ -1096,14 +1142,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 @@ -1115,7 +1156,15 @@ 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 Task.checkCancellation() try await Current.helper.switchXcodePathAsync(installedXcodePath.string) try Task.checkCancellation() @@ -1124,7 +1173,7 @@ class AppState: ObservableObject { onSelectActionType != .rename, let isBeta = automaticSymbolicLinkIsBeta(for: xcode) { - createSymbolicLink(to: installedXcodePath, isBeta: isBeta) + await createSymbolicLink(to: installedXcodePath, isBeta: isBeta) } } catch is CancellationError { } catch { @@ -1165,7 +1214,9 @@ 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 automaticSymbolicLinkIsBeta(for xcode: Xcode) -> Bool? { @@ -1175,25 +1226,38 @@ class AppState: ObservableObject { return createSymLinkOnSelect ? false : nil } - 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( - fileExists: { path in - (try? FileManager.default.attributesOfItem(atPath: path)) != nil - }, - 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 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 installHelperIfNecessaryAsync() + 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( + fileExists: { path in + (try? FileManager.default.attributesOfItem(atPath: path)) != nil + }, + 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 is CancellationError { } catch { Logger.appState.error("Unable to create symbolic Link") self.error = error @@ -1204,19 +1268,34 @@ 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" + guard installedXcodePath != destinationPath else { return destinationPath } + try await installHelperIfNecessaryAsync() + 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 is CancellationError { } catch { Logger.appState.error("Unable to create rename Xcode.app back to original") self.error = error @@ -1270,10 +1349,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) + } } func waitForAuthenticationTerminalState() async throws { @@ -1305,7 +1390,7 @@ class AppState: ObservableObject { private func handleInstallError(_ error: Error, id: XcodeID) { let presentedError = Self.userFacingAuthenticationError(error) // Prevent setting the app state error if it is an invalid session, we will present the sign in view instead - if let error = error as? AuthenticationError, case .notAuthorized = error { + if Self.isUnauthorizedInstallError(error) { self.error = presentedError self.presentedAlert = .unauthenticated @@ -1318,6 +1403,23 @@ class AppState: ObservableObject { } } + static func isUnauthorizedInstallError(_ error: Error) -> Bool { + if let authenticationError = error as? AuthenticationError { + switch authenticationError { + case .notAuthorized, .badStatusCode(statusCode: 401, data: _, response: _): + return true + default: + break + } + } + + guard let networkError = error as? NetworkError, + case .non200StatusCode(statusCode: 401, data: _) = networkError else { + return false + } + return true + } + /// removes saved username and credentials stored in keychain private func clearLoginCredentials() { if let username = savedUsername { diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 003de731..5edf8685 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -539,4 +539,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..b1368141 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) + .foregroundStyle(.secondary) + } } } } diff --git a/Xcodes/Frontend/InfoPane/PlatformsView.swift b/Xcodes/Frontend/InfoPane/PlatformsView.swift index 8f277999..d169fbcb 100644 --- a/Xcodes/Frontend/InfoPane/PlatformsView.swift +++ b/Xcodes/Frontend/InfoPane/PlatformsView.swift @@ -18,27 +18,30 @@ struct PlatformsView: View { var body: some View { let builds = xcode.sdks?.allBuilds - let runtimes = (builds?.flatMap { sdkBuild in + let availableRuntimes = (builds?.flatMap { sdkBuild in appState.downloadableRuntimes.filter { - $0.sdkBuildUpdate?.contains(sdkBuild) ?? false && - ($0.architectures?.isEmpty ?? true || - ($0.architectures?.isUniversal ?? false && selectedVariant == .universal) || - ($0.architectures?.isAppleSilicon ?? false && selectedVariant == .appleSilicon) - ) + $0.sdkBuildUpdate?.contains(sdkBuild) ?? false } } ?? []).removingReleaseCandidateDisplayDuplicates(installedRuntimes: appState.installedRuntimes) - - let architectures = Set(runtimes.flatMap { $0.architectures ?? [] }) + + let availableVariants = ArchitectureVariant.allCases.filter { variant in + availableRuntimes.contains { $0.supports(variant) } + } + let displayedVariant = availableVariants.count == 1 ? availableVariants.first : selectedVariant + let runtimes = availableRuntimes.filter { runtime in + guard !(runtime.architectures?.isEmpty ?? true), let displayedVariant else { return true } + return runtime.supports(displayedVariant) + } VStack { HStack { Text("Platforms") .font(.title3) .frame(maxWidth: .infinity, alignment: .leading) - if !architectures.isEmpty { + if availableVariants.count > 1 { Spacer() Picker("Architecture", selection: $selectedVariant) { - ForEach(ArchitectureVariant.allCases, id: \.self) { arch in + ForEach(availableVariants, id: \.self) { arch in Label(variantLabel(for: arch), systemImage: arch.iconName) .tag(arch) } @@ -71,33 +74,33 @@ struct PlatformsView: View { @ViewBuilder func runtimeView(runtime: DownloadableRuntime) -> some View { - VStack(spacing: 10) { - HStack { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { runtime.icon() - Text("\(runtime.visibleIdentifier)") + Text(runtime.visibleIdentifier) .font(.headline) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + pathIfAvailable(xcode: xcode, runtime: runtime) + + if runtime.installState == .notInstalled, + appState.runtimeInstallPath(xcode: xcode, runtime: runtime) == nil { + DownloadRuntimeButton(runtime: runtime) + } + } + + HStack { ForEach(runtime.architectures ?? [], id: \.self) { architecture in TagView(text: architecture.displayString) + .fixedSize() } - - pathIfAvailable(xcode: xcode, runtime: runtime) - - if runtime.installState == .notInstalled { - // TODO: Update the downloadableRuntimes with the appropriate installState so we don't have to check path awkwardly - if appState.runtimeInstallPath(xcode: xcode, runtime: runtime) != nil { - EmptyView() - } else { - HStack { - Spacer() - DownloadRuntimeButton(runtime: runtime) - } - } - } - + Spacer() Text(runtime.downloadFileSizeString) .font(.subheadline) - .frame(width: 70, alignment: .trailing) + .foregroundStyle(.secondary) + .fixedSize() } if case let .installing(installationStep) = runtime.installState { @@ -138,6 +141,15 @@ private struct RuntimeDisplayKey: Hashable { } private extension DownloadableRuntime { + func supports(_ variant: ArchitectureVariant) -> Bool { + switch variant { + case .universal: + return architectures?.isUniversal ?? false + case .appleSilicon: + return architectures?.isAppleSilicon ?? false + } + } + var isReleaseCandidate: Bool { name.localizedCaseInsensitiveContains("Release Candidate") || identifier.localizedCaseInsensitiveContains("_rc") diff --git a/Xcodes/Frontend/MainWindow.swift b/Xcodes/Frontend/MainWindow.swift index 85774f0b..5ee6db78 100644 --- a/Xcodes/Frontend/MainWindow.swift +++ b/Xcodes/Frontend/MainWindow.swift @@ -24,7 +24,9 @@ struct MainWindow: View { .layoutPriority(1) .alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in Alert(title: Text(String(format: localizeString("Alert.Uninstall.Title"), xcode.description)), - message: Text("Alert.Uninstall.Message"), + message: Text(appState.usePrivilegedHelperForFileOperations + ? "Alert.Uninstall.PermanentMessage" + : "Alert.Uninstall.Message"), primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(xcode: xcode) }), secondaryButton: .cancel(Text("Cancel"))) } @@ -46,7 +48,16 @@ struct MainWindow: View { .toolbar { ToolbarItemGroup { Button(action: { appState.presentedSheet = .signIn }, label: { - Label("AppleAccount", systemImage: "person.circle") + Label { + if let authenticatedUserName { + Text(verbatim: authenticatedUserName) + } else { + Text("AppleAccount") + } + } icon: { + Image(systemName: "person.circle") + } + .labelStyle(.titleAndIcon) }) .help("ManageAppleAccount") .disabled(appState.isRestoringAuthenticationState) @@ -95,6 +106,13 @@ struct MainWindow: View { appState.allXcodes.first(where: { $0.id == selectedXcodeID }) } + private var authenticatedUserName: String? { + guard case .authenticated = appState.authenticationState else { + return nil + } + return appState.appleAccountDisplayName + } + private var subtitleText: Text { if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) { return Text("\(localizeString("UpdatedAt")) \(lastUpdated, style: .date) \(lastUpdated, style: .time)") @@ -179,7 +197,7 @@ struct MainWindow: View { title: Text("Alert.Install.Error.Title"), message: Text("Alert.Install.AuthError.Message"), primaryButton: .default( - Text("OK"), + Text("SignIn"), action: { appState.presentedSheet = .signIn } diff --git a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift index b92d9d95..27891997 100644 --- a/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/AdvancedPreferencePane.swift @@ -155,7 +155,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/Preferences/UpdatesPreferencePane.swift b/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift index eea7a878..7ffe7037 100644 --- a/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift @@ -110,21 +110,21 @@ class ObservableUpdater: ObservableObject { automaticallyChecksForUpdatesObservation = updater.observe( \.automaticallyChecksForUpdates, options: [.initial, .new, .old], - changeHandler: { [weak self] updater, change in + changeHandler: { [weak self] _, change in guard change.newValue != change.oldValue else { return } - let automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates Task { @MainActor [weak self] in - self?.automaticallyChecksForUpdates = automaticallyChecksForUpdates + guard let self else { return } + self.automaticallyChecksForUpdates = self.updater.automaticallyChecksForUpdates } } ) lastUpdateCheckDateObservation = updater.observe( \.lastUpdateCheckDate, options: [.initial, .new, .old], - changeHandler: { [weak self] updater, change in - let lastUpdateCheckDate = updater.lastUpdateCheckDate + changeHandler: { [weak self] _, _ in Task { @MainActor [weak self] in - self?.lastUpdateCheckDate = lastUpdateCheckDate + guard let self else { return } + self.lastUpdateCheckDate = self.updater.lastUpdateCheckDate } } ) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index b0d16613..5fec4f9f 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -40,32 +40,32 @@ struct XcodeListView: View { } var body: some View { - List(selection: $selectedXcodeID) { - if appState.enableGroupedXcodeList { - GroupedXcodeListContent( - xcodes: visibleXcodes, - allXcodes: appState.allXcodes, - selectedXcodeID: $selectedXcodeID, - appState: appState - ) - } else { - ForEach(visibleXcodes) { entry in - XcodeListViewRow( - xcode: entry.xcode, - selected: selectedXcodeID == entry.xcode.id, - appState: appState, - latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + VStack(spacing: 0) { + List(selection: $selectedXcodeID) { + if appState.enableGroupedXcodeList { + GroupedXcodeListContent( + xcodes: visibleXcodes, + allXcodes: appState.allXcodes, + selectedXcodeID: $selectedXcodeID, + appState: appState ) - .tag(entry.xcode.id) + } else { + ForEach(visibleXcodes) { entry in + XcodeListViewRow( + xcode: entry.xcode, + selected: selectedXcodeID == entry.xcode.id, + appState: appState, + latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + ) + .tag(entry.xcode.id) + } } } - } - .listStyle(.sidebar) - .safeAreaInset(edge: .bottom, spacing: 0) { + .listStyle(.sidebar) + + Divider() PlatformsPocket() - .padding(.horizontal) - .padding(.vertical, 8) - + .padding(10) } } } @@ -228,11 +228,13 @@ private struct XcodeVersionGroupRow: View { VStack(alignment: .leading, spacing: 2) { Text(verbatim: "Xcode \(displayName)") .font(.body.weight(indentation == 0 ? .medium : .regular)) + .fixedSize(horizontal: false, vertical: true) if let latestRelease { Text(verbatim: "Latest: \(latestRelease.description)") .font(.caption) .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } } @@ -244,7 +246,7 @@ private struct XcodeVersionGroupRow: View { .buttonStyle(.plain) selectControl - .padding(.trailing, 16) + .padding(.trailing, 4) installControl } .padding(.leading, indentation) @@ -287,7 +289,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 { @@ -318,7 +320,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.") } } @@ -346,7 +348,7 @@ private struct XcodeVersionGroupRow: View { .textCase(.uppercase) .buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false)) .help("InstallDescription") - case .installing: + case .installing, .uninstalling: EmptyView() } } @@ -385,34 +387,14 @@ struct PlatformsPocket: View { @SwiftUI.Environment(\.openWindow) private var openWindow var body: some View { - Button(action: { + Button { openWindow(id: "platforms") + } label: { + Label("PlatformsDescription", systemImage: "square.3.layers.3d") + .frame(maxWidth: .infinity, alignment: .leading) } - ) { - if #available(macOS 26.0, *) { - platformsLabel - .glassEffect(in: .rect(cornerRadius: 8, style: .continuous)) - } else { - platformsLabel - .background(.quaternary.opacity(0.75)) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - } - .buttonStyle(.plain) - } - - var platformsLabel: some View { - HStack(spacing: 5) { - Image(systemName: "square.3.layers.3d") - .font(.title3.weight(.medium)) - Text("PlatformsDescription") - Spacer() - } - .font(.body.weight(.medium)) - .padding(.horizontal) - .padding(.vertical, 12) - .contentShape(Rectangle()) + .buttonStyle(.borderless) + .controlSize(.regular) } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 9153d0a4..a278d5fc 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -24,6 +24,8 @@ struct XcodeListViewRow: View { HStack { Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") .font(.body) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) if !xcode.identicalBuildsForCurrentVariant.isEmpty { Image(systemName: "square.fill.on.square.fill") @@ -47,13 +49,16 @@ struct XcodeListViewRow: View { Text(verbatim: path.string) .font(.caption) .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(path.string) } } Spacer() selectControl(for: xcode) - .padding(.trailing, 16) + .padding(.trailing, 4) installControl(for: xcode) } .padding(.vertical, 4) @@ -63,6 +68,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 +126,7 @@ struct XcodeListViewRow: View { Image(systemName: "checkmark.circle.fill") .foregroundColor(.yellow) .help(staleSelectedHelpText) - case .installing: + case .installing, .uninstalling: EmptyView() } } else if xcode.selected { @@ -168,6 +175,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) + .foregroundStyle(.secondary) + } } } @@ -180,7 +195,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/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index 1687a178..fade5414 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -1,4 +1,4 @@ -{\rtf1\ansi\ansicpg1252\cocoartf2869 +{\rtf1\ansi\ansicpg1252\cocoartf2907 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 .SFNS-Regular;} {\colortbl;\red255\green255\blue255;} {\*\expandedcolortbl;;} @@ -1090,33 +1090,6 @@ SOFTWARE.\ \ \ -\fs34 data\ -\ - -\fs26 MIT License\ -\ -Copyright (c) 2018 \ -\ -Permission is hereby granted, free of charge, to any person obtaining a copy\ -of this software and associated documentation files (the "Software"), to deal\ -in the Software without restriction, including without limitation the rights\ -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ -copies of the Software, and to permit persons to whom the Software is\ -furnished to do so, subject to the following conditions:\ -\ -The above copyright notice and this permission notice shall be included in all\ -copies or substantial portions of the Software.\ -\ -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ -SOFTWARE.\ -\ -\ - \fs34 Sparkle\ \ @@ -1178,7 +1151,7 @@ POSSIBILITY OF SUCH DAMAGE.\ \ --\ \ -sais.c and sais.c, from sais-lite (2010/08/07) :\ +sais.c and sais.h, from sais-lite (2010/08/07) :\ \ The sais-lite copyright is as follows:\ \ diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index f973f248..73aa6a15 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -4937,6 +4937,136 @@ } } }, + "Alert.Uninstall.PermanentMessage" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "سيُحذف نهائيًا ولن يُنقل إلى سلة المهملات." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "S’eliminarà permanentment i no es mourà a la paperera." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xcode wird dauerhaft gelöscht und nicht in den Papierkorb verschoben." + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Θα διαγραφεί οριστικά και δεν θα μεταφερθεί στον Κάδο." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "It will be permanently deleted and will not be moved to the Trash." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se eliminará permanentemente y no se moverá a la Papelera." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se poistetaan pysyvästi eikä sitä siirretä roskakoriin." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il sera supprimé définitivement et ne sera pas placé dans la Corbeille." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इसे स्थायी रूप से मिटा दिया जाएगा और ट्रैश में नहीं भेजा जाएगा।" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verrà eliminato definitivamente e non verrà spostato nel Cestino." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ゴミ箱に移動せず、完全に削除されます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "휴지통으로 이동하지 않고 영구적으로 삭제됩니다." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xcode wordt permanent verwijderd en niet naar de prullenmand verplaatst." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zostanie trwale usunięty i nie zostanie przeniesiony do Kosza." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ele será excluído permanentemente e não será movido para o Lixo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xcode будет удалён безвозвратно, без перемещения в Корзину." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รายการนี้จะถูกลบอย่างถาวรและจะไม่ถูกย้ายไปยังถังขยะ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kalıcı olarak silinecek ve Çöp Sepeti’ne taşınmayacak." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xcode буде видалено назавжди, без переміщення до Смітника." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "它将被永久删除,不会移到废纸篓。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "它將被永久刪除,不會移到垃圾桶。" + } + } + } + }, "Alert.Uninstall.Title" : { "comment" : "Uninstall", "extractionState" : "manual", @@ -25766,6 +25896,136 @@ } } }, + "Uninstalling" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جارٍ إلغاء التثبيت" + } + }, + "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" : "Удаление" + } + }, + "th" : { + "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" : { @@ -26278,6 +26538,136 @@ } } }, + "UsePrivilegedHelperForFileOperations" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تنفيذ عمليات الملفات باستخدام المساعد ذي الصلاحيات المرتفعة" + } + }, + "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" : "Выполнять файловые операции с помощью привилегированного помощника" + } + }, + "th" : { + "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 6849810a..e5312062 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -27,6 +27,45 @@ private final class TestLockedBox: Sendable { } } +private final class MockURLProtocol: URLProtocol, @unchecked Sendable { + typealias Handler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse) + + private nonisolated(unsafe) static var handler: Handler? + + static func session(handler: @escaping Handler) -> URLSession { + self.handler = handler + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: configuration) + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (data, response) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + @MainActor class AppStateTests: XCTestCase { var subject: AppState! @@ -40,7 +79,7 @@ class AppStateTests: XCTestCase { func test_NewlyAvailableXcodes_IgnoresInitialLoad() { let initial = makeAvailableXcode(version: "27.0.0") - XCTAssertTrue(AppState.newlyAvailableXcodes(old: [], new: [initial]).isEmpty) + XCTAssertTrue(AppState.newlyAvailableXcodes(oldXcodes: [], newXcodes: [initial]).isEmpty) } func test_NewlyAvailableXcodes_DetectsIdentityWhenCountDoesNotGrow() { @@ -48,7 +87,7 @@ class AppStateTests: XCTestCase { let retained = makeAvailableXcode(version: "26.5.0") let added = makeAvailableXcode(version: "27.0.0") - let result = AppState.newlyAvailableXcodes(old: [removed, retained], new: [retained, added]) + let result = AppState.newlyAvailableXcodes(oldXcodes: [removed, retained], newXcodes: [retained, added]) XCTAssertEqual(result.map(\.xcodeID), [added.xcodeID]) } @@ -57,7 +96,7 @@ class AppStateTests: XCTestCase { let existing = makeAvailableXcode(version: "27.0.0", filename: "Xcode.xip") let duplicate = makeAvailableXcode(version: "27.0.0", filename: "Xcode-copy.xip") - XCTAssertTrue(AppState.newlyAvailableXcodes(old: [existing], new: [existing, duplicate]).isEmpty) + XCTAssertTrue(AppState.newlyAvailableXcodes(oldXcodes: [existing], newXcodes: [existing, duplicate]).isEmpty) } func test_NewlyAvailableXcodes_TreatsArchitectureAsIdentity() { @@ -70,7 +109,7 @@ class AppStateTests: XCTestCase { architectures: [.arm64] ) - let result = AppState.newlyAvailableXcodes(old: [universal], new: [universal, appleSilicon]) + let result = AppState.newlyAvailableXcodes(oldXcodes: [universal], newXcodes: [universal, appleSilicon]) XCTAssertEqual(result.map(\.xcodeID), [appleSilicon.xcodeID]) } @@ -86,14 +125,6 @@ class AppStateTests: XCTestCase { ) } - func test_InstallNotificationTitle_DoesNotDuplicateMajorVersion() { - XCTAssertEqual( - AppState.installNotificationTitle(for: Version("27.0.0-Beta.4")!), - "27.0 Beta 4" - ) - XCTAssertEqual(AppState.installNotificationTitle(for: Version("26.5.0")!), "26.5") - } - func test_CopyPath_WritesOnlyPlainText() throws { let path = try XCTUnwrap(Path("/Applications/Xcode 27.app")) let xcode = Xcode( @@ -191,6 +222,94 @@ class AppStateTests: XCTestCase { } } + func test_AuthenticationPolicy_HandlesAuthenticationHTTPStatusCodes() async throws { + for statusCode in [502, 503, 504, 401] { + let attempts = TestLockedBox(0) + let response = try XCTUnwrap(HTTPURLResponse( + url: URL(string: "https://idmsa.apple.com/appleauth/auth")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )) + let failure = AuthenticationError.badStatusCode(statusCode: statusCode, data: nil, response: response) + + do { + let _: String = try await AuthenticationRequestPolicy(delayBeforeRetry: .zero).perform { + attempts.withValue { $0 += 1 } + throw failure + } + XCTFail("Expected authentication HTTP error") + } catch { + if statusCode == 401 { + XCTAssertEqual(error as? AuthenticationError, failure) + XCTAssertEqual(attempts.read { $0 }, 1) + } else { + XCTAssertEqual(error as? AuthenticationRequestError, .serviceTemporarilyUnavailable(statusCode: statusCode)) + XCTAssertEqual(attempts.read { $0 }, 3) + } + XCTAssertFalse(AuthenticationRequestPolicy.shouldClearCredentials(after: error)) + } + } + } + + func test_AuthenticationPolicy_RetriesTransientServiceKeyFailureUntilSuccess() async throws { + let attempts = TestLockedBox(0) + let failure = AuthenticationError.serviceKeyResolutionFailed(attempts: [ + .init(source: .appStoreConnectSignOut, failure: .missingRedirect), + .init(source: .olympus, failure: .httpStatus(code: 503, bodyPreview: nil)) + ]) + + let result = try await AuthenticationRequestPolicy(delayBeforeRetry: .zero).perform { + let attempt = attempts.withValue { $0 += 1; return $0 } + if attempt < 3 { throw failure } + return "authenticated" + } + + XCTAssertEqual(result, "authenticated") + XCTAssertEqual(attempts.read { $0 }, 3) + } + + func test_AuthenticationPolicy_MapsExhaustedServiceKeyFailures() async { + for statusCode in [502, 503, 504] { + let attempts = TestLockedBox(0) + let failure = AuthenticationError.serviceKeyResolutionFailed(attempts: [ + .init(source: .appStoreConnectSignOut, failure: .httpStatus(code: statusCode, bodyPreview: nil)), + .init(source: .olympus, failure: .missingKey) + ]) + + do { + let _: String = try await AuthenticationRequestPolicy(delayBeforeRetry: .zero).perform { + attempts.withValue { $0 += 1 } + throw failure + } + XCTFail("Expected temporary service error") + } catch { + XCTAssertEqual(error as? AuthenticationRequestError, .serviceTemporarilyUnavailable(statusCode: statusCode)) + XCTAssertEqual(attempts.read { $0 }, 3) + XCTAssertFalse(AuthenticationRequestPolicy.shouldClearCredentials(after: error)) + } + } + } + + func test_AuthenticationPolicy_DoesNotRetryServiceKeyParsingFailures() async { + let attempts = TestLockedBox(0) + let failure = AuthenticationError.serviceKeyResolutionFailed(attempts: [ + .init(source: .appStoreConnectSignOut, failure: .invalidRedirect), + .init(source: .olympus, failure: .missingKey) + ]) + + do { + let _: String = try await AuthenticationRequestPolicy(delayBeforeRetry: .zero).perform { + attempts.withValue { $0 += 1 } + throw failure + } + XCTFail("Expected service-key parsing failure") + } catch { + XCTAssertEqual(error as? AuthenticationError, failure) + XCTAssertEqual(attempts.read { $0 }, 1) + } + } + func test_AuthenticationPolicy_DoesNotClearCredentialsFor503() { XCTAssertFalse( AuthenticationRequestPolicy.shouldClearCredentials( @@ -712,6 +831,48 @@ class AppStateTests: XCTestCase { from: data ) } + + func test_InstallError_Network401IsUnauthorized() { + let error = NetworkError.non200StatusCode(statusCode: 401, data: Data()) + + XCTAssertTrue(AppState.isUnauthorizedInstallError(error)) + } + + func test_InstallError_OtherNetworkStatusIsNotUnauthorized() { + let error = NetworkError.non200StatusCode(statusCode: 500, data: Data()) + + XCTAssertFalse(AppState.isUnauthorizedInstallError(error)) + } + + func test_ChoosePhoneNumberForSMS_WithOneTrustedPhoneNumberRequestsSMS() async throws { + let trustedPhoneNumber = AuthOptionsResponse.TrustedPhoneNumber(id: 7, numberWithDialCode: "(•••) •••-••90") + let authOptions = AuthOptionsResponse( + trustedPhoneNumbers: [trustedPhoneNumber], + trustedDevices: nil, + securityCode: .init(length: 6) + ) + let sessionData = AppleSessionData(serviceKey: "service-key", sessionID: "session-id", scnt: "scnt") + Current.network = Network(session: MockURLProtocol.session { request in + XCTAssertEqual(request.url?.absoluteString, "https://idmsa.apple.com/appleauth/auth/verify/phone") + XCTAssertEqual(request.httpMethod, "PUT") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Apple-ID-Session-Id"), "session-id") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Apple-Widget-Key"), "service-key") + XCTAssertEqual(request.value(forHTTPHeaderField: "scnt"), "scnt") + return (Data(), HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!) + }) + + subject.choosePhoneNumberForSMS(authOptions: authOptions, sessionData: sessionData) + for _ in 0..<100 where subject.presentedSheet == nil && subject.authError == nil { + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertNil(subject.authError) + guard case let .twoFactor(secondFactorData) = subject.presentedSheet else { + XCTFail("Expected the SMS code-entry sheet to be presented") + return + } + XCTAssertEqual(secondFactorData.option, .smsSent(trustedPhoneNumber)) + } func test_ParseCertificateInfo_Succeeds() throws { let sampleRawInfo = """ @@ -927,7 +1088,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-") @@ -942,13 +1103,13 @@ 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) } - func test_CreateSymbolicLink_ReplacesBrokenStableLink() throws { + func test_CreateSymbolicLink_ReplacesBrokenStableLink() async throws { let installDirectory = try XCTUnwrap(Path( NSTemporaryDirectory() .appending("XcodesAppStateTests-") @@ -967,13 +1128,13 @@ 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) } - func test_CreateSymbolicLink_ReplacesBrokenBetaLink() throws { + func test_CreateSymbolicLink_ReplacesBrokenBetaLink() async throws { let installDirectory = try XCTUnwrap(Path( NSTemporaryDirectory() .appending("XcodesAppStateTests-") @@ -992,12 +1153,75 @@ class AppStateTests: XCTestCase { key == "installPath" ? installDirectory.string : nil } - subject.createSymbolicLink(to: installedXcodePath, isBeta: true) + await subject.createSymbolicLink(to: installedXcodePath, isBeta: true) let destination = try FileManager.default.destinationOfSymbolicLink(atPath: symlinkPath.string) XCTAssertEqual(destination, installedXcodePath.string) } + func test_CreateSymbolicLink_PrivilegedBetaLinkUsesHelper() async throws { + let installDirectory = try XCTUnwrap(Path(NSTemporaryDirectory().appending(UUID().uuidString))) + let installedXcodePath = installDirectory/"Xcode-27.0-Beta.5.app" + let calls = TestLockedBox<[[String]]>([]) + Current.defaults.string = { key in key == "installPath" ? installDirectory.string : nil } + Current.defaults.bool = { key in key == PreferenceKey.usePrivilegeHelperForFileOperations.rawValue } + Current.helper.checkIfLatestHelperIsInstalledAsync = { true } + Current.helper.createSymbolicLinkAsync = { source, destination in + calls.withValue { $0.append([source, destination]) } + } + + await subject.createSymbolicLink(to: installedXcodePath, isBeta: true) + + XCTAssertEqual(calls.read { $0 }, [[installedXcodePath.string, (installDirectory/"Xcode-Beta.app").string]]) + XCTAssertNil(subject.error) + } + + func test_CreateSymbolicLink_PrivilegedLinkDoesNotReplaceRealApp() async throws { + let installDirectory = try XCTUnwrap(Path(NSTemporaryDirectory().appending(UUID().uuidString))) + let destination = installDirectory/"Xcode.app" + try FileManager.default.createDirectory(at: destination.url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: installDirectory.url) } + let calledHelper = TestLockedBox(false) + Current.defaults.string = { key in key == "installPath" ? installDirectory.string : nil } + Current.defaults.bool = { key in key == PreferenceKey.usePrivilegeHelperForFileOperations.rawValue } + Current.helper.createSymbolicLinkAsync = { _, _ in calledHelper.withValue { $0 = true } } + + await subject.createSymbolicLink(to: installDirectory/"Xcode-27.0.app") + + XCTAssertFalse(calledHelper.read { $0 }) + XCTAssertEqual(subject.error as? XcodeSelectionFilesystemError, .destinationExistsAndIsNotSymlink(destination)) + XCTAssertTrue(FileManager.default.fileExists(atPath: destination.string)) + } + + func test_CreateSymbolicLink_HelperCancellationDoesNotPresentError() async throws { + let directory = try XCTUnwrap(Path(NSTemporaryDirectory().appending(UUID().uuidString))) + Current.defaults.string = { key in key == "installPath" ? directory.string : nil } + Current.defaults.bool = { key in key == PreferenceKey.usePrivilegeHelperForFileOperations.rawValue } + Current.helper.checkIfLatestHelperIsInstalledAsync = { true } + Current.helper.createSymbolicLinkAsync = { _, _ in throw CancellationError() } + + await subject.createSymbolicLink(to: directory/"Xcode-27.0.app") + + XCTAssertNil(subject.error) + XCTAssertNil(subject.presentedAlert) + } + + func test_RenameToXcode_HelperCancellationDoesNotPresentError() async throws { + let directory = try XCTUnwrap(Path(NSTemporaryDirectory().appending(UUID().uuidString))) + let xcode = Xcode(version: Version("27.0.0")!, installState: .installed(directory/"Xcode-27.0.app"), selected: false, icon: nil) + Current.defaults.string = { key in key == "installPath" ? directory.string : nil } + Current.defaults.bool = { key in key == PreferenceKey.usePrivilegeHelperForFileOperations.rawValue } + Current.files.fileExistsAtPath = { _ in false } + Current.helper.checkIfLatestHelperIsInstalledAsync = { true } + Current.helper.renameAsync = { _, _ in throw CancellationError() } + + let destination = await subject.renameToXcode(xcode: xcode) + + XCTAssertNil(destination) + XCTAssertNil(subject.error) + XCTAssertNil(subject.presentedAlert) + } + func test_AutomaticSymbolicLink_ReleaseUsesStableLinkOnly() { subject.createSymLinkOnSelect = true subject.createBetaSymLinkOnSelect = true @@ -1166,6 +1390,67 @@ class AppStateTests: XCTestCase { XCTAssertFalse(didTryToTrashItem.read { $0 }) } + func test_Uninstall_CancelledPreviousItemClearsSpinner() async throws { + try await verifyCancelledUninstallState(repeatsSameItem: false) + } + + func test_Uninstall_CancelledSameItemPreservesReplacementSpinner() async throws { + try await verifyCancelledUninstallState(repeatsSameItem: true) + } + + private func verifyCancelledUninstallState(repeatsSameItem: Bool) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let path = try XCTUnwrap(Path(url: directory.appendingPathComponent("Xcode-0.0.0.app", isDirectory: true))) + let secondPath = try XCTUnwrap(Path(url: directory.appendingPathComponent("Xcode-0.0.1.app", isDirectory: true))) + try FileManager.default.createDirectory(at: path.url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: secondPath.url, withIntermediateDirectories: true) + let first = Xcode(version: Version("0.0.0")!, installState: .installed(path), selected: false, icon: nil) + let second = repeatsSameItem ? first : Xcode( + version: Version("0.0.1")!, + installState: .installed(secondPath), + selected: false, + icon: nil + ) + subject.allXcodes = repeatsSameItem ? [first] : [first, second] + Current.defaults.bool = { key in key == PreferenceKey.usePrivilegeHelperForFileOperations.rawValue } + Current.helper.checkIfLatestHelperIsInstalledAsync = { true } + let continuations = TestLockedBox<[CheckedContinuation]>([]) + let firstStarted = expectation(description: "first uninstall reached helper") + let secondStarted = expectation(description: "second uninstall reached helper") + Current.helper.removeAsync = { _ in + try await withCheckedThrowingContinuation { continuation in + let count = continuations.withValue { $0.append(continuation); return $0.count } + if count == 1 { firstStarted.fulfill() } + if count == 2 { secondStarted.fulfill() } + } + } + + subject.uninstall(xcode: first) + let firstTask = try XCTUnwrap(subject.uninstallTask) + await fulfillment(of: [firstStarted], timeout: 5) + XCTAssertEqual(continuations.read { $0.count }, 1) + subject.uninstall(xcode: second) + let secondTask = try XCTUnwrap(subject.uninstallTask) + await fulfillment(of: [secondStarted], timeout: 5) + let pending = continuations.read { $0 } + guard pending.count == 2 else { + pending.forEach { $0.resume(throwing: CancellationError()) } + return XCTFail("Expected both uninstall operations to reach helper") + } + pending[0].resume(throwing: CancellationError()) + await firstTask.value + + XCTAssertEqual(subject.allXcodes.first { $0.id == first.id }?.installState, + repeatsSameItem ? .uninstalling(path) : .installed(path)) + XCTAssertEqual(subject.allXcodes.first { $0.id == second.id }?.installState, .uninstalling(second.installedPath!)) + + pending[1].resume(throwing: CancellationError()) + await secondTask.value + XCTAssertNil(subject.uninstallTask) + XCTAssertNil(subject.uninstallXcodeID) + } + func test_Uninstall_RefreshesInstalledXcodeList() async throws { let installedPath = try XCTUnwrap(Path("/Applications/Xcode-0.0.0.app")) let version = try XCTUnwrap(Version("0.0.0")) @@ -1669,6 +1954,35 @@ class AppStateTests: XCTestCase { XCTAssertTrue(fileManager.fileExists(atPath: archive.path)) } + func test_RestoreAuthenticationState_UsesPersistedSession() async throws { + let appleSession = try JSONDecoder().decode( + AppleSession.self, + from: Data(#"{"user":{"fullName":"Jane Developer"}}"#.utf8) + ) + let expectedState = AuthenticationState.authenticated(appleSession) + Current.defaults.string = { key in + key == "username" ? "jane@example.com" : nil + } + Current.network.validateSessionAsync = { expectedState } + + await subject.restoreAuthenticationStateAsync() + + XCTAssertEqual(subject.authenticationState, expectedState) + } + + func test_RestoreAuthenticationState_ValidatesSessionWithoutSavedUsername() async throws { + let didValidate = TestLockedBox(false) + Current.network.validateSessionAsync = { + didValidate.withValue { $0 = true } + return .unauthenticated + } + + await subject.restoreAuthenticationStateAsync() + + XCTAssertTrue(didValidate.read { $0 }) + XCTAssertEqual(subject.authenticationState, .unauthenticated) + } + func test_DownloadRuntimeViaXcodeBuild_ClearsRuntimeTaskWhenComplete() async throws { let runtime = try Self.downloadableRuntime() subject.downloadableRuntimes = [runtime] @@ -2275,6 +2589,22 @@ class AppStateTests: XCTestCase { } } + func test_InstallNotificationTitle_DoesNotDuplicateMajorVersion() { + XCTAssertEqual( + AppState.installNotificationTitle(for: Version(major: 27, minor: 0, patch: 0, prereleaseIdentifiers: ["beta", "4"])), + "27.0 Beta 4" + ) + XCTAssertEqual( + AppState.installNotificationTitle(for: Version(major: 26, minor: 5, patch: 0)), + "26.5" + ) + // Stable release with patch + XCTAssertEqual( + AppState.installNotificationTitle(for: Version(major: 10, minor: 2, patch: 1)), + "10.2.1" + ) + } + private func recordAllXcodeInstallStates(during operation: () async throws -> Void) async throws -> [[XcodeInstallState]] { var states: [[XcodeInstallState]] = [] var cancellable: AnyCancellable? diff --git a/XcodesTests/Environment+Mock.swift b/XcodesTests/Environment+Mock.swift index 43e12c9e..58a8483a 100644 --- a/XcodesTests/Environment+Mock.swift +++ b/XcodesTests/Environment+Mock.swift @@ -136,7 +136,11 @@ extension Helper { devToolsSecurityEnableAsync: { }, addStaffToDevelopersGroupAsync: { }, acceptXcodeLicenseAsync: { _ in }, - runFirstLaunchAsync: { _ in } + runFirstLaunchAsync: { _ in }, + moveAppAsync: { _, _ in }, + createSymbolicLinkAsync: { _, _ in }, + renameAsync: { _, _ in }, + removeAsync: { _ in } ) } } diff --git a/XcodesTests/FileOperationsTests.swift b/XcodesTests/FileOperationsTests.swift new file mode 100644 index 00000000..eae74f03 --- /dev/null +++ b/XcodesTests/FileOperationsTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest +@testable import Xcodes + +final class FileOperationsTests: XCTestCase { + func testRejectsRelativePathsBeforeFileOperations() throws { + try withTemporaryDirectory { directory in + let absolute = directory.appendingPathComponent("Xcode.app").path + let relative = "xcodes-test-\(UUID().uuidString)" + for operation in [FileOperations.moveApp, FileOperations.createSymbolicLink, FileOperations.rename] { + var error: Error? + operation(relative, absolute) { error = $0 } + XCTAssertEqual((error as? XPCDelegateError)?.code, .invalidSourcePath) + + operation(directory.path, relative) { error = $0 } + XCTAssertEqual((error as? XPCDelegateError)?.code, .invalidDestinationPath) + } + + var error: Error? + FileOperations.remove(path: relative) { error = $0 } + XCTAssertEqual((error as? XPCDelegateError)?.code, .invalidSourcePath) + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.path)) + } + } + + func testCreatesNewSymbolicLink() throws { + try withTemporaryDirectory { directory in + let destination = directory.appendingPathComponent("Xcode.app") + var error: Error? + FileOperations.createSymbolicLink(source: "/new-xcode", destination: destination.path) { error = $0 } + XCTAssertNil(error) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: destination.path), "/new-xcode") + } + } + + func testReplacesDanglingSymbolicLink() throws { + try withTemporaryDirectory { directory in + let destination = directory.appendingPathComponent("Xcode.app") + try FileManager.default.createSymbolicLink(atPath: destination.path, withDestinationPath: "/missing-old-xcode") + + var error: Error? + FileOperations.createSymbolicLink(source: "/new-xcode", destination: destination.path) { error = $0 } + + XCTAssertNil(error) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: destination.path), "/new-xcode") + } + } + + func testReplacesExistingSymbolicLinkWithoutRemovingTarget() throws { + try withTemporaryDirectory { directory in + let target = directory.appendingPathComponent("OldXcode.app") + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: false) + let destination = directory.appendingPathComponent("Xcode.app") + try FileManager.default.createSymbolicLink(at: destination, withDestinationURL: target) + + var error: Error? + FileOperations.createSymbolicLink(source: "/new-xcode", destination: destination.path) { error = $0 } + + XCTAssertNil(error) + XCTAssertTrue(FileManager.default.fileExists(atPath: target.path)) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: destination.path), "/new-xcode") + } + } + + func testDoesNotReplaceRealDirectoryOrFile() throws { + try withTemporaryDirectory { directory in + let app = directory.appendingPathComponent("Xcode.app") + try FileManager.default.createDirectory(at: app, withIntermediateDirectories: false) + let file = directory.appendingPathComponent("file") + let contents = Data("keep me".utf8) + try contents.write(to: file) + + for destination in [app, file] { + var error: Error? + FileOperations.createSymbolicLink(source: "/new-xcode", destination: destination.path) { error = $0 } + XCTAssertEqual((error as? XPCDelegateError)?.code, .destinationIsNotASymbolicLink) + } + + XCTAssertTrue(FileManager.default.fileExists(atPath: app.path)) + XCTAssertEqual(try Data(contentsOf: file), contents) + } + } + + private func withTemporaryDirectory(_ body: (URL) throws -> Void) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: directory) } + try body(directory) + } +} diff --git a/XcodesTests/NewVersionNotificationTests.swift b/XcodesTests/NewVersionNotificationTests.swift new file mode 100644 index 00000000..1057ff29 --- /dev/null +++ b/XcodesTests/NewVersionNotificationTests.swift @@ -0,0 +1,145 @@ +import Version +import XcodesKit +@testable import Xcodes +import XCTest + +/// Regression tests for the "New Xcode version available" notification trigger. +/// +/// The decision to notify is extracted into the pure helper +/// `AppState.newlyAvailableXcodes(oldXcodes:newXcodes:)` and asserted directly here. +/// No notification spying and no `Current = .mock` are required: the helper is the single +/// source of truth that the `availableXcodes.willSet` predicate consults. +@MainActor +final class NewVersionNotificationTests: XCTestCase { + // MARK: - Fixtures + + /// Builds an `AvailableXcode` for `version` with a distinct download URL, mirroring the + /// construction style in `AppStateUpdateTests.swift` (`Version("0.0.0")!`, three components). + /// `architectures` defaults to `nil`. + private func makeAvailableXcode( + version: String, + architectures: [Architecture]? = nil, + urlSuffix: String = "" + ) -> AvailableXcode { + AvailableXcode( + version: Version(version)!, + url: URL(string: "https://example.com/Xcode-\(version)-\(urlSuffix).xip")!, + filename: "Xcode-\(version)-\(urlSuffix).xip", + releaseDate: nil, + architectures: architectures + ) + } + + /// The set of stable identities (`xcodeID`) for the given available Xcodes — an + /// order-independent comparison keyed on version + architecture, not array position. + private func identities(_ xcodes: [AvailableXcode]) -> Set { + Set(xcodes.map(\.xcodeID)) + } + + // MARK: - Initial-load suppression + + func testInitialLoadDoesNotNotify() { + // First population (empty -> populated) is the initial cache load, NOT "a new version + // since you last looked", so the result must be empty even though the array grew. + let old: [AvailableXcode] = [] + let new = [makeAvailableXcode(version: "15.0.0")] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertTrue(result.isEmpty, "Initial population must not be treated as a new version") + } + + // MARK: - True positive — one genuinely new version + + func testGenuinelyNewVersionIsReported() { + let existingA = makeAvailableXcode(version: "15.0.0") + let existingB = makeAvailableXcode(version: "15.1.0") + let added = makeAvailableXcode(version: "16.0.0") + let old = [existingA, existingB] + let new = [existingA, existingB, added] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertEqual(identities(result), [added.xcodeID]) + } + + // MARK: - FALSE NEGATIVE (the bug) — new version added AND old version removed, count unchanged + + func testNewVersionAddedAndOldRemovedIsReported() { + // The OLD count-based predicate saw "no growth" (2 -> 2) here and MISSED version C. + // A data source can drop an obsolete beta row the same refresh it adds the new one. + let existing = makeAvailableXcode(version: "15.1.0") + let removed = makeAvailableXcode(version: "15.0.0") + let added = makeAvailableXcode(version: "16.0.0") + let old = [removed, existing] + let new = [existing, added] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertEqual(identities(result), [added.xcodeID], "A genuinely new version must be reported even when the count is unchanged") + } + + // MARK: - FALSE NEGATIVE variant — a new version appears while the list shrinks + + func testNewVersionReportedEvenWhenListShrinks() { + // A genuinely new version can appear even when the overall count DECREASES: a data source + // prunes older rows the same refresh it surfaces the newest (3 -> 2 here). The OLD + // count-based predicate saw "no growth" and MISSED D; the identity-based helper reports it. + let droppedA = makeAvailableXcode(version: "15.0.0") + let droppedB = makeAvailableXcode(version: "15.1.0") + let kept = makeAvailableXcode(version: "16.0.0") + let added = makeAvailableXcode(version: "17.0.0") + let old = [droppedA, droppedB, kept] + let new = [kept, added] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertEqual(identities(result), [added.xcodeID], "A new version must be reported even when the list shrinks") + } + + // MARK: - FALSE POSITIVE (the bug) — count grows with NO new identity + + func testDuplicateIdentityIsNotNew() { + // The OLD count-based predicate FIRED here (1 -> 2); the fixed helper correctly does not. + // The feed returns a row whose xcodeID was already present (a duplicate, same version). + let present = makeAvailableXcode(version: "15.0.0") + let duplicate = makeAvailableXcode(version: "15.0.0", urlSuffix: "duplicate") // same version -> same xcodeID + let old = [present] + let new = [present, duplicate] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertTrue(result.isEmpty, "A duplicate of an existing xcodeID must not be treated as new") + } + + // MARK: - Identical list + + func testIdenticalListIsNotNew() { + let a = makeAvailableXcode(version: "15.0.0") + let b = makeAvailableXcode(version: "15.1.0") + let old = [a, b] + let new = [a, b] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertTrue(result.isEmpty) + } + + // MARK: - Identity is version + architecture, not version alone + + func testSameVersionDifferentArchitectureIsDistinctIdentity() { + // Two AvailableXcodes with the same version but different architectures have DIFFERENT + // xcodeIDs (XcodeID.id = version.description + architectures), so an Apple-Silicon-only + // release is distinct from a Universal release of the same version. + let universal: [Architecture] = [.arm64, .x86_64] + let appleSilicon: [Architecture] = [.arm64] + let universalRelease = makeAvailableXcode(version: "15.0.0", architectures: universal) + let appleSiliconRelease = makeAvailableXcode(version: "15.0.0", architectures: appleSilicon) + let old = [universalRelease] + let new = [universalRelease, appleSiliconRelease] + + let result = AppState.newlyAvailableXcodes(oldXcodes: old, newXcodes: new) + + XCTAssertEqual(identities(result), [appleSiliconRelease.xcodeID]) + } +} diff --git a/XcodesTests/SignInCredentialsViewTests.swift b/XcodesTests/SignInCredentialsViewTests.swift new file mode 100644 index 00000000..f7c3b0e2 --- /dev/null +++ b/XcodesTests/SignInCredentialsViewTests.swift @@ -0,0 +1,28 @@ +import Cocoa +import SwiftUI +import XCTest + +@testable import Xcodes + +@MainActor +final class SignInCredentialsViewTests: XCTestCase { + func test_MarksCredentialFieldsForAutofill() { + let appState = AppState() + let hostingView = NSHostingView(rootView: SignInCredentialsView().environmentObject(appState)) + hostingView.frame = NSRect(x: 0, y: 0, width: 420, height: 180) + hostingView.layoutSubtreeIfNeeded() + + let editableTextFields = hostingView.recursiveSubviews(ofType: NSTextField.self) + .filter(\.isEditable) + let contentTypes = Set(editableTextFields.compactMap(\.contentType)) + + XCTAssertTrue(contentTypes.contains(.username)) + XCTAssertTrue(contentTypes.contains(.password)) + } +} + +private extension NSView { + func recursiveSubviews(ofType type: T.Type) -> [T] { + subviews.compactMap { $0 as? T } + subviews.flatMap { $0.recursiveSubviews(ofType: type) } + } +} diff --git a/dev.jacobcx.Xcodes.Helper/XPCDelegate.swift b/dev.jacobcx.Xcodes.Helper/XPCDelegate.swift index eb9fe069..2e5a4213 100644 --- a/dev.jacobcx.Xcodes.Helper/XPCDelegate.swift +++ b/dev.jacobcx.Xcodes.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." - ] - } - } -} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 65646cb0..3b76923c 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -46,8 +46,8 @@ Configure GitHub Actions to allow selected pinned actions. Protect the `v*` tag 3. Commit the version change. Create an annotated tag whose values exactly match the project, for example: ```sh - git tag -a v4.0.5b48 -m 'Xcodes 4.0.5 build 48' - git push origin v4.0.5b48 + git tag -a v4.1.1b49 -m 'Xcodes 4.1.1 build 49' + git push origin v4.1.1b49 ``` 4. Approve the protected `release` environment deployment after confirming the tag and commit. @@ -56,16 +56,16 @@ Configure GitHub Actions to allow selected pinned actions. Protect the `v*` tag Tags using this contract are stable releases. The workflow does not infer prerelease status from the build-number suffix. Add an explicit, reviewed tag grammar and matching appcast policy before publishing prereleases. -For a manual rerun, `workflow_dispatch` reruns must use `--ref v4.0.5b48` and the same `release_tag`; selecting a branch is rejected before credential files are written: +For a manual rerun, `workflow_dispatch` reruns must use `--ref v4.1.1b49` and the same `release_tag`; selecting a branch is rejected before credential files are written: ```sh -gh workflow run release.yml --ref v4.0.5b48 -f release_tag=v4.0.5b48 +gh workflow run release.yml --ref v4.1.1b49 -f release_tag=v4.1.1b49 ``` A manual appcast rerun must likewise use the published tag for both the workflow ref and input (`--ref -f tag=`): ```sh -gh workflow run appcast.yml --ref v4.0.5b48 -f tag=v4.0.5b48 +gh workflow run appcast.yml --ref v4.1.1b49 -f tag=v4.1.1b49 ``` ## Local packaging dry run @@ -77,8 +77,8 @@ export NOTARY_KEY_ID='' export NOTARY_ISSUER_ID='' export NOTARY_KEY_PATH='/absolute/path/AuthKey_ID.p8' export SPARKLE_PRIVATE_KEY_FILE='/absolute/path/sparkle-private-key' -bash Scripts/package_release.sh v4.0.5b48 -bash Scripts/validate_release_artifacts.sh Product/v4.0.5b48 v4.0.5b48 +bash Scripts/package_release.sh v4.1.1b49 +bash Scripts/validate_release_artifacts.sh Product/v4.1.1b49 v4.1.1b49 ``` Do not paste secret values into shell history on shared machines. Prefer a local secret manager or a short-lived protected shell environment.