Skip to content

ユーザー辞書をiCloud (CloudKit) で複数のMacと同期する - #547

Merged
mtgto merged 14 commits into
mainfrom
528-userdict-cloudkit-redesign
Nov 15, 2026
Merged

mtgto merged 14 commits into
mainfrom
528-userdict-cloudkit-redesign

Conversation

@mtgto

@mtgto mtgto commented Nov 8, 2026

Copy link
Copy Markdown
Owner

Closes #528

このPRは #530 を作り直したものです。

同じApple Accountでサインインした複数のMacの間で、ユーザー辞書をiCloudで同期できるようにします。変換・単語登録・削除した結果が、他のMacにも反映されます。
同期には CloudKit の CKSyncEngine を使用します。

image

マージまでにやること

リリース作業

  • CloudKit Console で Development のスキーマを Production に Deploy する。Production では一度入れたフィールドを削除・型変更できないので、Development 側に余計なレコード型・フィールドがないか先に見る (使うのはレコード型 UserDictCandidate だけ)
  • 書き出した pkg の entitlement に com.apple.developer.icloud-container-environment = Production が入っているか codesign -d --entitlements - で確かめる。必要なら Makefile にチェックを足す
  • Production 環境 (公証済みの pkg) の2台で同期を確かめる

動作環境

  • macOS 14 以降(CKSyncEngine を使うため)
  • アプリ自体の最低動作環境は macOS 13.3 のままです。macOS 13 では設定画面の同期トグルが無効になります
  • iCloud にサインインしている必要があります

使い方

設定画面の「iCloud」タブに、ユーザー辞書の同期の項目を追加。

  1. 「ユーザー辞書をiCloudで同期する」をONにする
  2. 初回だけ、このMacのユーザー辞書にすでにある単語をどう扱うかを選ぶ
    • これ以降の変換だけ同期: 同期を開始してから変換・単語登録・削除した単語だけを送信します。すでにある単語も、もう一度変換すれば送信されます。iCloud にある他のMacの単語は取り込みます
    • すべて同期: すでにある単語もすべて iCloud に送ります。2台目以降のMacでは、先に同期を開始したMacの変換候補の順序が優先されます

同期の状態(取得中・反映中・送信中・最終同期日時・エラー)は設定画面に表示されます。取得中は受け取った件数、反映中は反映した件数、送信中は残りの件数を表示します。「今すぐ同期」ボタンで、すぐに取得・送信することもできます。

他のMacの変更を取り込むと、通知センターに「追加 N件、更新 N件、削除 N件」のように表示されます。初回同期で取り込んだときは表示しません。

image

iCloudからユーザー辞書を削除

「iCloudからユーザー辞書を削除」ボタンで、iCloud 上のユーザー辞書を丸ごと削除できます。iCloud に送ってはいけない単語を送ってしまったときなどに使います。

  • 各Macのユーザー辞書は削除されません。単語自体を消したい場合は、各Macのユーザー辞書からも削除してください
  • 同期している他のMacは、削除を検知すると同期を自動で停止します

同期を無効にしただけでは、iCloud 上のデータもユーザー辞書も削除されません。

設計

データの持ち方

  • iCloud の private database の専用ゾーン UserDict に、1つの変換候補を1つのレコード(レコード型 UserDictCandidate)として保存します
  • レコードに持たせる値は、読み・変換候補・送り仮名と、最後に変換・単語登録した日時(lastUsedAt)、最後に削除した日時(deletedAt)です
  • 値はすべて encryptedValues で保存します。iCloud の「高度なデータ保護」が有効なら、エンドツーエンドで暗号化されます
  • レコード名は (読み, 変換候補, 送り仮名) を NFC に正規化してから取った SHA256 ハッシュです
    • 2台で同じ変換候補を追加しても同じレコードになるので、自然にマージされます
    • レコード名は暗号化されないので、平文のまま残らないようにハッシュにしています
  • ローカルに同期専用のファイルは作りません。ユーザー辞書は今まで通り skk-jisyo.utf8 だけで、変換候補ごとの日時は iCloud のレコードにだけ持ちます。UserDefaults には CKSyncEngine の状態と最終同期日時などだけを保存します
    • 将来同期の機能を広げるときに、今決めたローカルのファイル形式に縛られないようにするためです。その代わりの制限は「既知の制限」を参照してください

マージと並び順

  • 変換候補ごとに、日時が新しいほうを採用します(Last Writer Wins)
  • 並び順そのものは同期しません。受け取った側で lastUsedAt の新しい順に先頭へ並べます。見出しの順も同様です
  • deletedAt が lastUsedAt より新しければ削除、古ければ残ります。Mac A で削除した後に、まだ同期していない Mac B で同じ変換候補を変換した場合は残ります
  • 削除したレコード(tombstone)は消さずに残します。長くオフラインだったMacから古い変換の記録が届いたときに、削除した変換候補が復活しないようにするためです
  • 日時は丸めずに保存します。暗号化したフィールドに保存した Date が同じ値で返ってくることを結合テストで確かめています

