Engineering

@AppStorage のキー衝突を User Script Sandboxing 有効のままビルドで検知する

  • iOS
  • SwiftUI
  • Bookil
  • Swift
  • UserDefaults
  • Xcode

書籍リーダー Bookil の開発中ブランチで、ライブラリ一覧の表示モードとビューアーの見開き設定が、同じ @AppStorage のキーを別の型で共有していました。UserDefaults のディスク上の値が型をまたいで上書きし合うため、画面を行き来すると一覧の表示モードが意図しない値に戻ります。ストア配信前のブランチで踏んだ事象で、修正自体はキーを分割する数行ですが、本題は「同じ事故をビルドで止めるにはどう組むか」です。型安全ラッパーは予防にならず、SwiftLint でも捕まえられず、最終的に ENABLE_USER_SCRIPT_SANDBOXING を有効に保ったまま検知する形に落ち着きました。

App StoreBookil App - App StoreDownload Bookil by Labee LLC on the App Store. See screenshots, ratings and reviews, user tips, and more apps like Bookil.

同じ開発サイクルでは描画層の作り直しも並行しています。ビューアー側を書き換えていたタイミングで踏んだ事象なので、描画層側の話と合わせて読むと文脈が補えます。

ブログBookilで漫画の見開きをページ送りで読ませたくて描画を作り直しているiOS の PDFKit は「ページ送り」と「見開き表示」を同時に成立させられません。書籍リーダー Bookil で見開きの読み心地を作りたかった結果、描画層を自前で持つ方針に振って今もその開発を進めています。PDFKit の制約と、判断の軸を扱います。

開発中ブランチで再現していた挙動

開発中のビルドをシミュレーターで触っていたとき、ライブラリ一覧の表示モードを切り替えてからビューアーを開き、見開き設定を変えて一覧へ戻ると、一覧の表示が初期値のグリッドに戻っていました。手順としてはこの並びです。

  • ライブラリ一覧をリスト表示に切り替える
  • そのままビューアーを開いて見開き表示を選ぶ
  • 一覧へ戻ると、リストにしていたはずがグリッドへ戻っている

表示モードを切り替えるだけでは起きず、「一覧 → ビューアー → 一覧」という画面遷移をまたいだときだけ再現しました。配信前の開発中ブランチで踏んだので、ユーザー環境に出る前に拾えた事象です。

キーの共有と RawRepresentable のフォールバックが噛み合う

一覧の表示モードもビューアーの見開き設定も @AppStorage で永続化していました。問題は、両者が同じキー文字列 "settings:displayMode" を別の型で参照していたことです。修正前のコードは次の形でした(現在の HEAD ではキーを分割済みです)。

// LibraryView
@AppStorage("settings:displayMode")
private var displayMode: LibraryDisplayMode = .grid  // rawValue: "grid" / "list"

// ViewerView
@AppStorage("settings:displayMode")
private var displayMode: String = ReaderDisplayMode.single.rawValue  // "single" / "spread"

@AppStorageRawRepresentable を扱うと、保存済みの文字列を init(rawValue:) でデコードできなかったときにデフォルト値へフォールバックします。この挙動が値空間の共有と噛み合うと、次の連鎖が起きます。

  1. 一覧をリスト表示にする。UserDefaults 上の "settings:displayMode""list" が入る
  2. ビューアーで見開きを選ぶ。同じキーが "spread" で上書きされる
  3. 一覧へ戻って再描画。LibraryDisplayMode(rawValue: "spread")nil になり、デフォルトの .grid に落ちる

UserDefaults のキーは型を持たないので、コンパイラはこの共有を一切咎めません。修正自体はキーを "settings:libraryDisplayMode""settings:readerDisplayMode" に分けるだけの数行で、開発中ブランチの話だったため、保存済み値の移行ロジックも要りませんでした。

直す行数は少ないものの、症状から原因に辿り着くまでが長い種類のバグです。型の不一致が起きているのは Swift の型システムの外、UserDefaults のディスク上の値で、ソース上はどちらの宣言も完全に妥当に見えます。配信後に同じ構造を踏み込めば、今度はユーザー環境に残った値の移行まで抱える話になります。今のうちにビルドで止める仕組みを噛ませておきたい、という方針が固まりました。

