この記事は Goodpatch Advent Calendar 2020 の20日目です.

年末が近づいているということで,どこの会社でもどこのご家庭でも一年間溜まったアレの片付けに追われているのではないでしょうか. そうです Apple Developer に登録した端末一覧です. 今年の端末は今年の内に.新年を清々しい気持ちで迎えるぞ.

概要

  • Apple Developer に登録している古くなった端末を削除したい
  • Swift Package Manager を使ってコマンドラインツールを作る
  • 端末の操作は App Store Connect API で行う
    • 端末の一覧を取得する
    • 古くなった端末を Disabled にする(削除はできない仕様)

コードは GitHub にあります.

環境

  • Swift 5.3.1
  • App Store Connect API v1

Apple Developer 管理画面における端末管理

iOS アプリを自由にインストールできる端末は開発者ライセンスと証明書によって制限されています. その中で,比較的制約条件が少ない Ad Hoc 方式を使いたい場合,Apple Developer にインストール対象端末をひとつひとつ登録する必要があります.

端末の UDID を集めて登録するのは一手間かかる作業ですが,多くの鍛えられた iOS Developer はこの手間に慣れっこです. UDID 確認方法の手順書を用意していたり,Firebase App Distribution など 3rd パーティ製のインフラに乗るようにしていたり,既に解決手段を持っていると思います.

それでは,ここで登録した端末はその後どうなっているでしょうか?

個人の経験や想像でしかありませんが,多くの場合「そのまま放置されている」のではないでしょうか. 年に一回の開発者ライセンス更新タイミングで,Apple から登録端末リセットの機会を与えられたものの,端末一覧を見ただけではどれがリセットして良い端末かは判断できないのが実情だと思います. とどのつまり,削除を諦めて「現状維持」とするか,強い気持ちとマナコストを支払って「全体除去」を唱えるかといった選択肢しかありません.

App Store Connect API

それでは「もう少し良い方法はないのか?」というと,なくはないです.

Apple は App Sotre Connect API の提供を WWDC 2018 で発表しました. この API を使うことで,開発者は Apple Developer アカウントや App Store Connect の管理項目をプログラムから変更できるようになります.

API の使い方については公式ドキュメントに詳しくあるとおりですが,その中でも Devices の使い方を調べてみると,API を使えば各端末について以下のようなプロパティが取得できることが分かります.

Name Type Note
deviceClass string Possible values: APPLE_WATCH, IPAD, IPHONE, IPOD, APPLE_TV, MAC
model string  
name string  
platform BundleIdPlatform Possible values: IOS, MAC_OS
status string Possible values: ENABLED, DISABLED
udid string  
addedDate date-time  

参考:Device.Attributes | Apple Developer Documentation

ここで,端末管理のために特に使えそうなものが addedDate です. 他のプロパティは管理画面の一覧や詳細を見れば確認することができるのですが,なぜか端末の登録日を管理画面から知ることはできません.

端末の登録日が分かれば,例えば端末管理ルールとして「登録から2年以上経過している端末は削除する」といったものを考えることができそうです.

なお,端末のモデルを使ってルールを決めることもできそうですが,端末を積極的に削除することができなかったり,あえて古めの端末を登録している場合を考慮できなかったりといった問題がありそうです. 例えば2020年末時点で iOS 13.x までしかサポートしなくて良いとした場合でも,iPhone 6s 以降の端末については使われている可能性が残ります. 端末一覧にリストされているその iPhone 6s は既に数年前から使われていない端末かもしれませんし,つい数ヶ月前にテスト用に登録した端末かもしれません.

今回の検証では「登録から〇〇以上経過している端末は削除する」のルールを考え,これを API を使って自動で運用する方法を検証します.

技術構成

今回の検証では技術構成を次のように考えました.

App Store Connect API

既に説明したとおりです. 公式ドキュメントの Topics > Essentials を読んでいけば特に難しいことはないと思います.

Swift

基本的には App Store Connect API を叩くだけなので,プログラミング言語は好きなものを使えば良さそうです. 今回は Apple のお膝元ということで Swift を使ってみます. API を叩くために有志によって開発されている OSS のラッパーを使うこともできますが,それではネタがなくなりそうなので自前で書くことにします.

