🍎

(わりと)任意の Mac Application に GraphQL でアクセスできるようにした

2022/03/06に公開

「(わりと)任意」、と書きましたが実際には AppleScript をサポートしている Application が対象で、基本的に AppleScript で取得できる情報については全て取得できるはずです。現状では read のみ実装しています。
ソースコードはこちら
https://github.com/potsbo/jxa-graphql

使い方

実行中の Google Chrome に対して incognito の window で開いている tab の titleurl が欲しい、という query を実行してみている例です。

npx で試す

とりあえずサクッと試す場合は npx jxa-graphql server <appPath> で試せます。curl でも良いですし Apollo Sandbox の Explorer などを使っても良いでしょう。

$ npx jxa-graphql serve /Applications/Google\ Chrome.app
🚀  Server ready at http://localhost:4000/

# in another shell
$ curl 'http://localhost:4000/' \
  -H 'content-type: application/json' \
  --data-raw '{"query":"query { application { name } }"}' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   107  100    50  100    57    548    625 --:--:-- --:--:-- --:--:--  1371
{
  "data": {
    "application": {
      "name": "Google Chrome"
    }
  }
}

ライブラリとして

下のように使うことで、サーバーを経由せずに使うこともできます。Raycast extension などに使う場合はこの手法を取ることになるでしょう。

import { graphql } from "graphql";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { runJXACode } from "@jxa/run";
import { buildRootValue, buildSchema } from "jxa-graphql";

(async () => {
  // build schema from AppleScript `sdef` file
  const typeDefs = await buildSchema("/Applications/Google Chrome.app");
  // build rootValue, which can compile GraphQL queries into jxa code
  const rootValue = buildRootValue("Google Chrome", runJXACode);
  const source = `
    query {
      application {
        name
      }
    }
  `;

  // compile typeDef and resolvers into executable schema
  const schema = makeExecutableSchema({ typeDefs, resolvers: { Query: rootValue } });

  // execute
  const { data } = await graphql({ schema, source });
  console.log(JSON.stringify(data)); // prints {"application":{"name":"Google Chrome"}}
})();

全ては AppleScript で困らないため

まずはなぜこんなものを作ったのかのモチベーションを書きます。

ただ OmniFocus にアクセスしたかっただけ

前の記事で Raycast の extension の作り方がわかったので OmniFocus の extension を作ろうと思い立ちました。

AppleScript を使うしかなさそう

少し調べた限り OmniFocus のデータにアクセスするには AppleScript を使うしかなさそうでした。AppleScript を今から学ぶのは厳しいなと思いましたが、同等のことができるインターフェースとして JavaScript も提供されていて JXA と呼ばれています。後述する「Application コードと混ぜづらい」という性質と、特殊な仕様の処理系で処理されるという性質のため JavaScript の常識を持ち込むと頭が混乱するので、JXA という JavaScript に似た別言語があると思ったほうが何かと楽です。実際 JXA は AppleScript と同様に仕様が難解でドキュメントに乏しいので使ってみるとめちゃくちゃバグり散らかしました。

JXA と JavaScript は分離する必要がある

どんな自分の知らない常識を要求されてもその枠を超えないように型が守ってくれたら良いのでは?と思って TypeScript に移行して自分で型を書いたり @jxa/sdef-to-dts を活用したりしました。ある程度うまくいくものの結局生成物は JavaScript であり、JXA ではうまく動かないものが生成される場合があります。結局 JXA は JavaScript と似た挙動をするなにかでしかないのです。

また、JXA の呼び出し方が osascript コマンドに渡すという方法しかなく、これのために「実行したい JXA コードを文字列で渡して結果を文字列で受け取る」という制約が課されます。つまりデータ取得の JXA を JavaScript で記述する場合、toString したときに実行可能な JXA になっている必要があるわけです。

これがかなり厳しい制約で、外部ライブラリを使ったり、高階関数を使いたかったりすると簡単に [object Object] など JXA として解釈できない文字列を生じてしまいます。これを回避するには、Application 用のコード(JavaScript)とJXAのコードを完全に分離するというプラクティスを取るという方針をとらざるを得なくなります。この意味で JXA は JavaScript とは別言語で FFI で呼んでいるくらいの気持ちを持ったほうがよいと考えています。

