本文へスキップ
nomunomu0504.dev

BLOG #IOS

Stacky: iOS でクリップボード履歴アプリを作るとき、OS の制約から逆算した話

Hiroki Nomura 7 MIN READ ios macos swift react-native indie-dev
目次

前提: 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。本文は出さず、件数と種類タグだけの捕捉メーターにした
Stacky のアプリ画面 3 枚
左から、履歴 / 背面タップで保存 / iPhone と Mac の同期

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 のタイミングで取り込む。

Stacky: how clips move between extensions, the app and the MacIOS APP EXTENSIONS · SWIFTAction ExtensionShare → Save copyApp IntentBack TapKeyboard Extensionreads a snapshotLive Activitycount + type onlyApp Group inboxJSON / plistappended by extensionswidgetsnapshotimport on openStacky app · Expo + expo-routerSQLite in the app containerOTA for JS · native builds for SwiftiCloud · CloudKit private DBbase + tombstones · batches of 100 / 400Mac companion · Swift / AppKitmenu bar · auto-capture · ⇧⌘V quick paste
拡張は App Group の受信トレイに書くだけ。DB は本体側に置き、iCloud 経由で Mac と同期する

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 で全端末が成立する。

stacky.tools のトップページ
stacky.tools。iPhone の履歴と、Mac の ⇧⌘V クイックペースト

同期で踏んだこと

同期は 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

  1. Cherimo: 大切な人の「小さなこと」を覚えておくアプリを、利用データから作り直した話 6 min
  2. LaterHub: 「あとで読む」をチームのナレッジにするために作ったもの 5 min
  3. みんなの生ビールマップ: 「銘柄から店を探す」だけに絞った地図アプリの作り方 4 min
Hiroki Nomura @nomunomu0504 · CTO, MIRAI STUDIO

Flutter / AWS / 自宅サーバー。個人開発と OSS をいくつか。設計相談や協業の話は X の DM か About から。 About

COMMENTS · GISCUS