24/7 twenty-four seven

iOS/OS X application programing topics.

try! Swift 2026「OSのない世界でSwiftを書く」

try! Swift Tokyo 2026で「Writing Swift Without an OS」というタイトルで発表しました。

発表の概要

Raspberry Pi Pico(RP2040、ARM Cortex-M0+)をターゲットに、OSなし・Cなし・100% SwiftでLEDの点滅からテトリスまでを実装する、という内容です。

この発表は組み込みシステムの話というよりは、ソフトウェアが動く基本的な仕組みの話です。iOSアプリで@mainをつけるだけでアプリが起動しますが、その裏側ではスタックの設定、メモリの初期化、ランタイムの準備といった処理が行われています。普段はOSやフレームワークが用意してくれているこれらの仕組みを、マイコン上で自分の手で一から書いていきます。マイコンを使うのは通常のPCでは複雑すぎてできないことができ、すべてが見えているからです。MacやiPhoneでも同じことが起きていますが、OSによって隠されています。

発表は大きく2つのパートで構成しました。

Part 1: main()を呼ぶまで

電源を入れてからSwiftのmain()関数を呼べるようになるまでに必要なことを順番に見ていきます。

  1. Boot2: Flashを設定してCPUがコードを実行できるようにする(256バイト)
  2. ベクタテーブル: CPUにスタックポインタの位置と最初に呼ぶ関数を教える
  3. リセットハンドラ: ハードウェアから最初に呼ばれるSwift関数
  4. メモリ初期化: グローバル変数の初期値をROMからRAMにコピーし、残りをゼロクリア

これらをSwiftの@section@used@cといった属性を使って実装します。iOSでは使う機会のほとんどない属性ですが、ここではSwiftとハードウェアの橋渡しになります。

Part 2: main()の先

main()の先では、アプリケーションの実行をどう構成するかを考えます。iOSではUIKitやFoundationがRunLoopやスレッドを提供してくれますが、ベアメタル環境ではそれはありません。

3つの実行パターンを実際のコードで紹介しました。

  • スーパーループ: while trueの中ですべてを順番に実行する。ゲームコントローラの実装で使用
  • 協調スケジューラ: タスクをインターバルと共に登録し、時間が来たら実行する。タスクごとに状態管理を分離できる
  • ハイブリッド: スケジューラに割り込みを組み合わせる。テトリスの実装で使用。音楽はSysTick割り込みで正確に再生し、ボタン入力はGPIO割り込みで即座に反応する

発表で触れられなかった補足

メモリマップドI/OとVolatile

発表ではメモリマップドI/Oについて「すべてはメモリの読み書き」と説明しました。CPUがLEDを光らせるのも、UARTで通信するのも、すべて特定のメモリアドレスに値を書き込むことで行われます。

ここで重要になるのがVolatileです。発表では簡単に触れましたが、もう少し詳しく説明します。

LEDをトグルするコードでは、ループの毎回の繰り返しで同じアドレスに同じ値を書き込みます。

while true {
    Register(address: 0xD000_001C).store(1 << 25)  // ビットを反転
    // ...
}

普通のコンパイラの最適化では「同じ場所に同じ値を書いているから、最初の1回だけでいい」と判断されます。しかしハードウェアレジスタへの書き込みには副作用があり、書き込むたびに物理的にLEDがトグルします。コンパイラが書き込みを省略すると、その副作用も消えてしまいます。

Register構造体の内部ではVolatileMappedRegisterを使っています。Volatileはコンパイラに2つのことを保証させます。

  1. 省略禁止: 読み書きを最適化で削除しない
  2. 順序保持: volatile同士の読み書きの順序を入れ替えない

順序の保持も重要です。ペリフェラルを有効にしてからピンを設定する、という順番が入れ替わるとハードウェアは正しく動きません。

SwiftコンパイラとPIC、GOTの問題

発表では時間の関係で触れられませんでしたが、開発中に遭遇した興味深い問題があります。

グローバル変数を使うためにはメモリの初期化が必要です。初期化コードは@_extern属性でリンカシンボル(__data_start__data_endなど)を参照し、ROMからRAMへのコピー範囲を知ります。

@_extern(c, "__data_start")
nonisolated(unsafe) var __data_start: UInt8

ここで問題が起きます。Swiftコンパイラは常にPIC(Position Independent Code)を生成します。これはコンパイラのソースコード(lib/IRGen/IRGen.cppcreateTargetMachine関数)でReloc::PIC_がハードコーディングされているためで、変更するオプションはありません。

llvm::TargetMachine *TargetMachine = Target->createTargetMachine(
    EffectiveTriple, CPU, targetFeatures, TargetOpts,
    Reloc::PIC_,  // ← ハードコーディング
    cmodel, OptLevel);

https://github.com/swiftlang/swift/blob/8c692ea543216e3577f0bc08ad290d78b71dce59/lib/IRGen/IRGen.cpp#L1195-L1198

PICではグローバル変数へのアクセスにGOT(Global Offset Table)を経由します。GOTにはグローバル変数の実際のアドレスが格納されています。

問題は、リンカースクリプトにGOTの配置を明示しない場合、リンカのデフォルト動作でGOTがRAMに配置されることです。すると以下の鶏と卵のどちらが先かという問題が発生します。

  1. initializeMemorySections()@_extern__data_startにアクセスする
  2. PICコードなのでGOT経由でアクセスする
  3. GOTはRAMにある
  4. しかしRAMはまだ初期化されていない(initializeMemorySections()がやろうとしていること)
  5. 未初期化のGOTから不定値を読む → クラッシュ

修正はリンカースクリプトに3行追加するだけです。

.got : {
    *(.got)
    *(.got.plt)
} > ROM

GOTをROMに配置することで、メモリ初期化前でも常にGOTの値を正しく読めるようになります。静的バイナリではGOTの値はリンク時に確定して変わらないので、ROMに置いても問題ありません。

これはバイナリダンプで確認できます。

# GOTがROMにある場合(正常動作)
$ llvm-objdump -h Application
  4  .got    00000010 10000b4c   ← ROM ✓

# GOTの記述がない場合(クラッシュ)
$ llvm-objdump -h Application
  6  .got    00000010 2000000c   ← RAM ✗

リンカースクリプトとiOS/macOS

リンカースクリプトはベアメタルや組み込み開発で使われるもので、iOSやmacOSのアプリ開発では使いません。Apple環境ではリンカ(ld64)にプラットフォームのルールがハードコーディングされており、実行時の配置はカーネルとdyld(dynamic linker/loader)が仮想メモリ上で決定します。

ベアメタルではOSがないため、物理メモリのどこに何を置くかを自分でリンカースクリプトに書きます。やっていることの本質は同じで、誰がやるかが違うだけです。

ビルドの仕組み

発表ではswift buildコマンドだけでビルドできると説明しました。

$ swift build \
    --toolset toolset.json \
    --triple armv6m-none-none-eabi

--tripleはターゲットのアーキテクチャを指定します。armv6m-none-none-eabiは4つの部分で構成されています。

  • armv6m: CPU(ARM Cortex-M0+)
  • none: ベンダー(特定のベンダーなし)
  • none: OS(OSなし)
  • eabi: ABI(Embedded ARM ABI)

--toolsetはコンパイラとリンカのオプションをまとめたJSONファイルです。Embedded Swiftモードの有効化、標準ライブラリなしのリンク(-nostdlib)、リンカースクリプトの指定などが含まれています。

Swift 6.3以降、Embedded Swiftはリリース版のSwiftツールチェーンに含まれるようになりました。ただしXcodeに同梱されているSwiftにはARMのクロスコンパイルターゲットが含まれていないため、swift.orgからOSS版のSwift 6.3以上をインストールする必要があります。

ARM Cortex-M0+のメモリマップ

ARM Cortex-M0+は4GBのアドレス空間を固定的に分割しています。

アドレス 用途
0x00000000 Code(512MB)
0x20000000 SRAM(512MB)
0x40000000 Peripherals(512MB)
0xE0000000 System(SysTick、NVIC)

RP2040はこの中に2MBのFlash(0x10000000)と256KBのSRAM(0x20000000)を持っています。RAMが0x20000000から始まるのはARMのアーキテクチャ仕様でSRAM領域がそう定められているからです。

スタックポインタの初期値0x20040000は、RAMの開始0x20000000 + RAMのサイズ0x40000(256KB)= RAMの末尾です。スタックはここから下(アドレスの小さい方向)に伸びていきます。

サンプルコード

サンプルコードはGitHubで公開しています。段階的に複雑になる構成で、起動処理からテトリスまでのコードを追えるようにしてあります。

  • tryswift2026: 発表で使用したサンプルコード
  • pico-bare-swift: 汎用的なサンプルコード(LED点滅、OLEDディスプレイ表示、ボタン入力など)

何かの原理や仕組みを学ぶための最も優れた方法の一つは、自分で作ってみることです。すべてをSwiftで書き、コンピュータの動作原理を学ぶ。それはソフトウェア開発の原始的な楽しさを与えてくれます。

SwiftのAutomatic Grammar Agreementについて

