使用 Swift Package Manager 建立 Command line tool

1,332 阅读4分钟

作为iOS开发,我们的 CI 经常使用 Ruby 的命令行工具,像 fastlane, CocoaPods, Xcodeproj。

随着 Ruby 逐渐没落,维护成本逐渐上升。

通过 Swift Package Manager,使用 Apple Swift 语言建立 Command line tool,让团队中的iOS开发者更易于开发维护。

An example: Creating a xcode helper

使用 Swift Package Manager 创建一个示例, 用于查看 xcode 的 cache 文件。如图:

Creating a command-line tool


mkdir xcode-helper && cd xcode-helper

swift package init --type executable

type

  • library 创建 library。

  • executable. 创建命令行工具。

Build and run an executable product

命令行运行


swift run


> swift run

[3/3] Linking xcode-helper

* Build Completed!

Hello, world!

使用 Xcode 运行


swift package generate-xcodeproj

open *.xcodeproj

Adding dependencies

添加 apple/swift-argument-parser 来获取命令行参数。


vi Package.swift


.package(

url: "https://github.com/apple/swift-argument-parser",

from: "0.4.0"

)

Include "ArgumentParser" as a dependency for your executable target:


.product(name: "ArgumentParser", package: "swift-argument-parser"),

Package.swift Example:


// swift-tools-version:5.3

// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(

name: "xcode-helper",

dependencies: [

.package(

url: "https://github.com/apple/swift-argument-parser",

from: "0.4.0"

)

],

targets: [

.target(

name: "xcode-helper",

dependencies: [

.product(name: "ArgumentParser", package: "swift-argument-parser"),

]),

.testTarget(

name: "xcode-helperTests",

dependencies: ["xcode-helper"]),

]

)

Installing dependencies

修改后,通过swift package update拉取依赖


swift package update

Creating the main execution command

Sources/<target_name>/main.swift,加入处理逻辑


vi Sources/xcode-helper/main.swift


import Foundation

import ArgumentParser

struct Constant {

struct App {

static let version = "0.0.1"

}

}

@discardableResult

func shell(_ command: String) -> String {

let task = Process()

let pipe = Pipe()

task.standardOutput = pipe

task.standardError = pipe

task.arguments = ["-c", command]

task.launchPath = "/bin/zsh"

task.launch()

let data = pipe.fileHandleForReading.readDataToEndOfFile()

let output = String(data: data, encoding: .utf8)!

return output

}

struct Print {

enum Color: String {

case reset = "\u{001B}[0;0m"

case black = "\u{001B}[0;30m"

case red = "\u{001B}[0;31m"

case green = "\u{001B}[0;32m"

case yellow = "\u{001B}[0;33m"

case blue = "\u{001B}[0;34m"

case magenta = "\u{001B}[0;35m"

case cyan = "\u{001B}[0;36m"

case white = "\u{001B}[0;37m"

}

static func h3(_ items: Any..., separator: String = " ", terminator: String = "\n") {

// https://stackoverflow.com/questions/39026752/swift-extending-functionality-of-print-function

let output = items.map { "\($0)" }.joined(separator: separator)

print("\(Color.green.rawValue)\(output)\(Color.reset.rawValue)")

}

static func h6(_ verbose: Bool, _ items: Any..., separator: String = " ", terminator: String = "\n") {

if verbose {

let output = items.map { "\($0)" }.joined(separator: separator)

print("\(output)")

}

}

}

extension XcodeHelper {

enum CacheFolder: String, ExpressibleByArgument, CaseIterable {

case all

case archives

case simulators

case deviceSupport

case derivedData

case previews

case coreSimulatorCaches

}

}

fileprivate extension XcodeHelper.CacheFolder {

var paths: [String] {

switch self {

case .archives:

return ["~/Library/Developer/Xcode/Archives"]

case .simulators:

return ["~/Library/Developer/CoreSimulator/Devices"]

case .deviceSupport:

return ["~/Library/Developer/Xcode"]

case .derivedData:

return ["~/Library/Developer/Xcode/DerivedData"]

case .previews:

return ["~/Library/Developer/Xcode/UserData/Previews/Simulator Devices"]

case .coreSimulatorCaches:

return ["~/Library/Developer/CoreSimulator/Caches/dyld"]

case .all:

var paths: [String] = []

for caseValue in Self.allCases {

if caseValue != self {

paths.append(contentsOf: caseValue.paths)

}

}

return paths

}

}

static var suggestion: String {

let suggestion = Self.allCases.map { caseValue in

return caseValue.rawValue

}.joined(separator: " | ")

return "[ \(suggestion) ]"

}

}

struct XcodeHelper: ParsableCommand {

public static let configuration = CommandConfiguration(

abstract: "Xcode helper",

version: "xcode-helper version \(Constant.App.version)",

subcommands: [

Cache.self

]

)

}

extension XcodeHelper {

struct Cache: ParsableCommand {

public static let configuration = CommandConfiguration(

abstract: "Xcode cache helper",

subcommands: [

List.self

]

)

}

}

