takasekといいます。iOSDCにて、Xcode Source Editor Extensionの世界という発表をしました。
iOSDC ですけどiOSの話は一切出てきません。

iOSDC での発表について

かいつまむと、以下のような話です。

Xcode8から導入されたXcode Source Editor Extensionは、思ったより簡単に作れるんですが、正攻法だとやれることが少ないです。

  • 現在編集中のファイルのテキストバッファに読み書きする
  • カーソル位置・文字選択状態を変更する

それだけしかできません。
Xcode Source Editor Extensionの世界は、高い壁に阻まれています。その壁を、どう登るのか。

  • 壁(1)入出力の壁: AppKit の助けを借りればペーストボードや他アプリとの連携が可能
  • 壁(2)言語機能の壁: Process を使ってある程度のLinuxコマンドを実行可能
  • 壁(3)ネットワークの壁: ネットワークに繋ぎたいときはApp Sandboxで Outgoing connections (Client) にチェックを入れる
  • 壁(4)Sandboxの壁: Sandboxによって制限されているファイルシステムへのアクセス等を実現するためには XPC を仲介する
    • ただしApp Storeには出せなくなります。GitHubに公開して誰かのcloneを待つ、野良extensionとして生きる覚悟を。
  • 壁(5)GUIの壁: GUIを出したければ、extensionから自身のApplicationを立ち上げて、そちらで処理をする
    • App ⇄ extension間のデータの受け渡しにも色々方法がある

等々、色々やりようはあるのですよ、という発表でした。詳しくはスライド(SpeakerDeck)セッション動画(Youtube)見て下さい。

壁(6) Xcode そのものの操作の壁

そんな発表を踏まえた今回は、さらにもうひとつの壁を登りましょう

image.png

5つの壁を登った結果、extensionでやれることがずいぶんと増えました。
しかし強力になったのはプロジェクト外部との連携方法に過ぎません。情報の取得・書き出しに使えるのは、結局は、現在編集中のテキストバッファだけ。
現在のプロジェクトの情報などを取得したり、Xcodeをダイレクトに操作できればもっと良いのですが……。
と思っていたところ、実現する方法が見つかりました。それが、Scripting Bridgeです。

Scripting Bridge とは

Introduction to Scripting Bridge Programming Guide for Cocoa

A scriptable application is one that you can communicate with from a script or another application, enabling you to control the application and exchange data with it.

Cocoaアプリケーションの中には、 Apple Script や他のアプリケーションによって操作可能なインタフェースを備えたものがあります。そして、Xcodeもそのひとつです。
このインタフェースにソースコード上でアクセスできるようにしてしまえば、我々の勝ちです。

extension上でScripting Bridgeを使うにあたっての注意点

extensionのコマンド内でダイレクトにScripting Bridgeにアクセスしようとすると、失敗します。どうやらSandboxのせいらしいです。
ということは、解決のためには、 壁(4) で述べたとおり、 XPC Service を仲介する必要があります。

Scripting Bridgeの前に… XPC Serviceを導入する

プロジェクトのTARGETSの追加から XPC Service を選びます。

image.png

名前は XcodeHelper とでもしましょうか。

image.png

Build Phasesで extension に xpc をコピーするのを忘れないようにしてください。
(自分は、これを知らずに長期間ハマりました)

image.png

Bridging-Headerを作成してBuild Settingsに設定すれば、Swiftで書かれたextensionのコマンドからでもXPCにアクセスできます。

image.png

XPCServiceを新規作成した時、何もしなくてもテンプレートとして、 upperCaseString(_:withReply:) というメソッドが生えています。
SourceEditorCommand に次のようなミニマムなコードを書いて、動作確認してみましょう。

        let connection = NSXPCConnection(serviceName: "XcodeHelperのbundle identifier")
        connection.remoteObjectInterface = NSXPCInterface(with: XcodeHelperProtocol.self)
        connection.resume()
        let xcode = connection.remoteObjectProxy as! XcodeHelperProtocol

        let semaphore = DispatchSemaphore(value: 0)
        xcode.upperCaseString("aaaa") { (str) in
            invocation.buffer.lines.add(str!)
            semaphore.signal()
        }
        _ = semaphore.wait(timeout: .now() + 10)

Run すると黒いXcodeが立ち上がりますので、適当なソースコード上でextensionのコマンドを実行します。

image.png

エディタに AAAA と追記されれば、XPCとの接続は成功です。

ようやく Scripting Bridge の使い方

