24/7 twenty-four seven

iOS/OS X application programing topics.

【レビュー】広い照明範囲を持つモニターライト「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

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分以上かかるので歩くのは少し遠い。

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

CIのシークレット変数に1Password CLIを利用する

CIでいろいろなタスクを自動化していると、CIで必要とするAPIのトークンやアカウント情報など設定しているシークレット変数が増えてきます。

たいていの場合はCIサービスのシークレット変数を利用すればよいですが、サービスによっては一度設定したシークレット変数を見ることができなかったり(GitHub ActionsやCircle CIが該当)、トークンやアカウント情報の更新や追加があったときにCIの変数を更新していくのが大変だったり、シークレット変数のメンテナンスはそこそこ面倒な作業です。

性質上かなり強い権限が設定されているトークンだったりすることもあるので、誰がその値をメンテナンスできるか、という管理の問題もあります。

そこで1Passwordをアカウント情報の共有に使っている組織なら、1PasswordはCLIの操作が提供されているのでCIから1Passwordのアカウント情報を取得すると便利だったので方法を共有します。

しばらく運用してみてわかったことは、すべてを1Passwordから取得するようにするのはシークレット変数の設定に1Password CLIのセットアップが必要で、初見の人には何をしているか分かりにくいのでDocs as Codeの側面が弱くなるというデメリットがあるので、ほとんどのシークレット変数は普通にCIサービスの機能を使う方が良いと思います。

その上で、

  • 更新や追加がそこそこある
  • 更新や追加をソフトウェアエンジニア以外の人がやることがある
  • シークレット情報の権限管理を簡単にしたい
  • 2要素認証のワンタイムパスワードをCIで入力したい

という課題がある場合に必要に応じて利用すると良いと思います。

ヤプリではApple IDと、Googleアカウントの情報をCIから1Password CLIを用いて取得しています。

Apple IDはクライアントごとのアプリの申請に必要でクライアントの増加とともにアカウントも増えます。

その際のアカウントの管理はソフトウェアエンジニア以外の人によって行われます。アカウントが追加されたときはCIのシークレット変数にも更新が必要ですが、1Passwordから取得することでメンテナンスの手間が不要になります。

GoogleアカウントはGoogleのサービスでAPIが提供されていない操作を自動化するために、ヘッドレスChrome APIライブラリのPuppeteerでログインするときの2要素認証をクリアするためにワンタイムパスワードを1Passwordから取得しています。

1Password CLIをCIの環境にインストールする

Macホストで動かす場合はHomebrewでインストールできます。

brew install --cask 1password-cli

LinuxやDocker環境の場合は下記のようにバイナリをダウンロードしてPATHの通っている場所に展開します。

export ONE_PASSWORD_VERSION="v1.8.0"

curl -sS -o 1password.zip https://cache.agilebits.com/dist/1P/op/pkg/$ONE_PASSWORD_VERSION/op_linux_amd64_$ONE_PASSWORD_VERSION.zip \
    && unzip -o 1password.zip op -d /usr/bin \
    && rm 1password.zip

詳しくは公式ドキュメントを見てください。

1Password CLIのセットアップ

1Password CLIで保管庫にアクセスするには、まず1Passwordのアカウントでログインして返ってきたセッション変数を環境変数に設定します。

CIのステップでセットアップするためのBashスクリプト、Fastlane等で利用するためのRubyスクリプト、Slack Bot等で利用するためのNode.jsスクリプトのそれぞれのセットアップ方法を下記に示します。

OP_PASSWORDOP_SIGN_IN_ADDRESSOP_EMAIL_ADDRESSOP_SECRET_KEYはCIのシークレット変数に設定します。

人の入れ替わり等でOP_〜変数の更新が発生しないようにCI専用の1Passwordユーザーを作成するとよいです。そのアカウント情報も1Passwordで管理するとうまくいきます。