SwiftのAutomatic Grammar Agreementとは、英語における複数形や三単現のsのように、翻訳テキストの一部に語形の変化がありうるという情報を埋め込み、実行時にOSが 数などに合わせて指定した語句を文法的に正しい文章に自動的に修正してくれる仕組みです。

例を見てみましょう。

Text("Add ^[\(count) ticket](inflect: true) to your order.")

この例ではcount変数に入る数によって後続のticketの語形が単数形か複数形のどちらかに自動的に変化します。

Add 0 tickets to your order.
Add 1 ticket to your order.
Add 2 tickets to your order.

この機能を使わずに実装するなら、あらかじめticket/ticketsの複数のテキストを用意しておいて、使い分ける処理を書くか、String Catalogなどで条件によって正しいテキストが選ばれるようにします。

Automatic Grammar Agreementを使うと複数のテキストを用意する必要がなく、リソースをシンプルに保てます。

また、翻訳対象の言語について詳しくなければ、どの語系に変化させるのか判断できないこともあります。この例ではゼロの場合は0 ticketsと複数形が正しいですが、意外と迷うところではないでしょうか。ゼロの扱いは言語によって異なる場合があり、Automatic Grammar Agreementはこうした言語の差を吸収できます。

言語によっては名詞に性別がある場合があります。また、離れたところにある語が変化に影響することもあります。

https://developer.apple.com/videos/play/wwdc2023/10153/

"Our \(food) is made of \(ingredients)."

上記のテキストのスペイン語翻訳が下記である場合、スペイン語は名詞の性別によって他の語形が変化します。

"^[Nuestro %@](inflect: true) está ^[hecho](agreeWithArgument: 1) de %@."

このとき、food変数の名詞によってNuestroの語形が変化しますが、語形の変化は後ろのhechoにも影響します。 スペイン語では、所有形容詞(nuestro/nuestra)や過去分詞(hecho/hecha)が、参照する名詞の性・数に一致して変化します。

このように、ある単語が離れた別のところの単語を変化させうる場合、^[hecho](agreeWithArgument: 1)のように書きます。 ここで1が示すのはテキストに与えられた変数で1始まりのインデックス番号です。

言い換えると、「文字列補間(引数)の1番目」を示します。※%@ はString Catalog上のプレースホルダです。AttributedString(localized:)中に文字列補間を使って引数として渡されます。

food"ensalada"(女性名詞)だった場合、翻訳テキストは最終的に次のようになります。

Nuestra ensalada está hecha de lechuga y tomate.

NuestroNuestraに、hechohechaにそれぞれ語形変化しています。

同様のケースで、異なるUI要素のテキストが他のUIのテキストに影響する場合があります。 次の図では、左の「メニュー名」と右の「サイズ表示」が別々のUI要素になっています。サイズ側がメニュー名に一致して変化するのがポイントです。

https://developer.apple.com/videos/play/wwdc2023/10153/

この例では、選択されたメニュー(Ensalada)が女性名詞なので、大きさを形容する単語Pequeñoもそれに合わせて女性形であるpequeñaに変化する必要があります。

このような語形変化を引き起こすテキストがテキスト自身に含まれていない場合、agreeWithConceptを使います。

例えば翻訳テキストを次のように用意します。

Key English Spanish
salad salad ensalada
small small ^[pequeño](agreeWithConcept: 1)

^[pequeño](agreeWithConcept: 1)は与えられたconceptの単語によって変化する、ということを示します。1は1始まりのインデックス番号です。

conceptAttributedString.LocalizationOptions()を使って配列の形で渡します。

let food = String(localized: "salad")

...

var options = AttributedString.LocalizationOptions()
options.concepts = [.localizedPhrase(food)]

let size = AttributedString(localized: "small", options: options) // pequeño → pequeña

課題

アノテーションはApple独自の形式(Markdown拡張属性)なので独特な文法の知識が必要になります。文字列中に書くことになるので型安全性は期待できず、書き間違いによって期待通りの結果が得られない場合があります。

またアノテーションはほとんどの場合、コード中ではなく翻訳テキストとしてString Catalogなど別のファイルに記述されます。つまり、コードを読解するために翻訳テキストを参照する必要があるので、可読性やメンテナンス性が悪くなる恐れがあります。

^[pequeño](agreeWithConcept: 1)といったアノテーションを網羅的に解説したドキュメントはないので、どのようなアノテーションがあるのか複数のWWDCのセッションを参照して覚える必要があります。

アノテーションの文法は実装が露出しすぎているので、一般的な翻訳者が理解することを期待するのは現実的ではありません。ではソフトウェアエンジニアがアノテーションを付加できるかというと翻訳対象の言語の知識が求められるためにそれも現実的とは言えません。この機能を活用するためには翻訳のワークフローを考え直す必要があります。

まとめ

Automatic Grammar Agreementは、ローカライズ文字列の一部にアノテーション(inflect / agreeWithArgument / agreeWithConcept など)を付け、実行時にOSが数・性・参照関係に基づいて語形を自動調整する仕組みです。英語の複数形のような分かりやすい例だけでなく、スペイン語のように離れた語が一致するケースや、別UI要素間で依存関係を持つケースにも対応できます。

一方で、アノテーションはApple独自の記法で翻訳テキストにロジックが混ざるため、型安全性・可読性・翻訳者・開発者の理解コスト、ドキュメント不足といった課題もあります。使いこなせればかなり強力な仕組みであることは間違いないですが、実際に運用するには綿密なコミュニケーションやワークフローの構築が求められるでしょう。

個人的には現段階で実運用に載せることは難しいと考えています。はたして上手に活用できる方法はあるのでしょうか。

参考資料

developer.apple.com

developer.apple.com

developer.apple.com

developer.apple.com

www.youtube.com

iOSDC 2025「作って学ぶWebP入門」

先日のiOSDC 2025にて「作って学ぶWebP入門」という発表をしました。

fortee.jp

スライド: speakerdeck.com

台本とアニメーション付きのスライド: www.icloud.com

サンプルコード: github.com

何かの原理や仕組みを学ぶための最も優れた方法の一つは、自分で作ってみることです。

WebPのデコーダ・エンコーダの実装は類似のソフトウェアを書いた経験がなければ非常に難易度が高いです。 その理由は、圧縮の工程が複数の処理から成り立っていて、各工程が前の工程に依存するために、各工程を独立して一つずつ検証しながら実装することが難しいためです。

そこでこの講演では、WebPのLossless圧縮における最も唯一必須かつ難解に使われている圧縮手法であるハフマン符号化について重点的に解説し、ハフマン符号化だけを適用したWebP画像が作れるエンコーダをサンプルコードとして提供することで、段階的に実装を進めていける環境を提供しました。

サンプルコードを順番に実装していくことでフル機能(Lossless圧縮のみ。Lossyについては完成版のみ提供。)のWebPコーデックが実装できるようになっています。

講演後に多くの人から聞かれたことに「WebPについて話そうと思ったきっかけは何か」ということがあります。

きっかけは少し前にiOS 18でWebPのアニメーション再生のパフォーマンスが非常に劣化した、という問題を見たことです。

iOS 18 で Animated WebP 再生時のパフォーマンスが著しく悪くなった問題と対応 - Mirrativ Tech Blog

WebP animations lag on iOS but not Android

以前に私はSMBというファイル共有プロトコルのクライアントをSwiftで実装しましたが、それは何年もAppleプラットフォームではSMBによるファイル共有に問題を抱えていたためです。 何年も直らないので、自分で作るしかない、と考えたのでした。 この試みを非常にうまくいっていて、私は普段のファイル共有のアクセスは今は自作のファイルブラウザを使っていて、AppleのSMB実装による問題をまったく受けずに快適に使えています。

WebPの問題も同様にAppleの実装に何か問題があるのでは、と考えて自分で作れば直せると思ったためです。 実際は自分で作ったWebPのデコーダはAppleの実装よりも3倍ほど遅く、またiOS 18でパフォーマンスが悪い理由も全くわかりませんでしたが、とても良い勉強になりました。

WebPに限らず、自分で作ってみることによって学べることは多いです。 下記のリポジトリはさまざまな技術について、そんな「作って学ぶ」ためのガイド集です。

Swiftのコンテンツは残念ながらほとんどないのですが、多言語の内容を参考にして自分でやってみる、というのもいいかもしれません。

github.com

【レビュー】広い照明範囲を持つモニターライト「ScreenBar Pro」

BenQからモニターライト「ScreenBar Pro」の提供とレビュー依頼を受けたのでレビューします。

