cockscomblog?

cockscomb on hatena blog

AWS LambdaでSlackアプリを動かす

プライベートな用事でサーバサイドで何かやりたい場合、サーバレスな構成が第一選択になる。規模が十分に小さい場合、サーバレスにした方が安い。常にインスタンスが立ち上がっているような構成は(たとえ冗長構成を取らなくても)プライベートな用事程度では大げさになる。またサーバレスな構成は放置しやすいのも魅力である。

f:id:cockscomb:20220307094357p:plain
Lambdaで動くcockscombot

最近、サーバサイドで何かしたあとの通知先としてSlackを使っている。Slackはちょっとしたユーザーインターフェースの代わりになる。その延長線上でSlackアプリを作ってみようと考えた。Slackが提供しているBoltというのを使うと、Slackアプリが簡単に作れる。

Slack | Bolt for JavaScript | Bolt 入門ガイド

BoltはAWS Lambdaにデプロイできるようになっている。ドキュメントではServerless Frameworkが使われているが、要するにAPI Gateway経由でLambdaを呼び出しているだけだ。

Slack | Bolt for JavaScript | AWS Lambda へのデプロイ

AWS CDKでSlackアプリを構築する

Serverless Frameworkでもいいけど、AWS CDKでもすぐにできる。API GatewayにNode.jsランタイムのLambdaをくっつけるのは、次のように書け、これでPOST /slack/eventsでLambdaが呼び出されるようになる。

import * as apigateway from '@aws-cdk/aws-apigatewayv2-alpha'
import * as apigateway_integrations from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import { Stack, StackProps } from 'aws-cdk-lib'
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'
import { Construct } from 'constructs'

export class SlackBotStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props)

    const botFunction = new lambda.NodejsFunction(this, 'BotFunction', {
      entry: './slack/src/bot.ts',
      handler: 'handler',
    })

    const botIntegration = new apigateway_integrations.HttpLambdaIntegration('BotIntegration', botFunction)

    const api = new apigateway.HttpApi(this, 'BotAPI')
    api.addRoutes({
      path: '/slack/events',
      methods: [apigateway.HttpMethod.POST],
      integration: botIntegration,
    })
  }
}

API GatewayをHTTP APIにしたかったので、experimentalの@aws-cdk/aws-apigatewayv2-alpha Construct Libraryを使っているが、これくらいなら大きな問題にはならないだろう。

aws-cdk-lib/aws-lambda-nodejsのおかげで、TypeScriptで書いたSlackアプリのコードがesbuildでいい具合にトランスパイルされ、手間要らずだ。

肝心のSlackアプリ(./slack/src/bot.ts)はBoltを使って次のように書ける。

import bolt from '@slack/bolt'
const { App, AwsLambdaReceiver } = bolt

const SLACK_BOT_TOKEN = ??? // TODO
const SLACK_SIGNING_SECRET = ??? // TODO

const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: SLACK_SIGNING_SECRET,
})
const app = new App({
  token: SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver,
})

app.message('Hello', async ({ message, say }) => {
  await say('Hi')
})

export const handler: ReturnType<typeof awsLambdaReceiver.toHandler> = async (event, context, callback) => {
  const handler = await awsLambdaReceiver.start()
  return handler(event, context, callback)
}

AwsLambdaReceiverというのが用意されているおかげで、Lambdaと繋ぎこむところはほとんど何もしなくていい。

シークレットをParameter Storeから読み取る

SLACK_BOT_TOKENSLACK_SIGNING_SECRETのふたつのシークレットを使っている。これらはハードコードしたくないので、AWS Systems Manager Parameter Storeに入れておいたものをLambdaから読み取らせることにする。

Parameter Storeにそれぞれ/slack/bot/SLACK_BOT_TOKEN/slack/bot/SLACK_SIGNING_SECRETの名前で、シークレットとして保存しておく。これらをLambdaから読み取るために、スタックを次のように書き換える。

import * as apigateway from '@aws-cdk/aws-apigatewayv2-alpha'
import * as apigateway_integrations from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
import { Stack, StackProps } from 'aws-cdk-lib'
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'
import * as ssm from 'aws-cdk-lib/aws-ssm'
import { Construct } from 'constructs'

