From a7d500ade2150836e566b1dcdae9c1bacb557feb Mon Sep 17 00:00:00 2001 From: Stijn Willems Date: Tue, 17 Mar 2026 10:32:07 +0100 Subject: [PATCH] feat: add extraction backend preference (native/system xip) --- Xcodes/Backend/AppState+Install.swift | 9 +++--- Xcodes/Backend/AppState.swift | 29 +++++++++++++++---- .../ExperiementsPreferencePane.swift | 18 +++++++----- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 6f95df7..68cd138 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -251,7 +251,7 @@ extension AppState { func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher { self.setInstallationStep(of: availableXcode.version, to: .unarchiving) - return unxipOrUnxipExperiment(source) + return extractXIP(source) .catch { error -> AnyPublisher in if let executionError = error as? ProcessExecutionError { if executionError.standardError.contains("damaged and can’t be expanded") { @@ -291,13 +291,14 @@ extension AppState { .eraseToAnyPublisher() } - func unxipOrUnxipExperiment(_ source: URL) -> AnyPublisher { - if unxipExperiment { + func extractXIP(_ source: URL) -> AnyPublisher { + switch extractionBackend { + case .nativeLibunxip: // Native libunxip integration - no subprocess needed // https://github.com/saagarjha/unxip let destination = source.deletingLastPathComponent() return Current.shell.unxipNative(source, destination) - } else { + case .systemXip: return Current.shell.unxip(source) } } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 1f6419d..13b9cf1 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -14,7 +14,7 @@ import LibFido2Swift enum PreferenceKey: String { case installPath case localPath - case unxipExperiment + case extractionBackend case createSymLinkOnSelect case onSelectActionType case showOpenInRosettaOption @@ -31,6 +31,23 @@ enum PreferenceKey: String { func isManaged() -> Bool { UserDefaults.standard.objectIsForced(forKey: self.rawValue) } } +/// Controls which backend is used for extracting .xip archives. +enum ExtractionBackend: String, CaseIterable, Identifiable { + /// Uses /usr/bin/xip --expand (slowest, always works) + case systemXip = "system" + /// Uses libunxip natively (fastest, built-in) + case nativeLibunxip = "native" + + var id: Self { self } + + var displayName: String { + switch self { + case .systemXip: return "System xip (slowest, most compatible)" + case .nativeLibunxip: return "Native libunxip (fastest)" + } + } +} + class AppState: ObservableObject { private let client = AppleAPI.Client() internal let runtimeService = RuntimeService() @@ -97,13 +114,13 @@ class AppState: ObservableObject { var disableInstallPathChange: Bool { PreferenceKey.installPath.isManaged() } - @Published var unxipExperiment = false { + @Published var extractionBackend: ExtractionBackend = .nativeLibunxip { didSet { - Current.defaults.set(unxipExperiment, forKey: "unxipExperiment") + Current.defaults.set(extractionBackend.rawValue, forKey: "extractionBackend") } } - - var disableUnxipExperiment: Bool { PreferenceKey.unxipExperiment.isManaged() } + + var disableExtractionBackendChange: Bool { PreferenceKey.extractionBackend.isManaged() } @Published var createSymLinkOnSelect = false { didSet { @@ -203,7 +220,7 @@ class AppState: ObservableObject { func setupDefaults() { localPath = Current.defaults.string(forKey: "localPath") ?? Path.defaultXcodesApplicationSupport.string - unxipExperiment = Current.defaults.bool(forKey: "unxipExperiment") ?? false + extractionBackend = ExtractionBackend(rawValue: Current.defaults.string(forKey: "extractionBackend") ?? "") ?? .nativeLibunxip createSymLinkOnSelect = Current.defaults.bool(forKey: "createSymLinkOnSelect") ?? false onSelectActionType = SelectedActionType(rawValue: Current.defaults.string(forKey: "onSelectActionType") ?? "none") ?? .none installPath = Current.defaults.string(forKey: "installPath") ?? Path.defaultInstallDirectory.string diff --git a/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift b/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift index 68dad51..0ce6584 100644 --- a/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/ExperiementsPreferencePane.swift @@ -4,17 +4,19 @@ import SwiftUI struct ExperimentsPreferencePane: View { @EnvironmentObject var appState: AppState - + var body: some View { VStack(alignment: .leading, spacing: 20) { - GroupBox(label: Text("FasterUnxip")) { + GroupBox(label: Text("ExtractionBackend")) { VStack(alignment: .leading) { - Toggle( - "UseUnxipExperiment", - isOn: $appState.unxipExperiment - ) - .disabled(appState.disableUnxipExperiment) - Text("FasterUnxipDescription") + Picker("ExtractionBackendPicker", selection: $appState.extractionBackend) { + ForEach(ExtractionBackend.allCases) { backend in + Text(backend.displayName).tag(backend) + } + } + .pickerStyle(.radioGroup) + .disabled(appState.disableExtractionBackendChange) + Text("ExtractionBackendDescription") .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true)