同期の進め方

同期は「取得 → ユーザー辞書への反映 → 送信」を1周として、常に1本だけ直列に回します。

  • 取得・反映・送信が並行して走らないので、状態の組み合わせが減り、取得と送信のあいだの隙間で起きる不具合を構造的に避けられます。データの正しさは直列化ではなく、Last Writer Wins と競合時のマージで保っています
  • そのため CKSyncEngine は automaticallySync = false で使い、取得と送信は自分で呼びます。CKSyncEngine のコールバックでは結果を集めるだけにし、処理は周の中で await の後に行います
  • 同期を始めるたび(有効化・起動)に、CKSyncEngine と未送信の変更をまとめた「セッション」を作り、止めるときはセッションごと捨てます。止めたあとに古いセッションの処理が新しいセッションの状態を書き換えることはありません

送信と取得のタイミング

  • 変換・単語登録・削除をしたとき: 0.5秒後に1周します
  • 定期: 5分ごとに1周して、他のMacの変更を取得します
    • CKSyncEngine は本来 CloudKit のサイレントプッシュ通知をきっかけに取得しますが、初期リリースではプッシュ通知を使いません。プロファイルの作り直しが必要なことと、入力メソッドのように Dock に出ないプロセスでサイレントプッシュが届くか未確認なためです
    • そのため、他のMacの変更が反映されるまで最大5分ほどかかります。通信が戻ったときの即時の再送もなく、次の周まで待ちます
  • 「今すぐ同期」: すぐに1周します。周の途中なら、その周が終わってからもう1周します
  • 取得に失敗した周は送信しません。受け取った分は反映し、次の周でやり直します
  • 取得した変更はユーザー辞書に反映してすぐ保存し、そのあとで CKSyncEngine の状態を保存します。反映前に終了しても、次回同じ変更を取得し直せます

初回同期の「すべて同期」

今あるユーザー辞書には変換した日時の情報がないため、ファイル内の順序から 1970年1月1日より前の仮の日時 を割り当てて送信します。同期を始めた後の実際の変換は必ずそれより新しいので、常に優先されます。

まず iCloud にあるレコードをすべて取得してこのMacに反映し、そのあとで iCloud にない変換候補だけを送信します。

このMac iCloud 結果
ある ある 送信しません。並び順は iCloud の日時に従います
ある ない 仮の日時で送信します。iCloud にあるどの変換候補よりも古い扱いになるので、後ろに並びます
ない ある このMacのユーザー辞書に追加します
ある 削除済み このMacからも削除します

既存の変換候補を全部送り終えたら初回同期の完了とします。通信エラーなどで送り残しがあれば次の周で送ります。完了する前に終了・停止した場合は、次に始めたときに取得からやり直します。

大量の変更を受け取るとき

初回同期などで数千件以上の変更を受け取ると、反映に時間がかかります。入力メソッドなのでメインスレッドを止めないように、100件ごとに Task.yield() で他の処理に譲りながら反映します。反映中に変換・確定しても、その操作は失われません。受け取った後にこのMacで編集した変換候補は、受け取った古い内容で上書きしません。

送信の失敗

  • 競合(serverRecordChanged): サーバーの版とマージし、このMacのほうが新しければ送り直します
  • unknownItem: 変更タグを捨てて送り直します
  • 通信エラー・レート制限などの再試行できるエラー: 変更を残して次の周で送ります
  • 送信中に同じ変換候補を編集した場合は、送信の成功後に新しい版を送ります

同期を自動で止めるとき

次の場合は同期を自動で停止し、通知センターと設定画面に理由を表示します。もう一度有効にすると、初回同期からやり直します。

  • iCloud からサインアウトした、または別のアカウントに切り替えた
  • 他のMacで iCloud からユーザー辞書が削除された

「高度なデータ保護」の鍵のリセット(encryptedDataReset)でゾーンが消えた場合は、同期を止めずにゾーンを作り直します。

entitlementの扱い

#515 と同じく、iCloud の entitlement はリリースビルド用の macSKK/macSKK-iCloud.entitlements だけに追加しました。Developer Program に参加していない環境でも、今まで通りビルドできます。

  • com.apple.developer.icloud-services: CloudKit
  • com.apple.developer.icloud-container-identifiers: iCloud.net.mtgto.inputmethod.macSKK

CloudKit の環境(Development / Production)を決める com.apple.developer.icloud-container-environment は entitlement ファイルには書かず、script/export-options.plist の iCloudContainerEnvironment で Production を指定しています。make でエクスポートするときに Xcode がこの entitlement を付けて署名し直すので、リリースの pkg は Production 環境につながります。entitlement ファイルに書くと、Local.xcconfig で iCloud 版の entitlement を使う開発用のビルドまで Production に固定されてしまうためです。