export class SlackBotStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props)

    const slackBotToken = ssm.StringParameter.fromSecureStringParameterAttributes(this, 'SlackBotToken', {
      parameterName: '/slack/bot/SLACK_BOT_TOKEN',
    })
    const slackSigningSecret = ssm.StringParameter.fromSecureStringParameterAttributes(this, 'SlackSigningSecret', {
      parameterName: '/slack/bot/SLACK_SIGNING_SECRET',
    })
    const botFunction = new lambda.NodejsFunction(this, 'BotFunction', {
      entry: './slack/src/bot.ts',
      handler: 'handler',
      environment: {
        SLACK_BOT_TOKEN_NAME: slackBotToken.parameterName,
        SLACK_SIGNING_SECRET_NAME: slackSigningSecret.parameterName,
      },
      bundling: {
        target: 'node14.14', // Top-level awaitが使えるバージョン
        tsconfig: './slack/tsconfig.json',
        format: lambda.OutputFormat.ESM,
        nodeModules: ['@aws-sdk/client-ssm', '@slack/bolt'],
      },
    })
    slackBotToken.grantRead(botFunction)
    slackSigningSecret.grantRead(botFunction)

    const botIntegration = new apigateway_integrations.HttpLambdaIntegration('BotIntegration', botFunction)

    const api = new apigateway.HttpApi(this, 'BotAPI')
    api.addRoutes({
      path: '/slack/events',
      methods: [apigateway.HttpMethod.POST],
      integration: botIntegration,
    })
  }
}

ssm.StringParameter.fromSecureStringParameterAttributes()でパラメータを参照し、slackBotToken.grantRead(botFunction)のようにしてLambdaに読み取り権限を与える。また環境変数を介してパラメータの名前を渡しておく。

さらにlambda.NodejsFunctionのオプションにbundlingを増やし、esbuildにオプションを渡す。./slack/tsconfig.jsonも少し調整してある。

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "target": "es2019",
    "module": "es2022",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true
  }
}

面倒な感じになっているが、こうするとTypeScriptからES modulesが生成され、top-level awaitが使えるようになる。AWS LambdaではNode.js 14ランタイムでtop-level awaitがサポートされており、特にLambdaのProvisioned Concurrencyを利用している場合にコールドスタート時のレイテンシが改善するとされている。

後は次のようにシークレットを読み取る関数を用意しておけばよい。

import { GetParametersCommand, SSMClient } from '@aws-sdk/client-ssm'
import { strict as assert } from 'assert'

export async function getSlackSecrets(): Promise<{ token: string; signingSecret: string }> {
  const [SlackBotTokenName, SlackSigningSecretName] = [
    process.env.SLACK_BOT_TOKEN_NAME,
    process.env.SLACK_SIGNING_SECRET_NAME,
  ]
  assert(SlackBotTokenName, 'SLACK_BOT_TOKEN_NAME is not set')
  assert(SlackSigningSecretName, 'SLACK_SIGNING_SECRET_NAME is not set')

  const ssmClient = new SSMClient({
    region: process.env.AWS_REGION,
  })
  const parametersResponse = await ssmClient.send(
    new GetParametersCommand({
      Names: [SlackBotTokenName, SlackSigningSecretName],
      WithDecryption: true,
    })
  )
  const parameters = parametersResponse.Parameters
  assert(parameters, 'Parameter Store values not found')
  const [{ Value: token }, { Value: signingSecret }] = parameters
  assert(token, 'Parameter Store value for SLACK_BOT_TOKEN is not set')
  assert(signingSecret, 'Parameter Store value for SLACK_SIGNING_SECRET is not set')

  return {
    token,
    signingSecret,
  }
}

シークレットの取得はtop-level awaitを使って次の1行で済む。

const { token: SLACK_BOT_TOKEN, signingSecret: SLACK_SIGNING_SECRET } = await getSlackSecrets()