Swift Argument Parser

Swift でコマンドラインツールを作りたいわけですが,コマンドライン引数をパースすることは地味に面倒なので Apple が公開している Swift Argument Parser を使います. Protocol 準拠した struct を定義すれば引数やオプションを簡単にパースでき,さらに help オプションまで用意してくるのでとても便利です.

JWTKit

App Store Connect API は JSON Web Tokens(JWT)による認証を要求します. JWT の header や payload の構造は自前で定義しますが,それを JWT にエンコードする処理はライブラリに任せることにします. 今回は Vapor プロジェクトでも使われている JWTKit を採用します.

プロジェクトを作る

Swift Package Manager を使ってプロジェクトを作ります. まずは swift コマンドで雛形に従ったプロジェクトを作ります.

$ mkdir apple-device-manager
$ cd apple-device-manager
$ swift package init --type executable

Creating executable package: apple-device-manager
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/apple-device-manager/main.swift
Creating Tests/
Creating Tests/LinuxMain.swift
Creating Tests/apple-device-managerTests/
Creating Tests/apple-device-managerTests/apple_device_managerTests.swift
Creating Tests/apple-device-managerTests/XCTestManifests.swift

続いて,今回使用するパッケージを Package.swift ファイルに追記します. Package.swift ファイルを Xcode で開くと Xcode Workspace が作られるので,以降の編集作業はそちらで進めます.

// swift-tools-version:5.3

import PackageDescription

let package = Package(
    name: "apple-device-manager",
    platforms: [
        .macOS(.v10_15),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "0.3.1"),
        .package(url: "https://github.com/vapor/jwt-kit.git", from: "4.0.0")
    ],
    targets: [
        .target(
            name: "apple-device-manager",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "JWTKit", package: "jwt-kit")
            ]),
        .testTarget(
            name: "apple-device-managerTests",
            dependencies: ["apple-device-manager"]),
    ]
)

Package.swift を更新して保存すると,Xcode は自動でパッケージのインストールを開始します.

インストールが完了した後,Xcode からプログラムを実行(Run)すると Console にメッセージが表示されるはずです.

Hello, world!
Program ended with exit code: 0

認証する

API の認証を通過するためには,API トークンが必要です. まず Apple Developer 管理画面で App Store Connect の API キーを作る手順に従って API キー(.p8)を作ります. さらにその API キーを元に API トークンを生成すると実際の HTTP リクエストで使うトークン(JWT)を得ることができます.

App Store Connect API の仕様で,トークンの有効期限は20分以内に設定する必要があるため,トークンは適宜作り直す必要があります. キーの idissuerIdvalue(値そのもの)を入力として,JWT にエンコードされたトークンを出力してみます.

import Foundation

struct APIKey {
    let id: String
    let issuerId: String
    let value: String
}
import Foundation
import JWTKit

struct APIToken {
    let value: String
    let expiration: Date

    struct Payload: JWTPayload {
        /// Your issuer ID from the API Keys page in App Store Connect (Ex: 57246542-96fe-1a63-e053-0824d011072a)
        var iss: IssuerClaim

        /// The token's expiration time, in Unix epoch time; tokens that expire more than 20 minutes in the future are not valid (Ex: 1528408800)
        var exp: ExpirationClaim

        var aud: AudienceClaim = .init(value: "appstoreconnect-v1")

        func verify(using signer: JWTSigner) throws {
            try self.exp.verifyNotExpired()
        }
    }

    static func encode(_ key: APIKey) throws -> APIToken {
        let expiration = Date().addingTimeInterval(20 * 60)
        let payload = Payload(iss: .init(value: key.issuerId), exp: .init(value: expiration))
        let signer = try JWTSigner.es256(key: .private(pem: key.value))
        let jwt = try signer.sign(payload, kid: .init(string: key.id))

        return APIToken(value: jwt, expiration: expiration)
    }
}

試しに Swift のコードを実行して API トークンを生成して出力してみます.

import Foundation