entitlement のないビルドでは UserDictSyncer.isAvailable が false になり、設定画面のトグルが無効になるだけで他の動作は変わりません。entitlement なしで CKContainer を作るとクラッシュするため、事前に確認しています。

開発時の注意

  • Debug ビルド・make dev-pkg は CloudKit の Development 環境、pkg(Release・Developer ID 署名)は Production 環境につながります。両者のデータは同期されないので、2台で確認するときは同じ種類のビルドを使ってください
  • Debug ビルドを別のMac(VMを含む)で動かすには、そのMacを開発用のプロビジョニングプロファイルに登録する必要があります
  • CloudKit のスキーマは Development 環境でレコードを保存すると自動で作られます。Production には CloudKit Console から Deploy する必要があり、Production のスキーマはフィールドの追加はできても削除や型の変更はできません

テスト

  • 単体テスト: レコードのマージ、ユーザー辞書への反映、未送信の変更の管理、初回同期の判定など
  • CloudKit結合テスト (UserDictSyncerIntegrationTests): 1プロセスの中で UserDictSyncer を2つ作って2台のMacを再現し、本物の CloudKit (Development 環境) で送信・受信・削除・競合・初回同期・停止と再開などを確かめます
    • テストごとに使い捨てのゾーン UserDictTest-<UNIX秒>-<UUID> を使い、終わったら消します。本番のゾーン UserDict には触りません
    • 通信して遅いので、テストプラン CloudKitIntegration.xctestplan(環境変数 MACSKK_CLOUDKIT_TESTS=1)のときだけ動きます。通常の macSKK.xctestplan ではスキップします
    • iCloud の entitlement が付いたビルドと、iCloud にサインインした環境が必要です
    • 実行: xcodebuild test -project macSKK.xcodeproj -scheme macSKK -testPlan CloudKitIntegration -destination 'platform=macOS'

既知の制限

  • 注釈は同期しません。macSKK がユーザー辞書に注釈を書き込むことは基本的になく、他の辞書の注釈を他のMacへ同期したいかも疑問なためです。iCloud のレコードの注釈フィールドには読み書きのどちらも触れないので、将来注釈を同期するようになっても、この版がその注釈を消すことはありません。受け取った変更を反映するときも、ローカルにある変換候補の注釈は残します
  • 外部エディタでの編集は送信されません。skk-jisyo.utf8 を直接編集すると、このMacではすぐ反映されますが、iCloud には送信されません。同期の日時をローカルに持たないので、どこが編集されたか判断できないためです。追加した変換候補は、そのあと変換すれば送信されます
  • 送信前にアプリが終了すると、未送信の変更を取りこぼすことがあります。未送信の変更はメモリにしか持たないためです
  • プライベートモード中の操作は、今まで通りユーザー辞書に記録しないので同期もしません
  • 反映中の並び順: 大量の変更を反映している間に変換した変換候補は、そのあと反映される古い変換候補より後ろに並ぶことがあります
  • 送信が一度競合することがある: 取得したレコードの変更タグ (システムフィールド) を保存していないため、iCloud にすでにある変換候補を起動後に初めて送信すると、serverRecordChanged で一度拒否されます。そのときはサーバーの版とマージして送り直すので、結果は正しく、通信が1往復増えるだけです。変更タグを保存すれば解消できますが、取得した件数に比例してメモリを使うため、初期リリースでは対応していません
  • 2台でほぼ同時に同じ変換候補を変更すると、取り込みの通知が出ないことがあります。例えば、Mac A で「かんじ /漢字/」を削除した数秒後に、Mac B でも「漢字」を変換した場合です。同期の1周は「取得 → ユーザー辞書への反映 → 送信」の順に進むので、Mac B が取得を終えてから送信するまでの間に Mac A の変更が iCloud に届くと、Mac B は送信の競合でその変更を知ります。競合で知った変更もユーザー辞書には正しく反映されますが、通知センターには「他のMacの変更を取り込みました」と表示されません。ユーザー辞書の中身は正しいので、通知が出なかったこと以外の影響はありません
  • ユーザー辞書を読み込めないと、同期が始まらないまま止まります。同期は、ユーザー辞書の読み込みが終わってから始まります。そのため、skk-jisyo.utf8 を読み込めないと同期を始められません。例えば、外部エディタで UTF-8 以外の文字コード (EUC-JP など) で保存した場合や、ファイルを読む権限がない場合です。このとき設定画面の同期の状態は「待機中」のまま (最終同期日時が更新されない、または「まだ同期していません」) で、同期できていないことは表示されません。ユーザー辞書を読み込めなかったことは、通知センターと設定画面の「辞書」タブに表示されます。ファイルを直して読み込めるようになれば、そのまま同期が始まります。他のMacの変更が何日も届かず、最終同期日時も進まない場合は、「辞書」タブでユーザー辞書の状態を確認してください
  • 上記のうち外部エディタでの編集と送信前の終了は、変換候補ごとの同期情報をローカルに持てば対応できます。将来の課題として ユーザー辞書をiCloud (CloudKit) で複数のMacと同期する #528 に残しています

