Oyakodon for Mastodon を App Store で見る
この記事は、放置していた自作 iOS アプリを Claude Code に近代化してもらった記録です。私はコードを1行も書いていないどころか、Mac にも Claude Code が動く PC にも一切触れていません。指示はすべてスマホから出しただけで、コード修正・調査・ビルド・TestFlight 配信・App Store 申請まで Claude Code が Mac にヘッドレス(SSH 経由)で実行しました。2017年に公開して以来ほぼ放置していた約9年もの(うち7年半は完全放置)の Mastodon ビューアの「何が壊れていて、何をどう直したか」を技術的にまとめます。
この記事でわかること
- 放置した古い iOS アプリを、自分でコードを書かずに Claude Code へ近代化してもらう進め方
- その作業分担(私が頼んだこと / Claude Code が私に求めたこと)
- 結果として実現した技術的な更新(deprecated API 置き換え・OAuth 移行・Realm 実機クラッシュ修正など)
作業分担:私が頼んだこと、Claude Code が私に求めたこと
最初に私が Claude Code に頼んだのは、ひとことで言えば「7年半放置した Oyakodon を、作り直さずに直して、もう一度 App Store に出せる状態にしてほしい」でした。具体的な実装方針は対話しながら詰めましたが、エディタを開いてコードを書いたのは私ではなく Claude Code です。
Claude Code 側がやったこと(Mac にヘッドレスで):
- Swift コードの修正そのもの(後述の Before / After はすべて Claude Code の変更)
- クラッシュログ(
.ips)をatosでシンボル解決して原因箇所を特定 curlで Mastodon API のレスポンスを叩いて挙動を切り分け- SSH 経由で Mac 上の
xcodebuild archive→altoolを回し、TestFlight へアップロード - 提出ビルドのバージョン番号(CFBundleVersion)の更新と、App Store Connect への審査提出(API 経由)。App Store の初期設定(アプリ枠・契約・基本情報)は旧アプリ時代に済んでいたので、私が App Store Connect に新規ログインする必要はなく、申請まで Claude Code が完結させた
私がやったこと(人間にしかできない部分だけ):
- 「作り直さず近代化する」という方針を決め、スマホから指示を出すこと
- TestFlight に上がったビルドを自分の iPhone で触り、挙動の異常をスクリーンショットで報告すること。後述の「起動直後に落ちる」「画面下端がズレる」はこの実機確認で見つかった。とくに WebView の位置ズレのように言葉で正確に説明しづらい不具合も、私はスクショを1枚送るだけで済み、それを読んで原因を切り分けたのは Claude Code だった
つまり私は Mac にも、Claude Code が動いている PC にも一度も触れていません。コードも見ていないし、App Store Connect にもログインしていません。私が物理的に関わったのは、手元の iPhone に届いた TestFlight ビルドを触って異常を伝えることだけ。Claude Code の役割は「調査・コード修正・ビルド・配信・申請」、私の役割は「方針決め」と「実機で触って異常を伝える目と手」という分担でした。
背景:何が「腐って」いたのか
題材は Oyakodon という個人開発の iOS アプリです。複数の Mastodon インスタンスを並べて管理できるビューアで、初リリースは2017年4月。つまりアプリ自体は約9年もので、2018年10月の更新を最後に約7年半は完全に放置していました。今回近代化したビルドは App Store(Oyakodon for Mastodon)で配信中です。久しぶりに開いたらビルドすら通らない状態で、原因は1つではありませんでした。
- iOS SDK 側:
UILocalNotification、setMinimumBackgroundFetchInterval、UIImageJPEGRepresentationといった deprecated(非推奨)API が積み重なり、value(forKey: "statusBar")のような private API はそもそもコンパイルを通さなくなっていた - Mastodon 側:Web UI の URL ルート変化(
/web/timelines/home→/homeなど)、API のバージョンアップ(v1 → v2)、ID の型変化 - 依存ライブラリ:Realm(モバイル向けデータベース)のスキーマ変化、Firebase API 改訂、レビュー誘導ライブラリ Appirater のメンテ終了
- 外部サービスの消滅:インスタンス一覧を返していた API(instances.mastodon.xyz)の廃止
- 認証フロー:password grant(ユーザー名とパスワードを直接送る方式)が廃止され、Authorization Code flow への移行が事実上強制された
つまり「SDK・サーバー・ライブラリ・外部サービス・認証」の5方向から同時に腐食していたわけです。1個直すと次が崩れる、という典型的な放置プロジェクトの状態でした。
方針:作り直さず in-place で近代化する
最初に決めたのは「全面書き直しをしない」ことでした。Oyakodon の差別化機能は、N 個のインスタンスへ同時投稿するマルチポストと、インスタンスをまたいだ boost(ブースト=リポスト)です。この核心部分は既存の Swift コードでちゃんと動いていました。動いているものを捨ててゼロから書き直すと、その「動く」を再現するコストのほうが高くつきます。
そこで、アプリの骨格はそのままに、表面の腐食(deprecated API・廃止された認証・古い型)だけを順番に剥がしていく in-place 近代化を選びました。作業は Phase 0〜8 に分割し、各 Phase は「ビルドが green(成功)になる」ことを区切りにしています。
Phase 別の対応一覧(すべて Claude Code が実施)
| Phase | 内容 | 規模感 |
|---|---|---|
| 0 | iOS 17 ビルド green / CocoaPods → SPM 移行 | Pods/ 配下 917 ファイル削除、pbxproj を自動変換 |
| 1 | WebView タイムライン正常化・ログイン保持・設定の外部ファイル化 | URL ルート表 / CSS / JS を外部ファイルに分離 |
| 2 | 廃止された instance picker 撤去・ドメイン直接入力に変更 | instances.mastodon.xyz 廃止への対応 |
| 3 | Status / Notification / Attachment の ID を Int → String 化 | 比較ヘルパー実装・ユニットテスト16件 |
| 4 | OAuth を Authorization Code フローへ | OAuth 用 WebView 画面を新設・URL スキーム登録 |
| 5 | /api/v2/media + /api/v2/search 対応・旧 Twitter 連携の死コード削除 | メディア処理完了待ちのポーリング実装 |
| 6 | BGTaskScheduler + UserNotifications へ | 古い background fetch 廃止対応、iOS 10 ガード全削除 |
| 7 | Share Extension の型システム移行 | MobileCoreServices → UniformTypeIdentifiers、139 行削除 |
| 8 | StoreKit 2 への IAP(アプリ内課金)移行 | 250 行 → 79 行に縮小 |
規模としては最終的に 949 ファイル変更・2,717 行追加・225,731 行削除。削除行が極端に多いのは、Phase 0 で CocoaPods(依存管理ツール)をやめて、コミット済みだった大量のライブラリバイナリを SPM(Swift Package Manager、Apple 純正の依存管理)に置き換えたためです。
技術的に何が変わったか:Before / After
ここからは Claude Code が入れた変更のうち、特徴的なものを Before / After で見ていきます。
1. WebView のログインを、コードが毎起動で壊していた
放置前のコードは、起動のたびにキャッシュをクリアする処理で、ログイン状態を保持する領域(localStorage と IndexedDB)まで巻き込んで消していました。「キャッシュをきれいにする善意」のつもりが、結果として毎起動でログアウトさせていたわけです。
// BEFORE: 毎起動でキャッシュ+ログインデータを全消去
func removeCache() {
URLCache.shared.removeAllCachedResponses()
WKWebsiteDataStore.default().removeData(
ofTypes: [
WKWebsiteDataTypeDiskCache,
WKWebsiteDataTypeOfflineWebApplicationCache,
WKWebsiteDataTypeSessionStorage,
WKWebsiteDataTypeLocalStorage, // ← ログイン状態を保持する領域
WKWebsiteDataTypeIndexedDBDatabases // ← 同上
], modifiedSince: Date(timeIntervalSince1970: 0), completionHandler: {})
// Library/Caches 以下も全削除...
}
// AFTER: URLCache だけクリア。WKWebsiteDataStore には触らない
func removeCache() {
URLCache.shared.removeAllCachedResponses()
}
修正は「触らない」だけ。WKWebView(iOS の Web 表示部品)のセッションは WKWebsiteDataStore が握っているので、ここを消さなければログインは保持されます。
2. Mastodon の ID は Int ではなく String で扱う
古いコードは Status(投稿)の ID を Int として保持し、NSString 経由で数値化していました。Mastodon の ID は時間とともに桁が増え続けるため、Int で扱うとオーバーフローの危険があります。素直に文字列のまま扱うのが正解でした。
// BEFORE: NSString 経由でパース(巨大 ID はオーバーフローの恐れ)
public class Status { public var id: Int! }
if let statusId = (item["id"] as? NSString)?.integerValue {
status.id = statusId
}
let body = ["since_id": "\(sinceId)"] // 毎回文字列に変換
// AFTER: JSON から String として直接取り出す
public class Status { public var id: String! }
if let statusId = item["id"] as? String {
status.id = statusId
}
if !sinceId.isEmpty { body["since_id"] = sinceId } // 空なら送らない
ID を String 化すると大小比較が文字列比較になってしまうので、「桁数が多いほうが新しい、桁が同じなら辞書順」という比較ヘルパーを実装し、ユニットテストを16件付けて担保しています。
3. OAuth:password grant から Authorization Code flow へ
Mastodon 側で password grant(ユーザー名とパスワードを直接トークンと交換する方式)が廃止されたため、ブラウザ経由で認可コードを受け取る Authorization Code flow へ移行が必要でした。
// BEFORE: password grant(廃止済み)
static func fetchAccessToken(addr: String, clientId: String, clientSecret: String,
username: String, password: String, ...) {
let body = [
"grant_type": "password", // ← 廃止されたフロー
"username": username,
"password": password,
...
]
}
// AFTER: Authorization Code flow(WebView で得たコードのみ送る)
static func fetchAccessToken(addr: String, clientId: String, clientSecret: String,
code: String, ...) {
let body = [
"grant_type": "authorization_code",
"redirect_uri": "oyakodon://oauth",
"code": code,
...
]
}
設計の工夫:Apple 推奨の ASWebAuthenticationSession(OS が用意する認証専用ブラウザ)ではなく、アプリ既存の WKWebView の中で OAuth を完結させています。理由は UX です。ASWebAuthenticationSession はタイムライン表示に使っている WKWebView とは独立したブラウザセッションを使うため、「タイムライン表示用に1回 + OAuth 用にもう1回」と二重ログインを強いてしまいます。既存 WebView の navigation delegate(ページ遷移を監視するフック)で oyakodon://oauth?code=... への遷移を捕まえれば、同一セッションのまま1回のログインで済みます。
4. StoreKit 1 から StoreKit 2 へ(250行 → 79行)
IAP(In-App Purchase=アプリ内課金)は、Observer パターン+デリゲートで状態を手管理する StoreKit 1 の実装でした。StoreKit 2 の async/await ベースに置き換えると、状態フラグやエラーカウントの管理が丸ごと消えて、250行が79行になりました。
// BEFORE: Observer パターン、シングルトン、デリゲート(約250行)
class PurchaseManager: NSObject, SKPaymentTransactionObserver {
var delegate: PurchaseManagerDelegate?
func startWithProduct(_ product: SKProduct) {
if SKPaymentQueue.canMakePayments() == false { ... }
// フラグ管理、エラーカウント...
SKPaymentQueue.default().add(payment) // 結果は Observer へ委ねる
}
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions: ...) {
// switch で .purchased / .failed / .restored を振り分け
}
}
// AFTER: async/await、@MainActor(約79行)
@MainActor final class IAPManager {
static let shared = IAPManager()
func purchase(productID: String) async throws -> Bool {
let products = try await Product.products(for: [productID])
guard let product = products.first else { return false }
let result = try await product.purchase()
switch result {
case .success(let v): return await handleVerification(v)
case .userCancelled, .pending: return false
@unknown default: return false
}
}
func restore() async throws {
try await AppStore.sync()
}
}
他にも、バックグラウンド通知を古い performFetchWithCompletionHandler から BGTaskScheduler(iOS 13以降のバックグラウンドタスク API)へ、Share Extension を MobileCoreServices の C 定数から UniformTypeIdentifiers の UTType へ移行しています。どれも「古い API を新しい API に1対1で置き換える」作業で、最低対応 OS を iOS 17 に引き上げたことで if #available(iOS 10.0, *) のような分岐を全削除できました。
TestFlight で実機に入れて初めて出たバグ3つ
ここが今回いちばん伝えたい部分です。ビルドが通り、シミュレータで完璧に動いても、TestFlight 配信ビルドを実機(iPhone)に入れた瞬間に落ちる・ズレるバグが3つ出ました。私が実機で触って異常を報告し、Claude Code が .ips クラッシュログを atos でシンボル解決して原因を特定する、という流れで潰しています。シミュレータだけ見ていたらリリース後にユーザーの端末で初めて発覚していたものばかりです。
(1) Realm が実機起動で即クラッシュ(埋め込み漏れ)
SPM で追加した RealmSwift は type: .dynamic、つまり常に動的フレームワーク(実行時に読み込まれる形式)です。ところが Phase 0 で pbxproj(Xcode のプロジェクト設定ファイル)を自動変換した際、フレームワークの「リンク」だけ設定して「埋め込み(Embed Frameworks フェーズ)」を追加していませんでした。
結果、シミュレータでは別経路で埋め込まれるため動くのに、実機では起動直後に dyld: Library not loaded: @rpath/RealmSwift.framework でクラッシュします。
判別の小ワザ:正常な IPA(iOS アプリのパッケージ)は RealmSwift.framework を内包して約12MB になります。これが2.5MB しかなければ埋め込み漏れです。アップロード前に IPA のサイズを見るだけで一発で分かります。
(2) Realm の Configuration を無視する罠
2つ目も Realm です。Realm.Configuration.defaultConfiguration に schemaVersion と migrationBlock(スキーマ移行処理)を設定していたのに、起動時にクラッシュしました。
原因は、DB を開くときに Realm(fileURL:) を使っていたことです。この初期化方法は defaultConfiguration を無視します。そのため既存 DB のスキーマバージョン(2)と、設定が反映されない新しい既定値(0)がミスマッチを起こしていました。
// NG: defaultConfiguration の schemaVersion / migrationBlock が無視される
let realm = try Realm(fileURL: dbURL)
// OK: Configuration を明示的に渡す
var config = Realm.Configuration.defaultConfiguration
config.fileURL = dbURL
let realm = try Realm(configuration: config)
(3) WebView 下端のズレ(3回直してやっと根治)
3つ目は、画面下端で WebView がツールバーに少しめり込む/隙間が空くズレです。これは2.7.2 build 2・2.7.3 build 1・2.7.3 build 2 と、計3回直しています(毎回 TestFlight に上げて実機で確認しました)。1〜2回目は対症療法で、根本原因は「3者がジオメトリ(座標・サイズ)を奪い合っていたこと」でした。
この「何 pt めり込んでいるか」を言葉で正確に伝えるのは難しいのですが、私がやったのは 実機の画面をスクショして送るだけ。ズレの量も方向も、Claude Code が画像から読み取って原因を絞り込んでいきました。テキストだけのやり取りなら何往復もかかったはずの問題が、画像1枚で前に進んだ場面です。実際に私が送った before / after の実機スクショが以下です。
Before: 下端がツールバーにめり込んでいる
After: 4辺をピン留めしてズレが解消
- Storyboard の Auto Layout:
translatesAutoresizingMaskIntoConstraints = falseのビューは、レイアウトのたびに制約値へ戻る - 手動フレーム計算:メニューバー表示/非表示の処理が、deprecated な
statusBarFrameを使い、ボトムセーフエリア(ホームバー領域)34pt を無視していた - リサイズ処理:コンテナ高さをその瞬間にスナップショットしていたため、ツールバーの開閉アニメーション完了後の変化に追従できていなかった
この3者がフレームの値を毎回上書きし合うので、どこを直しても別の処理が巻き戻していたわけです。根治は「手動フレーム計算とリサイズ処理を捨て、WebView の4辺を Auto Layout でコンテナにピン留めする」こと。1回ピン留めすれば、以後はレイアウトが自動追従します。
// AFTER: 4辺を Auto Layout でピン留め → 以後は自動追従
let webView = self.webViewController.webView
webView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
webView.topAnchor.constraint(equalTo: webContainerView.topAnchor),
webView.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor),
webView.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
])
// 手動の resizeWebView() は削除
設計判断のメモ
- 作り直さない:差別化機能(マルチポスト・クロスインスタンス boost)が既存コードで動いていたので、それを維持して表面だけ直すほうが確実だった。
- CocoaPods → SPM:直接のきっかけはビルド環境の制約だったが、コミット済みだった50MB超のバイナリを一掃できる副次効果が大きかった。
- 設定ファイルの外部化:Mastodon の Web UI ルートは今後も変わり続ける。URL パス表・CSS・JS を JSON やファイルに切り出しておくと、次に変わってもアプリ本体のビルドなしで追従しやすい。将来 Android 版を書くときの仕様共有も狙っている。
- App Store 審査リスク:WebView でサイトを表示するだけのアプリは、ガイドライン 4.2(minimum functionality=最低限の機能しかない)で reject されることがある。マルチポスト・OAuth・通知・課金といったネイティブ層が、その防御ラインになっている。
結果として 2.7.2 はリリース済み、2.7.3 を App Store 審査に提出できる状態まで持っていけました。実作業期間は約1週間です。
よくある質問
Q. コードは自分で書いたんですか?
A. いいえ、1行も書いていませんし、コードを見てすらいません。私は「作り直さず近代化する」という方針を決めてスマホから指示しただけで、コード修正・調査・ビルド・TestFlight 配信・App Store 申請まで Claude Code が Mac にヘッドレス(SSH 経由)で実行しました。Mac にも Claude Code が動く PC にも触れていません。私が物理的にやったのは、手元の iPhone に届いた TestFlight ビルドを触って異常を報告することだけ。App Store Connect への申請も Claude Code が API 経由で行っています(App Store の初期設定は旧アプリ時代に完了済みだったため、新規ログインは不要でした)。
Q. シミュレータで動くのに実機で落ちるのはなぜ?
A. 動的フレームワークの埋め込み(Embed Frameworks)有無、コード署名、実機固有のセーフエリアなど、シミュレータが肩代わりしてくれる部分が実機では露出するためです。今回の Realm クラッシュはまさにこれで、リリース前に必ず TestFlight などで実機に配信して起動確認すべきです。
Q. 古いアプリは作り直したほうが速くないですか?
A. 差別化機能がまだ動いているなら、作り直しはおすすめしません。「動く」を再現するコストが高いからです。逆に、コア機能自体が時代遅れ・破綻している場合は書き直しのほうが速いこともあります。判断材料は「壊れているのは中身か、表面か」です。
Q. WebView 内で OAuth を完結させるのは安全ですか?
A. セキュリティ的には ASWebAuthenticationSession のほうが望ましいです。今回は二重ログインを避ける UX 優先で WKWebView 内に閉じましたが、自分が管理する認可サーバーでないアプリでは Apple 推奨方式を検討してください。
※本記事のコードは執筆時点(2026年6月)の Xcode / iOS SDK / 各ライブラリで実際に動かしたものを簡略化して載せています。バージョンが変わるとそのままでは動かない場合があります。うまくいかないときは コメント欄で教えてください。なお Bundle ID など環境固有の値はプレースホルダーに置き換えています。
まとめ
約9年もの(うち7年半は放置)の iOS アプリでも、壊れているのが「表面」なら作り直さずに直せます。今回は私自身はコードを書かず・見ず、Mac にも触れず、方針出しと自分の iPhone での実機確認に徹し、コード修正からビルド・TestFlight 配信・App Store 申請までを Claude Code に任せました。山場は、ビルドが通っても実機でだけ落ちる Realm まわりと WebView のジオメトリ。シミュレータを信じすぎず、TestFlight で実機に配信して確かめるのが近道でした。
この記事が役に立ったら X(Twitter)でシェアしてもらえると喜びます。
関連記事
- スマホのリモート操作で iOS アプリをビルド&TestFlight 配信する【SSH + build keychain・2026年版】 — 今回のヘッドレスなビルド/配信の仕組み
- 7 年放置の iOS アプリを Claude Code で復活させて App Store に出した 6 日間【Xcode 26 / Swift 6 / v3.0.0】
- Text to Speech が OS の音声をミュートする問題を直した話【iOS audio session 衝突・2026年版】
参考
- Mastodon documentation — OAuth methods
- Apple Developer — BGTaskScheduler
- Apple Developer — StoreKit 2
- Apple Developer — UniformTypeIdentifiers
※この記事は Claude Code を使った執筆フローを試しています。
0 件のコメント:
コメントを投稿