├── .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 |
--------------------------------------------------------------------------------