ただ、私はMacBookしか使わないので、商品はnoppeさん(https://x.com/noppefoxwolf)に進呈してレビューを書いてもらいました。

以下、noppeさんの文章です。


BenQは、プロジェクターや液晶モニターなど快適なデスク環境を提供する製品を販売しています。今回は、先月発売されたばかりのモニターライト「ScreenBar Pro」をレビューします。

以前から同社の「ScreenBar」を愛用していましたが、「ScreenBar Pro」は広範囲の照明や超音波感知による自動点灯・消灯機能などが追加された上位モデルです。

同梱品とデザイン

「ScreenBar Pro」の同梱品は、本体、ACアダプター、保証書などの書類です。「ScreenBar」と異なり、ケーブルやハンガー部分が本体と一体化しており、USB-C端子を採用しています。モニターからの給電を考えている場合は、端子の確認をおすすめします。

同梱品
同梱品

ScreenBarとの比較

「ScreenBar Pro」(上)の長さは50cmで、「ScreenBar」(下)の45cmに比べて左右に2.5cmずつ長くなっています。27インチモニターでは、「ScreenBar」の長さがバランスが悪かったので、このアップデートは嬉しいです。

長さの比較
長さの比較

本体の質感は「ScreenBar」の落ち着いたメタリックなデザインを継承し、サイズが大きくなっても主張しすぎません。

質感
質感

ボタン類は、左から「自動点灯・消灯機能」「明るさ」「色温度」「お気に入りモード」「自動調光モード」「電源」が並んでいます。アイコンとボタンが一体化しており、直感的に操作できます。アイコンや印字の色も控えめで、視認性と利便性のバランスが良いです。

ボタン類
ボタン類

「ScreenBar」は設置時にロゴが刻印されたハンガーが目立ちますが、「ScreenBar Pro」ではこの部分がなくなり、スッキリした印象になりました。

フロントロゴ
フロントロゴ

モニターに取り付けた様子

27インチのモニターに取り付けてみました。角度は25度程度回転できるので、モニター側を照らすこともデスクを照らすこともできます。昇降デスクを使っていることもあり、モニターを見る角度によって反射する光が気になることもあるので、この角度調整は非常に便利です。

モニターのベゼルが薄くてもハンガー部分が画面に被ることなく取り付けられました。反対側の重りでモニターを挟むため、ある程度の厚みがあるモニターでも取り付け可能です。重さのおかげで、本体のボタンを操作しても揺れず、ボタンもタップするだけで反応するのでモニターアームがズレる心配もありません。

取り付けの様子
取り付けの様子

スペック

明るさ

自動調光モードは、非常に自然な色と明るさで、環境光と見分けがつかないことがあります。部屋が暗い時にはしっかり明るく、部屋が明るい時は自然に控えめにしてくれます。

自動調光
自動調光

範囲

自動調光モードでどの程度の範囲が明るくなるのか見てみました。幅140x奥行き70のデスクでも、作業に使う部分は本も読めるほど十分に明るかったです。

照明範囲
照明範囲

自動消灯

我が家では部屋の照明をオートメーションしていますが、「ScreenBar」は常に点灯しているため、部屋に戻った時にデスクだけが煌々と照らされていることがありました。「ScreenBar Pro」では、人がいるかどうかを常に監視してくれるので、座ると点いて離れると消えてくれます。デスクに座った時に自動でモニターライトが点灯するのは、意外にも気持ちよく仕事を始めることができると感じました。

まとめ

「ScreenBar Pro」は、従来の「ScreenBar」に比べて多くのアップデートが施されており、より快適なデスク環境を提供してくれました。 広範囲の照明と自然な色味は、特に長時間の作業をするときに目の負担を下げてくれそうに思えました。 デザインや取り付けの工夫も細かい部分に配慮されており、美しいデザインを保ちながら機能性も兼ね備えている点もデスク環境をこだわる人にとっては嬉しいポイントだと思います。

item.rakuten.co.jp

Content-Security-Policy (CSP)でWeb Extension(Webブラウザ拡張機能)のCSSがブロックされる場合の対処

Webブラウザの拡張機能は本質的に第三者のWebサイトにJavaScriptやCSSをインジェクションします。

WebサイトによってはContent-Security-Policy (CSP)によってJSやCSSのインジェクションを制限している場合があります。

developer.mozilla.org

外部のユーザーエージェントによるJSやCSSを制限するContent-Security-Policyが設定されているWebサイトは多くはないのですが、AppleのDeveloperドキュメント以下のコンテンツにはかなりImageやCSSについて厳しめのCSPが設定されています。

developer.apple.com

Content-Security-Policy: default-src 'self' *.apple.com; script-src 'self' *.apple.com 'unsafe-eval' 'sha256-7njJh...' 'sha256-fgSWl...'; img-src 'self' *.apple.com data:; style-src 'self' *.apple.com 'sha256-8sYhe...';

レスポンスヘッダを見ると上記のようなContent-Security-Policyヘッダが設定されています。

このときstyle-src 'self' *.apple.comなのでapple.comドメイン以外のソースから提供されるCSSはブロックされます。

<link href="https://not-apple.com/styles/main.css" rel="stylesheet" />

<style>
  #inline-style {
    background: red;
  }
</style>

<style>
  @import url("https://not-apple.com/styles/print.css") print;
</style>

インラインのstyle属性もブロックされます。

<div style="display:none">Foo</div>

Webブラウザの拡張機能では、WebサイトのCSSに影響を与えないように拡張機能が挿入したHTMLにはインラインのstyle属性を使うことはわりとありますが、そのように書いている場合はスタイルがブロックされて適用されません。

JavaScript で直接style属性を設定したり、cssTextを設定したりしたスタイルも同様です。

document.querySelector("div").setAttribute("style", "display:none;");
document.querySelector("div").style.cssText = "display:none;";

以下のような、JavaScriptのstyleプロパティを使って直接設定する場合はスタイルが適用されます。

document.querySelector("div").style.display = "none";

詳しくは下記の解説を見てください。

developer.mozilla.org

ただ、すべてのCSSをJavaScriptのstyleプロパティを使って書いていくことは現実的ではないことが多いですし、DOM要素があらかじめ存在していなかったり擬似要素には適用できないなど、技術的に不可能な場合もあります。

対策としてはbrowser.runtime.getURL()関数で取得したURLはそのWebサイト自身のソース扱いになるので、下記のようにCSSを参照するLinkタグを追加するとContent-Security-Policyでstyle-src 'self'と設定されていてもそのCSSは問題なく読み込まれます。

衝突するようなスタイルがあるとWebサイトに影響を与えてしまうのでそこは注意する必要があります。

const link = document.createElement("link");
link.setAttribute("rel", "stylesheet");
link.setAttribute("href", browser.runtime.getURL("assets/style.css"));
document.head.appendChild(link);

Webコンポーネント(Shadow DOM)に対して同様のことを行うには下記のようにします。

const link = document.createElement("link");
link.setAttribute("rel", "stylesheet");
link.setAttribute("href", browser.runtime.getURL("assets/style.css"));
this.shadowRoot.appendChild(link);

try! Swift 2024 「Accessibility APIを使ってアプリケーションを拡張する」

先日のtry! Swift 2024にて「Accessibility APIを使ってアプリケーションを拡張する」という発表をしました。

tryswift.jp

スライド: speakerdeck.com

台本とアニメーション付きのスライド: github.com

サンプルコード: github.com

講演のビデオ: youtu.be

Accessibility APIとはUIテストや自動化システムなどで使われている、別のプロセスからアプリケーションの情報を読み取ったりボタンを押したりなど操作することができるAPIです。

スクリーンリーダーやボイスオーバーなどで自身のアプリケーションを操作可能にすることもAccessibility APIの役割ですが、今回は自身をアクセシブルにすることではなく他のアプリケーションを操作することで機能を付け足したりできる、ということを題材にお話ししました。

VS Codeや一部のIDEではAIの機能が統合されていてエディタから使えたりしますが、そのような機能を任意のテキストエディタやブラウザ、その他のアプリケーションで利用できるような拡張機能をAccessibility APIを使えば提供できる、という話です。

以下は講演の中で紹介した技術のデモです。

チャットAIアシスタント

Accessibility APIを使って他のアプリケーションが表示するテキストを取得、グローバルホットキーを監視して指定のホットキーの組み合わせが押されたタイミングでAIアシスタントに指示を出すテキストウインドウを表示する。

インラインAIアシスタント

テキスト入力を監視し、あらかじめ決められたキーワード(/assist)の後に入力されたテキストをAIアシスタントへのプロンプトとして、結果を直接テキストエディタに反映する。

Comic Translator

マンガのセリフを翻訳する。

Accessibility APIでは表示されている画像を取得することはできないので、Accessibility APIはウインドウの座標を特定するために使用して、内容は別途ScreenCaptureKit.frameworkを使用して座標のスクリーンショットを取得して表示中の画像を得る。

VisionKit.frameworkを使ったOCRは縦書きも正確にテキストに変換してくれるのでそれを利用する。なぜかVision.frameworkではうまくいかない。

VisionKit.frameworkだとOCRしたテキストの座標などは取れないのでUIを工夫する必要がありそう。

AXUIElementInspector

ルート要素のAXUIElementを作成してツリー構造を辿る、取得できる要素の名前をAPIを通じて取得する、などAccessibility APIの基本的な使い方を一通り学ぶことができるサンプル。

CarbonHotKey

Carbon APIを使ったキー入力の監視のサンプルコード。特別なパーミッションを取得しなくても動作するという特徴がある。

おいしい朝ごはん

会期中に訪れた朝から開いていて朝食メニューがあるお店を紹介します。

gdyg500.gorp.jp

ビストロ ロジウラ(Bistro Rojiura)。初日の朝ごはん。とてもおいしい。時間の制約がなかったら3日間ここに行ってたと思う。