型安全ラッパーや既製 Linter では塞げない理由

同じ事故を防ぐ仕組みを考えると、まず型安全なキー管理が思い浮かびます。Bookil でも、表記揺れによるキー文字列のタイポを避けるために SettingsStorageKey という列挙体を持っています。

enum SettingsStorageKey {
  static let appearance = "settings:appearance"
  static let keepScreenOn = "settings:keepScreenOn"
  static let sidebarSide = "settings:sidebarSide"
  static let defaultReadingDirection = "settings:defaultReadingDirection"
}

これは「文字列リテラルを直書きしない規約」を支える仕組みで、強制力はありません。実際 displayMode 系のキーはこの列挙体に集約されておらず、@AppStorage("settings:libraryDisplayMode") のように直書きされている箇所が残っています。集約を進めても、隣で誰かが新しく @AppStorage("settings:brightness") と直書きすればすり抜けるという性質は変わりません。

「拡張イニシャライザを足して @AppStorage(SettingsStorageKey.libraryDisplayMode, default: .grid) のような形に統一する」も同じ筋で、ラッパーを通さなかった瞬間にすり抜けます。ラッパー設計だけでは「逸脱したら落とす」が成立しません。

Swift マクロも今回の用途には届きません。マクロは適用された宣言のスコープ外を参照できず、全ファイルを横断する大域シンボルテーブルが展開時には存在しないため、「別ファイルで同じキーが別の型に使われている」ことをマクロからは知りようがありません。

SwiftLint も同じ理由で外れます。SwiftLint は基本的に1ファイル単位の解析で、custom_rules の正規表現マッチもファイル単位です。今回必要なのは「あるキー文字列が、別のファイルで別の型に紐づいていないか」という cross-file の照合で、SwiftLint の土俵の外側にあります。

つまり、強制力を持たせるには「全ソースを走査して、衝突したらビルドを落とす」自前の仕組みを噛ませるしかありません。走査本体は外部依存を足さず bashgrepawk(いずれも macOS 標準)で書けます。@AppStorage("...") のキー文字列と宣言型を集めて、同一キーに2種類以上の型が現れたら file:line: error: ... を出力して exit 1 する素朴なスクリプトです。問題は、これをビルドのどこで、どう走らせるかでした。

壁になった User Script Sandboxing

Run Script build phase にスクリプトを仕込んで exit 1 でビルドを落とす、という単純な算段は、Xcode 15 から新規プロジェクトでデフォルト有効になった ENABLE_USER_SCRIPT_SANDBOXING に阻まれます。実行ログには次のような拒否が並びます。

Sandbox: bash(...) deny(1) file-read-data .../scripts/check-appstorage-keys.sh
Sandbox: grep(...) deny(1) file-read-data .../Bookil/bookil

サンドボックスは、ソースルートと DerivedData の読み書きを、ビルドフェーズに宣言した Input / Output ファイルだけに絞ります。外部スクリプトを読むのも、grep -r でソースディレクトリを走査するのも、宣言していなければ拒否されます。

ここで詰まったのは3点です。

  • Input Files はワイルドカード非対応 — $(SRCROOT)/bookil/**/*.swift のような glob は書けず、リテラルパスと $(SRCROOT) などの変数展開しか受け付けません。全 .swift を1つずつ並べるのは現実的ではなく、新規ファイルの追加も拾えません。
  • grep -r のディレクトリ走査は deny される — 宣言済みファイルの読み取りは許されても、ディレクトリ自体を舐めて回る動作は許可されません。
  • Scheme の Pre-action はビルドを失敗させられない — Pre-action はサンドボックスの外で走るためファイル走査は自由ですが、exit 1 しても終了コードが無視され、ビルドは BUILD SUCCEEDED のまま通ります。ゲートには使えません。