extension XcodeHelper.Cache {

struct List: ParsableCommand {

public static let configuration = CommandConfiguration(

abstract: "Show Xcode cache files"

)

@Option(name: .shortAndLong, help: "The cache folder")

private var cacheFolder: XcodeHelper.CacheFolder = .all

@Flag(name: .shortAndLong, help: "Show extra logging for debugging purposes.")

private var verbose: Bool = false

func run() throws {

Print.h3("list cache files:")

Print.h3("------------------------")

if cacheFolder == .all {

var allCases = XcodeHelper.CacheFolder.allCases

allCases.remove(at: allCases.firstIndex(of: .all)!)

handleList(allCases)

} else {

handleList([cacheFolder])

}

}

private func handleList(_ folders: [XcodeHelper.CacheFolder]) {

for folder in folders {

Print.h3(folder.rawValue)

for path in folder.paths {

let cmd = "du -hs \(path)"

Print.h6(verbose, cmd)

let output = shell(cmd)

print(output)

}

}

}

}

}

XcodeHelper.main()

Build and run an executable product

Get all targets

获取当前项目下所有的 targets。


python3 -c "\

import sys, json, subprocess;\

package_data = subprocess.Popen('swift package dump-package', shell=True, stdout=subprocess.PIPE).stdout.read().decode('utf-8');\

targets = json.loads(package_data)['targets'];\

target_names = list(map(lambda x: x['name'], targets));\

print(target_names)\

"

Start using command-line

使用 swift run <target> 看下效果


swift run xcode-helper

Start using subcommand

为保证 xcode-helper 的扩展,实现时 cache 是子命令


swift run xcode-helper cache list

Writing Unit testing

Tests/<target_name>Tests/<target_name>Tests.swift, 添加必要的单元测试。


vi Tests/xcode-helperTests/xcode_helperTests.swift


import XCTest

import class Foundation.Bundle

extension XCTest {

public var debugURL: URL {

let bundleURL = Bundle(for: type(of: self)).bundleURL

return bundleURL.lastPathComponent.hasSuffix("xctest")

? bundleURL.deletingLastPathComponent()

: bundleURL

}

public func AssertExecuteCommand(

command: String,

expected: String? = nil,

exitCode: Int32 = EXIT_SUCCESS,

file: StaticString = #file, line: UInt = #line) {

let splitCommand = command.split(separator: " ")

let arguments = splitCommand.dropFirst().map(String.init)

let commandName = String(splitCommand.first!)

let commandURL = debugURL.appendingPathComponent(commandName)

guard (try? commandURL.checkResourceIsReachable()) ?? false else {

XCTFail("No executable at '\(commandURL.standardizedFileURL.path)'.",

file: (file), line: line)

return

}

let process = Process()

if #available(macOS 10.13, *) {

process.executableURL = commandURL

} else {

process.launchPath = commandURL.path

}

process.arguments = arguments

let output = Pipe()

process.standardOutput = output

let error = Pipe()

process.standardError = error

if #available(macOS 10.13, *) {

guard (try? process.run()) != nil else {

XCTFail("Couldn't run command process.", file: (file), line: line)

return

}

} else {

process.launch()

}

process.waitUntilExit()

let outputData = output.fileHandleForReading.readDataToEndOfFile()

let outputActual = String(data: outputData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)

let errorData = error.fileHandleForReading.readDataToEndOfFile()

let errorActual = String(data: errorData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)

if let expected = expected {

XCTAssertEqual(expected, errorActual + outputActual)

}

XCTAssertEqual(process.terminationStatus, exitCode, file: (file), line: line)

}

}

final class xcode_helperTests: XCTestCase {

func test_Xcode_Helper_Versions() throws {

AssertExecuteCommand(command: "xcode-helper --version",

expected: "xcode-helper version 0.0.1")

}

func test_Xcode_Helper_Help() throws {

let helpText = """

OVERVIEW: Xcode helper

USAGE: xcode-helper <subcommand>

OPTIONS:

--version Show the version.

-h, --help Show help information.

SUBCOMMANDS:

cache Xcode cache helper

See 'xcode-helper help <subcommand>' for detailed help.

"""

AssertExecuteCommand(command: "xcode-helper", expected: helpText)

AssertExecuteCommand(command: "xcode-helper -h", expected: helpText)

AssertExecuteCommand(command: "xcode-helper --help", expected: helpText)

}

}

通过 swift test 运行单元测试。


swift test


> swift test

Test Suite 'All tests' started at 2021-07-17 14:01:47.357

Test Suite 'xcode-helperPackageTests.xctest' started at 2021-07-17 14:01:47.358

Test Suite 'xcode_helperTests' started at 2021-07-17 14:01:47.358

Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Help]' started.

Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Help]' passed (0.202 seconds).

Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Versions]' started.

Test Case '-[xcode_helperTests.xcode_helperTests test_Xcode_Helper_Versions]' passed (0.074 seconds).

Test Suite 'xcode_helperTests' passed at 2021-07-17 14:01:47.634.

Executed 2 tests, with 0 failures (0 unexpected) in 0.276 (0.276) seconds

Test Suite 'xcode-helperPackageTests.xctest' passed at 2021-07-17 14:01:47.634.

Executed 2 tests, with 0 failures (0 unexpected) in 0.276 (0.276) seconds

Test Suite 'All tests' passed at 2021-07-17 14:01:47.634.

Executed 2 tests, with 0 failures (0 unexpected) in 0.276 (0.277) seconds

也可以使用 Xcode Command-U 跑测试。

Installing your command line tool

测试通过,release 打包,并移至/usr/local/bin


swift build -c release

cp -f .build/release/xcode-helper /usr/local/bin/xcode-helper

xcode-helper --version


> xcode-helper --version

xcode-helper version 0.0.1

Demo

References