@kjunichiです。この記事はQiita Node.js Advent Calendar 2019 6日の記事です。
背景
たまーに、流行のGitHub見て転職案内してくれるサイトに登録してるので、 KeyCast.js見ました!なんてメールをいただくことがありますが。 AIからのメールなのはバレバレでもメンタル豆腐なので、この数年メンテしておらず心苦しくになることがあるので、 重い腰を上げて対応しようとしたら、NodObjCが不安定すぎて、別の方法を模索している。
ってのと、Keycast.jsが以前のアドカレがきっかけで作成したので、新ネタ 思いつかなかったというのもある。
Node.jsで直接Cocoaを扱えなくなった?!
node-ffiはここ数年更新がされていない状態が続いている。
NAPIに対応した node-ffi-napiが作られている。これはNode.jsにスレッドを入れた方で、Node学園祭でもスピーカで 来日された @addaleaxさんがメンテしている。
しかし、こちらのNAPI版のffi-napiではNodObjCをサポートしないっぽい。以下のISSUE参照。
ということで、今回は、Cocoa APIをPythonのライブラリであるPyObjCから叩くことにした。 PyObjCは開発が活発なようで今年もアップデートされているのを観測済み。
HomebrewなPythonへの対応
dylibではなく、.framework形式で共有ライブラリがインストールされるので、node-ffi-napiで通常の処理ではアクセスできない。
node-ffi-napiのコードを眺めて以下のようにすれば解決した。
const handle = ffi.DynamicLibrary('/usr/local/Cellar/python/3.7.5/Frameworks/Python.framework/Versions/3.7/Python') const libpython={} libpython.Py_SetProgramName = ffi.ForeignFunction( handle.get('Py_SetProgramName'), 'void', [voidPtr] )
ffi.DynamicLibraryにてFrameworkの中身?を指定して、そのハンドルを取得して、 取得したハンドルに対してgetメソッドで個々のframework内のシンボル名を指定することでCで言うところの関数ポインタが 取得できて、これを使って、ffi.ForeiginFuncionでJSでの関数を取り出す事ができる。
Pythonからの結果を受け取るのがめんどくさい問題
Node.jsからnode-ffi-napiを経由してPythonを経由してPyObjCでMacOS側からキーイベントを受け取ることはできるが、 これを受取には、Node.js側で、node-ffi-napiを経由してコールバック関数をPython側に渡す必要が出てくる。
これ、できなくはないが、めんどくさい。コールバック関数の型定義が複雑(個人の感想です)
ところで、PythonもNode.jsもhttp通信が追加モジュールなしで行える!
これだ! Python側で取得したキーイベントをJSONでNode.js側にPOSTすれば解決だ。
HTTPクライアントとHTTPサーバーを一つのプログラムで動かすには
シングルプロセスで、シングルスレッドではNode.jsからPythonを呼び出して、Python側でHTTPを呼び出しても、 Node.js側が受信待ち状態になってないので、アウト!という落とし穴が待ち受けていたw
しかし、この問題の対策として、Node.js側でスレッドを追加してマルチスレッドにすることで対応できる!
// HTTPサーバーを別スレッドで動かす worker = new Worker(path.join(__dirname, "../httpserver.js"), { workerData: 0 }) worker.on('message', (msg) => { //console.log(`${msg}`) event.reply('nsevent', msg) }) // Pythonを呼び出す const voidPtr = ref.refType(ref.types.void); const handle = ffi.DynamicLibrary('/usr/local/Cellar/python/3.7.5/Frameworks/Python.framework/Versions/3.7/Python') libpython.Py_SetProgramName = ffi.ForeignFunction( handle.get('Py_SetProgramName'), 'void', [voidPtr] ) libpython.Py_Initialize = ffi.ForeignFunction( handle.get('Py_Initialize'), 'void', [] ) libpython.PyRun_SimpleString = ffi.ForeignFunction( handle.get('PyRun_SimpleString'), 'int', ['string'] ) libpython.Py_Finalize = ffi.ForeignFunction( handle.get('Py_Finalize'), 'void', [] ) libpython.Py_SetProgramName(null) libpython.Py_Initialize() const pyscript = fs.readFileSync(path.join(__dirname, 'api.py')) libpython.PyRun_SimpleString(pyscript)
httpserver.js
// server.js // worker thread const { parentPort } = require('worker_threads') const http = require('http') const server = http.createServer() server.on('request', (req, res) => { if (req.method === 'POST') { var data = '' //POSTデータを受けとる req.on('data', (chunk) => { data += chunk }) .on('end', () => { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.write('hello world') res.end() parentPort.postMessage(data) }) } //console.dir(req) }) server.listen(3000) server.on('listening', () => { // ここで random port が取れる const port = server.address().port console.log(`port = ${port}`) parentPort.postMessage(`port = ${port}`) }) parentPort.on('message', (msg) => { process.exit() })
Cocoa APIの罠
前述の一連の方法を素のnode.jsで動かそうとするとCocoa APIの呼び出しで
(スクショ撮り忘れてるので、後日貼ります。。)
というようにmacOSからダメだしされる。
electronで動かすことで対処できる。おそらくmacOSのアプリとして必要なAPIの呼び出しをelectronが済ませてくれるからと 思われる。
また、コマンドラインから動かす場合、使っているターミナルソフト(標準ならTerminal.app)にコントロールパネルの セキュリティ設定でアクセス許可を与える必要がある。
electronをツールなりでパッケージ化して.appにして動かす場合は、.appを一度起動して、同様にコントロールパネルの セキュリティ設定でアクセス許可を与える必要がある。
electronをパッケージ化して発覚した問題
どうもworkerを作る際にasar形式の中のファイルにアクセスできないっぽい。
対応策としては、.appの中のResourceにworkerで読み込む.jsファイルを格納して物理的に独立したファイルとして 用意。
const builder = require('electron-builder') const Platform = builder.Platform const fs = require('fs') builder.build({ targets: Platform.MAC.createTarget(), config: { 'appId': 'com.hatenablog.abrakatabura.keycastjs', 'target': 'dir' } }).then(()=>{ fs.copyFileSync('./httpserver.js','dist/mac/keycastjsdev.app/Contents/Resources/httpserver.js') }).catch((e) =>{ console.log(e) })
実行結果
ごちゃごちゃ書いてもコードが動かないと説得力ないので、動画を上げました。
成果物
ごめんなさい、Keycast.jsにこれらの成果を反映させた版は担当の日までに間に合いませんでした。。 出来れば、年内に、Githubに上げたいです。
実験用リポジトリを晒しておきます。
git clone cd keycastjsdev npm install node build.js open dist/mac/keycastjsdev.app
まとめ
- Node.jsでマルチスレッドを使った。
- プログラム内でHTTP通信することで、割と疎結合でPythonとNode.jsをつなぐことが出来た。
- Node.jsの12系でnpm installしたネイティブモジュールはElectron v7系でもそのまま動くことが分かった。
- Electronをアプリ化してスレッドを使う際は.jsファイルをasarの外に置く必要がありそう。
- PythonでのJSONをPOSTする方法が学べた。
- Node.jsでのPOSTされたJSONを受取る方法が学べた。
関連記事
- MavericksでNodObjCを試すには
- それでもNode.jsをMacアプリ化するをやる
- Cocoa APIのAXIsProcessTrusterdとシステム環境設定のセキュリティとプライバシーの関係
- NAPI版のnode-ffi-napiを使ってNode.jsからPythonを動かす