ここまでやってはみたが、別にLambdaのProvisioned Concurrencyを使うわけではない。Top-level awaitを使おうとするとES modulesにする必要があって、現状のエコシステムでは少し複雑になる。これくらいならシークレットをそのまま環境変数に入れてしまった方がいいかもしれない。

費用

試しに実行してみた感じでは、128MBのメモリ割り当てで、コールドスタートなら平均的に250msくらい、ウォームスタートなら2msくらいのBilled Durationになった。メッセージに反応するくらいなら一瞬で処理できる。

app.message()は、Slackアプリが参加しているチャンネルのメッセージすべてに対して呼び出される。実際に処理する必要があるかどうかは問わない。例えばSlackアプリが参加しているチャンネルに毎日1,000件のメッセージが投稿されるとすると、これに伴って呼び出されるLambdaはこれくらいの頻度であれば毎回コールドスタートになるだろうから、東京リージョンでは月々$0.03かかる。API Gatewayも呼び出し毎に課金され、月々にして$0.04かかる。合わせて月々$0.07になった。お小遣いで払えそうでよかった。

サービス 費用(月々)
AWS Lambda $0.03
Amazon API Gateway $0.04
合計 $0.07

AWS Pricing Calculator

ここから、Lambdaのアーキテクチャx86からArmにすることでLambdaの費用を2/3に抑えられる可能性がある。そもそもLambdaの無料枠が残っていればそれに収まる。

このSlackアプリはほとんど意味のあることをしていないが、ちゃんと実装して処理時間が呼び出しあたり100ms増えたとしても月々$0.10程度に収まるだろうし、データベースにDynamoDBを使ってもたかがしれていると思う。

イベントの購読

費用の計算で、「app.message()は、Slackアプリが参加しているチャンネルのメッセージすべてに対して呼び出される」ことを前提にした。app.message()を使うにはmessage.channelsmessage.groupsmessage.immessage.mpimのようなmessage.*イベントを購読する。しかし例えば、Slackアプリに対してのメンションにだけ反応すればいいなら、app.event()app_mentionイベントだけを購読すればいい。呼び出し回数を大きく抑えられ、費用が下がる。

いかがでしたか

Slackアプリをサーバレスで構築するのは簡単で、趣味で使う範囲では十分に安い。Serverless Frameworkだと(ngrokを使えば)ローカルで開発しやすいが、そこに目を瞑ればCDKでも普通に開発できる。DynamoDBを組み合わせるときなどはCDKの方が楽だと思う。開発時にはCDK Watchdeploy --hotswapなどを使うことでデプロイを速く楽にできる。

みんなもやってみてね。

ScrapboxでMermaidを使う

筆者の勤務先ではScrapboxというWiki的なツールが導入されていて、何でもそこに書いている。

そして筆者は以前からPlantUMLなどで作図するのが気に入っているが、最近は同様の目的を持ったMermaidがよく使われている様子がある。2021年12月にはNotionが、そして2022年2月にGitHubがそれぞれMermaidの機能を発表している。

いずれもドキュメント中にコードブロックを埋め込み、シンタックスとして「Mermaid」を設定すると、表示時には図に置き換わる。

特にソフトウェア開発に関連したツールでは、こういった機能は便利だ。ちょっとした図を載せたいときに、別なツールで作図して画像として貼り付けるのは手間だから、ひとつのツール内で閉じていると嬉しい。PlantUMLにしろMermaidにしろ、元がテキストなのもいい。

ある日仕事をしていて、どうしてもScrapbox上のドキュメントに図を入れたくなった。ScrapboxにMermaidが統合されていれば……。試しに別なツールで作図してSVGで出力して貼り付けて、とやったら1度で我慢の限界を迎えた。

ScrapboxのUserScriptを書く

ということでなんとかしていく。ScrapboxはUserScriptの機能を持っているので、危険を承知でこれを使う。

ということで書いてみたものがこちら。

UserScriptを有効にした上で、プロジェクト内の自分のページにscript.jsコードブロックを置いて読み込む。

code:script.js
    import '/api/code/cockscomb/Mermaid/script.js'
code:example.mmd
    graph LR
        Mermaid--makes y'all-->Happy