let keyId = "XXXXXXXXXX"
let keyIssuerId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
let keyFilePath = "/full/path/to/AuthKey_XXXXXXXXXX.p8"

let keyValue = try String(contentsOfFile: keyFilePath)

let key = APIKey(id: keyId, issuerId: keyIssuerId, value: keyValue)
let token = try APIToken.encode(key)

print(token.value)
xxxxxx.xxxxxxxxxxxxx.xxxxxxxx
Program ended with exit code: 0

得られたトークンを使って curl でリクエストを投げてみます. 端末の一覧が JSON 形式で取得できれば成功です.

$ curl https://api.appstoreconnect.apple.com/v1/devices \
      -H 'Authorization: Bearer xxxxxx.xxxxxxxxxxxxx.xxxxxxxx'

{
  "data" : [ {
    "type" : "devices",
    "id" : "xxxxxxxxxx",
    "attributes" : {
      "addedDate" : "yyyy-MM-ddTHH:mm:ss.000+0000",
      "name" : "xxxx",
      "deviceClass" : "IPHONE",
      "model" : null,
      "udid" : "xxxxxxxx-xxxxxxxxxxxxxxxx",
      "platform" : "IOS",
      "status" : "ENABLED"
    },
    "links" : {
      "self" : "https://api.appstoreconnect.apple.com/v1/devices/xxxxxxxxxx"
    }
  }, {
    /* ... */
  } ],
  "links" : {
    "self" : "https://api.appstoreconnect.apple.com/v1/devices"
  },
  "meta" : {
    "paging" : {
      "total" : 5,
      "limit" : 20
    }
  }
}

コマンドライン引数から入力を受け取る

前述どおり Swift Argument Parser を使います.

README が充実しているので特に説明することはありませんが,Subcommands や ParsableArguments を使うとコマンドを簡単に構造化できます. ここでは adm list で端末リストを出力するようなコマンドを考えました.

struct AppleDeviceManager: ParsableCommand {
    static var configuration = CommandConfiguration(
        commandName: "adm",
        abstract: "Apple Device Manager",
        subcommands: [List.self]
    )
}

extension AppleDeviceManager {
    struct Options: ParsableArguments {
        @Option(help: "Path to the private key.", completion: .file(extensions: [".p8"]))
        var keyPath: String

        @Option(help: "Id of the private key.")
        var keyId: String

        @Option(help: "Id of the issuer of private key.")
        var issuerId: String
    }

    struct List: ParsableCommand {
        @OptionGroup var baseOptions: Options

        mutating func run() throws {
          // Do something
        }
    }
}

AppleDeviceManager.main()

コマンドライン引数やオプションの parse は煩雑になりがちですが,Swift Argument Parser を使うと簡単に可読性高いコードで記述することができ,ひとことで言うとめっちゃ良いです.

端末一覧を取得する

正直なところ,API トークンを得られた後は普通に API を叩くだけの簡単なお仕事なので特に説明するところがありません.

リクエストやレスポンスは APIKit で型付けしています. レスポンスが返ってくるまでコマンドをサスペンドしておく必要があるので,DispatchSemaphore を使って wait しています. APIKit の Session.send のコールバックは sessionQueue にしておかないと semaphore.signal() が発火しないので注意します.

struct List: ParsableCommand {
    @OptionGroup var baseOptions: Options

    mutating func run() throws {
        let key = try baseOptions.getKey()
        let token = try APIToken.encode(key)

        let semaphore = DispatchSemaphore(value: 0)
        Session.send(GetDeivcesRequest(token: token), callbackQueue: .sessionQueue) { result in
            switch result {
                case .success(let response):
                    DevicePrinter().print(response.data)
                    semaphore.signal()
                case .failure(let error):
                    AppleDeviceManager.exit(withError: error)
            }
        }
        semaphore.wait()
    }
}

端末のステータスを更新する

API には登録済み端末を編集するエンドポイントが用意されているので,これを使って端末のステータスを DISABLED に更新します. 簡単なお仕事なので(以下略).

コマンドをビルドする

ビルドコマンド swift build を実行してバイナリを書き出します. 書き出しに成功したら,適当に PATH が通っているところに移動して実行します.

