← Sail Manifest

同じ Mac アプリを App Store と直接配布の両方に出すと踏むもの

Mac アプリを Mac App Store と自社サイトからのダウンロードの両方で配っています。 理由は珍しくもなくて、手数料と、審査待ちです。軽微な修正を出すのに審査を数日待つ のは、直せると分かっている不具合を抱えたまま待つということなので、直接配布の経路を 併せて持っておきたい。

ただ、同じソースコードから出しているのに、出来上がるものは別物です。ここを 「同じアプリの配り方が 2 通りあるだけ」と思っていると、静かに事故ります。

この記事は、実際に踏んだものだけを書きます。

1. 署名している証明書が違う

まずここです。同じ .app に見えて、署名している証明書が違います。

codesign -dvv MyApp.app 2>&1 | grep '^Authority='
出てくるものどっち
Authority=Developer ID Application: ...直接配布
Authority=Apple Distribution: ...App Store
Authority=Apple Development: ...開発ビルド

そして App Store に上げたビルドは、Apple 側で署名し直されます。手元にある提出用 アーカイブは「Apple に渡す前の状態」であって、ユーザーに届くものとは別です。

2. 公証が要るのは直接配布だけ

直接配布には公証(notarization)が要ります。App Store 版には要りません。Apple が 処理の過程でやるからです。

なので App Store 版のアーカイブに対して

xcrun stapler validate MyApp.app        # → 失敗する
spctl --assess --type exec MyApp.app    # → rejected

を叩くと両方とも落ちます。これは異常ではなく、そうなるのが正しい

ここで私は間違えました

出荷物を検査するスクリプトを書いて、直接配布の DMG に対しては期待どおり動いたので 満足していました。同じスクリプトを App Store 版のアーカイブに向けたら、

FAIL  Gatekeeper assessment (app)
FAIL  hardened runtime and secure timestamp
FAIL  notarization ticket (app)

完全に正常な成果物に対して 3 件の失敗を報告しました。

誤検知を出す検査器は、検査器として無いより悪いです。出力を無視する習慣を教えて しまうので。結局、署名の発行元から配布経路を判定して、その経路で意味を持つ検査 だけを走らせるように直しました。

逆向き — App Store 用に署名したアプリが DMG に入っている — は本物の事故です。 落とした人は誰も起動できません。こちらは検知すべき側です。

3. エンタイトルメントも経路で意味が変わる — そして起動しなくなる

ここが一番高くつきました。

ローンチした翌朝、ユーザーからメールが来ました。

アプリが開けません。「アプリケーションを開けません」としか出ない。 アンインストールして、再起動して、入れ直しても同じです。 セキュリティの問題かと思って確認しましたが、違いました。 別の Mac でも試しましたが、同じでした。

2 台とも Apple Silicon。前のバージョンでは動いていたとのことでした。

私のマシンでは普通に開きます。codesign --verify --deep --strict も通る。公証も 通っている。自分が実行できる検査は全部「正常」と言っていました。

原因はエンタイトルメント 1 行でした。

<key>keychain-access-groups</key>
<array><string>TEAMID.com.example.myapp</string></array>

なぜ直接配布だけで壊れるのか

keychain-access-groupscom.apple.developer.* 系は、プロビジョニング プロファイルによる裏付けを要求します。

経路プロファイル結果
App Store埋め込まれる正常に動く
直接配布(Developer ID)埋め込まれない起動できない

アプリが「誰も承認できない権限」を要求している状態なので、macOS は起動を拒否します。 Gatekeeper のダイアログは出ません(Gatekeeper の判断ではないので)。クラッシュ レポートも出ません(起動していないので)。再インストールも効きません(成果物その ものが間違っているので)。

🔴 なぜ自分のマシンでは再現しないのか

開発機には、Xcode がチームのプロファイルを入れています。 だから検証が通り、 アプリは起動し、何も問題が無いように見えます。

分かれ目は「新規インストールか上書きか」ではありません。報告してくれたユーザーは 何週間も前の版を使っていました。ハードウェアでも OS のバージョンでもない。

自分のマシンと、それ以外です。

これは相当たちの悪い性質です。目の前のマシンでは絶対にテストできず、しかも 問題が起きる前から使ってくれている既存のテスターも、同じ理由で再現しません。

成果物を読めば分かります

私は「一度も入れたことがないマシン」を探しに行きました。それが間違いでした。 判断に必要な材料は、最初から成果物の中に全部あります。

codesign -dvv MyApp.app 2>&1 | grep '^Authority='   # Developer ID か
codesign -d --entitlements :- MyApp.app             # 要プロファイル系が在るか
ls MyApp.app/Contents/embedded.provisionprofile     # プロファイルが在るか

この 3 つが「Developer ID / 在り / 無し」なら、プロファイルを持たないマシンでは 起動しません。クリーンな Mac も、2 台目も、ユーザーからの報告も要りません。

そして、これは経路を見ないと検査できません

まったく同じエンタイトルメントが、App Store 版では正常です。 プロファイルが 付いているので。§2 で書いた誤検知と同じ構造で、「entitlement が在る」だけで 落とす検査は、正しい App Store 版を壊れていると報告します。

判定は 3 つの条件の組み合わせで書く必要があります。

4. 公証するのは「渡すもの」であって「中身」ではない

直接配布側で一番高くついた失敗がこれです。

.app を公証して staple し、それを DMG に包んで配りました。.app は完璧に公証 済みです。DMG は一度も公証していませんでした。

