個人でアプリを作っていると、リリース当日に App Store Connect から「ITMS-91053: Missing API declarations」と返ってきて、丸一日予定が吹き飛ぶことがあります。私自身、累計 5,000 万ダウンロードを超えた壁紙系・癒し系のアプリを 2014 年から運用してきましたが、2024 年に Privacy Manifest が必須化されてから、リリース直前のリジェクトに当たる回数が一気に増えました。
ここで一番苦しかったのは「自分が書いたコードのせいではない」ことでした。Rork や Expo が読み込むネイティブライブラリの奥深く、自分が触ったことのない SDK の中で UserDefaults が呼ばれていて、その宣言が抜けているだけで全部止まる。Apple の自動チェックは人間より厳しく、しかも理由が表に出てくるまでに数時間かかる。事後対応ばかりしていると、月のリリース計画が崩れていきます。
この記事は、その「事後対応モード」から「事前監査モード」へ移行するための実用手順をまとめたものです。Rork で生成した Expo / React Native アプリを前提に、提出 1 週間前の半日〜1 日で SDK 連鎖を全数洗うフローを示します。読み終わったあとに、npm ls と find だけで実用的な監査スクリプトを 1 本作れるところまで持っていきます。
なぜ「リジェクト後対応」では遅いか
Required Reason API の事後対応は、見かけより重い作業です。1 回目のリジェクトを受けてから対応すると、最低でも次のサイクルが回ります。
まずリジェクトメールが届くまでに自動審査で 2〜6 時間。原因 SDK を突き止めるのに 1〜3 時間。PrivacyInfo.xcprivacy の編集と EAS Build のやり直しに 30 分〜2 時間。再アップロードとプロセスが終わるまでにさらに 1 時間。順調にいって半日、悪くて 1.5 日が消えます。
私の場合、2024 年の春にこれを 3 回連続でやりました。新作の壁紙アプリで、AdMob を入れる前は通っていたビルドが、AdMob 追加で UserDefaults 系の Required Reason 違反が出る。RevenueCat を追加するとさらに NSPrivacyAccessedAPICategoryDiskSpace の宣言が必要になります。SDK を 1 個入れるたびに 1 回リジェクトを食らって、リリース予定がまるごと 1 週間後ろにずれました。
事前監査に切り替えてから、私の個人開発のリリースサイクルでは ITMS-91053 のリジェクトはほぼゼロになりました。事前監査が「審査運に賭ける作業」を「再現性のある工程」に変えてくれたわけです。
SDK 連鎖の正体を最初に把握する
Privacy Manifest は階層構造を持ちます。アプリ本体の PrivacyInfo.xcprivacy 1 枚で済むケースはほぼなく、ネイティブモジュールごとに同じファイルが必要になります。Apple の判定はビルドされた .ipa の中身全体に対して行われるため、依存 SDK のどこか 1 枚でも未宣言だと全部落ちます。
Rork が生成する典型的な Expo アプリの構造は、おおよそ次の 3 階層になっています。
アプリ本体(app/ 配下と app.json から生成される iOS プロジェクト)
Expo SDK(expo-* 系モジュール、約 20〜40 個)
サードパーティ React Native ライブラリ(広告・課金・分析・ストレージ・通知系、5〜15 個)
このうちアプリ本体は自分で PrivacyInfo.xcprivacy を置けば終わりですが、残りの 2 階層は SDK 提供元が同梱しているかどうかに依存します。2026 年 5 月時点で、Expo SDK 53 系・54 系はほぼ全モジュールが対応済みですが、サードパーティはまだムラがあります。
特に注意すべきは、Expo の Config Plugin で動的にネイティブコードを差し込むタイプのライブラリです。Plugin 経由で追加されるネイティブ参照は EAS Build のたびに生成されるため、ローカルの ios/ フォルダを grep しても見えないことがあります。私自身、expo-notifications の Plugin が出力するファイルに PrivacyInfo.xcprivacy が含まれていないことに気づくのに 1 日かかった経験があります。
1 日で監査するスケジュール(半日コースと終日コース)
「半日コース」は新規追加 SDK が 1〜2 個のとき向けです。「終日コース」はメジャー Expo SDK アップグレード後・Rork の AI 提案で大幅に依存ツリーが変わったとき向けです。私は壁紙アプリ系を更新するときは半日コース、癒し系アプリで OS の年次アップデートに追従するときは終日コースを使っています。
半日コース(3 時間想定)の構成はこうです。
0:00〜0:30 — npm ls --production --depth=5 で依存ツリーを取得し、PrivacyInfo.xcprivacy 検索スクリプトを流す
0:30〜1:30 — 未同梱 SDK をリスト化し、最新版アップデートで解決するかを確認
1:30〜2:30 — PrivacyInfo.xcprivacy の自前マージ・追記・EAS Build のローカル検証
2:30〜3:00 — xcrun altool --validate-app 相当の自動チェックで .ipa 全体を検証
終日コース(7〜8 時間想定)は、上記に加えて以下を入れます。
Expo Config Plugin が出力するファイルの追跡(EAS Build ログを読む)
Apple Developer Console の「Required Reason API documentation」の差分チェック
Privacy Nutrition Label(App Privacy)と Manifest の整合性チェック
ここからは半日コースの中身を、Rork で生成した実プロジェクトに当てはめながら順に展開していきます。
ステップ 1:依存ツリーを正確に取り出す
最初にやるのは「アプリのビルドに含まれる SDK を全部リストにする」ことです。Rork のチャットに依頼してもいいのですが、ここはローカルで完結する方が再現性があります。
# Rork が生成したプロジェクトのルートで実行
npm ls --production --depth=5 --json > /tmp/deps-tree.json
# ネイティブ実装を含む可能性が高いパッケージだけ抽出
jq -r '
[.. | objects | select(.dependencies != null)
| .dependencies | keys[]] | unique[]
' /tmp/deps-tree.json \
| grep -E "^(react-native-|expo-|@react-native-|@expo/|@stripe/|@sentry/|firebase|@react-native-firebase|react-native-google-mobile-ads|react-native-mmkv|react-native-purchases)" \
> /tmp/native-deps.txt
wc -l /tmp/native-deps.txt
--depth=5 にしているのは、Expo SDK の中の依存(expo-modules-core など)が 2〜3 階層深いところに居ることが多いからです。--depth=Infinity だとファイルが大きすぎて後段の処理が遅くなります。
私の壁紙アプリ(依存約 22 個)の場合、上記スクリプトで 40〜55 行ほどが返ってきます。Rork が AI でコードを書き換えるたびにこの数字が変動するので、リリース直前にもう一度走らせると差分が見えます。これが「監査の出発点」になります。
ステップ 2:PrivacyInfo.xcprivacy の同梱状況を一括チェック
依存パッケージごとに PrivacyInfo.xcprivacy が同梱されているかを確認します。素直にやると 1 個ずつ find する地獄になるので、私は以下のシェルスクリプトを _tools/audit-privacy-manifest.sh として置いています。
#!/usr/bin/env bash
# audit-privacy-manifest.sh — 依存 SDK の PrivacyInfo.xcprivacy 同梱状況を一括チェック
set -euo pipefail
NATIVE_DEPS = " ${1 :-/ tmp / native-deps . txt } "
REPORT = "/tmp/privacy-audit-$( date +%Y%m%d-%H%M).tsv"
printf "package\tstatus\tpath\n" > " $REPORT "
while IFS = read -r pkg ; do
base = "node_modules/ $pkg "
if [ ! -d " $base " ]; then
printf "%s\tmissing-dir\t-\n" " $pkg " >> " $REPORT "
continue
fi
found = $( find " $base " -name "PrivacyInfo.xcprivacy" 2> /dev/null | head -1 || true )
if [ -n " $found " ]; then
printf "%s\thas-manifest\t%s\n" " $pkg " " $found " >> " $REPORT "
else
printf "%s\tNO-MANIFEST\t-\n" " $pkg " >> " $REPORT "
fi
done < " $NATIVE_DEPS "
echo "report: $REPORT "
column -t -s $' \t ' " $REPORT " | head -30
実行すると、NO-MANIFEST の行が「今すぐ対応が必要な SDK」、has-manifest の行が「同梱済み・追加対応不要」になります。私の最新の壁紙アプリでは、依存 48 個のうち 6 個が NO-MANIFEST で、そのうち 3 個は最新版で対応済みだったので npm update で解消、残り 3 個を手動対応する流れでした。
ステップ 3:未同梱 SDK の判定フロー
NO-MANIFEST の行が出てきても、慌てる必要はありません。次の順番でチェックすれば、ほとんどは数十分で片づきます。
第一段階は「最新版で同梱されているか」です。npm view <package> versions で過去バージョンを並べて、npm view <package@latest> files で同梱ファイルを確認します。同梱されていれば npm install <package>@latest で解決です。私の経験では、2024 年中盤までにメジャーリリースした SDK の 80% は、2025 年中に Privacy Manifest 同梱版を出していました。
第二段階は「GitHub の Issues で対応状況を見る」です。https://github.com/<org>/<repo>/issues?q=PrivacyInfo でほぼ確実にスレッドが見つかります。マージ済みなら次のリリースを待つか、patch-package で当てる選択になります。
第三段階は「自前で同梱する」です。ライブラリ作者が放棄している場合や、社内 fork している場合の最終手段で、postinstall スクリプトで node_modules/<package>/ios/PrivacyInfo.xcprivacy をコピーします。実例として、私が古めの React Native アニメーションライブラリで使っているスニペットを置きます。
// scripts/inject-privacy-manifest.js
// SDK 提供元が同梱していない場合の最終手段
const fs = require ( "fs" );
const path = require ( "path" );
const targets = [
{
pkg: "react-native-some-old-lib" ,
iosDir: "ios" ,
manifest: "manifests/some-old-lib.xcprivacy" ,
},
];
for ( const t of targets) {
const dest = path. join ( "node_modules" , t.pkg, t.iosDir, "PrivacyInfo.xcprivacy" );
if (fs. existsSync (dest)) continue ;
fs. mkdirSync (path. dirname (dest), { recursive: true });
fs. copyFileSync (t.manifest, dest);
console. log ( `injected: ${ dest }` );
}
package.json の scripts.postinstall に node scripts/inject-privacy-manifest.js を追加しておけば、npm install の度に自動で復元されます。patch-package でも同じことができますが、私はファイル単位で完結するこちらのほうが追跡しやすいと感じています。
ステップ 4:頻出 SDK の対応状況早見表(2026 年 5 月時点)
私が壁紙系・癒し系・引き寄せ系のアプリで実際に使っている SDK の現状をまとめておきます。バージョンは記事公開時点の最新を想定しています。リリース直前は必ず最新の npm view <pkg>@latest を見るようにしてください。
react-native-google-mobile-ads(AdMob)— 13.x 系で同梱。UserDefaults: CA92.1 + SystemBootTime: 35F9.1 を要求。AdMob 単体で Manifest 対応が一番厄介な部類で、12.x 以下は要手動対応
react-native-purchases(RevenueCat)— 9.x 系で同梱。DiskSpace: E174.1 と UserDefaults: CA92.1。アプリ側の Privacy Nutrition Label と整合させる
@sentry/react-native — 6.x 系で同梱。FileTimestamp: C617.1 と UserDefaults: CA92.1 が標準
@react-native-firebase/app 系 — 22.x 系以降で同梱、Auth/Analytics/Messaging/Crashlytics サブパッケージそれぞれにファイルあり
react-native-mmkv — 3.x 系で同梱、UserDefaults: CA92.1
@react-native-async-storage/async-storage — 2.x 系で同梱、UserDefaults: CA92.1
expo-notifications / expo-file-system / expo-secure-store — Expo SDK 53 以降で同梱、追加宣言不要
react-native-mixpanel — 系列によって状況がバラつくため、最新版同梱なら採用、そうでなければ純正の Mixpanel iOS SDK を直接組み込む方が早い
ここに載っていない SDK は、最初に npm view <pkg> files | grep -i privacy をたたいて、含まれていれば即採用、なければ第三段階の自前同梱に進む、というルートで処理しています。
ステップ 5:アプリ本体側の PrivacyInfo.xcprivacy を整える
依存 SDK の整理が終わったら、自分のアプリ本体の PrivacyInfo.xcprivacy を最終確認します。Rork で生成した Expo アプリの場合、配置場所はやや特殊です。
通常の Expo: app/PrivacyInfo.xcprivacy(app.json の ios.privacyManifests から取り込まれる)
bare workflow: ios/<AppName>/PrivacyInfo.xcprivacy
Rork が生成したカスタム構成: app.json の expo.ios.privacyManifests を読む
app.json を使う場合のテンプレートはこんな形になります。私は壁紙系アプリで以下のテンプレートをそのまま使っており、AdMob・RevenueCat・Sentry を入れた状態でも追加宣言なしで通っています。
{
"expo" : {
"ios" : {
"privacyManifests" : {
"NSPrivacyAccessedAPITypes" : [
{
"NSPrivacyAccessedAPIType" : "NSPrivacyAccessedAPICategoryUserDefaults" ,
"NSPrivacyAccessedAPITypeReasons" : [ "CA92.1" ]
},
{
"NSPrivacyAccessedAPIType" : "NSPrivacyAccessedAPICategoryFileTimestamp" ,
"NSPrivacyAccessedAPITypeReasons" : [ "C617.1" ]
},
{
"NSPrivacyAccessedAPIType" : "NSPrivacyAccessedAPICategoryDiskSpace" ,
"NSPrivacyAccessedAPITypeReasons" : [ "E174.1" ]
},
{
"NSPrivacyAccessedAPIType" : "NSPrivacyAccessedAPICategorySystemBootTime" ,
"NSPrivacyAccessedAPITypeReasons" : [ "35F9.1" ]
}
],
"NSPrivacyTracking" : false ,
"NSPrivacyTrackingDomains" : [],
"NSPrivacyCollectedDataTypes" : []
}
}
}
}
NSPrivacyTracking を false で出している点に注意してください。ATT で追跡しないアプリ(壁紙系の多くがこの設計)はこれで十分ですが、AdMob のターゲティング広告を使う場合は true に変えて NSPrivacyTrackingDomains を追加します。私の癒し系アプリでは ATT を出していないので false、Web 連携ありのアプリでは true という分け方をしています。
ステップ 6:EAS Build のローカル検証で「最後の一押し」を確かめる
PrivacyInfo.xcprivacy の整理ができたら、EAS Build を回す前にローカルでビルドして .ipa の中身を確認します。クラウドビルドで失敗するとフィードバックループが長いため、ここでミスを刈り取っておきます。
# ローカルビルド(Mac 必須)
npx expo prebuild --platform ios --clean
cd ios && pod install && cd ..
# Xcode でアーカイブし、ipa を取り出す
xcodebuild archive \
-workspace ios/MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath /tmp/MyApp.xcarchive \
-destination "generic/platform=iOS"
xcodebuild -exportArchive \
-archivePath /tmp/MyApp.xcarchive \
-exportPath /tmp/MyApp \
-exportOptionsPlist ios/export-options.plist
# ipa 内の PrivacyInfo.xcprivacy を全部列挙
cd /tmp/MyApp
unzip -o MyApp.ipa -d MyApp-unpacked > /dev/null
find MyApp-unpacked -name "PrivacyInfo.xcprivacy"
ここで列挙されるファイル数が、ステップ 2 の has-manifest 行数 + アプリ本体 1 個 と一致していれば概ね正解です。ズレている場合は、Pod が古い Privacy Manifest を上書きしているか、Expo の Config Plugin が後段で削っている可能性があります。
最後に xcrun altool --validate-app で App Store Connect 側の自動チェックを通します。
xcrun altool --validate-app \
--type ios \
--file /tmp/MyApp/MyApp.ipa \
--apiKey < YOUR_API_KE Y > \
--apiIssuer < YOUR_ISSUER_I D >
No errors が返れば提出してほぼ確実に通ります。私の運用では、この validate-app を通すかどうかでリリース当日の心理的負荷がまったく違うので、必ず通すことにしています。
Privacy Manifest と Privacy Nutrition Label のズレを潰す
ここで一度、混同しがちな 2 つの仕組みを整理しておきます。Privacy Manifest(PrivacyInfo.xcprivacy)は「コードレベルで何を触っているか」を Apple に伝える機械可読の宣言で、Privacy Nutrition Label(App Store Connect 上の App Privacy)は「ユーザーにどう開示しているか」を人間に見せる表示です。両者は内容が一致しているのが前提で、ズレていると審査で個別問い合わせが来ます。
私が壁紙系アプリで実際にあったケースを 1 つ書きます。Manifest 側では NSPrivacyTracking: false で出していたのに、App Store Connect の App Privacy 側で「Identifiers — Device ID」が「Used for Tracking」にチェックされたまま残っていたことがあり、レビュー担当者から「Manifest と Label の整合性を確認してください」と連絡が来ました。回答自体は 5 分でできたものの、その 1 回のラリーで 24 時間消えました。
提出前のチェックリストとしては、最低この 3 行を回せれば十分です。
App Store Connect の「App Privacy」を開き、収集データ種別ごとに「Used for Tracking」「Linked to User」「Linked to Identity」の値が app.json の NSPrivacyTracking および NSPrivacyCollectedDataTypes と矛盾しないか
AdMob を入れているなら、IDFA を取得する設計か(取得するなら NSPrivacyTracking: true、取得しないなら false)
RevenueCat を入れているなら、ユーザー ID をどう紐づけているか(匿名なら Linked to User: false、メアド紐づけなら true)
私の運用では、app.json と App Privacy の対応関係を _documents/privacy-mapping.md に 1 枚の表で書き残しています。1 度書けば次のリリースでも 5 分で確認できる資産になります。
Required Reason API の理由コードは「業務動機」で選ぶ
PrivacyInfo.xcprivacy に書く理由コード(CA92.1 など)は、API のリストごとに Apple が許容している番号が決まっています。SDK 提供元が同梱しているマニフェストをそのままコピーすれば動きますが、自分のアプリ本体が「設定保存」目的で UserDefaults を呼ぶ場合は CA92.1、ユーザー解析目的で呼ぶ場合は C56D.1 のように、業務動機側の番号を入れる必要があります。
頻出する API ごとの、個人開発で使う頻度の高い理由コードだけまとめます。Apple Developer の公式ページ(https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api)を引用しているので、最新版は必ず公式で確認してください。
NSPrivacyAccessedAPICategoryUserDefaults(UserDefaults)— アプリ内設定の保存は CA92.1、共通設定の参照は 1C8F.1、CloudKit 連携は AC6B.1
NSPrivacyAccessedAPICategoryFileTimestamp(ファイル日時)— 自アプリ内のキャッシュ・同期管理は C617.1、ユーザーが選んだファイルの作成日表示は 3B52.1
NSPrivacyAccessedAPICategorySystemBootTime(システム起動時間)— 起動からの経過秒で UI 更新を判定するなら 35F9.1、計測目的なら 8FFB.1
NSPrivacyAccessedAPICategoryDiskSpace(ディスク容量)— アプリ内のファイル保存可否判断は E174.1、ダウンロード前の容量確認は 85F4.1
私が壁紙アプリで 35F9.1 を入れ忘れて、AdMob 由来のリジェクトを 1 回食らったことがあります。AdMob 側の Manifest には 35F9.1 が同梱されていても、自アプリ本体側にも別の理由で SystemBootTime を読む箇所があり、そちらの宣言が漏れていたためでした。「アプリ本体は SDK と独立して理由を宣言する」という Apple のルールに沿って、_documents/required-reason-mapping.md のような対応表を持っておくと、新しい SDK を入れたときの判断ミスが減ります。
個人開発者として、この監査をどこまで「自動化」するか
ここまでの手順は半日で回せるとはいえ、毎リリースで手動でやるのは現実的ではありません。私は壁紙アプリと癒し系アプリの両方で、GitHub Actions の release-prep ワークフローに以下を組み込んでいます。
リリースタグを切る前に audit-privacy-manifest.sh を自動実行
NO-MANIFEST 行が 1 つでもあれば、Slack に通知して人間判断を待つ
すべて has-manifest ならそのまま EAS Build に進む
NO-MANIFEST が出たら、その時点で 30 分〜1 時間を確保してステップ 3 のフローを走らせます。これだけで、過去 1 年で 5 本のアプリをリリースしましたが、ITMS-91053 によるリジェクトは一度も食らっていません。
1997 年に独学でプログラミングを始めたころから、ツールの自動化に頼る感覚は変わっていません。ただ Apple のポリシーは年々細かくなっており、人間が記憶で対応するのは限界に達しています。Rork のように AI がコード生成を担う時代だからこそ、自分の手元では「機械が落とせる種類の不具合は機械に落とさせる」という設計を持っておくのが、長く個人開発を続けるための現実解だと感じています。
次のリリースで audit-privacy-manifest.sh を 1 本だけ書いてみてください。リジェクト 1 回分の時間で書けますし、その後のリリースが嘘のように静かになります。