使用 Swift Package Manager 建立 Command line tool

作为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 文件。如图:


image

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"),
image

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

image

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

swift run xcode-helper cache list
image

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 跑测试。

image

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容