ブラウザをリロードしてから、適当なページに拡張子「mmd」のコードブロックを書くと、そのコードブロックの上に図が表示される。

f:id:cockscomb:20220221095309p:plain
Mermaidによって描画された図がコードブロック上に表示される

拡張子が「mmd」なのはmermaid-cliの例に倣った。

コードブロックを編集すれば図も更新される。複数人で同時に編集すると、図がリアルタイムにどんどん変化していって、おもしろい。

ScrapboxのUserScriptについて

現代のWebでUserScriptを書くことはなくなっていたので、Greasemonkeyを思い出したりして懐かしい。機能としてUserScript機能を持たせるのは胆力がある。利用者側としては便利だ。

ScrapboxのUserScriptを書くにあたって以下のようなテクニックを使った。

ライブラリの読み込み

Mermaidを読み込むのには、script要素を動的に追加した。

async function loadScript(src) {
   const script = document.createElement('script');
   script.src = src;
   const promise = new Promise(resolve => {
     script.addEventListener('load', resolve);
   });
   document.body.appendChild(script);
   return promise;
}

ScrapboxはContent-Security-Policyが設定されているので、許可されているcdnjs (cdnjs.cloudflare.com) を使った。

https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.14.0/mermaid.min.js

ScrapboxAPI

UserScriptからグローバル変数scrapboxを介してページの情報を得られる。scrapbox.Page.linesで行の情報が得られるのでこれを使っている。

行が変更されたり、ページを移動したとき、イベントによってそれを検知できる。これでMermaidの図を再描画させたり、ページが変わったら後片付けしたりしている。

コードブロックの内容もAPIから取得できる。

DOM操作

図を表示させるのにDOM操作するしかなく躊躇した。DOM操作するのは乱暴だと思う。しかしScrapboxの開発者もUserScriptを提供している時点でDOM操作されるだろうと想像しているはずなので、諦める。

どうですか

ScrapboxでUserScriptを書いたことがなかったが、同僚がなんかやっているのを覗き見たりしてキャッチアップできた。ありがたい。そして社内で宣伝したら乗っかってくれる人が多くて、みんなで適当な図を共同編集できておもしろかった。PlantUMLもMermaidも行志向だから、Scrapboxと相性がいいと思う。

気をよくして、Scrapboxリニューアルされたはてなスターを設置しようとしたが、Content-Security-Policyで制約されており、iframeが読み込まれず挫折した。

GitHub ActionsでDocker Buildするときのキャッシュテクニック

GitHub Actionsでdocker buildすることが多い。このときのキャッシュをどうするかという話題。

基本

GitHub Actionsでdocker buildしてAmazon ECRにdocker pushする、典型的な.github/workflow/docker-push-to-ecr.ymlはこういう感じ。

name: Push to Amazon ECR

on:
  push:
    branches: [ 'main' ]

jobs:

  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - uses: docker/setup-buildx-action@v1

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - uses: docker/metadata-action@v3
        id: meta
        with:
          images: |
            ${{ steps.login-ecr.outputs.registry }}/awesome-app 
          tags: |
            type=sha,prefix=

      - uses: docker/build-push-action@v2
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Docker周りはDocker公式のアクションを組み合わせて使って、BuildKitでやる。

キャッシュはdocker/build-push-action@v2cache-fromcache-toでやっていて、type=ghaとしてGitHub Actionsのキャッシュにのせている。

type=ghaは、BuildKitで使えるキャッシュの中でも手軽で使いやすい。GitHub Actionsのキャッシュはブランチと紐づいていて、現在のブランチとベースブランチのキャッシュが利用できるので、CIと組み合わせたときにわりと都合がいい。

Build Matrixとの組み合わせ

背景

去年、業務で利用しているMacがM1 Proのそれになって、我らがCTO id:motemenが用意しているmod_perl用Dockerイメージ(motemen/docker-mod_perl)にパッチを送り、arm64アーキテクチャのイメージをビルドしてもらうようにした。

変更は簡単で、docker/setup-qemu-actionQEMUのセットアップをして、docker/build-push-actionplatformsを指定した。ベースイメージの時点でマルチアーキテクチャに対応しているし、Dockerfileでアーキテクチャに依存したことをしていないので、これで十分。