汎用性のある JXA を書くのは難しい

この「分離する必要がある」というのは結構大きな問題です。例えば下のように 10 の field を持つ 1000要素 の array があるとしましょう。

// こういうデータが JXA 経由で取得可能だとする
const data = [
  { field0: 1, ..., field9: 10 },
  { field0: 2, ..., field9: 20 },
  { field0: 3, ..., field9: 30 },
  // これが 1000 個続く
]

ここで field0 にしか興味なかったり data.slice(0, 5) にしか興味興味がなかったりする場合に取れる方針は2つです。

  • JXA からはとりあえず全部のデータを貰って Application で filter する
  • 絞り込みがしたくなるたびに、それをサポートするように JXA を書き換える

前者の方針は、予想以上にデータ量が簡単に膨れ上がって律速になる要因になりました。後者では実質的に json に落とせる情報しか渡せないので REST API を書くのと完全に同じ気持ちになりますが、データとパラメーターの間に O/Rマッパー のようなレイヤがないので手作業の実装が多くて大変でした。O/Rマッパー 相当のレイヤがないことで association を掘ろうとすると簡単に N+1 問題も生じるので、一回の実行ミニマルで 10ms 程度かかる JXA においては実装はなかなか大変な問題でした。

GraphQL にしよう

色々書いてきましたが結局今回の問題の要件は下のようにまとまります。

  • 型がついた状態で安心して開発したい
  • 欲しい field だけを選択的に取得したい (速度のため)
  • どれだけ association を深ぼっても一回の JXA 実行だけで取得したい (速度のため)

この性質を考えるとどう考えても GraphQL の得意分野じゃね?ということで GraphQL で問題を解いてみたらかなり良い体験を提供できるようになりました。少し工夫をしたら任意の AppleScript をサポートしているアプリケーションに対応できたのでライブラリに切り出した、というのが今回の作成物です。順を追ってどうやって作ったのかを見ていきましょう。

schema を自動生成する

GraphQL を使うには良い schema が必要ですが、これを自分で書いたら辛いので自動生成するようにしました。sdef コマンドを使えば特定の Application がどんな script の interface を持っているかわかるのでこれを使って parse して schema に落とすようにしました。他の sdef ファイルを include できるという機能にあとから気づいて設計変更を余儀なくされたのがちょっとつらかったです。

schema だけ欲しい場合は下のようなコマンドで生成できます。

$ npx jxa-graphql schema /Applications/Google\ Chrome.app
// 標準出力に graphql schema が吐かれる

結構地味で大変だったんですがやるだけっちゃやるだけなので詳細は割愛します。Application への path を sdefnpx jxa-graphql schema に渡してみて見比べたら何をやっているか多分わかります。

GraphQLResolveInfo から全てを取得する

さて schema ができても今回の問題では「field ごとに resolver を書いていけば好きな情報が取れるようになる」という GraphQL の常識が通用しません。JXA実行を一回にするということは root の object の解決が終わったタイミングでは全てのデータが取得完了している必要があるからです。

つまり今回のタスクでは絶対に複数の resolver を使えないという制約が乗ることになります。これにより、取れる解決方法は実質的に 「schema と query から JXA を生成する」に収束しました。実際に作ったコードでは resolver は下のように全ての引数を無視して GraphQLResolveInfo だけを使っています。

src/rootValue.ts
return (_1: unknown, _2: unknown, _3: unknown, info: GraphQLResolveInfo) => {
  // JXA のコード生成
  const code = compile(appName, info);
  // default では `@jxa/run` の `runJXACode`
  return runner(code); // response の `data` の要件を満たす object が得られる
};

この compile の実装が今回の本質ですが、自明っぽい割に地味に大変なところがあるという解説には向かない代物なので割愛します。興味がある人は src/compiler/index.ts を参照してください。ザックリいうとある node の field a が取得したかったら { a: node.a() } [1] などと言う文字列を生成していくだけです。

対応した細かい機能

whose

