目次
前提: iOS では「コピーした瞬間に自動保存」は誰にも作れない
クリップボード履歴アプリを iOS で作ろうとして、最初にぶつかるのがこれだ。クリップボードの変更を検知する API はなく、常駐のバックグラウンド実行もなく、ショートカットにもそのトリガーはない。機微情報を守るために Apple が意図的に閉じている。Mac の Clipy のような「コピーした瞬間に積まれる」体験は、iOS では原理的に不可能だ。
ほかにも前提がある。アプリがクリップボードを読むと iOS 16 以降は「ペースト許可」のバナーが出る(設定で「許可」にすれば消える)。UIPasteboard.detectPatterns と hasStrings は中身を読まずに型だけ判定できるので、バナーなしに「URL をコピーしたようです」と言える。そして Dynamic Island、キーボード、共有拡張はすべて Swift の App Extension で、React Native では書けない。
競合も全員この制約の下にいる。勝負は「捕捉口の摩擦をどれだけ下げるか」に集約される。
捕捉口
| 捕捉口 | 仕組み |
|---|---|
| 開くだけで取り込み | フォアグラウンド復帰時に読み取る。直前と同じ内容はスキップ |
| 選択 → 共有 → 「保存コピー」 | UI のない Action Extension。選択テキストを履歴に保存しつつ UIPasteboard.general にも書く。書き込みは許可なしでできるので、1 アクションで保存とコピーが同時に済む |
| 背面タップ | App Intent「クリップを保存」を、設定からアクセシビリティの背面タップに割り当ててもらう |
| キーボード | Keyboard Extension から履歴と定型文を挿入。本体がスナップショットを書き、キーボードは読むだけなので「フルアクセス」が要らない |
| Dynamic Island | Live Activity。本文は出さず、件数と種類タグだけの捕捉メーターにした |

Dynamic Island にクリップの中身を出さないのは意図的だ。ロック画面にコピーした文字列が見えるのは、プライバシーを売りにするアプリとして筋が通らない。
本体は Expo、拡張は Swift
履歴一覧、定型文、設定、課金といった本体は Expo と expo-router で書き、@bacons/apple-targets で widget、action、keyboard の 3 ターゲットを Swift として足した。JS 層は EAS Update で OTA 配信し、Swift を触ったときだけネイティブビルドと審査に出す。
本体と拡張の間のデータ共有は App Groups だが、App Group コンテナ上の SQLite を拡張から直接触ると、拡張がサスペンドされたときにロックを握ったまま 0xdead10cc で落ちる既知の問題がある。そこで DB 本体はアプリ側のコンテナに置き、拡張とのやり取りは App Group 上の「受信トレイ」(JSON / plist)と UserDefaults だけにした。保存コピーは受信トレイに追記し、本体が起動・フォアグラウンド復帰・pull-to-refresh のタイミングで取り込む。
Mac コンパニオン
macOS では常駐監視も自動書き込みも合法だ。Mac 版は Swift / AppKit のネイティブアプリとして、メニューバーに常駐してコピーを全自動で履歴にし、⇧⌘V のクイックペーストで履歴と定型文を番号一発で貼る。iPhone で「保存コピー」したものは iCloud 経由で Mac に届き、席に着いたら ⌘V で貼れる。
クイックペーストは CGEvent で ⌘V を送るので App Sandbox を無効にする必要があり、Mac App Store には出せない。Developer ID で公証して直接配布にした。Mac 側には課金を持たせず、iOS 側が Pro の状態を CloudKit のプライベート DB に 1 レコード書き、Mac はそれを読んで同期と ⌘V 自動反映をゲートする。アカウント登録なしのまま、1 つの Pro で全端末が成立する。

同期で踏んだこと
同期は CloudKit で、サーバーは持たない。設計上の穴を 2 つ踏んだ。
消したものが戻ってくる。 最初の同期は「手元と iCloud の和集合を取り、手元にだけあるものを新規として送る」だった。これだと iPhone で消したクリップは iCloud からは消えるが、Mac が手元に残っている分を新規として送り直すので、iPhone に戻ってくる。どの端末も「相手が消した」と「自分が足した」を区別できていなかった。対処は、前回の同期の終わりに iCloud にあった一覧(id と savedAt だけ)を base として端末に覚えておくこと。「base にあって、いまの iCloud に無く、手元で変えていない」ものは他端末で消されたと読み、id で取り直して本当に無ければ手元から外す。手元の削除は従来どおりトゥームストーンで伝える。
400 件の壁。 CloudKit の modify は 1 回 400 件までで、超えると呼び出しごと断られる。全消去や履歴の多い端末の初回同期が毎回まるごと失敗していた。保存は 100 件、削除は 400 件ずつに分け、それでも断られたら半分にして送り直す。
ほかにも、一覧クエリは直後の変更を反映しないことがあるので消す前に id で取り直す、アカウントを切り替えたら base を使わない、など守りを足した。これらは CloudKit に触らない Swift スクリプトで 30 項目を確かめてから出した。
課金とプライバシー
線引きは「捕捉は全部無料、蓄積と越境に課金」。全捕捉口、履歴 50 件、定型文 10 件は無料で、履歴無制限、全文検索、カスタム変数、iCloud 同期と Mac 連携が Pro。サブスクに加えて買い切りも併設した。広告は入れない。クリップボードは機微情報の塊で、そこに広告 SDK を入れるのは信頼を自分で壊すことになる。中身は端末と自分の iCloud だけに置き、外部サーバーには送らない。
iPhone と Mac で使える。stacky.tools
RELATED
Flutter / AWS / 自宅サーバー。個人開発と OSS をいくつか。設計相談や協業の話は X の DM か About から。 About