sniper, the OSX app terminator in Swift

sniper というコマンドラインツールをSwiftで作ったので、作る過程で得られた知見も交えて紹介したいと思います。

f:id:toshi0383:20151107082936p:plain

github.com

TL;DR

  • sniper は、アプリのプロセスを探して殺すことができる簡単なコマンドです。
  • bin/sniper をどこかにコピーして使ってください。
  • sniper はCIプロセスで使うとたまに便利だと思います。
  • sniperOSX以外のアプリは見つけられないし殺せません。
  • アプリのプロセスを殺すにはNSRunningApplicationを使いました。
  • コマンドラインツールにはDynamic Frameworkは組み込めないため、壮大なワークアラウンドが必要。(なので諦めましょう。)
  • サバゲーが楽しみすぎて sniper とかちょっと恥ずかしい名前つけてしまった。

背景

下記の課題がありました。

  • UIテストをCIプロセスに組み込みたいが、初回起動時にしか出ないダイアログとかがある
  • 使用するSimulatorを決めてアプリを使い回すよりは、毎回リセットして初回起動として扱いたい
  • Simulatorをリセットするには、泥臭いAppleScript を書くか、 Simulatorを削除(rm -rf ~/Library/Developer/CoreSimulator/Devices/*) するしかない模様だった
  • AppleScriptは書きたくない
  • でもSimulatorが起動している状態で上記の rm コマンドで削除すると、次回ちゃんと起動しなくなってテスト失敗する
  • Simulatorを駆逐したい

というわけで、Simulatorを殺すうまい方法はないかと調べていたわけです。

ps コマンドからの kill ${pid} のことを思い出したんですが、なんとなくOSXではご法度な気がするし。。

で、見つかりました。

NSRunningApplication

NSRunningApplication というのがあって、これに terminate() というメソッドがあります。こいつを取れれば、殺せそうです。

取得する方法はいくつかあるみたいで、

NSRunningApplication.runningApplicationsWithBundleIdentifier(bundleId)

もしくは

NSWorkspace.sharedWorkspace().runningApplications

でもこれで本当に自分以外のプロセスも取れちゃうんでしょうか。やってみましょう。

$ echo "import AppKit; NSWorkspace.sharedWorkspace().runningApplications.forEach{print(\$0)}" | xcrun swift
Welcome to Apple Swift version 2.1 (700.1.101.6 700.1.76). Type :help for assistance.
<NSRunningApplication: 0x10060aa50 (com.apple.loginwindow - 89)>
<NSRunningApplication: 0x10060aae0 (com.apple.inputmethod.Kotoeri - 271)>
<NSRunningApplication: 0x10060abe0 (com.apple.internetaccounts - 277)>
<NSRunningApplication: 0x10060ace0 (com.apple.AirPlayUIAgent - 279)>
<NSRunningApplication: 0x10060ade0 (com.apple.iChat - 296)>
<NSRunningApplication: 0x10060aee0 (com.apple.systemuiserver - 303)>
<NSRunningApplication: 0x10060afe0 (com.apple.dock - 302)>
<NSRunningApplication: 0x10060b0e0 ((null) - -1)>
<NSRunningApplication: 0x10060b1e0 (com.apple.finder - 304)>
<NSRunningApplication: 0x10060b2e0 (com.apple.sharingd - 309)>
<NSRunningApplication: 0x10060b3e0 (com.adobe.accmac.ACCFinderSync - 312)>
<NSRunningApplication: 0x10060b4e0 (com.apple.dock.extra - 315)>
<NSRunningApplication: 0x10060b5e0 (com.apple.Spotlight - 317)>
<NSRunningApplication: 0x10060b6e0 (com.apple.storeuid - 335)>
<NSRunningApplication: 0x10060b7e0 (com.apple.MailServiceAgent - 458)>
<NSRunningApplication: 0x10060b8e0 (com.apple.iTunesHelper - 486)>
<NSRunningApplication: 0x10060b9e0 (com.kensington.trackballworks.helper - 488)>
<NSRunningApplication: 0x10060bae0 (com.security.apple.Keychain-Circle-Notification - 468)>
<NSRunningApplication: 0x10060bbe0 (com.apple.TISwitcher - 487)>
<NSRunningApplication: 0x10060bce0 (com.apple.notificationcenterui - 470)>
<NSRunningApplication: 0x10060bde0 (com.fabriceleyne.hourlite - 491)>
<NSRunningApplication: 0x10060bee0 (com.linebreak.CloudAppMacOSX - 492)>
<NSRunningApplication: 0x10060bfe0 (com.apple.wifi.WiFiAgent - 481)>
<NSRunningApplication: 0x10060c0e0 (com.adobe.acc.AdobeCreativeCloud - 478)>
<NSRunningApplication: 0x10060c1e0 (com.adobe.AdobeIPCBroker - 504)>
<NSRunningApplication: 0x10060c2e0 (com.adobe.acc.AdobeDesktopService - 514)>
<NSRunningApplication: 0x10060c3e0 (com.adobe.acc.HEXHelper - 515)>
<NSRunningApplication: 0x10060c4e0 (com.adobe.accmac - 517)>
<NSRunningApplication: 0x10060c5e0 ((null) - -1)>
<NSRunningApplication: 0x10060c6e0 (com.apple.lateragent - 534)>
<NSRunningApplication: 0x10060c7e0 (com.apple.cloudphotosd - 729)>
<NSRunningApplication: 0x10060c8e0 (com.apple.EscrowSecurityAlert - 1049)>
<NSRunningApplication: 0x10060c9e0 (com.apple.nbagent - 1341)>
<NSRunningApplication: 0x10060cae0 (com.apple.Terminal - 1447)>
<NSRunningApplication: 0x10060cbe0 (com.apple.iCal - 52911)>
<NSRunningApplication: 0x10060cce0 (co.gitup.mac - 65045)>
<NSRunningApplication: 0x10060cde0 (com.apple.Safari - 16036)>
<NSRunningApplication: 0x10060cee0 (com.apple.WebKit.Networking - 16038)>
<NSRunningApplication: 0x10060cfe0 (com.tinyspeck.slackmacgap - 16258)>
<NSRunningApplication: 0x10060d0e0 (com.apple.qtkitserver - 16266)>
<NSRunningApplication: 0x10060d1e0 (com.apple.WebKit.Plugin.64 - 16272)>
<NSRunningApplication: 0x10060d2e0 (com.apple.WebKit.Databases - 84902)>
<NSRunningApplication: 0x10060d3e0 (com.apple.WebKit.WebContent - 2098)>
<NSRunningApplication: 0x10060d4e0 (com.github.atom - 5135)>
<NSRunningApplication: 0x10060d5e0 (com.github.atom.helper - 5141)>
<NSRunningApplication: 0x10060d6e0 (com.apple.coreservices.uiagent - 13010)>
<NSRunningApplication: 0x10060d7e0 (com.apple.WebKit.WebContent - 13061)>
<NSRunningApplication: 0x10060d8e0 (com.apple.WebKit.WebContent - 13066)>
<NSRunningApplication: 0x10060d9e0 (com.apple.WebKit.WebContent - 13067)>
<NSRunningApplication: 0x10060dae0 (com.apple.WebKit.WebContent - 13068)>
<NSRunningApplication: 0x10060dbe0 (com.apple.dt.Xcode - 13088)>
<NSRunningApplication: 0x10060dce0 (com.apple.iphonesimulator - 13512)>
<NSRunningApplication: 0x10060dde0 (com.apple.WebKit.WebContent - 13632)>
<NSRunningApplication: 0x10060dee0 (com.apple.WebKit.WebContent - 13633)>
<NSRunningApplication: 0x10060dfe0 (com.apple.WebKit.WebContent - 13636)>

めっちゃとれちょる。というわけでこれでいけそうです。

コマンドラインツールの作成

で、CIに組み込むのでコマンドラインツールにしたかったんです。
以前 kitasuke氏のスライド を見て試したことがあって、非常に大変だったことは覚えていたのですが、まあとりあえずやってみようということで。

で、今回は案外簡単にできました。
Xcodeのプロジェクトテンプレートも Command Line Tool です!
オプションのパース用にライブラリが必要だったのですが、たまたま選んだSwiftCLI がgit-submoduleでのインストール推奨にしてくれていたため、DynamicFrameworkのための壮大なワークアラウンドが必要なかったのです。
というかまあソースコード直接突っ込めばなんでもいけるわけです。ただ、SwiftはModuleのnamespaceがあるので、ClassPrefixをつけるとかいうObjective-Cの素晴らしい文化は、基本的にはありません。よってライブラリ同士でクラス名が競合することが多く、その場合どうするかというと。。考えたくもないですね。(自分でprefixつけて回る?いやいやいや。。)

なぜDynamicFrameworkがコマンドラインツールに直接組み込めないのかというと... まあいまはそういう仕様なんです。諦めましょう。
どうしても組み込みたい方は上記kitasukeさんのslideとか、これも参考になるかと思います。

使い方

使い方は、こんな感じになりました。README.mdから抜粋です。

# List all running apps
$ sniper target
37 apps found.
com.apple.loginwindow: 89
.
.
.

# Filter by keyword
$ sniper target firefox
1 app found.
org.mozilla.firefox: 4822

# Terminate process by bundle ID
$ ./sniper shoot -b org.mozilla.firefox
Terminating org.mozilla.firefox: 4822 ...

# Terminate process by PID
$ sniper shoot -p 4703
Terminating com.apple.iphonesimulator: 4703 ...

で、今回の目的 "Simulatorを駆逐する" を達成するには、こうすると良いと思いました。

$ sniper shoot -b com.apple.iphonesimulator  # shutdown before reset(remove) all simulators
$ rm -rf ~/Library/Developer/CoreSimulator/Devices/*

TravisやCircle、bitrise のようなCIサービスであればコンテナが毎回使い捨てだと思うので、なにも考えなくても毎回すべてのアプリが駆逐された状態かと思いますが、余っているMacにJenkins立てているケースも多々あるかと思いますので、そういった場合に使えるかと思います。

まとめ

というわけで、Swiftで簡単にコマンドラインツールを作ることができました。
簡単な処理だと他のscript言語の方が簡単にかけるケースの方がよっぽど多いと思いますが、Macアプリのプロセスをどうこうするとかの場合は、AppKitを使うのが一番手っ取り早そうだなというのが所感でした。

以上になります。

参考