選択的な field の読み出しは比較的自明ですが、array の中から特定の条件を満たす要素だけに絞るというのはそうでもありません。任意の predicate に対応した filter をサポートするは困難ですが、 特定の field をベースにしたものであれば built-in の whose が使えます。
これを使って下のように任意の field の条件を指定できるようにしました。[2]

query {
  flattenedTasks(
    whose: {
      operator: "and"
      operands: [
        { field: "effectivelyCompleted", value: "false" }
        { field: "flagged", enabled: $onlyFlagged }
        {
          operator: "not"
          operands: [{ field: "effectiveDeferDate", value: "null" }]
        }
      ]
    }
  )
}

内部ではこんな JXA に変換されます。

...省略
flattenedTasks.whose({
  _and: [
    { effectivelyCompleted: { _equals: false } },
    { flagged: { _equals: true } },
    { _not: [{ effectiveDeferDate: { _equals: null } }] },
  ],
})
...省略

Pagination

下のように書けるように pagination をサポートしてます。

query {
  application {
    windows(first: 42, after: 1234) {
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
      edges {
        cursor
        node {
          name
        }
      }
    }
  }
}

あとから思えばちょっと too much [3]でしたが、GraphQL Cursor Connections Specification に(ほぼ)準拠するようにしました。

内部的には ObjectSpecifier というポインタ的な object の列の slice をとっています。after を使うと全要素を含む array に findIndex を書けているので最終的には一応 O(N) かかってしまってますが、定数項部分がかなり小さくなってくれてるはずです。(ちゃんと測ってないけど体感ではこれで結構早くなった。)

InlineFragment

sdef ではカジュアルに継承が使われるのでこれを GraphQL で再現すると Interface を使うことになり InlineFragment のサポートが無いと継承ツリーの一番上の親の field しか取れなくなってしまいます。

実はこれがかなり大変でした。理由は、InlineFragment を使うと __typename を返す必要に迫られることです。つまり JXA で reflection ができないと行けないということになります。よく調べると各 object に自分の constructor が誰かを示す field は見つかりましたがこれが Application によって異なるので悩みました。
最終的には下のコードで取得できるという結論に至りました。

// ObjectSpecifier を文字列で取得
const specifierStr = Automation.getDisplayString(obj) // Application("OmniFocus").defaultDocument.folders.byId("avhc4B1U1p5")

// これを eval して ObjectSpecifier のインスタンスにする
const specifier = eval(specifierStr)

// それの classOf を取ると class 名になる
ObjectSpecifier.classOf(specifier) // "folder"

なぜいちいち文字列にして eval する必要があるのかは謎ですが、この操作をしないと親クラスの名前が帰ってくることがあるので一応挟んでいます。

この機能のおかげで例えばこんな事ができるようになります。

# 起動コマンド: npx jxa-graphql serve /System/Library/CoreServices/Finder.app
# リンク先が存在しない alias があると落ちてしまうので注意
query {
  application {
    # home directory を指定
    home {
      # file を全て list
      files {
        edges {
          node {
	    # Alias だったら original を取りに行く
            ... on AliasFile {
              originalItem {
                ... on DocumentFile {
                  url
                }
              }
            }
	    # 普通の file だったらそのまま path をもらう
            ... on DocumentFile {
              url
            }
          }
        }
      }
    }
  }
}

これからやりたいこと

そもそもの目標はいい感じの Raycast 拡張を作ることだったのでそれもやりたいんですが、こっちにはまってしまったので当分こっちで遊ぼうかなと思っています。これからの展望としては mutation とか複数 application を 1 query で取りたりする機能がつけられるといいなと思っています。

これからの開発

この記事の example を作る過程で5個くらいバグを直しました。まだそれくらいの完成度だし、これからも大きくデザインを変えないといけなくなる可能性も大いにあるので「とにかくテストは厚めに書く」「必要になるまでは実装しない」「取りあえず動くようにする」というようなアプローチで進んでいこうと思っています。なにかに応用したい人がもしいたら是非フィードバックとかくれると嬉しいです。

参考

脚注
  1. JXA では field の中身が欲しい場合は関数として実行することになっています。 ↩︎

  2. 余談ですがすごく [RFC] GraphQL Input Union type が欲しくなりました。 ↩︎

  3. edge を挟む理由が今の所まったくない ↩︎

Discussion