マルチアーキテクチャになったのはよかったけど、GitHub Actionsがすごく遅くなってしまった。もともとキャッシュが切れていても5分30秒くらいで済んでいたのが、1時間以上かかるようになった。とはいえ、QEMUでCPUアーキテクチャをまたいでビルドすると遅いのは、それはそう。それはそうなんだけど、キャッシュの効きが悪いのも気になっていた。

f:id:cockscomb:20220215180147p:plain

add perl 5.34.0 · motemen/docker-mod_perl@1b6041d

こういうのをみると、Matrixでビルドしているうち「5.30.1」のみ9秒で終わっているが、残りは1時間近くかかっている。

f:id:cockscomb:20220215180222p:plain

Merge pull request #15 from motemen/modperl-2-0-12 · motemen/docker-mod_perl@ca11e85

さっきのブランチのベースになっているのはこちらで、「5.30.1」が一番最後に終わっていそうだった。

このことから、Matrixビルドでキャッシュが競合していて、後勝ちになっていることが類推された。

scopeを設定する

ということでBuildKitのghaキャッシュのドキュメントをもう一回見ると、scopeというのがある。これを使って、docker/build-push-action@v2cache-fromcache-toscopeを設定してみる。

          cache-from: type=gha,scope=perl-${{ matrix.perl_version }}
          cache-to: type=gha,mode=max,scope=perl-${{ matrix.perl_version }}

これでMatrixごとにキャッシュが分離された。

f:id:cockscomb:20220215175945p:plain

[DNM] test actions · motemen/docker-mod_perl@3b407c8

各バージョンが20秒前後になり、全体で1分弱になった。よかった。

まとめ

GitHub ActionsでBuildKitを使うときにtype=ghaのキャッシュが使える。ただしMatrixビルドなどで並列に複数のジョブが走ると、キャッシュが衝突してしまうが、scopeを設定することで回避できる。

いいマイクアームを買った

f:id:cockscomb:20211221102110p:plain

春にShure MV7というマイクを買って、快適に過ごしていた。

快適に過ごしていたけど、このとき適当なマイクアームを買っていたことで、気になるところがあった。

口から近い位置に設置できた方がいいので、適当な安いアームも買った。

マイクアームとしての用は足りているが、最高ではなかった。安いので、見た目が少し野暮ったい。特にバネが露出していることで、2歳の娘の格好の餌食となり、ビヨンビヨンやられる。ビヨンビヨンされるとアームを介してマイクが音を拾ってしまう。

もう少しマシなマイクアームを買ってもいいかと思ったが、普通に売っているマイクアームでは解決しない本質的な課題に気づきつつあった。

人体の構造上、口は目より下側にある。マイクは口の近くに配置したい。そして通常、マイクアームは上側からマイクをぶら下げる形になっている。

f:id:cockscomb:20211220233751j:plain

模式的に表すとこうです。わかりますか

おわかりだろうか。マイクアームは人間の視界を塞ぐのである。目とディスプレイの間にマイクアームが置かれることになってしまう。これはあまりスマートではないと思う。思いませんか?

Wave Mic Arm LP

ということで、ゲーム配信者向けのデバイスのブランド「Elgato」から、Wave Mic Arm LPが出ている。これだとマイクを上からぶら下げるのではなく、横から保持できる。

海外では以前から販売されていて目をつけていたのだが、ついに日本でも12月3日から発売になった。

Elgatoの機能性とデザイン性に優れた2種類のマイクアームとPS5などに対応のオーディオアダプタ「Chat Link Pro」の販売を開始

発売に数日気づかず、12月7日にAmazonで「一時的に在庫切れ; 入荷時期は未定です。」の表示になっていたが、注文できた。しばらく届かないかと思ったが、12月15日には届いた。

ということで設置しました。剛性も高く、ビヨンビヨンしないし、質感も高級感がある。ケーブルも収納できて、何もいうことがない。

Shure MV7にElgato Wave Mic Arm LP、これが新たなスタンダードだと思う。