Xcodeのスクリプティング・インタフェースを出力するには、以下のコマンドを叩くだけです。

$ sdef /Applications/Xcode.app | sdp -fh --basename Xcode

Xcode.h が出力されました。 Objective-Cのヘッダファイルです。
これをXcodeに突っ込んでやりましょう。

image.png

これで準備は完了です。
Scripting Bridgeは、Swiftとの相性が悪いです。Obj-Cのヘッダを介してはいるもののその実装がないためです。Swiftから使うことも、ヘッダの定義を地道に protocol に書き替えれば不可能ではありせんが、ここは大人しくObj-Cで書いてみます。XPC Serviceを新規作成した際のテンプレートもObj-Cなので、丁度いいです。
テンプレートの upperCaseString(_:withReply:) の中身を書き替えましょう。(メソッド名と処理が合わなくなる? 許して!)

- (void)upperCaseString:(NSString *)aString withReply:(void (^)(NSString *))reply {
    XcodeApplication *app = [SBApplication applicationWithBundleIdentifier: @"com.apple.dt.Xcode"];

    XcodeWorkspaceDocument *workspaceDocument = app.activeWorkspaceDocument;

    SBElementArray<XcodeProject *> *projects = workspaceDocument.projects;
    XcodeProject *project = projects.firstObject;

    SBElementArray<XcodeWindow *> * windows = app.windows;
    XcodeWindow *window = windows.firstObject;

    XcodeScheme *scheme = workspaceDocument.activeScheme;

    NSString *response = [NSString stringWithFormat:
                          @"Xcode version: %@\nworkspaceDocument.file: %@\nproject.name: %@\nwindow.name: %@\nscheme.name: %@",
                          app.version, workspaceDocument.file, project.name, window.name, scheme.name
                          ];
    reply(response);
}

extensionを再実行し、コマンドを走らせると……

image.png

いとも簡単にXcode内部の情報が取れました。 :tada:
また未検証ですが、情報を取得するだけでなく、Xcodeに対しての操作もある程度は可能なようです。

@protocol XcodeGenericMethods

- (void) closeSaving:(XcodeSaveOptions)saving savingIn:(NSURL *)savingIn;  // Close a document.
- (void) delete;  // Delete an object.
- (void) moveTo:(SBObject *)to;  // Move an object to a new location.
- (XcodeSchemeActionResult *) build;  // Invoke the "build" scheme action. This command should be sent to a workspace document. The build will be performed using the workspace document's current active scheme and active run destination. This command does not wait for the action to complete; its progress can be tracked with the returned scheme action result.
- (XcodeSchemeActionResult *) clean;  // Invoke the "clean" scheme action. This command should be sent to a workspace document. The clean will be performed using the workspace document's current active scheme and active run destination. This command does not wait for the action to complete; its progress can be tracked with the returned scheme action result.
- (void) stop;  // Stop the active scheme action, if one is running. This command should be sent to a workspace document. This command does not wait for the action to stop.
- (XcodeSchemeActionResult *) runWithCommandLineArguments:(id)withCommandLineArguments withEnvironmentVariables:(id)withEnvironmentVariables;  // Invoke the "run" scheme action. This command should be sent to a workspace document. The run action will be performed using the workspace document's current active scheme and active run destination. This command does not wait for the action to complete; its progress can be tracked with the returned scheme action result.
- (XcodeSchemeActionResult *) testWithCommandLineArguments:(id)withCommandLineArguments withEnvironmentVariables:(id)withEnvironmentVariables;  // Invoke the "test" scheme action. This command should be sent to a workspace document. The test action will be performed using the workspace document's current active scheme and active run destination. This command does not wait for the action to complete; its progress can be tracked with the returned scheme action result.

@end

@interface XcodeApplication : SBApplication

- (id) open:(id)x;  // Open a document.
- (void) quitSaving:(XcodeSaveOptions)saving;  // Quit the application.
- (BOOL) exists:(id)x;  // Verify that an object exists.

@end

まとめ

如何だったでしょうか。
Scripting Bridgeを使えば、Xcodeを色々いじって楽しめそうですね。皆さんも是非、Xcode Extensionで遊んでみて下さい。
なお今回の調査にあたり、 @norio_nomura さんに多くのコツをご教授いただきました。ありがとうございました。

最後に宣伝

iOSDCの登壇をきっかけに、自分でXcode Source Editor Extensionを作ってストアで頒布してみました。
PasteTheTypeというextensionです。よかったら使ってみて下さい。

800x500bb.jpg
800x500bb.jpg