スマホのキャプチャを扱いやすくするCLI「pbssc」を作った
久しぶりの更新です。
コードレビューや動作確認などでスマホ画面のキャプチャを貼り付けたくなることがあります。
そういう時に、シュッとキャプチャをとってClipboardに貼り付けられるツールを作りました。
インストールはHomebrew経由で行えます。
$ brew tap sakebook/tap && brew install pbssc
オプションをつけてOSを指定して実行できます。
// Android $ pbssc -a // iOS $ pbssc -i
READMEにdemo動画があります。
実装のコアは既存ツールに依存
実際にキャプチャを撮る部分はadbとlibimobiledeviceに依存しています。
adb、つまりAndroid端末だと次のようにキャプチャを取れます。
$ adb exec-out screencap -p > example.png
libimobiledevice、つまりiOS端末だと次のようになります。
$ idevicescreenshot example.png
キャプチャを撮るだけであればそれぞれのツールで既にサポートされていたので問題ないのですが、今回特に解決したかったのは Clipboardへの貼り付けでした。
Clipboardへの貼り付け
pbcopy
が使えるかと思ったのですが、 画像pathからは渡すことができませんでした。
そこで今回使ったのは AppleScript です。
AppleScript
AppleScript is a scripting language created by Apple. It allows users to directly control scriptable Macintosh applications, as well as parts of macOS itself. You can create scripts—sets of written instructions—to automate repetitive tasks, combine features from multiple scriptable applications, and create complex workflows.
DeepL翻訳
AppleScriptは、アップル社が開発したスクリプト言語です。ユーザーは、スクリプト可能なMacintoshアプリケーションやmacOS自体の一部を直接制御することができます。スクリプト(一連の命令文)を作成することで、反復的なタスクを自動化したり、複数のスクリプト可能なアプリケーションの機能を組み合わせたり、複雑なワークフローを作成したりすることができます。
試しにどんなものか確認したい場合は、手元で次のコマンドを実行するとmacOSのアラートを表示することができます。
$ osascript -e 'display alert "Hello World"'
AppleScriptを使えば、macOS上の.appなども起動したりできます。cronで動かすことで日々のルーチンの自動化などもできそうですね。
今回はAppleScriptを使ってClipboardを操作しました。
既存ツールとAppleScriptを混ぜる
ShellScriptからAppleScriptを呼び出すことも、その逆も可能でしたが、今回はShellScriptからAppleScriptを呼び出すことにしました。既存ツールの存在確認などがやりやすいと思ったからです。
AppleScriptを実行するときは osascript
コマンドを使います。なので、ShellScript内では次のように記述してAppleScriptを呼び出します。
copy_to_clipboard () { osascript -e " on run set the clipboard to POSIX file \"$filepath\" end " echo "Set image to clipboard from $filepath" }
関数にすることは必須ではないです。
これらをまとめて取り扱いやすくしたのがpbsscです。
まとめ
同じような課題感を持っている人に対して便利なツールになったと思います。
直接使わなくても、今回紹介した内容が間接的にでも助けになれば良いなと思います。
参考
Android Debug Bridge (adb) | Android Developers
2020年を振り返って
目標とか
英語
年始は本を読んだり洋書(絵本)に手を出してたのですが、わりと早い段階で途切れてしまっていました。しかしTwitterで知り合いが英語をコツコツ継続して勉強してるのを見て、自分もやらねば!と復活しました。
Duolingoというアプリで英語を学んでいます。割と破壊的な変更が予告なく行われる点はうーむといった感じなのですが、自分のスタイルとあっているみたいで継続はできています。
ただ、学んでて感じるのは単語力が必要なのと、リスニング、作文がDuolingoだと学ぶのが難しいです。
このあたりは実際にリスニングや作文をしないと身につかないので、最近はHelloTalkというアプリを教わったので使ってます。
きちんと学んでいる人とつながれば、ネイティブスピーカーと会話できるので良いなと感じています。また、日本語を学んでいる外国人の投稿も見れるので、お互い得意な言語で文章を修正し合ったり、そういうところを疑問に思うんだなというのが見え、気づきがあって面白いです。
リングフィットアドベンチャー
今年といえばコロナがあり、尚の事外に出る機会も減り、運動が必要になる年でした。
しかし、部屋のレイアウトを変更してから全くできていません。こちらは習慣が切れてしまったのでなんとか巻き直す必要があります。
同僚が毎日リングフィットを継続できているみたいで、そこから刺激をもらってやっていきたい。
Flutter
全然触れてなかったです。Kotlin/JSで小さく動くものを作ってばかりだった気がします。
この1年でFlutterの事例なども無数に増えて、そこまで珍しいものでは無くなっている感じがします。
使われて当たり前の技術になっていくのであれば、尚の事抑えたいのでこちらも継続して取り組んでいきたいことには変わらないです。
やったこと
Kotlin/JS
いくつか動くものを作りました。
オウム返しするだけのLINE Bot
任意のURLのキャプチャをSlackに投稿してくれるやつ
Server Side Kotlin
業務でKtor + Firebase + Cloud Runでサーバレスな通知基盤とCRUDなAPIを用意したりしました。
普段あまりサーバに触れないので経験としてよかったです。
Kotlin/JSでサブシステムも作ったりはしていたのですが、本運用はしていません。
シニアエンジニアへの道
会社で求められることも少し変わったりしました。今はスクラムマスター的なことにも取り組んでいます。 社内で明確に次のステップが用意されてるのはありがたく思います。
Contribute
Exposure Notifications APIが公開され、動かしてコードを見てたところ、少し直せそうなところがあったので最速でPR作りました。
しかし内部で修正され、マージするに至りませんでした。
趣味とかその他
Minecraftにハマった
名前は聞いたことあったのですが、やったことはなかったです。
会社の人と始めたのですが、自分はドハマリしてかなりやってました。
エンダードラゴンを倒してエリトラを入手したのですが、帰路で全ロスしその後エンチャントを付与したダイヤ装備を2連続で全ロスして心が折れました。
傷が癒えた頃に再びやりだして、ダイヤ装備と厳選したエンチャント、エメラルドを安定して入手できる仕組みを作るまではやりましたがそこで止まっています。
家の環境整備
リモートワークがメインになったので、家の環境を整えました。
昇降デスクは2020年のベストバイでした。
コーヒーを飲む機会も増え、いろんな抽出機材を買ったりしましたが今はまたハンドドリップに落ち着いています。自家焙煎にチャレンジしたい。
また、年内には届かなかったのですが良い椅子も買いました。多分2020年で一番高い買い物でした。
お菓子みたいな、つまむものを置いておく場所が家になかったので、スツールを買ってみたのですが思った以上に具合が良かったので追加でもう一つ買ってしまいました。
一つはお菓子BOXでソファーのオットマンとして利用し、もう一つは飲料水などを入れて玄関でちょっと腰掛けられる場所として利用してます。
自分が購入したのはこちらですが、サイズやデザインが若干違うものが無数にあるので気になったものが見つかればそれを選ぶと良いと思います。
完全栄養食
朝はCOMP DRINKにして、昼はガッツリ食べて夜は日中お菓子を食べているので抜きにするか追加でお菓子を食べるという生活をしていたのですが、体重がかなり落ちました。
お菓子も、ポテチかナッツというのが多かったのですがジャンキーなのは体に良くないと考え、ポテチはBASE BREADに差し替えました。
以前試したときは、冷凍でかつ小分けになっていなかったのでやめたのですが、今はそれらの問題が解決され、味も増えているので継続できそうです。
夜飯を完全に置き換えるというよりは、罪悪感のないお菓子くらいな感覚で利用しています。
こう書くとストイックそうに見えますが、土日は好きなものだけ食べたり、お酒の席では暴飲暴食をしています。
本を読む
本を読む習慣をつけたいと思い、家だとその切り替えがなかなかできないため、特別感を出すためにカフェで読むことが多いです。
カフェ自体は行くのであまり特別感はないのですが、そこで世界のビールを飲むことで特別感を出しています。
金曜の夜に敢えてカフェに行くことで、三密を避けることができています。
個人的には好きな時間になってるのでなるべく継続したいなと思っています。
ボドゲ
今年はやる機会も大変少なく、あまり購入していません。
2021年
2020年にやりたいと思ってたことは継続してやりたいことなので引き続きやっていきます。
定量的に見る場合、
- 英語はDuolingoで全スキルを5にする
- リングフィットはストーリーは終わらせたいのと習慣化(週1とか?)できているかどうか
- Flutterは何らかのアプリを作る
とかですかね。
あと、漠然としてますが何か新しいこともやりたいですね。やりたいというより、やっておかないと諸々まずいなという漠然とした焦燥感かもしれません。
Kotlin/JSでHeadless Chromeを使って画面をキャプチャしてSlackに投稿する
ちょっと利用したくなったのでKotlin 1.4のキャッチアップも兼ねて作りました。
環境は
- Kotlin 1.4.21
- dukat 0.5.8-rc.3
- Node 12
です
こんな感じで任意のURLのキャプチャがSlackに投稿されるものです。
前回触った記事はこちら
kotlin 1.4でどう変わった
KotlinのJSの実装は package_imported/
というフォルダに展開されていました。しかしプロジェクト の package.json
にその参照はされていませんでした。なので、解決のためにはnpm registryから取得してきていました。
しかし、1.4から package.json
に、ローカルpathが参照されるようになりました(file:/XXX)。なので、以前のように外部から無理やり依存関係を解決しなくても良くなりました。
ただしこれはこれでCloud Functiionsへのデプロイ時に一部修正が必要です(後述)。
1.4以前
{ "main": "kotlin/kotlin-js-cloud-function-linebot.js", "devDependencies": { "source-map-support": "0.5.16" }, "dependencies": { "kotlin": "1.3.72", "kotlinx-serialization-kotlinx-serialization-runtime": "0.20.0", "@jetbrains/kotlin-extensions": "^1.0.1-pre.91", "axios": "^0.19.2" }, "peerDependencies": {}, "optionalDependencies": {}, "bundledDependencies": [], "name": "kotlin-js-cloud-function-linebot", "version": "1.0.0-SNAPSHOT" }
1.4以後
{ "main": "kotlin/kotlin-js-headless-chrome.js", "devDependencies": { "dukat": "0.5.8-rc.3", "source-map-support": "0.5.19" }, "dependencies": { "axios": "0.21.0", "puppeteer": "5.5.0", "form-data": "3.0.0", "@google-cloud/secret-manager": "3.2.2", "kotlin": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin/1.4.21", "kotlinx-coroutines-core": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-coroutines-core/1.3.9", "kotlinx-atomicfu": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-atomicfu/0.14.4", "kotlinx-serialization-kotlinx-serialization-core-jsLegacy": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlinx-serialization-kotlinx-serialization-core-jsLegacy/1.0.0-RC", "17def1782b5ee417-kotlinx-nodejs-jsLegacy": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/17def1782b5ee417-kotlinx-nodejs-jsLegacy/0.0.7", "kotlin-test-js-runner": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin-test-js-runner/1.4.21", "kotlin-test": "file:/USER_PATH/kotlin-js-headless-chrome/build/js/packages_imported/kotlin-test/1.4.21" }, "peerDependencies": {}, "optionalDependencies": {}, "bundledDependencies": [], "name": "kotlin-js-headless-chrome", "version": "0.0.1" }
dukatはKotlin/JSのpluginに統合され、利用しやすくなりました。具体的には、次のように引数で指定してあげることで generateExternalsIntegrated
タスクが実行され、ソースコードを生成してくれます。
implementation(npm("axios", "0.21.0", generateExternals = true))
なので package.json
のdevDependenciesに含まれているわけです。
また、CoroutineやSerializationも同様に利用しやすくなっています。
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9") implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC")
採用したのはPuppeteer
Cloud FunctionsでPuppeteerが利用可能になったのは知っていたので、同様にHeadless Browserを扱えるPlaywrightを利用しようとしたのですが、結論うまくいきませんでした。
デプロイ時にchromiumを含めると、ファイルサイズの制約でデプロイに失敗します。なので、デプロイ後のmain関数実行時にchromiumをDLする流れを取っているPuppeteerを採用しました。
関数実行時にchromiumをDLする流れ
Playwrightでも環境変数を指定してあげれば同じ流れになるはずなのですがうまく行っていません。issueは上げたのでどこかのバージョンで解決されると思います。
ちなみにどうしてもPlaywrightを使いたい場合、playwright-aws-lambdaを利用すれば可能です。こちらはchromiumを含んでいますが、圧縮することでファイルサイズの制約を回避しています。
Cloud Functionsへのデプロイ
Cloud Functionsへのデプロイは、今回はCloud Buildで行いました。
Kotlin/JSをCloud Functionsで動かすには次の3パターンの方法が考えられます。
すべて込みのjsファイルを作る
こちらはひとつのjsファイルにすべての実装を詰め込んだものを用意して動かすパターンです。あまり筋が良くないので却下しました。
package-lock.json込みであげて依存関係を解決する
こちらは node_modules/
をデプロイ時のソースコードに含むものです。前述の通り、chromiumを含むことになるのでファイルサイズの問題で却下しました。
package.json込みでdeploy時に実行してもらう
なので消去法でこちらの方法でデプロイします。
Cloud Functionsデプロイのために、タスクを定義します。
- build.gradle.kts
tasks { val packaging by creating(Copy::class) { val directory = "functions" from("build/js/packages/${project.name}/kotlin/${project.name}.js", "build/js/", "build/js/packages/${project.name}/package.json") exclude("**/packages/", "node_modules/", "node_modules.state", "yarn.lock") into("$directory/") rename { it.replace("${project.name}.js", "index.js") } doLast { val jsonFile = file("$directory/package.json") val texts = jsonFile.readLines() .map { it.replace("kotlin/${project.name}.js", "index.js") } .map { it.replace("file:${project.projectDir.absolutePath}/build/js/", "") } jsonFile.writeText(texts.joinToString("\n")) } } }
Kotlin関連の実装はローカルの絶対pathを参照しているので、このままだと環境によって差分が出てしまいます。それを埋めるために、相対pathを参照するようにし、デプロイ時に必要なものだけをまとめるディレクトリを生成しています。
これによってfunctionsフォルダが生成され、次の構成になります。
├── functions │ ├── index.js │ ├── package.json │ └── packages_imported │ ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy │ │ └── 0.0.7 │ │ ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy.js │ │ ├── 17def1782b5ee417-kotlinx-nodejs-jsLegacy.js.map │ │ └── package.json │ ├── kotlin │ │ └── 1.4.21 │ │ ├── kotlin.js │ │ ├── kotlin.js.map │ │ └── package.json │ ├── kotlin-test │ │ └── 1.4.21 │ │ ├── kotlin-test.js │ │ ├── kotlin-test.js.map │ │ └── package.json │ ├── kotlin-test-js-runner │ │ └── 1.4.21 │ │ ├── karma-debug-framework.js │ │ ├── karma-debug-runner.js │ │ ├── karma-kotlin-reporter.js │ │ ├── kotlin-test-karma-runner.js │ │ ├── kotlin-test-karma-runner.js.map │ │ ├── kotlin-test-nodejs-runner.js │ │ ├── kotlin-test-nodejs-runner.js.map │ │ ├── mocha-kotlin-reporter.js │ │ ├── package.json │ │ ├── tc-log-appender.js │ │ └── tc-log-error-webpack.js │ ├── kotlinx-atomicfu │ │ └── 0.14.4 │ │ ├── kotlinx-atomicfu.js │ │ ├── kotlinx-atomicfu.js.map │ │ └── package.json │ ├── kotlinx-coroutines-core │ │ └── 1.3.9 │ │ ├── kotlinx-coroutines-core.js │ │ ├── kotlinx-coroutines-core.js.map │ │ └── package.json │ └── kotlinx-serialization-kotlinx-serialization-core-jsLegacy │ └── 1.0.0-RC │ ├── kotlinx-serialization-kotlinx-serialization-core-jsLegacy.js │ ├── kotlinx-serialization-kotlinx-serialization-core-jsLegacy.js.map │ └── package.json
functionsディレクトリには、ローカルで参照するKotlin関連の実装と、Cloud Functionsの規則に合わせたindex.jsというデプロイしたい関数の実装ファイルと、package.jsonがあります。
package.jsonは次のようになります。
- package.json
{ "main": "index.js", "devDependencies": { "dukat": "0.5.8-rc.3" }, "dependencies": { "axios": "0.21.0", "puppeteer": "5.5.0", "form-data": "3.0.0", "@google-cloud/secret-manager": "3.2.2", "kotlin": "packages_imported/kotlin/1.4.21", "kotlinx-coroutines-core": "packages_imported/kotlinx-coroutines-core/1.3.9", "kotlinx-atomicfu": "packages_imported/kotlinx-atomicfu/0.14.4", "kotlinx-serialization-kotlinx-serialization-core-jsLegacy": "packages_imported/kotlinx-serialization-kotlinx-serialization-core-jsLegacy/1.0.0-RC", "17def1782b5ee417-kotlinx-nodejs-jsLegacy": "packages_imported/17def1782b5ee417-kotlinx-nodejs-jsLegacy/0.0.7", "kotlin-test-js-runner": "packages_imported/kotlin-test-js-runner/1.4.21", "kotlin-test": "packages_imported/kotlin-test/1.4.21" }, "peerDependencies": {}, "optionalDependencies": {}, "bundledDependencies": [], "name": "kotlin-js-headless-chrome", "version": "0.0.1" }
main
が変わり、絶対pathだった部分が相対pathになっています。
デプロイについてですが、cloudbuild.yamlは次のようになっています。
steps: - name: gradle:6.3-jdk8 entrypoint: gradle args: ['cKJ', 'packaging'] - name: 'gcr.io/cloud-builders/gcloud' dir: 'functions/' args: - functions - deploy - capture - --source=. - --region=asia-northeast1 - --trigger-http - --runtime=nodejs12 - --allow-unauthenticated - --memory - 512MB - --service-account=secret-manager@able-source-341.iam.gserviceaccount.com - --update-env-vars - PROJECT_ID=able-source-341,SLACK_CHANNEL_KEY=capture_slack_channel,SLACK_TOKEN_KEY=capture_slack_token
Gradleが実行可能なDockerコンテナでjsファイルの生成とデプロイの準備をし、gcloudコマンドが利用可能なDockerコンテナでCloud Functionsのデプロイコマンドを実行します。
不要なファイルをアップロードしないように dir
で作業ディレクトリを移動しています。
Secret Managerの利用
Cloud Functionsで環境変数に機密性の高い値を置くのは不適切とされています。
前回は環境変数に置いてしまっていたので、今回はSecret Managerで管理する方法を取りました。
dukatでそのまま生成はできなかったので、次のようなファイルを用意してKotlinから利用しました。
- SecretManager.kt
@JsModule("@google-cloud/secret-manager") external object SecretManager { class SecretManagerServiceClient { fun accessSecretVersion(json: kotlin.js.Json = definedExternally): Promise<Array<AccessSecretVersionResponse>> } } @Serializable external class AccessSecretVersionResponse { @SerialName("name") val name: String val payload: Data } @JsName("data") external class Data { val data: Buffer } external class Buffer { fun toString(str: String): String } fun Array<AccessSecretVersionResponse>.value(): String { return this[0].payload.data.toString("utf8") }
利用するには
Secret Manager のシークレット アクセサー
のロール- PROJECT_ID
- 保存しているSecretのKey名
が必要になります。Cloud Buildでは次の部分がそれに当たります。
- --service-account=secret-manager@able-source-341.iam.gserviceaccount.com - --update-env-vars - PROJECT_ID=able-source-341,SLACK_CHANNEL_KEY=capture_slack_channel,SLACK_TOKEN_KEY=capture_slack_token
Cloud Functionsのランタイムサービスアカウントは、デフォルトだと PROJECT_ID@appspot.gserviceaccount.com
となっています。なので、そのサービスアカウントにSecret Manager のシークレット アクセサー
のロールを与えるか、Secret Manager のシークレット アクセサー
のロールを持ったサービスアカウントを別途用意する必要があります。今回は後者のほうで実現しています。
ロールの設定
少し複雑ですが、
- Cloud Buildを実行するメンバー
- Cloud Functionsを呼び出すメンバー
- Cloud Functionsを実行するメンバー
はそれぞれ別です。
Cloud Buildを実行するメンバーはデフォルトだと PROJECT_NUMBER@cloudbuild.gserviceaccount.com
というサービスアカウントです。Cloud Functionsのリソースにアクセスするので Cloud Functions 開発者
のロールを追加しておく必要があります。
Cloud Functionsを呼び出すメンバーには、Cloud Functions 起動元
のロールが必要です。publicにしたい場合は allUsers
メンバーにCloud Functions 起動元
のロールを追加してあげると良いです。
今回は定期実行させたかったので、 Cloud Schedulerから呼び出すことにしました。
AuthヘッダーにOIDCトークンを選択し、Cloud Functions 起動元
のロールをつけたサービスアカウントを設定することで安全に呼び出しています。
Cloud Functionsを実行するメンバーは、 前項で設定しているサービスアカウントです。
注意が必要なのは、Cloud Functionsを呼び出すメンバーと実行するメンバーが異なるということです。呼び出すメンバーにSecret Manager のシークレット アクセサー
のロールは不要です。逆に持っていたとしても、実行するメンバーにそのロールが与えられていなければ読み取りエラーになります。
ファイルの保存
今回はSlackに投稿するに当たり、一旦キャプチャした画像をファイルとして保存してから投稿するようにしました。
Cloud Functionsでは、/tmp
ディレクトリ以外は書き込み権限がありません。ローカルでも同様に確認できるようにするため、os.tmpdir
で保存先を確認するようにしています。
form-dataでファイルを投稿する
ハマってしまったのですが、Node.jsからファイルを送る場合form-data形式で送る必要があります。Kotlin/JSでもFormDataは用意されてるのですが、こちらはブラウザでの利用を想定されているものでNode.jsから動かした場合正しく機能しません。 form-dataを依存関係に追加して利用する必要があります。
Kotlinから利用するに当たり、最低限次のように定義しました。
- FormData.kt
@JsModule("form-data") external class FormData { fun getBoundary(): String fun append(string: String, dynamic: dynamic) }
まとめ
ローカルで動かすだけなら比較的すんなりいくのですが、Cloud Functionsで動かそうとすると考えることがとても増えて苦しみました。 IAM周りの話や、Headless Chromeを扱うようなときは参考にできると思います。
リポジトリはこちらです。
参考
品質とパフォーマンスに注力した Kotlin 1.4 をリリースしました | JetBrains Blog
Specifying dependencies in Node.js | Cloud Functions Documentation
https://rominirani.com/using-puppeteer-in-google-cloud-functions-809a14856e14
playwright/installation.md at master · microsoft/playwright · GitHub
Secret Managerを使ってみる@Cloud Functions編 | apps-gcp.com
javascript - Google Clould Functions deploy: EROFS: read-only file system - Stack Overflow