macOSのメモからMarkdownを作るショートカット

macOS Montereyから、「メモ」アプリに「クイックメモ」という機能がついた。

インターネットブラウジングをしていて気になったことを書きつけていくのにちょうどよさそうなので、使ってみることにする。ある事柄に関連するリンクをひとつのメモに連ねていって、ちょっとテキストで補足を書いておく。

これをブログで手軽に共有できたら、なおいい。Markdown形式に変換したい。

メモの本文を取得する

メモの内容を取得するには、Apple Script的なやつを使う。ここではJXA (JavaScript for Automation)というので、JavaScriptっぽく書く。選択中のノートの本文を得るには、次のようにする。

const app = Application("Notes");
console.log(app.selection()[0].body());

これでノート本文がHTML形式で取得できる。便利だが、リッチな埋め込み形式にはならないのが玉に瑕で、リンクはURLだけになってしまう。とはいえ最終的には、はてなブログの埋め込み機能を使うのだから、まあいい。

ショートカットでMarkdownにする

macOS Montereyから、macOSにも「ショートカット」が搭載された。

これに先ほどのJXAをのせてしまう。最終的なショートカットは次の通り。

f:id:cockscomb:20211031231251p:plain
ショートカット

先ほどのJXAを関数にして、選択されたメモがない場合のハンドリングも加える。

function run(input, parameters) {
    const app = Application("Notes");
    const selections = app.selection();
    if (selections.length === 0) {
        return undefined;
    }
    return selections[0].body();
}

選択されたメモがない場合は「if文」を使ってショートカットを終わらせる。

HTMLからMarkdownへの変換は、「HTMLからリッチテキストを作成」してから「リッチテキストからマークダウンを作成」によって達成される。間にリッチテキストを挟むが、見出しやリストなどはうまく変換できる。

最後は「結果を表示」しているけど、「クリップボードにコピー」でもよさそう。

ショートカットは共有できるので、共有リンクを記載しておく。

これでやっていく

ということで試しにクイックメモを元にしてブログに書き出した。あっさりしてるけど、まあなんでも公開したらいい。

変換したMarkdownをそのまま公開できるほどではなく、マークアップを整えたりした。この辺がよくなるとさらにいいけど、メモから取り出せるHTMLの側にもっと情報が増えないと難しい。

AndroidのウィジェットをComposeのAPIで開発できるGlanceはアルファ版が開発中

AndroidウィジェットをComposeのAPIで作る方法がAndroid Dev Summitで紹介された。RemoteViewにうまく対応する。

Glanceと呼ばれていて、コードは公開されている。

しかしまだアルファ版も出ていない。

アルファリリースに向かって開発中らしい。

Xcode 13.2とSwift Concurrencyのバックデプロイメント、iPadOS向けのSwiftUIでのアプリ開発をサポートしたSwift Playgrounds、macOSでのSharePlayとUniversal Controlはこの秋にリリースされる

この秋のうちにSharePlayとUniversal ControlをサポートするmacOS Montereyがリリースされる。バージョン番号はおそらく12.1になるだろう。

Available later this fall macOS Monterey - Apple

同じタイミングでmacOS Monterey 12.1のSDKを含んだXcode 13.2がリリースされるはずだ。このバージョンのXcodeからSwift Concurrencyのバックデプロイメントが可能になる。

Xcode 13.2 beta includes SDKs for iOS 15.2, iPadOS 15.2, tvOS 15.2, watchOS 8.3, and macOS Monterey 12.1. Apple Developer Documentation

You can now use Swift Concurrency in applications that deploy to macOS 10.15, iOS 13, tvOS 13, and watchOS 6 or newer. Apple Developer Documentation

Xcode 13.2はSwift Playgrounds 4の新しいプロジェクト形式をサポートする。これはiPadOS向けのSwiftUIでのアプリ開発をサポートしたSwift Playgroundsが今年中にリリースされるはずであることと整合する。

Xcode 13.2 beta includes support for app projects created with Swift Playgrounds 4. Apple Developer Documentation

* Available late 2021. iPadOS 15 - Apple