「サンドボックスを無効にすればいい」は確かに動きます。SwiftLint の README も Xcode 15 以降の手順として ENABLE_USER_SCRIPT_SANDBOXING = NO を案内していて、全ソースを読むツールとサンドボックスはそもそも噛み合いません。それでも他のビルドスクリプトも一緒に外れてしまうのは避けたかったので、有効のまま成立させる筋を探しました。

Pre-action と Run Script の役割分担で解く

鍵は、3つの制約を裏返すと役割分担が見えることでした。Pre-action は「サンドボックス外で全ソースを読める」が「ビルドを失敗させられない」、Run Script は「ビルドを失敗させられる」が「ディレクトリ走査ができない」。どちらも単独では完結しませんが、組み合わせれば噛み合います。

実行ポイントできることできないこと
Pre-action(サンドボックス外)ディレクトリ走査ビルドを失敗させる
Run Script(サンドボックス内)exit 1 でビルドを失敗させるディレクトリ走査

Pre-action が全ソースを走査して「衝突した箇所」を1つの結果ファイルに書き、Run Script はその結果ファイル1つだけを読み、中身があれば落とす、と分担を決めました。Run Script が読むのは事前に Input Files として宣言した小さなファイル1つなので、サンドボックスは有効のまま通せます。

flowchart LR
  Start([ビルド開始]) --> Pre["Pre-action<br/>サンドボックス外<br/>grep -r で全ソース走査"]
  Pre --> File[("appstorage-errors.txt<br/>衝突行のみを書き出す")]
  File --> Run["Run Script<br/>サンドボックス内<br/>Input Files に宣言した1ファイルを読む"]
  Run -->|空ファイル| Pass([ビルド継続])
  Run -->|中身あり| Fail([cat して exit 1])

Pre-action(Edit Scheme → Build → Pre-actions、Provide build settings from にターゲットを指定)に置くのは次の数行です。

mkdir -p "${SRCROOT}/build/build_phases"
"${SRCROOT}/scripts/check-appstorage-keys.sh" 2>&1 | grep ': error:' \
  > "${SRCROOT}/build/build_phases/appstorage-errors.txt" || true

走査の本体 check-appstorage-keys.shgrep -r でソースを舐め、@AppStorage("...") のキー文字列と直後の宣言型を awk で突き合わせ、衝突行を file:line: error: ... 形式で吐きます。中核はこういう構造です。

#!/usr/bin/env bash
set -euo pipefail

scan_dir="${1:-${SRCROOT:-$(cd "$(dirname "$0")/.." && pwd)}/bookil}"

