├── .gitignore ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── xctrim.xcscheme ├── Package.resolved ├── Package.swift ├── README.md └── Sources ├── CLI ├── PlatformOptions+ExpressibleByArgument.swift ├── Trim.swift └── XCTrimCommand.swift └── XCTrimCore ├── FileHandler └── FileHandler.swift ├── LipoHelper └── Lipo.swift ├── Loader └── XCFrameworkLoader.swift ├── Trimmer └── Trimmer.swift ├── Types ├── Architecture.swift ├── Library.swift ├── Platform.swift ├── PlatformOptions.swift ├── Plist.swift └── XCFramework.swift └── Utils └── AsyncExtensions.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/xctrim.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 60 | 61 | 71 | 73 | 79 | 80 | 81 | 82 | 85 | 86 | 89 | 90 | 93 | 94 | 95 | 96 | 102 | 104 | 110 | 111 | 112 | 113 | 115 | 116 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-argument-parser", 6 | "repositoryURL": "https://github.com/apple/swift-argument-parser", 7 | "state": { 8 | "branch": null, 9 | "revision": "d2930e8fcf9c33162b9fcc1d522bc975e2d4179b", 10 | "version": "1.0.1" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "xctrim", 7 | platforms: [.macOS(.v12)], 8 | products: [ 9 | .executable(name: "xctrim", targets: ["CLI"]) 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.1") 13 | ], 14 | targets: [ 15 | .executableTarget( 16 | name: "CLI", 17 | dependencies: [ 18 | "XCTrimCore", 19 | .product(name: "ArgumentParser", package: "swift-argument-parser") 20 | ] 21 | ), 22 | .target(name: "XCTrimCore") 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xctrim 2 | 3 | A cli program written in swift (with async/await) that removes the unnecessary parts of xcframeworks. 4 | 5 | ## Usecase 6 | Say you downloaded firebase sdk and added it to your project. It is a huge sdk that has many xcframeworks. And every xcframework has ios, ios simulator, tvos, tvos simulator, mac and catalyst slices. Usually your project doesn't need all these slices. For example if your app is only ios app then you just need ios and ios simulator slices. With xctrim you can keep only necessary parts and reduce the space. 7 | 8 | ## Installation 9 | Clone the repo and run `swift build -c release` command. You will find xctrim executable in `.build/release` directory 10 | 11 | ## Usage 12 | `xctrim --path directory/that/contains/xcframeworks --platform "ios arm64" --platform "iossimulator arm64 x86_64"` 13 | This command will keep only ios version with arm64 slice and ios simulator version with arm64(apple silicon) and 64 bit intel slices and remove every other version and slice from xcframeworks. If you omit the path parameter it finds xcframeworks in current path. 14 | 15 | ### Available platforms and slices 16 | | Platform | Slices | 17 | |------------------|----------------------------| 18 | | ios | arm64, armv7 | 19 | | iossimulator | arm64, i386, x86\_64 | 20 | | ioscatalyst | arm64, x86\_64 | 21 | | tvos | arm64 | 22 | | tvossimulator | arm64, x86\_64 | 23 | | macos | arm64, i386, x86\_64 | 24 | -------------------------------------------------------------------------------- /Sources/CLI/PlatformOptions+ExpressibleByArgument.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import XCTrimCore 3 | 4 | extension PlatformOptions: ExpressibleByArgument { 5 | public init?(argument: String) { 6 | let values = argument.split(separator: " ").map(String.init) 7 | guard values.count >= 2 else { return nil } 8 | 9 | let platform: Platform 10 | switch values[0].lowercased() { 11 | case "ios": platform = .ios 12 | case "iossimulator": platform = .iosSimulator 13 | case "ioscatalyst": platform = .iosCatalyst 14 | case "tvos": platform = .tvOS 15 | case "tvossimulator": platform = .tvOSSimulator 16 | case "macos": platform = .macOS 17 | default: return nil 18 | } 19 | 20 | let architectures = values.dropFirst() 21 | .compactMap(Architecture.init(rawValue:)) 22 | 23 | guard !architectures.isEmpty else { return nil } 24 | 25 | self.init(platform: platform, architectures: architectures) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/CLI/Trim.swift: -------------------------------------------------------------------------------- 1 | import XCTrimCore 2 | import Foundation 3 | 4 | func trim( 5 | at url: URL?, 6 | options: Set, 7 | trimmer: Trimmer = .default(), 8 | loader: XCFrameworkLoader = .default(), 9 | fileHandler: FileHandler = .default 10 | ) async { 11 | let url = url ?? fileHandler.currentPath() 12 | do { 13 | let xcframeworks = try await loader.load(url) 14 | 15 | try await xcframeworks.concurrentForEach { xcframework in 16 | print("trimming \(xcframework.name)") 17 | try await trimmer.trim(xcframework, options) 18 | } 19 | } catch { 20 | print(error) 21 | exit(EXIT_FAILURE) 22 | } 23 | 24 | print("done") 25 | exit(EXIT_SUCCESS) 26 | } 27 | -------------------------------------------------------------------------------- /Sources/CLI/XCTrimCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import XCTrimCore 3 | import Foundation 4 | 5 | @main 6 | struct XCTrim: ParsableCommand { 7 | static var configuration: CommandConfiguration { 8 | .init( 9 | commandName: "xctrim", 10 | abstract: "a cli program to thin xcframeworks and save some space in your project source code", 11 | version: "0.1" 12 | ) 13 | } 14 | 15 | @Option(help: "folder that contains xcframeworks") 16 | var path: String? 17 | 18 | @Option var platform: [PlatformOptions] 19 | 20 | func run() throws { 21 | Task { 22 | await trim(at: path.map(URL.init(fileURLWithPath:)), options: Set(platform)) 23 | } 24 | 25 | dispatchMain() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/FileHandler/FileHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct FileHandler { 4 | public var currentPath: () -> URL 5 | public var moveItem: (URL, URL) throws -> Void 6 | public var removeItem: (URL) throws -> Void 7 | public var contents: (URL) throws -> [URL] 8 | public var load: (URL) throws -> Data 9 | public var save: (URL, Data) throws -> Void 10 | } 11 | 12 | public extension FileHandler { 13 | static let `default` = FileHandler( 14 | currentPath: { URL(fileURLWithPath: FileManager.default.currentDirectoryPath) }, 15 | moveItem: { from, to in 16 | try FileManager.default.moveItem(at: from, to: to) 17 | }, 18 | removeItem: { itemUrl in 19 | try FileManager.default.removeItem(at: itemUrl) 20 | }, 21 | contents: { directoryUrl in 22 | try FileManager.default.contentsOfDirectory(at: directoryUrl, includingPropertiesForKeys: nil) 23 | }, 24 | load: { fileUrl in 25 | try Data(contentsOf: fileUrl) 26 | }, 27 | save: { fileUrl, data in 28 | try data.write(to: fileUrl) 29 | } 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/LipoHelper/Lipo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Lipo { 4 | public var remove: (Set, URL) -> Void 5 | public var thin: (Architecture, URL) -> Void 6 | } 7 | 8 | public extension Lipo { 9 | static let `default` = Lipo(remove: lipoRemove, thin: lipoThin) 10 | } 11 | 12 | private func lipoRemove(archs: Set, at url: URL) { 13 | let removeCommand = archs.map { "-remove \($0.name)" } 14 | .joined(separator: " ") 15 | 16 | let file = url.path 17 | 18 | let command = "lipo \(removeCommand) \(file) -o \(file)" 19 | let _ = shell(command) 20 | } 21 | 22 | private func lipoThin(arch: Architecture, at url: URL) { 23 | let file = url.path 24 | 25 | let command = "lipo -thin \(arch.name) \(file) -o \(file)" 26 | let _ = shell(command) 27 | } 28 | 29 | 30 | private func shell(_ command: String) -> String { 31 | let task = Process() 32 | let pipe = Pipe() 33 | 34 | task.standardOutput = pipe 35 | task.standardError = pipe 36 | task.arguments = ["-c", command] 37 | task.launchPath = "/bin/zsh" 38 | task.launch() 39 | 40 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 41 | let output = String(data: data, encoding: .utf8)! 42 | 43 | return output 44 | } 45 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/Loader/XCFrameworkLoader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct XCFrameworkLoader { 4 | public var load: (URL) async throws -> [XCFramework] 5 | } 6 | 7 | public extension XCFrameworkLoader { 8 | static func `default`(with fileHandler: FileHandler = .default) -> Self { 9 | XCFrameworkLoader( 10 | load: { url in 11 | try await fileHandler.contents(url) 12 | .filter { $0.pathExtension == "xcframework" } 13 | .concurrentMap { url in 14 | try loadXCFramework(at: url) 15 | } 16 | } 17 | ) 18 | } 19 | } 20 | 21 | private func loadXCFramework(at url: URL) throws -> XCFramework { 22 | let plsitUrl = url.appendingPathComponent("Info.plist") 23 | let data = try Data(contentsOf: plsitUrl) 24 | let plist = try PropertyListDecoder().decode(Plist.self, from: data) 25 | 26 | let name = url.lastPathComponent 27 | 28 | return XCFramework( 29 | name: name, 30 | url: url, 31 | plist: plist 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/Trimmer/Trimmer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Trimmer { 4 | public var trim: (XCFramework, Set) async throws -> Void 5 | } 6 | 7 | public extension Trimmer { 8 | static func `default`(with lipo: Lipo = .default, fileHandler: FileHandler = .default) -> Trimmer { 9 | Trimmer { xcframework, options in 10 | let libraries = try await xcframework.plist.availableLibraries 11 | .concurrentMap { lib in 12 | try handleLibrary(lib, at: xcframework.url, for: options, lipo: lipo) 13 | } 14 | .compactMap { $0 } 15 | 16 | try savePlist( 17 | xcframework.plist, 18 | at: xcframework.url, 19 | libraries: libraries, 20 | fileHandler: fileHandler 21 | ) 22 | } 23 | } 24 | } 25 | 26 | private func handleLibrary( 27 | _ library: Library, 28 | at url: URL, 29 | for options: Set, 30 | lipo: Lipo 31 | ) throws -> Library? { 32 | let libraryUrl = url.appendingPathComponent(library.libraryIdentifier) 33 | if 34 | let platform = library.platform, 35 | let option = options.first(where: { $0.platform == platform }) 36 | { 37 | let (editedLibrary, deletedArchs) = library.edit(with: option.architectures) 38 | 39 | if !deletedArchs.isEmpty { 40 | let binaryName = library.libraryPath.split(separator: ".").first ?? "" 41 | 42 | let binaryUrl = libraryUrl 43 | .appendingPathComponent(library.libraryPath) 44 | .appendingPathComponent(String(binaryName)) 45 | 46 | if editedLibrary.supportedArchitectures.count == 1 { 47 | lipo.thin(editedLibrary.supportedArchitectures.first!, binaryUrl) 48 | } else { 49 | lipo.remove(deletedArchs, binaryUrl) 50 | } 51 | 52 | try FileManager.default 53 | .moveItem( 54 | at: url.appendingPathComponent(library.libraryIdentifier), 55 | to: url.appendingPathComponent(editedLibrary.libraryIdentifier) 56 | ) 57 | } 58 | 59 | return editedLibrary 60 | } else { 61 | try FileManager.default.removeItem(at: libraryUrl) 62 | return nil 63 | } 64 | } 65 | 66 | private func savePlist(_ plist: Plist, at url: URL, libraries: [Library], fileHandler: FileHandler) throws { 67 | var plist = plist 68 | plist.availableLibraries = libraries 69 | 70 | let data = try PropertyListEncoder().encode(plist) 71 | let plistUrl = url.appendingPathComponent("Info.plist") 72 | try fileHandler.save(plistUrl, data) 73 | } 74 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/Types/Architecture.swift: -------------------------------------------------------------------------------- 1 | public enum Architecture: String, Codable { 2 | case arm64 3 | case i386 4 | case armv7 5 | case x86_64 6 | } 7 | 8 | public extension Architecture { 9 | var name: String { 10 | rawValue 11 | } 12 | 13 | var sortRate: Int { 14 | switch self { 15 | case .arm64: 16 | return 0 17 | case .armv7: 18 | return 1 19 | case .i386: 20 | return 2 21 | case .x86_64: 22 | return 3 23 | } 24 | } 25 | } 26 | 27 | extension Sequence where Element == Architecture { 28 | func sortWithRate() -> [Element] { 29 | self.sorted { a1, a2 in 30 | a1.sortRate < a2.sortRate 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/Types/Library.swift: -------------------------------------------------------------------------------- 1 | public struct Library: Codable, Equatable { 2 | public var libraryIdentifier: String 3 | public var libraryPath: String 4 | public var supportedArchitectures: Set 5 | public var supportedPlatform: String 6 | public var supportedPlatformVariant: String? 7 | 8 | enum CodingKeys: String, CodingKey { 9 | case libraryIdentifier = "LibraryIdentifier" 10 | case libraryPath = "LibraryPath" 11 | case supportedArchitectures = "SupportedArchitectures" 12 | case supportedPlatform = "SupportedPlatform" 13 | case supportedPlatformVariant = "SupportedPlatformVariant" 14 | } 15 | 16 | public func edit(with architectures: [Architecture]) -> (Self, Set) { 17 | guard let platform = platform else { return (self, []) } 18 | 19 | let supportedArchitectures = platform.supportedArchitectures.intersection(architectures) 20 | let deletedArchs = self.supportedArchitectures.subtracting(supportedArchitectures) 21 | 22 | var copy = self 23 | copy.libraryIdentifier = platform.frameworkName(for: supportedArchitectures) 24 | copy.supportedArchitectures = supportedArchitectures 25 | 26 | return (copy, deletedArchs) 27 | } 28 | } 29 | 30 | public extension Library { 31 | var platform: Platform? { 32 | switch (supportedPlatform, supportedPlatformVariant) { 33 | case ("ios", "simulator"): 34 | return .iosSimulator 35 | case ("ios", "maccatalyst"): 36 | return .iosCatalyst 37 | case ("ios", nil): 38 | return .ios 39 | case ("tvos", "simulator"): 40 | return .tvOSSimulator 41 | case ("tvos", nil): 42 | return .tvOS 43 | case ("macos", nil): 44 | return .macOS 45 | default: 46 | return nil 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/Types/Platform.swift: -------------------------------------------------------------------------------- 1 | public enum Platform { 2 | case ios 3 | case iosSimulator 4 | case iosCatalyst 5 | case tvOS 6 | case tvOSSimulator 7 | case macOS 8 | } 9 | 10 | extension Platform { 11 | var supportedArchitectures: Set { 12 | switch self { 13 | case .ios: 14 | return [.arm64, .armv7] 15 | case .iosSimulator: 16 | return [.arm64, .i386, .x86_64] 17 | case .iosCatalyst: 18 | return [.arm64, .x86_64] 19 | case .tvOS: 20 | return [.arm64] 21 | case .tvOSSimulator: 22 | return [.arm64, .x86_64] 23 | case .macOS: 24 | return [.arm64, .x86_64, .i386] 25 | } 26 | } 27 | 28 | func frameworkName(for architectures: Set) -> String { 29 | let archStr = architectures.sortWithRate() 30 | .map(\.name) 31 | .joined(separator: "_") 32 | 33 | switch self { 34 | case .ios: 35 | return "ios-\(archStr)" 36 | case .iosSimulator: 37 | return "ios-\(archStr)-simulator" 38 | case .iosCatalyst: 39 | return "ios-\(archStr)-maccatalyst" 40 | case .tvOS: 41 | return "tvos-\(archStr)" 42 | case .tvOSSimulator: 43 | return "tvos-\(archStr)-simulator" 44 | case .macOS: 45 | return "macos-\(archStr)" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/Types/PlatformOptions.swift: -------------------------------------------------------------------------------- 1 | public struct PlatformOptions: Hashable { 2 | public var platform: Platform 3 | public var architectures: [Architecture] 4 | 5 | public init(platform: Platform, architectures: [Architecture]) { 6 | self.platform = platform 7 | self.architectures = architectures 8 | } 9 | } 10 | 11 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/Types/Plist.swift: -------------------------------------------------------------------------------- 1 | public struct Plist: Codable { 2 | public var xcFrameworkFormatVersion: String 3 | public var bundleOSTypeCode: String 4 | public var availableLibraries: [Library] 5 | 6 | enum CodingKeys: String, CodingKey { 7 | case xcFrameworkFormatVersion = "XCFrameworkFormatVersion" 8 | case bundleOSTypeCode = "CFBundlePackageType" 9 | case availableLibraries = "AvailableLibraries" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/Types/XCFramework.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct XCFramework { 4 | public var name: String 5 | public var url: URL 6 | public var plist: Plist 7 | } 8 | -------------------------------------------------------------------------------- /Sources/XCTrimCore/Utils/AsyncExtensions.swift: -------------------------------------------------------------------------------- 1 | public extension Sequence { 2 | func concurrentMap(_ transform: @escaping (Element) async -> T) async -> [T] { 3 | var values = [T]() 4 | 5 | await withTaskGroup(of: T.self) { group in 6 | for element in self { 7 | group.addTask { 8 | await transform(element) 9 | } 10 | } 11 | 12 | for await value in group { 13 | values.append(value) 14 | } 15 | } 16 | 17 | return values 18 | } 19 | 20 | func concurrentMap(_ transform: @escaping (Element) async throws -> T) async throws -> [T] { 21 | var values = [T]() 22 | 23 | try await withThrowingTaskGroup(of: T.self) { group in 24 | for element in self { 25 | group.addTask { 26 | try await transform(element) 27 | } 28 | } 29 | 30 | for try await value in group { 31 | values.append(value) 32 | } 33 | } 34 | 35 | return values 36 | } 37 | } 38 | 39 | public extension Sequence { 40 | func concurrentForEach(_ body: @escaping (Element) async -> Void) async { 41 | await withTaskGroup(of: Void.self) { group in 42 | for element in self { 43 | group.addTask { 44 | await body(element) 45 | } 46 | } 47 | 48 | for await _ in group { } 49 | } 50 | } 51 | 52 | func concurrentForEach(_ body: @escaping (Element) async throws -> Void) async throws { 53 | try await withThrowingTaskGroup(of: Void.self) { group in 54 | for element in self { 55 | group.addTask { 56 | try await body(element) 57 | } 58 | } 59 | 60 | for try await _ in group { } 61 | } 62 | } 63 | } 64 | 65 | --------------------------------------------------------------------------------