Gatekeeper が見るのは、ユーザーが最初に開くもの、つまり DMG です。結果、 ダウンロードした人は全員開けません。

なぜ手元で気づけないのか

Gatekeeper が評価するのは com.apple.quarantine 属性が付いたファイルだけです。 ブラウザはダウンロードにこれを付けますが、自分のビルドシステムは付けません

build/ に転がっている DMG をダブルクリックしても、何も証明したことになりません。 Gatekeeper が最初から見ていないファイルを開いているだけです。

ユーザーと同じ条件にするには、自分で付けます。

xattr -w com.apple.quarantine "0081;00000000;Safari;" MyApp-1.0.0.dmg
open MyApp-1.0.0.dmg

開かずに確かめるなら:

spctl --assess --type open --context context:primary-signature -v MyApp-1.0.0.dmg
xcrun stapler validate MyApp-1.0.0.dmg

🔴 --type execute を DMG に使ってはいけません。 正常な DMG にも rejected を 返します。壊れているものと同じ判定で、括弧の中だけが違う:

壊れている  rejected  source=Unnotarized Developer ID
正常        rejected (the code is valid but does not seem to be an app)

直した直後にこれで確かめて「直っていない」と誤解する、というのが一番踏みやすい罠です。

正しい順序

# 1. app に署名(hardened runtime + セキュアタイムスタンプ)
# 2. app を公証 → app に staple
# 3. staple 済みの app から DMG を作る
# 4. DMG に署名
# 5. DMG も公証 → DMG に staple

ひっくり返しやすいのが 2 つあります。

staple は DMG を作る前。後からやると、誰も起動しないコピーに貼ったことになります。

app は ZIP に固めて公証に出す(notary service は単一ファイルを取るため)。 DMG はそのまま出す — ZIP に包むと、中の DMG に ticket を貼れなくなります。

そしてもう 1 つ、staple はファイルのバイト列を書き換えます(実測で +2257 バイト)。 appcast の length と EdDSA 署名は DMG のバイト列から作るので、staple より後に 生成しないといけません。順序を逆にすると、1 つ目の穴を塞いだ結果 2 つ目が開きます

5. ビルド番号は「表示用の版」とは別の生き物

CFBundleShortVersionString(1.2.3)と CFBundleVersion(ビルド番号)は別物で、 機械が見ているのは後者だけです。

表示上の版だけ上げてビルド番号を据え置くと、配信は成功し、appcast も配られ、 全ユーザーに「最新です」と表示されて更新は永久に入りません。何もエラーになりません。

両方の経路に出すなら、ビルド番号は通しにするのが楽です。私は版ごとに帯を決めて います(2.1.x は 200 番台、2.2.x は 300 番台、といった具合)。どちらの経路に どのビルドが出たかを、番号だけで言えるようにしておくためです。

6. appcast は「作ったもの」から起こす

Sparkle の appcast に書く sparkle:version は、実際に置いた DMG の中の CFBundleVersion から取ります。ビルド設定から生成すると、そのときチェック アウトしていたブランチ次第で嘘をつきます

配信されるのは appcast のほうなので、ユーザーには「存在しない版」が案内されます。

7. dSYM は後から作れない

これが一番取り返しがつきません。

dSYM を残さずに出荷すると、その版のクラッシュレポートは公開期間中ずっと読めません。 後から同じソースをビルドしても UUID が一致しないので、シンボル化できません。

dwarfdump --uuid MyApp.app/Contents/MacOS/MyApp
dwarfdump --uuid MyApp.app.dSYM

この 2 つが一致していることを、出荷前に確認してください。出荷後には手がありません。

共通しているのは「何も赤くならない」こと

ここまでの全部に、同じ形があります。

ビルドは通ります。アーカイブも成功します。アップロードも通ります。CI も緑のままです。

気づくのは、ユーザーからの連絡か、読めないクラッシュレポートか、いつまでも降って こない更新によってです。そして大半のユーザーは連絡せずに立ち去ります。

検査するだけの道具を書きました

出荷しようとしている成果物を読んで、ユーザーに何が起きるかを言うだけのものです。

$ mac-release-verify MyApp-1.0.0.dmg --appcast appcast.xml \
    --dsym MyApp.xcarchive --previous-build 41

  channel  direct distribution (Developer ID)

  PASS  code signature
  FAIL  entitlements are backed by a provisioning profile
        The bundle asks for entitlements that only a provisioning profile can
        grant (keychain-access-groups) but carries no
        Contents/embedded.provisionprofile. macOS refuses to launch it on any
        machine that does not already have your team's profile installed —
        which is every machine except a developer's.
  PASS  notarization ticket (app)
  FAIL  notarization ticket (disk image)
        The app is stapled but the disk image is not. Gatekeeper checks the
        disk image when the user opens the download, so this blocks every
        download even though the app inside is fine.

署名も公証もアップロードも公開もタグ付けもしません。資格情報も要求しません。 macOS に最初から入っているコマンドだけを使ったシェルスクリプト 1 本なので、 署名済みのリリースに向ける前に全部読めます

brew install cyber937/tap/mac-release-verify

MIT ライセンスです → github.com/cyber937/mac-release-verify

入っている検査は全部、実際に出荷して事故になったものです。 心当たりのある 失敗があれば Issue に書いてください。「何が出て、ユーザーに何が起きたか」が 分かれば、検査にできます。