{ grep -rnE '@AppStorage\("' "$scan_dir" --include='*.swift' || true; } | awk '
  {
    p1 = index($0, ":"); path = substr($0, 1, p1 - 1); rest = substr($0, p1 + 1)
    p2 = index(rest, ":"); lineno = substr(rest, 1, p2 - 1); content = substr(rest, p2 + 1)

    if (!match(content, /@AppStorage\("[^"]+"/)) next
    seg = substr(content, RSTART, RLENGTH)
    match(seg, /"[^"]+"/)
    key = substr(seg, RSTART + 1, RLENGTH - 2)

    type = "(inferred)"
    if (match(content, /var[ \t]+[A-Za-z0-9_]+[ \t]*:[ \t]*[A-Za-z0-9_]+/)) {
      n = split(substr(content, RSTART, RLENGTH), a, ":")
      type = a[n]; gsub(/[ \t]/, "", type)
    }

    reckey[NR] = key; rectype[NR] = type; recloc[NR] = path ":" lineno
    pair = key SUBSEP type
    if (!(pair in seen)) { seen[pair] = 1; typecount[key]++ }
  }
  END {
    bad = 0
    for (i = 1; i <= NR; i++) {
      k = reckey[i]
      if (k != "" && typecount[k] > 1) {
        print recloc[i] ": error: @AppStorage key \"" k "\" is bound to type " rectype[i] " here but to a different type elsewhere"
        bad = 1
      }
    }
    exit bad
  }
'

grep がノーマッチでも止まらないように || true で受け、awk 側で seen[key SUBSEP type] 集合を作り、同一キーに対する型の種類が2以上のときだけ error: 行を吐きます。Pre-action はその出力から error: を含む行だけを結果ファイルに書きます。衝突がなければ空ファイルです。

Run Script build phase 側は、Input Files に $(SRCROOT)/build/build_phases/appstorage-errors.txt を宣言したうえで、本文をこう書きます。

set -euo pipefail
errors="${SCRIPT_INPUT_FILE_0:?}"
if [ -s "$errors" ]; then
  cat "$errors"   # file:line: error: 行をそのまま出すので Xcode が該当行に表示する
  exit 1
fi
echo "AppStorage keys OK"

ビルド開始時に Pre-action が走査して結果を書き、Run Script は結果1ファイルだけを読み、衝突行があれば cat して exit 1 する流れです。cat した file:line: error: 行を Xcode がそのまま解釈して、該当行にインラインでエラーを表示します。

配線で詰まった2箇所

仕組みが正しくても、最初は意図的に衝突を仕込んでもビルドが通ってしまいました。原因は配線の細部です。

  • Pre-action の Provide build settings from を None のままにしていた — このまま実行すると ${SRCROOT} が空に展開され、書き込み先が /build/build_phases になって失敗します。Pre-action の失敗は握り潰されるため、結果ファイルだけが静かに生成されません。Provide build settings from にターゲットを指定すれば解決します。
  • スクリプトに実行権限が付いていなかった — Pre-action は "${SRCROOT}/scripts/..."bash を介さず直接実行するため、+x がないと Permission denied で死にます。error: 行を吐かないので結果ファイルは空、ビルドは通ります。chmod +x で解決し、実行ビットは git が追跡するので CI でも保たれます。

最終的に、settings:brightnessStringDouble で衝突させると該当2箇所を Xcode のエディター上で error: で指してビルドが失敗し、衝突を外すと通る、という両方向の挙動を確認しました。

このゲートで拾える範囲とこぼれるところ

このゲートが拾うのは @AppStorage("...") の宣言だけです。UserDefaults.standard.string(forKey:) のように UserDefaults を直接叩く箇所は走査の対象外で、同じキー文字列に対する別型のアクセスがそこに紛れ込めば検知できません。キーと型の対応を1対1に保つ原則を本気で強制したいなら、生の UserDefaults アクセスまで走査範囲を広げる余地があります。

走査側のスクリプトも素朴な実装で、複数行に分割された @AppStorage 宣言や、型推論に頼って明示型を書かない宣言は拾い切れません。Bookil の現状のコード規約では @AppStorage("...") private var name: Type = default を1行で書いているため当面は問題になりませんが、規約に依存している割れやすさは残ります。

それでも、cross-file のキー衝突という既製 Linter が踏み込めない領域を、外部依存ゼロの grepawk で塞げたのは収穫でした。型安全ラッパーやマクロは「使えば安全」で止まり、生リテラルの直書きには強制力がありません。逸脱をビルドで落とすところまで持っていって初めて予防になります。User Script Sandboxing の3つの制約も、走査を Pre-action に、ゲートを Run Script に分けて読めば、無効化せずに済む設計図として裏返せました。

次に手を入れたいところ

走査範囲を @AppStorage から UserDefaults の直接アクセスまで広げるのが次の課題です。UserDefaults.standard.set(_:forKey:)object(forKey:) 系の呼び出しに対しても、キー文字列と引数・戻り値の型を突き合わせる必要があります。grepawk の延長で書くか、SwiftSyntax で構文木を歩く実装に切り替えるかは、ルールが増えてきたタイミングで決めます。

CI で同じゲートを通す配線も入れます。手元の Xcode ビルドではこのゲートが効きますが、CI が xcodebuild を直接呼ぶ場合は Scheme の Pre-action が走らないため、Pre-action 相当の走査を CI の独立したジョブに移すか、xcodebuild のフックを噛ませて等価の処理を行うかを検討中です。Scheme に閉じている現状は片肺なので、CI で衝突したコミットを止められる状態に揃えるところまで持っていきます。