# Bashスクリプト
yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS" "$OP_EMAIL_ADDRESS" "$OP_SECRET_KEY"
eval $(yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS")

 

# Rubyスクリプト
%x(yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS" "$OP_EMAIL_ADDRESS" "$OP_SECRET_KEY")
ENV["OP_SESSION_#{ENV["OP_SIGN_IN_ADDRESS"]}"] = %x(yes "$OP_PASSWORD" | op signin --raw "$OP_SIGN_IN_ADDRESS").strip

 

// Node.js
const env = process.env;

await exec(
  `yes "${env.OP_PASSWORD}" | op signin ${env.OP_SIGN_IN_ADDRESS} ${env.OP_EMAIL_ADDRESS} ${env.OP_SECRET_KEY}`,
  {
    env: env,
  }
);
const session = await exec(`yes "${env.OP_PASSWORD}" | op signin --raw`, {
  env: env,
});

env[`OP_SESSION_${env.OP_SIGN_IN_ADDRESS}`] = session.stdout.trim();

詳しくは公式ドキュメントを見てください。

1Password CLIからアカウント情報を取得する

1Password CLIを用いてアカウント情報を取得する例を下記に示します。

キーに対応するパスワードを取得する

もっとも基本的なキーに対応する値を取得する例は次のようになります。

envman add --key FASTLANE_PASSWORD --value `op get item "Apple ID - $YAPPLI_APP_STORE_USERNAME" --fields password`

このコードはApple ID - $YAPPLI_APP_STORE_USERNAMEという名前に対応したパスワードを1Passwordから取得して、FASTLANE_PASSWORDというキーで環境変数に設定しています。

op get item "<名前>" --fields passwordで名前に対応するパスワードが取得できます。

名前だけで一意に決まらない場合は--vaultオプションで保管庫を指定できます。

名前の代わりに内部的なUUIDを用いて参照することもできます。

名前で参照すると何を取得しているかが読みやすく、UUIDで参照すると何を取得しているのかが分かりにくくなりますが変更に強くなります(名前をいつでも好きに変えられる)。

特定の保管庫に保存されているアカウント情報をすべて取得する

次に特定の保管庫に保存されているアカウント情報をすべて取得してループする例です。

これは先に書いたようなアカウント情報の増減に自動的に対応するような処理を書く際に便利です。

def get_accounts
  %x(yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS" "$OP_EMAIL_ADDRESS" "$OP_SECRET_KEY")
  ENV["OP_SESSION_#{ENV["OP_SIGN_IN_ADDRESS"]}"] = %x(yes "$OP_PASSWORD" | op signin --raw "$OP_SIGN_IN_ADDRESS").strip

  items = JSON.parse(%x(op list items --vault "アプリ公開関係"))
  accounts = {}
  items.each do |item|
    account = JSON.parse(%x(op get item #{item["uuid"]} --fields username,password))
    accounts[account["username"]] = account["password"]
  end
  accounts
end

上記のコードは、まずop list items --vault "アプリ公開関係"として"アプリ公開関係"という名前の保管庫に保存されている項目をすべて取得します。

ここで返ってくる情報は概要のみ(UUIDや名前など)で実際のアカウント情報は含まれていないので、さらに各項目に対してループの中で先ほどのop get itemコマンドを使ってユーザー名とパスワードを取得しています。

最終的に、ユーザー名とパスワードがキーと値のHashオブジェクトが作られています。

2要素認証のワンタイムパスワードを取得する

1Passwordには2要素認証のワンタイムパスワードも共有できます。

その値もCLIを用いて取得できるので、2要素認証が必要な処理をCIで利用できます。

const env = process.env;
  
...

const totp = await exec(`op get totp ${env.GOOGLE_USERNANE}`, {
  env: env,
});

...

op get totp <名前>で共有されているワンタイムパスワードを取得できます。

ワンタイムパスワード(TOTP)なので時間によって変化するのでCIのシークレット環境変数等に登録することはできませんが、1Passwordに共有されているものを取得するようにすることでCIでも2要素認証をクリアできます。

上記の処理を用いてPuppeteerを使って、APIが提供されていないGoogleアカウントの操作を自動化しています。

実際のコード例を下記に示します。

const env = process.env;
  
await exec(
  `yes "${env.OP_PASSWORD}" | op signin ${env.OP_SIGN_IN_ADDRESS} ${env.OP_EMAIL_ADDRESS} ${env.OP_SECRET_KEY}`,
  {
    env: env,
  }
);
const session = await exec(`yes "${env.OP_PASSWORD}" | op signin --raw`, {
  env: env,
});

env[`OP_SESSION_${env.OP_SIGN_IN_ADDRESS}`] = session.stdout.trim();

const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
await page.goto("https://marketingplatform.google.com/home?authuser=0");

await page.type("#identifierId", env.GOOGLE_USERNANE);
await Promise.all([
  page.waitForNavigation({ waitUntil: ["load", "networkidle2"] }),
  page.click("#identifierNext > div > button"),
]);

await page.waitForTimeout(1000);
await page.waitForSelector('input[type="password"]');
await page.type('input[type="password"]', env.GOOGLE_PASSWORD);
await Promise.all([
  page.waitForNavigation({ waitUntil: ["load", "networkidle2"] }),
  page.click("#passwordNext > div > button"),
]);

await page.waitForTimeout(1000);
await page.waitForSelector("#totpPin");

const totp = await exec(`op get totp ${env.GOOGLE_USERNANE}`, {
  env: env,
});

await page.type("#totpPin", totp.stdout.trim());
await Promise.all([
  page.waitForNavigation({ waitUntil: ["load", "networkidle2"] }),
  page.click("#totpNext > div > button"),
]);

...

iOSDC 2019で「ライブラリのインポートとリンクの仕組み完全解説」という話をします

f:id:KishikawaKatsumi:20190905065623p:plain

スケジュール

概要(リンクにまつわる問題)

インポート・リンクの仕組みがよくわかっていない状態だと、エラーと自分の加えた変更が結びつかないので、よくわからないエラーが無限に起こっていると感じます(同じエラーメッセージを引き起こす原因は複数あるため)。

しかし実際はそうではないので、可能性の高い順番で確認していけば問題を解決できます。

そのための基礎として、インポート・リンクの仕組みを理解が必要になります。

モジュールのインポート・リンクがどのように解決されるのか、リンクとはいったい何をしているのかを学ぶことで、システマチックに問題を切り分け、解決できるようになります。

この話を聞いて得られること

基礎的な能力

  • ライブラリの形式に関する知識
  • インポートとリンクの知識
  • インポートとリンクにまつわるトラブル解決の能力

応用

  • ライブラリ作成における配布形態の技術判断
  • パッケージマネージャの高度な利用

話さないこと

Swiftコンパイラが具体的にバイナリをリンクする実装についてやOSが実行ファイルを読み込む方法、といった極端に低レイヤーのことは話しません。

それはシステムプログラミングの範疇です。

あくまでライブラリの利用者(応用編で少しだけ作成者についても)としてアプリケーションプログラミングの範囲で、ライブラリを利用可能にする基礎的な手順を解説します。

具体的な内容

以下に現在のスライドを少し示します。

例えば、問題解決チャートの設問に「リンクのエラー」か「インポートのエラー」か書かれていますが、この区別がつかない・自信がない人はけっこういるのではないでしょうか?

この話を聞くと、「これはインポートで起こってるエラー」「これはリンクエラー」「問題の切り分けとして〇〇をする」ということがスムーズにできるようになります。

f:id:KishikawaKatsumi:20190905074522p:plain:w300 f:id:KishikawaKatsumi:20190905074038p:plain:w300

f:id:KishikawaKatsumi:20190905074122p:plain:w300 f:id:KishikawaKatsumi:20190905074137p:plain:w300

f:id:KishikawaKatsumi:20190905074153p:plain:w300 f:id:KishikawaKatsumi:20190905074209p:plain:w300

FolioのiOSチームで利用しているFastfileとBitriseワークフロー

FolioのiOSチームではさまざまなタスクをそこそこ高度に自動化していると思うので、(そのまま別のプロジェクトで使いまわせるほどポータブルではないけど)参考にしてもらえる部分はけっこうありそうと思うので公開リポジトリに置いてみました。

github.com

簡単に解説します。

Fastfile

lane :snapshot_test

Folioアプリのユニットテストはいわゆる一般的なロジックテストに加えてスクリーンショットを用いたスナップショットテストがあります。

GitHub - uber/ios-snapshot-test-case: Snapshot view unit tests for iOS

目的は修正によって意図しない影響が起こっていないことを検証するためと、現状の画面の一覧をGitHubで変更管理したいからです(これについては詳細を後述)。

(ボタンを追加したら関係ないはずのラベルのテキストが溢れた、とか。あるいはエラー系の画面など通常の開発で確認を忘れたがちな画面についても安心できるとか)

これはよほど再現が難しい一部の画面をのぞいて、現在はかなりの画面をカバーできているので、安心できる反面、UIの変更でテストを一緒に修正しなければならない手間もあります。

なので、意図的なUIの変更はあまり何も考えずに関係する画面のスクリーンショットを撮り直して、git add -Aしてコミットする、というワークフローにしています。

(そうするとPRで確認できるし、変更は全部Gitに残るわけだからちょっと間違えても問題ない)

(それでもテストケースが増えたことでそこそこ大変になりつつあるので改善の余地あり)

このタスクは、スナップショットテストを必要なデバイスぶん記録モードで実行してローカルのスクリーンショットを上書きする、というものです。

このFastfileに定義されているタスクでは珍しくCIではなく手元で実行するタスクです。

lane :add_release_tag

アプリがリリースされたらそのタイミングでタグを作成する、というタスクですが、ちょうど良いトリガーのタイミングがなくて今は利用されていません。

lane : update_dependencies

導入しているライブラリの更新があったら自動的にCarthage.resolvedかPodfile.lockを更新してPRを出します。

ライブラリに更新があったら次のようなPRが夜の間に自動的に作成されます。基本的にテストが通っていればサッと確認してマージするだけ、Breaking Changeがある場合は必要に応じてこのPRにコミットを追加するか別PRでマニュアル作業で対応します。

f:id:KishikawaKatsumi:20190619155451p:plain

目的はライブラリの更新を溜めずに小さい単位にまとめてコントロール可能にすることです。

そのためにまず自動化したことで更新に気づくことができます。

そのままマージするだけのこともけっこうあるので負担の軽減ができます。

そうでない場合は対処が必要ですが、放置していると同じPRが無限に作成され続けるので、気持ちが悪いからどこかで対応しようという圧力になります。

このタスクはCIで平日の深夜に毎日実行されています。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L323-L337

lane : update_license_list

LicensePlistを実行して、利用しているライブラリのライセンス情報を更新します。

このタスクもCIで平日の深夜に毎日実行され、自動的にPRを作成します。基本的にただマージするだけです。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L338-L348

lane : update_tools

アプリで使用する以外のツール(主にFastlaneかCocoaPods)を自動的にアップデートします。 CIで平日の深夜に毎日実行され、自動的にPRを作成します。ただマージするだけです。それなら直接masterにコミットしてもいいのですが、PRを経由したほうが変更がわかりやすいのでそうしています。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L349-L359

lane : sync_bitrise_yml

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L91-L104

Bitrise.ymlをリポジトリにダウンロードします。変更があったら自動的にPRを作成します。

BitriseはWebインターフェースがとてもよくできていますが、一般的なCIサービスと同様に設定ファイルベースでも動作します。

というか、設定ファイルベースで動かしてタスクはGitで管理できる方が良いのですが、Bitriseの設定ファイル(Bitrise.yml)はWebインターフェースに比べると格段に難しく、またBitrise.ymlを優先で使うためには追加のセットアップが必要なので、それをやるのはいろいろ面倒になるのでやめました。

代わりに発想を逆転させて、Webインターフェースで設定した内容をリポジトリに定期的にダウンロードするようにしました。そのためのタスクがこれです。

平日の深夜にCIで実行されるので、最大で1日の遅延がありますが、CIのワークフローは今や頻繁に変更するものでもないので、変更があったタイミングでそれがわかって、追跡可能であるという要件はこれで完全に満たせていて、かつ明らかにこちらの仕組みの方が簡単なのでこのようにしています。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L252-L262

lane : screenshots_preview_generator

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L119-L123

スナップショットテストの実行で得られたスクリーンショットをMarkdown形式に整形してGitHubで簡単に確認できるようにします。

lane : refresh_dsyms

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L125-L144

AppStore Connectからデバッグシンボルをダウンロードして、Firebase Crashlyticsにアップロードします。

平日深夜にCIで実行されます。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L220-L234

lane : image_assets_tests, lane: folio_tests, lane: redux_tests, lane: notification_service_tests

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L146-L181

ユニットテストを実行します。実行時間の関係で、いくつかのテストは関係のあるファイルに変更があったときだけ実行できるように、テストの種類の応じてタスクを分割しています。

毎回のPRやmasterにマージされたタイミングで実行されます。

lane : folio_nightly_tests

毎回のPRで実行されるテストはすべてではないので、念のため、平日深夜にできるだけ多くのテストを実行するためのタスクです。

lane : upload_build_cache, lane: download_build_cache, lane: renew_build_cache

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L278-L303

Bitriseのキャッシュはブランチごと、PRでキャッシュは更新されないという仕様があるので、ライブラリの構成を変更するPRの場合、そのPRにコミットを追加する場合は基本的にライブラリのフルビルドが動いてしまう問題がありました。

現在のプロジェクトではCarthageとCocoaPodsでライブラリを管理していて、CocoaPodsについてもこちらの記事で紹介したようにビルド済みライブラリとして取り扱っているので、独自のキャッシュの仕組みを作ったのがこのタスクです。

Carthageのビルド済みライブラリと、CocoaPodsのビルド済みライブラリをtar.gzにアーカイブして独立したGitHubリポジトリのReleasesにアップロードします。

利用はReleasesからダウンロードして展開するだけです。

キャッシュのキーにはCarthage.resolvedとPodfile.lockのハッシュ値を使っています。ライブラリ構成に変更があったらこの値が変わるので新しいキャッシュが保存されます。

この仕組みにすることで、ライブラリの再ビルドは最初の1回だけ行われて、あとはキャッシュを利用できるようになり、PRのテストにかかる時間が大幅に短縮されました。

副次的なメリットとして、lane: download_cacheのタスクを手元で実行すると、CIでビルド済みのライブラリをダウンロードして利用できるようになりました。

このおかげで手元でライブラリをインストール、ビルドすることはほぼ不要になり、ライブラリ構成に変更があるようなPRをレビューする場合でもブランチを変更してlane: download_cacheを実行するだけでよくなり、待ち時間が減ってとても快適になりました。

(ネットワーク速度によるけど、オフィスでやる分には圧倒的にビルドするよりもダウンロードする方が速い)

この仕組みについては別の記事で詳しく書きたいと思います。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L44-L60

Bitrise.yml

workflow: test

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L14-L25

ユニットテスト(スナップショットテストを含む)を実行します。

PRとPRのマージ(=masterへのPush)に実行されます。

次のリリースのためのブランチとしてv2.16.0のようなブランチができることがあるのでvで始まるブランチ名もmasterと同様に扱います。

workflow: deliver, workflow: deliver-external, workflow: deliver-internal

リリースビルドもしくはAd-HocビルドをしてAppStore ConnectかFabric Betaにアップロードします。

_release/*_testflight/*_fabric-beta/*という特定のPrefixで始まるブランチからPRが出されると動作する仕組みです。

Bitriseはこのようにブランチのネーミングルールでトリガーするワークフローをマッピングすると便利、ということに気づくと飛躍的に便利度が上がります。

リリースの際にはビルド番号をインクリメントするという退屈だけど正確に行わなければ失敗するタスクがあるので、私たちのチームではChatBotによりそれを自動化しています。

ChatBotはバージョンをインクリメントしてPRを出すところまでを担当し、PRが出るとCIでリリースビルドが作成される、という風にタスクのチェーンが繋がります。

ChatBotはこちら。

github.com