SummerWind

Web, Photography, Space Development

JXA で AXUIElement を使用して UI の情報にアクセスする

macOS の JavaScript for Automation (JXA) で Visual Studio Code の UI の情報にアクセスがしたくなったので調べてみたところ、AXUIElement を使えばできることが分かったのでその方法をメモしておく。ただ先に書いておくと、結構コードがトリッキーでデバッグもなかなか難しいため、素直に Swift を使って実装した方がいいかもしれない。

まず、AXUIElement は Application Services フレームワークに含まれているので、JXA からは Objective-C ブリッジ経由でこれにアクセスする。JXA でフレームワークをインポートすると次のようなコードになる。ここではついでに関連する Cocoa もあわせてインポートしている。

ObjC.import("Cocoa");
ObjC.import("ApplicationServices");

次に CF で始まる名前の型を NS で始まる名前の型に変換するために CFMakeCollectable を次のように明示的に bind しておく。これについては自分も完全に理解したわけではないが、JXA から値にアクセスするには NS で始まる型になっていた方が都合が良いので、その変換を容易にするための Hack だと理解している。

ObjC.bindFunction("CFMakeCollectable", ["id", ["void *"]]);

フレームワークのインポートや関数の bind ができたら、実行中の VSCode のアプリケーションオブジェクトを Bundle Identifier から取得する。$ は Objective-C ブリッジでインポートした各種フレームワークの操作に使用する。

const id = "com.microsoft.VSCode";
const vscode = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(id).firstObject;

次に AXUIElementCreateApplication を呼び出してトップレベルのアクセシビリティ要素を取得する。呼び出し時には実行中の VSCode の PID を指定する。

const app = $.AXUIElementCreateApplication(vscode.processIdentifier);

トップレベルの要素が取得できたので、ここからアプリの UI の各種属性値にアクセスできる。属性値の取得には AXUIElementCopyAttributeValue を使用する。例えばアプリケーションの名前を取得する場合は次のようになる。Objective-C ブリッジ経由で取得した値はそのままだと JavaScript の値としては扱えないため ObjC.unwrap()ObjC.deepUnwrap() などを使う必要があることに注意する。

// 要素の属性名
const attr = $.CFStringCreateWithCString($.kCFAllocatorDefault, "AXTitle", $.kCFStringEncodingUTF8);
// 属性値を受け取るための参照
const valueRef = Ref();

// トップレベル要素からアプリ名を取得
const ret = $.AXUIElementCopyAttributeValue(app, attr, valueRef);
if (ret !== 0) {
  throw new Error(`failed to get attribute value: ${ret}`);
}

// アプリ名を NSString に変換してから、JavaScript の文字列型に変換
const title = ObjC.unwrap($.CFMakeCollectable(valueRef[0]));

// アプリ名を表示
console.log(title); // => "Code"

要素から取得可能な属性は AXUIElementCopyAttributeNames を呼び出すと確認できる。なお取得可能な属性は要素の種類によって異なるので注意が必要。

// 属性リストを受け取るための参照
const namesRef = Ref();

// 要素から属性リストを取得
const ret = $.AXUIElementCopyAttributeNames(app, namesRef);
if (ret !== 0) {
  throw new Error(`failed to get attribute names: ${ret}`);
}

// 文字列の配列に変換
const names = ObjC.deepUnwrap($.CFMakeCollectable(namesRef[0]));

// 属性リストを表示
console.log(names); // => AXFunctionRowTopLevelElements,AXManualAccessibility, ...

AXUIElement の要素はツリー状になっているので、AXChildren 属性を指定して子要素を取得し、UI の各要素を辿っていくことができる。アプリの要素がどのようなツリー構造になっているかは、XCode に付属する Accessibility Inspector を使えば実際の画面の要素を指定して確認できる。

Moto Ishizawa

Moto Ishizawa
ソフトウェアエンジニア。ロケットの打上げを見学するために、たびたびフロリダや種子島にでかけるなど、宇宙開発分野のファンでもある。