朝8時から開いているので時間に余裕をもっていける。

ただしベルサール渋谷ファーストへは自転車でも10分以上かかるので歩くのは少し遠い。

https://buymestand.com/buymestand.com

バイ ミー スタンド(BUY ME STAND)。おいしいサンドウィッチのお店。ボリュームがあっておいしい。

ハンバーガー屋さんにあるような紙の袋があるのでそれを使ってトーストに卵焼きやベーコンをはさんでガブっと食べる。おいしい。

会場のベルサール渋谷ファーストがある通り沿いにあって歩いてもたぶん6、7分だと思う。ただ線路を越えるので坂を登ることになる。

bread-espresso.jp

パンとエスプレッソとまちあわせ。宮下パークにあるお店。普通においしいけど宮下パークにあるお店って感じのお店。

発表で使ったもの

今回スライドに使うフォントをヒラギノUDにしてみました。ちょっとみやすくなった気がします。

8BitDo Microというカードサイズのすごい小さいゲームコントローラ。かわいい。キー設定のアプリがiPad用だけどMacでも普通に動くのでこれをKeynoteのスライドが移動できるキーに設定してリモコンとして使った。台にMacを置くとちょっと低いけどこれで操作することで前を向いて喋ることができます。

www.8bitdo.com

iOSDC 2023 Mastering SwiftSyntax

先日のiOSDC 2023にて「Mastering SwiftSyntax」という発表をしました。

fortee.jp

スライド: speakerdeck.com

台本とアニメーション付きのスライド: www.icloud.com

サンプルコード: github.com

マクロではなくSwiftSyntaxの解説なのでマクロの話を詳しくしなかったことは想定通りですが、改めてプロポーザルを読んでみるとマクロという単語をたくさん使ってるので、これはちょっとマクロの話だと期待した方もいたのかなと後で反省しました。

意図としてはマクロの数パターンについてチュートリアルのような説明をしてもそのパターン以外に使えるようになるかというと難しいので、基礎的な知識をできるだけ網羅的体系的に伝えることにしました。

だいぶ詰め込むことになったのと論理構成の都合で体系的に伝えることはそこまでできなかったのですが、必要なことはすべてスライドか話した内容に含まれているので、Keynoteを発表者ノートとアニメーションを有効にして復習してみてください。

SwiftSyntaxは自動生成されたコードが大部分を占めていて、構文木だけでなくParserも含み、Parserのコードはパフォーマンスを優先するためにマニュアルのメモリ管理など読みやすいコードとはいえません。

しかし発表にある通りSwiftSyntaxを使う上ではその部分を気にすることはなく、一定のパターンに従った定型的なプログラミングになります。

少し書いてみるとすぐに慣れると思うので、マクロを作ってみたりSwiftLintのルールを修正してみたりして練習してください。

発表で使ったもの

今回スライドに使うフォントをヒラギノUDにしてみました。ちょっとみやすくなった気がします。

8BitDo Microというカードサイズのすごい小さいゲームコントローラ。かわいい。キー設定のアプリがiPad用だけどMacでも普通に動くのでこれをKeynoteのスライドが移動できるキーに設定してリモコンとして使った。台にMacを置くとちょっと低いけどこれで操作することで前を向いて喋ることができます。

www.8bitdo.com

一部の人にウケてた「9:41」のTシャツ。MacやApple製品っぽいモチーフのアパレルなどを作ってるところで、他の製品はライセンス的に怪しいけれども「9:41」のTシャツは人前で着ても問題ない。ただの時刻だから。これが何か特別な数字に見えるひとは・・・。

9:41 T-shirt Blackthrowboy.com

SwiftマクロでPowerAssertを実装する

現在Swiftにマクロを導入しようという提案がSwift Evolutionのレビュー中*1です。 SwiftによってSwiftの構文を拡張できる、いわゆるメタプログラミングと呼ばれる機能です。

実はマクロの他にもSwiftでメタプログラミングを実現する機能の提案が複数提案*2*3されています。 Swift 6はメタプログラミングの時代になるかもしれません。

現代的なプログラミング言語のマクロ

みなさんはマクロと聞いて、どのような機能を想像しますか? C言語のマクロは、プリプロセッサと呼ばれるコンパイル前のプログラムによってプログラムのソースコードに置換や文字列連結を行う機能でした。 原理的には単なる文字列操作なので、プログラムの構造や型を破壊する可能性がありました。 最初のマクロに関する投稿に対しての否定的なコメントは、C言語のマクロのような機能をSwiftに導入することは危険だという意見が多かったようです。

しかし、現代のプログラミング言語では、マクロの機能は大きく異なっています。 C++に導入されたテンプレートやRustに導入されたマクロは、プログラムの構造を操作する機能です。 たとえばRustのマクロは、プログラムのAST(抽象構文木)を操作する機能です。 ASTはプログラムの構造を抽象化した木構造で、プログラムのコンパイルや解析などに利用します。 Rustのマクロは、プログラムのASTを操作することで、プログラムの構造を変更できます。 このようなマクロは、プログラムの構造を操作する機能であるため、C言語のマクロのような危険性はありません。 また、マクロを使うことでプログラムの構造をより柔軟に操作できるようになります。

Swiftのマクロはコンパイラによる型チェックが行われるため、型安全であることが期待できます。 Swiftではマクロを導入することで、一部のDSLをより簡潔に記述できるようになったり、機械的なボイラープレートの記述をなくせたりということが利点として挙げられています。

Swiftにおけるマクロの実装

Swiftのマクロが単なる文字列操作でないことを理解するために、Swiftのマクロをどのように実装するのかをみてみましょう。

Swift Coreチームは、現在Compiler Control Statementsと呼ばれる#file#colorLiteralなどの機能をマクロで再実装することを検討しているようです。 提案書にはマクロのサンプルコードとして現在は標準の言語機能として組み込まれている#colorLiteralをマクロ機能で再実装したもの*4が公開されています。 #colorLiteralマクロは次のように使用します。

let backgroundColor: UIColor
  = #colorLiteral(red: 0.5, green: 0.5, blue: 0.25, alpha: 1.0)

もともとのCompiler Control Statementsである#colorLiteralはXcode上でカラーピッカーを用いて色を選択できたり、実際の色がソースコード上に表示され確認できるというIDEのサポートを前提とした機能で、実際にマクロとして動作するわけではありません。 今のところSwiftマクロの実装では#colorLiteralマクロは次のようなソースコードの形に展開するように実装しています。

let backgroundColor: UIColor
  = .init(_colorLiteralRed: 0.5, green: 0.5, blue: 0.25, alpha: 1.0)

では、このような動作を実現する#colorLiteralマクロの実装をみてみましょう。

// Declaration of #colorLiteral
@expression macro colorLiteral(
  red: Float, green: Float, blue: Float, alpha: Float
) -> _ColorLiteralType
  = SwiftBuiltinMacros.ColorLiteralMacro

// Implementation of #colorLiteral
struct ColorLiteralMacro: ExpressionMacro {
  /// Replace the label of the first element in the tuple with the given
  /// new label.
  func replaceFirstLabel(
    of tuple: TupleExprElementListSyntax, with newLabel: String
  ) -> TupleExprElementListSyntax{
    guard let firstElement = tuple.first else {
      return tuple
    }

    return tuple.replacing(
      childAt: 0, with: firstElement.withLabel(.identifier(newLabel)))
  }

  static func expansion(
    of node: MacroExpansionExprSyntax, in context: MacroExpansionContext
  ) -> MacroResult<ExprSyntax> {
    let argList = replaceFirstLabel(
      of: node.argumentList, with: "_colorLiteralRed"
    )
    let initSyntax: ExprSyntax = ".init(\(argList))"
    if let leadingTrivia = node.leadingTrivia {
      return MacroResult(initSyntax.withLeadingTrivia(leadingTrivia))
    }
    return initSyntax
  }  
}

まず次にに示すようにマクロの型宣言があります。

@expression macro colorLiteral(
  red: Float, green: Float, blue: Float, alpha: Float
) -> _ColorLiteralType
  = SwiftBuiltinMacros.ColorLiteralMacro

このコードは#colorLiteralというマクロはFloatの引数を3つ受け取り、戻り値として_ColorLiteralTypeを返すというインターフェースであることを定義しています。 文法は関数の定義とほとんど同様です。関数を示すfuncの代わりに@expressionmacroという2つのキーワードを使用します。 マクロであることを示すだけなら@expressionは不要ですが、これは将来マクロの種類が増えた場合に各マクロを区別するために用意されています。

マクロの可能性を探る

ここまででSwiftのマクロがどのようなものか、そしてどのように実装されているかを解説してきました。 単なる文字列操作ではなく、プログラムの構造を操作する機能であることがわかりました。

前述のサンプルコードは型チェックが働くものの、結果的に単なる文字列操作と変わらないと感じるかもしれません。 そこで少し高度で、かつ実践的な例をひとつ考えてみましょう。

マクロを使ってPower Assertを実装する

Power Assertは、プログラムのテストが失敗したときにテスト条件の評価結果を分かりやすく表示する機能です。 たとえば次のようなテストコードがあったとします。