確認したこと

  • 単体テスト・CloudKit結合テストがこのMacで通ること
  • 2台の Mac (macOS ?? と macOS ??) で、make dev-pkg のビルド(Development 環境)により次を確認
    • 約8600件のユーザー辞書での「すべて同期」の初回同期。送信は約2分35秒で、送信中も入力が引っかからないこと
    • 2台目の「すべて同期」で、約8600件の受信・反映中も入力できること、初回同期では通知が出ないこと、Issue の表の4通りの結果
    • 追加・更新・削除の送受信と通知、5分ごとの定期取得、2台でほぼ同時に同じ変換候補を変更したときの結果、再起動後の同期の引き継ぎ
    • 設定画面の状態表示、通信できないときのエラー表示と回復、同期の無効化からすぐの有効化
    • iCloudからの削除 (失敗時の赤字表示、他のMacの自動停止、各Macのユーザー辞書が残ること、再有効化)

mtgto added 14 commits November 8, 2026 13:44
旧ブランチから同期クラス以外を持ち込む。実際の変換日時がわからず仮に割り当てた日時の呼び名をsyntheticからplaceholderに改める。
UserDictSyncer (窓口) が start() でセッションを作り、stop() で捨てる。
セッションは取得→反映→送信を1周として1本のループで直列に回す。
開始時・定期取得・変換のあとの送信の予約・今すぐ同期は1周の依頼になり、
実行中の依頼は「もう1周」にまとまる。CKSyncEngine は automaticallySync = false で包み、
他のゾーンのイベントを捨ててセッションに渡す。イベントのコールバックでは集めるだけにする。

取得が途中で失敗した周でも、受け取ったレコードは反映し、届いた同期状態も保存する
(CKSyncEngine がメモリ上の change token を進めるため)。送信と lastSyncedAt・
最初からの取得の完了の印の保存、エラーの消去だけを行わずに周を終える (runIteration で確認)。

送信の失敗の扱いは後で足す。アカウント変更・ゾーン削除はログだけ出す。
結合テストのテストプランが選ぶテストの指定を、クラス名だけの形に直した。
最初からの取得が成功して反映し終えた周の送信の前に、iCloudにない既存の変換候補に仮の日時を割り当てて台帳に足す。
初回同期の段階と完了の判定をUserDictInitialSyncに切り出す
アカウントのサインアウト・切り替え、他の端末でのゾーンの削除、送信時のzoneNotFoundは、
CKSyncEngineのイベントの中では印をつけるだけにして、ループがawaitの後に見て自分で止まる。
ループの中からはstop()を呼べない (自分のTaskの終わりを待ってしまう) ので、stop()と共通の後始末を
先に呼ばれたほうで1回だけ行い、窓口が同期状態を破棄して停止の理由を知らせる。
最初からの取得中に届いたゾーンの削除の履歴は無視し、encryptedDataResetならゾーンを作り直して同期を続ける。

インスタンスのdeleteAllFromiCloud()は自分のゾーンを消す (結合テストで本物のゾーンを消さないため)。
CloudKitの日時 (TIMESTAMP) はミリ秒で表現されるというCloudKit Web Servicesの古いドキュメントを根拠に、
iCloudを経由しても同じ値に戻るよう日時をミリ秒に丸めていた。

結合テストの仕組みで、ミリ秒より細かい端数をもつDateを暗号化したフィールドに保存し、
CKDatabaseで取得し直して確かめたところ、丸められずにDoubleの精度のまま同じ値が返ってきた。
(1700000000.123456717、-0.001500010、現在時刻など6つの値で、取得した値と保存した値が一致し、
ミリ秒に丸めた値とは一致しなかった)
暗号化したフィールドは値を暗号化したバイト列として保存するため、サーバーのTIMESTAMPの表現の影響を受けないと思われる。

根拠にしていたコメントが誤りとわかり、丸めなくても同じ値に戻るので、丸めをやめてコードを簡単にする。
丸めずに保存した同期情報がiCloudから同じ値で返ってくることを、結合テストに残す。
@mtgto
mtgto merged commit 5236a76 into main Nov 15, 2026
2 checks passed
@mtgto
mtgto deleted the 528-userdict-cloudkit-redesign branch November 15, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ユーザー辞書をiCloud (CloudKit) で複数のMacと同期する

1 participant