$ swift build --configuration release
$ cp .build/release/apple-device-manager /usr/local/bin/adm

# Swift Argument Parser が生成してくれる help を見る
$ adm --help

OVERVIEW: Apple Device Manager

USAGE: adm <subcommand>

OPTIONS:
  -h, --help              Show help information.

SUBCOMMANDS:
  list
  disable

  See 'adm help <subcommand>' for detailed help.

# 端末一覧を表示する
$ adm list --key-id=xxxx --issuer-id=xxxx --key-path=/xxx/xxx/xxxx.p8

XXXXXXXXXX E 2020-07-12 10:22:31 +0000 "iPhone SE"
XXXXXXXXXX E 2019-10-30 13:30:12 +0000 "Xxxxxxxx - iPhone X"
XXXXXXXXXX E 2020-04-20 00:44:59 +0000 "Xxxxxxxx - iPhone 8 Plus"
XXXXXXXXXX E 2020-04-29 11:28:08 +0000 "Xxxxxxxx - iPhone 11 Pro"
XXXXXXXXXX E 2019-10-30 14:08:55 +0000 "Xxxxx - iPhone X"

# 登録から1年以上経過している端末は Disabled にする
$ adm disable --age=1 --key-id=xxxx --issuer-id=xxxx --key-path=/xxx/xxx/xxxx.p8

XXXXXXXXXX D 2019-10-30 13:30:12 +0000 "Xxxxxxxx - iPhone X"
XXXXXXXXXX D 2019-10-30 14:08:55 +0000 "Xxxxx - iPhone X"

という感じで,Apple Developer に登録している端末に対して,コマンドラインツールを用いて削除する(フラグを立てる)ことができるようになりました. あとは好きな CI に乗せて定期的に実行すれば良さそうです. なお,GitHub Actions だとビルド時間が気になるかな?と思いましたが杞憂でした.私の mac 遅すぎ.

通知

まとめ

Apple Developer に登録している端末が放置されている問題(仮説)に対して,古い端末を自動で削除する(フラグを立てる)方法を検証しました.

  • App Store Connect API で端末一覧を取得することや,端末のプロパティを編集することができた
  • Swift Argument Parser はめちゃくちゃ便利だった
  • ただし Swift で書くのが楽かというと…うーん

なにはともあれ,今年もあと10日ほど.端末の掃除も終わった(?)ということで来年も良い年になりますように.

Goodpatch Advent Calendar 2020 はもう少しだけ続きます.

:sunrise:

One More Thing

Linux 用にビルドする

今回は Swift Package Manager でプロジェクトを作りました.つまり Linux 用にもビルドできるのでは? ということで,Docker を使って Linux 用のバイナリを書き出します.

Docker Image については Vapor プロジェクトでも使われている Swift 公式の Docker Image を使えば良さそうです. 適当に pull して run してビルドします.

$ docker pull swift:5.3-focal
$ docker run --rm -it -v $PWD:/adm -w /adm swift:5.3-focal swift build --configuration release

がしかし,ここでビルドエラーになります.やはり Write once, run anywhere とはいかないようです.

URLSession が解決できない問題を解決する

Linux の場合 URLSessionURLRequest などは FoundationNetworking という別モジュールから読む必要があるようです. そこで,コンパイラディレクティブを使って,Linux の場合のみ FoundationNetworking を import します.

import Foundation
#if os(Linux)
import FoundationNetworking
#endif

本編では APIKit を使っていましたが,同様のエラーが APIKit の内部でも出ます. ということで一旦 APIKit を使うことを諦めることにします. かと言っていまさらコードを書き換えるのも面倒なので,適当にパッチを書いてコンパイルを通しました.

$ docker run --rm -it -v $PWD:/adm -w /adm swift:5.3-focal swift build --configuration release
$ docker run --rm -it -v $PWD:/adm -w /adm swift:5.3-focal .build/release/apple-device-manager --help

OVERVIEW: Apple Device Manager

USAGE: adm <subcommand> # 以下略

Linux 向けにビルドするつもりなら,ライブラリを選ぶときは少し気をつけたほうが良さそう.

:memo:

参考