func testPowerAssert() {
  let a = 1
  let b = 2
  let c = 3
  #powerAssert(max(a, b) == c)
}

このテストコードはabのうち大きい方がcと等しいことをテストしています。 このテストが失敗したときにPower Assertは次のようにテスト条件の評価結果をアスキーアートを使ってわかりやすく表示します。

#powerAssert(max(a, b) == c)
             |   |  |  |  |
             7   4  7  |  12
                       false

Swiftのマクロは構文木を受け取って構文木を返す機能です。 ソースコードの変数や式といった構造を受け取れるのでPower Assertの機能を実装するために必要な引数のひとつひとつの情報をマクロの内部で扱えます。

具体的には#powerAssertマクロに渡された引数の各要素を評価して、引数の位置とともに保存するコードを生成します。 マクロに渡された式全体がfalseのときにテストが失敗したとして、それぞれの変数や式の値をアスキーアートで表示するコードを生成します。

最初のテストコードに含まれるマクロ#powerAssert(max(a, b) == c)は、マクロによって実際は次のようなコードに変換されます。

PowerAssert.Assertion(#"#powerAssert(max(a, b) == c)"#, line: 14)
  .assert(max(a, b) == c)
  .capture(expression: max(a, b), column: 13)
  .capture(expression: a.self, column: 17)
  .capture(expression: b.self, column: 20)
  .capture(expression: max(a, b) == c, column: 23)
  .capture(expression: c.self, column: 26
  .render()

このコードを実行すると前述のアスキーアートを表示する結果が得られます。 これはコンパイル時にソースコードの構造に対して変更を適用できるというSwiftのマクロならではの活用例です。単純な文字列操作では、Swiftの構文解析器を実装するなどしなければ、このような機能は実現できません。

さらに本Power Assertの実装は、後に提出されたSwift Evolutionのプロポーザル*5にマクロのユースケースのひとつとして紹介*6されています。

このSwift Power Assertの実装はGitHubリポジトリ*7で公開しています。興味があったら参考にしてください。

参考資料

DeepLの非公式Safari機能拡張(Mac・iPhone・iPad)

公式に提供されているChrome機能拡張とだいたい同じような使い勝手になっています。

ソースコードはこちら

github.com

機能概要

ページ全体の翻訳(Proユーザーのみ)

選択したテキストを翻訳(誰でも)

その他のスクリーンショット

📱 iOS app

 iPad app

💻 Mac app

iOSDC 2022「アニメーションAPIのすべて」補足など

先日のiOSDC 2022にて「アニメーションAPIのすべて」という発表をしました。

fortee.jp

きっかけはDroidKaigi 2021で荒木佑一さんの「動かす」という発表です。

www.youtube.com

Androidのさまざまなアニメーション APIについてコードや具体的な例を用いて解説する内容です。最後にスライド自体がAndroidアプリとして作られていて、サンプルのアニメーションはすべて実際に動いていたものだった、と明かされるところが非常におもしろいと思ったのです。ぜひこれのiOS版をやろうとそのとき考えたのでした。

ちなみに、荒木さんはそれ以前のDroidKaigiや別のカンファレンスでも「動かす」シリーズで話されているので資料などを探して読んでみるとどれもおもしろいです。

ということで1年間あたためていたアイデアが無事採択されたことはよかったのですが、さすがにこの内容を40分にまとめるのは簡単ではありませんでした。最初は漠然とAPIを一つずつ例と一緒に使い方を説明して、できるだけ高レベルなAPIを使いましょう、でいいかと思ってましたが、全然時間が足りないしたぶん例だけ並べても使い分けを判断できるようには伝わないなと思って半分はアニメーション自体の仕組みの説明に割くことにしました。

ただ、そうすると当然個々のAPIの具体的な説明はさらに減らすことになるので、なんとか40分のプレゼンテーションの形にまとめたものの、それぞれの説明を完全にはできなかったなと思ってこの記事で少し補足します。

APIの使い分けについて

次のようにUIViewはCALayerのラッパー、UIView.animate()系のメソッドはCore AnimationのアニメーションAPIのラッパー、UIViewPropertyAnimatorはさらにUIView.animate()系のラッパー、ということで基本的には高レベルのAPIを使えばいいです。ただUIViewPropertyAnimatorUIView.animate()を比べるとコードのシンプルさが全然違うので、UIView.animate()で十分書ける場合はそちらを使う方がいいでしょう。

Core Animationのアニメーションはなぜ効率的で高速なのか

Core Animationとは、のくだりでCore Animationは画面表示のための合成エンジン、ということを言ったのですが、それで済ませてしまってなぜiOSにCore Animationが導入され名前にもあるとおりアニメーションをスムーズに効率よく実行できるのかという説明は省いたのでここで補足します。 この説明に入るとCore Animationのレンダリングシステムについての話をずっとすることになり、時間がなくなるので仕方なく省きました。 もし詳しく聞きたいということがあれば、企画してもらえれば詳しく話すことも可能です、たぶん。

簡単にいうと、下のようにCALayerを使わない場合は描画をすべてビューが担当するので、ビューをアニメーションさせた場合に毎フレームDrawコールが呼ばれる、というような挙動になります。これはMacでCALayerを使わないNSViewを使って実験してみるとわかります。iOSは発表中に説明した通り、CALayerは必ず作られるのでCALayerを使わない画面表示ということは不可能です。 いっぽう、MacはCore Animationを使うかどうかは選択できるのでCALayerを使わないNSViewが使えます。その場合、例えばビューに背景色を設定するというだけのことでもdrawRect()メソッドをオーバーライドして背景色でビューの矩形を塗る、という処理を書く必要があります。 ビューをアニメーションさせた場合、毎フレームdrawRect()で背景色を塗る、という処理が呼ばれますが、Core Animationを使う場合、CALayerが一度表示内容を計算すれば(サブビューがあれば重なりも含めて)表示内容をビットマップとしてキャッシュします。 アニメーションさせる場合はCore Animationアニメーションパラメータから中間の値を導出してひとまとまりのパッケージとしてRender Serverに転送してあとはGPUが処理します。

という仕組みなのであらゆる画面表示やアニメーションはCore AnimationとRender Serverによってハードウェアを効率的に使って実行できるのです。

アニメーションの途中の状態を取得する

Core Animationのアニメーションは基本的にあるプロパティの値abに変わるその間の各値をアニメーションのパラメータに従ってなめらかに見えるように自動的に補完する、というものです。

アニメーション対象のプロパティの値は、アニメーションが発動しているときにはすでにアニメーション終了後の値に変わっているので、途中の値を観測することはできません。

ただし、CALayerにはpresentationLayerというプロパティがあり、このオブジェクト(CALayerのインスタンス)のプロパティを調べるとアニメーション中の現在の値がわかります。

通常のアプリケーションで利用することはほとんどなく、アニメーションAPIというよりはCore Animationのレンダリングの仕組みの話なので省きましたが、Discordで質問があったのでここで補足します。

Presentation TreeとRender Tree

この話をするとUIViewのツリー構造とCALayerのツリー構造に加えてさらにCALayerにはPresentation TreeとRender Treeというツリー構造があるという話になって完全にCore Animationのレダリングシステムの解説をすることになるので省いたのでした。

私たちが操作しているCALayer(あるいはUIViewを通じて)は実際にはモデルを変更していて、Core AnimationはそこからPresentation Treeという実際に画面に表示されるべき状態とRender Treeという画面に表示されるテクスチャそのものの構造を導出します。

Core Animationは最終的にRender TreeをRender Serverに転送して、実際の画面表示はRender Serverによって行われます。この仕組みにより、プロセスをまたぐホーム画面のAppスイッチャーのアニメーションなども非常になめらかに処理できます。

Render Treeは完全にプライベートで私たちが触れることはできませんが、Presentation Treeは先述のpresentationLayerプロパティを使って確認できます。

CALayerはタッチイベントを受け取りませんが、hitTest()メソッドがあるので、presentationLayerhitTest()frameの値から現在アニメーション中のレイヤーにタッチしたかどうか、なども判定できます。

まあ必要とすることはほとんどないのですが、稀にそういうことがしたい場合に、このような仕組みを知っておくと役に立ちます。

Specialized Layers

CALayerのサブクラス群は非常に有用なのでもっと時間をとって紹介したかったところです。非常に数多くのサブクラスが存在しますが、紹介できなかった中でも有用だと思うものがこちらです。

  • CATiledLayer

    • マップ.appのようなズームレベルによって異なる画像を表示することができるレイヤーです。非常に大きな画像を一部だけレンダリングしつつズームすると高精細な画像に切り替えるという挙動が簡単に実装できます。名前の通り、表示エリアをタイル状に分割してそれぞれの読み込みは非同期で行われるのでUIは非常になめらかに動きます。
  • AVSynchronizedLayer

    • AVFoundationやCore Videoにはビデオやサンプルバッファを表示するためのレイヤーのサブクラスがあります。これはAVPlayerAVPlayerLayerでビデオを再生している場合に再生時刻とCALayerのアニメーションを同期できるレイヤーです。ビデオの再生中にフェードなどのトランジションを追加する、といったことができます。

余談ですが、AVFoundationとCore Animationは関係が深く、AVVideoCompositionCoreAnimationToolというAPIを使うとCore Animationによるアニメーションをビデオに合成でき、特殊効果をつける、といったことが簡単にできます。

スライドアプリの構造

プレゼンテーションをiOSアプリを使って行うというのはそもそもの目的でした。

スライドの内容とアプリの設計を同時に行うことは不確定要素が多いのでSwiftUIではなく慣れているクラシックなUIKitアプリとして作られています。

普段はKeynoteを使ってスライドを作っているので、まずKeynoteに用意されているテンプレートのうち普段使っているものを模倣することにしました。

このようにStoryboardで同じフォントとレイアウトを作ってテンプレートとして利用できるようにします。

基本はテンプレートのどれかのクラスに、titleプロパティやsubtilteプロパティを設定すればいいのですが、大事なことはサンプルがスライドに組み込まれて実際に動いているということなので、それぞれのテンプレートにはconentViewのようなプロパティを作り、自由にビューを追加できるようにしました。

サンプルをどう表示するかという点について、最初はシンプルに直接ビューに追加することを試しましたが、そうしてしまうとスライドが表示される前にアニメーションが終了してしまったり、スライドに戻ってきた場合にアニメーションが再開しなかったりとアニメーションの管理に問題がありました。

最終的にそれぞれのサンプルをView Controllerにして、Child View Controllerとして表示することで、スライドのページ送りでviewWillAppaer/disappear()などのライフサイクルのメソッドが呼ばれるので、そこでアニメーションを作成したり削除することで表示されるときに必ず最初からアニメーションが再生できるようになりました。

複数のデバイスサイズに対応する

最初は手軽に見てもらえるようにiPhone環境に合わせて作っていましたが、さすがに小さすぎるということがすぐにわかったので、大きなデバイスを使うことにしました。 iPadは良さそうだったのですが、現代のiPadは16:9のサイズのデバイスがなかったので実際のプレゼンテーションはウインドウサイズが自由にできるCatalystでMacアプリとしてやることにしました。

ただ、実際にそのまま動かせる、ということが売りなので、iPhoneやiPadでちゃんと動くということは必要でした。

最初はAuto Layoutでなんとかなるかと思いましたが、さすがにサイズが違いすぎるし、Auto Layoutはデバイスに合わせて全体的にスケールする、というような表現にはあまり向いてないのでやめました。

コードを見るとわかりますが、結局ウインドウ自体をデバイスの画面サイズに合わせて縮小し、中身は縮小した比率にスケールするという方法で各デバイスで同じ見た目になるようにしています。

#if !targetEnvironment(macCatalyst)
    let bounds = window.bounds

    let width: CGFloat = 1920
    let height: CGFloat = 1080

    window.frame = CGRect(x: 0, y: 0, width: width, height: height)

    if bounds.width < bounds.height {
      let scale = bounds.height / height
      window.transform = CGAffineTransform(scaleX: scale, y: scale)
    } else {
      let scale = bounds.width / width
      window.transform = CGAffineTransform(scaleX: scale, y: scale)
    }

    if window.frame.width > bounds.width {
      let scale = bounds.width / window.frame.width
      window.transform = window.transform.scaledBy(x: scale, y: scale)
    }
    if window.frame.height > bounds.height {
      let scale = bounds.height / window.frame.height
      window.transform = window.transform.scaledBy(x: scale, y: scale)
    }

    window.frame.origin.x = (bounds.width - window.frame.width) / 2
    window.frame.origin.y = (bounds.height - window.frame.height) / 2
#endif

コードハイライト

スライドに載せたコードがそのまま動いている、ような表現にしたかったのでコードはそこそこたくさん載せています。 コードがハイライトされていないととても見にくいのでコードハイライトはなんとかして実現したかったことです。

最初は画像にすることを考えましたが、画像は画像でサイズをうまく合わせるのが難しく、細かく調整していると時間が足りなくなりそうだったのでなんとかしてテキストでやる必要がありました。

Xcodeのテキストエディタはコードハイライトを保ったままコピー&ペーストできるのでなんとかそれをうまく利用できないかと考えました。 問題は背景色までコピーされてしまって、白背景以外だと白の背景色が奇妙に見えてしまうということだけでした。

Keynoteの場合はペーストした後に背景色を透明にすればいいのですが、XcodeのInterface Builderで同じ操作をすると、色の設定も無くなってしまう、ということが問題でした。

ただ、何回かやっているうちに偶然、色の設定も無くなってしまったあとにUndoして再度同じ操作をしてさらにUndo、Redoとするとその過程でうまいこと色の設定が復活するという挙動が判明したので解決しました。

サンプルコードが載っているStoryboardやXIBファイルを見るとわかりますが、UITextViewにAttributed Stringでそのままコードハイライトされたテキストをペーストして使っています。

以上です。 プレゼンテーションに使ったスライドのアプリはこちらです。サンプルのアニメーションもそのまま動きます。台本の原稿も入っています。

github.com

参考になれば幸いです。アニメーションを使いこなして使って楽しいアプリを作りましょう。

macOS MontereyでXcode 14を起動する、またはmacOS VenturaでXcode 13以下を起動する。

未サポートのOSでバージョンが古すぎたり新しすぎで起動できないXcodeを起動するには、Terminal.appで

$ /Applications/Xcode-beta.app/Contents/MacOS/Xcode

のようにパッケージの中の実行ファイルを直接実行すると起動できる。

または、アプリケーションアイコンを右クリックしてShow Package Contentsを選んで、MacOS、Contentsとフォルダを開いてそこにあるXcodeの実行ファイルをダブルクリックでもOK。GUIでやりたい場合はこっち。

この方法で起動した場合、しばらく放置していると勝手に終了してしまったりするので、いつの間にか終了していた場合はやり直す。

stackoverflow.com

賃貸マンションでGoogle Nest Doorbellを使ってみた

スマートドアベル「Google Nest Doorbell (Battery Type)」を買って4か月ほど使ったので感想を書きます。

store.google.com

要約

  • 簡単に試したり現状回復を優先するなら設置は両面テープで
  • 既存のドアホンがカメラ付きならドアホンとしての性能を超えることはなく、利点はスマートフォン等で手元で応答できるという一点だけなのでそこが魅力と感じるならアリ
  • 充電しながらの使用はできない。電源の配線をしない場合は定期的に取り外しての充電が必要。

設置

キレイに設置するなら既存のドアホンを外してマウントをネジ止めして、、、とすると良さそうですがそれなりに面倒なのと原状回復のために取り外したものを保管しておくのも大変なので、設置は両面テープでやることにしました。

ただ、単に既存のドアホンの隣に付けてしまうと間違いなく既存のドアホンの方が使われてしまうので既存のドアホンを覆うように隠せてその上に設置できるようなマウントを作りました。

www.thingiverse.com

f:id:KishikawaKatsumi:20211224043346p:plain:w500

実際に設置した様子は下記になります。既存のドアホンの4辺にちょっと厚みがある両面テープで貼り付けています。3Mの両面テープは最強なので脱脂さえしっかりすれば落ちたりすることはありません。屋外に設置する場合は耐候性の高いものを選ぶと良いと思います。

それでもこれがドアホンだとはわかりにくく、特にボタンがわかりにくいので「PUSH」と書いたシールを作ってボタンに貼っています。

f:id:KishikawaKatsumi:20211224041300j:plain:w500 f:id:KishikawaKatsumi:20211224042227j:plain:w500

amzn.to

Google Homeのセットアップ

Nest Doorbellの操作はGoogle Homeアプリから行います。iPhoneやAndroidにGoogle Homeアプリをインストールしてセットアップします。

Nest Doorbellは家のWiFiに参加します。

そのためWiFiの電波が玄関ドアの外からそれなりの強度で受信できる必要があります。

5GHz帯の電波は障害物、特に金属のドアなどに弱いのでWiFiルーターの設置場所が玄関より遠いとうまく接続できない恐れがあります。

私の家はスマートロックのSesameをWiFiで操作しているのでそのために玄関ドアのすぐ近くにアクセスポイントを設置してあり、電波に関しては問題ありません。

完了すると、Google Homeアプリからカメラを確認したり会話ができます。ドアホンのボタンが押されたときはプッシュ通知が届きます。

設定によってカメラが人や荷物を認識したときにもプッシュ通知を受けることができます。

ここまでで手元でドアホンに応対するという目的は達成できますが、iPhoneは常に手元にあるものの、マナーモードで気づかなかったりすることもあるので、固定の場所で応答できるものがあった方が便利なので合わせてGoogle Nest Hubも導入しました。

store.google.com

Google Nest Hubだとドアホンのボタンが押されたときは自動的に画面が切り替わってカメラを確認したりそのまま応答できます。

これを机の手の届くところに置いておくことで簡単に席を立つことなくドアホンに応答できるようになりました。

また、ドアホンの応答については会話することもできますが、「クイック応答」というあらかじめ3種類の自動音声による応答が登録されていて、それを選ぶだけで決まった音声を再生してくれます。

f:id:KishikawaKatsumi:20211224082300p:plain:w400

ほとんどすべての来客は宅配便なので(それ以外のアポなしの来客はないし、あっても応じる必要はない)基本的に「荷物は置いていってください」を選択するだけでOKです。

ビデオ会議中にドアホンが鳴っても机の上のNest Hubからサッとクイック応答を選択するだけ、という感じで非常に快適に使えています。

クイック応答はiPhoneやNest HubだけでなくApple Watchでも使えるので、ちょっと外にいるときに荷物が届いた、という場合でもApple Watchでクイック応答を返すこともできて便利です。

カメラの応答性について

カメラの応答性はそんなに良くないです。

ドアホンがなってからカメラの映像が確認できるまで数十秒ほどかかることもあります。

なのでカメラの映像を確認しようとしている間に2回目のボタンが押されたり、不在と思われることもあります。

そのため私はカメラを確認することは結構あきらめて、単にクイック応答で投機的に「荷物は置いていってください」を返すようにしています。

クール便とかじゃなければだいたいそれで問題ないのでそのように使っています。

APIについて

DoobellをはじめNest製品はAPIが用意されています。

developers.google.com

APIを利用してカメラの映像をWebRTCで受信したりボタンが押されたなどのイベントの通知を受けることができます。

Googleがそのまま使えるサンプルアプリを公開しているので試したい場合はまずここから触ってみるのがいいでしょう。

github.com

このサンプルアプリはFirebaseにデプロイされているので自分でデプロイしなくてもAPIの利用登録とOAuthの設定などが済んでいれば、その情報を入力してそのまま使ってみることができます。

残念ながらAPIはDoorbellに関してはそれほど強力なものではなく、カメラの映像を見ることとドアベルが押された、などのいくつかのイベントのタイミングが取れるというくらいであまり良い利用方法は思いつきませんでした。

イベントに対してクイック応答が返せる、などができるとさらに自動化が進んでおもしろいと思ったのですが、そういうことはできませんでした。

期待したものとはちょっと違いましたがせっかく使い方を勉強したので電力や湿度など家のいろいろな数値と合わせてダッシュボードのように表示するWebアプリを作ってみました。

f:id:KishikawaKatsumi:20211224084642p:plain:w500

https://kishikawakatsumi-home-monitor.netlify.app/

まとめ

Nest Doorbell、非常に簡単に使えていろいろ遊べるので悪くないかと思います。

ただ、ドアホンとしては既存のドアホンよりめっちゃ優れているということはないのであくまでもスマートフォンなどと連携ができるという点を評価できない場合は難しいかと思います。

CIによるXcodeのテスト実行結果をGitHub Checksにわかりやすく表示する

Xcodeによるユニットテストの実行結果をCIサービスの画面で確認するのはなかなか大変です。

GitHubにはCIのステータスをそこそこリッチな画面表示として返せて、Pull Requestの画面から1クリックでアクセスできるGitHub Checksがあるのでそこで確認できればとても便利です。

f:id:KishikawaKatsumi:20211118115025p:plain:w400

ということでXcodeのテスト結果をGitHub Checksに表示するGitHub ActionとBitrise Stepを作りました。

github.com

Xcodeがテストを実行した際に生成するXcode Result Bundleというテスト結果やログ、コードカバレッジ、スクリーンショットなどをすべてまとめたデータを解析して、Markdownの形式でまとめてGitHub Checks APIにPOSTする、ということで実現しています。

次のように、テストを実行する際にXcode Result Bundleの出力先を指定して、このGitHub Actionにそのパスを渡すとGitHub Checksに結果が表示されます。

jobs:
  test:
    runs-on: macos-11
    name: Test
    steps:
      - uses: actions/checkout@v2
      - name: Test
        run: |
          xcodebuild -scheme MyFramework -resultBundlePath TestResults test

      - uses: kishikawakatsumi/xcresulttool@v1
        with:
          path: TestResults.xcresult
        if: success() || failure()
        # ^ This is important because the action will be run
        # even if the test fails in the previous step.

実行結果は次のようになります。サマリで失敗したテストがあるかどうかをサッと確認して、失敗してテストがあればページ内リンクで詳細にすぐに移動できるようになっています。 実際の実行結果はこちらのリンクから確認できます

UIテストなどでスクリーンショットをXCAttachmentsとして保存していた場合は画像が表示されます。便利です。

コードカバレッジを計測していた場合は、サマリの部分に表示されます。

Screen Shot

Screen Shot

Screen Shot

Screen Shot

Screen Shot

Screen Shot

CIサービスとしてBitriseを使っている場合はカスタムステップとしてXcode Result Bundle to GitHub Checksが使えます。

この場合はGitHub ChecksのAPIを利用するためにXcode Result to CheckというGitHub Appsをリポジトリにインストールする必要があります。

f:id:KishikawaKatsumi:20211118120856p:plain

Automate solving two-factor authentication for Apple ID on CI systems

Apple enforcing 2FA for all accounts.

Starting February 2021, additional authentication will be required for all users to sign in to App Store Connect. This extra layer of security for your Apple ID helps ensure that you’re the only person who can access your account. You can enable two-step verification or two-factor authentication now in the Security section of your Apple ID account or in the Apple ID section of Settings on your device.

It prevents some Fastlane automated tasks such as binary uploads without 2FA.

The best solution is to use the App Store Connect API key instead of the username and password authentication.

lane :release do
  api_key = app_store_connect_api_key(
    key_id: "D383SF739",
    issuer_id: "6053b7fe-68a8-4acb-89be-165aa6465141",
    key_filepath: "./AuthKey_D383SF739.p8",
    duration: 1200, # optional
    in_house: false, # optional but may be required if using match/sigh
  )

  pilot(api_key: api_key)
end

See also:

docs.fastlane.tools

However, if you want to automate an operation that is not supported by the App Store Connect API, such as downloading dSYM, or if you are unable to obtain an App Store Connect API key for some reason, I will share a way to automate to prompt the two-factor authentication code.

Set up a 3rd party web service to receive SMS

Vonage SMS API and their Australian phone number is able to receive SMS from Apple. Not many services seem to be able to receive Apple's 2FA SMS. I have tried Twilio and Clickatell, but they cannot receive Apple's SMS.

Sign up for Vonage and go to "Buy Numbers" to get an Australian phone number.

f:id:KishikawaKatsumi:20210126163252p:plain:w600

Add the phone number as a trusted phone number for Apple ID two-factor authentication. You can set multiple phone numbers.

f:id:KishikawaKatsumi:20210126165346p:plain:w600

Update Fastlane session cookies periodically on CI

When using Fastlane with an Apple ID that has two-factor authentication enabled, you will be asked to enter the two-factor authentication number when App Store Connect requires login.

In an interactive environment, you can enter the code, but on a CI systems, you cannot enter it and you will be stuck there. To solve this problem, you can generate a session cookie for successful login in advance and reuse it.

You can generate a session cookie with the fastlane spaceauth command and save the session variable or session cookie files to the CI cache for reuse.

See also:

docs.fastlane.tools

The following code automates 2FA authentication and updating Fastlane login sessions. You can run this code periodically on CI.

The generated session cookies are saved in the ~/.fastlane directory, so the ~/.fastlane directory is saved in the cache of CI and restored in each job of CI.

require "net/http"
require "uri"
require "pty"
require "expect"
require "fastlane"
require "spaceship"
require_relative "./setup_credentials"

def fastlane_spaceauth(user, password, default_phone_number)
  ENV["FASTLANE_USER"] = user
  ENV["FASTLANE_PASSWORD"] = password

  # SMS will be sent to the number specified
  # by `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` environment variable.
  ENV["SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER"] = default_phone_number

  $expect_verbose = true

  # Run the `fastlane spaceauth` command using PTY to respond to 2FA input
  cmd = "fastlane spaceauth"
  PTY.spawn(cmd) do |i, o|
    o.sync = true

    # If there is a valid session cookie, do nothing
    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      if match
        o.puts "y"
        return
      end
    end

    # If the session is invalid and need to enter the 2FA code
    i.expect(/Please enter the 6 digit code you received at .+:/, 60) do |match|
      raise "UnknownError" unless match
      sleep 10

      now = Time.now.utc - 120
      date_start = now.strftime("%Y-%m-%dT%H:%M:%SZ")
      date_end = (now + 120 + 120).strftime("%Y-%m-%dT%H:%M:%SZ")

      api_key = ENV["VONAGE_API_KEY"]
      api_secret = ENV["VONAGE_API_SECRET"]

      # Retrieve SMS containing 2FA code from the API
      uri = URI.parse("https://api.nexmo.com/v2/reports/records?account_id=#{api_key}&product=SMS&direction=inbound&include_message=true&date_start=#{date_start}&date_end=#{date_end}")
      request = Net::HTTP::Get.new(uri)
      request.basic_auth(api_key, api_secret)

      options = {
        use_ssl: true,
      }

      response = Net::HTTP.start(uri.hostname, uri.port, options) do |http|
        http.request(request)
      end

      records = JSON.parse(response.body)["records"]
      if records.nil? || records.empty?
        raise "NotFoundError"
      end
      message_body = records[0]["message_body"]

      # Parse a 2FA code from the SMS body
      code = message_body[/\d{6}/]
      if code.nil? || code.empty?
        raise "NotFoundError"
      end

      # Enter the code
      o.puts code
    end

    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      raise "UnknownError" unless match
      o.puts "y"
    end

    begin
      while (i.eof? == false)
        puts i.gets
      end
    rescue Errno::EIO
    end
  end
end

Another solution: Forwarding SMS using an Android device

On Android, you can get SMS from the program. So it may be possible to achieve this more cheaply by Android device with SIM and setting up a program to forward SMS to Gmail, etc.

See the following example:

github.com

Apple IDの2ファクタ認証をCI環境で突破する

【注意】この記事で紹介しているSMS APIサービスのVonageは利用規約により認証にVonageの電話番号を利用することを禁止しているという記述があるので、末尾の別解として載せたAndroidデバイスを使ってSMSを転送する方法が良さそうです。

help.nexmo.com

   

2021年2月から、App Store Connectにログインする際にすべてのApple IDで2ファクタ認証が必須になります。

Starting February 2021, additional authentication will be required for all users to sign in to App Store Connect. This extra layer of security for your Apple ID helps ensure that you’re the only person who can access your account. You can enable two-step verification or two-factor authentication now for the Apple ID associated with your developer account. Visit the Security section of your Apple ID account or the Apple ID section of Settings on your device.

この変更により、CIでFastlane等を使用してApp Store Connectの操作(バイナリのアップロード、TestFlightや審査の提出、etc.)を自動化しているところに支障をきたします。

これまでは2ファクタ認証が必須だったのはAccount Holderなど強い権限を持つ一部のアカウントだけでしたので、Account Holderではないアカウントを別に作成して、CIではそのアカウントを使用することでこの問題を回避できていました。

あるいはFastlaneは少し前にApp Store Connect APIの利用に完全対応したので、App Store Connect APIのキーを設定して利用する方法もあります。

前者の2FAを無効にしたアカウントを使う方法は2月以降は使えなくなります。また、すでに新規に作成するApple IDは2FAが必須になっており無効にできなくなっています。

理想の解決方法はApp Store Connect APIを利用することですが、App Store Connect APIキーの発行にはAccount Holderの権限が必要なため、クライアントワークの場合などすぐに対応できないこともあると思います。

この記事では2ファクタ認証が有効なApple IDでもCI環境でFastlaneが利用できるようにする方法を紹介します。

SMS送受信API

要するにSMSを受信できるサービスを利用します。

特別なことはないオーソドックスなアプローチです。

ただ、意外とAppleの2FAのSMSが届くサービスがなかったのでこれならいけた、というノウハウの共有になります。

結論をいうとVonageのSMS APIで、オーストラリアの電話番号を使うとAppleのSMSが受信できました。

Vonageにサインアップして「Buy Numbers」から電話番号を取得します。

f:id:KishikawaKatsumi:20210126163252p:plain:w600

Apple IDの2ファクタ認証に使用する電話番号は複数設定できるので、ここで取得した電話番号を追加します。

f:id:KishikawaKatsumi:20210126165346p:plain:w600

余談ですが、Vonageではオーストラリア以外の国の電話番号も取得できます。

いくつかの国の番号を試して、たまたま受信できたのがオーストラリアの番号だったというだけなので他の国の番号でもよいかもしれません。

国によって番号の維持費が違うのでより安価な番号が使えるならそれがよいでしょう。

受信したSMSはReports APIまたはWebのGUIから取得できます。

Incoming Webhookもあるので受信したらどこかに転送する、というのでもいいかもしれません。

Fastlaneのセッションを定期的に更新する

2ファクタ認証が有効なApple IDでFastlaneを利用する場合、App Store Connectのログインが必要なタイミングで2ファクタ認証の番号の入力が求められます。

インタラクティブな環境では番号の入力ができますが、CI環境では番号の入力ができずそこで止まってしまうのでその問題の解決としてログイン成功時のセッションクッキーを事前に生成しておき、それを再利用する方法があります。

具体的にはfastlane spaceauthコマンドにてログインセッションを生成し、セッション変数、あるいは保存されたセッションクッキーのファイルをキャッシュ等に保存して再利用する、となります。

詳しくは下記の公式ドキュメントをご覧ください。

docs.fastlane.tools

spaceauthコマンドによって生成されたセッション情報は、最初の1回は2FAの認証が必要ですが、有効な間に定期的に更新できれば2回目以降は2FAの認証は不要です。

ただしCI環境のマシン構成やタイムゾーンなどが大きく変わった場合には別のマシンと認識されるため再度2FAの認証が求められます。

そこで必要になるのが先ほどのSMS APIによる2ファクタ認証の自動化です。

最初の1回と、なんらかの理由により再度2ファクタ認証が必要になったときにCIの処理が止まってしまわないように自動で2ファクタ認証の番号が入力できるようにしておきます。

下記のコードは実際にヤプリのCIで利用しているFastlaneのセッションを定期的に更新する処理のコードです。

この処理を1日1回CIで定期実行しています。

生成されたセッションクッキーは~/.fastlaneディレクトリに保存されるので、~/.fastlaneディレクトリをCIのキャッシュに保存して、CIの各Jobで復元します。

今のところ、これでCIのJobが2FAにより失敗することはなくなりました。

もしかしたら1日の間にCIマシンの割り当てが大きく変わる、などがあると困るのかもしれませんが今のところ起こっていません。

Vonage APIで確認すると数日に1回は2FAのSMSが届いているようなので、なんらかの要因によりセッションが無効と判断されることがそこそこあるようです。 (複数のCIとBotの環境で利用しているので一般の環境よりは複雑です。)

require "net/http"
require "uri"
require "pty"
require "expect"
require "fastlane"
require "spaceship"
require_relative "./setup_credentials"

def fastlane_spaceauth(user, password, default_phone_number)
  ENV["FASTLANE_USER"] = user
  ENV["FASTLANE_PASSWORD"] = password

  # SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER環境変数を設定すると、
  # 複数の電話番号を登録している場合にどの番号にSMSを送るかを指定できる
  ENV["SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER"] = default_phone_number

  $expect_verbose = true

  # 2FAの入力に応答するためにPTYを使って`fastlane spaceauth`コマンドを実行する
  cmd = "fastlane spaceauth"
  PTY.spawn(cmd) do |i, o|
    o.sync = true

    # 有効なセッションクッキーがある場合は何もしない
    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      if match
        o.puts "y"
        return
      end
    end

    # セッションが無効で2FAの入力が必要な場合
    i.expect(/Please enter the 6 digit code you received at .+:/, 60) do |match|
      raise "UnknownError" unless match
      sleep 10

      now = Time.now.utc - 120
      date_start = now.strftime("%Y-%m-%dT%H:%M:%SZ")
      date_end = (now + 120 + 120).strftime("%Y-%m-%dT%H:%M:%SZ")

      api_key = ENV["VONAGE_API_KEY"]
      api_secret = ENV["VONAGE_API_SECRET"]

      # SMS APIから2FAのSMSを取得する
      uri = URI.parse("https://api.nexmo.com/v2/reports/records?account_id=#{api_key}&product=SMS&direction=inbound&include_message=true&date_start=#{date_start}&date_end=#{date_end}")
      request = Net::HTTP::Get.new(uri)
      request.basic_auth(api_key, api_secret)

      options = {
        use_ssl: true,
      }

      response = Net::HTTP.start(uri.hostname, uri.port, options) do |http|
        http.request(request)
      end

      records = JSON.parse(response.body)["records"]
      if records.nil? || records.empty?
        raise "NotFoundError"
      end
      message_body = records[0]["message_body"]

      # SMSの本文から2FAのコードを取得する
      code = message_body[/\d{6}/]
      if code.nil? || code.empty?
        raise "NotFoundError"
      end

      # コードを入力する
      o.puts code
    end

    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      raise "UnknownError" unless match
      o.puts "y"
    end

    begin
      while (i.eof? == false)
        puts i.gets
      end
    rescue Errno::EIO
    end
  end
end

ここからは蛇足ですがVonage以外のSMSが受信できるサービスはTwilioClickatellを試しました。

いろいろ試行錯誤しましたが、結論としてはAppleの2FAのSMSを受け取ることはこの2つのサービスでは不可能でした。

電話番号にはLong NumberとShort code(5-6桁の番号)がありAppleの2FAはShort codeから送られるようですが、どうもそれが受信できない模様です。

TwilioはリクエストするとShort codeの受信が有効になるアップグレードがあり、それを有効にして試したものの受信できませんでした。

ということで、いろいろなSMSのサービスを試したところVonageの番号だとAppleの2FAのSMSが受信できた、という話でした。

(別解)Androidデバイスを用いてSMSを転送する

これはまったく試してない方法ですが、AndroidはプログラムからSMSが取得できる手段があるそうです。なので、適当に余っている検証機にSIMを挿して、SMSを適当にGmail等に転送するようなプログラムを設定すればもっと安価に実現できるのかもしれません。

Androidデバイスを用いた方法は下記のコードで実現できるようです。

github.com