[{"rendered_body":"\u003cp data-sourcepos=\"1:1-1:202\"\u003e自分用に、Macのメニューバーから使える音声入力アプリ「KoeBridge」を作りました。Swift製で、GeminiとAppleオンデバイスの認識方式を切り替えられます。\u003c/p\u003e\n\u003cp data-sourcepos=\"3:1-3:240\"\u003eこの記事では機能紹介よりも、音声認識の結果を普段の入力欄へ渡す部分の設計を紹介します。特に、非同期で結果が返る処理を、ユーザーの操作順に反映する方法を扱います。\u003c/p\u003e\n\u003cp data-sourcepos=\"5:1-5:154\"\u003e対象読者は、Swiftで入力支援ツールを作っている方や、非同期処理の結果をUIへ反映する順番で悩んでいる方です。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"7:1-7:72\"\u003e\n\u003cspan id=\"録音できると安全に入力できるは別の処理\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E9%8C%B2%E9%9F%B3%E3%81%A7%E3%81%8D%E3%82%8B%E3%81%A8%E5%AE%89%E5%85%A8%E3%81%AB%E5%85%A5%E5%8A%9B%E3%81%A7%E3%81%8D%E3%82%8B%E3%81%AF%E5%88%A5%E3%81%AE%E5%87%A6%E7%90%86\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e「録音できる」と「安全に入力できる」は別の処理\u003c/h2\u003e\n\u003cp data-sourcepos=\"9:1-9:72\"\u003e音声入力の処理は、大きく次のように分けられます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"11:1-13:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e録音開始 → 音声認識 → 結果確定 → 入力先の照合 → 文字入力 → 反映の確認\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"15:1-15:240\"\u003e認識が終わった時点で、入力先が録音開始時と同じとは限りません。別のアプリへ切り替えたかもしれませんし、同じウィンドウ内の別の入力欄をクリックしたかもしれません。\u003c/p\u003e\n\u003cp data-sourcepos=\"17:1-17:141\"\u003eまた、前の認識処理を待たずに次の録音を始められるようにすると、結果の返る順序も問題になります。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"19:1-22:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e録音した順：A → B\n認識完了順：B → A\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"24:1-24:180\"\u003e完了した順に文字を入力すると、文章の順番が逆転します。そこでKoeBridgeでは、認識処理の完了と、結果の取り出しを分けています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"26:1-26:54\"\u003e\n\u003cspan id=\"1-完了した結果を録音順に取り出す\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#1-%E5%AE%8C%E4%BA%86%E3%81%97%E3%81%9F%E7%B5%90%E6%9E%9C%E3%82%92%E9%8C%B2%E9%9F%B3%E9%A0%86%E3%81%AB%E5%8F%96%E3%82%8A%E5%87%BA%E3%81%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e1. 完了した結果を、録音順に取り出す\u003c/h2\u003e\n\u003cp data-sourcepos=\"28:1-28:96\"\u003e\u003ccode\u003eOrderedResultBuffer\u003c/code\u003eは、開始順を配列、完了した結果を辞書で保持します。\u003c/p\u003e\n\u003cul data-sourcepos=\"30:1-33:0\"\u003e\n\u003cli data-sourcepos=\"30:1-30:46\"\u003e\n\u003ccode\u003ebegin(id)\u003c/code\u003e：録音の順番を登録する\u003c/li\u003e\n\u003cli data-sourcepos=\"31:1-31:53\"\u003e\n\u003ccode\u003efinish(id, result:)\u003c/code\u003e：認識結果を保存する\u003c/li\u003e\n\u003cli data-sourcepos=\"32:1-33:0\"\u003e\n\u003ccode\u003edrain()\u003c/code\u003e：先頭から連続して完了している分だけ取り出す\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"34:1-34:45\"\u003e中心になるのは、次の部分です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"36:1-46:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003emutating\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003edrain\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e[(\u003c/span\u003e\u003cspan class=\"kt\"\u003eUUID\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eValue\u003c/span\u003e\u003cspan class=\"p\"\u003e)]\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eitems\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[(\u003c/span\u003e\u003cspan class=\"kt\"\u003eUUID\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eValue\u003c/span\u003e\u003cspan class=\"p\"\u003e)]\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[]\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ewhile\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eid\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003epending\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efirst\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n          \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eresolved\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eremoveValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eforKey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003epending\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eremoveFirst\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n        \u003cspan class=\"n\"\u003eitems\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eappend\u003c/span\u003e\u003cspan class=\"p\"\u003e((\u003c/span\u003e\u003cspan class=\"n\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003eitems\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"48:1-48:131\"\u003eBの結果が先に来ても、Aが未解決なら取り出しません。Aが解決した時点で、A、Bの順に返します。\u003c/p\u003e\n\u003cp data-sourcepos=\"50:1-50:133\"\u003e\u003ca href=\"https://github.com/ma-do-ka/KoeBridge/blob/main/Sources/KoeBridgeCore/OrderedResultBuffer.swift\" rel=\"nofollow noopener\" target=\"_blank\"\u003e実装：OrderedResultBuffer.swift\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"52:1-52:290\"\u003e注意点は、先頭の処理が失敗した場合です。成功した結果だけを登録する設計では、Aが失敗したままB以降を永遠に待たせてしまいます。失敗も一つの解決済み結果として扱い、後ろの処理へ進める必要があります。\u003c/p\u003e\n\u003cp data-sourcepos=\"54:1-54:296\"\u003eこのバッファ自体はスレッドセーフなコンテナではありません。KoeBridgeでは、これを所有する\u003ccode\u003eVoiceController\u003c/code\u003eを\u003ccode\u003e@MainActor\u003c/code\u003eに置いています。他のアプリへ転用する場合も、更新する実行コンテキストを揃える必要があります。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"56:1-56:63\"\u003e\n\u003cspan id=\"2-重複したコールバックを二重入力にしない\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#2-%E9%87%8D%E8%A4%87%E3%81%97%E3%81%9F%E3%82%B3%E3%83%BC%E3%83%AB%E3%83%90%E3%83%83%E3%82%AF%E3%82%92%E4%BA%8C%E9%87%8D%E5%85%A5%E5%8A%9B%E3%81%AB%E3%81%97%E3%81%AA%E3%81%84\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e2. 重複したコールバックを二重入力にしない\u003c/h2\u003e\n\u003cp data-sourcepos=\"58:1-58:112\"\u003e\u003ccode\u003efinish\u003c/code\u003eでは、開始済みのIDで、まだ結果が登録されていない場合だけ受け付けます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"60:1-65:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003epending\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"n\"\u003eresolved\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"n\"\u003eresolved\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eresult\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"67:1-67:208\"\u003eこれにより、同じ完了通知が複数回来てもバッファから結果を重複して取り出しません。すでに取り出した古いIDや、登録していないIDも受け付けません。\u003c/p\u003e\n\u003cp data-sourcepos=\"69:1-69:188\"\u003eただし、ここで防げるのはバッファ内の重複処理です。OSへ送信した入力がアプリに反映されたかどうかは、別に確認する必要があります。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"71:1-71:72\"\u003e\n\u003cspan id=\"3-同じアプリだけでは入力先の判定が足りない\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#3-%E5%90%8C%E3%81%98%E3%82%A2%E3%83%97%E3%83%AA%E3%81%A0%E3%81%91%E3%81%A7%E3%81%AF%E5%85%A5%E5%8A%9B%E5%85%88%E3%81%AE%E5%88%A4%E5%AE%9A%E3%81%8C%E8%B6%B3%E3%82%8A%E3%81%AA%E3%81%84\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e3. 「同じアプリ」だけでは入力先の判定が足りない\u003c/h2\u003e\n\u003cp data-sourcepos=\"73:1-73:81\"\u003e入力先の照合では、少なくとも次の情報を分けて考えます。\u003c/p\u003e\n\u003ctable data-sourcepos=\"75:1-80:97\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"75:1-75:37\"\u003e\n\u003cth data-sourcepos=\"75:2-75:15\"\u003e確認対象\u003c/th\u003e\n\u003cth data-sourcepos=\"75:17-75:36\"\u003e確認する理由\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"77:1-77:61\"\u003e\n\u003ctd data-sourcepos=\"77:2-77:15\"\u003eプロセス\u003c/td\u003e\n\u003ctd data-sourcepos=\"77:17-77:60\"\u003e別のアプリへ移動していないか\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"78:1-78:76\"\u003e\n\u003ctd data-sourcepos=\"78:2-78:18\"\u003eウィンドウ\u003c/td\u003e\n\u003ctd data-sourcepos=\"78:20-78:75\"\u003e同じアプリの別画面へ移動していないか\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"79:1-79:76\"\u003e\n\u003ctd data-sourcepos=\"79:2-79:15\"\u003e入力要素\u003c/td\u003e\n\u003ctd data-sourcepos=\"79:17-79:75\"\u003e同じ画面の別の入力欄へ移動していないか\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"80:1-80:97\"\u003e\n\u003ctd data-sourcepos=\"80:2-80:15\"\u003e選択範囲\u003c/td\u003e\n\u003ctd data-sourcepos=\"80:17-80:96\"\u003e取得できる場合、カーソルや選択位置が変わっていないか\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"82:1-82:226\"\u003eKoeBridgeの\u003ccode\u003eInsertionFocusPolicy\u003c/code\u003eでは、プロセスとウィンドウだけでなく、同じ入力要素であることを確認します。選択範囲が取得できる場合は、その一致も判定に使います。\u003c/p\u003e\n\u003cp data-sourcepos=\"84:1-84:294\"\u003e一方、選択範囲を提供しないエディターでは、同じ入力要素だと確認できても、その中でのカーソル移動は分かりません。「同じ要素を確認できた」と「カーソルまで確認できた」を同じ意味にしないことが重要です。\u003c/p\u003e\n\u003cp data-sourcepos=\"86:1-86:135\"\u003e\u003ca href=\"https://github.com/ma-do-ka/KoeBridge/blob/main/Sources/KoeBridgeCore/InsertionFocusPolicy.swift\" rel=\"nofollow noopener\" target=\"_blank\"\u003e実装：InsertionFocusPolicy.swift\u003c/a\u003e\u003c/p\u003e\n\u003ch2 data-sourcepos=\"88:1-88:72\"\u003e\n\u003cspan id=\"4-入力を送信したことと反映されたことを分ける\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#4-%E5%85%A5%E5%8A%9B%E3%82%92%E9%80%81%E4%BF%A1%E3%81%97%E3%81%9F%E3%81%93%E3%81%A8%E3%81%A8%E5%8F%8D%E6%98%A0%E3%81%95%E3%82%8C%E3%81%9F%E3%81%93%E3%81%A8%E3%82%92%E5%88%86%E3%81%91%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e4. 入力を送信したことと、反映されたことを分ける\u003c/h2\u003e\n\u003cp data-sourcepos=\"90:1-90:110\"\u003eOSへ文字入力のイベントを送れても、入力先がそれを受け取ったとは限りません。\u003c/p\u003e\n\u003cp data-sourcepos=\"92:1-92:207\"\u003eKoeBridgeでは、確認できる入力欄について、入力前の本文・選択範囲と、送信した文字列から期待する入力後の状態を組み立て、実際の状態と照合します。\u003c/p\u003e\n\u003cp data-sourcepos=\"94:1-94:346\"\u003eこのとき位置の計算にはUTF-16の長さを使います。\u003ccode\u003eString.count\u003c/code\u003eはユーザーが見る文字の単位に近い数え方なので、macOSの選択範囲に使う位置とそのまま置き換えられません。実装ではサロゲートペアの途中を選択範囲の境界として扱わない確認も入れています。\u003c/p\u003e\n\u003cp data-sourcepos=\"96:1-96:139\"\u003e\u003ca href=\"https://github.com/ma-do-ka/KoeBridge/blob/main/Sources/KoeBridgeCore/InsertionReceiptPolicy.swift\" rel=\"nofollow noopener\" target=\"_blank\"\u003e実装：InsertionReceiptPolicy.swift\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"98:1-98:375\"\u003eここで反映を確認できなかった場合、すぐ同じ文字を再送すると、遅れて反映された最初の入力と重なって二重入力になる可能性があります。そのため、送信済み・反映未確認の結果を盲目的に再送しない方針にしています。認識結果は履歴からコピーできるようにしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"100:1-100:219\"\u003e便利さだけを見ると、自動で何度も試したくなります。しかし、別の場所への誤入力や二重入力を避けるには、確認できない状態を独立して扱う必要があります。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"102:1-102:60\"\u003e\n\u003cspan id=\"5-外部apiなしで確認できる部分を切り出す\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#5-%E5%A4%96%E9%83%A8api%E3%81%AA%E3%81%97%E3%81%A7%E7%A2%BA%E8%AA%8D%E3%81%A7%E3%81%8D%E3%82%8B%E9%83%A8%E5%88%86%E3%82%92%E5%88%87%E3%82%8A%E5%87%BA%E3%81%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e5. 外部APIなしで確認できる部分を切り出す\u003c/h2\u003e\n\u003cp data-sourcepos=\"104:1-104:168\"\u003e順序管理のロジックは、マイクや認識APIを使わずにテストできます。リポジトリのテストでは、次のケースを扱っています。\u003c/p\u003e\n\u003col data-sourcepos=\"106:1-110:0\"\u003e\n\u003cli data-sourcepos=\"106:1-106:80\"\u003eBが先に完了しても、Aが解決するまで結果を取り出さない\u003c/li\u003e\n\u003cli data-sourcepos=\"107:1-107:68\"\u003e同じIDの開始・完了通知を重複して受け付けない\u003c/li\u003e\n\u003cli data-sourcepos=\"108:1-108:84\"\u003e先行処理の失敗を解決すると、後続の完了済み結果へ進める\u003c/li\u003e\n\u003cli data-sourcepos=\"109:1-110:0\"\u003e保留件数の上限に達したら、新規登録を受け付けない\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"111:1-111:149\"\u003e\u003ca href=\"https://github.com/ma-do-ka/KoeBridge/blob/main/Tests/KoeBridgeCoreTests/OrderedResultBufferTests.swift\" rel=\"nofollow noopener\" target=\"_blank\"\u003eテスト：OrderedResultBufferTests.swift\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"113:1-113:297\"\u003eこのテストで確かめられるのは順序管理の振る舞いです。実際のマイク、音声認識サービス、入力先アプリとの組み合わせまで保証するものではありません。純粋なロジックのテストと実機確認は、分けて考えています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"115:1-115:12\"\u003e\n\u003cspan id=\"まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eまとめ\u003c/h2\u003e\n\u003cp data-sourcepos=\"117:1-117:93\"\u003e音声入力アプリでは、認識結果を得た後にも考えることがあります。\u003c/p\u003e\n\u003cul data-sourcepos=\"119:1-123:0\"\u003e\n\u003cli data-sourcepos=\"119:1-119:71\"\u003e非同期処理の完了順と、ユーザーの操作順を分ける\u003c/li\u003e\n\u003cli data-sourcepos=\"120:1-120:68\"\u003e失敗も解決済みとして扱い、後続を詰まらせない\u003c/li\u003e\n\u003cli data-sourcepos=\"121:1-121:59\"\u003e同じアプリ内でも、入力欄や位置は変わる\u003c/li\u003e\n\u003cli data-sourcepos=\"122:1-123:0\"\u003e送信成功と反映確認を区別し、不確かな入力を自動で再送しない\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"124:1-124:162\"\u003eこれらは音声入力に限らず、非同期で得た結果をユーザーの画面へ自動反映するツールでも使える考え方だと思います。\u003c/p\u003e\n\u003cp data-sourcepos=\"126:1-126:173\"\u003eKoeBridgeのソースはMITライセンスで公開しています。実装全体や対応条件は\u003ca href=\"https://github.com/ma-do-ka/KoeBridge\" rel=\"nofollow noopener\" target=\"_blank\"\u003eGitHub\u003c/a\u003eにまとめています。\u003c/p\u003e\n\u003cp data-sourcepos=\"128:1-128:362\"\u003eなお、Geminiモードは本人のAPIキーと別途API利用料が必要です。Appleオンデバイス認識は対応するMac・言語環境で利用できます。配布物のDeveloper ID署名・公証は未取得です。すべての入力先アプリへの対応や、認識精度・処理速度の優位性を保証するものではありません。\u003c/p\u003e\n","body":"自分用に、Macのメニューバーから使える音声入力アプリ「KoeBridge」を作りました。Swift製で、GeminiとAppleオンデバイスの認識方式を切り替えられます。\n\nこの記事では機能紹介よりも、音声認識の結果を普段の入力欄へ渡す部分の設計を紹介します。特に、非同期で結果が返る処理を、ユーザーの操作順に反映する方法を扱います。\n\n対象読者は、Swiftで入力支援ツールを作っている方や、非同期処理の結果をUIへ反映する順番で悩んでいる方です。\n\n## 「録音できる」と「安全に入力できる」は別の処理\n\n音声入力の処理は、大きく次のように分けられます。\n\n```text\n録音開始 → 音声認識 → 結果確定 → 入力先の照合 → 文字入力 → 反映の確認\n```\n\n認識が終わった時点で、入力先が録音開始時と同じとは限りません。別のアプリへ切り替えたかもしれませんし、同じウィンドウ内の別の入力欄をクリックしたかもしれません。\n\nまた、前の認識処理を待たずに次の録音を始められるようにすると、結果の返る順序も問題になります。\n\n```text\n録音した順：A → B\n認識完了順：B → A\n```\n\n完了した順に文字を入力すると、文章の順番が逆転します。そこでKoeBridgeでは、認識処理の完了と、結果の取り出しを分けています。\n\n## 1. 完了した結果を、録音順に取り出す\n\n`OrderedResultBuffer`は、開始順を配列、完了した結果を辞書で保持します。\n\n- `begin(id)`：録音の順番を登録する\n- `finish(id, result:)`：認識結果を保存する\n- `drain()`：先頭から連続して完了している分だけ取り出す\n\n中心になるのは、次の部分です。\n\n```swift\npublic mutating func drain() -\u003e [(UUID, Value)] {\n    var items: [(UUID, Value)] = []\n    while let id = pending.first,\n          let value = resolved.removeValue(forKey: id) {\n        pending.removeFirst()\n        items.append((id, value))\n    }\n    return items\n}\n```\n\nBの結果が先に来ても、Aが未解決なら取り出しません。Aが解決した時点で、A、Bの順に返します。\n\n[実装：OrderedResultBuffer.swift](https://github.com/ma-do-ka/KoeBridge/blob/main/Sources/KoeBridgeCore/OrderedResultBuffer.swift)\n\n注意点は、先頭の処理が失敗した場合です。成功した結果だけを登録する設計では、Aが失敗したままB以降を永遠に待たせてしまいます。失敗も一つの解決済み結果として扱い、後ろの処理へ進める必要があります。\n\nこのバッファ自体はスレッドセーフなコンテナではありません。KoeBridgeでは、これを所有する`VoiceController`を`@MainActor`に置いています。他のアプリへ転用する場合も、更新する実行コンテキストを揃える必要があります。\n\n## 2. 重複したコールバックを二重入力にしない\n\n`finish`では、開始済みのIDで、まだ結果が登録されていない場合だけ受け付けます。\n\n```swift\nguard pending.contains(id), resolved[id] == nil else {\n    return false\n}\nresolved[id] = result\n```\n\nこれにより、同じ完了通知が複数回来てもバッファから結果を重複して取り出しません。すでに取り出した古いIDや、登録していないIDも受け付けません。\n\nただし、ここで防げるのはバッファ内の重複処理です。OSへ送信した入力がアプリに反映されたかどうかは、別に確認する必要があります。\n\n## 3. 「同じアプリ」だけでは入力先の判定が足りない\n\n入力先の照合では、少なくとも次の情報を分けて考えます。\n\n| 確認対象 | 確認する理由 |\n| --- | --- |\n| プロセス | 別のアプリへ移動していないか |\n| ウィンドウ | 同じアプリの別画面へ移動していないか |\n| 入力要素 | 同じ画面の別の入力欄へ移動していないか |\n| 選択範囲 | 取得できる場合、カーソルや選択位置が変わっていないか |\n\nKoeBridgeの`InsertionFocusPolicy`では、プロセスとウィンドウだけでなく、同じ入力要素であることを確認します。選択範囲が取得できる場合は、その一致も判定に使います。\n\n一方、選択範囲を提供しないエディターでは、同じ入力要素だと確認できても、その中でのカーソル移動は分かりません。「同じ要素を確認できた」と「カーソルまで確認できた」を同じ意味にしないことが重要です。\n\n[実装：InsertionFocusPolicy.swift](https://github.com/ma-do-ka/KoeBridge/blob/main/Sources/KoeBridgeCore/InsertionFocusPolicy.swift)\n\n## 4. 入力を送信したことと、反映されたことを分ける\n\nOSへ文字入力のイベントを送れても、入力先がそれを受け取ったとは限りません。\n\nKoeBridgeでは、確認できる入力欄について、入力前の本文・選択範囲と、送信した文字列から期待する入力後の状態を組み立て、実際の状態と照合します。\n\nこのとき位置の計算にはUTF-16の長さを使います。`String.count`はユーザーが見る文字の単位に近い数え方なので、macOSの選択範囲に使う位置とそのまま置き換えられません。実装ではサロゲートペアの途中を選択範囲の境界として扱わない確認も入れています。\n\n[実装：InsertionReceiptPolicy.swift](https://github.com/ma-do-ka/KoeBridge/blob/main/Sources/KoeBridgeCore/InsertionReceiptPolicy.swift)\n\nここで反映を確認できなかった場合、すぐ同じ文字を再送すると、遅れて反映された最初の入力と重なって二重入力になる可能性があります。そのため、送信済み・反映未確認の結果を盲目的に再送しない方針にしています。認識結果は履歴からコピーできるようにしています。\n\n便利さだけを見ると、自動で何度も試したくなります。しかし、別の場所への誤入力や二重入力を避けるには、確認できない状態を独立して扱う必要があります。\n\n## 5. 外部APIなしで確認できる部分を切り出す\n\n順序管理のロジックは、マイクや認識APIを使わずにテストできます。リポジトリのテストでは、次のケースを扱っています。\n\n1. Bが先に完了しても、Aが解決するまで結果を取り出さない\n2. 同じIDの開始・完了通知を重複して受け付けない\n3. 先行処理の失敗を解決すると、後続の完了済み結果へ進める\n4. 保留件数の上限に達したら、新規登録を受け付けない\n\n[テスト：OrderedResultBufferTests.swift](https://github.com/ma-do-ka/KoeBridge/blob/main/Tests/KoeBridgeCoreTests/OrderedResultBufferTests.swift)\n\nこのテストで確かめられるのは順序管理の振る舞いです。実際のマイク、音声認識サービス、入力先アプリとの組み合わせまで保証するものではありません。純粋なロジックのテストと実機確認は、分けて考えています。\n\n## まとめ\n\n音声入力アプリでは、認識結果を得た後にも考えることがあります。\n\n- 非同期処理の完了順と、ユーザーの操作順を分ける\n- 失敗も解決済みとして扱い、後続を詰まらせない\n- 同じアプリ内でも、入力欄や位置は変わる\n- 送信成功と反映確認を区別し、不確かな入力を自動で再送しない\n\nこれらは音声入力に限らず、非同期で得た結果をユーザーの画面へ自動反映するツールでも使える考え方だと思います。\n\nKoeBridgeのソースはMITライセンスで公開しています。実装全体や対応条件は[GitHub](https://github.com/ma-do-ka/KoeBridge)にまとめています。\n\nなお、Geminiモードは本人のAPIキーと別途API利用料が必要です。Appleオンデバイス認識は対応するMac・言語環境で利用できます。配布物のDeveloper ID署名・公証は未取得です。すべての入力先アプリへの対応や、認識精度・処理速度の優位性を保証するものではありません。\n","coediting":false,"comments_count":0,"created_at":"2026-11-07T20:53:00+09:00","group":null,"id":"312e4445d4f737737f1e","likes_count":1,"private":false,"reactions_count":0,"stocks_count":1,"tags":[{"name":"Swift","versions":[]},{"name":"macOS","versions":[]},{"name":"音声認識","versions":[]},{"name":"個人開発","versions":[]}],"title":"Macの音声入力アプリをSwiftで作って考えた、認識結果の「順番」と「入力先」の守り方","updated_at":"2026-11-07T20:53:00+09:00","url":"https://qiita.com/ma-do-ka/items/312e4445d4f737737f1e","user":{"description":null,"facebook_id":null,"followees_count":1,"followers_count":0,"github_login_name":"ma-do-ka","id":"ma-do-ka","items_count":1,"linkedin_id":null,"location":null,"name":"","organization":null,"permanent_id":4528991,"profile_image_url":"https://avatars.githubusercontent.com/u/258586627?v=4","team_only":false,"twitter_screen_name":null,"website_url":null},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch1 data-sourcepos=\"1:1-1:91\"\u003e\n\u003cspan id=\"apple-silicon-neural-engine-で-ocr-認識を-01秒未満にする-swift-実装の勘所\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#apple-silicon-neural-engine-%E3%81%A7-ocr-%E8%AA%8D%E8%AD%98%E3%82%92-01%E7%A7%92%E6%9C%AA%E6%BA%80%E3%81%AB%E3%81%99%E3%82%8B-swift-%E5%AE%9F%E8%A3%85%E3%81%AE%E5%8B%98%E6%89%80\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eApple Silicon Neural Engine で OCR 認識を 0.1秒未満にする Swift 実装の勘所\u003c/h1\u003e\n\u003ch2 data-sourcepos=\"3:1-3:92\"\u003e\n\u003cspan id=\"はじめになぜ01秒未満100msの壁を超える必要があるのか\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB%E3%81%AA%E3%81%9C01%E7%A7%92%E6%9C%AA%E6%BA%80100ms%E3%81%AE%E5%A3%81%E3%82%92%E8%B6%85%E3%81%88%E3%82%8B%E5%BF%85%E8%A6%81%E3%81%8C%E3%81%82%E3%82%8B%E3%81%AE%E3%81%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eはじめに：なぜ「0.1秒未満（100ms）」の壁を超える必要があるのか\u003c/h2\u003e\n\u003cp data-sourcepos=\"5:1-6:209\"\u003eデスクトップにおける OCR（光学文字認識）は、長らく「待たされるツール」でした。\u003cbr\u003e\n画面上の選択できないエラーメッセージや PDF 論文の文字列をキャプチャして認識させる際、多くのツールは以下のいずれかのアプローチを取っています。\u003c/p\u003e\n\u003col data-sourcepos=\"8:1-12:0\"\u003e\n\u003cli data-sourcepos=\"8:1-9:211\"\u003e\n\u003cstrong\u003eクラウド OCR API 経由\u003c/strong\u003e：\u003cbr\u003e\nスクショ画像を外部サーバー（Google Cloud Vision, AWS Textract, Azure 等）に送信して解析。ネットワーク往復（RTT）とサーバー推論で \u003cstrong\u003e1,000ms〜2,500ms\u003c/strong\u003e を要する。\u003c/li\u003e\n\u003cli data-sourcepos=\"10:1-12:0\"\u003e\n\u003cstrong\u003eオープンソース OCR ライブラリ（Tesseract 等）の CPU 実行\u003c/strong\u003e：\u003cbr\u003e\nローカルで完結するものの、CPU バウンドな画像前処理とビームサーチ探索により \u003cstrong\u003e400ms〜800ms\u003c/strong\u003e かかる。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"13:1-13:323\"\u003e認知心理学や HCI（ヒューマンコンピュータインタラクション）の研究において、\u003cstrong\u003e100ms（0.1秒）未満のシステム応答は「即時（Instant）」として知覚\u003c/strong\u003eされ、人間の思考のコンテキストスイッチ（フローの途絶）が発生しないとされています。\u003c/p\u003e\n\u003cp data-sourcepos=\"15:1-15:314\"\u003emacOS 開発において、Apple Silicon（M1〜M4）に標準搭載されている \u003cstrong\u003eApple Neural Engine (ANE)\u003c/strong\u003e と Vision フレームワークを限界まで最適化して駆動させることで、\u003cstrong\u003eOCR 認識パイプライン全体を 60〜85ms（0.1秒未満）で完了させる\u003c/strong\u003eことが可能です。\u003c/p\u003e\n\u003cp data-sourcepos=\"17:1-17:204\"\u003e本稿では、Swift を用いて Apple Silicon の Neural Engine をフル稼働させ、OCR 認識を 0.1 秒未満に押し込むための 4 つの実装勘所とベンチマークを解説します。\u003c/p\u003e\n\u003chr data-sourcepos=\"19:1-20:0\"\u003e\n\u003ch2 data-sourcepos=\"21:1-21:70\"\u003e\n\u003cspan id=\"1-apple-vision-と-neural-engine-ane-の推論パイプライン\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#1-apple-vision-%E3%81%A8-neural-engine-ane-%E3%81%AE%E6%8E%A8%E8%AB%96%E3%83%91%E3%82%A4%E3%83%97%E3%83%A9%E3%82%A4%E3%83%B3\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e1. Apple Vision と Neural Engine (ANE) の推論パイプライン\u003c/h2\u003e\n\u003cp data-sourcepos=\"23:1-23:194\"\u003emacOS の Vision フレームワーク（\u003ccode\u003eVNRecognizeTextRequest\u003c/code\u003e）は、内部で Core ML をベースとした深層学習モデル（CNN + Transformer 構造）を動作させています。\u003c/p\u003e\n\u003cp data-sourcepos=\"25:1-25:149\"\u003eApple Silicon アーキテクチャでは、推論エンジンは以下の 3 つのプロセッサから自動的にディスパッチされます。\u003c/p\u003e\n\u003cul data-sourcepos=\"27:1-30:0\"\u003e\n\u003cli data-sourcepos=\"27:1-27:132\"\u003e\n\u003cstrong\u003eCPU\u003c/strong\u003e：高いクロックと低レイテンシだが、並列テンソル演算の電力効率とスループットが低い。\u003c/li\u003e\n\u003cli data-sourcepos=\"28:1-28:192\"\u003e\n\u003cstrong\u003eGPU\u003c/strong\u003e：大規模な浮動小数点並列演算に強いが、グラフィックス描画パイプラインとの競合やコンテキストスイッチのオーバーヘッドがある。\u003c/li\u003e\n\u003cli data-sourcepos=\"29:1-30:0\"\u003e\n\u003cstrong\u003eNeural Engine (ANE)\u003c/strong\u003e：行列積和演算（GEMM）に特化した専用コプロセッサ。専用 SRAM を備え、ユニファイドメモリとの間で最小の電力・極小の遅延で推論を実行する。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"31:1-47:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e[ CGImage / CVPixelBuffer (RAM) ]\n               │\n               ▼  Zero-Copy (IOSurface 参照渡し)\n[ VNImageRequestHandler ]\n               │\n               ▼  ANE 最適化モデルへのディスパッチ (約 65ms)\n┌──────────────────────────────────────────────┐\n│  Apple Neural Engine (16-core ANE)           │\n│  ├─ 特徴抽出 (CNN Backbone)                  │\n│  ├─ 文字領域検出 (Text Line Detection)       │\n│  └─ 系列予測 (Transformer Decoder)           │\n└──────────────────────────────────────────────┘\n               │\n               ▼  言語モデルによるビームサーチ (約 10ms)\n[ VNRecognizedTextObservation (正規化座標 + 文字列) ]\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"49:1-49:248\"\u003e通常、Vision は推論先プロセッサを自動選択しますが、パラメータやデータ受け渡しの設計を誤ると、処理が \u003cstrong\u003eCPU にフォールバック\u003c/strong\u003eしてしまい、一気に 300〜500ms に遅延が悪化します。\u003c/p\u003e\n\u003chr data-sourcepos=\"51:1-52:0\"\u003e\n\u003ch2 data-sourcepos=\"53:1-53:93\"\u003e\n\u003cspan id=\"2-勘所ピクセルバッファ変換のゼロコピー化zero-copy-pipeline\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#2-%E5%8B%98%E6%89%80%E3%83%94%E3%82%AF%E3%82%BB%E3%83%AB%E3%83%90%E3%83%83%E3%83%95%E3%82%A1%E5%A4%89%E6%8F%9B%E3%81%AE%E3%82%BC%E3%83%AD%E3%82%B3%E3%83%94%E3%83%BC%E5%8C%96zero-copy-pipeline\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e2. 勘所①：ピクセルバッファ変換のゼロコピー化（Zero-Copy Pipeline）\u003c/h2\u003e\n\u003cp data-sourcepos=\"55:1-55:119\"\u003eOCR 処理で最も見落とされがちなボトルネックが、\u003cstrong\u003e画像の前処理とメモリコピー\u003c/strong\u003eです。\u003c/p\u003e\n\u003cp data-sourcepos=\"57:1-57:245\"\u003e多くの実装では、画面キャプチャ結果を一度 PNG や JPEG 形式でシリアライズしたり、不要な \u003ccode\u003eNSImage\u003c/code\u003e / \u003ccode\u003eCIImage\u003c/code\u003e 変換を多段で行っています。これだけで 30〜50ms の CPU 時間を浪費します。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"59:1-59:63\"\u003e\n\u003cspan id=\"ゼロコピーを実現する-coregraphics-キャプチャ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%BC%E3%83%AD%E3%82%B3%E3%83%94%E3%83%BC%E3%82%92%E5%AE%9F%E7%8F%BE%E3%81%99%E3%82%8B-coregraphics-%E3%82%AD%E3%83%A3%E3%83%97%E3%83%81%E3%83%A3\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eゼロコピーを実現する CoreGraphics キャプチャ\u003c/h3\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"61:1-92:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eCocoa\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eCoreGraphics\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eVision\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003efinal\u003c/span\u003e \u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"kt\"\u003eInstantOCRService\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eshared\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eInstantOCRService\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\n    \u003cspan class=\"c1\"\u003e/// 指定されたスクリーン矩形からゼロコピーで CGImage を切り出し\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003ecaptureRegion\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003escreenRect\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGRect\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003ewindowID\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGWindowID\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGImage\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003emainScreen\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSScreen\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003escreens\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efirst\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \n        \u003cspan class=\"c1\"\u003e// AppKit（左下原点）から CoreGraphics（左上原点）への座標変換\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003escreenHeight\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003emainScreen\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eframe\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eheight\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecaptureRect\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGRect\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n            \u003cspan class=\"nv\"\u003ex\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003escreenRect\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eorigin\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ex\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n            \u003cspan class=\"nv\"\u003ey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003escreenHeight\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"n\"\u003escreenRect\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eorigin\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ey\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"n\"\u003escreenRect\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eheight\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n            \u003cspan class=\"nv\"\u003ewidth\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003escreenRect\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ewidth\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n            \u003cspan class=\"nv\"\u003eheight\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003escreenRect\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eheight\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\n        \u003cspan class=\"c1\"\u003e// ディスク I/O や画像圧縮を介さず、RAM 上のメモリマップから直接 CGImage を生成\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eoptions\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGWindowImageOption\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebestResolution\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eboundsIgnoreFraming\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGWindowListCreateImage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n            \u003cspan class=\"n\"\u003ecaptureRect\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n            \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eoptionOnScreenBelowWindow\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n            \u003cspan class=\"n\"\u003ewindowID\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n            \u003cspan class=\"n\"\u003eoptions\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"94:1-94:366\"\u003e\u003ccode\u003eCGWindowListCreateImage\u003c/code\u003e に \u003ccode\u003e.bestResolution\u003c/code\u003e を指定することで、Retina ディスプレイの高密度ピクセル（2x）をそのまま保持したポインタが返ります。この \u003ccode\u003eCGImage\u003c/code\u003e を \u003ccode\u003eVNImageRequestHandler\u003c/code\u003e に直接渡すことで、\u003cstrong\u003eメモリ確保とコピーのオーバーヘッドをほぼ 0ms\u003c/strong\u003e に抑えることができます。\u003c/p\u003e\n\u003chr data-sourcepos=\"96:1-97:0\"\u003e\n\u003ch2 data-sourcepos=\"98:1-98:75\"\u003e\n\u003cspan id=\"3-勘所探索空間の刈り込み言語プルーニング\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#3-%E5%8B%98%E6%89%80%E6%8E%A2%E7%B4%A2%E7%A9%BA%E9%96%93%E3%81%AE%E5%88%88%E3%82%8A%E8%BE%BC%E3%81%BF%E8%A8%80%E8%AA%9E%E3%83%97%E3%83%AB%E3%83%BC%E3%83%8B%E3%83%B3%E3%82%B0\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e3. 勘所②：探索空間の刈り込み（言語プルーニング）\u003c/h2\u003e\n\u003cp data-sourcepos=\"100:1-100:175\"\u003e\u003ccode\u003eVNRecognizeTextRequest\u003c/code\u003e の遅延を大きく左右するのが、デコーダステップにおける\u003cstrong\u003e言語モデル（Language Model）の探索空間サイズ\u003c/strong\u003eです。\u003c/p\u003e\n\u003cp data-sourcepos=\"102:1-102:327\"\u003e言語指定を怠ったり、過剰に多言語を指定すると、Neural Engine の推論後に走るビームサーチ探索において、漢字、ひらがな、カタカナ、キリル文字、アクセント付きラテン文字などの広大な語彙辞書をスキャンするため、CPU 時間が激増します。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"104:1-104:46\"\u003e\n\u003cspan id=\"最速化のためのリクエスト構成\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%9C%80%E9%80%9F%E5%8C%96%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E6%A7%8B%E6%88%90\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e最速化のためのリクエスト構成\u003c/h3\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"106:1-139:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003ecreateFastTextRequest\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ecompletion\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kd\"\u003e@escaping\u003c/span\u003e \u003cspan class=\"p\"\u003e([\u003c/span\u003e\u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e])\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eVoid\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eVNRecognizeTextRequest\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erequest\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eVNRecognizeTextRequest\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003ereq\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eerror\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003eerror\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eobservations\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ereq\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eresults\u003c/span\u003e \u003cspan class=\"k\"\u003eas?\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"kt\"\u003eVNRecognizedTextObservation\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"nf\"\u003ecompletion\u003c/span\u003e\u003cspan class=\"p\"\u003e([])\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \n        \u003cspan class=\"c1\"\u003e// 空間ソート（行ごとのクラスタリング）\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esorted\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eobservations\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esorted\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003ea\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n            \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"nf\"\u003eabs\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ea\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eboundingBox\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emidY\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eboundingBox\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emidY\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.025\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n                \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003ea\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eboundingBox\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emidY\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eboundingBox\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emidY\u003c/span\u003e\n            \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003ea\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eboundingBox\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eminX\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eboundingBox\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eminX\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elines\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003esorted\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecompactMap\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etopCandidates\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efirst\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estring\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"nf\"\u003ecompletion\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003elines\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"c1\"\u003e// ANE アクセラレーションが効く高精度モデルを指定\u003c/span\u003e\n    \u003cspan class=\"n\"\u003erequest\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erecognitionLevel\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eaccurate\u003c/span\u003e\n    \u003cspan class=\"n\"\u003erequest\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eusesLanguageCorrection\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n\n    \u003cspan class=\"c1\"\u003e// 対象言語を実務で必要な ja-JP と en-US の2つに限定して探索空間を刈り込む\u003c/span\u003e\n    \u003cspan class=\"n\"\u003erequest\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erecognitionLanguages\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s\"\u003e\"ja-JP\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s\"\u003e\"en-US\"\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n    \n    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003e#available\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003emacOS\u003c/span\u003e \u003cspan class=\"mf\"\u003e13.0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003erequest\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eautomaticallyDetectsLanguage\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 自動判別をオフにしてオーバーヘッドを排除\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003erequest\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch3 data-sourcepos=\"141:1-141:56\"\u003e\n\u003cspan id=\"なぜ-fast-ではなく-accurate-なのか\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AA%E3%81%9C-fast-%E3%81%A7%E3%81%AF%E3%81%AA%E3%81%8F-accurate-%E3%81%AA%E3%81%AE%E3%81%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eなぜ \u003ccode\u003e.fast\u003c/code\u003e ではなく \u003ccode\u003e.accurate\u003c/code\u003e なのか？\u003c/h3\u003e\n\u003cp data-sourcepos=\"142:1-142:424\"\u003e直感的には \u003ccode\u003e.fast\u003c/code\u003e のほうが速そうに見えますが、テスト環境でのプロファイリングによると、\u003cstrong\u003eApple Silicon 環境下では \u003ccode\u003e.accurate\u003c/code\u003e のほうが Neural Engine (ANE) に最適化された重みテンソルが割り当てられる\u003c/strong\u003eため、計算時間差はわずか数ミリ秒にとどまり、文字認識精度（特に日本語の漢字や記号）は圧倒的に向上します。\u003c/p\u003e\n\u003chr data-sourcepos=\"144:1-145:0\"\u003e\n\u003ch2 data-sourcepos=\"146:1-146:78\"\u003e\n\u003cspan id=\"4-勘所roiregion-of-interestによる推論テンソル縮小\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#4-%E5%8B%98%E6%89%80roiregion-of-interest%E3%81%AB%E3%82%88%E3%82%8B%E6%8E%A8%E8%AB%96%E3%83%86%E3%83%B3%E3%82%BD%E3%83%AB%E7%B8%AE%E5%B0%8F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e4. 勘所③：ROI（Region of Interest）による推論テンソル縮小\u003c/h2\u003e\n\u003cp data-sourcepos=\"148:1-148:233\"\u003e4K や 5K の外部ディスプレイ環境において、画面全体（3840×2160 や 5120×2880）を Vision に渡すと、リサイズとタイル分割（Tiling）の前処理だけで 150ms 近く消費してしまいます。\u003c/p\u003e\n\u003cp data-sourcepos=\"150:1-150:198\"\u003eNeural Engine のスループットを最大化するには、\u003cstrong\u003eユーザーが必要としている領域（ROI）のみをピクセル単位で切り抜いて渡す\u003c/strong\u003eことが絶対原則です。\u003c/p\u003e\n\u003ctable data-sourcepos=\"152:1-157:69\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"152:1-152:68\"\u003e\n\u003cth data-sourcepos=\"152:2-152:18\"\u003e入力解像度\u003c/th\u003e\n\u003cth data-sourcepos=\"152:20-152:36\"\u003eピクセル数\u003c/th\u003e\n\u003cth data-sourcepos=\"152:38-152:67\"\u003e前処理 + ANE 推論遅延\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"154:1-154:59\"\u003e\n\u003ctd data-sourcepos=\"154:2-154:28\"\u003e全画面 4K (3840×2160)\u003c/td\u003e\n\u003ctd data-sourcepos=\"154:30-154:47\"\u003e約 8,290,000 px\u003c/td\u003e\n\u003ctd data-sourcepos=\"154:49-154:58\"\u003e165.2 ms\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"155:1-155:59\"\u003e\n\u003ctd data-sourcepos=\"155:2-155:29\"\u003e全画面 FHD (1920×1080)\u003c/td\u003e\n\u003ctd data-sourcepos=\"155:31-155:48\"\u003e約 2,070,000 px\u003c/td\u003e\n\u003ctd data-sourcepos=\"155:50-155:58\"\u003e94.8 ms\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"156:1-156:63\"\u003e\n\u003ctd data-sourcepos=\"156:2-156:35\"\u003eウィンドウ領域 (800×600)\u003c/td\u003e\n\u003ctd data-sourcepos=\"156:37-156:52\"\u003e約 480,000 px\u003c/td\u003e\n\u003ctd data-sourcepos=\"156:54-156:62\"\u003e62.1 ms\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"157:1-157:69\"\u003e\n\u003ctd data-sourcepos=\"157:2-157:34\"\u003e\u003cstrong\u003e矩形選択 ROI (400×150)\u003c/strong\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"157:36-157:54\"\u003e\u003cstrong\u003e約 60,000 px\u003c/strong\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"157:56-157:68\"\u003e\u003cstrong\u003e38.4 ms\u003c/strong\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"159:1-159:165\"\u003e矩形選択によって推論テンソルサイズを小さく保つことで、Neural Engine の推論遅延は \u003cstrong\u003e40ms 前後まで劇的に短縮\u003c/strong\u003eされます。\u003c/p\u003e\n\u003chr data-sourcepos=\"161:1-162:0\"\u003e\n\u003ch2 data-sourcepos=\"163:1-163:76\"\u003e\n\u003cspan id=\"5-勘所swift-concurrency-と-qosquality-of-serviceの選定\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#5-%E5%8B%98%E6%89%80swift-concurrency-%E3%81%A8-qosquality-of-service%E3%81%AE%E9%81%B8%E5%AE%9A\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e5. 勘所④：Swift Concurrency と QoS（Quality of Service）の選定\u003c/h2\u003e\n\u003cp data-sourcepos=\"165:1-165:293\"\u003e推論処理をバックグラウンドスレッドに逃がす際、QoS の設定を誤ると、macOS のスケジューラ（Grand Central Dispatch）によって低電力コア（Efficiency Cores）に割り振られ、Neural Engine への投入優先度が下げられてしまいます。\u003c/p\u003e\n\u003cp data-sourcepos=\"167:1-167:149\"\u003e即座に画面へ結果をフィードバックする必要がある対話型ツールでは、明示的に \u003ccode\u003e.userInteractive\u003c/code\u003e を指定します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"169:1-187:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eperformInstantOCR\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eon\u003c/span\u003e \u003cspan class=\"nv\"\u003eimage\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGImage\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ewithCheckedContinuation\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003econtinuation\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n        \u003cspan class=\"c1\"\u003e// ANE と高性能コア（P-Cores）をフル動員させるため userInteractive を明示\u003c/span\u003e\n        \u003cspan class=\"kt\"\u003eDispatchQueue\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eglobal\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eqos\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003euserInteractive\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ehandler\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eVNImageRequestHandler\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ecgImage\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eimage\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eoptions\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[:])\u003c/span\u003e\n            \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erequest\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecreateFastTextRequest\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003elines\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n                \u003cspan class=\"n\"\u003econtinuation\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eresume\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ereturning\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elines\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n            \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n            \u003cspan class=\"k\"\u003edo\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n                \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"n\"\u003ehandler\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eperform\u003c/span\u003e\u003cspan class=\"p\"\u003e([\u003c/span\u003e\u003cspan class=\"n\"\u003erequest\u003c/span\u003e\u003cspan class=\"p\"\u003e])\u003c/span\u003e\n            \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003ecatch\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n                \u003cspan class=\"n\"\u003econtinuation\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eresume\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ereturning\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[])\u003c/span\u003e\n            \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"189:1-189:190\"\u003eこれにより、macOS のカーネルスケジューラはタスクを最優先キューに配置し、ANE とユニファイドメモリバスの帯域を最大限に割り当てます。\u003c/p\u003e\n\u003chr data-sourcepos=\"191:1-192:0\"\u003e\n\u003ch2 data-sourcepos=\"193:1-193:75\"\u003e\n\u003cspan id=\"6-実機ベンチマーク測定結果instruments--time-profiler\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#6-%E5%AE%9F%E6%A9%9F%E3%83%99%E3%83%B3%E3%83%81%E3%83%9E%E3%83%BC%E3%82%AF%E6%B8%AC%E5%AE%9A%E7%B5%90%E6%9E%9Cinstruments--time-profiler\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e6. 実機ベンチマーク測定結果（Instruments / Time Profiler）\u003c/h2\u003e\n\u003cp data-sourcepos=\"195:1-196:143\"\u003eXcode の Instruments（Time Profiler および \u003ccode\u003eos_signpost\u003c/code\u003e）を用いて、M1 / M2 / M3 の各実機環境でレイテンシを計測しました。\u003cbr\u003e\n（測定条件：英語および日本語混在の技術ドキュメント 150 単語、選択領域 450×200 px、100回試行の平均値）\u003c/p\u003e\n\u003ctable data-sourcepos=\"198:1-202:78\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"198:1-198:150\"\u003e\n\u003cth data-sourcepos=\"198:2-198:27\"\u003e機種 / プロセッサ\u003c/th\u003e\n\u003cth data-sourcepos=\"198:29-198:43\"\u003eANE コア数\u003c/th\u003e\n\u003cth data-sourcepos=\"198:45-198:76\"\u003eキャプチャ (CoreGraphics)\u003c/th\u003e\n\u003cth data-sourcepos=\"198:78-198:104\"\u003eOCR 推論 (Vision + ANE)\u003c/th\u003e\n\u003cth data-sourcepos=\"198:106-198:128\"\u003eテキストソート\u003c/th\u003e\n\u003cth data-sourcepos=\"198:130-198:149\"\u003e合計処理遅延\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"200:1-200:75\"\u003e\n\u003ctd data-sourcepos=\"200:2-200:21\"\u003e\u003cstrong\u003eM1 MacBook Air\u003c/strong\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"200:23-200:31\"\u003e16-core\u003c/td\u003e\n\u003ctd data-sourcepos=\"200:33-200:41\"\u003e12.8 ms\u003c/td\u003e\n\u003ctd data-sourcepos=\"200:43-200:51\"\u003e68.4 ms\u003c/td\u003e\n\u003ctd data-sourcepos=\"200:53-200:60\"\u003e0.8 ms\u003c/td\u003e\n\u003ctd data-sourcepos=\"200:62-200:74\"\u003e\u003cstrong\u003e82.0 ms\u003c/strong\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"201:1-201:72\"\u003e\n\u003ctd data-sourcepos=\"201:2-201:18\"\u003e\u003cstrong\u003eM2 Mac mini\u003c/strong\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"201:20-201:28\"\u003e16-core\u003c/td\u003e\n\u003ctd data-sourcepos=\"201:30-201:38\"\u003e10.4 ms\u003c/td\u003e\n\u003ctd data-sourcepos=\"201:40-201:48\"\u003e56.2 ms\u003c/td\u003e\n\u003ctd data-sourcepos=\"201:50-201:57\"\u003e0.6 ms\u003c/td\u003e\n\u003ctd data-sourcepos=\"201:59-201:71\"\u003e\u003cstrong\u003e67.2 ms\u003c/strong\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"202:1-202:78\"\u003e\n\u003ctd data-sourcepos=\"202:2-202:25\"\u003e\u003cstrong\u003eM3 Max MacBook Pro\u003c/strong\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"202:27-202:35\"\u003e16-core\u003c/td\u003e\n\u003ctd data-sourcepos=\"202:37-202:44\"\u003e7.9 ms\u003c/td\u003e\n\u003ctd data-sourcepos=\"202:46-202:54\"\u003e42.1 ms\u003c/td\u003e\n\u003ctd data-sourcepos=\"202:56-202:63\"\u003e0.4 ms\u003c/td\u003e\n\u003ctd data-sourcepos=\"202:65-202:77\"\u003e\u003cstrong\u003e50.4 ms\u003c/strong\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"204:1-204:293\"\u003eいずれのチップセットにおいても、\u003cstrong\u003e目標であった「0.1秒（100ms）未満」を余裕を持ってクリア\u003c/strong\u003eし、最速環境では約 50ms（1/20秒）という、人間のまばたき（100〜150ms）よりも速い速度での文字認識を実現できています。\u003c/p\u003e\n\u003chr data-sourcepos=\"206:1-207:0\"\u003e\n\u003ch2 data-sourcepos=\"208:1-208:15\"\u003e\n\u003cspan id=\"7-まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#7-%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e7. まとめ\u003c/h2\u003e\n\u003cp data-sourcepos=\"210:1-210:219\"\u003eApple Silicon の真価は、単なる CPU や GPU のクロック性能だけでなく、\u003cstrong\u003eNeural Engine (ANE) とユニファイドメモリアーキテクチャが緊密に統合されている点\u003c/strong\u003eにあります。\u003c/p\u003e\n\u003col data-sourcepos=\"212:1-216:0\"\u003e\n\u003cli data-sourcepos=\"212:1-212:112\"\u003e\n\u003cstrong\u003eゼロコピー\u003c/strong\u003e：メモリ上の \u003ccode\u003eCGImage\u003c/code\u003e ポインタを直接 \u003ccode\u003eVNImageRequestHandler\u003c/code\u003e に流し込む\u003c/li\u003e\n\u003cli data-sourcepos=\"213:1-213:131\"\u003e\n\u003cstrong\u003e言語プルーニング\u003c/strong\u003e：探索言語を必要最小限（\u003ccode\u003eja-JP\u003c/code\u003e, \u003ccode\u003een-US\u003c/code\u003e）に絞り込んでデコード負荷を削減\u003c/li\u003e\n\u003cli data-sourcepos=\"214:1-214:103\"\u003e\n\u003cstrong\u003eROI 駆動\u003c/strong\u003e：必要な矩形領域のみを切り出して ANE テンソルサイズを最小化\u003c/li\u003e\n\u003cli data-sourcepos=\"215:1-216:0\"\u003e\n\u003cstrong\u003eQoS 最適化\u003c/strong\u003e：\u003ccode\u003e.userInteractive\u003c/code\u003e でスケジューラの最優先キューと推論帯域を確保\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"217:1-217:234\"\u003eこの 4 つの原則を徹底することで、外部クラウドや重厚なライブラリに依存せずとも、macOS 原生の Swift だけで驚異的な超低遅延 OCR パイプラインを構築することが可能です。\u003c/p\u003e\n\u003cp data-sourcepos=\"219:1-219:114\"\u003eデスクトップアプリや常駐ユーティリティを自作される方の参考になれば幸いです。\u003c/p\u003e\n","body":"# Apple Silicon Neural Engine で OCR 認識を 0.1秒未満にする Swift 実装の勘所\n\n## はじめに：なぜ「0.1秒未満（100ms）」の壁を超える必要があるのか\n\nデスクトップにおける OCR（光学文字認識）は、長らく「待たされるツール」でした。\n画面上の選択できないエラーメッセージや PDF 論文の文字列をキャプチャして認識させる際、多くのツールは以下のいずれかのアプローチを取っています。\n\n1. **クラウド OCR API 経由**：\n   スクショ画像を外部サーバー（Google Cloud Vision, AWS Textract, Azure 等）に送信して解析。ネットワーク往復（RTT）とサーバー推論で **1,000ms〜2,500ms** を要する。\n2. **オープンソース OCR ライブラリ（Tesseract 等）の CPU 実行**：\n   ローカルで完結するものの、CPU バウンドな画像前処理とビームサーチ探索により **400ms〜800ms** かかる。\n\n認知心理学や HCI（ヒューマンコンピュータインタラクション）の研究において、**100ms（0.1秒）未満のシステム応答は「即時（Instant）」として知覚**され、人間の思考のコンテキストスイッチ（フローの途絶）が発生しないとされています。\n\nmacOS 開発において、Apple Silicon（M1〜M4）に標準搭載されている **Apple Neural Engine (ANE)** と Vision フレームワークを限界まで最適化して駆動させることで、**OCR 認識パイプライン全体を 60〜85ms（0.1秒未満）で完了させる**ことが可能です。\n\n本稿では、Swift を用いて Apple Silicon の Neural Engine をフル稼働させ、OCR 認識を 0.1 秒未満に押し込むための 4 つの実装勘所とベンチマークを解説します。\n\n---\n\n## 1. Apple Vision と Neural Engine (ANE) の推論パイプライン\n\nmacOS の Vision フレームワーク（`VNRecognizeTextRequest`）は、内部で Core ML をベースとした深層学習モデル（CNN + Transformer 構造）を動作させています。\n\nApple Silicon アーキテクチャでは、推論エンジンは以下の 3 つのプロセッサから自動的にディスパッチされます。\n\n* **CPU**：高いクロックと低レイテンシだが、並列テンソル演算の電力効率とスループットが低い。\n* **GPU**：大規模な浮動小数点並列演算に強いが、グラフィックス描画パイプラインとの競合やコンテキストスイッチのオーバーヘッドがある。\n* **Neural Engine (ANE)**：行列積和演算（GEMM）に特化した専用コプロセッサ。専用 SRAM を備え、ユニファイドメモリとの間で最小の電力・極小の遅延で推論を実行する。\n\n```\n[ CGImage / CVPixelBuffer (RAM) ]\n               │\n               ▼  Zero-Copy (IOSurface 参照渡し)\n[ VNImageRequestHandler ]\n               │\n               ▼  ANE 最適化モデルへのディスパッチ (約 65ms)\n┌──────────────────────────────────────────────┐\n│  Apple Neural Engine (16-core ANE)           │\n│  ├─ 特徴抽出 (CNN Backbone)                  │\n│  ├─ 文字領域検出 (Text Line Detection)       │\n│  └─ 系列予測 (Transformer Decoder)           │\n└──────────────────────────────────────────────┘\n               │\n               ▼  言語モデルによるビームサーチ (約 10ms)\n[ VNRecognizedTextObservation (正規化座標 + 文字列) ]\n```\n\n通常、Vision は推論先プロセッサを自動選択しますが、パラメータやデータ受け渡しの設計を誤ると、処理が **CPU にフォールバック**してしまい、一気に 300〜500ms に遅延が悪化します。\n\n---\n\n## 2. 勘所①：ピクセルバッファ変換のゼロコピー化（Zero-Copy Pipeline）\n\nOCR 処理で最も見落とされがちなボトルネックが、**画像の前処理とメモリコピー**です。\n\n多くの実装では、画面キャプチャ結果を一度 PNG や JPEG 形式でシリアライズしたり、不要な `NSImage` / `CIImage` 変換を多段で行っています。これだけで 30〜50ms の CPU 時間を浪費します。\n\n### ゼロコピーを実現する CoreGraphics キャプチャ\n\n```swift\nimport Cocoa\nimport CoreGraphics\nimport Vision\n\npublic final class InstantOCRService {\n    public static let shared = InstantOCRService()\n\n    /// 指定されたスクリーン矩形からゼロコピーで CGImage を切り出し\n    public func captureRegion(screenRect: CGRect, windowID: CGWindowID = 0) -\u003e CGImage? {\n        guard let mainScreen = NSScreen.screens.first else { return nil }\n        \n        // AppKit（左下原点）から CoreGraphics（左上原点）への座標変換\n        let screenHeight = mainScreen.frame.height\n        let captureRect = CGRect(\n            x: screenRect.origin.x,\n            y: screenHeight - screenRect.origin.y - screenRect.height,\n            width: screenRect.width,\n            height: screenRect.height\n        )\n\n        // ディスク I/O や画像圧縮を介さず、RAM 上のメモリマップから直接 CGImage を生成\n        let options: CGWindowImageOption = [.bestResolution, .boundsIgnoreFraming]\n        return CGWindowListCreateImage(\n            captureRect,\n            .optionOnScreenBelowWindow,\n            windowID,\n            options\n        )\n    }\n}\n```\n\n`CGWindowListCreateImage` に `.bestResolution` を指定することで、Retina ディスプレイの高密度ピクセル（2x）をそのまま保持したポインタが返ります。この `CGImage` を `VNImageRequestHandler` に直接渡すことで、**メモリ確保とコピーのオーバーヘッドをほぼ 0ms** に抑えることができます。\n\n---\n\n## 3. 勘所②：探索空間の刈り込み（言語プルーニング）\n\n`VNRecognizeTextRequest` の遅延を大きく左右するのが、デコーダステップにおける**言語モデル（Language Model）の探索空間サイズ**です。\n\n言語指定を怠ったり、過剰に多言語を指定すると、Neural Engine の推論後に走るビームサーチ探索において、漢字、ひらがな、カタカナ、キリル文字、アクセント付きラテン文字などの広大な語彙辞書をスキャンするため、CPU 時間が激増します。\n\n### 最速化のためのリクエスト構成\n\n```swift\npublic func createFastTextRequest(completion: @escaping ([String]) -\u003e Void) -\u003e VNRecognizeTextRequest {\n    let request = VNRecognizeTextRequest { req, error in\n        guard error == nil, let observations = req.results as? [VNRecognizedTextObservation] else {\n            completion([])\n            return\n        }\n        \n        // 空間ソート（行ごとのクラスタリング）\n        let sorted = observations.sorted { a, b in\n            if abs(a.boundingBox.midY - b.boundingBox.midY) \u003e 0.025 {\n                return a.boundingBox.midY \u003e b.boundingBox.midY\n            }\n            return a.boundingBox.minX \u003c b.boundingBox.minX\n        }\n\n        let lines = sorted.compactMap { $0.topCandidates(1).first?.string }\n        completion(lines)\n    }\n\n    // ANE アクセラレーションが効く高精度モデルを指定\n    request.recognitionLevel = .accurate\n    request.usesLanguageCorrection = true\n\n    // 対象言語を実務で必要な ja-JP と en-US の2つに限定して探索空間を刈り込む\n    request.recognitionLanguages = [\"ja-JP\", \"en-US\"]\n    \n    if #available(macOS 13.0, *) {\n        request.automaticallyDetectsLanguage = false // 自動判別をオフにしてオーバーヘッドを排除\n    }\n\n    return request\n}\n```\n\n### なぜ `.fast` ではなく `.accurate` なのか？\n直感的には `.fast` のほうが速そうに見えますが、テスト環境でのプロファイリングによると、**Apple Silicon 環境下では `.accurate` のほうが Neural Engine (ANE) に最適化された重みテンソルが割り当てられる**ため、計算時間差はわずか数ミリ秒にとどまり、文字認識精度（特に日本語の漢字や記号）は圧倒的に向上します。\n\n---\n\n## 4. 勘所③：ROI（Region of Interest）による推論テンソル縮小\n\n4K や 5K の外部ディスプレイ環境において、画面全体（3840×2160 や 5120×2880）を Vision に渡すと、リサイズとタイル分割（Tiling）の前処理だけで 150ms 近く消費してしまいます。\n\nNeural Engine のスループットを最大化するには、**ユーザーが必要としている領域（ROI）のみをピクセル単位で切り抜いて渡す**ことが絶対原則です。\n\n| 入力解像度 | ピクセル数 | 前処理 + ANE 推論遅延 |\n|---|---|---|\n| 全画面 4K (3840×2160) | 約 8,290,000 px | 165.2 ms |\n| 全画面 FHD (1920×1080) | 約 2,070,000 px | 94.8 ms |\n| ウィンドウ領域 (800×600) | 約 480,000 px | 62.1 ms |\n| **矩形選択 ROI (400×150)** | **約 60,000 px** | **38.4 ms** |\n\n矩形選択によって推論テンソルサイズを小さく保つことで、Neural Engine の推論遅延は **40ms 前後まで劇的に短縮**されます。\n\n---\n\n## 5. 勘所④：Swift Concurrency と QoS（Quality of Service）の選定\n\n推論処理をバックグラウンドスレッドに逃がす際、QoS の設定を誤ると、macOS のスケジューラ（Grand Central Dispatch）によって低電力コア（Efficiency Cores）に割り振られ、Neural Engine への投入優先度が下げられてしまいます。\n\n即座に画面へ結果をフィードバックする必要がある対話型ツールでは、明示的に `.userInteractive` を指定します。\n\n```swift\npublic func performInstantOCR(on image: CGImage) async -\u003e [String] {\n    return await withCheckedContinuation { continuation in\n        // ANE と高性能コア（P-Cores）をフル動員させるため userInteractive を明示\n        DispatchQueue.global(qos: .userInteractive).async {\n            let handler = VNImageRequestHandler(cgImage: image, options: [:])\n            let request = self.createFastTextRequest { lines in\n                continuation.resume(returning: lines)\n            }\n\n            do {\n                try handler.perform([request])\n            } catch {\n                continuation.resume(returning: [])\n            }\n        }\n    }\n}\n```\n\nこれにより、macOS のカーネルスケジューラはタスクを最優先キューに配置し、ANE とユニファイドメモリバスの帯域を最大限に割り当てます。\n\n---\n\n## 6. 実機ベンチマーク測定結果（Instruments / Time Profiler）\n\nXcode の Instruments（Time Profiler および `os_signpost`）を用いて、M1 / M2 / M3 の各実機環境でレイテンシを計測しました。\n（測定条件：英語および日本語混在の技術ドキュメント 150 単語、選択領域 450×200 px、100回試行の平均値）\n\n| 機種 / プロセッサ | ANE コア数 | キャプチャ (CoreGraphics) | OCR 推論 (Vision + ANE) | テキストソート | 合計処理遅延 |\n|---|---|---|---|---|---|\n| **M1 MacBook Air** | 16-core | 12.8 ms | 68.4 ms | 0.8 ms | **82.0 ms** |\n| **M2 Mac mini** | 16-core | 10.4 ms | 56.2 ms | 0.6 ms | **67.2 ms** |\n| **M3 Max MacBook Pro** | 16-core | 7.9 ms | 42.1 ms | 0.4 ms | **50.4 ms** |\n\nいずれのチップセットにおいても、**目標であった「0.1秒（100ms）未満」を余裕を持ってクリア**し、最速環境では約 50ms（1/20秒）という、人間のまばたき（100〜150ms）よりも速い速度での文字認識を実現できています。\n\n---\n\n## 7. まとめ\n\nApple Silicon の真価は、単なる CPU や GPU のクロック性能だけでなく、**Neural Engine (ANE) とユニファイドメモリアーキテクチャが緊密に統合されている点**にあります。\n\n1. **ゼロコピー**：メモリ上の `CGImage` ポインタを直接 `VNImageRequestHandler` に流し込む\n2. **言語プルーニング**：探索言語を必要最小限（`ja-JP`, `en-US`）に絞り込んでデコード負荷を削減\n3. **ROI 駆動**：必要な矩形領域のみを切り出して ANE テンソルサイズを最小化\n4. **QoS 最適化**：`.userInteractive` でスケジューラの最優先キューと推論帯域を確保\n\nこの 4 つの原則を徹底することで、外部クラウドや重厚なライブラリに依存せずとも、macOS 原生の Swift だけで驚異的な超低遅延 OCR パイプラインを構築することが可能です。\n\nデスクトップアプリや常駐ユーティリティを自作される方の参考になれば幸いです。\n","coediting":false,"comments_count":0,"created_at":"2026-11-07T13:21:37+09:00","group":null,"id":"31dcbfeef16784848c12","likes_count":0,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Swift","versions":[]},{"name":"macOS","versions":[]},{"name":"OCR","versions":[]},{"name":"Apple","versions":[]}],"title":"Apple Silicon Neural Engine で OCR 認識を 0.1秒未満にする Swift 実装の勘所","updated_at":"2026-11-07T13:21:37+09:00","url":"https://qiita.com/dayu510/items/31dcbfeef16784848c12","user":{"description":null,"facebook_id":null,"followees_count":2,"followers_count":0,"github_login_name":null,"id":"dayu510","items_count":7,"linkedin_id":null,"location":null,"name":"","organization":null,"permanent_id":4525486,"profile_image_url":"https://lh3.googleusercontent.com/a/ACg8ocJ4sMyNmnTxJpk5-hdzNB6i7dhhQEWKazGkBc6GcAkNHxrdBA=s96-c","team_only":false,"twitter_screen_name":null,"website_url":null},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch2 data-sourcepos=\"1:1-1:72\"\u003e\n\u003cspan id=\"モバイルアプリのデータ永続化で何が課題になるか\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%A2%E3%83%90%E3%82%A4%E3%83%AB%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E6%B0%B8%E7%B6%9A%E5%8C%96%E3%81%A7%E4%BD%95%E3%81%8C%E8%AA%B2%E9%A1%8C%E3%81%AB%E3%81%AA%E3%82%8B%E3%81%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eモバイルアプリのデータ永続化で何が課題になるか\u003c/h2\u003e\n\u003cp data-sourcepos=\"3:1-3:615\"\u003eモバイルアプリ開発では、オフライン時にもデータを保持し、オンライン復帰時に整合性を保ちながら動作させる仕組みが必要になります。加えて、UIの状態とデータベースの内容を同期させるための独自のコードが増えがちで、これが保守コストや不具合の原因になることがあります。Realm Swiftの公式READMEでは、こうした課題に対して、オブジェクト指向のデータモデルと、UIに変更を自動反映する仕組みを提供するデータベースとして紹介されています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"5:1-5:45\"\u003e\n\u003cspan id=\"readmeに記載されている関連機能\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#readme%E3%81%AB%E8%A8%98%E8%BC%89%E3%81%95%E3%82%8C%E3%81%A6%E3%81%84%E3%82%8B%E9%96%A2%E9%80%A3%E6%A9%9F%E8%83%BD\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eREADMEに記載されている関連機能\u003c/h2\u003e\n\u003cp data-sourcepos=\"7:1-7:365\"\u003e公式READMEによると、Realm SwiftはiOS、macOS、tvOS、watchOS向けのモバイルデータベースで、Swiftの通常のクラス定義をそのままデータモデルとして扱える点が特徴とされています。ORMを介さずにオブジェクトをデータベースに書き込めるため、記述量を減らせるとされています。\u003c/p\u003e\n\u003cp data-sourcepos=\"9:1-9:490\"\u003eまた、データベース上のオブジェクトを観察することで、値が変更された際に通知を受け取れる仕組みが紹介されており、これにより別の場所で更新されたデータを自動的に反映する構成を組みやすいとされています。SwiftUIとの統合も記載されており、専用のプロパティラッパーを使うことで、データベースの内容をビューに直接バインドできるとされています。\u003c/p\u003e\n\u003cp data-sourcepos=\"11:1-11:325\"\u003eさらに、暗号化キーを設定に渡すことで、保存データを暗号化した状態で利用できる機能もREADMEに記載されています。導入方法としては、Swift Package Manager、CocoaPods、Carthage、あるいはXCFrameworkの直接インポートに対応しているとされています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"13:1-13:63\"\u003e\n\u003cspan id=\"どのような場面で検討する価値がありそうか\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%A9%E3%81%AE%E3%82%88%E3%81%86%E3%81%AA%E5%A0%B4%E9%9D%A2%E3%81%A7%E6%A4%9C%E8%A8%8E%E3%81%99%E3%82%8B%E4%BE%A1%E5%80%A4%E3%81%8C%E3%81%82%E3%82%8A%E3%81%9D%E3%81%86%E3%81%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eどのような場面で検討する価値がありそうか\u003c/h2\u003e\n\u003cp data-sourcepos=\"15:1-15:663\"\u003eオフライン対応が必須で、かつUIとデータの同期処理を自前で書くコストが大きいと感じている場合、READMEに書かれているライブオブジェクトの仕組みは検討材料になり得ます。特にSwiftUIをベースにしたアプリで、一覧表示や詳細画面にデータベースの値を直接反映させたい場合には、専用の統合が用意されている点が選定理由になりやすいと考えられます。また、端末内に保存するデータに機密性が求められる場合、暗号化オプションの有無を確認する際の候補としても挙げられそうです。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"17:1-17:42\"\u003e\n\u003cspan id=\"readmeだけでは判断できない点\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#readme%E3%81%A0%E3%81%91%E3%81%A7%E3%81%AF%E5%88%A4%E6%96%AD%E3%81%A7%E3%81%8D%E3%81%AA%E3%81%84%E7%82%B9\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eREADMEだけでは判断できない点\u003c/h2\u003e\n\u003cp data-sourcepos=\"19:1-19:676\"\u003e一方で、READMEの記載だけでは、既存のCore DataやSQLiteベースの構成からの移行コストや、大規模データセットでの書き込み・読み取りの挙動、マルチプロセスやApp Extensionとの共存時の制約などは判断できません。これらは実際のプロジェクト構成やデータ量に強く依存するため、別途検証が必要になると考えられます。また、READMEにはビルド手順や依存関係に関する簡単な説明はあるものの、バージョンごとの互換性や移行時の注意点については別のドキュメントを確認する必要があるとされています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"21:1-21:9\"\u003e\n\u003cspan id=\"出典\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%87%BA%E5%85%B8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e出典\u003c/h2\u003e\n\u003cp data-sourcepos=\"23:1-24:61\"\u003e本記事は以下の公式READMEの内容に基づいています。\u003cbr\u003e\n\u003ca href=\"https://github.com/realm/realm-swift/blob/community/README.md\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://github.com/realm/realm-swift/blob/community/README.md\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"26:1-26:125\"\u003e公式READMEをもとに、AIを執筆・推敲支援に使用しています。実機検証の報告ではありません。\u003c/p\u003e\n","body":"## モバイルアプリのデータ永続化で何が課題になるか\n\nモバイルアプリ開発では、オフライン時にもデータを保持し、オンライン復帰時に整合性を保ちながら動作させる仕組みが必要になります。加えて、UIの状態とデータベースの内容を同期させるための独自のコードが増えがちで、これが保守コストや不具合の原因になることがあります。Realm Swiftの公式READMEでは、こうした課題に対して、オブジェクト指向のデータモデルと、UIに変更を自動反映する仕組みを提供するデータベースとして紹介されています。\n\n## READMEに記載されている関連機能\n\n公式READMEによると、Realm SwiftはiOS、macOS、tvOS、watchOS向けのモバイルデータベースで、Swiftの通常のクラス定義をそのままデータモデルとして扱える点が特徴とされています。ORMを介さずにオブジェクトをデータベースに書き込めるため、記述量を減らせるとされています。\n\nまた、データベース上のオブジェクトを観察することで、値が変更された際に通知を受け取れる仕組みが紹介されており、これにより別の場所で更新されたデータを自動的に反映する構成を組みやすいとされています。SwiftUIとの統合も記載されており、専用のプロパティラッパーを使うことで、データベースの内容をビューに直接バインドできるとされています。\n\nさらに、暗号化キーを設定に渡すことで、保存データを暗号化した状態で利用できる機能もREADMEに記載されています。導入方法としては、Swift Package Manager、CocoaPods、Carthage、あるいはXCFrameworkの直接インポートに対応しているとされています。\n\n## どのような場面で検討する価値がありそうか\n\nオフライン対応が必須で、かつUIとデータの同期処理を自前で書くコストが大きいと感じている場合、READMEに書かれているライブオブジェクトの仕組みは検討材料になり得ます。特にSwiftUIをベースにしたアプリで、一覧表示や詳細画面にデータベースの値を直接反映させたい場合には、専用の統合が用意されている点が選定理由になりやすいと考えられます。また、端末内に保存するデータに機密性が求められる場合、暗号化オプションの有無を確認する際の候補としても挙げられそうです。\n\n## READMEだけでは判断できない点\n\n一方で、READMEの記載だけでは、既存のCore DataやSQLiteベースの構成からの移行コストや、大規模データセットでの書き込み・読み取りの挙動、マルチプロセスやApp Extensionとの共存時の制約などは判断できません。これらは実際のプロジェクト構成やデータ量に強く依存するため、別途検証が必要になると考えられます。また、READMEにはビルド手順や依存関係に関する簡単な説明はあるものの、バージョンごとの互換性や移行時の注意点については別のドキュメントを確認する必要があるとされています。\n\n## 出典\n\n本記事は以下の公式READMEの内容に基づいています。\nhttps://github.com/realm/realm-swift/blob/community/README.md\n\n公式READMEをもとに、AIを執筆・推敲支援に使用しています。実機検証の報告ではありません。\n","coediting":false,"comments_count":0,"created_at":"2026-11-02T13:00:55+09:00","group":null,"id":"cbd468edbd3e745c26d3","likes_count":0,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Realm","versions":[]},{"name":"Swift","versions":[]},{"name":"iOS","versions":[]},{"name":"SwiftUI","versions":[]}],"title":"Realm Swiftのライブオブジェクトとオブジェクト指向モデルを導入前に検討する観点","updated_at":"2026-11-02T13:00:55+09:00","url":"https://qiita.com/gitmeshi/items/cbd468edbd3e745c26d3","user":{"description":"GitHubで伸びているOSSを、毎朝ひとつずつ日本語で紹介しています。\r\n一日一膳、GitHub。オープンソースの潮流を、好みの深さで。\r\n甘口・中辛・辛口の三段階で、AIが要約します。毎朝7時更新。","facebook_id":"","followees_count":50,"followers_count":3,"github_login_name":null,"id":"gitmeshi","items_count":36,"linkedin_id":"","location":"","name":"ギット飯","organization":"COLDBYTE","permanent_id":4485911,"profile_image_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/4485911/dd015d1fecc308f6d807e2702b9287a6a8a1b2d6/x_large.png?1787746575","team_only":false,"twitter_screen_name":null,"website_url":"https://www.gitbap.com/ja"},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003cp data-sourcepos=\"1:1-1:140\"\u003e\u003cem\u003eこの記事はChatGPT-6 Atlasにより作成されました。記事をまるっとLLMに渡すと実装してくれると思います。\u003c/em\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"3:1-3:177\"\u003ePreviewでPDFを開いているとき、あるいはMicrosoft Wordで文書を開いているとき、そのファイルの絶対パスが欲しくなることがあります。\u003c/p\u003e\n\u003cp data-sourcepos=\"5:1-5:215\"\u003eこの記事では、前面アプリに開かれている文書のパスをメニューバーからコピーする小さなmacOSアプリを、SwiftとAppKitだけで作ります。対応対象は次の3つです。\u003c/p\u003e\n\u003cul data-sourcepos=\"7:1-10:0\"\u003e\n\u003cli data-sourcepos=\"7:1-7:24\"\u003ePreviewの前面文書\u003c/li\u003e\n\u003cli data-sourcepos=\"8:1-8:34\"\u003eMicrosoft Wordのactive document\u003c/li\u003e\n\u003cli data-sourcepos=\"9:1-10:0\"\u003eFinderで1件だけ選択している項目\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"11:1-11:182\"\u003e外部ライブラリ、Xcodeプロジェクト、配布ZIPは使いません。この記事に載せたファイルを作れば、そのままテストとビルドができます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"13:1-13:21\"\u003e\n\u003cspan id=\"先に重要な点\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%85%88%E3%81%AB%E9%87%8D%E8%A6%81%E3%81%AA%E7%82%B9\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e先に重要な点\u003c/h2\u003e\n\u003cp data-sourcepos=\"15:1-15:338\"\u003eこのアプリは対象アプリへApple Eventsを送るため、初回実行時にmacOSのオートメーション権限が必要になることがあります。必要なのは対象アプリへのオートメーション権限だけです。アクセシビリティ、画面収録、フルディスクアクセスは使いません。\u003c/p\u003e\n\u003cp data-sourcepos=\"17:1-17:75\"\u003eまた、この記事で実施した確認範囲は次のとおりです。\u003c/p\u003e\n\u003cul data-sourcepos=\"19:1-25:0\"\u003e\n\u003cli data-sourcepos=\"19:1-19:44\"\u003eコアロジックのテスト7件: 成功\u003c/li\u003e\n\u003cli data-sourcepos=\"20:1-20:38\"\u003eUniversal Binaryのビルド: 成功\u003c/li\u003e\n\u003cli data-sourcepos=\"21:1-21:22\"\u003ead-hoc署名: 成功\u003c/li\u003e\n\u003cli data-sourcepos=\"22:1-22:28\"\u003e\n\u003ccode\u003eInfo.plist\u003c/code\u003e検証: 成功\u003c/li\u003e\n\u003cli data-sourcepos=\"23:1-23:74\"\u003ePreview、Word、Finder向けAppleScriptの構文コンパイル: 成功\u003c/li\u003e\n\u003cli data-sourcepos=\"24:1-25:0\"\u003e実際にアプリを起動し、PreviewやWordへApple Eventsを送る動作確認: 未実施\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"26:1-26:222\"\u003e最後の項目は、検証中に予期しないオートメーション権限ダイアログを出さないためです。したがって、この記事では実アプリ連携まで実証済みとは主張しません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"28:1-28:12\"\u003e\n\u003cspan id=\"仕組み\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BB%95%E7%B5%84%E3%81%BF\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e仕組み\u003c/h2\u003e\n\u003cp data-sourcepos=\"30:1-30:30\"\u003e処理は次の順番です。\u003c/p\u003e\n\u003col data-sourcepos=\"32:1-36:0\"\u003e\n\u003cli data-sourcepos=\"32:1-32:149\"\u003eメニューバーアイコンが左クリックされた時点で、\u003ccode\u003eNSWorkspace.shared.frontmostApplication\u003c/code\u003eから前面アプリを取得する\u003c/li\u003e\n\u003cli data-sourcepos=\"33:1-33:80\"\u003eアプリのBundle IDに応じたAppleScriptを\u003ccode\u003eNSAppleScript\u003c/code\u003eで実行する\u003c/li\u003e\n\u003cli data-sourcepos=\"34:1-34:78\"\u003e対象アプリが返した値をローカル絶対パスへ正規化する\u003c/li\u003e\n\u003cli data-sourcepos=\"35:1-36:0\"\u003e成功した場合だけクリップボードへ書き込む\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"37:1-37:380\"\u003eメニューバーアイコンをクリックしてからメニュー項目を選ぶ方式にすると、その間に自分のアプリが前面扱いになったり、対象文書が変わったりする余地が増えます。この実装では左クリックを直接コピー操作に割り当て、UIのフィードバックを出す前に前面アプリを確保します。\u003c/p\u003e\n\u003cp data-sourcepos=\"39:1-39:252\"\u003eパスの取得にウィンドウタイトルは使いません。同名ファイル、未保存文書、クラウド文書などを区別できず、誤ったパスを作る危険があるからです。ディスク全体の検索も行いません。\u003c/p\u003e\n\u003cp data-sourcepos=\"41:1-41:54\"\u003eアダプタが使うAppleScriptは次の3つです。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"applescript\" data-sourcepos=\"43:1-52:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e-- Preview\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003cspan class=\"k\"\u003etell\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003eapplication\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s2\"\u003e\"com.apple.Preview\"\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eto\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003ereturn\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"na\"\u003epath\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eof\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003efront\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003edocument\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eas \u003c/span\u003e\u003cspan class=\"nc\"\u003etext\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\n\u003c/span\u003e\u003cspan class=\"c1\"\u003e-- Microsoft Word\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003cspan class=\"k\"\u003etell\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003eapplication\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s2\"\u003e\"com.microsoft.Word\"\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eto\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003ereturn\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nv\"\u003efull\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003ename\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eof\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003eactive\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003edocument\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eas \u003c/span\u003e\u003cspan class=\"nc\"\u003etext\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\n\u003c/span\u003e\u003cspan class=\"c1\"\u003e-- Finder\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003cspan class=\"k\"\u003etell\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003eapplication\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s2\"\u003e\"com.apple.finder\"\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eto\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nb\"\u003ecount\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eof\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nv\"\u003eselection\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"ow\"\u003eis\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003ethen\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003ereturn\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nb\"\u003eitem\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eof\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nv\"\u003eselection\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eas \u003c/span\u003e\u003cspan class=\"nc\"\u003ealias \u003c/span\u003e\u003cspan class=\"k\"\u003eas \u003c/span\u003e\u003cspan class=\"nc\"\u003etext\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"54:1-54:409\"\u003eローカルのMicrosoft Wordのスクリプティング辞書では、documentの\u003ccode\u003efull name\u003c/code\u003eはHFS形式のフルパスとして定義されています。その一方で、クラウド文書ではWeb URLが返る可能性もあります。そこで、POSIXパス、\u003ccode\u003efile:\u003c/code\u003e URL、HFS形式を明示的に扱い、\u003ccode\u003ehttps:\u003c/code\u003eなどのリモートURLはローカルパスに見せかけずエラーにします。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"56:1-56:18\"\u003e\n\u003cspan id=\"必要な環境\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%BF%85%E8%A6%81%E3%81%AA%E7%92%B0%E5%A2%83\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e必要な環境\u003c/h2\u003e\n\u003cul data-sourcepos=\"58:1-61:0\"\u003e\n\u003cli data-sourcepos=\"58:1-58:24\"\u003emacOS 13 Ventura以降\u003c/li\u003e\n\u003cli data-sourcepos=\"59:1-59:75\"\u003eXcode本体、または対応するSwiftツールチェーンとmacOS SDK\u003c/li\u003e\n\u003cli data-sourcepos=\"60:1-61:0\"\u003eMicrosoft Word対応を使う場合はMicrosoft Word\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"62:1-62:42\"\u003e外部パッケージはありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"64:1-64:54\"\u003eこの記事の検証環境は次のとおりです。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"66:1-71:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003emacOS 27.0.1 (Build 26A434)\nXcode 27.2 (Build 27B52)\nApple Swift 6.4.2\narm64 Mac\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"73:1-73:87\"\u003eビルドスクリプトはarm64とx86_64を含むUniversal Binaryを生成します。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"75:1-75:27\"\u003e\n\u003cspan id=\"プロジェクト構成\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E6%A7%8B%E6%88%90\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eプロジェクト構成\u003c/h2\u003e\n\u003cp data-sourcepos=\"77:1-77:103\"\u003e任意の作業場所で、次の構成を作ります。以下では\u003ccode\u003e~/OpenFilePath\u003c/code\u003eを使います。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"79:1-93:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eOpenFilePath/\n├── App/\n│   └── Info.plist\n├── Sources/\n│   ├── OpenFilePathApp/\n│   │   └── main.swift\n│   └── OpenFilePathCore/\n│       └── PathResolver.swift\n├── Tests/\n│   └── OpenFilePathCoreTests/\n│       └── PathResolverTests.swift\n├── Package.swift\n└── build.sh\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"95:1-95:45\"\u003e最初にディレクトリを作ります。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"97:1-100:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"nb\"\u003emkdir\u003c/span\u003e \u003cspan class=\"nt\"\u003e-p\u003c/span\u003e ~/OpenFilePath/\u003cspan class=\"o\"\u003e{\u003c/span\u003eApp,Sources/OpenFilePathApp,Sources/OpenFilePathCore,Tests/OpenFilePathCoreTests\u003cspan class=\"o\"\u003e}\u003c/span\u003e\n\u003cspan class=\"nb\"\u003ecd\u003c/span\u003e ~/OpenFilePath\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"102:1-102:19\"\u003e\n\u003cspan id=\"1-packageswift\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#1-packageswift\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e1. Package.swift\u003c/h2\u003e\n\u003cp data-sourcepos=\"104:1-104:36\"\u003e\u003ccode\u003ePackage.swift\u003c/code\u003eを作成します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"106:1-123:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e// swift-tools-version: 6.0\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003ePackageDescription\u003c/span\u003e\n\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003epackage\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003ePackage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"OpenFilePath\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003eplatforms\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003emacOS\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ev13\u003c/span\u003e\u003cspan class=\"p\"\u003e)],\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003eproducts\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n        \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003elibrary\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"OpenFilePathCore\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003etargets\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s\"\u003e\"OpenFilePathCore\"\u003c/span\u003e\u003cspan class=\"p\"\u003e]),\u003c/span\u003e\n        \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eexecutable\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"OpenFilePath\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003etargets\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s\"\u003e\"OpenFilePathApp\"\u003c/span\u003e\u003cspan class=\"p\"\u003e]),\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e],\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003etargets\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n        \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etarget\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"OpenFilePathCore\"\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n        \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eexecutableTarget\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"OpenFilePathApp\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003edependencies\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s\"\u003e\"OpenFilePathCore\"\u003c/span\u003e\u003cspan class=\"p\"\u003e]),\u003c/span\u003e\n        \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etestTarget\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"OpenFilePathCoreTests\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003edependencies\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s\"\u003e\"OpenFilePathCore\"\u003c/span\u003e\u003cspan class=\"p\"\u003e]),\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e]\u003c/span\u003e\n\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"125:1-125:241\"\u003eパス正規化をUIから分離した\u003ccode\u003eOpenFilePathCore\u003c/code\u003eと、AppKitアプリ本体の\u003ccode\u003eOpenFilePathApp\u003c/code\u003eに分けています。これにより、実際のクリップボードやApple Eventsを使わずコアだけをテストできます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"127:1-127:49\"\u003e\n\u003cspan id=\"2-sourcesopenfilepathcorepathresolverswift\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#2-sourcesopenfilepathcorepathresolverswift\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e2. Sources/OpenFilePathCore/PathResolver.swift\u003c/h2\u003e\n\u003cp data-sourcepos=\"129:1-129:66\"\u003e\u003ccode\u003eSources/OpenFilePathCore/PathResolver.swift\u003c/code\u003eを作成します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"131:1-203:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eFoundation\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003eenum\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eError\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eEquatable\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eLocalizedError\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"nf\"\u003eunsupportedApplication\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"n\"\u003enoDocument\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"n\"\u003eunsavedDocument\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"nf\"\u003eremoteURL\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eURL\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"nf\"\u003einvalidValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"n\"\u003eautomationDenied\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"nf\"\u003eapplicationError\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eerrorDescription\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eswitch\u003c/span\u003e \u003cspan class=\"k\"\u003eself\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eunsupportedApplication\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003ename\u003c/span\u003e\u003cspan class=\"se\"\u003e)\u003c/span\u003e\u003cspan class=\"s\"\u003e はまだ対応していません\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003enoDocument\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"前面に開いている文書がありません\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003eunsavedDocument\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"未保存の文書には絶対パスがありません\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eremoteURL\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eurl\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"ローカルパスではなくURLです: \u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003eurl\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eabsoluteString\u003c/span\u003e\u003cspan class=\"se\"\u003e)\u003c/span\u003e\u003cspan class=\"s\"\u003e\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003einvalidValue\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"アプリから有効なパスを取得できませんでした\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003eautomationDenied\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"このアプリの操作が許可されていません\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eapplicationError\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003emessage\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003emessage\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003eenum\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathNormalizer\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003elocalPath\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003efrom\u003c/span\u003e \u003cspan class=\"nv\"\u003erawValue\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eraw\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003erawValue\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etrimmingCharacters\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ein\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ewhitespacesAndNewlines\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEmpty\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eunsavedDocument\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ehasPrefix\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"file:\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"://\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)),\u003c/span\u003e\n           \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eurl\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eURL\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003estring\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003escheme\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eurl\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003escheme\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003escheme\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003elowercased\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"file\"\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eremoteURL\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eurl\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n            \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003eurl\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisFileURL\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003einvalidValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003eurl\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estandardizedFileURL\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epath\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ehasPrefix\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"/\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kt\"\u003eURL\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003efileURLWithPath\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estandardizedFileURL\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epath\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n        \u003cspan class=\"c1\"\u003e// Word's documented `full name` is an HFS path. Convert its volume and\u003c/span\u003e\n        \u003cspan class=\"c1\"\u003e// components directly, never by guessing from the document title.\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\":\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eparts\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esplit\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eseparator\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\":\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eomittingEmptySubsequences\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003emap\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kd\"\u003einit\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n            \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003eparts\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003eparts\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEmpty\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003einvalidValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n            \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erootVolume\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003etry\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e \u003cspan class=\"kt\"\u003eURL\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003efileURLWithPath\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"/\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eresourceValues\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eforKeys\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003evolumeNameKey\u003c/span\u003e\u003cspan class=\"p\"\u003e])\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003evolumeName\u003c/span\u003e\n            \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ebase\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eparts\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"n\"\u003erootVolume\u003c/span\u003e \u003cspan class=\"p\"\u003e?\u003c/span\u003e \u003cspan class=\"s\"\u003e\"/\"\u003c/span\u003e \u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"/Volumes/\u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003eparts\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\u003cspan class=\"se\"\u003e)\u003c/span\u003e\u003cspan class=\"s\"\u003e\"\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kt\"\u003eURL\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003efileURLWithPath\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003ebase\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eappending\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003epath\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eparts\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003edropFirst\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ejoined\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eseparator\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"/\"\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estandardizedFileURL\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epath\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003einvalidValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eraw\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003eprotocol\u003c/span\u003e \u003cspan class=\"kt\"\u003eDocumentValueProviding\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003edocumentValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eFrontDocumentResolver\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eprovider\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kd\"\u003eany\u003c/span\u003e \u003cspan class=\"kt\"\u003eDocumentValueProviding\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"nf\"\u003einit\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eprovider\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kd\"\u003eany\u003c/span\u003e \u003cspan class=\"kt\"\u003eDocumentValueProviding\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eprovider\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eprovider\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eresolve\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esupported\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s\"\u003e\"com.apple.Preview\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s\"\u003e\"com.microsoft.Word\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s\"\u003e\"com.apple.finder\"\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003esupported\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eunsupportedApplication\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathNormalizer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003elocalPath\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003efrom\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eprovider\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003edocumentValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"205:1-205:45\"\u003e正規化では次を区別しています。\u003c/p\u003e\n\u003cul data-sourcepos=\"207:1-212:0\"\u003e\n\u003cli data-sourcepos=\"207:1-207:43\"\u003e\n\u003ccode\u003e/Users/...\u003c/code\u003eのようなPOSIX絶対パス\u003c/li\u003e\n\u003cli data-sourcepos=\"208:1-208:41\"\u003e\n\u003ccode\u003efile:///Users/...\u003c/code\u003eのようなfile URL\u003c/li\u003e\n\u003cli data-sourcepos=\"209:1-209:52\"\u003e\n\u003ccode\u003eArchive:Reports:sample.docx\u003c/code\u003eのようなHFS形式\u003c/li\u003e\n\u003cli data-sourcepos=\"210:1-210:42\"\u003e\n\u003ccode\u003ehttps://...\u003c/code\u003eのようなリモートURL\u003c/li\u003e\n\u003cli data-sourcepos=\"211:1-212:0\"\u003e空文字列など、未保存文書を示す値\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"213:1-213:290\"\u003e\u003ccode\u003eURL\u003c/code\u003eを使うことで、file URLのパーセントエンコード、空白、日本語なども正しく戻せます。HFS形式では先頭要素をボリューム名として扱い、起動ボリュームなら\u003ccode\u003e/\u003c/code\u003e、それ以外なら\u003ccode\u003e/Volumes/\u0026lt;ボリューム名\u0026gt;\u003c/code\u003eへ変換します。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"215:1-215:40\"\u003e\n\u003cspan id=\"3-sourcesopenfilepathappmainswift\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#3-sourcesopenfilepathappmainswift\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e3. Sources/OpenFilePathApp/main.swift\u003c/h2\u003e\n\u003cp data-sourcepos=\"217:1-217:57\"\u003e\u003ccode\u003eSources/OpenFilePathApp/main.swift\u003c/code\u003eを作成します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"219:1-365:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eAppKit\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eFoundation\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eOpenFilePathCore\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003efinal\u003c/span\u003e \u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"kt\"\u003eAppleScriptDocumentProvider\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eDocumentValueProviding\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003edocumentValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esource\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eswitch\u003c/span\u003e \u003cspan class=\"n\"\u003ebundleIdentifier\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"s\"\u003e\"com.apple.Preview\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n            \u003cspan class=\"n\"\u003esource\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"err\"\u003e#\u003c/span\u003e\u003cspan class=\"s\"\u003e\"tell application id \"\u003c/span\u003e\u003cspan class=\"n\"\u003ecom\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eapple\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003ePreview\u003c/span\u003e\u003cspan class=\"s\"\u003e\" to return (path of front document) as text\"\u003c/span\u003e\u003cspan class=\"err\"\u003e#\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"s\"\u003e\"com.microsoft.Word\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n            \u003cspan class=\"n\"\u003esource\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"err\"\u003e#\u003c/span\u003e\u003cspan class=\"s\"\u003e\"tell application id \"\u003c/span\u003e\u003cspan class=\"n\"\u003ecom\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emicrosoft\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eWord\u003c/span\u003e\u003cspan class=\"s\"\u003e\" to return full name of active document as text\"\u003c/span\u003e\u003cspan class=\"err\"\u003e#\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"s\"\u003e\"com.apple.finder\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n            \u003cspan class=\"n\"\u003esource\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"err\"\u003e#\u003c/span\u003e\u003cspan class=\"s\"\u003e\"tell application id \"\u003c/span\u003e\u003cspan class=\"n\"\u003ecom\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eapple\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efinder\u003c/span\u003e\u003cspan class=\"s\"\u003e\" to if (count of selection) is 1 then return (item 1 of selection) as alias as text\"\u003c/span\u003e\u003cspan class=\"err\"\u003e#\u003c/span\u003e\n        \u003cspan class=\"k\"\u003edefault\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eunsupportedApplication\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eerrorInfo\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSDictionary\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003escript\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSAppleScript\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003esource\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003esource\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n              \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003escript\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eexecuteAndReturnError\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026amp;\u003c/span\u003e\u003cspan class=\"n\"\u003eerrorInfo\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estringValue\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003enumber\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eerrorInfo\u003c/span\u003e\u003cspan class=\"p\"\u003e?[\u003c/span\u003e\u003cspan class=\"kt\"\u003eNSAppleScript\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eerrorNumber\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"k\"\u003eas?\u003c/span\u003e \u003cspan class=\"kt\"\u003eInt\u003c/span\u003e\n            \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003enumber\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e\u003cspan class=\"mi\"\u003e1743\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eautomationDenied\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n            \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003enumber\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e\u003cspan class=\"mi\"\u003e1728\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003enoDocument\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n            \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003emessage\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eerrorInfo\u003c/span\u003e\u003cspan class=\"p\"\u003e?[\u003c/span\u003e\u003cspan class=\"kt\"\u003eNSAppleScript\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eerrorMessage\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"k\"\u003eas?\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"s\"\u003e\"文書パスを取得できませんでした\"\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ethrow\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eapplicationError\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003emessage\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003evalue\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@MainActor\u003c/span\u003e\n\u003cspan class=\"kd\"\u003efinal\u003c/span\u003e \u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"kt\"\u003eAppDelegate\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSObject\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSApplicationDelegate\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003eenum\u003c/span\u003e \u003cspan class=\"kt\"\u003eStatusIcon\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eidle\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"doc.on.clipboard\"\u003c/span\u003e\n        \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esuccess\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"checkmark.circle\"\u003c/span\u003e\n        \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eerror\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"exclamationmark.triangle\"\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSStatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eresolver\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eFrontDocumentResolver\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eprovider\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eAppleScriptDocumentProvider\u003c/span\u003e\u003cspan class=\"p\"\u003e())\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eresetWorkItem\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eDispatchWorkItem\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eapplicationDidFinishLaunching\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"nv\"\u003enotification\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eNotification\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003estatusItem\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSStatusBar\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esystem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ewithLength\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSStatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003evariableLength\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ebutton\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebutton\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"nf\"\u003esetStatusIcon\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eStatusIcon\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eidle\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ebutton\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etoolTip\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"左クリック: 前面の文書パスをコピー / 右クリック: メニュー\"\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ebutton\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etarget\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003eself\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ebutton\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eaction\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003e#selector\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nf\"\u003estatusItemClicked\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e_\u003c/span\u003e\u003cspan class=\"p\"\u003e:))\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ebutton\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esendAction\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eon\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eleftMouseUp\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erightMouseUp\u003c/span\u003e\u003cspan class=\"p\"\u003e])\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003e@objc\u003c/span\u003e \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003estatusItemClicked\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"nv\"\u003esender\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSStatusBarButton\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSApp\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrentEvent\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etype\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erightMouseUp\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003emenu\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSMenu\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n            \u003cspan class=\"n\"\u003emenu\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eaddItem\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ewithTitle\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"終了\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eaction\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003e#selector\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eNSApplication\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eterminate\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e_\u003c/span\u003e\u003cspan class=\"p\"\u003e:)),\u003c/span\u003e \u003cspan class=\"nv\"\u003ekeyEquivalent\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"q\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n            \u003cspan class=\"n\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emenu\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003emenu\u003c/span\u003e\n            \u003cspan class=\"n\"\u003esender\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eperformClick\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kc\"\u003enil\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n            \u003cspan class=\"n\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emenu\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n        \u003cspan class=\"c1\"\u003e// A status item click does not activate this accessory app. Capture the\u003c/span\u003e\n        \u003cspan class=\"c1\"\u003e// target before any UI feedback, then resolve immediately.\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eapp\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSWorkspace\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eshared\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efrontmostApplication\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n              \u003cspan class=\"n\"\u003eapp\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebundleIdentifier\u003c/span\u003e \u003cspan class=\"o\"\u003e!=\u003c/span\u003e \u003cspan class=\"kt\"\u003eBundle\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emain\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebundleIdentifier\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"nf\"\u003eshow\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"対象なし\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eisError\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003edo\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003epath\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"n\"\u003eresolver\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eresolve\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n                \u003cspan class=\"nv\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eapp\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebundleIdentifier\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n                \u003cspan class=\"nv\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eapp\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elocalizedName\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"s\"\u003e\"このアプリ\"\u003c/span\u003e\n            \u003cspan class=\"p\"\u003e)\u003c/span\u003e\n            \u003cspan class=\"kt\"\u003eNSPasteboard\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003egeneral\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eclearContents\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n            \u003cspan class=\"kt\"\u003eNSPasteboard\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003egeneral\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esetString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003epath\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eforType\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estring\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n            \u003cspan class=\"nf\"\u003eshow\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"✓ コピー済み\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eisError\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003ecatch\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"nf\"\u003eshow\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eerror\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elocalizedDescription\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eisError\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n            \u003cspan class=\"nf\"\u003epresentError\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eerror\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eapp\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elocalizedName\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"s\"\u003e\"このアプリ\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eshow\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"nv\"\u003etext\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eisError\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003eresetWorkItem\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecancel\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n        \u003cspan class=\"nf\"\u003esetStatusIcon\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eisError\u003c/span\u003e \u003cspan class=\"p\"\u003e?\u003c/span\u003e \u003cspan class=\"kt\"\u003eStatusIcon\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003eerror\u003c/span\u003e \u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eStatusIcon\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esuccess\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"n\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebutton\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etoolTip\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003etext\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ework\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eDispatchWorkItem\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"k\"\u003eweak\u003c/span\u003e \u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n            \u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esetStatusIcon\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eStatusIcon\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eidle\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n            \u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebutton\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etoolTip\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"左クリック: 前面の文書パスをコピー / 右クリック: メニュー\"\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"n\"\u003eresetWorkItem\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ework\u003c/span\u003e\n        \u003cspan class=\"kt\"\u003eDispatchQueue\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emain\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003easyncAfter\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003edeadline\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"mi\"\u003e3\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eexecute\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003ework\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003esetStatusIcon\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"nv\"\u003esymbolName\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eimage\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSImage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003esystemSymbolName\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003esymbolName\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eaccessibilityDescription\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"文書パスをコピー\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"n\"\u003eimage\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisTemplate\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n        \u003cspan class=\"n\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebutton\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eimage\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eimage\u003c/span\u003e\n        \u003cspan class=\"n\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebutton\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eimagePosition\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eimageOnly\u003c/span\u003e\n        \u003cspan class=\"n\"\u003estatusItem\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebutton\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etitle\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\"\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003epresentError\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"nv\"\u003eerror\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eError\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ealert\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSAlert\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ealert\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ealertStyle\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ewarning\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ealert\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emessageText\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"パスをコピーできませんでした\"\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ealert\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003einformativeText\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003eerrorGuidance\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eerror\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ealert\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eaddButton\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ewithTitle\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"OK\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eautomationDenied\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eerror\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"n\"\u003ealert\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eaddButton\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ewithTitle\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"オートメーション設定を開く\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n        \u003cspan class=\"kt\"\u003eNSApp\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eactivate\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eignoringOtherApps\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eresponse\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ealert\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003erunModal\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003eresponse\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ealertSecondButtonReturn\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n           \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esettingsURL\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eURL\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003estring\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"x-apple.systempreferences:com.apple.preference.security?Privacy_Automation\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"kt\"\u003eNSWorkspace\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eshared\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eopen\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esettingsURL\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eerrorGuidance\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"nv\"\u003eerror\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eError\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eswitch\u003c/span\u003e \u003cspan class=\"n\"\u003eerror\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003eunsupportedApplication\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"se\"\u003e)\u003c/span\u003e\u003cspan class=\"s\"\u003e は現在の対応対象ではありません。PreviewまたはMicrosoft Wordで保存済み文書を前面にするか、Finderで項目を1件選択してから、もう一度クリックしてください。\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003eautomationDenied\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"se\"\u003e)\u003c/span\u003e\u003cspan class=\"s\"\u003e の操作が許可されていません。システム設定の「プライバシーとセキュリティ \u0026gt; オートメーション」でOpenFilePathを許可してください。\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003enoDocument\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"se\"\u003e)\u003c/span\u003e\u003cspan class=\"s\"\u003e に開いている文書がないか、Finderで選択している項目が1件ではありません。対象を開くか1件だけ選択してから、もう一度クリックしてください。\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathResolutionError\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003eunsavedDocument\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"前面の文書はまだ保存されていません。文書を保存してから、もう一度クリックしてください。\"\u003c/span\u003e\n        \u003cspan class=\"k\"\u003edefault\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"s\"\u003e\"対象: \u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"se\"\u003e)\\n\\(\u003c/span\u003e\u003cspan class=\"n\"\u003eerror\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elocalizedDescription\u003c/span\u003e\u003cspan class=\"se\"\u003e)\u003c/span\u003e\u003cspan class=\"s\"\u003e\"\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eapp\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSApplication\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eshared\u003c/span\u003e\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003edelegate\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eAppDelegate\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003cspan class=\"n\"\u003eapp\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003edelegate\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003edelegate\u003c/span\u003e\n\u003cspan class=\"n\"\u003eapp\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esetActivationPolicy\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eaccessory\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"n\"\u003eapp\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003erun\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"367:1-367:258\"\u003e\u003ccode\u003eLSUIElement\u003c/code\u003eを有効にしたアクセサリアプリなので、通常のアプリウィンドウやDockアイコンは表示しません。状態はSF Symbolsのアイコンで示し、エラー時は理由と対処をダイアログに表示します。\u003c/p\u003e\n\u003cp data-sourcepos=\"369:1-369:207\"\u003eクリップボードを書き換えるのは左クリックが成功した場合だけです。バックグラウンドでクリップボードを監視したり、文書を監視したりはしません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"371:1-371:57\"\u003e\n\u003cspan id=\"4-testsopenfilepathcoretestspathresolvertestsswift\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#4-testsopenfilepathcoretestspathresolvertestsswift\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e4. Tests/OpenFilePathCoreTests/PathResolverTests.swift\u003c/h2\u003e\n\u003cp data-sourcepos=\"373:1-373:74\"\u003e\u003ccode\u003eTests/OpenFilePathCoreTests/PathResolverTests.swift\u003c/code\u003eを作成します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"375:1-419:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eFoundation\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eTesting\u003c/span\u003e\n\u003cspan class=\"kd\"\u003e@testable\u003c/span\u003e \u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eOpenFilePathCore\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eFakeProvider\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eDocumentValueProviding\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003edocumentValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003evalue\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@Test\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003edecodesFileURLWithSpacesAndUnicode\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003epath\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathNormalizer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003elocalPath\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003efrom\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"file:///tmp/%E6%97%A5%E6%9C%AC%20file.pdf\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"cp\"\u003e#expect(path == \"/tmp/日本 file.pdf\")\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@Test\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003epreservesPOSIXUnicodePath\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"cp\"\u003e#expect(try PathNormalizer.localPath(from: \"/tmp/資料 1.docx\") == \"/tmp/資料 1.docx\")\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@Test\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003econvertsWordHFSPathOnExternalVolume\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"cp\"\u003e#expect(try PathNormalizer.localPath(from: \"Archive:Reports:資料 1.docx\") == \"/Volumes/Archive/Reports/資料 1.docx\")\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@Test\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003erejectsRemoteURLInsteadOfInventingLocalPath\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"cp\"\u003e#expect(throws: PathResolutionError.remoteURL(URL(string: \"https://example.sharepoint.com/a.docx\")!)) {\u003c/span\u003e\n        \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"kt\"\u003ePathNormalizer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003elocalPath\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003efrom\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"https://example.sharepoint.com/a.docx\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@Test\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003erejectsUnsavedDocument\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"cp\"\u003e#expect(throws: PathResolutionError.unsavedDocument) { try PathNormalizer.localPath(from: \"  \") }\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@Test\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eresolverUsesAdapterValueNotWindowTitle\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eresolver\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eFrontDocumentResolver\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eprovider\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eFakeProvider\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"/tmp/actual.pdf\"\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n    \u003cspan class=\"cp\"\u003e#expect(try resolver.resolve(bundleIdentifier: \"com.apple.Preview\", applicationName: \"Preview\") == \"/tmp/actual.pdf\")\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@Test\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003erejectsUnsupportedApplication\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eresolver\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eFrontDocumentResolver\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eprovider\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eFakeProvider\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"/tmp/x\"\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n    \u003cspan class=\"cp\"\u003e#expect(throws: PathResolutionError.unsupportedApplication(\"TextEdit\")) {\u003c/span\u003e\n        \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"n\"\u003eresolver\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eresolve\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ebundleIdentifier\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"com.apple.TextEdit\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eapplicationName\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"TextEdit\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"421:1-421:141\"\u003eこのテストはfake providerを使うため、PreviewやWordを起動せず、クリップボードも変更せずに実行できます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"423:1-423:20\"\u003e\n\u003cspan id=\"5-appinfoplist\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#5-appinfoplist\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e5. App/Info.plist\u003c/h2\u003e\n\u003cp data-sourcepos=\"425:1-425:37\"\u003e\u003ccode\u003eApp/Info.plist\u003c/code\u003eを作成します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"xml\" data-sourcepos=\"427:1-441:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"cp\"\u003e\u0026lt;?xml version=\"1.0\" encoding=\"UTF-8\"?\u0026gt;\u003c/span\u003e\n\u003cspan class=\"cp\"\u003e\u0026lt;!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"\u0026gt;\u003c/span\u003e\n\u003cspan class=\"nt\"\u003e\u0026lt;plist\u003c/span\u003e \u003cspan class=\"na\"\u003eversion=\u003c/span\u003e\u003cspan class=\"s\"\u003e\"1.0\"\u003c/span\u003e\u003cspan class=\"nt\"\u003e\u0026gt;\u0026lt;dict\u0026gt;\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e\u0026lt;key\u0026gt;\u003c/span\u003eCFBundleExecutable\u003cspan class=\"nt\"\u003e\u0026lt;/key\u0026gt;\u0026lt;string\u0026gt;\u003c/span\u003eOpenFilePath\u003cspan class=\"nt\"\u003e\u0026lt;/string\u0026gt;\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e\u0026lt;key\u0026gt;\u003c/span\u003eCFBundleIdentifier\u003cspan class=\"nt\"\u003e\u0026lt;/key\u0026gt;\u0026lt;string\u0026gt;\u003c/span\u003elocal.example.OpenFilePath\u003cspan class=\"nt\"\u003e\u0026lt;/string\u0026gt;\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e\u0026lt;key\u0026gt;\u003c/span\u003eCFBundleName\u003cspan class=\"nt\"\u003e\u0026lt;/key\u0026gt;\u0026lt;string\u0026gt;\u003c/span\u003eOpenFilePath\u003cspan class=\"nt\"\u003e\u0026lt;/string\u0026gt;\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e\u0026lt;key\u0026gt;\u003c/span\u003eCFBundlePackageType\u003cspan class=\"nt\"\u003e\u0026lt;/key\u0026gt;\u0026lt;string\u0026gt;\u003c/span\u003eAPPL\u003cspan class=\"nt\"\u003e\u0026lt;/string\u0026gt;\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e\u0026lt;key\u0026gt;\u003c/span\u003eCFBundleShortVersionString\u003cspan class=\"nt\"\u003e\u0026lt;/key\u0026gt;\u0026lt;string\u0026gt;\u003c/span\u003e0.2.0\u003cspan class=\"nt\"\u003e\u0026lt;/string\u0026gt;\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e\u0026lt;key\u0026gt;\u003c/span\u003eCFBundleVersion\u003cspan class=\"nt\"\u003e\u0026lt;/key\u0026gt;\u0026lt;string\u0026gt;\u003c/span\u003e1\u003cspan class=\"nt\"\u003e\u0026lt;/string\u0026gt;\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e\u0026lt;key\u0026gt;\u003c/span\u003eLSUIElement\u003cspan class=\"nt\"\u003e\u0026lt;/key\u0026gt;\u0026lt;true/\u0026gt;\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e\u0026lt;key\u0026gt;\u003c/span\u003eNSAppleEventsUsageDescription\u003cspan class=\"nt\"\u003e\u0026lt;/key\u0026gt;\u0026lt;string\u0026gt;\u003c/span\u003e前面で開いている文書の保存先パスを取得するため、選択したアプリに問い合わせます。\u003cspan class=\"nt\"\u003e\u0026lt;/string\u0026gt;\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e\u0026lt;key\u0026gt;\u003c/span\u003eNSPrincipalClass\u003cspan class=\"nt\"\u003e\u0026lt;/key\u0026gt;\u0026lt;string\u0026gt;\u003c/span\u003eNSApplication\u003cspan class=\"nt\"\u003e\u0026lt;/string\u0026gt;\u003c/span\u003e\n\u003cspan class=\"nt\"\u003e\u0026lt;/dict\u0026gt;\u0026lt;/plist\u0026gt;\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"443:1-443:189\"\u003e\u003ccode\u003eLSUIElement\u003c/code\u003eはアプリをDockに表示しないための設定です。\u003ccode\u003eNSAppleEventsUsageDescription\u003c/code\u003eには、なぜApple Eventsを使うのかをユーザー向けに記述します。\u003c/p\u003e\n\u003cp data-sourcepos=\"445:1-445:203\"\u003e\u003ccode\u003eCFBundleIdentifier\u003c/code\u003eは自分の識別子へ変更して構いません。Apple Developer IDによる配布署名を行う場合は、その署名設定と一致する識別子を使ってください。\u003c/p\u003e\n\u003cp data-sourcepos=\"447:1-447:213\"\u003eこの構成ではentitlementsファイルは不要です。App Sandboxを有効にして配布する設計へ変更する場合は、Apple Events関連のsandbox要件を別途検討する必要があります。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"449:1-449:14\"\u003e\n\u003cspan id=\"6-buildsh\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#6-buildsh\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e6. build.sh\u003c/h2\u003e\n\u003cp data-sourcepos=\"451:1-451:31\"\u003e\u003ccode\u003ebuild.sh\u003c/code\u003eを作成します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"453:1-489:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c\"\u003e#!/bin/zsh\u003c/span\u003e\n\u003cspan class=\"nb\"\u003eset\u003c/span\u003e \u003cspan class=\"nt\"\u003e-euo\u003c/span\u003e pipefail\n\n\u003cspan class=\"nv\"\u003eproject_dir\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"k\"\u003e${\u003c/span\u003e\u003cspan class=\"nv\"\u003e0\u003c/span\u003e:A:h\u003cspan class=\"k\"\u003e}\u003c/span\u003e\n\u003cspan class=\"nv\"\u003ebuild_dir\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$project_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e/build\"\u003c/span\u003e\n\u003cspan class=\"nv\"\u003eapp_dir\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$build_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e/OpenFilePath.app\"\u003c/span\u003e\n\u003cspan class=\"nv\"\u003escratch_dir\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$build_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e/SwiftPM\"\u003c/span\u003e\n\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"o\"\u003e[[\u003c/span\u003e \u003cspan class=\"nt\"\u003e-d\u003c/span\u003e /Applications/Xcode.app/Contents/Developer \u003cspan class=\"o\"\u003e]]\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"k\"\u003ethen\n  \u003c/span\u003e\u003cspan class=\"nb\"\u003eexport \u003c/span\u003e\u003cspan class=\"nv\"\u003eDEVELOPER_DIR\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e/Applications/Xcode.app/Contents/Developer\n\u003cspan class=\"k\"\u003efi\n\u003c/span\u003e\u003cspan class=\"nb\"\u003eexport \u003c/span\u003e\u003cspan class=\"nv\"\u003eCLANG_MODULE_CACHE_PATH\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$scratch_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e/ModuleCache\"\u003c/span\u003e\n\u003cspan class=\"nb\"\u003eexport \u003c/span\u003e\u003cspan class=\"nv\"\u003eSWIFTPM_MODULECACHE_OVERRIDE\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$scratch_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e/ModuleCache\"\u003c/span\u003e\n\n/usr/bin/xcrun swift build \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--disable-sandbox\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--package-path\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$project_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--scratch-path\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$scratch_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e-c\u003c/span\u003e release \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--arch\u003c/span\u003e arm64 \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--arch\u003c/span\u003e x86_64 \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--product\u003c/span\u003e OpenFilePath\n\u003cspan class=\"nv\"\u003ebinary_path\u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"si\"\u003e$(\u003c/span\u003e/usr/bin/xcrun swift build \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--package-path\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$project_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--scratch-path\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$scratch_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e-c\u003c/span\u003e release \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--arch\u003c/span\u003e arm64 \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--arch\u003c/span\u003e x86_64 \u003cspan class=\"se\"\u003e\\\u003c/span\u003e\n  \u003cspan class=\"nt\"\u003e--show-bin-path\u003c/span\u003e\u003cspan class=\"si\"\u003e)\u003c/span\u003e\n\n/bin/rm \u003cspan class=\"nt\"\u003e-rf\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$app_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\n/bin/mkdir \u003cspan class=\"nt\"\u003e-p\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$app_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e/Contents/MacOS\"\u003c/span\u003e\n/bin/cp \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$project_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e/App/Info.plist\"\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$app_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e/Contents/Info.plist\"\u003c/span\u003e\n/bin/cp \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$binary_path\u003c/span\u003e\u003cspan class=\"s2\"\u003e/OpenFilePath\"\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$app_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e/Contents/MacOS/OpenFilePath\"\u003c/span\u003e\n/usr/bin/codesign \u003cspan class=\"nt\"\u003e--force\u003c/span\u003e \u003cspan class=\"nt\"\u003e--sign\u003c/span\u003e - \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$app_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\n\u003cspan class=\"nb\"\u003eecho\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"nv\"\u003e$app_dir\u003c/span\u003e\u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"491:1-491:30\"\u003e実行権限を付けます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"493:1-495:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"nb\"\u003echmod\u003c/span\u003e +x build.sh\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"497:1-497:218\"\u003eこのスクリプトはSwiftPMでUniversal Binaryを作り、最小構成の\u003ccode\u003e.app\u003c/code\u003eバンドルへ配置した後、ローカル実行用のad-hoc署名を行います。Developer ID署名や公証は行いません。\u003c/p\u003e\n\u003cp data-sourcepos=\"499:1-499:213\"\u003e\u003ccode\u003e--disable-sandbox\u003c/code\u003eはSwiftPM自身のビルド用sandboxを無効にするオプションであり、macOSのSIPやGatekeeper、アプリのプライバシー保護を無効にするものではありません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"501:1-501:12\"\u003e\n\u003cspan id=\"テスト\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%86%E3%82%B9%E3%83%88\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eテスト\u003c/h2\u003e\n\u003cp data-sourcepos=\"503:1-503:48\"\u003eプロジェクトルートで実行します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"505:1-508:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"nb\"\u003ecd\u003c/span\u003e ~/OpenFilePath\nswift \u003cspan class=\"nb\"\u003etest\u003c/span\u003e \u003cspan class=\"nt\"\u003e--disable-sandbox\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"510:1-510:75\"\u003e成功時はSwift Testingの結果に7 tests passedと表示されます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"512:1-512:12\"\u003e\n\u003cspan id=\"ビルド\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%93%E3%83%AB%E3%83%89\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eビルド\u003c/h2\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"514:1-517:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"nb\"\u003ecd\u003c/span\u003e ~/OpenFilePath\n./build.sh\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"519:1-519:63\"\u003e成功すると次の場所にアプリが生成されます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"521:1-523:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e~/OpenFilePath/build/OpenFilePath.app\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"525:1-525:75\"\u003eバンドルと署名を追加確認する場合は次を実行します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"527:1-531:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eplutil \u003cspan class=\"nt\"\u003e-lint\u003c/span\u003e build/OpenFilePath.app/Contents/Info.plist\ncodesign \u003cspan class=\"nt\"\u003e--verify\u003c/span\u003e \u003cspan class=\"nt\"\u003e--deep\u003c/span\u003e \u003cspan class=\"nt\"\u003e--strict\u003c/span\u003e build/OpenFilePath.app\nfile build/OpenFilePath.app/Contents/MacOS/OpenFilePath\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"533:1-533:85\"\u003e\u003ccode\u003efile\u003c/code\u003eの結果に\u003ccode\u003earm64\u003c/code\u003eと\u003ccode\u003ex86_64\u003c/code\u003eが含まれていればUniversal Binaryです。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"535:1-535:21\"\u003e\n\u003cspan id=\"起動と使い方\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%B5%B7%E5%8B%95%E3%81%A8%E4%BD%BF%E3%81%84%E6%96%B9\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e起動と使い方\u003c/h2\u003e\n\u003cp data-sourcepos=\"537:1-537:192\"\u003e実際に対象アプリへ問い合わせるとオートメーション権限ダイアログが表示される可能性があります。内容を確認してから起動してください。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"539:1-541:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eopen build/OpenFilePath.app\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003col data-sourcepos=\"543:1-546:0\"\u003e\n\u003cli data-sourcepos=\"543:1-543:123\"\u003ePreviewまたはWordで保存済み文書を前面にします。Finderの場合は項目を1件だけ選択します。\u003c/li\u003e\n\u003cli data-sourcepos=\"544:1-544:72\"\u003eメニューバーの書類アイコンを左クリックします。\u003c/li\u003e\n\u003cli data-sourcepos=\"545:1-546:0\"\u003eチェックマークが表示されたら、絶対パスがクリップボードに入っています。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"547:1-547:72\"\u003e右クリックすると「終了」メニューが表示されます。\u003c/p\u003e\n\u003cp data-sourcepos=\"549:1-549:332\"\u003e初回の問い合わせ時にmacOSがオートメーション権限を求めた場合は、対象と理由を確認して許可します。後から変更する場合は「システム設定 \u0026gt; プライバシーとセキュリティ \u0026gt; オートメーション」でOpenFilePathの対象アプリへの許可を変更できます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"551:1-551:24\"\u003e\n\u003cspan id=\"エラー時の確認\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%A8%E3%83%A9%E3%83%BC%E6%99%82%E3%81%AE%E7%A2%BA%E8%AA%8D\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eエラー時の確認\u003c/h2\u003e\n\u003ch3 data-sourcepos=\"553:1-553:64\"\u003e\n\u003cspan id=\"このアプリの操作が許可されていません\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%93%E3%81%AE%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AE%E6%93%8D%E4%BD%9C%E3%81%8C%E8%A8%B1%E5%8F%AF%E3%81%95%E3%82%8C%E3%81%A6%E3%81%84%E3%81%BE%E3%81%9B%E3%82%93\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e「このアプリの操作が許可されていません」\u003c/h3\u003e\n\u003cp data-sourcepos=\"555:1-555:264\"\u003e「システム設定 \u0026gt; プライバシーとセキュリティ \u0026gt; オートメーション」で、OpenFilePathから対象アプリへの許可を確認します。アクセシビリティやフルディスクアクセスを追加する必要はありません。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"557:1-557:58\"\u003e\n\u003cspan id=\"前面に開いている文書がありません\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%89%8D%E9%9D%A2%E3%81%AB%E9%96%8B%E3%81%84%E3%81%A6%E3%81%84%E3%82%8B%E6%96%87%E6%9B%B8%E3%81%8C%E3%81%82%E3%82%8A%E3%81%BE%E3%81%9B%E3%82%93\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e「前面に開いている文書がありません」\u003c/h3\u003e\n\u003cp data-sourcepos=\"559:1-559:187\"\u003ePreviewまたはWordで文書が開かれているか確認します。Finderの場合は、選択が0件または複数件だと取得しません。1件だけ選択してください。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"561:1-561:22\"\u003e\n\u003cspan id=\"未保存の文書\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%9C%AA%E4%BF%9D%E5%AD%98%E3%81%AE%E6%96%87%E6%9B%B8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e未保存の文書\u003c/h3\u003e\n\u003cp data-sourcepos=\"563:1-563:111\"\u003e未保存文書にはローカル絶対パスがありません。先に保存してから再実行します。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"565:1-565:34\"\u003e\n\u003cspan id=\"onedriveやsharepointの文書\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#onedrive%E3%82%84sharepoint%E3%81%AE%E6%96%87%E6%9B%B8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eOneDriveやSharePointの文書\u003c/h3\u003e\n\u003cp data-sourcepos=\"567:1-567:338\"\u003eWordが同期済みローカルファイルのパスを返す場合はコピーできます。Wordが\u003ccode\u003ehttps:\u003c/code\u003e URLだけを返す場合、この実装はローカル絶対パスを推測しません。URLとローカル同期先の対応を誤ると、存在しないパスや別ファイルのパスを返す危険があるためです。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"569:1-569:49\"\u003e\n\u003cspan id=\"開発元を確認できないという警告\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E9%96%8B%E7%99%BA%E5%85%83%E3%82%92%E7%A2%BA%E8%AA%8D%E3%81%A7%E3%81%8D%E3%81%AA%E3%81%84%E3%81%A8%E3%81%84%E3%81%86%E8%AD%A6%E5%91%8A\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e開発元を確認できないという警告\u003c/h3\u003e\n\u003cp data-sourcepos=\"571:1-571:321\"\u003eこの手順の署名はad-hoc署名で、Developer ID署名やAppleの公証ではありません。自分のMacでビルドして試す用途を想定しています。第三者へ配布する場合はDeveloper ID署名と公証を行ってください。SIPやGatekeeperを無効化する方法は推奨しません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"573:1-573:18\"\u003e\n\u003cspan id=\"終了と削除\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%B5%82%E4%BA%86%E3%81%A8%E5%89%8A%E9%99%A4\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e終了と削除\u003c/h2\u003e\n\u003cp data-sourcepos=\"575:1-575:105\"\u003e終了するには、メニューバーアイコンを右クリックして「終了」を選びます。\u003c/p\u003e\n\u003cp data-sourcepos=\"577:1-577:346\"\u003eログイン項目は登録していません。不要になったらアプリを終了し、作成した\u003ccode\u003e~/OpenFilePath\u003c/code\u003eディレクトリを削除すれば完了です。すでに付与したオートメーション権限が気になる場合は、システム設定のオートメーション画面で許可状態を確認してください。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"579:1-579:18\"\u003e\n\u003cspan id=\"現在の制限\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%8F%BE%E5%9C%A8%E3%81%AE%E5%88%B6%E9%99%90\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e現在の制限\u003c/h2\u003e\n\u003cul data-sourcepos=\"581:1-588:0\"\u003e\n\u003cli data-sourcepos=\"581:1-581:56\"\u003e対応対象はPreview、Microsoft Word、Finderのみ\u003c/li\u003e\n\u003cli data-sourcepos=\"582:1-582:30\"\u003eFinderは1項目選択のみ\u003c/li\u003e\n\u003cli data-sourcepos=\"583:1-583:29\"\u003e未保存文書は非対応\u003c/li\u003e\n\u003cli data-sourcepos=\"584:1-584:65\"\u003eリモートURLからローカル同期パスを推測しない\u003c/li\u003e\n\u003cli data-sourcepos=\"585:1-585:86\"\u003eグローバルショートカットやログイン時起動は実装していない\u003c/li\u003e\n\u003cli data-sourcepos=\"586:1-586:163\"\u003eAppleScriptアダプタは構文コンパイルまで確認済みだが、この記事の検証では実アプリへのApple Events送信を実行していない\u003c/li\u003e\n\u003cli data-sourcepos=\"587:1-588:0\"\u003eDeveloper ID署名、公証、Mac App Store向けsandbox構成は含まない\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 data-sourcepos=\"589:1-589:51\"\u003e\n\u003cspan id=\"セキュリティ面で行っていないこと\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3%E9%9D%A2%E3%81%A7%E8%A1%8C%E3%81%A3%E3%81%A6%E3%81%84%E3%81%AA%E3%81%84%E3%81%93%E3%81%A8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eセキュリティ面で行っていないこと\u003c/h2\u003e\n\u003cp data-sourcepos=\"591:1-591:51\"\u003eこのアプリは次の処理を行いません。\u003c/p\u003e\n\u003cul data-sourcepos=\"593:1-600:0\"\u003e\n\u003cli data-sourcepos=\"593:1-593:29\"\u003eディスク全体の検索\u003c/li\u003e\n\u003cli data-sourcepos=\"594:1-594:35\"\u003eファイル内容の読み取り\u003c/li\u003e\n\u003cli data-sourcepos=\"595:1-595:32\"\u003e常時のウィンドウ監視\u003c/li\u003e\n\u003cli data-sourcepos=\"596:1-596:32\"\u003eクリップボードの監視\u003c/li\u003e\n\u003cli data-sourcepos=\"597:1-597:47\"\u003e自動的なクリップボード書き換え\u003c/li\u003e\n\u003cli data-sourcepos=\"598:1-598:44\"\u003eネットワーク通信やテレメトリ\u003c/li\u003e\n\u003cli data-sourcepos=\"599:1-600:0\"\u003eアクセシビリティ権限、画面収録権限、フルディスクアクセスの要求\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"601:1-601:150\"\u003eパス取得とクリップボード書き込みは、メニューバーアイコンを明示的に左クリックしたときだけ発生します。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"603:1-603:15\"\u003e\n\u003cspan id=\"参考資料\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%8F%82%E8%80%83%E8%B3%87%E6%96%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e参考資料\u003c/h2\u003e\n\u003cul data-sourcepos=\"605:1-608:0\"\u003e\n\u003cli data-sourcepos=\"605:1-605:153\"\u003e\u003ca href=\"https://developer.apple.com/documentation/appkit/nsworkspace/frontmostapplication\" rel=\"nofollow noopener\" target=\"_blank\"\u003eNSWorkspace.frontmostApplication — Apple Developer Documentation\u003c/a\u003e\u003c/li\u003e\n\u003cli data-sourcepos=\"606:1-606:119\"\u003e\u003ca href=\"https://developer.apple.com/documentation/foundation/nsapplescript\" rel=\"nofollow noopener\" target=\"_blank\"\u003eNSAppleScript — Apple Developer Documentation\u003c/a\u003e\u003c/li\u003e\n\u003cli data-sourcepos=\"607:1-608:0\"\u003e\u003ca href=\"https://developer.apple.com/documentation/bundleresources/information-property-list/lsuielement\" rel=\"nofollow noopener\" target=\"_blank\"\u003eLSUIElement — Apple Developer Documentation\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 data-sourcepos=\"609:1-609:12\"\u003e\n\u003cspan id=\"まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eまとめ\u003c/h2\u003e\n\u003cp data-sourcepos=\"611:1-611:231\"\u003e前面文書の絶対パス取得で難しいのは、文字列をクリップボードへ入れる部分ではなく、「どの文書か」と「返された値が本当にローカルパスか」を誤認しないことです。\u003c/p\u003e\n\u003cp data-sourcepos=\"613:1-613:415\"\u003eこの実装では、クリック直後の前面アプリをBundle IDで判定し、アプリ自身が返した文書情報だけを使います。さらに、POSIXパス、file URL、WordのHFS形式、リモートURL、未保存文書を分けて扱います。対応アプリを追加する場合も、\u003ccode\u003eDocumentValueProviding\u003c/code\u003eへアダプタを追加し、正規化とUIを分離したまま拡張できます。\u003c/p\u003e\n","body":"*この記事はChatGPT-6 Atlasにより作成されました。記事をまるっとLLMに渡すと実装してくれると思います。*\n\nPreviewでPDFを開いているとき、あるいはMicrosoft Wordで文書を開いているとき、そのファイルの絶対パスが欲しくなることがあります。\n\nこの記事では、前面アプリに開かれている文書のパスをメニューバーからコピーする小さなmacOSアプリを、SwiftとAppKitだけで作ります。対応対象は次の3つです。\n\n- Previewの前面文書\n- Microsoft Wordのactive document\n- Finderで1件だけ選択している項目\n\n外部ライブラリ、Xcodeプロジェクト、配布ZIPは使いません。この記事に載せたファイルを作れば、そのままテストとビルドができます。\n\n## 先に重要な点\n\nこのアプリは対象アプリへApple Eventsを送るため、初回実行時にmacOSのオートメーション権限が必要になることがあります。必要なのは対象アプリへのオートメーション権限だけです。アクセシビリティ、画面収録、フルディスクアクセスは使いません。\n\nまた、この記事で実施した確認範囲は次のとおりです。\n\n- コアロジックのテスト7件: 成功\n- Universal Binaryのビルド: 成功\n- ad-hoc署名: 成功\n- `Info.plist`検証: 成功\n- Preview、Word、Finder向けAppleScriptの構文コンパイル: 成功\n- 実際にアプリを起動し、PreviewやWordへApple Eventsを送る動作確認: 未実施\n\n最後の項目は、検証中に予期しないオートメーション権限ダイアログを出さないためです。したがって、この記事では実アプリ連携まで実証済みとは主張しません。\n\n## 仕組み\n\n処理は次の順番です。\n\n1. メニューバーアイコンが左クリックされた時点で、`NSWorkspace.shared.frontmostApplication`から前面アプリを取得する\n2. アプリのBundle IDに応じたAppleScriptを`NSAppleScript`で実行する\n3. 対象アプリが返した値をローカル絶対パスへ正規化する\n4. 成功した場合だけクリップボードへ書き込む\n\nメニューバーアイコンをクリックしてからメニュー項目を選ぶ方式にすると、その間に自分のアプリが前面扱いになったり、対象文書が変わったりする余地が増えます。この実装では左クリックを直接コピー操作に割り当て、UIのフィードバックを出す前に前面アプリを確保します。\n\nパスの取得にウィンドウタイトルは使いません。同名ファイル、未保存文書、クラウド文書などを区別できず、誤ったパスを作る危険があるからです。ディスク全体の検索も行いません。\n\nアダプタが使うAppleScriptは次の3つです。\n\n```applescript\n-- Preview\ntell application id \"com.apple.Preview\" to return (path of front document) as text\n\n-- Microsoft Word\ntell application id \"com.microsoft.Word\" to return full name of active document as text\n\n-- Finder\ntell application id \"com.apple.finder\" to if (count of selection) is 1 then return (item 1 of selection) as alias as text\n```\n\nローカルのMicrosoft Wordのスクリプティング辞書では、documentの`full name`はHFS形式のフルパスとして定義されています。その一方で、クラウド文書ではWeb URLが返る可能性もあります。そこで、POSIXパス、`file:` URL、HFS形式を明示的に扱い、`https:`などのリモートURLはローカルパスに見せかけずエラーにします。\n\n## 必要な環境\n\n- macOS 13 Ventura以降\n- Xcode本体、または対応するSwiftツールチェーンとmacOS SDK\n- Microsoft Word対応を使う場合はMicrosoft Word\n\n外部パッケージはありません。\n\nこの記事の検証環境は次のとおりです。\n\n```text\nmacOS 27.0.1 (Build 26A434)\nXcode 27.2 (Build 27B52)\nApple Swift 6.4.2\narm64 Mac\n```\n\nビルドスクリプトはarm64とx86_64を含むUniversal Binaryを生成します。\n\n## プロジェクト構成\n\n任意の作業場所で、次の構成を作ります。以下では`~/OpenFilePath`を使います。\n\n```text\nOpenFilePath/\n├── App/\n│   └── Info.plist\n├── Sources/\n│   ├── OpenFilePathApp/\n│   │   └── main.swift\n│   └── OpenFilePathCore/\n│       └── PathResolver.swift\n├── Tests/\n│   └── OpenFilePathCoreTests/\n│       └── PathResolverTests.swift\n├── Package.swift\n└── build.sh\n```\n\n最初にディレクトリを作ります。\n\n```bash\nmkdir -p ~/OpenFilePath/{App,Sources/OpenFilePathApp,Sources/OpenFilePathCore,Tests/OpenFilePathCoreTests}\ncd ~/OpenFilePath\n```\n\n## 1. Package.swift\n\n`Package.swift`を作成します。\n\n```swift\n// swift-tools-version: 6.0\nimport PackageDescription\n\nlet package = Package(\n    name: \"OpenFilePath\",\n    platforms: [.macOS(.v13)],\n    products: [\n        .library(name: \"OpenFilePathCore\", targets: [\"OpenFilePathCore\"]),\n        .executable(name: \"OpenFilePath\", targets: [\"OpenFilePathApp\"]),\n    ],\n    targets: [\n        .target(name: \"OpenFilePathCore\"),\n        .executableTarget(name: \"OpenFilePathApp\", dependencies: [\"OpenFilePathCore\"]),\n        .testTarget(name: \"OpenFilePathCoreTests\", dependencies: [\"OpenFilePathCore\"]),\n    ]\n)\n```\n\nパス正規化をUIから分離した`OpenFilePathCore`と、AppKitアプリ本体の`OpenFilePathApp`に分けています。これにより、実際のクリップボードやApple Eventsを使わずコアだけをテストできます。\n\n## 2. Sources/OpenFilePathCore/PathResolver.swift\n\n`Sources/OpenFilePathCore/PathResolver.swift`を作成します。\n\n```swift\nimport Foundation\n\npublic enum PathResolutionError: Error, Equatable, LocalizedError {\n    case unsupportedApplication(String)\n    case noDocument\n    case unsavedDocument\n    case remoteURL(URL)\n    case invalidValue(String)\n    case automationDenied\n    case applicationError(String)\n\n    public var errorDescription: String? {\n        switch self {\n        case .unsupportedApplication(let name): return \"\\(name) はまだ対応していません\"\n        case .noDocument: return \"前面に開いている文書がありません\"\n        case .unsavedDocument: return \"未保存の文書には絶対パスがありません\"\n        case .remoteURL(let url): return \"ローカルパスではなくURLです: \\(url.absoluteString)\"\n        case .invalidValue: return \"アプリから有効なパスを取得できませんでした\"\n        case .automationDenied: return \"このアプリの操作が許可されていません\"\n        case .applicationError(let message): return message\n        }\n    }\n}\n\npublic enum PathNormalizer {\n    public static func localPath(from rawValue: String) throws -\u003e String {\n        let raw = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !raw.isEmpty else { throw PathResolutionError.unsavedDocument }\n\n        if (raw.hasPrefix(\"file:\") || raw.contains(\"://\")),\n           let url = URL(string: raw), let scheme = url.scheme {\n            guard scheme.lowercased() == \"file\" else { throw PathResolutionError.remoteURL(url) }\n            guard url.isFileURL else { throw PathResolutionError.invalidValue(raw) }\n            return url.standardizedFileURL.path\n        }\n\n        if raw.hasPrefix(\"/\") {\n            return URL(fileURLWithPath: raw).standardizedFileURL.path\n        }\n\n        // Word's documented `full name` is an HFS path. Convert its volume and\n        // components directly, never by guessing from the document title.\n        if raw.contains(\":\") {\n            let parts = raw.split(separator: \":\", omittingEmptySubsequences: false).map(String.init)\n            guard parts.count \u003e 1, !parts[0].isEmpty else { throw PathResolutionError.invalidValue(raw) }\n            let rootVolume = try? URL(fileURLWithPath: \"/\").resourceValues(forKeys: [.volumeNameKey]).volumeName\n            let base = parts[0] == rootVolume ? \"/\" : \"/Volumes/\\(parts[0])\"\n            return URL(fileURLWithPath: base).appending(path: parts.dropFirst().joined(separator: \"/\")).standardizedFileURL.path\n        }\n\n        throw PathResolutionError.invalidValue(raw)\n    }\n}\n\npublic protocol DocumentValueProviding {\n    func documentValue(bundleIdentifier: String) throws -\u003e String\n}\n\npublic struct FrontDocumentResolver {\n    public let provider: any DocumentValueProviding\n\n    public init(provider: any DocumentValueProviding) { self.provider = provider }\n\n    public func resolve(bundleIdentifier: String, applicationName: String) throws -\u003e String {\n        let supported = [\"com.apple.Preview\", \"com.microsoft.Word\", \"com.apple.finder\"]\n        guard supported.contains(bundleIdentifier) else {\n            throw PathResolutionError.unsupportedApplication(applicationName)\n        }\n        return try PathNormalizer.localPath(from: provider.documentValue(bundleIdentifier: bundleIdentifier))\n    }\n}\n```\n\n正規化では次を区別しています。\n\n- `/Users/...`のようなPOSIX絶対パス\n- `file:///Users/...`のようなfile URL\n- `Archive:Reports:sample.docx`のようなHFS形式\n- `https://...`のようなリモートURL\n- 空文字列など、未保存文書を示す値\n\n`URL`を使うことで、file URLのパーセントエンコード、空白、日本語なども正しく戻せます。HFS形式では先頭要素をボリューム名として扱い、起動ボリュームなら`/`、それ以外なら`/Volumes/\u003cボリューム名\u003e`へ変換します。\n\n## 3. Sources/OpenFilePathApp/main.swift\n\n`Sources/OpenFilePathApp/main.swift`を作成します。\n\n```swift\nimport AppKit\nimport Foundation\nimport OpenFilePathCore\n\nfinal class AppleScriptDocumentProvider: DocumentValueProviding {\n    func documentValue(bundleIdentifier: String) throws -\u003e String {\n        let source: String\n        switch bundleIdentifier {\n        case \"com.apple.Preview\":\n            source = #\"tell application id \"com.apple.Preview\" to return (path of front document) as text\"#\n        case \"com.microsoft.Word\":\n            source = #\"tell application id \"com.microsoft.Word\" to return full name of active document as text\"#\n        case \"com.apple.finder\":\n            source = #\"tell application id \"com.apple.finder\" to if (count of selection) is 1 then return (item 1 of selection) as alias as text\"#\n        default:\n            throw PathResolutionError.unsupportedApplication(bundleIdentifier)\n        }\n\n        var errorInfo: NSDictionary?\n        guard let script = NSAppleScript(source: source),\n              let value = script.executeAndReturnError(\u0026errorInfo).stringValue else {\n            let number = errorInfo?[NSAppleScript.errorNumber] as? Int\n            if number == -1743 { throw PathResolutionError.automationDenied }\n            if number == -1728 { throw PathResolutionError.noDocument }\n            let message = errorInfo?[NSAppleScript.errorMessage] as? String ?? \"文書パスを取得できませんでした\"\n            throw PathResolutionError.applicationError(message)\n        }\n        return value\n    }\n}\n\n@MainActor\nfinal class AppDelegate: NSObject, NSApplicationDelegate {\n    private enum StatusIcon {\n        static let idle = \"doc.on.clipboard\"\n        static let success = \"checkmark.circle\"\n        static let error = \"exclamationmark.triangle\"\n    }\n\n    private var statusItem: NSStatusItem!\n    private let resolver = FrontDocumentResolver(provider: AppleScriptDocumentProvider())\n    private var resetWorkItem: DispatchWorkItem?\n\n    func applicationDidFinishLaunching(_ notification: Notification) {\n        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n        guard let button = statusItem.button else { return }\n        setStatusIcon(StatusIcon.idle)\n        button.toolTip = \"左クリック: 前面の文書パスをコピー / 右クリック: メニュー\"\n        button.target = self\n        button.action = #selector(statusItemClicked(_:))\n        button.sendAction(on: [.leftMouseUp, .rightMouseUp])\n    }\n\n    @objc private func statusItemClicked(_ sender: NSStatusBarButton) {\n        if NSApp.currentEvent?.type == .rightMouseUp {\n            let menu = NSMenu()\n            menu.addItem(withTitle: \"終了\", action: #selector(NSApplication.terminate(_:)), keyEquivalent: \"q\")\n            statusItem.menu = menu\n            sender.performClick(nil)\n            statusItem.menu = nil\n            return\n        }\n\n        // A status item click does not activate this accessory app. Capture the\n        // target before any UI feedback, then resolve immediately.\n        guard let app = NSWorkspace.shared.frontmostApplication,\n              app.bundleIdentifier != Bundle.main.bundleIdentifier else {\n            show(\"対象なし\", isError: true)\n            return\n        }\n\n        do {\n            let path = try resolver.resolve(\n                bundleIdentifier: app.bundleIdentifier ?? \"\",\n                applicationName: app.localizedName ?? \"このアプリ\"\n            )\n            NSPasteboard.general.clearContents()\n            NSPasteboard.general.setString(path, forType: .string)\n            show(\"✓ コピー済み\", isError: false)\n        } catch {\n            show(error.localizedDescription, isError: true)\n            presentError(error, applicationName: app.localizedName ?? \"このアプリ\")\n        }\n    }\n\n    private func show(_ text: String, isError: Bool) {\n        resetWorkItem?.cancel()\n        setStatusIcon(isError ? StatusIcon.error : StatusIcon.success)\n        statusItem.button?.toolTip = text\n        let work = DispatchWorkItem { [weak self] in\n            self?.setStatusIcon(StatusIcon.idle)\n            self?.statusItem.button?.toolTip = \"左クリック: 前面の文書パスをコピー / 右クリック: メニュー\"\n        }\n        resetWorkItem = work\n        DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work)\n    }\n\n    private func setStatusIcon(_ symbolName: String) {\n        let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: \"文書パスをコピー\")\n        image?.isTemplate = true\n        statusItem.button?.image = image\n        statusItem.button?.imagePosition = .imageOnly\n        statusItem.button?.title = \"\"\n    }\n\n    private func presentError(_ error: Error, applicationName: String) {\n        let alert = NSAlert()\n        alert.alertStyle = .warning\n        alert.messageText = \"パスをコピーできませんでした\"\n        alert.informativeText = errorGuidance(error, applicationName: applicationName)\n        alert.addButton(withTitle: \"OK\")\n\n        if case PathResolutionError.automationDenied = error {\n            alert.addButton(withTitle: \"オートメーション設定を開く\")\n        }\n\n        NSApp.activate(ignoringOtherApps: true)\n        let response = alert.runModal()\n        if response == .alertSecondButtonReturn,\n           let settingsURL = URL(string: \"x-apple.systempreferences:com.apple.preference.security?Privacy_Automation\") {\n            NSWorkspace.shared.open(settingsURL)\n        }\n    }\n\n    private func errorGuidance(_ error: Error, applicationName: String) -\u003e String {\n        switch error {\n        case PathResolutionError.unsupportedApplication:\n            return \"\\(applicationName) は現在の対応対象ではありません。PreviewまたはMicrosoft Wordで保存済み文書を前面にするか、Finderで項目を1件選択してから、もう一度クリックしてください。\"\n        case PathResolutionError.automationDenied:\n            return \"\\(applicationName) の操作が許可されていません。システム設定の「プライバシーとセキュリティ \u003e オートメーション」でOpenFilePathを許可してください。\"\n        case PathResolutionError.noDocument:\n            return \"\\(applicationName) に開いている文書がないか、Finderで選択している項目が1件ではありません。対象を開くか1件だけ選択してから、もう一度クリックしてください。\"\n        case PathResolutionError.unsavedDocument:\n            return \"前面の文書はまだ保存されていません。文書を保存してから、もう一度クリックしてください。\"\n        default:\n            return \"対象: \\(applicationName)\\n\\(error.localizedDescription)\"\n        }\n    }\n}\n\nlet app = NSApplication.shared\nlet delegate = AppDelegate()\napp.delegate = delegate\napp.setActivationPolicy(.accessory)\napp.run()\n```\n\n`LSUIElement`を有効にしたアクセサリアプリなので、通常のアプリウィンドウやDockアイコンは表示しません。状態はSF Symbolsのアイコンで示し、エラー時は理由と対処をダイアログに表示します。\n\nクリップボードを書き換えるのは左クリックが成功した場合だけです。バックグラウンドでクリップボードを監視したり、文書を監視したりはしません。\n\n## 4. Tests/OpenFilePathCoreTests/PathResolverTests.swift\n\n`Tests/OpenFilePathCoreTests/PathResolverTests.swift`を作成します。\n\n```swift\nimport Foundation\nimport Testing\n@testable import OpenFilePathCore\n\nprivate struct FakeProvider: DocumentValueProviding {\n    let value: String\n    func documentValue(bundleIdentifier: String) throws -\u003e String { value }\n}\n\n@Test func decodesFileURLWithSpacesAndUnicode() throws {\n    let path = try PathNormalizer.localPath(from: \"file:///tmp/%E6%97%A5%E6%9C%AC%20file.pdf\")\n    #expect(path == \"/tmp/日本 file.pdf\")\n}\n\n@Test func preservesPOSIXUnicodePath() throws {\n    #expect(try PathNormalizer.localPath(from: \"/tmp/資料 1.docx\") == \"/tmp/資料 1.docx\")\n}\n\n@Test func convertsWordHFSPathOnExternalVolume() throws {\n    #expect(try PathNormalizer.localPath(from: \"Archive:Reports:資料 1.docx\") == \"/Volumes/Archive/Reports/資料 1.docx\")\n}\n\n@Test func rejectsRemoteURLInsteadOfInventingLocalPath() {\n    #expect(throws: PathResolutionError.remoteURL(URL(string: \"https://example.sharepoint.com/a.docx\")!)) {\n        try PathNormalizer.localPath(from: \"https://example.sharepoint.com/a.docx\")\n    }\n}\n\n@Test func rejectsUnsavedDocument() {\n    #expect(throws: PathResolutionError.unsavedDocument) { try PathNormalizer.localPath(from: \"  \") }\n}\n\n@Test func resolverUsesAdapterValueNotWindowTitle() throws {\n    let resolver = FrontDocumentResolver(provider: FakeProvider(value: \"/tmp/actual.pdf\"))\n    #expect(try resolver.resolve(bundleIdentifier: \"com.apple.Preview\", applicationName: \"Preview\") == \"/tmp/actual.pdf\")\n}\n\n@Test func rejectsUnsupportedApplication() {\n    let resolver = FrontDocumentResolver(provider: FakeProvider(value: \"/tmp/x\"))\n    #expect(throws: PathResolutionError.unsupportedApplication(\"TextEdit\")) {\n        try resolver.resolve(bundleIdentifier: \"com.apple.TextEdit\", applicationName: \"TextEdit\")\n    }\n}\n```\n\nこのテストはfake providerを使うため、PreviewやWordを起動せず、クリップボードも変更せずに実行できます。\n\n## 5. App/Info.plist\n\n`App/Info.plist`を作成します。\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003c!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"\u003e\n\u003cplist version=\"1.0\"\u003e\u003cdict\u003e\n  \u003ckey\u003eCFBundleExecutable\u003c/key\u003e\u003cstring\u003eOpenFilePath\u003c/string\u003e\n  \u003ckey\u003eCFBundleIdentifier\u003c/key\u003e\u003cstring\u003elocal.example.OpenFilePath\u003c/string\u003e\n  \u003ckey\u003eCFBundleName\u003c/key\u003e\u003cstring\u003eOpenFilePath\u003c/string\u003e\n  \u003ckey\u003eCFBundlePackageType\u003c/key\u003e\u003cstring\u003eAPPL\u003c/string\u003e\n  \u003ckey\u003eCFBundleShortVersionString\u003c/key\u003e\u003cstring\u003e0.2.0\u003c/string\u003e\n  \u003ckey\u003eCFBundleVersion\u003c/key\u003e\u003cstring\u003e1\u003c/string\u003e\n  \u003ckey\u003eLSUIElement\u003c/key\u003e\u003ctrue/\u003e\n  \u003ckey\u003eNSAppleEventsUsageDescription\u003c/key\u003e\u003cstring\u003e前面で開いている文書の保存先パスを取得するため、選択したアプリに問い合わせます。\u003c/string\u003e\n  \u003ckey\u003eNSPrincipalClass\u003c/key\u003e\u003cstring\u003eNSApplication\u003c/string\u003e\n\u003c/dict\u003e\u003c/plist\u003e\n```\n\n`LSUIElement`はアプリをDockに表示しないための設定です。`NSAppleEventsUsageDescription`には、なぜApple Eventsを使うのかをユーザー向けに記述します。\n\n`CFBundleIdentifier`は自分の識別子へ変更して構いません。Apple Developer IDによる配布署名を行う場合は、その署名設定と一致する識別子を使ってください。\n\nこの構成ではentitlementsファイルは不要です。App Sandboxを有効にして配布する設計へ変更する場合は、Apple Events関連のsandbox要件を別途検討する必要があります。\n\n## 6. build.sh\n\n`build.sh`を作成します。\n\n```zsh\n#!/bin/zsh\nset -euo pipefail\n\nproject_dir=${0:A:h}\nbuild_dir=\"$project_dir/build\"\napp_dir=\"$build_dir/OpenFilePath.app\"\nscratch_dir=\"$build_dir/SwiftPM\"\nif [[ -d /Applications/Xcode.app/Contents/Developer ]]; then\n  export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer\nfi\nexport CLANG_MODULE_CACHE_PATH=\"$scratch_dir/ModuleCache\"\nexport SWIFTPM_MODULECACHE_OVERRIDE=\"$scratch_dir/ModuleCache\"\n\n/usr/bin/xcrun swift build \\\n  --disable-sandbox \\\n  --package-path \"$project_dir\" \\\n  --scratch-path \"$scratch_dir\" \\\n  -c release \\\n  --arch arm64 \\\n  --arch x86_64 \\\n  --product OpenFilePath\nbinary_path=$(/usr/bin/xcrun swift build \\\n  --package-path \"$project_dir\" \\\n  --scratch-path \"$scratch_dir\" \\\n  -c release \\\n  --arch arm64 \\\n  --arch x86_64 \\\n  --show-bin-path)\n\n/bin/rm -rf \"$app_dir\"\n/bin/mkdir -p \"$app_dir/Contents/MacOS\"\n/bin/cp \"$project_dir/App/Info.plist\" \"$app_dir/Contents/Info.plist\"\n/bin/cp \"$binary_path/OpenFilePath\" \"$app_dir/Contents/MacOS/OpenFilePath\"\n/usr/bin/codesign --force --sign - \"$app_dir\"\necho \"$app_dir\"\n```\n\n実行権限を付けます。\n\n```bash\nchmod +x build.sh\n```\n\nこのスクリプトはSwiftPMでUniversal Binaryを作り、最小構成の`.app`バンドルへ配置した後、ローカル実行用のad-hoc署名を行います。Developer ID署名や公証は行いません。\n\n`--disable-sandbox`はSwiftPM自身のビルド用sandboxを無効にするオプションであり、macOSのSIPやGatekeeper、アプリのプライバシー保護を無効にするものではありません。\n\n## テスト\n\nプロジェクトルートで実行します。\n\n```bash\ncd ~/OpenFilePath\nswift test --disable-sandbox\n```\n\n成功時はSwift Testingの結果に7 tests passedと表示されます。\n\n## ビルド\n\n```bash\ncd ~/OpenFilePath\n./build.sh\n```\n\n成功すると次の場所にアプリが生成されます。\n\n```text\n~/OpenFilePath/build/OpenFilePath.app\n```\n\nバンドルと署名を追加確認する場合は次を実行します。\n\n```bash\nplutil -lint build/OpenFilePath.app/Contents/Info.plist\ncodesign --verify --deep --strict build/OpenFilePath.app\nfile build/OpenFilePath.app/Contents/MacOS/OpenFilePath\n```\n\n`file`の結果に`arm64`と`x86_64`が含まれていればUniversal Binaryです。\n\n## 起動と使い方\n\n実際に対象アプリへ問い合わせるとオートメーション権限ダイアログが表示される可能性があります。内容を確認してから起動してください。\n\n```bash\nopen build/OpenFilePath.app\n```\n\n1. PreviewまたはWordで保存済み文書を前面にします。Finderの場合は項目を1件だけ選択します。\n2. メニューバーの書類アイコンを左クリックします。\n3. チェックマークが表示されたら、絶対パスがクリップボードに入っています。\n\n右クリックすると「終了」メニューが表示されます。\n\n初回の問い合わせ時にmacOSがオートメーション権限を求めた場合は、対象と理由を確認して許可します。後から変更する場合は「システム設定 \u003e プライバシーとセキュリティ \u003e オートメーション」でOpenFilePathの対象アプリへの許可を変更できます。\n\n## エラー時の確認\n\n### 「このアプリの操作が許可されていません」\n\n「システム設定 \u003e プライバシーとセキュリティ \u003e オートメーション」で、OpenFilePathから対象アプリへの許可を確認します。アクセシビリティやフルディスクアクセスを追加する必要はありません。\n\n### 「前面に開いている文書がありません」\n\nPreviewまたはWordで文書が開かれているか確認します。Finderの場合は、選択が0件または複数件だと取得しません。1件だけ選択してください。\n\n### 未保存の文書\n\n未保存文書にはローカル絶対パスがありません。先に保存してから再実行します。\n\n### OneDriveやSharePointの文書\n\nWordが同期済みローカルファイルのパスを返す場合はコピーできます。Wordが`https:` URLだけを返す場合、この実装はローカル絶対パスを推測しません。URLとローカル同期先の対応を誤ると、存在しないパスや別ファイルのパスを返す危険があるためです。\n\n### 開発元を確認できないという警告\n\nこの手順の署名はad-hoc署名で、Developer ID署名やAppleの公証ではありません。自分のMacでビルドして試す用途を想定しています。第三者へ配布する場合はDeveloper ID署名と公証を行ってください。SIPやGatekeeperを無効化する方法は推奨しません。\n\n## 終了と削除\n\n終了するには、メニューバーアイコンを右クリックして「終了」を選びます。\n\nログイン項目は登録していません。不要になったらアプリを終了し、作成した`~/OpenFilePath`ディレクトリを削除すれば完了です。すでに付与したオートメーション権限が気になる場合は、システム設定のオートメーション画面で許可状態を確認してください。\n\n## 現在の制限\n\n- 対応対象はPreview、Microsoft Word、Finderのみ\n- Finderは1項目選択のみ\n- 未保存文書は非対応\n- リモートURLからローカル同期パスを推測しない\n- グローバルショートカットやログイン時起動は実装していない\n- AppleScriptアダプタは構文コンパイルまで確認済みだが、この記事の検証では実アプリへのApple Events送信を実行していない\n- Developer ID署名、公証、Mac App Store向けsandbox構成は含まない\n\n## セキュリティ面で行っていないこと\n\nこのアプリは次の処理を行いません。\n\n- ディスク全体の検索\n- ファイル内容の読み取り\n- 常時のウィンドウ監視\n- クリップボードの監視\n- 自動的なクリップボード書き換え\n- ネットワーク通信やテレメトリ\n- アクセシビリティ権限、画面収録権限、フルディスクアクセスの要求\n\nパス取得とクリップボード書き込みは、メニューバーアイコンを明示的に左クリックしたときだけ発生します。\n\n## 参考資料\n\n- [NSWorkspace.frontmostApplication — Apple Developer Documentation](https://developer.apple.com/documentation/appkit/nsworkspace/frontmostapplication)\n- [NSAppleScript — Apple Developer Documentation](https://developer.apple.com/documentation/foundation/nsapplescript)\n- [LSUIElement — Apple Developer Documentation](https://developer.apple.com/documentation/bundleresources/information-property-list/lsuielement)\n\n## まとめ\n\n前面文書の絶対パス取得で難しいのは、文字列をクリップボードへ入れる部分ではなく、「どの文書か」と「返された値が本当にローカルパスか」を誤認しないことです。\n\nこの実装では、クリック直後の前面アプリをBundle IDで判定し、アプリ自身が返した文書情報だけを使います。さらに、POSIXパス、file URL、WordのHFS形式、リモートURL、未保存文書を分けて扱います。対応アプリを追加する場合も、`DocumentValueProviding`へアダプタを追加し、正規化とUIを分離したまま拡張できます。\n","coediting":false,"comments_count":0,"created_at":"2026-11-01T14:39:00+09:00","group":null,"id":"547a19019992f1f52fc2","likes_count":0,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Mac","versions":[]}],"title":"PreviewやWordで開いているファイルの絶対パスを1クリックでコピーするmacOSメニューバーアプリをSwiftで作る","updated_at":"2026-11-01T14:39:00+09:00","url":"https://qiita.com/kanonundgigue/items/547a19019992f1f52fc2","user":{"description":"Scientist","facebook_id":"kanonundgigue","followees_count":2,"followers_count":2,"github_login_name":"kanonundgigue","id":"kanonundgigue","items_count":41,"linkedin_id":"","location":"","name":"Kanon KINO","organization":"UTokyo","permanent_id":83663,"profile_image_url":"https://qiita-image-store.s3.amazonaws.com/0/83663/profile-images/1473762423","team_only":false,"twitter_screen_name":"kanonundgigue","website_url":"https://kanon-kino.jimdosite.com/"},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003cp data-sourcepos=\"1:1-1:352\"\u003e展示会の案内PDFから、出展やセミナーの申込先を一覧にしようとしました。まず \u003ccode\u003epdftotext\u003c/code\u003e で文字を抜いてURLを探しましたが、1本も出てきません。「申込先が書かれていない案内」として報告しかけたところで、紙面にQRコードが並んでいることに気づきました。\u003c/p\u003e\n\u003cp data-sourcepos=\"3:1-3:256\"\u003e数えると、申込の入口は7本。そのうち6本は\u003cstrong\u003eQRコードの中にしかURLが無く\u003c/strong\u003e、本文の文字から拾えたものは0本でした。チラシの面は画像1枚で、文字として抜けるものがそもそもありません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"5:1-5:9\"\u003e\n\u003cspan id=\"原因\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%8E%9F%E5%9B%A0\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e原因\u003c/h2\u003e\n\u003cp data-sourcepos=\"6:1-6:291\"\u003e\u003ccode\u003epdftotext\u003c/code\u003e が取り出すのは PDF の中の文字だけです。QRコードは画像なので、中のURLは出てきません。案内のチラシは「詳しくはQRから」で済ませてURLを書かないことが多く、文字だけ見ると「申込先なし」に見えます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"8:1-8:43\"\u003e\n\u003cspan id=\"直し方1ページを画像にする\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%9B%B4%E3%81%97%E6%96%B91%E3%83%9A%E3%83%BC%E3%82%B8%E3%82%92%E7%94%BB%E5%83%8F%E3%81%AB%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e直し方1：ページを画像にする\u003c/h2\u003e\n\u003cp data-sourcepos=\"9:1-9:126\"\u003epoppler の \u003ccode\u003epdftoppm\u003c/code\u003e で、ページを高めの解像度で PNG にします（Homebrew なら \u003ccode\u003ebrew install poppler\u003c/code\u003e）。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"11:1-14:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003epdftoppm \u003cspan class=\"nt\"\u003e-r\u003c/span\u003e 400 \u003cspan class=\"nt\"\u003e-png\u003c/span\u003e flyer.pdf page\n\u003cspan class=\"c\"\u003e# page-1.png, page-2.png ... ができる\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"16:1-16:49\"\u003e\n\u003cspan id=\"直し方2swift-の-vision-で-qr-を読む\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%9B%B4%E3%81%97%E6%96%B92swift-%E3%81%AE-vision-%E3%81%A7-qr-%E3%82%92%E8%AA%AD%E3%82%80\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e直し方2：Swift の Vision で QR を読む\u003c/h2\u003e\n\u003cp data-sourcepos=\"17:1-17:182\"\u003emacOS なら、標準の Vision フレームワークで QR を読めます。\u003ccode\u003eswiftc\u003c/code\u003e は Command Line Tools に入っているので、追加で入れるものはありません。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"19:1-40:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003ereadqr.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eFoundation\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eVision\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eAppKit\u003c/span\u003e\n\n\u003cspan class=\"c1\"\u003e// 使い方: ./readqr page-1.png page-2.png ...\u003c/span\u003e\n\u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"n\"\u003epath\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e \u003cspan class=\"kt\"\u003eCommandLine\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003earguments\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003edropFirst\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eimg\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSImage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003econtentsOfFile\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003epath\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n          \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecg\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eimg\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecgImage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eforProposedRect\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003econtext\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003ehints\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"\u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003epath\u003c/span\u003e\u003cspan class=\"se\"\u003e)\\t\u003c/span\u003e\u003cspan class=\"s\"\u003e読めない\"\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e \u003cspan class=\"k\"\u003econtinue\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ereq\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eVNDetectBarcodesRequest\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n    \u003cspan class=\"n\"\u003ereq\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esymbologies\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eqr\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n    \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"kt\"\u003eVNImageRequestHandler\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ecgImage\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003ecg\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eperform\u003c/span\u003e\u003cspan class=\"p\"\u003e([\u003c/span\u003e\u003cspan class=\"n\"\u003ereq\u003c/span\u003e\u003cspan class=\"p\"\u003e])\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003efound\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ereq\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eresults\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"p\"\u003e[]\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"\u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003epath\u003c/span\u003e\u003cspan class=\"se\"\u003e)\\t\u003c/span\u003e\u003cspan class=\"s\"\u003eQR \u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003efound\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e\u003cspan class=\"se\"\u003e)\u003c/span\u003e\u003cspan class=\"s\"\u003e 個\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"n\"\u003er\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e \u003cspan class=\"n\"\u003efound\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eb\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003er\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eboundingBox\u003c/span\u003e  \u003cspan class=\"c1\"\u003e// 左下が原点・0〜1 の割合\u003c/span\u003e\n        \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eformat\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"  x=%.2f y=%.2f\u003c/span\u003e\u003cspan class=\"se\"\u003e\\t\u003c/span\u003e\u003cspan class=\"s\"\u003e%@\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emidX\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eb\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emidY\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003er\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epayloadStringValue\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"s\"\u003e\"(文字なし)\"\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"42:1-48:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eswiftc \u003cspan class=\"nt\"\u003e-O\u003c/span\u003e readqr.swift \u003cspan class=\"nt\"\u003e-o\u003c/span\u003e readqr\n./readqr page-\u003cspan class=\"k\"\u003e*\u003c/span\u003e.png\n\u003cspan class=\"c\"\u003e# page-1.png\tQR 2 個\u003c/span\u003e\n\u003cspan class=\"c\"\u003e#   x=0.71 y=0.44\thttps://example.com/apply-b\u003c/span\u003e\n\u003cspan class=\"c\"\u003e#   x=0.21 y=0.44\thttps://example.com/apply-a\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"50:1-50:58\"\u003e\n\u003cspan id=\"直し方3数と位置を紙面と突き合わせる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%9B%B4%E3%81%97%E6%96%B93%E6%95%B0%E3%81%A8%E4%BD%8D%E7%BD%AE%E3%82%92%E7%B4%99%E9%9D%A2%E3%81%A8%E7%AA%81%E3%81%8D%E5%90%88%E3%82%8F%E3%81%9B%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e直し方3：数と位置を紙面と突き合わせる\u003c/h2\u003e\n\u003cul data-sourcepos=\"51:1-54:0\"\u003e\n\u003cli data-sourcepos=\"51:1-51:265\"\u003e\n\u003cstrong\u003e読めた個数が、紙面のQRの個数と合うかを数えます。\u003c/strong\u003e 足りなければ解像度を上げるか、QRのあたりを切り抜いてから読み直します。小さいQRが並んでいると、400dpiでも取りこぼすことがあります\u003c/li\u003e\n\u003cli data-sourcepos=\"52:1-52:288\"\u003e出力の順番は紙面の順番ではありません。\u003ccode\u003eboundingBox\u003c/code\u003e の位置（x が左右、y が上下。y は下から数える）で、「どのQRがどの申込か」を紙面の見出しと対応させます。URLだけ並べても、どれが何の申込か分かりません\u003c/li\u003e\n\u003cli data-sourcepos=\"53:1-54:0\"\u003e短縮URLは、行き先を確かめてから使います\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"55:1-57:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003ecurl \u003cspan class=\"nt\"\u003e-sSL\u003c/span\u003e \u003cspan class=\"nt\"\u003e-o\u003c/span\u003e /dev/null \u003cspan class=\"nt\"\u003e-w\u003c/span\u003e \u003cspan class=\"s1\"\u003e'%{url_effective}\\n'\u003c/span\u003e https://短縮URL\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"59:1-59:39\"\u003e\n\u003cspan id=\"読めたら開いて確かめる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%AA%AD%E3%82%81%E3%81%9F%E3%82%89%E9%96%8B%E3%81%84%E3%81%A6%E7%A2%BA%E3%81%8B%E3%82%81%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e読めたら、開いて確かめる\u003c/h2\u003e\n\u003cp data-sourcepos=\"60:1-60:235\"\u003eURLが読めたことと、申し込めることは別です。今回は読み出した申込先を実際に開くと、3本はもう締切か満席でした。一覧にするなら「開いて受付中か」まで見て書きます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"62:1-62:12\"\u003e\n\u003cspan id=\"まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eまとめ\u003c/h2\u003e\n\u003cul data-sourcepos=\"63:1-66:0\"\u003e\n\u003cli data-sourcepos=\"63:1-63:160\"\u003e案内PDFの申込先は、QRコードの中にしか無いことがある。\u003ccode\u003epdftotext\u003c/code\u003e で何も出なくても「書かれていない」とは言えない\u003c/li\u003e\n\u003cli data-sourcepos=\"64:1-64:133\"\u003e\n\u003ccode\u003epdftoppm\u003c/code\u003e で画像にして、Swift の \u003ccode\u003eVNDetectBarcodesRequest\u003c/code\u003e で読む。macOS なら追加のライブラリは要らない\u003c/li\u003e\n\u003cli data-sourcepos=\"65:1-66:0\"\u003e読めた個数を紙面と数え合わせ、位置で対応を付け、開いて受付中かを確かめる\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr data-sourcepos=\"67:1-67:3\"\u003e\n\u003cp data-sourcepos=\"68:1-69:29\"\u003e生成AI（Claude Code）に書かせて一人で会社を回している記録を note に書いています。\u003cbr\u003e\n\u003ca href=\"https://note.com/kaito_cometa\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://note.com/kaito_cometa\u003c/a\u003e\u003c/p\u003e\n","body":"展示会の案内PDFから、出展やセミナーの申込先を一覧にしようとしました。まず `pdftotext` で文字を抜いてURLを探しましたが、1本も出てきません。「申込先が書かれていない案内」として報告しかけたところで、紙面にQRコードが並んでいることに気づきました。\n\n数えると、申込の入口は7本。そのうち6本は**QRコードの中にしかURLが無く**、本文の文字から拾えたものは0本でした。チラシの面は画像1枚で、文字として抜けるものがそもそもありません。\n\n## 原因\n`pdftotext` が取り出すのは PDF の中の文字だけです。QRコードは画像なので、中のURLは出てきません。案内のチラシは「詳しくはQRから」で済ませてURLを書かないことが多く、文字だけ見ると「申込先なし」に見えます。\n\n## 直し方1：ページを画像にする\npoppler の `pdftoppm` で、ページを高めの解像度で PNG にします（Homebrew なら `brew install poppler`）。\n\n```bash\npdftoppm -r 400 -png flyer.pdf page\n# page-1.png, page-2.png ... ができる\n```\n\n## 直し方2：Swift の Vision で QR を読む\nmacOS なら、標準の Vision フレームワークで QR を読めます。`swiftc` は Command Line Tools に入っているので、追加で入れるものはありません。\n\n```swift:readqr.swift\nimport Foundation\nimport Vision\nimport AppKit\n\n// 使い方: ./readqr page-1.png page-2.png ...\nfor path in CommandLine.arguments.dropFirst() {\n    guard let img = NSImage(contentsOfFile: path),\n          let cg = img.cgImage(forProposedRect: nil, context: nil, hints: nil) else {\n        print(\"\\(path)\\t読めない\"); continue\n    }\n    let req = VNDetectBarcodesRequest()\n    req.symbologies = [.qr]\n    try VNImageRequestHandler(cgImage: cg).perform([req])\n    let found = req.results ?? []\n    print(\"\\(path)\\tQR \\(found.count) 個\")\n    for r in found {\n        let b = r.boundingBox  // 左下が原点・0〜1 の割合\n        print(String(format: \"  x=%.2f y=%.2f\\t%@\", b.midX, b.midY, r.payloadStringValue ?? \"(文字なし)\"))\n    }\n}\n```\n\n```bash\nswiftc -O readqr.swift -o readqr\n./readqr page-*.png\n# page-1.png\tQR 2 個\n#   x=0.71 y=0.44\thttps://example.com/apply-b\n#   x=0.21 y=0.44\thttps://example.com/apply-a\n```\n\n## 直し方3：数と位置を紙面と突き合わせる\n- **読めた個数が、紙面のQRの個数と合うかを数えます。** 足りなければ解像度を上げるか、QRのあたりを切り抜いてから読み直します。小さいQRが並んでいると、400dpiでも取りこぼすことがあります\n- 出力の順番は紙面の順番ではありません。`boundingBox` の位置（x が左右、y が上下。y は下から数える）で、「どのQRがどの申込か」を紙面の見出しと対応させます。URLだけ並べても、どれが何の申込か分かりません\n- 短縮URLは、行き先を確かめてから使います\n\n```bash\ncurl -sSL -o /dev/null -w '%{url_effective}\\n' https://短縮URL\n```\n\n## 読めたら、開いて確かめる\nURLが読めたことと、申し込めることは別です。今回は読み出した申込先を実際に開くと、3本はもう締切か満席でした。一覧にするなら「開いて受付中か」まで見て書きます。\n\n## まとめ\n- 案内PDFの申込先は、QRコードの中にしか無いことがある。`pdftotext` で何も出なくても「書かれていない」とは言えない\n- `pdftoppm` で画像にして、Swift の `VNDetectBarcodesRequest` で読む。macOS なら追加のライブラリは要らない\n- 読めた個数を紙面と数え合わせ、位置で対応を付け、開いて受付中かを確かめる\n\n---\n生成AI（Claude Code）に書かせて一人で会社を回している記録を note に書いています。\nhttps://note.com/kaito_cometa\n","coediting":false,"comments_count":0,"created_at":"2026-10-27T10:44:55+09:00","group":null,"id":"dce1c71daab66c6534d8","likes_count":0,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"PDF","versions":[]},{"name":"QRコード","versions":[]},{"name":"Swift","versions":[]},{"name":"macOS","versions":[]},{"name":"自動化","versions":[]}],"title":"案内PDFに申込先のURLが無い？ → QRコードの中にしか無かった。pdftoppm と Swift の Vision で全部読み出す","updated_at":"2026-10-27T10:44:55+09:00","url":"https://qiita.com/20051215kaito/items/dce1c71daab66c6534d8","user":{"description":"学生をしながら一人で会社を運営。開発も事務も生成AI（Claude Code）に書かせ、決めるのと確かめるのは自分。Qiitaには実装の要点、経緯と数字はnoteに残しています。","facebook_id":"","followees_count":2,"followers_count":2,"github_login_name":null,"id":"20051215kaito","items_count":16,"linkedin_id":"","location":"","name":"ひとり社長とAI","organization":"","permanent_id":4127486,"profile_image_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/4127486/15b380950caccdc1c649ce76d3e2d59c7f78e977/x_large.png?1791951482","team_only":false,"twitter_screen_name":null,"website_url":"https://note.com/kaito_cometa"},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch1 data-sourcepos=\"1:1-1:14\"\u003e\n\u003cspan id=\"はじめに\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eはじめに\u003c/h1\u003e\n\u003cp data-sourcepos=\"2:1-2:134\"\u003eアプリで使用するパーツを作り終えたので、Firebaseを使って、データの保存・取得を試しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"4:1-4:36\"\u003eFirestoreを使っている中で、\u003c/p\u003e\n\u003cp data-sourcepos=\"6:1-7:95\"\u003e「リレーショナルデータベースなのか？」\u003cbr\u003e\n「NoSQLって聞くけど、リレーショナルデータベースと何が違うのか？」\u003c/p\u003e\n\u003cp data-sourcepos=\"9:1-9:33\"\u003eについてまとめました。\u003c/p\u003e\n\u003cp data-sourcepos=\"11:1-11:206\"\u003e今回は、リレーショナルデータベース（RDB）とNoSQLの違いから、Cloud Firestoreがどのようにデータを保存しているのかまで、自分なりに整理していきます。\u003c/p\u003e\n\u003ch1 data-sourcepos=\"13:1-13:22\"\u003e\n\u003cspan id=\"1-firebaseとは\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#1-firebase%E3%81%A8%E3%81%AF\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e1. Firebaseとは？\u003c/h1\u003e\n\u003cp data-sourcepos=\"15:1-15:228\"\u003eGoogleが提供する、アプリやWebサービスを開発するためのプラットフォームです。アプリの「裏側」にあたる機能を、すぐに使える部品としてまとめて提供してくれます。\u003c/p\u003e\n\u003cp data-sourcepos=\"17:1-17:185\"\u003eここでいう裏側とは、普段私たちが見ているアプリの画面(フロント)ではなく、データ保存したりやログイン認証したりする機能のこと。\u003c/p\u003e\n\u003cp data-sourcepos=\"19:1-20:29\"\u003e最初は、\u003cbr\u003e\nFirebase = データベース\u003c/p\u003e\n\u003cp data-sourcepos=\"22:1-22:33\"\u003eと思っていたのですが、\u003c/p\u003e\n\u003cp data-sourcepos=\"24:1-24:119\"\u003eFirebaseは、アプリ開発に必要なさまざまな機能を提供しているプラットフォームでした。\u003c/p\u003e\n\u003cp data-sourcepos=\"26:1-27:85\"\u003eその中の一つとしてデータベースが存在すると理解しています。\u003cbr\u003e\n今回、自分が個人開発で使用しているのは\u003cstrong\u003eCloud Firestore\u003c/strong\u003eです。\u003c/p\u003e\n\u003cp data-sourcepos=\"29:1-29:114\"\u003eCloud Firestoreは、分類としては\u003cstrong\u003eNoSQL（非リレーショナル）データベース\u003c/strong\u003eになります。\u003c/p\u003e\n\u003ch1 data-sourcepos=\"31:1-31:62\"\u003e\n\u003cspan id=\"2-リレーショナルデータベースrdbとは\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#2-%E3%83%AA%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%8A%E3%83%AB%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9rdb%E3%81%A8%E3%81%AF\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e2. リレーショナルデータベース（RDB）とは？\u003c/h1\u003e\n\u003cp data-sourcepos=\"33:1-33:78\"\u003eまず、リレーショナルデータベースについて整理します。\u003c/p\u003e\n\u003cp data-sourcepos=\"35:1-35:30\"\u003e代表的なものとして、\u003c/p\u003e\n\u003cul data-sourcepos=\"37:1-40:0\"\u003e\n\u003cli data-sourcepos=\"37:1-37:7\"\u003eMySQL\u003c/li\u003e\n\u003cli data-sourcepos=\"38:1-38:12\"\u003ePostgreSQL\u003c/li\u003e\n\u003cli data-sourcepos=\"39:1-40:0\"\u003eSQLite\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"41:1-42:88\"\u003eがあると認識しています。\u003cbr\u003e\nRDBでは、基本的にデータを**テーブル（表）**として管理します。\u003c/p\u003e\n\u003ch1 data-sourcepos=\"45:1-45:67\"\u003e\n\u003cspan id=\"3-非リレーショナルデータベースnosqlとは\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#3-%E9%9D%9E%E3%83%AA%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%8A%E3%83%AB%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9nosql%E3%81%A8%E3%81%AF\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e3. 非リレーショナルデータベース（NoSQL）とは？\u003c/h1\u003e\n\u003cp data-sourcepos=\"47:1-47:96\"\u003e一方で、Cloud Firestoreは非リレーショナルデータベースに分類されます。\u003c/p\u003e\n\u003cp data-sourcepos=\"49:1-49:134\"\u003eNoSQLは、RDBのようなテーブル形式だけに限定せず、別のデータモデルを使ってデータを管理します。\u003c/p\u003e\n\u003cp data-sourcepos=\"51:1-51:12\"\u003eただし、\u003c/p\u003e\n\u003cp data-sourcepos=\"53:1-53:35\"\u003e「NoSQL = 全部同じ仕組み」\u003c/p\u003e\n\u003cp data-sourcepos=\"55:1-55:39\"\u003eというわけではありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"57:1-57:153\"\u003eNoSQLにもいくつか種類があるそうですが、Cloud Firestoreはその中でも\u003cstrong\u003eドキュメント指向データベース\u003c/strong\u003eになります。\u003c/p\u003e\n\u003cp data-sourcepos=\"59:1-59:18\"\u003eFirestoreでは、\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"61:1-67:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eCollection\n    ↓\nDocument\n    ↓\nField\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"69:1-69:48\"\u003eという構造でデータを保存します。\u003c/p\u003e\n\u003ch1 data-sourcepos=\"71:1-71:43\"\u003e\n\u003cspan id=\"4-collectiondocumentfieldとは\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#4-collectiondocumentfield%E3%81%A8%E3%81%AF\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e4. Collection・Document・Fieldとは？\u003c/h1\u003e\n\u003cp data-sourcepos=\"73:1-73:72\"\u003e例えば、ユーザー情報をFirestoreに保存するとします。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"75:1-82:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eusers                 ← Collection\n\n └── user001          ← Document\n\n      name: \"kita\"    ← Field\n      age: 30         ← Field\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"84:1-84:15\"\u003eそれぞれ、\u003c/p\u003e\n\u003cul data-sourcepos=\"86:1-89:0\"\u003e\n\u003cli data-sourcepos=\"86:1-86:24\"\u003e\n\u003ccode\u003eusers\u003c/code\u003e → Collection\u003c/li\u003e\n\u003cli data-sourcepos=\"87:1-87:24\"\u003e\n\u003ccode\u003euser001\u003c/code\u003e → Document\u003c/li\u003e\n\u003cli data-sourcepos=\"88:1-89:0\"\u003e\n\u003ccode\u003ename\u003c/code\u003e、\u003ccode\u003eage\u003c/code\u003e → Field\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"90:1-90:18\"\u003eとなります。\u003c/p\u003e\n\u003cp data-sourcepos=\"92:1-92:75\"\u003eRDBと比較すると、考え方の違いが分かるかと思います。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"94:1-102:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eRDB\n\nTable\n ↓\nRow\n ↓\nColumn\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"104:1-104:18\"\u003eFirestoreでは、\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"106:1-114:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eFirestore\n\nCollection\n ↓\nDocument\n ↓\nField\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"116:1-116:18\"\u003eとなります。\u003c/p\u003e\n\u003cp data-sourcepos=\"118:1-118:177\"\u003e完全に同じものではありませんが、最初にFirestoreの構造を理解するイメージとして、この違いを意識すると分かりやすかったです。\u003c/p\u003e\n\u003ch1 data-sourcepos=\"120:1-120:41\"\u003e\n\u003cspan id=\"5-rdbとfirestoreを比較してみる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#5-rdb%E3%81%A8firestore%E3%82%92%E6%AF%94%E8%BC%83%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e5. RDBとFirestoreを比較してみる\u003c/h1\u003e\n\u003cp data-sourcepos=\"122:1-122:87\"\u003e今回学習した内容を簡単に比較すると、以下のようになります。\u003c/p\u003e\n\u003ctable data-sourcepos=\"124:1-131:79\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"124:1-124:28\"\u003e\n\u003cth data-sourcepos=\"124:2-124:3\"\u003e\u003c/th\u003e\n\u003cth data-sourcepos=\"124:5-124:9\"\u003eRDB\u003c/th\u003e\n\u003cth data-sourcepos=\"124:11-124:27\"\u003eCloud Firestore\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"126:1-126:44\"\u003e\n\u003ctd data-sourcepos=\"126:2-126:9\"\u003e分類\u003c/td\u003e\n\u003ctd data-sourcepos=\"126:11-126:35\"\u003eリレーショナルDB\u003c/td\u003e\n\u003ctd data-sourcepos=\"126:37-126:43\"\u003eNoSQL\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"127:1-127:37\"\u003e\n\u003ctd data-sourcepos=\"127:2-127:15\"\u003e基本構造\u003c/td\u003e\n\u003ctd data-sourcepos=\"127:17-127:23\"\u003eTable\u003c/td\u003e\n\u003ctd data-sourcepos=\"127:25-127:36\"\u003eCollection\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"128:1-128:30\"\u003e\n\u003ctd data-sourcepos=\"128:2-128:12\"\u003eデータ\u003c/td\u003e\n\u003ctd data-sourcepos=\"128:14-128:18\"\u003eRow\u003c/td\u003e\n\u003ctd data-sourcepos=\"128:20-128:29\"\u003eDocument\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"129:1-129:27\"\u003e\n\u003ctd data-sourcepos=\"129:2-129:9\"\u003e項目\u003c/td\u003e\n\u003ctd data-sourcepos=\"129:11-129:18\"\u003eColumn\u003c/td\u003e\n\u003ctd data-sourcepos=\"129:20-129:26\"\u003eField\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"130:1-130:52\"\u003e\n\u003ctd data-sourcepos=\"130:2-130:12\"\u003e代表例\u003c/td\u003e\n\u003ctd data-sourcepos=\"130:14-130:33\"\u003eMySQL / PostgreSQL\u003c/td\u003e\n\u003ctd data-sourcepos=\"130:35-130:51\"\u003eCloud Firestore\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"131:1-131:79\"\u003e\n\u003ctd data-sourcepos=\"131:2-131:21\"\u003eデータモデル\u003c/td\u003e\n\u003ctd data-sourcepos=\"131:23-131:51\"\u003eテーブル同士の関係\u003c/td\u003e\n\u003ctd data-sourcepos=\"131:53-131:78\"\u003eドキュメント指向\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"133:1-133:33\"\u003e特に大きな違いとして、\u003c/p\u003e\n\u003cp data-sourcepos=\"135:1-135:76\"\u003e\u003cstrong\u003eRDBはテーブル同士の関係を利用してデータを管理する\u003c/strong\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"137:1-137:18\"\u003eのに対して、\u003c/p\u003e\n\u003cp data-sourcepos=\"139:1-139:58\"\u003e\u003cstrong\u003eFirestoreはCollectionの中にDocumentを保存する\u003c/strong\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"141:1-141:66\"\u003eという考え方を持っていることが分かりました。\u003c/p\u003e\n\u003ch1 data-sourcepos=\"143:1-143:11\"\u003e\n\u003cspan id=\"まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eまとめ\u003c/h1\u003e\n\u003cp data-sourcepos=\"145:1-145:107\"\u003e今回はCloud Firestoreについて調べる中で、RDBとNoSQLの違いについて整理しました。\u003c/p\u003e\n\u003cp data-sourcepos=\"147:1-147:42\"\u003e今回特に覚えておきたいのは、\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"149:1-154:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eFirebase\n└── Cloud Firestore\n        └── NoSQL\n              └── ドキュメント指向\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"156:1-156:24\"\u003eという関係です。\u003c/p\u003e\n","body":"# はじめに\nアプリで使用するパーツを作り終えたので、Firebaseを使って、データの保存・取得を試しています。\n\nFirestoreを使っている中で、\n\n「リレーショナルデータベースなのか？」\n「NoSQLって聞くけど、リレーショナルデータベースと何が違うのか？」\n\nについてまとめました。\n\n今回は、リレーショナルデータベース（RDB）とNoSQLの違いから、Cloud Firestoreがどのようにデータを保存しているのかまで、自分なりに整理していきます。\n\n# 1. Firebaseとは？\n\nGoogleが提供する、アプリやWebサービスを開発するためのプラットフォームです。アプリの「裏側」にあたる機能を、すぐに使える部品としてまとめて提供してくれます。\n\nここでいう裏側とは、普段私たちが見ているアプリの画面(フロント)ではなく、データ保存したりやログイン認証したりする機能のこと。\n\n最初は、\nFirebase = データベース\n\nと思っていたのですが、\n\nFirebaseは、アプリ開発に必要なさまざまな機能を提供しているプラットフォームでした。\n\nその中の一つとしてデータベースが存在すると理解しています。\n今回、自分が個人開発で使用しているのは**Cloud Firestore**です。\n\nCloud Firestoreは、分類としては**NoSQL（非リレーショナル）データベース**になります。\n\n# 2. リレーショナルデータベース（RDB）とは？\n\nまず、リレーショナルデータベースについて整理します。\n\n代表的なものとして、\n\n- MySQL\n- PostgreSQL\n- SQLite\n\nがあると認識しています。\nRDBでは、基本的にデータを**テーブル（表）**として管理します。\n\n\n# 3. 非リレーショナルデータベース（NoSQL）とは？\n\n一方で、Cloud Firestoreは非リレーショナルデータベースに分類されます。\n\nNoSQLは、RDBのようなテーブル形式だけに限定せず、別のデータモデルを使ってデータを管理します。\n\nただし、\n\n「NoSQL = 全部同じ仕組み」\n\nというわけではありません。\n\nNoSQLにもいくつか種類があるそうですが、Cloud Firestoreはその中でも**ドキュメント指向データベース**になります。\n\nFirestoreでは、\n\n```text\nCollection\n    ↓\nDocument\n    ↓\nField\n```\n\nという構造でデータを保存します。\n\n# 4. Collection・Document・Fieldとは？\n\n例えば、ユーザー情報をFirestoreに保存するとします。\n\n```text\nusers                 ← Collection\n\n └── user001          ← Document\n\n      name: \"kita\"    ← Field\n      age: 30         ← Field\n```\n\nそれぞれ、\n\n- `users` → Collection\n- `user001` → Document\n- `name`、`age` → Field\n\nとなります。\n\nRDBと比較すると、考え方の違いが分かるかと思います。\n\n```text\nRDB\n\nTable\n ↓\nRow\n ↓\nColumn\n```\n\nFirestoreでは、\n\n```text\nFirestore\n\nCollection\n ↓\nDocument\n ↓\nField\n```\n\nとなります。\n\n完全に同じものではありませんが、最初にFirestoreの構造を理解するイメージとして、この違いを意識すると分かりやすかったです。\n\n# 5. RDBとFirestoreを比較してみる\n\n今回学習した内容を簡単に比較すると、以下のようになります。\n\n|  | RDB | Cloud Firestore |\n| --- | --- | --- |\n| 分類 | リレーショナルDB | NoSQL |\n| 基本構造 | Table | Collection |\n| データ | Row | Document |\n| 項目 | Column | Field |\n| 代表例 | MySQL / PostgreSQL | Cloud Firestore |\n| データモデル | テーブル同士の関係 | ドキュメント指向 |\n\n特に大きな違いとして、\n\n**RDBはテーブル同士の関係を利用してデータを管理する**\n\nのに対して、\n\n**FirestoreはCollectionの中にDocumentを保存する**\n\nという考え方を持っていることが分かりました。\n\n# まとめ\n\n今回はCloud Firestoreについて調べる中で、RDBとNoSQLの違いについて整理しました。\n\n今回特に覚えておきたいのは、\n\n```text\nFirebase\n└── Cloud Firestore\n        └── NoSQL\n              └── ドキュメント指向\n```\n\nという関係です。\n","coediting":false,"comments_count":0,"created_at":"2026-10-22T19:14:10+09:00","group":null,"id":"3c5f86c056a00a8665c4","likes_count":0,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"SwiftUI","versions":[]},{"name":"Firestore","versions":[]},{"name":"nosql","versions":[]}],"title":"【swift】Firebaseについてまとめてみた","updated_at":"2026-10-22T19:14:10+09:00","url":"https://qiita.com/Hiromu9915/items/3c5f86c056a00a8665c4","user":{"description":"この記事は備忘録です。","facebook_id":"","followees_count":1,"followers_count":2,"github_login_name":null,"id":"Hiromu9915","items_count":38,"linkedin_id":"","location":"","name":"","organization":"","permanent_id":4345108,"profile_image_url":"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4345108/profile-images/1773916573","team_only":false,"twitter_screen_name":"Hiro99152","website_url":""},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003cp data-sourcepos=\"1:1-1:287\"\u003eクラクラ（Clash of Clans）の配置を集めた無料のiOSアプリを個人開発しています。配置ごとに「ゲーム内コピー用リンク」を持っているのですが、運用していて一番ユーザー体験を損ねたのが \u003cstrong\u003eリンク切れ\u003c/strong\u003e でした。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"3:1-3:27\"\u003e\n\u003cspan id=\"何が起きていたか\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BD%95%E3%81%8C%E8%B5%B7%E3%81%8D%E3%81%A6%E3%81%84%E3%81%9F%E3%81%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e何が起きていたか\u003c/h2\u003e\n\u003cul data-sourcepos=\"4:1-7:0\"\u003e\n\u003cli data-sourcepos=\"4:1-4:143\"\u003eゲームのアップデートでタウンホールのレベルが上がると、古い配置のリンクが開けなくなることがある\u003c/li\u003e\n\u003cli data-sourcepos=\"5:1-5:65\"\u003e投稿元が配置を削除すると、リンクだけが残る\u003c/li\u003e\n\u003cli data-sourcepos=\"6:1-7:0\"\u003eユーザーから見ると「タップしても何も起きない」ので、アプリ自体が壊れているように見える\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 data-sourcepos=\"8:1-8:18\"\u003e\n\u003cspan id=\"やったこと\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%84%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eやったこと\u003c/h2\u003e\n\u003col data-sourcepos=\"9:1-12:0\"\u003e\n\u003cli data-sourcepos=\"9:1-9:156\"\u003e\n\u003cstrong\u003e公開前チェック\u003c/strong\u003e: 新しい配置を追加するときに、リンクの形式（スキーム・必須クエリ）をバリデーションする\u003c/li\u003e\n\u003cli data-sourcepos=\"10:1-10:138\"\u003e\n\u003cstrong\u003e定期チェック\u003c/strong\u003e: 既存リンクをバッチで確認し、怪しいものはフラグを立てて一覧から一時的に外す\u003c/li\u003e\n\u003cli data-sourcepos=\"11:1-12:0\"\u003e\n\u003cstrong\u003e表示側の工夫\u003c/strong\u003e: フラグ付きの配置は「確認中」と表示し、無言で失敗させない\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"13:1-13:69\"\u003e形式チェックはこんな感じのシンプルなものです。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"15:1-29:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eLayoutLink\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eurl\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eURL\u003c/span\u003e\n\n    \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eisWellFormed\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecomps\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eURLComponents\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eurl\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eurl\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eresolvingAgainstBaseURL\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n              \u003cspan class=\"n\"\u003ecomps\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003escheme\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"https\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n              \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eitems\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ecomps\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003equeryItems\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n              \u003cspan class=\"n\"\u003eitems\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ewhere\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ename\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"action\"\u003c/span\u003e \u003cspan class=\"p\"\u003e}),\u003c/span\u003e\n              \u003cspan class=\"n\"\u003eitems\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ewhere\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ename\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"id\"\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003evalue\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEmpty\u003c/span\u003e \u003cspan class=\"p\"\u003e})\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"31:1-31:9\"\u003e\n\u003cspan id=\"学び\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AD%A6%E3%81%B3\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e学び\u003c/h2\u003e\n\u003cul data-sourcepos=\"32:1-35:0\"\u003e\n\u003cli data-sourcepos=\"32:1-32:82\"\u003e「データが正しいこと」はUIの細かい改善よりも信頼に効く\u003c/li\u003e\n\u003cli data-sourcepos=\"33:1-33:95\"\u003e失敗を隠さず「確認中」と見せるだけで、問い合わせがかなり減った\u003c/li\u003e\n\u003cli data-sourcepos=\"34:1-35:0\"\u003e完璧な検証よりも、追加時の軽いチェック＋定期バッチの組み合わせで十分だった\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"36:1-37:80\"\u003e宣伝になってしまいますが、自分のアプリです：\u003cstrong\u003eBase Layouts: War \u0026amp; Farm Maps\u003c/strong\u003e（無料・iOS）。タウンホール別に対戦・資源・トロフィー配置をまとめていて、タップでゲームに配置をコピーできます。\u003cbr\u003e\n\u003ca href=\"https://apps.apple.com/app/apple-store/id6804564319?pt=2720803\u0026amp;ct=qiita_ja3\u0026amp;mt=8\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://apps.apple.com/app/apple-store/id6804564319?pt=2720803\u0026amp;ct=qiita_ja3\u0026amp;mt=8\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"39:1-39:150\"\u003e同じように外部リンクを大量に扱っている方がいれば、運用のコツをコメントで教えてもらえると嬉しいです。\u003c/p\u003e\n","body":"クラクラ（Clash of Clans）の配置を集めた無料のiOSアプリを個人開発しています。配置ごとに「ゲーム内コピー用リンク」を持っているのですが、運用していて一番ユーザー体験を損ねたのが **リンク切れ** でした。\n\n## 何が起きていたか\n- ゲームのアップデートでタウンホールのレベルが上がると、古い配置のリンクが開けなくなることがある\n- 投稿元が配置を削除すると、リンクだけが残る\n- ユーザーから見ると「タップしても何も起きない」ので、アプリ自体が壊れているように見える\n\n## やったこと\n1. **公開前チェック**: 新しい配置を追加するときに、リンクの形式（スキーム・必須クエリ）をバリデーションする\n2. **定期チェック**: 既存リンクをバッチで確認し、怪しいものはフラグを立てて一覧から一時的に外す\n3. **表示側の工夫**: フラグ付きの配置は「確認中」と表示し、無言で失敗させない\n\n形式チェックはこんな感じのシンプルなものです。\n\n```swift\nstruct LayoutLink {\n    let url: URL\n\n    var isWellFormed: Bool {\n        guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),\n              comps.scheme == \"https\",\n              let items = comps.queryItems,\n              items.contains(where: { $0.name == \"action\" }),\n              items.contains(where: { $0.name == \"id\" \u0026\u0026 !($0.value ?? \"\").isEmpty })\n        else { return false }\n        return true\n    }\n}\n```\n\n## 学び\n- 「データが正しいこと」はUIの細かい改善よりも信頼に効く\n- 失敗を隠さず「確認中」と見せるだけで、問い合わせがかなり減った\n- 完璧な検証よりも、追加時の軽いチェック＋定期バッチの組み合わせで十分だった\n\n宣伝になってしまいますが、自分のアプリです：**Base Layouts: War \u0026 Farm Maps**（無料・iOS）。タウンホール別に対戦・資源・トロフィー配置をまとめていて、タップでゲームに配置をコピーできます。\nhttps://apps.apple.com/app/apple-store/id6804564319?pt=2720803\u0026ct=qiita_ja3\u0026mt=8\n\n同じように外部リンクを大量に扱っている方がいれば、運用のコツをコメントで教えてもらえると嬉しいです。\n","coediting":false,"comments_count":0,"created_at":"2026-10-22T12:19:38+09:00","group":null,"id":"fafbe59c39da47467648","likes_count":0,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Swift","versions":[]},{"name":"iOS","versions":[]},{"name":"URLSession","versions":[]},{"name":"個人開発","versions":[]}],"title":"個人開発アプリで「コピーリンク切れ」を減らすために、Swiftで軽量なリンク検証を入れた話","updated_at":"2026-10-22T12:19:38+09:00","url":"https://qiita.com/BaseLayoutsChief/items/fafbe59c39da47467648","user":{"description":null,"facebook_id":null,"followees_count":1,"followers_count":0,"github_login_name":null,"id":"BaseLayoutsChief","items_count":7,"linkedin_id":null,"location":null,"name":"","organization":null,"permanent_id":4518651,"profile_image_url":"https://secure.gravatar.com/avatar/0c606a0c6ec799e5dae7af5a0fd08d70","team_only":false,"twitter_screen_name":null,"website_url":null},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003cp data-sourcepos=\"1:1-1:18\"\u003eこんにちは。\u003c/p\u003e\n\u003cp data-sourcepos=\"3:1-3:116\"\u003e今回、\u003cstrong\u003e「Squat for Mac」\u003c/strong\u003e というmacOS向けのアプリを個人開発し、GitHubで公開しました。\u003c/p\u003e\n\u003cp data-sourcepos=\"5:1-5:138\"\u003eChatGPTやClaudeなどのAIツールを長時間使っていると、スクワットをするように促してくれるアプリです。\u003c/p\u003e\n\u003cp data-sourcepos=\"7:1-7:103\"\u003e\u003cstrong\u003eDownload:\u003c/strong\u003e \u003ca href=\"https://github.com/rin999999334/squat-for-mac/releases/tag/v0.9.2\" rel=\"nofollow noopener\" target=\"_blank\"\u003eSquat for Mac v0.9.2\u003c/a\u003e\u003c/p\u003e\n\u003ch2 data-sourcepos=\"9:1-9:24\"\u003e\n\u003cspan id=\"なぜ作ったのか\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AA%E3%81%9C%E4%BD%9C%E3%81%A3%E3%81%9F%E3%81%AE%E3%81%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eなぜ作ったのか\u003c/h2\u003e\n\u003cp data-sourcepos=\"11:1-11:104\"\u003e最近、ChatGPTやClaude、CodexなどのAIツールを使って開発することが増えました。\u003c/p\u003e\n\u003cp data-sourcepos=\"13:1-13:140\"\u003eAIによって開発の効率は上がりましたが、一方で、パソコンの前に座っている時間も長くなっています。\u003c/p\u003e\n\u003cp data-sourcepos=\"15:1-15:155\"\u003e特にAIを使った開発では、プロンプトを入力してから回答が返ってくるまで、ちょっとした待ち時間が発生します。\u003c/p\u003e\n\u003cp data-sourcepos=\"17:1-17:73\"\u003e\u003cstrong\u003e「この時間に少しでも運動できたらいいのでは？」\u003c/strong\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"19:1-19:48\"\u003eというのが、開発のきっかけです。\u003c/p\u003e\n\u003cp data-sourcepos=\"21:1-21:153\"\u003eとはいえ、運動するためにわざわざ別のアプリを開いたり、トレーニングメニューを考えたりするのは面倒です。\u003c/p\u003e\n\u003cp data-sourcepos=\"23:1-23:142\"\u003eそこで、AIを使っている時間をきっかけに、1〜2回でもスクワットをする習慣を作れないかと考えました。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"25:1-25:18\"\u003e\n\u003cspan id=\"作ったもの\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BD%9C%E3%81%A3%E3%81%9F%E3%82%82%E3%81%AE\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e作ったもの\u003c/h2\u003e\n\u003cp data-sourcepos=\"27:1-27:103\"\u003eSquat for Macは、Macのメニューバーに常駐するシンプルな運動促進アプリです。\u003c/p\u003e\n\u003cp data-sourcepos=\"29:1-29:116\"\u003e\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fraw.githubusercontent.com%2Frin999999334%2Fsquat-for-mac%2Fmain%2FAssets%2Fscreenshots%2Fhome-demo.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=2e395b4fc6b7ecf05b9e1817d6977abc\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fraw.githubusercontent.com%2Frin999999334%2Fsquat-for-mac%2Fmain%2FAssets%2Fscreenshots%2Fhome-demo.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=2e395b4fc6b7ecf05b9e1817d6977abc\" alt=\"Squat for Mac\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fraw.githubusercontent.com%2Frin999999334%2Fsquat-for-mac%2Fmain%2FAssets%2Fscreenshots%2Fhome-demo.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=cc5038ef4f6dce1efbd350d4b724c79b 1x\" data-canonical-src=\"https://raw.githubusercontent.com/rin999999334/squat-for-mac/main/Assets/screenshots/home-demo.png\" loading=\"lazy\"\u003e\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"31:1-31:54\"\u003e※画面内の運動記録はデモデータです。\u003c/p\u003e\n\u003cp data-sourcepos=\"33:1-33:34\"\u003e主な機能は次の4つです。\u003c/p\u003e\n\u003col data-sourcepos=\"35:1-39:0\"\u003e\n\u003cli data-sourcepos=\"35:1-35:93\"\u003e\n\u003cstrong\u003eAIアプリの利用時間を計測\u003c/strong\u003e：ChatGPT・Codex・Claudeの利用時間を計測\u003c/li\u003e\n\u003cli data-sourcepos=\"36:1-36:99\"\u003e\n\u003cstrong\u003eスクワットのリマインド\u003c/strong\u003e：一定時間AIを使うと運動を促す通知を表示\u003c/li\u003e\n\u003cli data-sourcepos=\"37:1-37:112\"\u003e\n\u003cstrong\u003eカメラによる回数カウント\u003c/strong\u003e：Macのカメラで上下運動を認識し、回数を自動計測\u003c/li\u003e\n\u003cli data-sourcepos=\"38:1-39:0\"\u003e\n\u003cstrong\u003e運動記録の可視化\u003c/strong\u003e：GitHubのContribution Graphのようなカレンダーで運動履歴を確認\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"40:1-40:60\"\u003e運動の目標回数は1〜30回から設定できます。\u003c/p\u003e\n\u003cp data-sourcepos=\"42:1-42:175\"\u003e個人的には、毎日何十回もスクワットすることより、\u003cstrong\u003e運動を始めるまでのハードルをできるだけ下げること\u003c/strong\u003eを重視しています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"44:1-44:21\"\u003e\n\u003cspan id=\"技術スタック\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%8A%80%E8%A1%93%E3%82%B9%E3%82%BF%E3%83%83%E3%82%AF\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e技術スタック\u003c/h2\u003e\n\u003cp data-sourcepos=\"46:1-46:71\"\u003e今回はmacOSのネイティブアプリとして実装しました。\u003c/p\u003e\n\u003ctable data-sourcepos=\"48:1-55:46\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"48:1-48:19\"\u003e\n\u003cth data-sourcepos=\"48:2-48:9\"\u003e技術\u003c/th\u003e\n\u003cth data-sourcepos=\"48:11-48:18\"\u003e用途\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"50:1-50:45\"\u003e\n\u003ctd data-sourcepos=\"50:2-50:8\"\u003eSwift\u003c/td\u003e\n\u003ctd data-sourcepos=\"50:10-50:44\"\u003eアプリケーションの実装\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"51:1-51:25\"\u003e\n\u003ctd data-sourcepos=\"51:2-51:10\"\u003eSwiftUI\u003c/td\u003e\n\u003ctd data-sourcepos=\"51:12-51:24\"\u003eUIの構築\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"52:1-52:55\"\u003e\n\u003ctd data-sourcepos=\"52:2-52:9\"\u003eAppKit\u003c/td\u003e\n\u003ctd data-sourcepos=\"52:11-52:54\"\u003eメニューバー・ウィンドウ管理\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"53:1-53:43\"\u003e\n\u003ctd data-sourcepos=\"53:2-53:15\"\u003eAVFoundation\u003c/td\u003e\n\u003ctd data-sourcepos=\"53:17-53:42\"\u003eカメラ映像の取得\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"54:1-54:46\"\u003e\n\u003ctd data-sourcepos=\"54:2-54:9\"\u003eVision\u003c/td\u003e\n\u003ctd data-sourcepos=\"54:11-54:45\"\u003e顔の位置検出・動作認識\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"55:1-55:46\"\u003e\n\u003ctd data-sourcepos=\"55:2-55:9\"\u003eSQLite\u003c/td\u003e\n\u003ctd data-sourcepos=\"55:11-55:45\"\u003e運動履歴のローカル保存\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"57:1-57:71\"\u003e基本的にはAppleのフレームワークを活用しています。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"59:1-59:49\"\u003e\n\u003cspan id=\"-aiアプリの利用時間を計測する\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#-ai%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AE%E5%88%A9%E7%94%A8%E6%99%82%E9%96%93%E3%82%92%E8%A8%88%E6%B8%AC%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e① AIアプリの利用時間を計測する\u003c/h3\u003e\n\u003cp data-sourcepos=\"61:1-61:93\"\u003eAIアプリの利用状況を把握するために、\u003ccode\u003eNSWorkspace\u003c/code\u003eを使用しています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"63:1-65:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kt\"\u003eNSWorkspace\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eshared\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efrontmostApplication\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebundleIdentifier\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"67:1-67:96\"\u003eこの情報から、現在Macで前面に表示されているアプリを判定できます。\u003c/p\u003e\n\u003cp data-sourcepos=\"69:1-69:110\"\u003eSquatでは、ChatGPT・Codex・ClaudeのBundle IDを対象として、利用時間を集計しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"71:1-71:93\"\u003eただし、単純にアプリの起動時間を数えているわけではありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"73:1-73:139\"\u003e他のアプリを操作している時間や、5分以上操作がない時間、Macのスリープ中などは計測を停止します。\u003c/p\u003e\n\u003cp data-sourcepos=\"75:1-75:179\"\u003eまた、複数のAIアプリを開いていても、前面にあるアプリだけを計測することで、利用時間が二重に加算されないようにしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"77:1-77:173\"\u003e現在は、AIの回答生成そのものを検出しているのではなく、\u003cstrong\u003eAIアプリを前面で使用している時間を基準に通知する仕組み\u003c/strong\u003eです。\u003c/p\u003e\n\u003cp data-sourcepos=\"79:1-79:53\"\u003e通知間隔は30〜180分から選択できます。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"81:1-81:53\"\u003e\n\u003cspan id=\"-visionでスクワットをカウントする\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#-vision%E3%81%A7%E3%82%B9%E3%82%AF%E3%83%AF%E3%83%83%E3%83%88%E3%82%92%E3%82%AB%E3%82%A6%E3%83%B3%E3%83%88%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e② Visionでスクワットをカウントする\u003c/h3\u003e\n\u003cp data-sourcepos=\"83:1-83:95\"\u003eスクワットの認識には、AppleのVisionフレームワークを使用しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"85:1-85:181\"\u003e今回採用したのは、身体全体の姿勢を詳細に解析する方式ではなく、\u003cstrong\u003e顔の位置の上下移動を利用したシンプルなカウント方式\u003c/strong\u003eです。\u003c/p\u003e\n\u003cp data-sourcepos=\"87:1-87:54\"\u003e処理の流れは次のようになっています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"89:1-103:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eカメラ映像を取得\n       ↓\nVisionで顔を検出\n       ↓\n顔の位置を取得\n       ↓\n初期位置をキャリブレーション\n       ↓\n顔が一定以上下がったか判定\n       ↓\n元の位置まで戻ったか判定\n       ↓\nスクワット回数を +1\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"105:1-105:97\"\u003e実装では、\u003ccode\u003eVNDetectFaceRectanglesRequest\u003c/code\u003eを使って顔の位置を取得しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"107:1-107:144\"\u003eただし、単純に顔が下がって上がっただけでカウントすると、細かな頭の動きなどで誤検出が発生します。\u003c/p\u003e\n\u003cp data-sourcepos=\"109:1-109:186\"\u003eそのため、移動量だけでなく、一定時間その位置に留まったか、下降から上昇までの時間が十分か、といった条件も組み合わせています。\u003c/p\u003e\n\u003cp data-sourcepos=\"111:1-111:184\"\u003eまた、カメラの画角や使用者の位置によって認識結果が変わるため、運動開始前に約3秒間のキャリブレーションを行うようにしました。\u003c/p\u003e\n\u003cp data-sourcepos=\"113:1-113:162\"\u003eなお、この方式は上下運動をカウントするためのもので、スクワットのフォームを正確に評価するものではありません。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"115:1-115:41\"\u003e\n\u003cspan id=\"-ローカルで完結する設計\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#-%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E3%81%A7%E5%AE%8C%E7%B5%90%E3%81%99%E3%82%8B%E8%A8%AD%E8%A8%88\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e③ ローカルで完結する設計\u003c/h3\u003e\n\u003cp data-sourcepos=\"117:1-117:90\"\u003eカメラを利用するアプリなので、プライバシーにも配慮しました。\u003c/p\u003e\n\u003cp data-sourcepos=\"119:1-119:77\"\u003eSquatでは、カメラ映像の処理をMac上で完結させています。\u003c/p\u003e\n\u003cp data-sourcepos=\"121:1-121:93\"\u003e映像をサーバーへ送信したり、録画したりする仕組みはありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"123:1-123:87\"\u003eまた、運動履歴にはSQLiteを使用し、ローカルに保存しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"125:1-125:102\"\u003eそのため、アカウント登録やクラウドサービスへの接続は必要ありません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"127:1-127:60\"\u003e\n\u003cspan id=\"githubのcontribution-graphのように運動を可視化\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#github%E3%81%AEcontribution-graph%E3%81%AE%E3%82%88%E3%81%86%E3%81%AB%E9%81%8B%E5%8B%95%E3%82%92%E5%8F%AF%E8%A6%96%E5%8C%96\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eGitHubのContribution Graphのように運動を可視化\u003c/h2\u003e\n\u003cp data-sourcepos=\"129:1-129:111\"\u003e個人的に気に入っているのが、運動記録をカレンダー形式で可視化する機能です。\u003c/p\u003e\n\u003cp data-sourcepos=\"131:1-131:119\"\u003e\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fraw.githubusercontent.com%2Frin999999334%2Fsquat-for-mac%2Fmain%2FAssets%2Fscreenshots%2Fdetail-demo.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=df447dd1b18bd969a93e9887ff81c5a9\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fraw.githubusercontent.com%2Frin999999334%2Fsquat-for-mac%2Fmain%2FAssets%2Fscreenshots%2Fdetail-demo.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=df447dd1b18bd969a93e9887ff81c5a9\" alt=\"Squat Calendar\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fraw.githubusercontent.com%2Frin999999334%2Fsquat-for-mac%2Fmain%2FAssets%2Fscreenshots%2Fdetail-demo.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=581d58df66680df895e5342c4dec40c3 1x\" data-canonical-src=\"https://raw.githubusercontent.com/rin999999334/squat-for-mac/main/Assets/screenshots/detail-demo.png\" loading=\"lazy\"\u003e\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"133:1-133:39\"\u003e※こちらもデモデータです。\u003c/p\u003e\n\u003cp data-sourcepos=\"135:1-135:120\"\u003eGitHubのContribution Graphのように、日々の積み重ねを視覚的に確認できるようにしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"137:1-137:52\"\u003eスクワットは1日数回でも構いません。\u003c/p\u003e\n\u003cp data-sourcepos=\"139:1-139:96\"\u003e重要なのは、運動したという小さな実績が残ることだと考えています。\u003c/p\u003e\n\u003cp data-sourcepos=\"141:1-141:93\"\u003eまた、運動記録を画像として保存・共有する機能も用意しています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"143:1-143:27\"\u003e\n\u003cspan id=\"インストール方法\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB%E6%96%B9%E6%B3%95\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eインストール方法\u003c/h2\u003e\n\u003cp data-sourcepos=\"145:1-145:108\"\u003e現在、\u003cstrong\u003eApple Silicon搭載Mac向けの公開プレビュー版（v0.9.2）\u003c/strong\u003e を配布しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"147:1-147:45\"\u003e以下からダウンロードできます。\u003c/p\u003e\n\u003cp data-sourcepos=\"149:1-149:107\"\u003e\u003cstrong\u003e\u003ca href=\"https://github.com/rin999999334/squat-for-mac/releases/tag/v0.9.2\" rel=\"nofollow noopener\" target=\"_blank\"\u003eSquat for Macをダウンロード\u003c/a\u003e\u003c/strong\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"151:1-151:102\"\u003eZIPファイルを展開し、アプリケーションフォルダに移動して利用できます。\u003c/p\u003e\n\u003cp data-sourcepos=\"153:1-153:194\"\u003eなお、現時点ではApple Developer IDによる署名と公証には対応していないため、初回起動時にmacOSのセキュリティ警告が表示される場合があります。\u003c/p\u003e\n\u003cp data-sourcepos=\"155:1-155:123\"\u003e詳しい手順は\u003ca href=\"https://github.com/rin999999334/squat-for-mac/blob/main/README.ja.md\" rel=\"nofollow noopener\" target=\"_blank\"\u003eREADME\u003c/a\u003eに記載しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"157:1-157:87\"\u003eソースコードもGitHub上で公開しており、手元でビルドできます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"159:1-167:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003egit clone https://github.com/rin999999334/squat-for-mac.git\n\n\u003cspan class=\"nb\"\u003ecd \u003c/span\u003esquat-for-mac\n\n./scripts/build.sh\n\nopen \u003cspan class=\"s2\"\u003e\"dist/Squat for Mac.app\"\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"169:1-169:180\"\u003e※コードの公開ライセンスは現在未決定です。現時点では、改変・再配布を許可するオープンソースライセンスは付与していません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"171:1-171:21\"\u003e\n\u003cspan id=\"今後について\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BB%8A%E5%BE%8C%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e今後について\u003c/h2\u003e\n\u003cp data-sourcepos=\"173:1-173:93\"\u003eまだ公開プレビュー版なので、改善したい部分はいくつかあります。\u003c/p\u003e\n\u003cp data-sourcepos=\"175:1-175:200\"\u003e特に、今回の開発で実現したかったのは、AIの利用時間を計測すること自体ではなく、\u003cstrong\u003eAIを利用する中で自然に運動するきっかけを作ること\u003c/strong\u003eです。\u003c/p\u003e\n\u003cp data-sourcepos=\"177:1-177:152\"\u003e将来的には、AIの回答生成中や生成完了のタイミングと連動できれば、より自然に運動を促せると考えています。\u003c/p\u003e\n\u003cp data-sourcepos=\"179:1-179:130\"\u003eただし、通常のChatGPTやClaudeの回答生成との連動は、現時点では実装済みの機能ではありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"181:1-181:167\"\u003eまずは実際に使ってもらいながら、通知のタイミングやスクワットの認識精度、UIなどを改善していきたいと思っています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"183:1-183:12\"\u003e\n\u003cspan id=\"最後に\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%9C%80%E5%BE%8C%E3%81%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e最後に\u003c/h2\u003e\n\u003cp data-sourcepos=\"185:1-185:88\"\u003e今回のSquat for Macは、かなり小さな課題から生まれたアプリです。\u003c/p\u003e\n\u003cp data-sourcepos=\"187:1-187:122\"\u003eAIによって開発が便利になった一方で、人間が身体を動かす時間は減っている気がします。\u003c/p\u003e\n\u003cp data-sourcepos=\"189:1-189:92\"\u003eだったら、AIを使う時間を少しだけ健康的な時間に変えられないか。\u003c/p\u003e\n\u003cp data-sourcepos=\"191:1-191:39\"\u003eそんな発想から作りました。\u003c/p\u003e\n\u003cp data-sourcepos=\"193:1-193:140\"\u003e同じようにMacで長時間作業している方や、AIを使って開発している方に試してもらえたらうれしいです。\u003c/p\u003e\n\u003cp data-sourcepos=\"195:1-195:48\"\u003eフィードバックも歓迎しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"197:1-197:66\"\u003e気になった方はぜひGitHubを覗いてみてください。\u003c/p\u003e\n\u003cp data-sourcepos=\"199:1-199:57\"\u003e\u003cstrong\u003eGitHub: \u003ca href=\"https://github.com/rin999999334/squat-for-mac\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://github.com/rin999999334/squat-for-mac\u003c/a\u003e\u003c/strong\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"201:1-201:94\"\u003e役に立ちそうだと思ったら、Starを付けてもらえると励みになります。\u003c/p\u003e\n","body":"こんにちは。\n\n今回、**「Squat for Mac」** というmacOS向けのアプリを個人開発し、GitHubで公開しました。\n\nChatGPTやClaudeなどのAIツールを長時間使っていると、スクワットをするように促してくれるアプリです。\n\n**Download:** [Squat for Mac v0.9.2](https://github.com/rin999999334/squat-for-mac/releases/tag/v0.9.2)\n\n## なぜ作ったのか\n\n最近、ChatGPTやClaude、CodexなどのAIツールを使って開発することが増えました。\n\nAIによって開発の効率は上がりましたが、一方で、パソコンの前に座っている時間も長くなっています。\n\n特にAIを使った開発では、プロンプトを入力してから回答が返ってくるまで、ちょっとした待ち時間が発生します。\n\n**「この時間に少しでも運動できたらいいのでは？」**\n\nというのが、開発のきっかけです。\n\nとはいえ、運動するためにわざわざ別のアプリを開いたり、トレーニングメニューを考えたりするのは面倒です。\n\nそこで、AIを使っている時間をきっかけに、1〜2回でもスクワットをする習慣を作れないかと考えました。\n\n## 作ったもの\n\nSquat for Macは、Macのメニューバーに常駐するシンプルな運動促進アプリです。\n\n![Squat for Mac](https://raw.githubusercontent.com/rin999999334/squat-for-mac/main/Assets/screenshots/home-demo.png)\n\n※画面内の運動記録はデモデータです。\n\n主な機能は次の4つです。\n\n1. **AIアプリの利用時間を計測**：ChatGPT・Codex・Claudeの利用時間を計測\n2. **スクワットのリマインド**：一定時間AIを使うと運動を促す通知を表示\n3. **カメラによる回数カウント**：Macのカメラで上下運動を認識し、回数を自動計測\n4. **運動記録の可視化**：GitHubのContribution Graphのようなカレンダーで運動履歴を確認\n\n運動の目標回数は1〜30回から設定できます。\n\n個人的には、毎日何十回もスクワットすることより、**運動を始めるまでのハードルをできるだけ下げること**を重視しています。\n\n## 技術スタック\n\n今回はmacOSのネイティブアプリとして実装しました。\n\n| 技術 | 用途 |\n|---|---|\n| Swift | アプリケーションの実装 |\n| SwiftUI | UIの構築 |\n| AppKit | メニューバー・ウィンドウ管理 |\n| AVFoundation | カメラ映像の取得 |\n| Vision | 顔の位置検出・動作認識 |\n| SQLite | 運動履歴のローカル保存 |\n\n基本的にはAppleのフレームワークを活用しています。\n\n### ① AIアプリの利用時間を計測する\n\nAIアプリの利用状況を把握するために、`NSWorkspace`を使用しています。\n\n```swift\nNSWorkspace.shared.frontmostApplication?.bundleIdentifier\n```\n\nこの情報から、現在Macで前面に表示されているアプリを判定できます。\n\nSquatでは、ChatGPT・Codex・ClaudeのBundle IDを対象として、利用時間を集計しています。\n\nただし、単純にアプリの起動時間を数えているわけではありません。\n\n他のアプリを操作している時間や、5分以上操作がない時間、Macのスリープ中などは計測を停止します。\n\nまた、複数のAIアプリを開いていても、前面にあるアプリだけを計測することで、利用時間が二重に加算されないようにしています。\n\n現在は、AIの回答生成そのものを検出しているのではなく、**AIアプリを前面で使用している時間を基準に通知する仕組み**です。\n\n通知間隔は30〜180分から選択できます。\n\n### ② Visionでスクワットをカウントする\n\nスクワットの認識には、AppleのVisionフレームワークを使用しています。\n\n今回採用したのは、身体全体の姿勢を詳細に解析する方式ではなく、**顔の位置の上下移動を利用したシンプルなカウント方式**です。\n\n処理の流れは次のようになっています。\n\n```text\nカメラ映像を取得\n       ↓\nVisionで顔を検出\n       ↓\n顔の位置を取得\n       ↓\n初期位置をキャリブレーション\n       ↓\n顔が一定以上下がったか判定\n       ↓\n元の位置まで戻ったか判定\n       ↓\nスクワット回数を +1\n```\n\n実装では、`VNDetectFaceRectanglesRequest`を使って顔の位置を取得しています。\n\nただし、単純に顔が下がって上がっただけでカウントすると、細かな頭の動きなどで誤検出が発生します。\n\nそのため、移動量だけでなく、一定時間その位置に留まったか、下降から上昇までの時間が十分か、といった条件も組み合わせています。\n\nまた、カメラの画角や使用者の位置によって認識結果が変わるため、運動開始前に約3秒間のキャリブレーションを行うようにしました。\n\nなお、この方式は上下運動をカウントするためのもので、スクワットのフォームを正確に評価するものではありません。\n\n### ③ ローカルで完結する設計\n\nカメラを利用するアプリなので、プライバシーにも配慮しました。\n\nSquatでは、カメラ映像の処理をMac上で完結させています。\n\n映像をサーバーへ送信したり、録画したりする仕組みはありません。\n\nまた、運動履歴にはSQLiteを使用し、ローカルに保存しています。\n\nそのため、アカウント登録やクラウドサービスへの接続は必要ありません。\n\n## GitHubのContribution Graphのように運動を可視化\n\n個人的に気に入っているのが、運動記録をカレンダー形式で可視化する機能です。\n\n![Squat Calendar](https://raw.githubusercontent.com/rin999999334/squat-for-mac/main/Assets/screenshots/detail-demo.png)\n\n※こちらもデモデータです。\n\nGitHubのContribution Graphのように、日々の積み重ねを視覚的に確認できるようにしています。\n\nスクワットは1日数回でも構いません。\n\n重要なのは、運動したという小さな実績が残ることだと考えています。\n\nまた、運動記録を画像として保存・共有する機能も用意しています。\n\n## インストール方法\n\n現在、**Apple Silicon搭載Mac向けの公開プレビュー版（v0.9.2）** を配布しています。\n\n以下からダウンロードできます。\n\n**[Squat for Macをダウンロード](https://github.com/rin999999334/squat-for-mac/releases/tag/v0.9.2)**\n\nZIPファイルを展開し、アプリケーションフォルダに移動して利用できます。\n\nなお、現時点ではApple Developer IDによる署名と公証には対応していないため、初回起動時にmacOSのセキュリティ警告が表示される場合があります。\n\n詳しい手順は[README](https://github.com/rin999999334/squat-for-mac/blob/main/README.ja.md)に記載しています。\n\nソースコードもGitHub上で公開しており、手元でビルドできます。\n\n```bash\ngit clone https://github.com/rin999999334/squat-for-mac.git\n\ncd squat-for-mac\n\n./scripts/build.sh\n\nopen \"dist/Squat for Mac.app\"\n```\n\n※コードの公開ライセンスは現在未決定です。現時点では、改変・再配布を許可するオープンソースライセンスは付与していません。\n\n## 今後について\n\nまだ公開プレビュー版なので、改善したい部分はいくつかあります。\n\n特に、今回の開発で実現したかったのは、AIの利用時間を計測すること自体ではなく、**AIを利用する中で自然に運動するきっかけを作ること**です。\n\n将来的には、AIの回答生成中や生成完了のタイミングと連動できれば、より自然に運動を促せると考えています。\n\nただし、通常のChatGPTやClaudeの回答生成との連動は、現時点では実装済みの機能ではありません。\n\nまずは実際に使ってもらいながら、通知のタイミングやスクワットの認識精度、UIなどを改善していきたいと思っています。\n\n## 最後に\n\n今回のSquat for Macは、かなり小さな課題から生まれたアプリです。\n\nAIによって開発が便利になった一方で、人間が身体を動かす時間は減っている気がします。\n\nだったら、AIを使う時間を少しだけ健康的な時間に変えられないか。\n\nそんな発想から作りました。\n\n同じようにMacで長時間作業している方や、AIを使って開発している方に試してもらえたらうれしいです。\n\nフィードバックも歓迎しています。\n\n気になった方はぜひGitHubを覗いてみてください。\n\n**GitHub: https://github.com/rin999999334/squat-for-mac**\n\n役に立ちそうだと思ったら、Starを付けてもらえると励みになります。\n","coediting":false,"comments_count":0,"created_at":"2026-10-19T19:52:49+09:00","group":null,"id":"b86ad95103400d23e211","likes_count":5,"private":false,"reactions_count":0,"stocks_count":1,"tags":[{"name":"Swift","versions":[]},{"name":"macOS","versions":[]},{"name":"個人開発","versions":[]},{"name":"ChatGPT","versions":[]},{"name":"ClaudeCode","versions":[]}],"title":"ChatGPTを使っているとスクワットを促されるMacアプリを作った【Swift / Vision】","updated_at":"2026-10-19T20:54:06+09:00","url":"https://qiita.com/lei133802/items/b86ad95103400d23e211","user":{"description":"","facebook_id":"","followees_count":4,"followers_count":1,"github_login_name":null,"id":"lei133802","items_count":2,"linkedin_id":"","location":"","name":"つぼみ","organization":"","permanent_id":4519340,"profile_image_url":"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4519340/profile-images/1792407108","team_only":false,"twitter_screen_name":"anyohaseyo88","website_url":"https://note.com/tsubomi39"},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003cblockquote data-sourcepos=\"1:1-2:123\"\u003e\n\u003cp data-sourcepos=\"1:3-2:123\"\u003e\u003cstrong\u003e本記事はAIアシストで執筆し、人間が内容を確認しています。\u003c/strong\u003e\u003cbr\u003e\n例はすべて個人開発アプリ\u003cstrong\u003eConscio\u003c/strong\u003e（macOS常駐・オフラインNLP）の実装に基づく実測です。\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp data-sourcepos=\"4:1-4:173\"\u003eConscioは、最前面アプリの\u003cstrong\u003e窓タイトル\u003c/strong\u003eを2秒ごとに読み、そこからキーワードを抽出して提案や文脈表示に使います。条件は3つ:\u003c/p\u003e\n\u003cul data-sourcepos=\"6:1-9:0\"\u003e\n\u003cli data-sourcepos=\"6:1-6:120\"\u003e\n\u003cstrong\u003e弱い入力\u003c/strong\u003e: 「無題」「loading…」「イマイチ意味のない文字列」のような散漫な文字列\u003c/li\u003e\n\u003cli data-sourcepos=\"7:1-7:90\"\u003e\n\u003cstrong\u003e短い処理\u003c/strong\u003e: 2秒ループ内で終わる軽量処理（バッテリーのため）\u003c/li\u003e\n\u003cli data-sourcepos=\"8:1-9:0\"\u003e\n\u003cstrong\u003e強い制約\u003c/strong\u003e: 外部API・送信ゼロ（データ外送禁止）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"10:1-10:151\"\u003eこの3条件で動くNLPをどう組むか — 工夫3つと、「設計の仮定が崩れていた」を監査で発見した実話を書きます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"12:1-12:57\"\u003e\n\u003cspan id=\"工夫1-言語を検知して明示する\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%B7%A5%E5%A4%AB1-%E8%A8%80%E8%AA%9E%E3%82%92%E6%A4%9C%E7%9F%A5%E3%81%97%E3%81%A6%E6%98%8E%E7%A4%BA%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e工夫1: 言語を「検知」して「明示」する\u003c/h2\u003e\n\u003cp data-sourcepos=\"14:1-14:260\"\u003eNLTagger は言語未指定だと自動判定します。2〜3語の短文では判定がブレるため、\u003ccode\u003eNLLanguageRecognizer\u003c/code\u003e の結果を\u003cstrong\u003e明示的に\u003c/strong\u003e渡します。取得できなかった(nil)ときは無理に設定せず自動判定に任せます:\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"16:1-25:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erecognizer\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNLLanguageRecognizer\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003cspan class=\"n\"\u003erecognizer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eprocessString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003enlpText\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003edetected\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003erecognizer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003edominantLanguage\u003c/span\u003e\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003etagger\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNLTagger\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003etagSchemes\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elexicalClass\u003c/span\u003e\u003cspan class=\"p\"\u003e])\u003c/span\u003e\n\u003cspan class=\"n\"\u003etagger\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estring\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003enlpText\u003c/span\u003e\n\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elang\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003edetected\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003etagger\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esetLanguage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003elang\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003erange\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003enlpText\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estartIndex\u003c/span\u003e\u003cspan class=\"o\"\u003e..\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003enlpText\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eendIndex\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"27:1-27:98\"\u003e\n\u003cspan id=\"実測-日本語では品詞タグが効果を発揮しませんここが今日の本題\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AE%9F%E6%B8%AC-%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%A7%E3%81%AF%E5%93%81%E8%A9%9E%E3%82%BF%E3%82%B0%E3%81%8C%E5%8A%B9%E6%9E%9C%E3%82%92%E7%99%BA%E6%8F%AE%E3%81%97%E3%81%BE%E3%81%9B%E3%82%93%E3%81%93%E3%81%93%E3%81%8C%E4%BB%8A%E6%97%A5%E3%81%AE%E6%9C%AC%E9%A1%8C\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e実測: 日本語では品詞タグが効果を発揮しません（ここが今日の本題）\u003c/h2\u003e\n\u003cp data-sourcepos=\"29:1-29:211\"\u003e「名詞だけ重みを高くする」という一般的な設計を入れていたのですが、\u003cstrong\u003e監査ツールでの実測で、日本語ではこの仮定が崩れている\u003c/strong\u003eことが分かりました。\u003c/p\u003e\n\u003cp data-sourcepos=\"31:1-31:238\"\u003eNLTaggerの\u003ccode\u003elexicalClass\u003c/code\u003eスキームで日本語窓タイトルを解析すると、\u003cstrong\u003eすべての語が \u003ccode\u003eOtherWord\u003c/code\u003e になります\u003c/strong\u003e。品詞情報が得られないので \u003ccode\u003enoun\u003c/code\u003e/\u003ccode\u003everb\u003c/code\u003e のフィルタは一度も真になりません:\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"33:1-36:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e入力: 集中の高め方\n結果: OtherWord='集中' OtherWord='の' OtherWord='高め' OtherWord='方'\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"38:1-38:176\"\u003e（この挙動は macOS 標準の言語処理の粒度の問題で、言語を明示しても変わりません。上の1行検証コードで誰でも再現できます。）\u003c/p\u003e\n\u003cp data-sourcepos=\"40:1-40:34\"\u003eこの影響は連鎖的でした:\u003c/p\u003e\n\u003col data-sourcepos=\"42:1-45:0\"\u003e\n\u003cli data-sourcepos=\"42:1-42:57\"\u003e日本語のタイトルではNLP経路が実質不発\u003c/li\u003e\n\u003cli data-sourcepos=\"43:1-43:127\"\u003eキーワード抽出が\u003cstrong\u003e全面的にフォールバック経路\u003c/strong\u003e（区切り文字での単純分割）で動いていた\u003c/li\u003e\n\u003cli data-sourcepos=\"44:1-45:0\"\u003e区切り文字のない日本語チャンクは\u003cstrong\u003e丸ごと1語\u003c/strong\u003eになる — 「進化ログを見る」がそのまま1キーワードとして提案に出ていた\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 data-sourcepos=\"46:1-46:77\"\u003e\n\u003cspan id=\"対策-フォールバックをまともな日本語処理にする\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AF%BE%E7%AD%96-%E3%83%95%E3%82%A9%E3%83%BC%E3%83%AB%E3%83%90%E3%83%83%E3%82%AF%E3%82%92%E3%81%BE%E3%81%A8%E3%82%82%E3%81%AA%E6%97%A5%E6%9C%AC%E8%AA%9E%E5%87%A6%E7%90%86%E3%81%AB%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e対策: フォールバックを「まともな日本語処理」にする\u003c/h2\u003e\n\u003cp data-sourcepos=\"48:1-48:90\"\u003eNLP本体の代わりに、フォールバック側を語単位の精製にしました。\u003c/p\u003e\n\u003cul data-sourcepos=\"50:1-54:0\"\u003e\n\u003cli data-sourcepos=\"50:1-50:63\"\u003eチャンクごとに \u003ccode\u003eNLTokenizer\u003c/code\u003e で\u003cstrong\u003e再トークン化\u003c/strong\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"51:1-51:54\"\u003e助詞（の/を/に/は/が/で/…）の\u003cstrong\u003e除外\u003c/strong\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"52:1-52:99\"\u003e\n\u003cstrong\u003eひらがなのみの語\u003c/strong\u003eの除外（助詞・動詞のかたまり粒子が拾われない）\u003c/li\u003e\n\u003cli data-sourcepos=\"53:1-54:0\"\u003e閾値は言語別（日本語2文字以上・欧米3文字以上）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"55:1-64:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"n\"\u003etokenizer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eenumerateTokens\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ein\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003echunk\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estartIndex\u003c/span\u003e\u003cspan class=\"o\"\u003e..\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003echunk\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eendIndex\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003erange\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ew\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003echunk\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003erange\u003c/span\u003e\u003cspan class=\"p\"\u003e])\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ehiraganaOnly\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003ew\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEmpty\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"n\"\u003ew\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eunicodeScalars\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eallSatisfy\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mh\"\u003e0x3040\u003c/span\u003e\u003cspan class=\"o\"\u003e...\u003c/span\u003e\u003cspan class=\"mh\"\u003e0x309F\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003ew\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026gt;=\u003c/span\u003e \u003cspan class=\"n\"\u003efbMinLen\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003eparticles\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ew\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003ehiraganaOnly\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ecollected\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eappend\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ew\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"66:1-66:66\"\u003e実測（架空サンプル・11件の監査報告から抜粋）:\u003c/p\u003e\n\u003ctable data-sourcepos=\"68:1-72:61\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"68:1-68:64\"\u003e\n\u003cth data-sourcepos=\"68:2-68:27\"\u003e窓タイトル (架空)\u003c/th\u003e\n\u003cth data-sourcepos=\"68:29-68:45\"\u003e旧 (丸呑み)\u003c/th\u003e\n\u003cth data-sourcepos=\"68:47-68:63\"\u003e新 (精製後)\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"70:1-70:67\"\u003e\n\u003ctd data-sourcepos=\"70:2-70:24\"\u003e進化ログを見る\u003c/td\u003e\n\u003ctd data-sourcepos=\"70:26-70:48\"\u003e進化ログを見る\u003c/td\u003e\n\u003ctd data-sourcepos=\"70:50-70:66\"\u003e進化 / ログ\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"71:1-71:87\"\u003e\n\u003ctd data-sourcepos=\"71:2-71:31\"\u003e集中の高め方 — Qiita\u003c/td\u003e\n\u003ctd data-sourcepos=\"71:33-71:60\"\u003e集中の高め方 / Qiita\u003c/td\u003e\n\u003ctd data-sourcepos=\"71:62-71:86\"\u003e集中 / 高め / Qiita\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"72:1-72:61\"\u003e\n\u003ctd data-sourcepos=\"72:2-72:21\"\u003e数学の問題集\u003c/td\u003e\n\u003ctd data-sourcepos=\"72:23-72:42\"\u003e数学の問題集\u003c/td\u003e\n\u003ctd data-sourcepos=\"72:44-72:60\"\u003e数学 / 問題\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"74:1-74:198\"\u003e（正確に記すと精製後も「見る」のように動詞らしき語が残ることはあります。品詞が取れない以上、消しすぎより拾いすぎ、という方針です。）\u003c/p\u003e\n\u003ch2 data-sourcepos=\"76:1-76:45\"\u003e\n\u003cspan id=\"工夫3-解析する前につまみ出す\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%B7%A5%E5%A4%AB3-%E8%A7%A3%E6%9E%90%E3%81%99%E3%82%8B%E5%89%8D%E3%81%AB%E3%81%A4%E3%81%BE%E3%81%BF%E5%87%BA%E3%81%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e工夫3: 解析する前につまみ出す\u003c/h2\u003e\n\u003cp data-sourcepos=\"78:1-78:140\"\u003e解析コスト最大の節約は、\u003cstrong\u003e解析しないという選択\u003c/strong\u003eです。無意味なタイトルをNLPに渡す前に除外します:\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"80:1-87:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003emeaningless\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003etrimmed\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEmpty\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003elower\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"untitled\"\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003elower\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"無題\"\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003etrimmed\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"n\"\u003eappName\u003c/span\u003e           \u003cspan class=\"c1\"\u003e// タイトルがアプリ名そのまま\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003elower\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"page 1\"\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003elower\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ehasPrefix\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"loading\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"89:1-89:188\"\u003eloading類や「無題」を解析から外すだけで、ノイズのキーワードが提案に現れなくなりました。\u003cstrong\u003e強いフィルタは、NLPの前に置くもの\u003c/strong\u003eです。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"91:1-91:27\"\u003e\n\u003cspan id=\"監査ツールの運用\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%9B%A3%E6%9F%BB%E3%83%84%E3%83%BC%E3%83%AB%E3%81%AE%E9%81%8B%E7%94%A8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e監査ツールの運用\u003c/h2\u003e\n\u003cp data-sourcepos=\"93:1-93:393\"\u003eこれらの検証は \u003ccode\u003etools/keyword_audit.swift\u003c/code\u003e（開発用ツール）で行いました。架空のサンプル11件を入力に、現在の抽出ロジック mirror を走査し、表形式の実測結果を出します。\u003cstrong\u003e個人情報は含みません\u003c/strong\u003e（タイトルはすべてダミー）。設計の仮定を定期的に数値で検証するのが、この監査の目的です。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"95:1-95:45\"\u003e\n\u003cspan id=\"代替案との比較で意識したこと\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BB%A3%E6%9B%BF%E6%A1%88%E3%81%A8%E3%81%AE%E6%AF%94%E8%BC%83%E3%81%A7%E6%84%8F%E8%AD%98%E3%81%97%E3%81%9F%E3%81%93%E3%81%A8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e代替案との比較で意識したこと\u003c/h2\u003e\n\u003ctable data-sourcepos=\"97:1-102:64\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"97:1-97:55\"\u003e\n\u003cth data-sourcepos=\"97:2-97:6\"\u003e軸\u003c/th\u003e\n\u003cth data-sourcepos=\"97:8-97:38\"\u003eNLTagger（オフライン）\u003c/th\u003e\n\u003cth data-sourcepos=\"97:40-97:54\"\u003e外部LLM API\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"99:1-99:80\"\u003e\n\u003ctd data-sourcepos=\"99:2-99:9\"\u003e速度\u003c/td\u003e\n\u003ctd data-sourcepos=\"99:11-99:46\"\u003eミリ秒台 (2秒ループ余裕)\u003c/td\u003e\n\u003ctd data-sourcepos=\"99:48-99:79\"\u003eネット待ち・リトライ\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"100:1-100:32\"\u003e\n\u003ctd data-sourcepos=\"100:2-100:9\"\u003e費用\u003c/td\u003e\n\u003ctd data-sourcepos=\"100:11-100:16\"\u003e0円\u003c/td\u003e\n\u003ctd data-sourcepos=\"100:18-100:31\"\u003e従量課金\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"101:1-101:70\"\u003e\n\u003ctd data-sourcepos=\"101:2-101:21\"\u003eプライバシー\u003c/td\u003e\n\u003ctd data-sourcepos=\"101:23-101:39\"\u003e端末内完結\u003c/td\u003e\n\u003ctd data-sourcepos=\"101:41-101:69\"\u003e送信が必ず発生する\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"102:1-102:64\"\u003e\n\u003ctd data-sourcepos=\"102:2-102:9\"\u003e精度\u003c/td\u003e\n\u003ctd data-sourcepos=\"102:11-102:51\"\u003e品詞タグは日本語で効かない\u003c/td\u003e\n\u003ctd data-sourcepos=\"102:53-102:63\"\u003e高精度\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"104:1-104:337\"\u003e\u003cstrong\u003e結論\u003c/strong\u003e: 「外にデータを流さない」条件を守るため、日本語での品詞の弱さは\u003cstrong\u003eフォールバック側の精製で補う\u003c/strong\u003e構成にしました。誤キーワードが出ても、提案は利用者が一目で捨てられる軽さの表示なので、失敗のダメージが小さいという判断です。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"106:1-106:12\"\u003e\n\u003cspan id=\"まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eまとめ\u003c/h2\u003e\n\u003cul data-sourcepos=\"108:1-116:0\"\u003e\n\u003cli data-sourcepos=\"108:1-108:59\"\u003e\n\u003cp data-sourcepos=\"108:3-108:59\"\u003e言語は検知して明示（nilは自動に任せる）\u003c/p\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"109:1-109:117\"\u003e\n\u003cp data-sourcepos=\"109:3-109:117\"\u003e\u003cstrong\u003eNLTaggerの品詞タグは日本語で効かない\u003c/strong\u003e（すべてOtherWord） — 少なくともmacOS現行では\u003c/p\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"110:1-110:111\"\u003e\n\u003cp data-sourcepos=\"110:3-110:111\"\u003e効かない経路に依存するより、フォールバックを「まともな語分割+精製」にする\u003c/p\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"111:1-112:0\"\u003e\n\u003cp data-sourcepos=\"111:3-111:116\"\u003e設計の仮定は監査ツールで定期的に数値実測する（仮定が崩れていたら設計を直す）\u003c/p\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"113:1-113:136\"\u003e\n\u003cp data-sourcepos=\"113:3-113:136\"\u003eコード: \u003ca href=\"https://github.com/Andy234-andy/conscio\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://github.com/Andy234-andy/conscio\u003c/a\u003e （\u003ccode\u003eConscio/Services/ContextMonitor.swift\u003c/code\u003e・\u003ccode\u003eConscio/tools/keyword_audit.swift\u003c/code\u003e）\u003c/p\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"114:1-114:85\"\u003e\n\u003cp data-sourcepos=\"114:3-114:85\"\u003e安全設計: \u003ca href=\"https://github.com/Andy234-andy/conscio/blob/main/Conscio/SECURITY.md\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://github.com/Andy234-andy/conscio/blob/main/Conscio/SECURITY.md\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"115:1-116:0\"\u003e\n\u003cp data-sourcepos=\"115:3-115:208\"\u003e以前の連載: \u003ca href=\"https://qiita.com/Atsuki33/items/9f1b6af7a9edc5b70968\" id=\"reference-6063a00c2ab1bac9b693\"\u003e第1\u003c/a\u003e・\u003ca href=\"https://qiita.com/Atsuki33/items/041923a512b1c7b3328b\" id=\"reference-bcf7b48f5ac99a36a5bc\"\u003e第2\u003c/a\u003e・\u003ca href=\"https://qiita.com/Atsuki33/items/dbd23baa4b3976dfdfae\" id=\"reference-eae1752b36777addccdb\"\u003e第3\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr data-sourcepos=\"117:1-118:0\"\u003e\n\u003cp data-sourcepos=\"119:1-119:85\"\u003e\u003cem\u003e本記事はAIアシストで執筆し、人間が内容を確認しています。\u003c/em\u003e\u003c/p\u003e\n","body":"\u003e **本記事はAIアシストで執筆し、人間が内容を確認しています。**\n\u003e 例はすべて個人開発アプリ**Conscio**（macOS常駐・オフラインNLP）の実装に基づく実測です。\n\nConscioは、最前面アプリの**窓タイトル**を2秒ごとに読み、そこからキーワードを抽出して提案や文脈表示に使います。条件は3つ:\n\n- **弱い入力**: 「無題」「loading…」「イマイチ意味のない文字列」のような散漫な文字列\n- **短い処理**: 2秒ループ内で終わる軽量処理（バッテリーのため）\n- **強い制約**: 外部API・送信ゼロ（データ外送禁止）\n\nこの3条件で動くNLPをどう組むか — 工夫3つと、「設計の仮定が崩れていた」を監査で発見した実話を書きます。\n\n## 工夫1: 言語を「検知」して「明示」する\n\nNLTagger は言語未指定だと自動判定します。2〜3語の短文では判定がブレるため、`NLLanguageRecognizer` の結果を**明示的に**渡します。取得できなかった(nil)ときは無理に設定せず自動判定に任せます:\n\n```swift\nlet recognizer = NLLanguageRecognizer()\nrecognizer.processString(nlpText)\nlet detected = recognizer.dominantLanguage\nlet tagger = NLTagger(tagSchemes: [.lexicalClass])\ntagger.string = nlpText\nif let lang = detected {\n    tagger.setLanguage(lang, range: nlpText.startIndex..\u003cnlpText.endIndex)\n}\n```\n\n## 実測: 日本語では品詞タグが効果を発揮しません（ここが今日の本題）\n\n「名詞だけ重みを高くする」という一般的な設計を入れていたのですが、**監査ツールでの実測で、日本語ではこの仮定が崩れている**ことが分かりました。\n\nNLTaggerの`lexicalClass`スキームで日本語窓タイトルを解析すると、**すべての語が `OtherWord` になります**。品詞情報が得られないので `noun`/`verb` のフィルタは一度も真になりません:\n\n```\n入力: 集中の高め方\n結果: OtherWord='集中' OtherWord='の' OtherWord='高め' OtherWord='方'\n```\n\n（この挙動は macOS 標準の言語処理の粒度の問題で、言語を明示しても変わりません。上の1行検証コードで誰でも再現できます。）\n\nこの影響は連鎖的でした:\n\n1. 日本語のタイトルではNLP経路が実質不発\n2. キーワード抽出が**全面的にフォールバック経路**（区切り文字での単純分割）で動いていた\n3. 区切り文字のない日本語チャンクは**丸ごと1語**になる — 「進化ログを見る」がそのまま1キーワードとして提案に出ていた\n\n## 対策: フォールバックを「まともな日本語処理」にする\n\nNLP本体の代わりに、フォールバック側を語単位の精製にしました。\n\n- チャンクごとに `NLTokenizer` で**再トークン化**\n- 助詞（の/を/に/は/が/で/…）の**除外**\n- **ひらがなのみの語**の除外（助詞・動詞のかたまり粒子が拾われない）\n- 閾値は言語別（日本語2文字以上・欧米3文字以上）\n\n```swift\ntokenizer.enumerateTokens(in: chunk.startIndex..\u003cchunk.endIndex) { range, _ in\n    let w = String(chunk[range])\n    let hiraganaOnly = !w.isEmpty \u0026\u0026 w.unicodeScalars.allSatisfy { (0x3040...0x309F).contains($0.value) }\n    if w.count \u003e= fbMinLen, !particles.contains(w), !hiraganaOnly {\n        collected.append(w)\n    }\n    return true\n}\n```\n\n実測（架空サンプル・11件の監査報告から抜粋）:\n\n| 窓タイトル (架空) | 旧 (丸呑み) | 新 (精製後) |\n|---|---|---|\n| 進化ログを見る | 進化ログを見る | 進化 / ログ |\n| 集中の高め方 — Qiita | 集中の高め方 / Qiita | 集中 / 高め / Qiita |\n| 数学の問題集 | 数学の問題集 | 数学 / 問題 |\n\n（正確に記すと精製後も「見る」のように動詞らしき語が残ることはあります。品詞が取れない以上、消しすぎより拾いすぎ、という方針です。）\n\n## 工夫3: 解析する前につまみ出す\n\n解析コスト最大の節約は、**解析しないという選択**です。無意味なタイトルをNLPに渡す前に除外します:\n\n```swift\nlet meaningless = trimmed.isEmpty\n    || lower == \"untitled\"\n    || lower == \"無題\"\n    || trimmed == appName           // タイトルがアプリ名そのまま\n    || lower == \"page 1\"\n    || lower.hasPrefix(\"loading\")\n```\n\nloading類や「無題」を解析から外すだけで、ノイズのキーワードが提案に現れなくなりました。**強いフィルタは、NLPの前に置くもの**です。\n\n## 監査ツールの運用\n\nこれらの検証は `tools/keyword_audit.swift`（開発用ツール）で行いました。架空のサンプル11件を入力に、現在の抽出ロジック mirror を走査し、表形式の実測結果を出します。**個人情報は含みません**（タイトルはすべてダミー）。設計の仮定を定期的に数値で検証するのが、この監査の目的です。\n\n## 代替案との比較で意識したこと\n\n| 軸 | NLTagger（オフライン） | 外部LLM API |\n|----|----------|-------------|\n| 速度 | ミリ秒台 (2秒ループ余裕) | ネット待ち・リトライ |\n| 費用 | 0円 | 従量課金 |\n| プライバシー | 端末内完結 | 送信が必ず発生する |\n| 精度 | 品詞タグは日本語で効かない | 高精度 |\n\n**結論**: 「外にデータを流さない」条件を守るため、日本語での品詞の弱さは**フォールバック側の精製で補う**構成にしました。誤キーワードが出ても、提案は利用者が一目で捨てられる軽さの表示なので、失敗のダメージが小さいという判断です。\n\n## まとめ\n\n- 言語は検知して明示（nilは自動に任せる）\n- **NLTaggerの品詞タグは日本語で効かない**（すべてOtherWord） — 少なくともmacOS現行では\n- 効かない経路に依存するより、フォールバックを「まともな語分割+精製」にする\n- 設計の仮定は監査ツールで定期的に数値実測する（仮定が崩れていたら設計を直す）\n\n- コード: https://github.com/Andy234-andy/conscio （`Conscio/Services/ContextMonitor.swift`・`Conscio/tools/keyword_audit.swift`）\n- 安全設計: https://github.com/Andy234-andy/conscio/blob/main/Conscio/SECURITY.md\n- 以前の連載: [第1](https://qiita.com/Atsuki33/items/9f1b6af7a9edc5b70968)・[第2](https://qiita.com/Atsuki33/items/041923a512b1c7b3328b)・[第3](https://qiita.com/Atsuki33/items/dbd23baa4b3976dfdfae)\n\n---\n\n*本記事はAIアシストで執筆し、人間が内容を確認しています。*\n","coediting":false,"comments_count":0,"created_at":"2026-10-19T19:47:17+09:00","group":null,"id":"22a016208cab808bba1a","likes_count":1,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Swift","versions":[]},{"name":"macOS","versions":[]},{"name":"自然言語処理","versions":[]},{"name":"NLTagger","versions":[]},{"name":"個人開発","versions":[]}],"title":"SwiftのNLTaggerで日本語をまともに解析する3つの工夫 — 短文「窓タイトル」を2秒毎に読むための設計と実測","updated_at":"2026-10-19T19:47:17+09:00","url":"https://qiita.com/Andy342/items/22a016208cab808bba1a","user":{"description":"Andyです。このアカウントは全てAiが作成しておりますが自己紹介くらいは人間が書こうと思ったので人間が書いています。まずこの記事ではAiに世の中のためになるものを作成してと最初僕はAiにいいました。そして世界の様々な情勢などを読み取ってAiなりに作ったアプリがこの記事で取り扱っているconscioです。文字数制限がそろそろなのでいつか記事で詳しいことを書こうと思っているのでお楽しみに！ではまた！","facebook_id":"","followees_count":4,"followers_count":1,"github_login_name":"Andy234-andy","id":"Andy342","items_count":10,"linkedin_id":"","location":"みんなの心の中","name":"Andy 33","organization":"","permanent_id":2863297,"profile_image_url":"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2863297/profile-images/1792587793","team_only":false,"twitter_screen_name":"andy91lg","website_url":""},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch1 data-sourcepos=\"1:1-1:14\"\u003e\n\u003cspan id=\"はじめに\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eはじめに\u003c/h1\u003e\n\u003cp data-sourcepos=\"3:1-3:161\"\u003e各部品のパーツを作りながら、データ連携について学んでいきたいと思い、今回は \u003ccode\u003eCodable\u003c/code\u003e についてまとめていきます。\u003c/p\u003e\n\u003ch1 data-sourcepos=\"5:1-5:21\"\u003e\n\u003cspan id=\"1-codableとは\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#1-codable%E3%81%A8%E3%81%AF\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e1. Codableとは？\u003c/h1\u003e\n\u003cp data-sourcepos=\"7:1-7:34\"\u003e\u003ccode\u003eCodable\u003c/code\u003e は簡単にいうと、\u003c/p\u003e\n\u003cp data-sourcepos=\"9:1-9:140\"\u003e\u003cstrong\u003eSwiftの値を保存や通信で扱える形へ変換したり、そのデータからSwiftの値へ戻したりするための仕組み\u003c/strong\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"11:1-11:9\"\u003eです。\u003c/p\u003e\n\u003cp data-sourcepos=\"13:1-13:87\"\u003e今回、自分のアプリでは以下のようなモデルを作成しています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"15:1-23:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eStepHistory\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eIdentifiable\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eCodable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// 処理\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eSprint\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eIdentifiable\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eCodable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// 処理\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"25:1-25:90\"\u003e例えば、\u003ccode\u003eSprint\u003c/code\u003e には以下のようなさまざまな情報が入っています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"27:1-34:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003egoalTitle\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\n\u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003efirstStep\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\n\u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eperiodWeeks\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eInt\u003c/span\u003e\n\u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003erecordedDays\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eInt\u003c/span\u003e\n\u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eisActive\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e\n\u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003estartedAt\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eDate\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"36:1-36:114\"\u003eアプリを動かしている間は、これらの情報を \u003ccode\u003eSprint\u003c/code\u003e というSwiftの値として扱えます。\u003c/p\u003e\n\u003cp data-sourcepos=\"38:1-38:163\"\u003eしかし、アプリを終了してもデータを残したい場合などは、これらの情報を\u003cstrong\u003e保存できる形に変換する必要があります\u003c/strong\u003e。\u003c/p\u003e\n\u003cp data-sourcepos=\"40:1-40:47\"\u003eそこで登場するのが \u003ccode\u003eCodable\u003c/code\u003e です。\u003c/p\u003e\n\u003cp data-sourcepos=\"42:1-42:60\"\u003eイメージとしては、以下のようになります。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"44:1-51:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eSwiftの値\nSprint\n   ↓\nエンコード\n   ↓\n保存・通信できるデータ\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"53:1-53:77\"\u003e反対に、保存されているデータをSwiftで扱いたい場合は、\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"55:1-61:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e保存されているデータ\n   ↓\nデコード\n   ↓\nSprint\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"63:1-63:33\"\u003eという流れになります。\u003c/p\u003e\n\u003cp data-sourcepos=\"65:1-65:12\"\u003eつまり、\u003c/p\u003e\n\u003cul data-sourcepos=\"67:1-69:0\"\u003e\n\u003cli data-sourcepos=\"67:1-67:73\"\u003e\n\u003cstrong\u003eSwiftの値 → 保存・通信できるデータ\u003c/strong\u003e：エンコード\u003c/li\u003e\n\u003cli data-sourcepos=\"68:1-69:0\"\u003e\n\u003cstrong\u003e保存・通信されているデータ → Swiftの値\u003c/strong\u003e：デコード\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"70:1-70:33\"\u003eという関係になります。\u003c/p\u003e\n\u003ch1 data-sourcepos=\"72:1-72:46\"\u003e\n\u003cspan id=\"2-codableは2つの役割を持っている\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#2-codable%E3%81%AF2%E3%81%A4%E3%81%AE%E5%BD%B9%E5%89%B2%E3%82%92%E6%8C%81%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e2. Codableは2つの役割を持っている\u003c/h1\u003e\n\u003cp data-sourcepos=\"74:1-74:122\"\u003e\u003ccode\u003eCodable\u003c/code\u003e について調べてみると、実際には以下の2つを組み合わせたものだと分かりました。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"76:1-79:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kt\"\u003eEncodable\u003c/span\u003e\n\u003cspan class=\"kt\"\u003eDecodable\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"81:1-81:27\"\u003eそれぞれの役割は、\u003c/p\u003e\n\u003cul data-sourcepos=\"83:1-85:0\"\u003e\n\u003cli data-sourcepos=\"83:1-83:78\"\u003e\n\u003ccode\u003eEncodable\u003c/code\u003e：Swiftの値を保存・通信できるデータへ変換する\u003c/li\u003e\n\u003cli data-sourcepos=\"84:1-85:0\"\u003e\n\u003ccode\u003eDecodable\u003c/code\u003e：保存・通信されているデータからSwiftの値へ戻す\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"86:1-86:18\"\u003eとなります。\u003c/p\u003e\n\u003cp data-sourcepos=\"88:1-88:69\"\u003eつまり、イメージとしては以下のようになります。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"90:1-96:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e              Encodable\nSprint ----------------------\u0026gt; データ\n\nSprint \u0026lt;---------------------- データ\n              Decodable\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"98:1-98:66\"\u003eそして \u003ccode\u003eCodable\u003c/code\u003e は、この2つをまとめたものです。\u003c/p\u003e\n\u003cp data-sourcepos=\"100:1-100:39\"\u003eそのため、自分のコードで、\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"102:1-106:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eSprint\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCodable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// 処理\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"108:1-108:139\"\u003eと書くことで、\u003ccode\u003eSprint\u003c/code\u003e を\u003cstrong\u003eエンコードとデコードもできるモデル\u003c/strong\u003eとして扱えるということになります。\u003c/p\u003e\n\u003cp data-sourcepos=\"110:1-110:27\"\u003e今回のコードでは、\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"112:1-114:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eSprint\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eIdentifiable\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eCodable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"116:1-116:24\"\u003eとしているため、\u003c/p\u003e\n\u003cul data-sourcepos=\"118:1-120:0\"\u003e\n\u003cli data-sourcepos=\"118:1-118:70\"\u003e\n\u003ccode\u003eIdentifiable\u003c/code\u003e：\u003ccode\u003eSprint\u003c/code\u003e を一意に識別できるようにする\u003c/li\u003e\n\u003cli data-sourcepos=\"119:1-120:0\"\u003e\n\u003ccode\u003eCodable\u003c/code\u003e：\u003ccode\u003eSprint\u003c/code\u003e を保存・通信できる形へ変換したり、元に戻したりできるようにする\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"121:1-121:42\"\u003eという役割を持たせています。\u003c/p\u003e\n\u003ch1 data-sourcepos=\"123:1-123:11\"\u003e\n\u003cspan id=\"まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eまとめ\u003c/h1\u003e\n\u003cp data-sourcepos=\"125:1-125:43\"\u003e今回、\u003ccode\u003eCodable\u003c/code\u003e について学んで、\u003c/p\u003e\n\u003cp data-sourcepos=\"127:1-127:117\"\u003e\u003cstrong\u003eSwiftで扱っているデータを、保存や通信で扱える形へ変換するために使われる仕組み\u003c/strong\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"129:1-129:27\"\u003eだと理解しました。\u003c/p\u003e\n\u003cp data-sourcepos=\"131:1-131:36\"\u003e特に覚えておきたいのは、\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"133:1-142:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eEncodable\nSwiftの値 → 保存・通信できるデータ\n\nDecodable\n保存・通信されているデータ → Swiftの値\n\nCodable\nEncodable + Decodable\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"144:1-144:48\"\u003eという関係になると理解しました。\u003c/p\u003e\n","body":"# はじめに\n\n各部品のパーツを作りながら、データ連携について学んでいきたいと思い、今回は `Codable` についてまとめていきます。\n\n# 1. Codableとは？\n\n`Codable` は簡単にいうと、\n\n**Swiftの値を保存や通信で扱える形へ変換したり、そのデータからSwiftの値へ戻したりするための仕組み**\n\nです。\n\n今回、自分のアプリでは以下のようなモデルを作成しています。\n\n```swift\nstruct StepHistory: Identifiable, Codable {\n    // 処理\n}\n\nstruct Sprint: Identifiable, Codable {\n    // 処理\n}\n```\n\n例えば、`Sprint` には以下のようなさまざまな情報が入っています。\n\n```swift\nvar goalTitle: String\nvar firstStep: String\nvar periodWeeks: Int\nvar recordedDays: Int\nvar isActive: Bool\nvar startedAt: Date\n```\n\nアプリを動かしている間は、これらの情報を `Sprint` というSwiftの値として扱えます。\n\nしかし、アプリを終了してもデータを残したい場合などは、これらの情報を**保存できる形に変換する必要があります**。\n\nそこで登場するのが `Codable` です。\n\nイメージとしては、以下のようになります。\n\n```text\nSwiftの値\nSprint\n   ↓\nエンコード\n   ↓\n保存・通信できるデータ\n```\n\n反対に、保存されているデータをSwiftで扱いたい場合は、\n\n```text\n保存されているデータ\n   ↓\nデコード\n   ↓\nSprint\n```\n\nという流れになります。\n\nつまり、\n\n- **Swiftの値 → 保存・通信できるデータ**：エンコード\n- **保存・通信されているデータ → Swiftの値**：デコード\n\nという関係になります。\n\n# 2. Codableは2つの役割を持っている\n\n`Codable` について調べてみると、実際には以下の2つを組み合わせたものだと分かりました。\n\n```swift\nEncodable\nDecodable\n```\n\nそれぞれの役割は、\n\n- `Encodable`：Swiftの値を保存・通信できるデータへ変換する\n- `Decodable`：保存・通信されているデータからSwiftの値へ戻す\n\nとなります。\n\nつまり、イメージとしては以下のようになります。\n\n```text\n              Encodable\nSprint ----------------------\u003e データ\n\nSprint \u003c---------------------- データ\n              Decodable\n```\n\nそして `Codable` は、この2つをまとめたものです。\n\nそのため、自分のコードで、\n\n```swift\nstruct Sprint: Codable {\n    // 処理\n}\n```\n\nと書くことで、`Sprint` を**エンコードとデコードもできるモデル**として扱えるということになります。\n\n今回のコードでは、\n\n```swift\nstruct Sprint: Identifiable, Codable {\n```\n\nとしているため、\n\n- `Identifiable`：`Sprint` を一意に識別できるようにする\n- `Codable`：`Sprint` を保存・通信できる形へ変換したり、元に戻したりできるようにする\n\nという役割を持たせています。\n\n# まとめ\n\n今回、`Codable` について学んで、\n\n**Swiftで扱っているデータを、保存や通信で扱える形へ変換するために使われる仕組み**\n\nだと理解しました。\n\n特に覚えておきたいのは、\n\n```text\nEncodable\nSwiftの値 → 保存・通信できるデータ\n\nDecodable\n保存・通信されているデータ → Swiftの値\n\nCodable\nEncodable + Decodable\n```\n\nという関係になると理解しました。\n","coediting":false,"comments_count":0,"created_at":"2026-10-19T06:52:39+09:00","group":null,"id":"98addcafb6724acca1c1","likes_count":0,"private":false,"reactions_count":0,"stocks_count":1,"tags":[{"name":"SwiftUI","versions":[]},{"name":"Codable","versions":[]},{"name":"備忘録","versions":[]}],"title":"【Swift】Codableとは？","updated_at":"2026-10-19T06:52:39+09:00","url":"https://qiita.com/Hiromu9915/items/98addcafb6724acca1c1","user":{"description":"この記事は備忘録です。","facebook_id":"","followees_count":1,"followers_count":2,"github_login_name":null,"id":"Hiromu9915","items_count":38,"linkedin_id":"","location":"","name":"","organization":"","permanent_id":4345108,"profile_image_url":"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4345108/profile-images/1773916573","team_only":false,"twitter_screen_name":"Hiro99152","website_url":""},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003cp data-sourcepos=\"1:1-1:186\"\u003e\u003cstrong\u003e※本記事はAIアシストで執筆し、人間が内容を確認して公開しています。プロジェクトのライセンスはMIT、無料でご利用いただけます。\u003c/strong\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"3:1-3:69\"\u003eMacで作業していると、こんな瞬間がありませんか。\u003c/p\u003e\n\u003cul data-sourcepos=\"5:1-8:0\"\u003e\n\u003cli data-sourcepos=\"5:1-5:83\"\u003eエディタを開いたまま「そういえばあの資料どこだっけ…」\u003c/li\u003e\n\u003cli data-sourcepos=\"6:1-6:91\"\u003e検索のためにブラウザを開く → 別のタブに吸われて集中が切れる\u003c/li\u003e\n\u003cli data-sourcepos=\"7:1-8:0\"\u003e通知が鳴って、戻ってきたときには10分が消えている\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"9:1-9:178\"\u003eこの「文脈が切れる」時間を少しでも減らしたくて、**Conscio（コンシオ）**というmacOSのメニューバーアプリを作って公開しました。\u003c/p\u003e\n\u003cp data-sourcepos=\"11:1-11:39\"\u003e\u003ciframe id=\"qiita-embed-content__3509a6f6d786cd7b3b2fb4bbdcbe6450\" src=\"https://qiita.com/embed-contents/link-card#qiita-embed-content__3509a6f6d786cd7b3b2fb4bbdcbe6450\" data-content=\"https%3A%2F%2Fgithub.com%2FAndy234-andy%2Fconscio\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"width:100%;\" height=\"29\"\u003e\n\u003c/iframe\u003e\n\u003c/p\u003e\n\u003cblockquote data-sourcepos=\"13:1-13:201\"\u003e\n\u003cp data-sourcepos=\"13:3-13:201\"\u003e🚀 \u003cstrong\u003e無料で使ってみる → \u003ca href=\"https://github.com/Andy234-andy/conscio/releases/latest\" rel=\"nofollow noopener\" target=\"_blank\"\u003eGitHubからダウンロード (Releases)\u003c/a\u003e\u003c/strong\u003e（macOS 13+ / Apple Silicon・MIT・オフライン動作）\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2 data-sourcepos=\"15:1-15:19\"\u003e\n\u003cspan id=\"conscioの機能\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#conscio%E3%81%AE%E6%A9%9F%E8%83%BD\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eConscioの機能\u003c/h2\u003e\n\u003col data-sourcepos=\"17:1-21:0\"\u003e\n\u003cli data-sourcepos=\"17:1-17:155\"\u003e\n\u003cstrong\u003eリアルタイム文脈認識\u003c/strong\u003e — 2秒ごとに最前面アプリと開いているドキュメントを検知（Accessibility API + NSWorkspace）\u003c/li\u003e\n\u003cli data-sourcepos=\"18:1-18:253\"\u003e\n\u003cstrong\u003eオンデバイスAIキーワード抽出\u003c/strong\u003e — Apple NaturalLanguage frameworkが、作業中の文脈から重要語を取り出して「いま欲しい検索」を提案。\u003cstrong\u003e処理はすべて端末内であり、外部送信はありません\u003c/strong\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"19:1-19:466\"\u003e\n\u003cstrong\u003eFocus Guard (集中モード)\u003c/strong\u003e — 集中すべきアプリにいる間はConscio自体がバナーを出さず、離れたときに実データのセッション報告（どのアプリで何分集中したか）を1件だけ通知。※macOSの仕様上、他アプリの通知自体はOSの通知センターが管理するためConscioでは取得できません — このOSの限界も透明にして、記録できる実データだけを報告します\u003c/li\u003e\n\u003cli data-sourcepos=\"20:1-21:0\"\u003e\n\u003cstrong\u003eデジタル習慣レポート\u003c/strong\u003e — 累積の集中時間・文脈切替回数・アプリ別利用時間を可視化。直近7日の集中時間をSwift Chartsの棒グラフで表示\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"22:1-22:87\"\u003e開発期間はここまで数日、全コードはGitHubで公開中です（MIT）。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"24:1-24:24\"\u003e\n\u003cspan id=\"実装で得た学び\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AE%9F%E8%A3%85%E3%81%A7%E5%BE%97%E3%81%9F%E5%AD%A6%E3%81%B3\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e実装で得た学び\u003c/h2\u003e\n\u003ch3 data-sourcepos=\"26:1-26:70\"\u003e\n\u003cspan id=\"1-xcodeプロジェクトxcodeprojは実は不要だった\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#1-xcode%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88xcodeproj%E3%81%AF%E5%AE%9F%E3%81%AF%E4%B8%8D%E8%A6%81%E3%81%A0%E3%81%A3%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e1. Xcodeプロジェクト（.xcodeproj）は実は不要だった\u003c/h3\u003e\n\u003cp data-sourcepos=\"28:1-28:302\"\u003eSwift Package Managerで\u003ccode\u003eswift build\u003c/code\u003eした実行ファイルは、そのままでは通知などが動かず起動も不安定です。原因は\u003cstrong\u003eバンドルがないこと\u003c/strong\u003e。そこで\u003ccode\u003eInfo.plist\u003c/code\u003eを手書きした正規の\u003ccode\u003e.app\u003c/code\u003eバンドルをディレクトリ構造だけで自作しました。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"30:1-36:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eConscio.app/\n├── Contents/\n│   ├── Info.plist          ← 手書き（LSUIElement=true で Dock 非表示）\n│   ├── MacOS/Conscio       ← swift build の成果物をコピー\n│   └── Resources/*.icns    ← iconutil で生成\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"38:1-38:227\"\u003e\u003ccode\u003ecodesign --deep --sign -\u003c/code\u003eのアドホック署名をすれば取り急ぎローカル配布も可能です。\u003cstrong\u003e「Macアプリ開発はXcodeで開くもの」という固定観念が崩れたのが一番の収穫でした。\u003c/strong\u003e\u003c/p\u003e\n\u003ch3 data-sourcepos=\"40:1-40:62\"\u003e\n\u003cspan id=\"2-axuielementaccessibility-apiとの付き合い方\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#2-axuielementaccessibility-api%E3%81%A8%E3%81%AE%E4%BB%98%E3%81%8D%E5%90%88%E3%81%84%E6%96%B9\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e2. AXUIElement（Accessibility API）との付き合い方\u003c/h3\u003e\n\u003cp data-sourcepos=\"42:1-42:338\"\u003e\u003ccode\u003eAXUIElementCopyAttributeValue\u003c/code\u003eはCベースのAPIで、戻り値の扱いに癖があります。Optionalの二重ネストやAnyObjectのキャストを丁寧にほどく必要があり、ここがSwiftで最も「C言語に触れている」感覚でした。権限は\u003ccode\u003eAXIsProcessTrustedWithOptions\u003c/code\u003eでプロンプトを出せます。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"44:1-44:84\"\u003e\n\u003cspan id=\"3-nltaggerの日本語精度は言語を明示するだけで改善する\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#3-nltagger%E3%81%AE%E6%97%A5%E6%9C%AC%E8%AA%9E%E7%B2%BE%E5%BA%A6%E3%81%AF%E8%A8%80%E8%AA%9E%E3%82%92%E6%98%8E%E7%A4%BA%E3%81%99%E3%82%8B%E3%81%A0%E3%81%91%E3%81%A7%E6%94%B9%E5%96%84%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e3. NLTaggerの日本語精度は「言語を明示する」だけで改善する\u003c/h3\u003e\n\u003cp data-sourcepos=\"46:1-46:94\"\u003ev1.1から実装したオンデバイスNLPは、この一手で確実に安定しました。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"48:1-56:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erecognizer\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNLLanguageRecognizer\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003cspan class=\"n\"\u003erecognizer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eprocessString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003etext\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003etagger\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNLTagger\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003etagSchemes\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elexicalClass\u003c/span\u003e\u003cspan class=\"p\"\u003e])\u003c/span\u003e\n\u003cspan class=\"n\"\u003etagger\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estring\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003etext\u003c/span\u003e\n\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elang\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003erecognizer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003edominantLanguage\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003etagger\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esetLanguage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003elang\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003erange\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003etext\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estartIndex\u003c/span\u003e\u003cspan class=\"o\"\u003e..\u0026lt;\u003c/span\u003e\u003cspan class=\"n\"\u003etext\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eendIndex\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"58:1-58:265\"\u003e言語を明示的に設定しないと、URLフラグメントのような短文では品詞タグ付けが不安定になります。日本語は2文字の名詞でも意味を持つので、キーワードの最小文字数を言語ごとに分けています。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"60:1-60:54\"\u003e\n\u003cspan id=\"4-ブラウザのurlはapplescriptが一番早い\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#4-%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%81%AEurl%E3%81%AFapplescript%E3%81%8C%E4%B8%80%E7%95%AA%E6%97%A9%E3%81%84\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e4. ブラウザのURLはAppleScriptが一番早い\u003c/h3\u003e\n\u003cp data-sourcepos=\"62:1-62:156\"\u003eAccessibilityだけでもtitle属性は取れるのですが、URL取得はSafari/ChromeがAppleScriptを公開しているのでそちらが確実です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"applescript\" data-sourcepos=\"64:1-66:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003etell\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003eapplication\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s2\"\u003e\"Safari\"\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eto\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eget\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nv\"\u003eURL\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eof\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003efront\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"na\"\u003edocument\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"68:1-68:219\"\u003e初回のみmacOSから「ConscioがSafariを制御しようとしています」という確認ダイアログが出ます。この操作にはユーザーの明示的な許可が必要な点に注意が必要です。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"70:1-70:18\"\u003e\n\u003cspan id=\"進化の足跡\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E9%80%B2%E5%8C%96%E3%81%AE%E8%B6%B3%E8%B7%A1\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e進化の足跡\u003c/h2\u003e\n\u003cp data-sourcepos=\"72:1-72:59\"\u003e公開後の1日間で、このように進化しました:\u003c/p\u003e\n\u003cul data-sourcepos=\"74:1-77:0\"\u003e\n\u003cli data-sourcepos=\"74:1-74:91\"\u003ev1.0.0 — 初公開（文脈認識/スマート提案/通知抑制/習慣レポート）\u003c/li\u003e\n\u003cli data-sourcepos=\"75:1-75:60\"\u003ev1.1.0 — オンデバイスNLP＋AI検索サジェスト\u003c/li\u003e\n\u003cli data-sourcepos=\"76:1-77:0\"\u003eその後 — 習慣データのJSON永続化、日本語NLP改良、集中モードの視覚化（メニューアイコンが紫色に）、ブラウザURL実取得、7日間集中チャート\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 data-sourcepos=\"78:1-78:45\"\u003e\n\u003cspan id=\"よかったら使ってみてください\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%88%E3%81%8B%E3%81%A3%E3%81%9F%E3%82%89%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%81%A6%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eよかったら使ってみてください\u003c/h2\u003e\n\u003cul data-sourcepos=\"80:1-83:0\"\u003e\n\u003cli data-sourcepos=\"80:1-80:49\"\u003eGitHub: \u003ca href=\"https://github.com/Andy234-andy/conscio\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://github.com/Andy234-andy/conscio\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"81:1-81:77\"\u003eダウンロード: \u003ca href=\"https://github.com/Andy234-andy/conscio/releases/latest\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://github.com/Andy234-andy/conscio/releases/latest\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"82:1-83:0\"\u003e公式サイト: \u003ca href=\"https://andy234-andy.github.io/conscio/\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://andy234-andy.github.io/conscio/\u003c/a\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"84:1-84:205\"\u003e必要環境はmacOS 13+ / Apple Siliconです。未署名ビルドのため初回起動は右クリック→「開く」をお願いします。改善案・バグはIssueでぜひ教えてください🙏\u003c/p\u003e\n","body":"**※本記事はAIアシストで執筆し、人間が内容を確認して公開しています。プロジェクトのライセンスはMIT、無料でご利用いただけます。**\n\nMacで作業していると、こんな瞬間がありませんか。\n\n- エディタを開いたまま「そういえばあの資料どこだっけ…」\n- 検索のためにブラウザを開く → 別のタブに吸われて集中が切れる\n- 通知が鳴って、戻ってきたときには10分が消えている\n\nこの「文脈が切れる」時間を少しでも減らしたくて、**Conscio（コンシオ）**というmacOSのメニューバーアプリを作って公開しました。\n\nhttps://github.com/Andy234-andy/conscio\n\n\u003e 🚀 **無料で使ってみる → [GitHubからダウンロード (Releases)](https://github.com/Andy234-andy/conscio/releases/latest)**（macOS 13+ / Apple Silicon・MIT・オフライン動作）\n\n## Conscioの機能\n\n1. **リアルタイム文脈認識** — 2秒ごとに最前面アプリと開いているドキュメントを検知（Accessibility API + NSWorkspace）\n2. **オンデバイスAIキーワード抽出** — Apple NaturalLanguage frameworkが、作業中の文脈から重要語を取り出して「いま欲しい検索」を提案。**処理はすべて端末内であり、外部送信はありません**\n3. **Focus Guard (集中モード)** — 集中すべきアプリにいる間はConscio自体がバナーを出さず、離れたときに実データのセッション報告（どのアプリで何分集中したか）を1件だけ通知。※macOSの仕様上、他アプリの通知自体はOSの通知センターが管理するためConscioでは取得できません — このOSの限界も透明にして、記録できる実データだけを報告します\n4. **デジタル習慣レポート** — 累積の集中時間・文脈切替回数・アプリ別利用時間を可視化。直近7日の集中時間をSwift Chartsの棒グラフで表示\n\n開発期間はここまで数日、全コードはGitHubで公開中です（MIT）。\n\n## 実装で得た学び\n\n### 1. Xcodeプロジェクト（.xcodeproj）は実は不要だった\n\nSwift Package Managerで`swift build`した実行ファイルは、そのままでは通知などが動かず起動も不安定です。原因は**バンドルがないこと**。そこで`Info.plist`を手書きした正規の`.app`バンドルをディレクトリ構造だけで自作しました。\n\n```\nConscio.app/\n├── Contents/\n│   ├── Info.plist          ← 手書き（LSUIElement=true で Dock 非表示）\n│   ├── MacOS/Conscio       ← swift build の成果物をコピー\n│   └── Resources/*.icns    ← iconutil で生成\n```\n\n`codesign --deep --sign -`のアドホック署名をすれば取り急ぎローカル配布も可能です。**「Macアプリ開発はXcodeで開くもの」という固定観念が崩れたのが一番の収穫でした。**\n\n### 2. AXUIElement（Accessibility API）との付き合い方\n\n`AXUIElementCopyAttributeValue`はCベースのAPIで、戻り値の扱いに癖があります。Optionalの二重ネストやAnyObjectのキャストを丁寧にほどく必要があり、ここがSwiftで最も「C言語に触れている」感覚でした。権限は`AXIsProcessTrustedWithOptions`でプロンプトを出せます。\n\n### 3. NLTaggerの日本語精度は「言語を明示する」だけで改善する\n\nv1.1から実装したオンデバイスNLPは、この一手で確実に安定しました。\n\n```swift\nlet recognizer = NLLanguageRecognizer()\nrecognizer.processString(text)\nlet tagger = NLTagger(tagSchemes: [.lexicalClass])\ntagger.string = text\nif let lang = recognizer.dominantLanguage {\n    tagger.setLanguage(lang, range: text.startIndex..\u003ctext.endIndex)\n}\n```\n\n言語を明示的に設定しないと、URLフラグメントのような短文では品詞タグ付けが不安定になります。日本語は2文字の名詞でも意味を持つので、キーワードの最小文字数を言語ごとに分けています。\n\n### 4. ブラウザのURLはAppleScriptが一番早い\n\nAccessibilityだけでもtitle属性は取れるのですが、URL取得はSafari/ChromeがAppleScriptを公開しているのでそちらが確実です。\n\n```applescript\ntell application \"Safari\" to get URL of front document\n```\n\n初回のみmacOSから「ConscioがSafariを制御しようとしています」という確認ダイアログが出ます。この操作にはユーザーの明示的な許可が必要な点に注意が必要です。\n\n## 進化の足跡\n\n公開後の1日間で、このように進化しました:\n\n- v1.0.0 — 初公開（文脈認識/スマート提案/通知抑制/習慣レポート）\n- v1.1.0 — オンデバイスNLP＋AI検索サジェスト\n- その後 — 習慣データのJSON永続化、日本語NLP改良、集中モードの視覚化（メニューアイコンが紫色に）、ブラウザURL実取得、7日間集中チャート\n\n## よかったら使ってみてください\n\n- GitHub: https://github.com/Andy234-andy/conscio\n- ダウンロード: https://github.com/Andy234-andy/conscio/releases/latest\n- 公式サイト: https://andy234-andy.github.io/conscio/\n\n必要環境はmacOS 13+ / Apple Siliconです。未署名ビルドのため初回起動は右クリック→「開く」をお願いします。改善案・バグはIssueでぜひ教えてください🙏\n","coediting":false,"comments_count":0,"created_at":"2026-10-18T10:34:22+09:00","group":null,"id":"9f1b6af7a9edc5b70968","likes_count":2,"private":false,"reactions_count":0,"stocks_count":2,"tags":[{"name":"macOS","versions":[]},{"name":"Swift","versions":[]},{"name":"SwiftUI","versions":[]},{"name":"個人開発","versions":[]},{"name":"AI","versions":[]}],"title":"Swiftで文脈を認識するメニューバーアプリ「Conscio」を作った話 — Xcodeプロジェクトなし・オフライン・オンデバイスAI","updated_at":"2026-10-18T20:33:58+09:00","url":"https://qiita.com/Andy342/items/9f1b6af7a9edc5b70968","user":{"description":"Andyです。このアカウントは全てAiが作成しておりますが自己紹介くらいは人間が書こうと思ったので人間が書いています。まずこの記事ではAiに世の中のためになるものを作成してと最初僕はAiにいいました。そして世界の様々な情勢などを読み取ってAiなりに作ったアプリがこの記事で取り扱っているconscioです。文字数制限がそろそろなのでいつか記事で詳しいことを書こうと思っているのでお楽しみに！ではまた！","facebook_id":"","followees_count":4,"followers_count":1,"github_login_name":"Andy234-andy","id":"Andy342","items_count":10,"linkedin_id":"","location":"みんなの心の中","name":"Andy 33","organization":"","permanent_id":2863297,"profile_image_url":"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2863297/profile-images/1792587793","team_only":false,"twitter_screen_name":"andy91lg","website_url":""},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch2 data-sourcepos=\"2:1-2:30\"\u003e\n\u003cspan id=\"対象読者と動作環境\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AF%BE%E8%B1%A1%E8%AA%AD%E8%80%85%E3%81%A8%E5%8B%95%E4%BD%9C%E7%92%B0%E5%A2%83\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e対象読者と動作環境\u003c/h2\u003e\n\u003cp data-sourcepos=\"4:1-4:638\"\u003emacOS の常駐アプリで「修飾キーを素早く2回押した」ことを検出したい人、Core Animation で「広がって消える」演出を \u003ccode\u003eCAAnimationGroup\u003c/code\u003e で組みたい人を対象にしています。題材はメニューバー常駐アプリ「Neon Pointer」の \u003ca href=\"https://github.com/Tanishi-z/NeonPointer/releases/tag/v1.4.0\" rel=\"nofollow noopener\" target=\"_blank\"\u003ev1.4.0\u003c/a\u003e で追加した「修飾キーのダブルタップでカーソル位置に二重の波紋を出す」機能です。軌跡用の修飾キー（既定は Option）を、押してすぐ離す動作を2回すると波紋が出ます。設定でON/OFFできます（既定は ON）。\u003c/p\u003e\n\u003cp data-sourcepos=\"6:1-6:88\"\u003eアプリの動作環境とビルド設定（\u003ccode\u003eproject.yml\u003c/code\u003e）は次のとおりです。\u003c/p\u003e\n\u003cul data-sourcepos=\"8:1-11:0\"\u003e\n\u003cli data-sourcepos=\"8:1-8:52\"\u003emacOS 14 以上（\u003ccode\u003edeploymentTarget\u003c/code\u003e が \u003ccode\u003e14.0\u003c/code\u003e）\u003c/li\u003e\n\u003cli data-sourcepos=\"9:1-9:39\"\u003eSwift 5.0（\u003ccode\u003eSWIFT_VERSION: \"5.0\"\u003c/code\u003e）\u003c/li\u003e\n\u003cli data-sourcepos=\"10:1-11:0\"\u003eXcodeGen（ビルド時のみ）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"12:1-12:557\"\u003e記事中のコードは、コミット \u003ca href=\"https://github.com/Tanishi-z/NeonPointer/commit/140993d\" rel=\"nofollow noopener\" target=\"_blank\"\u003e140993d\u003c/a\u003e と、バージョンを上げた ed7b28e の内容です。ダブルタップの判定ロジックだけは、本体から切り出して単体のスクリプトで動かし、出力を確認しました。波紋の描画（アニメーションの見た目）は、この記事を書くための追加検証をしていません。描画まわりの説明はコードと、Core Animation の仕様から読み取れることに限っています。\u003c/p\u003e\n\u003cp data-sourcepos=\"14:1-14:402\"\u003eリポジトリは \u003ca href=\"https://github.com/Tanishi-z/NeonPointer\" rel=\"nofollow noopener\" target=\"_blank\"\u003eTanishi-z/NeonPointer\u003c/a\u003e です。なぜホットキーを新設せず修飾キーに相乗りしたのか、しきい値をどう考えたのかといった設計の話は、ペアの \u003ca href=\"https://zenn.dev/tanishi_z/articles/33-neonpointer-double-tap-ripple-design\" rel=\"nofollow noopener\" target=\"_blank\"\u003eZenn 版\u003c/a\u003e で扱います。この記事はコードと挙動に絞ります。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"16:1-16:12\"\u003e\n\u003cspan id=\"全体像\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%85%A8%E4%BD%93%E5%83%8F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e全体像\u003c/h2\u003e\n\u003cp data-sourcepos=\"18:1-18:43\"\u003e変更は3か所に分かれています。\u003c/p\u003e\n\u003ctable data-sourcepos=\"20:1-24:94\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"20:1-20:28\"\u003e\n\u003cth data-sourcepos=\"20:2-20:9\"\u003e役割\u003c/th\u003e\n\u003cth data-sourcepos=\"20:11-20:18\"\u003e場所\u003c/th\u003e\n\u003cth data-sourcepos=\"20:20-20:27\"\u003e内容\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"22:1-22:145\"\u003e\n\u003ctd data-sourcepos=\"22:2-22:9\"\u003e検出\u003c/td\u003e\n\u003ctd data-sourcepos=\"22:11-22:31\"\u003e\u003ccode\u003eOverlayController\u003c/code\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"22:33-22:144\"\u003e\n\u003ccode\u003ehandleTick\u003c/code\u003e から \u003ccode\u003edetectDoubleTap\u003c/code\u003e を呼び、成立したら描画先のビューに波紋を依頼する\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"23:1-23:128\"\u003e\n\u003ctd data-sourcepos=\"23:2-23:9\"\u003e描画\u003c/td\u003e\n\u003ctd data-sourcepos=\"23:11-23:28\"\u003e\u003ccode\u003eNeonCursorView\u003c/code\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"23:30-23:127\"\u003e\n\u003ccode\u003eemitRipple(at:screenOrigin:)\u003c/code\u003e が \u003ccode\u003eCAShapeLayer\u003c/code\u003e を2本作ってアニメーションさせる\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"24:1-24:94\"\u003e\n\u003ctd data-sourcepos=\"24:2-24:9\"\u003e設定\u003c/td\u003e\n\u003ctd data-sourcepos=\"24:11-24:44\"\u003e\n\u003ccode\u003eSettingsStore\u003c/code\u003e / \u003ccode\u003eSettingsView\u003c/code\u003e\n\u003c/td\u003e\n\u003ctd data-sourcepos=\"24:46-24:93\"\u003e\n\u003ccode\u003erippleEnabled\u003c/code\u003e（既定 \u003ccode\u003etrue\u003c/code\u003e）とトグル\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"26:1-26:591\"\u003e検出の入力は、既存の軌跡機能と同じものです。\u003ccode\u003eCursorTracker\u003c/code\u003e が tick ごとに \u003ccode\u003eNSEvent.mouseLocation\u003c/code\u003e と \u003ccode\u003eNSEvent.modifierFlags\u003c/code\u003e を読み、\u003ccode\u003eOverlayController.handleTick\u003c/code\u003e に渡します（コード中のコメントのとおり、読むだけなのでアクセシビリティ権限は不要です）。新しいイベント監視は足していません。tick の仕組みは \u003ca href=\"https://qiita.com/tanishi-z/items/62a1de75bf3b959c685a\" id=\"reference-f491d35afd08c9b3366b\"\u003ev1.0.0 の実装記事\u003c/a\u003eと \u003ca href=\"https://qiita.com/tanishi-z/items/2c6cd23b22785d36a0c6\" id=\"reference-9713f5484c1847942aab\"\u003ev1.2.0 の実装記事\u003c/a\u003eに書いています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"28:1-28:51\"\u003e\n\u003cspan id=\"修飾キーのダブルタップを検出する\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BF%AE%E9%A3%BE%E3%82%AD%E3%83%BC%E3%81%AE%E3%83%80%E3%83%96%E3%83%AB%E3%82%BF%E3%83%83%E3%83%97%E3%82%92%E6%A4%9C%E5%87%BA%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e修飾キーのダブルタップを検出する\u003c/h2\u003e\n\u003ch3 data-sourcepos=\"30:1-30:39\"\u003e\n\u003cspan id=\"状態は3つしきい値は2つ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%8A%B6%E6%85%8B%E3%81%AF3%E3%81%A4%E3%81%97%E3%81%8D%E3%81%84%E5%80%A4%E3%81%AF2%E3%81%A4\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e状態は3つ、しきい値は2つ\u003c/h3\u003e\n\u003cp data-sourcepos=\"32:1-32:118\"\u003e\u003ccode\u003eOverlayController\u003c/code\u003e に追加したのは、状態を持つプロパティ3つと、しきい値の定数2つです。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"34:1-44:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eOverlayController.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e/// 修飾キーを押してからこの時間内に離したら「タップ」とみなす。\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erippleMaxTapDuration\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCFTimeInterval\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.3\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// 前回のタップが終わってからこの時間内に次のタップが終われば、ダブルタップとみなす。\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erippleMaxTapInterval\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCFTimeInterval\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.35\u003c/span\u003e\n\n\u003cspan class=\"c1\"\u003e// OverlayController のプロパティ\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003emodifierWasActive\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003emodifierPressTime\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCFTimeInterval\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003elastTapEndTime\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCFTimeInterval\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cul data-sourcepos=\"46:1-49:0\"\u003e\n\u003cli data-sourcepos=\"46:1-46:131\"\u003e\n\u003ccode\u003emodifierWasActive\u003c/code\u003e: 前回の tick で修飾キーが押されていたか。立ち上がり・立ち下がりの検出に使う\u003c/li\u003e\n\u003cli data-sourcepos=\"47:1-47:50\"\u003e\n\u003ccode\u003emodifierPressTime\u003c/code\u003e: 押された瞬間の時刻\u003c/li\u003e\n\u003cli data-sourcepos=\"48:1-49:0\"\u003e\n\u003ccode\u003elastTapEndTime\u003c/code\u003e: 直前のタップが終わった時刻。\u003ccode\u003enil\u003c/code\u003e なら「待っているタップは無い」\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"50:1-50:46\"\u003e判定の本体は \u003ccode\u003edetectDoubleTap\u003c/code\u003e です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"52:1-73:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eOverlayController.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e/// 修飾キーの立ち上がり／立ち下がりから、素早い 2 回押しを検出する。\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// 短いタップは点が 2 個未満なので、線としては残らない。\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003edetectDoubleTap\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003emodifierActive\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCFTimeInterval\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003edefer\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003emodifierWasActive\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003emodifierActive\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003emodifierActive\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003emodifierWasActive\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003emodifierPressTime\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003enow\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003emodifierActive\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003emodifierWasActive\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003enow\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"n\"\u003emodifierPressTime\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan class=\"n\"\u003erippleMaxTapDuration\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"n\"\u003elastTapEndTime\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elast\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003elastTapEndTime\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003enow\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"n\"\u003elast\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan class=\"n\"\u003erippleMaxTapInterval\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"n\"\u003elastTapEndTime\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"n\"\u003elastTapEndTime\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003enow\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"75:1-75:446\"\u003etick ごとに「いま押されているか」しか分からないので、前回の値との差から立ち上がり（押された）と立ち下がり（離された）を作っています。押されたままの tick や離れたままの tick は、どちらの分岐にも入らず \u003ccode\u003efalse\u003c/code\u003e を返します。\u003ccode\u003edefer\u003c/code\u003e で \u003ccode\u003emodifierWasActive\u003c/code\u003e を更新するため、途中の \u003ccode\u003ereturn\u003c/code\u003e があっても前回値の更新は漏れません。\u003c/p\u003e\n\u003cp data-sourcepos=\"77:1-77:52\"\u003e立ち下がりのときの処理は3段階です。\u003c/p\u003e\n\u003col data-sourcepos=\"79:1-82:0\"\u003e\n\u003cli data-sourcepos=\"79:1-79:312\"\u003e押していた時間が \u003ccode\u003erippleMaxTapDuration\u003c/code\u003e（0.3秒）を超えていたら、タップではなく長押しです。\u003ccode\u003elastTapEndTime\u003c/code\u003e を \u003ccode\u003enil\u003c/code\u003e に戻して \u003ccode\u003efalse\u003c/code\u003e を返します。軌跡を描くための長押しが、ダブルタップの1回目として数えられないようにする処理です\u003c/li\u003e\n\u003cli data-sourcepos=\"80:1-80:188\"\u003e直前のタップが終わってから \u003ccode\u003erippleMaxTapInterval\u003c/code\u003e（0.35秒）以内なら、ダブルタップ成立です。\u003ccode\u003elastTapEndTime\u003c/code\u003e を \u003ccode\u003enil\u003c/code\u003e に戻して \u003ccode\u003etrue\u003c/code\u003e を返します\u003c/li\u003e\n\u003cli data-sourcepos=\"81:1-82:0\"\u003eどちらでもなければ、いまの立ち下がりの時刻を \u003ccode\u003elastTapEndTime\u003c/code\u003e に記録します。これが1回目のタップです\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"83:1-83:188\"\u003e成立したときに \u003ccode\u003elastTapEndTime\u003c/code\u003e を \u003ccode\u003enil\u003c/code\u003e に戻すのは、3回連続で押したときに、2回目と3回目をもう1組のダブルタップとして数えないためです。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"85:1-85:52\"\u003e\n\u003cspan id=\"状態遷移を実際に動かして確かめる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%8A%B6%E6%85%8B%E9%81%B7%E7%A7%BB%E3%82%92%E5%AE%9F%E9%9A%9B%E3%81%AB%E5%8B%95%E3%81%8B%E3%81%97%E3%81%A6%E7%A2%BA%E3%81%8B%E3%82%81%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e状態遷移を実際に動かして確かめる\u003c/h3\u003e\n\u003cp data-sourcepos=\"87:1-87:276\"\u003e上の \u003ccode\u003edetectDoubleTap\u003c/code\u003e と定数を、本体のコードのまま単体のスクリプトに切り出して、tick ごとの入力を手で作って確認しました（\u003ccode\u003eswiftc\u003c/code\u003e で直接コンパイル。\u003ccode\u003enow\u003c/code\u003e は引数で渡せるので、時刻を指定できます）。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"console\" data-sourcepos=\"89:1-96:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"gp\"\u003e$\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003eswiftc dt.swift \u003cspan class=\"nt\"\u003e-o\u003c/span\u003e dt \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e ./dt\n\u003cspan class=\"gp\"\u003e2 quick taps -\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e[\u003c/span\u003e0.3]\n\u003cspan class=\"gp\"\u003e3 quick taps -\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e[\u003c/span\u003e0.3]\n\u003cspan class=\"gp\"\u003elong press then tap -\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e[]\u003c/span\u003e\n\u003cspan class=\"gp\"\u003eslow second tap -\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e[]\u003c/span\u003e\n\u003cspan class=\"gp\"\u003eheld (polled repeatedly) -\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e[\u003c/span\u003e0.3]\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"98:1-98:88\"\u003e出力の配列は、\u003ccode\u003etrue\u003c/code\u003e が返った時刻です。入力は次のとおりです。\u003c/p\u003e\n\u003ctable data-sourcepos=\"100:1-106:104\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"100:1-100:55\"\u003e\n\u003cth data-sourcepos=\"100:2-100:9\"\u003e名前\u003c/th\u003e\n\u003cth data-sourcepos=\"100:11-100:45\"\u003e入力（時刻: 押下/離す）\u003c/th\u003e\n\u003cth data-sourcepos=\"100:47-100:54\"\u003e結果\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"102:1-102:84\"\u003e\n\u003ctd data-sourcepos=\"102:2-102:15\"\u003e2 quick taps\u003c/td\u003e\n\u003ctd data-sourcepos=\"102:17-102:67\"\u003e0.0 押す、0.1 離す、0.2 押す、0.3 離す\u003c/td\u003e\n\u003ctd data-sourcepos=\"102:69-102:83\"\u003e0.3 で成立\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"103:1-103:99\"\u003e\n\u003ctd data-sourcepos=\"103:2-103:15\"\u003e3 quick taps\u003c/td\u003e\n\u003ctd data-sourcepos=\"103:17-103:57\"\u003e上に続けて 0.4 押す、0.5 離す\u003c/td\u003e\n\u003ctd data-sourcepos=\"103:59-103:98\"\u003e0.3 で成立、0.5 では成立せず\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"104:1-104:90\"\u003e\n\u003ctd data-sourcepos=\"104:2-104:22\"\u003elong press then tap\u003c/td\u003e\n\u003ctd data-sourcepos=\"104:24-104:74\"\u003e0.0 押す、0.5 離す、0.6 押す、0.7 離す\u003c/td\u003e\n\u003ctd data-sourcepos=\"104:76-104:89\"\u003e成立せず\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"105:1-105:86\"\u003e\n\u003ctd data-sourcepos=\"105:2-105:18\"\u003eslow second tap\u003c/td\u003e\n\u003ctd data-sourcepos=\"105:20-105:70\"\u003e0.0 押す、0.1 離す、0.6 押す、0.7 離す\u003c/td\u003e\n\u003ctd data-sourcepos=\"105:72-105:85\"\u003e成立せず\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"106:1-106:104\"\u003e\n\u003ctd data-sourcepos=\"106:2-106:27\"\u003eheld (polled repeatedly)\u003c/td\u003e\n\u003ctd data-sourcepos=\"106:29-106:87\"\u003e押している間・離している間も tick が来る\u003c/td\u003e\n\u003ctd data-sourcepos=\"106:89-106:103\"\u003e0.3 で成立\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"108:1-108:366\"\u003e2回目のタップを離した瞬間（立ち下がり）に成立します。2回目を押した時点ではまだ波紋は出ません。\u003ccode\u003e3 quick taps\u003c/code\u003e では、3回目のタップは新しい1回目として記録されるだけです。ここで検証したのはロジックだけで、実機で Option キーを押して試したものではありません。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"110:1-110:42\"\u003e\n\u003cspan id=\"早期-return-より前で判定する\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%97%A9%E6%9C%9F-return-%E3%82%88%E3%82%8A%E5%89%8D%E3%81%A7%E5%88%A4%E5%AE%9A%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e早期 return より前で判定する\u003c/h3\u003e\n\u003cp data-sourcepos=\"112:1-112:40\"\u003e\u003ccode\u003ehandleTick\u003c/code\u003e の呼び出し側です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"114:1-129:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eOverlayController.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003emodifierActive\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etrailModifier\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eisActive\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ein\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eflags\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e// 静止中のタップも拾うため、早期 return より前で判定する。\u003c/span\u003e\n\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"nf\"\u003edetectDoubleTap\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003emodifierActive\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003emodifierActive\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCACurrentMediaTime\u003c/span\u003e\u003cspan class=\"p\"\u003e()),\u003c/span\u003e\n   \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etrailEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erippleEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003esuppressed\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003eemitRipple\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eat\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elocation\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eshouldDraw\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEnabled\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etrailEnabled\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"n\"\u003emodifierActive\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003esuppressed\u003c/span\u003e\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elocationChanged\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003elocation\u003c/span\u003e \u003cspan class=\"o\"\u003e!=\u003c/span\u003e \u003cspan class=\"n\"\u003elastCursorLocation\u003c/span\u003e\n\n\u003cspan class=\"c1\"\u003e// カーソルが静止していて、描画中でも軌跡が残ってもいなければ何もすることがない。\u003c/span\u003e\n\u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003eshouldDraw\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003etrail\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisDrawing\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003etrail\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estrokes\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEmpty\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003elocationChanged\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003esuppressionChanged\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"131:1-131:555\"\u003e判定が \u003ccode\u003eguard\u003c/code\u003e の前にあることが重要です。この \u003ccode\u003eguard\u003c/code\u003e は、カーソルが静止していて、描画中でも軌跡が残ってもいない tick を捨てています。ダブルタップはカーソルを動かさずに押すことが多い操作です。もし判定を \u003ccode\u003eguard\u003c/code\u003e の後ろに置くと、静止中の短いタップでは \u003ccode\u003eshouldDraw\u003c/code\u003e も \u003ccode\u003elocationChanged\u003c/code\u003e も立たないので、tick ごと捨てられます。すると押下と解放が \u003ccode\u003edetectDoubleTap\u003c/code\u003e に届かず、ダブルタップが検出できません。\u003c/p\u003e\n\u003cp data-sourcepos=\"133:1-133:380\"\u003e短いタップでは \u003ccode\u003eshouldDraw\u003c/code\u003e が一瞬 \u003ccode\u003etrue\u003c/code\u003e になるだけで、\u003ccode\u003etrail.append\u003c/code\u003e は「描画中で、かつ位置が変わった」ときしか呼ばれません。コードのコメントにあるとおり、点が2個に満たないので線としても残りません。軌跡機能とダブルタップは、同じ修飾キーを使っていても干渉しません。\u003c/p\u003e\n\u003cp data-sourcepos=\"135:1-135:430\"\u003e波紋を出す条件は、全体の ON/OFF（\u003ccode\u003eisEnabled\u003c/code\u003e）、軌跡の ON/OFF（\u003ccode\u003etrailEnabled\u003c/code\u003e）、波紋の ON/OFF（\u003ccode\u003erippleEnabled\u003c/code\u003e）、カーソル非表示モードで隠していないこと（\u003ccode\u003e!suppressed\u003c/code\u003e）です。条件を満たさないときも \u003ccode\u003edetectDoubleTap\u003c/code\u003e は先に呼ばれるので、状態の更新は止まりません。設定を ON に戻した直後に古い状態が残る心配がありません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"137:1-137:65\"\u003e\n\u003cspan id=\"広がって消える二重の波紋を-cashapelayer-で描く\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%BA%83%E3%81%8C%E3%81%A3%E3%81%A6%E6%B6%88%E3%81%88%E3%82%8B%E4%BA%8C%E9%87%8D%E3%81%AE%E6%B3%A2%E7%B4%8B%E3%82%92-cashapelayer-%E3%81%A7%E6%8F%8F%E3%81%8F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e広がって消える二重の波紋を CAShapeLayer で描く\u003c/h2\u003e\n\u003ch3 data-sourcepos=\"139:1-139:19\"\u003e\n\u003cspan id=\"全体の流れ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%85%A8%E4%BD%93%E3%81%AE%E6%B5%81%E3%82%8C\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e全体の流れ\u003c/h3\u003e\n\u003cp data-sourcepos=\"141:1-141:64\"\u003e\u003ccode\u003eNeonCursorView.emitRipple(at:screenOrigin:)\u003c/code\u003e の全文です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"143:1-195:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eNeonCursorView.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e/// 波紋 1 本が広がって消えるまでの時間と、2 本目を遅らせる時間。\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erippleDuration\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCFTimeInterval\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.6\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erippleRingDelay\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCFTimeInterval\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.12\u003c/span\u003e\n\n\u003cspan class=\"c1\"\u003e/// カーソル位置に、外側へ広がって消える二重の波紋を出す。アニメーションは\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// Core Animation 側で進み、終わったレイヤーは自分で取り除く。\u003c/span\u003e\n\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eemitRipple\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eat\u003c/span\u003e \u003cspan class=\"nv\"\u003elocation\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGPoint\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003escreenOrigin\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGPoint\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ehostLayer\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003elayer\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecenter\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGPoint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ex\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elocation\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ex\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"n\"\u003escreenOrigin\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ex\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003ey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elocation\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ey\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"n\"\u003escreenOrigin\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ey\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eradius\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003econfiguration\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esize\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mf\"\u003e1.5\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elineWidth\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003emax\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e2\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003econfiguration\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estrokeWidth\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecolor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003econfiguration\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecolor\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003escale\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ewindow\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebackingScaleFactor\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"mi\"\u003e2\u003c/span\u003e\n\n    \u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"n\"\u003edelay\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003erippleRingDelay\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ering\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCAShapeLayer\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebounds\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGRect\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ex\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003ey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003ewidth\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eradius\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mi\"\u003e2\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eheight\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eradius\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mi\"\u003e2\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eposition\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ecenter\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epath\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGPath\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eellipseIn\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebounds\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003einsetBy\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003edx\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elineWidth\u003c/span\u003e \u003cspan class=\"o\"\u003e/\u003c/span\u003e \u003cspan class=\"mi\"\u003e2\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003edy\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elineWidth\u003c/span\u003e \u003cspan class=\"o\"\u003e/\u003c/span\u003e \u003cspan class=\"mi\"\u003e2\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"nv\"\u003etransform\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efillColor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estrokeColor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ecolor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecgColor\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elineWidth\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003elineWidth\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eshadowColor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ecolor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecgColor\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eshadowOpacity\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eshadowRadius\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003econfiguration\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eglowRadius\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eshadowOffset\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ezero\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eshadowPath\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003econtentsScale\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003escale\u003c/span\u003e\n        \u003cspan class=\"c1\"\u003e// アニメーション開始前と終了後は見えない状態にしておく。\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eopacity\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003egrow\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCABasicAnimation\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ekeyPath\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"transform.scale\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"n\"\u003egrow\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efromValue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mf\"\u003e0.2\u003c/span\u003e\n        \u003cspan class=\"n\"\u003egrow\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etoValue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mf\"\u003e1.0\u003c/span\u003e\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003efade\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCABasicAnimation\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ekeyPath\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"opacity\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"n\"\u003efade\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efromValue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003econfiguration\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eopacity\u003c/span\u003e\n        \u003cspan class=\"n\"\u003efade\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etoValue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003egroup\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCAAnimationGroup\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n        \u003cspan class=\"n\"\u003egroup\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eanimations\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003egrow\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003efade\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n        \u003cspan class=\"n\"\u003egroup\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eduration\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003erippleDuration\u003c/span\u003e\n        \u003cspan class=\"n\"\u003egroup\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebeginTime\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCACurrentMediaTime\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003edelay\u003c/span\u003e\n        \u003cspan class=\"n\"\u003egroup\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etimingFunction\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCAMediaTimingFunction\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eeaseOut\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\n        \u003cspan class=\"kt\"\u003eCATransaction\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ebegin\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n        \u003cspan class=\"kt\"\u003eCATransaction\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esetCompletionBlock\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eremoveFromSuperlayer\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ehostLayer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eaddSublayer\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eadd\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003egroup\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eforKey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"ripple\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n        \u003cspan class=\"kt\"\u003eCATransaction\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecommit\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"197:1-197:148\"\u003e\u003ccode\u003efor delay in [0, rippleRingDelay]\u003c/code\u003e で、同じ処理を遅延 0 秒と 0.12 秒の2回繰り返します。これが「二重」の正体です。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"199:1-199:61\"\u003e\n\u003cspan id=\"形はレイヤーで動きはアニメーションで\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%BD%A2%E3%81%AF%E3%83%AC%E3%82%A4%E3%83%A4%E3%83%BC%E3%81%A7%E5%8B%95%E3%81%8D%E3%81%AF%E3%82%A2%E3%83%8B%E3%83%A1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%A7\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e形はレイヤーで、動きはアニメーションで\u003c/h3\u003e\n\u003cp data-sourcepos=\"201:1-201:452\"\u003e形は \u003ccode\u003eCAShapeLayer\u003c/code\u003e です。\u003ccode\u003ebounds\u003c/code\u003e は半径 \u003ccode\u003econfiguration.size * 1.5\u003c/code\u003e の正方形で、\u003ccode\u003eposition\u003c/code\u003e に中心を置きます。\u003ccode\u003epath\u003c/code\u003e は \u003ccode\u003ebounds\u003c/code\u003e を \u003ccode\u003elineWidth / 2\u003c/code\u003e だけ内側に縮めた楕円です。線は \u003ccode\u003epath\u003c/code\u003e の上に中心をそろえて引かれるので、半分の太さぶん内側に寄せておけば、線が \u003ccode\u003ebounds\u003c/code\u003e からはみ出しません。\u003ccode\u003efillColor = nil\u003c/code\u003e で塗りをなくし、輪郭だけを残しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"203:1-203:283\"\u003e光り方は、本体のネオンと同じく \u003ccode\u003eshadowColor\u003c/code\u003e を線と同じ色にして \u003ccode\u003eshadowRadius\u003c/code\u003e に \u003ccode\u003eglowRadius\u003c/code\u003e を入れます。\u003ccode\u003eshadowPath = nil\u003c/code\u003e は、影を固定のパスではなく、ストロークを含むレイヤーの描画結果から作らせる指定です。\u003c/p\u003e\n\u003cp data-sourcepos=\"205:1-205:484\"\u003e動きは、\u003ccode\u003etransform.scale\u003c/code\u003e を 0.2 から 1.0 へ、\u003ccode\u003eopacity\u003c/code\u003e を \u003ccode\u003econfiguration.opacity\u003c/code\u003e（設定の不透明度）から 0 へ動かす、2本の \u003ccode\u003eCABasicAnimation\u003c/code\u003e です。これを \u003ccode\u003eCAAnimationGroup\u003c/code\u003e にまとめ、\u003ccode\u003eduration\u003c/code\u003e を 0.6 秒、\u003ccode\u003etimingFunction\u003c/code\u003e を \u003ccode\u003e.easeOut\u003c/code\u003e にしています。グループに \u003ccode\u003etimingFunction\u003c/code\u003e を指定すると、グループの時間軸全体に掛かります。広がる速さは出だしが速く、終わりに向けて減速します。\u003c/p\u003e\n\u003cp data-sourcepos=\"207:1-207:398\"\u003e大きさをアニメーションで変えるのは、\u003ccode\u003epath\u003c/code\u003e を毎フレーム作り直さないためです。最大サイズの円を1回だけ作り、\u003ccode\u003etransform.scale\u003c/code\u003e で縮めた状態から元の大きさに戻しています。ただし、拡大縮小は線の太さも同じ比率で変えます。広がり始めでは線が細く、広がりきると設定した太さになります。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"209:1-209:41\"\u003e\n\u003cspan id=\"begintime-で2本目を遅らせる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#begintime-%E3%81%A72%E6%9C%AC%E7%9B%AE%E3%82%92%E9%81%85%E3%82%89%E3%81%9B%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003ccode\u003ebeginTime\u003c/code\u003e で2本目を遅らせる\u003c/h3\u003e\n\u003cp data-sourcepos=\"211:1-211:354\"\u003e2本目を遅らせるのは \u003ccode\u003egroup.beginTime = CACurrentMediaTime() + delay\u003c/code\u003e です。1本目は \u003ccode\u003edelay = 0\u003c/code\u003e なので、すぐ始まります。2本目は 0.12 秒後に始まります。レイヤーを2本作るだけで、タイマーや \u003ccode\u003eDispatchQueue.asyncAfter\u003c/code\u003e は使っていません。遅れの管理は Core Animation に任せています。\u003c/p\u003e\n\u003cp data-sourcepos=\"213:1-213:509\"\u003e\u003ccode\u003ebeginTime\u003c/code\u003e に \u003ccode\u003eCACurrentMediaTime()\u003c/code\u003e を使えるのは、このレイヤーが追加される先（\u003ccode\u003eNeonCursorView\u003c/code\u003e のホストレイヤー）に、時間をずらす指定がないためです。親が時間を変えていれば、\u003ccode\u003econvertTime(_:from:)\u003c/code\u003e で変換する必要があります。Apple のドキュメントが一般に勧める書き方は \u003ccode\u003elayer.convertTime(CACurrentMediaTime(), from: nil) + delay\u003c/code\u003e で、親に \u003ccode\u003espeed\u003c/code\u003e や \u003ccode\u003etimeOffset\u003c/code\u003e を設定する場合はこちらが安全です。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"215:1-215:46\"\u003e\n\u003cspan id=\"終わったレイヤーを自分で外す\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%B5%82%E3%82%8F%E3%81%A3%E3%81%9F%E3%83%AC%E3%82%A4%E3%83%A4%E3%83%BC%E3%82%92%E8%87%AA%E5%88%86%E3%81%A7%E5%A4%96%E3%81%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e終わったレイヤーを自分で外す\u003c/h3\u003e\n\u003cp data-sourcepos=\"217:1-217:212\"\u003eアニメーションが終わっても、レイヤーはツリーに残ります。そこで \u003ccode\u003eCATransaction.setCompletionBlock\u003c/code\u003e で、アニメーション完了時に \u003ccode\u003eremoveFromSuperlayer()\u003c/code\u003e を呼びます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"219:1-225:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kt\"\u003eCATransaction\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ebegin\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003cspan class=\"kt\"\u003eCATransaction\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esetCompletionBlock\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eremoveFromSuperlayer\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"n\"\u003ehostLayer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eaddSublayer\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"n\"\u003ering\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eadd\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003egroup\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eforKey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"ripple\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"kt\"\u003eCATransaction\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecommit\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"227:1-227:546\"\u003e完了ブロックは、そのトランザクションの中で追加したアニメーションがすべて終わったあとに呼ばれます（\u003ca href=\"https://developer.apple.com/documentation/quartzcore/catransaction/setcompletionblock(_:)\" rel=\"nofollow noopener\" target=\"_blank\"\u003e\u003ccode\u003esetCompletionBlock(_:)\u003c/code\u003e\u003c/a\u003e）。\u003ccode\u003ebegin()\u003c/code\u003e の後に \u003ccode\u003eaddSublayer\u003c/code\u003e と \u003ccode\u003eadd(_:forKey:)\u003c/code\u003e を入れているのは、このためです。リングごとにトランザクションを分けているので、1本目と2本目は、それぞれ自分のアニメーションが終わったときに外れます。\u003c/p\u003e\n\u003cp data-sourcepos=\"229:1-229:187\"\u003e\u003ccode\u003ering\u003c/code\u003e をクロージャで捕まえているため、レイヤーを配列などで管理する必要はありません。連打しても、リングごとに独立して消えます。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"231:1-231:42\"\u003e\n\u003cspan id=\"初期-opacity-を-0-にする理由\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%88%9D%E6%9C%9F-opacity-%E3%82%92-0-%E3%81%AB%E3%81%99%E3%82%8B%E7%90%86%E7%94%B1\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e初期 \u003ccode\u003eopacity\u003c/code\u003e を 0 にする理由\u003c/h3\u003e\n\u003cp data-sourcepos=\"233:1-233:205\"\u003eコードのコメントは「アニメーション開始前と終了後は見えない状態にしておく」です。ここで、\u003ccode\u003eCABasicAnimation\u003c/code\u003e を使うときの仕様を整理しておきます。\u003c/p\u003e\n\u003cul data-sourcepos=\"235:1-237:0\"\u003e\n\u003cli data-sourcepos=\"235:1-235:167\"\u003eアニメーションが動いている間、画面に出るのは補間された値で、レイヤー自身のプロパティ値（モデル値）は変わらない\u003c/li\u003e\n\u003cli data-sourcepos=\"236:1-237:0\"\u003e\n\u003ccode\u003efillMode\u003c/code\u003e の指定がなければ、\u003ccode\u003ebeginTime\u003c/code\u003e に達する前も、終了後も、画面にはモデル値が出る\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"238:1-238:515\"\u003e\u003ccode\u003eopacity\u003c/code\u003e のモデル値は、何もしなければ 1 です。もし \u003ccode\u003ering.opacity = 0\u003c/code\u003e を置かなければ、仕様上は、2本目は \u003ccode\u003ebeginTime\u003c/code\u003e までの 0.12 秒間、\u003ccode\u003etransform.scale\u003c/code\u003e が 1.0（最大サイズ）で不透明度 1 のリングが一瞬出るはずです。終了後も同じで、完了ブロックでレイヤーが外れるまでの間、フル表示に戻る可能性があります。\u003ccode\u003eopacity = 0\u003c/code\u003e にしておけば、どちらの区間もモデル値が 0 なので見えません。\u003c/p\u003e\n\u003cp data-sourcepos=\"240:1-240:310\"\u003e\u003ccode\u003efillMode\u003c/code\u003e に \u003ccode\u003e.backwards\u003c/code\u003e や \u003ccode\u003e.forwards\u003c/code\u003e を指定する方法もあります。ここでは、モデル値側を 0 にする方法が選ばれています。この説明は仕様から読み取ったもので、\u003ccode\u003eopacity = 0\u003c/code\u003e を外した場合の見た目を実際に試したわけではありません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"242:1-242:57\"\u003e\n\u003cspan id=\"マルチディスプレイでの描画先の決め方\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%9E%E3%83%AB%E3%83%81%E3%83%87%E3%82%A3%E3%82%B9%E3%83%97%E3%83%AC%E3%82%A4%E3%81%A7%E3%81%AE%E6%8F%8F%E7%94%BB%E5%85%88%E3%81%AE%E6%B1%BA%E3%82%81%E6%96%B9\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eマルチディスプレイでの描画先の決め方\u003c/h2\u003e\n\u003cp data-sourcepos=\"244:1-244:317\"\u003e波紋はカーソルがあるスクリーンのビューに描きます。v1.1.0 以降の NeonPointer は、スクリーンごとに1枚のパネルとビューを持っています（\u003ca href=\"https://qiita.com/tanishi-z/items/4c99095bb64a6b5598d1\" id=\"reference-da76cd01e593c0ba4dc2\"\u003ev1.1.0 の実装記事\u003c/a\u003e）。\u003ccode\u003eOverlayController.emitRipple(at:)\u003c/code\u003e です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"246:1-251:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eOverlayController.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eemitRipple\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eat\u003c/span\u003e \u003cspan class=\"nv\"\u003elocation\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGPoint\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eoverlay\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eoverlays\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003efirst\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ewhere\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epanel\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eframe\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003econtains\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003elocation\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e})\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eview\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eemitRipple\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eat\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elocation\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003escreenOrigin\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epanel\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eframe\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eorigin\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"253:1-253:351\"\u003e\u003ccode\u003elocation\u003c/code\u003e は \u003ccode\u003eNSEvent.mouseLocation\u003c/code\u003e で、全スクリーンをまたぐグローバル座標（原点は左下）です。パネルの \u003ccode\u003eframe\u003c/code\u003e も同じ座標系なので、\u003ccode\u003epanel.frame.contains(location)\u003c/code\u003e で、カーソルがどのスクリーンにあるかを判定できます。含むパネルがなければ、何もせずに戻ります。\u003c/p\u003e\n\u003cp data-sourcepos=\"255:1-255:166\"\u003eビューの中のレイヤーは、そのパネルの原点を基準にした座標です。そのため、\u003ccode\u003eemitRipple\u003c/code\u003e の冒頭で次の変換をしています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"257:1-259:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecenter\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGPoint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ex\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elocation\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ex\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"n\"\u003escreenOrigin\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ex\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003ey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elocation\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ey\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u003c/span\u003e \u003cspan class=\"n\"\u003escreenOrigin\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ey\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"261:1-261:189\"\u003e\u003ccode\u003escreenOrigin\u003c/code\u003e には \u003ccode\u003epanel.frame.origin\u003c/code\u003e を渡します。ネオン本体の \u003ccode\u003eupdate(cursorLocation:screenOrigin:)\u003c/code\u003e も、同じ引き算で \u003ccode\u003eneonLayer.position\u003c/code\u003e を決めています。\u003c/p\u003e\n\u003cp data-sourcepos=\"263:1-263:236\"\u003e軌跡のレイヤーは、パスをグローバル座標のまま持ち、コンテナごと \u003ccode\u003e-screenOrigin\u003c/code\u003e へ平行移動する方式です。波紋は短命なので、中心だけを変換する単純な方式にしています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"265:1-265:18\"\u003e\n\u003cspan id=\"設定の追加\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%A8%AD%E5%AE%9A%E3%81%AE%E8%BF%BD%E5%8A%A0\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e設定の追加\u003c/h2\u003e\n\u003cp data-sourcepos=\"267:1-267:291\"\u003e\u003ccode\u003eSettingsStore\u003c/code\u003e に \u003ccode\u003erippleEnabled\u003c/code\u003e を追加しました。保存先は \u003ccode\u003eUserDefaults\u003c/code\u003e で、既定値は \u003ccode\u003etrue\u003c/code\u003e です。\u003ccode\u003ehideWhenCursorHidden\u003c/code\u003e を足したときと同じ型で、キー、既定値、\u003ccode\u003e@Published\u003c/code\u003e、\u003ccode\u003einit\u003c/code\u003e、\u003ccode\u003eresetToDefaults()\u003c/code\u003e の5か所に1行ずつ足しています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"269:1-280:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSettingsStore.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erippleEnabled\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"rippleEnabled\"\u003c/span\u003e            \u003cspan class=\"c1\"\u003e// Key\u003c/span\u003e\n\u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erippleEnabled\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e                       \u003cspan class=\"c1\"\u003e// Default\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@Published\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003erippleEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003edidSet\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nf\"\u003epersist\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003erippleEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eforKey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eKey\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erippleEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"c1\"\u003e// init 内\u003c/span\u003e\n\u003cspan class=\"n\"\u003erippleEnabled\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003edefaults\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eobject\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eforKey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eKey\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erippleEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003eas?\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"kt\"\u003eDefault\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erippleEnabled\u003c/span\u003e\n\n\u003cspan class=\"c1\"\u003e// resetToDefaults() 内\u003c/span\u003e\n\u003cspan class=\"n\"\u003erippleEnabled\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eDefault\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erippleEnabled\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"282:1-282:484\"\u003e読み方が \u003ccode\u003edefaults.object(forKey:) as? Bool ?? 既定値\u003c/code\u003e なのは、既存の設定項目と同じ理由です。\u003ccode\u003ebool(forKey:)\u003c/code\u003e はキーがないと \u003ccode\u003efalse\u003c/code\u003e を返します。既定値が \u003ccode\u003etrue\u003c/code\u003e のこの項目では、更新前から使っているユーザー（保存済みのキーがない）が、アップデートしただけで OFF になってしまいます。\u003ccode\u003eobject(forKey:)\u003c/code\u003e で「未設定」を \u003ccode\u003enil\u003c/code\u003e として見分け、既定値の \u003ccode\u003etrue\u003c/code\u003e に倒します。\u003c/p\u003e\n\u003cp data-sourcepos=\"284:1-284:108\"\u003e設定画面には、軌跡の修飾キーを選ぶセグメントの下にトグルを足しています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"286:1-290:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSettingsView.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kt\"\u003eToggle\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"キーを2回押して波紋を出す\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eisOn\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003e$settings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erippleEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etoggleStyle\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003eswitch\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003efont\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecallout\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"292:1-292:289\"\u003e\u003ccode\u003ehandleTick\u003c/code\u003e の条件に \u003ccode\u003esettings.rippleEnabled\u003c/code\u003e を入れたので、OFF にすると波紋は出ません。\u003ccode\u003etrailEnabled\u003c/code\u003e が OFF のときも、波紋は出ません。軌跡の修飾キーを前提にした機能なので、条件に \u003ccode\u003esettings.trailEnabled\u003c/code\u003e を入れています。\u003c/p\u003e\n\u003cp data-sourcepos=\"294:1-294:223\"\u003eREADME には、fn キーを選ぶと macOS の「fn を2回押して音声入力」と競合することがある、と注記しました。この点は macOS 側の設定と合わせて確認する必要があります。\u003c/p\u003e\n\u003cp data-sourcepos=\"296:1-296:90\"\u003eバージョンは \u003ccode\u003eproject.yml\u003c/code\u003e の \u003ccode\u003eMARKETING_VERSION\u003c/code\u003e を \u003ccode\u003e1.4.0\u003c/code\u003e に上げました。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"298:1-298:30\"\u003e\n\u003cspan id=\"neonpointer-の関連記事\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#neonpointer-%E3%81%AE%E9%96%A2%E9%80%A3%E8%A8%98%E4%BA%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eNeonPointer の関連記事\u003c/h2\u003e\n\u003cul data-sourcepos=\"300:1-306:189\"\u003e\n\u003cli data-sourcepos=\"300:1-300:224\"\u003eZenn（この記事のペア）: \u003ca href=\"https://zenn.dev/tanishi_z/articles/33-neonpointer-double-tap-ripple-design\" rel=\"nofollow noopener\" target=\"_blank\"\u003e長押しは軌跡、素早い2回押しは波紋 ― 1つの修飾キーに2つの意味を持たせる設計\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"301:1-301:236\"\u003eQiita: \u003ca href=\"https://qiita.com/tanishi-z/items/417c9a7198c797e040e4\" id=\"reference-ee435f713a099020da2f\"\u003emacOS でシステムカーソルが非表示かを判定する ― dlsym で CGCursorIsVisible を引き、NSCursor.currentSystem の画像サイズで補う（Swift）\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"302:1-302:198\"\u003eZenn: \u003ca href=\"https://zenn.dev/tanishi_z/articles/31-neonpointer-cursor-hidden-design\" rel=\"nofollow noopener\" target=\"_blank\"\u003eカーソルが「消えた」ことをどう知るか ― 推奨された API がない状態を判定する設計\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"303:1-303:142\"\u003eQiita: \u003ca href=\"https://qiita.com/tanishi-z/items/62a1de75bf3b959c685a\"\u003e常駐アプリでクリック透過のネオンカーソルを実装する\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"304:1-304:250\"\u003eQiita: \u003ca href=\"https://qiita.com/tanishi-z/items/4c99095bb64a6b5598d1\"\u003eスクリーンごとにパネルを固定し、レイヤーだけ動かしてマルチディスプレイに対応する実装（＋SMAppServiceによるログイン時自動起動）\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"305:1-305:216\"\u003eZenn: \u003ca href=\"https://zenn.dev/tanishi_z/articles/19-neonpointer-modifier-key-trail-design\" rel=\"nofollow noopener\" target=\"_blank\"\u003emacOSアプリで新しい権限を求めないために、イベント監視ではなく状態のポーリングを選んだ話\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"306:1-306:189\"\u003eQiita: \u003ca href=\"https://qiita.com/tanishi-z/items/2c6cd23b22785d36a0c6\"\u003eNSEvent.modifierFlagsのポーリングとCAShapeLayerの多重シャドウで「動く軌跡」を描く（macOS/Swift）\u003c/a\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n","body":"\n## 対象読者と動作環境\n\nmacOS の常駐アプリで「修飾キーを素早く2回押した」ことを検出したい人、Core Animation で「広がって消える」演出を `CAAnimationGroup` で組みたい人を対象にしています。題材はメニューバー常駐アプリ「Neon Pointer」の [v1.4.0](https://github.com/Tanishi-z/NeonPointer/releases/tag/v1.4.0) で追加した「修飾キーのダブルタップでカーソル位置に二重の波紋を出す」機能です。軌跡用の修飾キー（既定は Option）を、押してすぐ離す動作を2回すると波紋が出ます。設定でON/OFFできます（既定は ON）。\n\nアプリの動作環境とビルド設定（`project.yml`）は次のとおりです。\n\n- macOS 14 以上（`deploymentTarget` が `14.0`）\n- Swift 5.0（`SWIFT_VERSION: \"5.0\"`）\n- XcodeGen（ビルド時のみ）\n\n記事中のコードは、コミット [140993d](https://github.com/Tanishi-z/NeonPointer/commit/140993d) と、バージョンを上げた ed7b28e の内容です。ダブルタップの判定ロジックだけは、本体から切り出して単体のスクリプトで動かし、出力を確認しました。波紋の描画（アニメーションの見た目）は、この記事を書くための追加検証をしていません。描画まわりの説明はコードと、Core Animation の仕様から読み取れることに限っています。\n\nリポジトリは [Tanishi-z/NeonPointer](https://github.com/Tanishi-z/NeonPointer) です。なぜホットキーを新設せず修飾キーに相乗りしたのか、しきい値をどう考えたのかといった設計の話は、ペアの [Zenn 版](https://zenn.dev/tanishi_z/articles/33-neonpointer-double-tap-ripple-design) で扱います。この記事はコードと挙動に絞ります。\n\n## 全体像\n\n変更は3か所に分かれています。\n\n| 役割 | 場所 | 内容 |\n| --- | --- | --- |\n| 検出 | `OverlayController` | `handleTick` から `detectDoubleTap` を呼び、成立したら描画先のビューに波紋を依頼する |\n| 描画 | `NeonCursorView` | `emitRipple(at:screenOrigin:)` が `CAShapeLayer` を2本作ってアニメーションさせる |\n| 設定 | `SettingsStore` / `SettingsView` | `rippleEnabled`（既定 `true`）とトグル |\n\n検出の入力は、既存の軌跡機能と同じものです。`CursorTracker` が tick ごとに `NSEvent.mouseLocation` と `NSEvent.modifierFlags` を読み、`OverlayController.handleTick` に渡します（コード中のコメントのとおり、読むだけなのでアクセシビリティ権限は不要です）。新しいイベント監視は足していません。tick の仕組みは [v1.0.0 の実装記事](https://qiita.com/tanishi-z/items/62a1de75bf3b959c685a)と [v1.2.0 の実装記事](https://qiita.com/tanishi-z/items/2c6cd23b22785d36a0c6)に書いています。\n\n## 修飾キーのダブルタップを検出する\n\n### 状態は3つ、しきい値は2つ\n\n`OverlayController` に追加したのは、状態を持つプロパティ3つと、しきい値の定数2つです。\n\n```swift:OverlayController.swift\n/// 修飾キーを押してからこの時間内に離したら「タップ」とみなす。\nprivate let rippleMaxTapDuration: CFTimeInterval = 0.3\n/// 前回のタップが終わってからこの時間内に次のタップが終われば、ダブルタップとみなす。\nprivate let rippleMaxTapInterval: CFTimeInterval = 0.35\n\n// OverlayController のプロパティ\nprivate var modifierWasActive = false\nprivate var modifierPressTime: CFTimeInterval = 0\nprivate var lastTapEndTime: CFTimeInterval?\n```\n\n- `modifierWasActive`: 前回の tick で修飾キーが押されていたか。立ち上がり・立ち下がりの検出に使う\n- `modifierPressTime`: 押された瞬間の時刻\n- `lastTapEndTime`: 直前のタップが終わった時刻。`nil` なら「待っているタップは無い」\n\n判定の本体は `detectDoubleTap` です。\n\n```swift:OverlayController.swift\n/// 修飾キーの立ち上がり／立ち下がりから、素早い 2 回押しを検出する。\n/// 短いタップは点が 2 個未満なので、線としては残らない。\nprivate func detectDoubleTap(modifierActive: Bool, now: CFTimeInterval) -\u003e Bool {\n    defer { modifierWasActive = modifierActive }\n\n    if modifierActive, !modifierWasActive {\n        modifierPressTime = now\n    } else if !modifierActive, modifierWasActive {\n        guard now - modifierPressTime \u003c= rippleMaxTapDuration else {\n            lastTapEndTime = nil\n            return false\n        }\n        if let last = lastTapEndTime, now - last \u003c= rippleMaxTapInterval {\n            lastTapEndTime = nil\n            return true\n        }\n        lastTapEndTime = now\n    }\n    return false\n}\n```\n\ntick ごとに「いま押されているか」しか分からないので、前回の値との差から立ち上がり（押された）と立ち下がり（離された）を作っています。押されたままの tick や離れたままの tick は、どちらの分岐にも入らず `false` を返します。`defer` で `modifierWasActive` を更新するため、途中の `return` があっても前回値の更新は漏れません。\n\n立ち下がりのときの処理は3段階です。\n\n1. 押していた時間が `rippleMaxTapDuration`（0.3秒）を超えていたら、タップではなく長押しです。`lastTapEndTime` を `nil` に戻して `false` を返します。軌跡を描くための長押しが、ダブルタップの1回目として数えられないようにする処理です\n2. 直前のタップが終わってから `rippleMaxTapInterval`（0.35秒）以内なら、ダブルタップ成立です。`lastTapEndTime` を `nil` に戻して `true` を返します\n3. どちらでもなければ、いまの立ち下がりの時刻を `lastTapEndTime` に記録します。これが1回目のタップです\n\n成立したときに `lastTapEndTime` を `nil` に戻すのは、3回連続で押したときに、2回目と3回目をもう1組のダブルタップとして数えないためです。\n\n### 状態遷移を実際に動かして確かめる\n\n上の `detectDoubleTap` と定数を、本体のコードのまま単体のスクリプトに切り出して、tick ごとの入力を手で作って確認しました（`swiftc` で直接コンパイル。`now` は引数で渡せるので、時刻を指定できます）。\n\n```console\n$ swiftc dt.swift -o dt \u0026\u0026 ./dt\n2 quick taps -\u003e [0.3]\n3 quick taps -\u003e [0.3]\nlong press then tap -\u003e []\nslow second tap -\u003e []\nheld (polled repeatedly) -\u003e [0.3]\n```\n\n出力の配列は、`true` が返った時刻です。入力は次のとおりです。\n\n| 名前 | 入力（時刻: 押下/離す） | 結果 |\n| --- | --- | --- |\n| 2 quick taps | 0.0 押す、0.1 離す、0.2 押す、0.3 離す | 0.3 で成立 |\n| 3 quick taps | 上に続けて 0.4 押す、0.5 離す | 0.3 で成立、0.5 では成立せず |\n| long press then tap | 0.0 押す、0.5 離す、0.6 押す、0.7 離す | 成立せず |\n| slow second tap | 0.0 押す、0.1 離す、0.6 押す、0.7 離す | 成立せず |\n| held (polled repeatedly) | 押している間・離している間も tick が来る | 0.3 で成立 |\n\n2回目のタップを離した瞬間（立ち下がり）に成立します。2回目を押した時点ではまだ波紋は出ません。`3 quick taps` では、3回目のタップは新しい1回目として記録されるだけです。ここで検証したのはロジックだけで、実機で Option キーを押して試したものではありません。\n\n### 早期 return より前で判定する\n\n`handleTick` の呼び出し側です。\n\n```swift:OverlayController.swift\nlet modifierActive = settings.trailModifier.isActive(in: flags)\n// 静止中のタップも拾うため、早期 return より前で判定する。\nif detectDoubleTap(modifierActive: modifierActive, now: CACurrentMediaTime()),\n   settings.isEnabled, settings.trailEnabled, settings.rippleEnabled, !suppressed {\n    emitRipple(at: location)\n}\n\nlet shouldDraw = settings.isEnabled \u0026\u0026 settings.trailEnabled \u0026\u0026 modifierActive \u0026\u0026 !suppressed\nlet locationChanged = location != lastCursorLocation\n\n// カーソルが静止していて、描画中でも軌跡が残ってもいなければ何もすることがない。\nguard shouldDraw || trail.isDrawing || !trail.strokes.isEmpty || locationChanged || suppressionChanged else {\n    return\n}\n```\n\n判定が `guard` の前にあることが重要です。この `guard` は、カーソルが静止していて、描画中でも軌跡が残ってもいない tick を捨てています。ダブルタップはカーソルを動かさずに押すことが多い操作です。もし判定を `guard` の後ろに置くと、静止中の短いタップでは `shouldDraw` も `locationChanged` も立たないので、tick ごと捨てられます。すると押下と解放が `detectDoubleTap` に届かず、ダブルタップが検出できません。\n\n短いタップでは `shouldDraw` が一瞬 `true` になるだけで、`trail.append` は「描画中で、かつ位置が変わった」ときしか呼ばれません。コードのコメントにあるとおり、点が2個に満たないので線としても残りません。軌跡機能とダブルタップは、同じ修飾キーを使っていても干渉しません。\n\n波紋を出す条件は、全体の ON/OFF（`isEnabled`）、軌跡の ON/OFF（`trailEnabled`）、波紋の ON/OFF（`rippleEnabled`）、カーソル非表示モードで隠していないこと（`!suppressed`）です。条件を満たさないときも `detectDoubleTap` は先に呼ばれるので、状態の更新は止まりません。設定を ON に戻した直後に古い状態が残る心配がありません。\n\n## 広がって消える二重の波紋を CAShapeLayer で描く\n\n### 全体の流れ\n\n`NeonCursorView.emitRipple(at:screenOrigin:)` の全文です。\n\n```swift:NeonCursorView.swift\n/// 波紋 1 本が広がって消えるまでの時間と、2 本目を遅らせる時間。\nprivate let rippleDuration: CFTimeInterval = 0.6\nprivate let rippleRingDelay: CFTimeInterval = 0.12\n\n/// カーソル位置に、外側へ広がって消える二重の波紋を出す。アニメーションは\n/// Core Animation 側で進み、終わったレイヤーは自分で取り除く。\nfunc emitRipple(at location: CGPoint, screenOrigin: CGPoint) {\n    guard let hostLayer = layer else { return }\n    let center = CGPoint(x: location.x - screenOrigin.x, y: location.y - screenOrigin.y)\n    let radius = configuration.size * 1.5\n    let lineWidth = max(2, configuration.strokeWidth)\n    let color = configuration.color\n    let scale = window?.backingScaleFactor ?? 2\n\n    for delay in [0, rippleRingDelay] {\n        let ring = CAShapeLayer()\n        ring.bounds = CGRect(x: 0, y: 0, width: radius * 2, height: radius * 2)\n        ring.position = center\n        ring.path = CGPath(ellipseIn: ring.bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2), transform: nil)\n        ring.fillColor = nil\n        ring.strokeColor = color.cgColor\n        ring.lineWidth = lineWidth\n        ring.shadowColor = color.cgColor\n        ring.shadowOpacity = 1\n        ring.shadowRadius = configuration.glowRadius\n        ring.shadowOffset = .zero\n        ring.shadowPath = nil\n        ring.contentsScale = scale\n        // アニメーション開始前と終了後は見えない状態にしておく。\n        ring.opacity = 0\n\n        let grow = CABasicAnimation(keyPath: \"transform.scale\")\n        grow.fromValue = 0.2\n        grow.toValue = 1.0\n        let fade = CABasicAnimation(keyPath: \"opacity\")\n        fade.fromValue = configuration.opacity\n        fade.toValue = 0\n\n        let group = CAAnimationGroup()\n        group.animations = [grow, fade]\n        group.duration = rippleDuration\n        group.beginTime = CACurrentMediaTime() + delay\n        group.timingFunction = CAMediaTimingFunction(name: .easeOut)\n\n        CATransaction.begin()\n        CATransaction.setCompletionBlock { ring.removeFromSuperlayer() }\n        hostLayer.addSublayer(ring)\n        ring.add(group, forKey: \"ripple\")\n        CATransaction.commit()\n    }\n}\n```\n\n`for delay in [0, rippleRingDelay]` で、同じ処理を遅延 0 秒と 0.12 秒の2回繰り返します。これが「二重」の正体です。\n\n### 形はレイヤーで、動きはアニメーションで\n\n形は `CAShapeLayer` です。`bounds` は半径 `configuration.size * 1.5` の正方形で、`position` に中心を置きます。`path` は `bounds` を `lineWidth / 2` だけ内側に縮めた楕円です。線は `path` の上に中心をそろえて引かれるので、半分の太さぶん内側に寄せておけば、線が `bounds` からはみ出しません。`fillColor = nil` で塗りをなくし、輪郭だけを残しています。\n\n光り方は、本体のネオンと同じく `shadowColor` を線と同じ色にして `shadowRadius` に `glowRadius` を入れます。`shadowPath = nil` は、影を固定のパスではなく、ストロークを含むレイヤーの描画結果から作らせる指定です。\n\n動きは、`transform.scale` を 0.2 から 1.0 へ、`opacity` を `configuration.opacity`（設定の不透明度）から 0 へ動かす、2本の `CABasicAnimation` です。これを `CAAnimationGroup` にまとめ、`duration` を 0.6 秒、`timingFunction` を `.easeOut` にしています。グループに `timingFunction` を指定すると、グループの時間軸全体に掛かります。広がる速さは出だしが速く、終わりに向けて減速します。\n\n大きさをアニメーションで変えるのは、`path` を毎フレーム作り直さないためです。最大サイズの円を1回だけ作り、`transform.scale` で縮めた状態から元の大きさに戻しています。ただし、拡大縮小は線の太さも同じ比率で変えます。広がり始めでは線が細く、広がりきると設定した太さになります。\n\n### `beginTime` で2本目を遅らせる\n\n2本目を遅らせるのは `group.beginTime = CACurrentMediaTime() + delay` です。1本目は `delay = 0` なので、すぐ始まります。2本目は 0.12 秒後に始まります。レイヤーを2本作るだけで、タイマーや `DispatchQueue.asyncAfter` は使っていません。遅れの管理は Core Animation に任せています。\n\n`beginTime` に `CACurrentMediaTime()` を使えるのは、このレイヤーが追加される先（`NeonCursorView` のホストレイヤー）に、時間をずらす指定がないためです。親が時間を変えていれば、`convertTime(_:from:)` で変換する必要があります。Apple のドキュメントが一般に勧める書き方は `layer.convertTime(CACurrentMediaTime(), from: nil) + delay` で、親に `speed` や `timeOffset` を設定する場合はこちらが安全です。\n\n### 終わったレイヤーを自分で外す\n\nアニメーションが終わっても、レイヤーはツリーに残ります。そこで `CATransaction.setCompletionBlock` で、アニメーション完了時に `removeFromSuperlayer()` を呼びます。\n\n```swift\nCATransaction.begin()\nCATransaction.setCompletionBlock { ring.removeFromSuperlayer() }\nhostLayer.addSublayer(ring)\nring.add(group, forKey: \"ripple\")\nCATransaction.commit()\n```\n\n完了ブロックは、そのトランザクションの中で追加したアニメーションがすべて終わったあとに呼ばれます（[`setCompletionBlock(_:)`](https://developer.apple.com/documentation/quartzcore/catransaction/setcompletionblock(_:))）。`begin()` の後に `addSublayer` と `add(_:forKey:)` を入れているのは、このためです。リングごとにトランザクションを分けているので、1本目と2本目は、それぞれ自分のアニメーションが終わったときに外れます。\n\n`ring` をクロージャで捕まえているため、レイヤーを配列などで管理する必要はありません。連打しても、リングごとに独立して消えます。\n\n### 初期 `opacity` を 0 にする理由\n\nコードのコメントは「アニメーション開始前と終了後は見えない状態にしておく」です。ここで、`CABasicAnimation` を使うときの仕様を整理しておきます。\n\n- アニメーションが動いている間、画面に出るのは補間された値で、レイヤー自身のプロパティ値（モデル値）は変わらない\n- `fillMode` の指定がなければ、`beginTime` に達する前も、終了後も、画面にはモデル値が出る\n\n`opacity` のモデル値は、何もしなければ 1 です。もし `ring.opacity = 0` を置かなければ、仕様上は、2本目は `beginTime` までの 0.12 秒間、`transform.scale` が 1.0（最大サイズ）で不透明度 1 のリングが一瞬出るはずです。終了後も同じで、完了ブロックでレイヤーが外れるまでの間、フル表示に戻る可能性があります。`opacity = 0` にしておけば、どちらの区間もモデル値が 0 なので見えません。\n\n`fillMode` に `.backwards` や `.forwards` を指定する方法もあります。ここでは、モデル値側を 0 にする方法が選ばれています。この説明は仕様から読み取ったもので、`opacity = 0` を外した場合の見た目を実際に試したわけではありません。\n\n## マルチディスプレイでの描画先の決め方\n\n波紋はカーソルがあるスクリーンのビューに描きます。v1.1.0 以降の NeonPointer は、スクリーンごとに1枚のパネルとビューを持っています（[v1.1.0 の実装記事](https://qiita.com/tanishi-z/items/4c99095bb64a6b5598d1)）。`OverlayController.emitRipple(at:)` です。\n\n```swift:OverlayController.swift\nprivate func emitRipple(at location: CGPoint) {\n    guard let overlay = overlays.first(where: { $0.panel.frame.contains(location) }) else { return }\n    overlay.view.emitRipple(at: location, screenOrigin: overlay.panel.frame.origin)\n}\n```\n\n`location` は `NSEvent.mouseLocation` で、全スクリーンをまたぐグローバル座標（原点は左下）です。パネルの `frame` も同じ座標系なので、`panel.frame.contains(location)` で、カーソルがどのスクリーンにあるかを判定できます。含むパネルがなければ、何もせずに戻ります。\n\nビューの中のレイヤーは、そのパネルの原点を基準にした座標です。そのため、`emitRipple` の冒頭で次の変換をしています。\n\n```swift\nlet center = CGPoint(x: location.x - screenOrigin.x, y: location.y - screenOrigin.y)\n```\n\n`screenOrigin` には `panel.frame.origin` を渡します。ネオン本体の `update(cursorLocation:screenOrigin:)` も、同じ引き算で `neonLayer.position` を決めています。\n\n軌跡のレイヤーは、パスをグローバル座標のまま持ち、コンテナごと `-screenOrigin` へ平行移動する方式です。波紋は短命なので、中心だけを変換する単純な方式にしています。\n\n## 設定の追加\n\n`SettingsStore` に `rippleEnabled` を追加しました。保存先は `UserDefaults` で、既定値は `true` です。`hideWhenCursorHidden` を足したときと同じ型で、キー、既定値、`@Published`、`init`、`resetToDefaults()` の5か所に1行ずつ足しています。\n\n```swift:SettingsStore.swift\nstatic let rippleEnabled = \"rippleEnabled\"            // Key\nstatic let rippleEnabled = true                       // Default\n\n@Published var rippleEnabled: Bool { didSet { persist(rippleEnabled, forKey: Key.rippleEnabled) } }\n\n// init 内\nrippleEnabled = defaults.object(forKey: Key.rippleEnabled) as? Bool ?? Default.rippleEnabled\n\n// resetToDefaults() 内\nrippleEnabled = Default.rippleEnabled\n```\n\n読み方が `defaults.object(forKey:) as? Bool ?? 既定値` なのは、既存の設定項目と同じ理由です。`bool(forKey:)` はキーがないと `false` を返します。既定値が `true` のこの項目では、更新前から使っているユーザー（保存済みのキーがない）が、アップデートしただけで OFF になってしまいます。`object(forKey:)` で「未設定」を `nil` として見分け、既定値の `true` に倒します。\n\n設定画面には、軌跡の修飾キーを選ぶセグメントの下にトグルを足しています。\n\n```swift:SettingsView.swift\nToggle(\"キーを2回押して波紋を出す\", isOn: $settings.rippleEnabled)\n    .toggleStyle(.switch)\n    .font(.callout)\n```\n\n`handleTick` の条件に `settings.rippleEnabled` を入れたので、OFF にすると波紋は出ません。`trailEnabled` が OFF のときも、波紋は出ません。軌跡の修飾キーを前提にした機能なので、条件に `settings.trailEnabled` を入れています。\n\nREADME には、fn キーを選ぶと macOS の「fn を2回押して音声入力」と競合することがある、と注記しました。この点は macOS 側の設定と合わせて確認する必要があります。\n\nバージョンは `project.yml` の `MARKETING_VERSION` を `1.4.0` に上げました。\n\n## NeonPointer の関連記事\n\n- Zenn（この記事のペア）: [長押しは軌跡、素早い2回押しは波紋 ― 1つの修飾キーに2つの意味を持たせる設計](https://zenn.dev/tanishi_z/articles/33-neonpointer-double-tap-ripple-design)\n- Qiita: [macOS でシステムカーソルが非表示かを判定する ― dlsym で CGCursorIsVisible を引き、NSCursor.currentSystem の画像サイズで補う（Swift）](https://qiita.com/tanishi-z/items/417c9a7198c797e040e4)\n- Zenn: [カーソルが「消えた」ことをどう知るか ― 推奨された API がない状態を判定する設計](https://zenn.dev/tanishi_z/articles/31-neonpointer-cursor-hidden-design)\n- Qiita: [常駐アプリでクリック透過のネオンカーソルを実装する](https://qiita.com/tanishi-z/items/62a1de75bf3b959c685a)\n- Qiita: [スクリーンごとにパネルを固定し、レイヤーだけ動かしてマルチディスプレイに対応する実装（＋SMAppServiceによるログイン時自動起動）](https://qiita.com/tanishi-z/items/4c99095bb64a6b5598d1)\n- Zenn: [macOSアプリで新しい権限を求めないために、イベント監視ではなく状態のポーリングを選んだ話](https://zenn.dev/tanishi_z/articles/19-neonpointer-modifier-key-trail-design)\n- Qiita: [NSEvent.modifierFlagsのポーリングとCAShapeLayerの多重シャドウで「動く軌跡」を描く（macOS/Swift）](https://qiita.com/tanishi-z/items/2c6cd23b22785d36a0c6)\n","coediting":false,"comments_count":0,"created_at":"2026-10-18T10:09:37+09:00","group":null,"id":"08ffe0e4eebf47770e7b","likes_count":1,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Swift","versions":[]},{"name":"macOS","versions":[]},{"name":"AppKit","versions":[]},{"name":"CoreAnimation","versions":[]},{"name":"個人開発","versions":[]}],"title":"CAAnimationGroup と CAShapeLayer で、カーソル位置に広がって消える二重の波紋を出す（macOS/Swift）","updated_at":"2026-10-18T10:09:37+09:00","url":"https://qiita.com/tanishi-z/items/08ffe0e4eebf47770e7b","user":{"description":"気ままにのんびりやってます。\r\nZennの記事もご覧ください。https://zenn.dev/tanishi_z","facebook_id":"","followees_count":1,"followers_count":0,"github_login_name":"Tanishi-z","id":"tanishi-z","items_count":15,"linkedin_id":"","location":"","name":"","organization":"","permanent_id":4477713,"profile_image_url":"https://avatars.githubusercontent.com/u/170227795?v=4","team_only":false,"twitter_screen_name":null,"website_url":""},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch2 data-sourcepos=\"1:1-1:15\"\u003e\n\u003cspan id=\"はじめに\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eはじめに\u003c/h2\u003e\n\u003cp data-sourcepos=\"3:1-3:197\"\u003eiPhone の電卓アプリを作りました。声で式を入れられて、聞き間違えた数字はタップで直せて、「=」キーが無くて打つたびに答えが出る電卓です。\u003c/p\u003e\n\u003cp data-sourcepos=\"5:1-5:359\"\u003e今って音声入力の時代じゃないですか。なので、電卓の右下にいつもある「=」のボタンを、思い切ってマイクのボタンに変えました。ホーム画面とロック画面のウィジェット、コントロールセンター、アクションボタンからも、1回押すだけで声の入力が始まります。\u003c/p\u003e\n\u003cp data-sourcepos=\"7:1-7:211\"\u003e今回はそのうちの声の部分の話で、聞き取った「12万8000 かける 3」とか \"128,000 times 3\" を、\u003ccode\u003e128000×3\u003c/code\u003e っていう式に直すところをどう作ったか書いていきます。\u003c/p\u003e\n\u003cp data-sourcepos=\"9:1-9:201\"\u003eコードは Claude（AI）と一緒に書きました。載せてるコードはアプリの実物から抜き出したもので、例はどれも実際にそのコードに通して出た式です。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"11:1-11:30\"\u003e\n\u003cspan id=\"ざっくり全体の流れ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%96%E3%81%A3%E3%81%8F%E3%82%8A%E5%85%A8%E4%BD%93%E3%81%AE%E6%B5%81%E3%82%8C\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eざっくり全体の流れ\u003c/h2\u003e\n\u003cp data-sourcepos=\"13:1-13:305\"\u003e聞き取りそのものは Apple の音声認識（\u003ccode\u003eSFSpeechRecognizer\u003c/code\u003e）にお任せしてます。計算でよく使う言葉（たす・かける・divided by など）を渡して聞き取りやすくしていて、端末の中で聞き取れる言語なら端末の中だけで処理してます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"15:1-21:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpeechInput.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e// 計算の言葉を聞き取りやすくする（効き目は言語と端末しだい）\u003c/span\u003e\n\u003cspan class=\"n\"\u003erequest\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003econtextualStrings\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003eSelf\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emathWords\u003c/span\u003e\n\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003erecognizer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esupportsOnDeviceRecognition\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003erequest\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erequiresOnDeviceRecognition\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"23:1-23:308\"\u003e返ってくるのはただの文字なので、そこから式を作る部分（\u003ccode\u003eSpokenMath\u003c/code\u003e）は自前で書いてます。聞き取りが終わったら、式にできる言葉があればカーソルの位置に入れて、1 つも無ければ「式にできませんでした」と出す感じです。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"25:1-34:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eCalculatorView.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"n\"\u003espeech\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eonFinish\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003etext\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eexpression\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eSpokenMath\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eexpression\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003efrom\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003etext\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003eexpression\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEmpty\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003evoiceNotice\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003enoCalculation\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003emodel\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003einsert\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eexpression\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eexpression\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"36:1-36:100\"\u003e\u003ccode\u003eSpokenMath.expression(from:)\u003c/code\u003e の中では、文字を何回かに分けて書き換えてます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"38:1-43:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpokenMath.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"n\"\u003etext\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003ereorderingJapanese\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003etext\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"n\"\u003etext\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003enumberWordsToDigits\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ein\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003etext\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"n\"\u003etext\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003eapplying\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eenglishSentences\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eto\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003etext\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"nf\"\u003escan\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eArray\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nf\"\u003ereplaceEnglishWords\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ein\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003etext\u003c/span\u003e\u003cspan class=\"p\"\u003e)))\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"45:1-45:478\"\u003eこの前に、全角を半角にしたり、日本語の言葉のあいだの空白を詰めたりしてます。そのあとは、日本語の言い方を並べ替える、英語の数の言葉を数字にする、英語の言い方を並べ替える、演算子の言葉を記号にする、の順です。最後の \u003ccode\u003escan\u003c/code\u003e で頭から 1 文字ずつ読んで式を組み立てます。「12万8000」みたいな日本語の数は、この \u003ccode\u003escan\u003c/code\u003e の中で読んでます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"47:1-47:51\"\u003e\n\u003cspan id=\"日本語の数は-4-桁ごとに読んでいく\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%95%B0%E3%81%AF-4-%E6%A1%81%E3%81%94%E3%81%A8%E3%81%AB%E8%AA%AD%E3%82%93%E3%81%A7%E3%81%84%E3%81%8F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e日本語の数は 4 桁ごとに読んでいく\u003c/h2\u003e\n\u003cp data-sourcepos=\"49:1-49:357\"\u003e日本語の数って、4 桁ごとに「万」「億」「兆」が付いて、その中に「千」「百」「十」が入るんですよね。なので、読んでる途中の数を 3 つに分けて持ってます。\u003ccode\u003etotal\u003c/code\u003e は「万」以上で決まった分、\u003ccode\u003esection\u003c/code\u003e は今の 4 桁のまとまり、\u003ccode\u003epending\u003c/code\u003e は直前に読んだ数字です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"51:1-67:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpokenMath.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eunit\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003esmallUnits\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003echaracter\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003esection\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003epending\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"n\"\u003eunit\u003c/span\u003e\n    \u003cspan class=\"n\"\u003epending\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\n    \u003cspan class=\"n\"\u003ei\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n    \u003cspan class=\"k\"\u003econtinue\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eunit\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ebigUnits\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003echaracter\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003esection\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"n\"\u003epending\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n    \u003cspan class=\"n\"\u003epending\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\n    \u003cspan class=\"n\"\u003etotal\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esection\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisZero\u003c/span\u003e \u003cspan class=\"p\"\u003e?\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e \u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003esection\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"n\"\u003eunit\u003c/span\u003e\n    \u003cspan class=\"n\"\u003esection\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n    \u003cspan class=\"n\"\u003ei\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n    \u003cspan class=\"k\"\u003econtinue\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"69:1-69:244\"\u003e「千」「百」「十」が来たら、直前の数（無ければ 1。「千」だけなら 1000）をかけて \u003ccode\u003esection\u003c/code\u003e に足します。「万」「億」「兆」が来たら、\u003ccode\u003esection\u003c/code\u003e に単位をかけて \u003ccode\u003etotal\u003c/code\u003e に移します。\u003c/p\u003e\n\u003cp data-sourcepos=\"71:1-71:318\"\u003e「12万8000」なら、12 を読んで、「万」で \u003ccode\u003etotal\u003c/code\u003e が 120000 になって、8000 を読んで、最後に全部足して 128000 です。「三千二百五十」も同じところを通って 3250 になります。数字と漢字が混ざってても、漢数字だけでも同じように読めます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"73:1-73:48\"\u003e\n\u003cspan id=\"英語の数は-3-桁ごとに読んでいく\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%8B%B1%E8%AA%9E%E3%81%AE%E6%95%B0%E3%81%AF-3-%E6%A1%81%E3%81%94%E3%81%A8%E3%81%AB%E8%AA%AD%E3%82%93%E3%81%A7%E3%81%84%E3%81%8F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e英語の数は 3 桁ごとに読んでいく\u003c/h2\u003e\n\u003cp data-sourcepos=\"75:1-75:151\"\u003e英語は thousand・million ごとに 3 桁でまとまるので、こっちは \u003ccode\u003etotal\u003c/code\u003e と、今の 3 桁の \u003ccode\u003ecurrent\u003c/code\u003e の 2 つで読んでます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"77:1-89:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpokenMath.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"mi\"\u003e100\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003ecurrent\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003emax\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrent\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"mi\"\u003e100\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026gt;=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1000\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003etotal\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"nf\"\u003emax\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrent\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"n\"\u003evalue\u003c/span\u003e\n    \u003cspan class=\"n\"\u003ecurrent\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// 「five three」のように並べた数は 1 つにまとめない（8 にしない）\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elastTwo\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSDecimalNumber\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003edecimal\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003ecurrent\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eintValue\u003c/span\u003e \u003cspan class=\"o\"\u003e%\u003c/span\u003e \u003cspan class=\"mi\"\u003e100\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e \u003cspan class=\"mi\"\u003e10\u003c/span\u003e \u003cspan class=\"p\"\u003e?\u003c/span\u003e \u003cspan class=\"n\"\u003elastTwo\u003c/span\u003e \u003cspan class=\"o\"\u003e%\u003c/span\u003e \u003cspan class=\"mi\"\u003e10\u003c/span\u003e \u003cspan class=\"o\"\u003e!=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e \u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elastTwo\u003c/span\u003e \u003cspan class=\"o\"\u003e!=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ebreak\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"n\"\u003ecurrent\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"n\"\u003evalue\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"91:1-91:273\"\u003e\"two thousand twenty four\" なら、two で 2、thousand で \u003ccode\u003etotal\u003c/code\u003e が 2000、twenty と four で \u003ccode\u003ecurrent\u003c/code\u003e が 24 になって、合わせて 2024 です。\"one hundred and five\" の and は、hundred とかの後で、次も数のときだけ読み飛ばしてます。\u003c/p\u003e\n\u003cp data-sourcepos=\"93:1-93:472\"\u003eここで気をつけたのが最後の \u003ccode\u003eelse\u003c/code\u003e の中です。そのまま足していくと、\"five three\" が 8 になっちゃうんですよね。\"twenty four\" の four は、20 の 1 の位が空いてるので足していいです。\"five three\" の three は、5 の 1 の位がもう埋まってるので、足さずにそこで数を切ります。なので \"five three\" は 5 と 3 の 2 つの数になって、後で書く決まりで \u003ccode\u003e5×3\u003c/code\u003e になります。\u003c/p\u003e\n\u003cp data-sourcepos=\"95:1-95:149\"\u003eもう 1 つ、\"twenty-five\" のハイフンは引き算じゃないので、英字にはさまれたハイフンは先に空白に直してます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"97:1-97:54\"\u003e\n\u003cspan id=\"動詞が後ろに来る言い方は並べ替える\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%8B%95%E8%A9%9E%E3%81%8C%E5%BE%8C%E3%82%8D%E3%81%AB%E6%9D%A5%E3%82%8B%E8%A8%80%E3%81%84%E6%96%B9%E3%81%AF%E4%B8%A6%E3%81%B9%E6%9B%BF%E3%81%88%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e動詞が後ろに来る言い方は並べ替える\u003c/h2\u003e\n\u003cp data-sourcepos=\"99:1-99:274\"\u003e「100に30を足す」って、動詞が最後に来ますよね。頭から読むと「100」「30」「足す」の順なので、言葉を記号に直すだけだと式にならないんです。なので先に正規表現で「100+30」の形に並べ替えてます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"101:1-114:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpokenMath.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e// 話し言葉では「を」を省くことが多いので、無くても読む（「100から30引いて」「100に30足して」）。\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e// 割り算の「を」は 2 つの数の区切りなので省かない（「1005で割る」を 100÷5 にしない）\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ejapaneseSentences\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[(\u003c/span\u003e\u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e)]\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ejapaneseNumber\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eopen\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esoftOpen\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"n\"\u003eclose\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esoftClose\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"kt\"\u003eString\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003egap\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"[にと]\u003c/span\u003e\u003cspan class=\"se\"\u003e\\\\\u003c/span\u003e\u003cspan class=\"s\"\u003es*\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"を?\u003c/span\u003e\u003cspan class=\"se\"\u003e\\\\\u003c/span\u003e\u003cspan class=\"s\"\u003es*\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003ejapaneseAdd\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kd\"\u003eopen\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"$1+$2\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003eclose\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"から\u003c/span\u003e\u003cspan class=\"se\"\u003e\\\\\u003c/span\u003e\u003cspan class=\"s\"\u003es*\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"を?\u003c/span\u003e\u003cspan class=\"se\"\u003e\\\\\u003c/span\u003e\u003cspan class=\"s\"\u003es*\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003ejapaneseSubtract\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kd\"\u003eopen\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"$1−$2\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003eclose\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"[にと]\u003c/span\u003e\u003cspan class=\"se\"\u003e\\\\\u003c/span\u003e\u003cspan class=\"s\"\u003es*\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"を?\u003c/span\u003e\u003cspan class=\"se\"\u003e\\\\\u003c/span\u003e\u003cspan class=\"s\"\u003es*\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003ejapaneseMultiply\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kd\"\u003eopen\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"$1×$2\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003eclose\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"を\u003c/span\u003e\u003cspan class=\"se\"\u003e\\\\\u003c/span\u003e\u003cspan class=\"s\"\u003es*\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003en\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"で\u003c/span\u003e\u003cspan class=\"se\"\u003e\\\\\u003c/span\u003e\u003cspan class=\"s\"\u003es*\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003ejapaneseDivide\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kd\"\u003eopen\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"s\"\u003e\"$1÷$2\"\u003c/span\u003e \u003cspan class=\"o\"\u003e+\u003c/span\u003e \u003cspan class=\"n\"\u003eclose\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e]\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}()\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"116:1-116:367\"\u003e\u003ccode\u003en\u003c/code\u003e は数（後ろに「円」「個」とかが付いてもいい）、\u003ccode\u003ejapaneseAdd\u003c/code\u003e は「足す」「足して」「足したら」などの形をまとめた正規表現です。最初は「引いたら」「割った」みたいな形を読めなくて、「100から30引いたら」が \u003ccode\u003e100×30\u003c/code\u003e になってました（× になる理由は次の節で）。\u003c/p\u003e\n\u003cp data-sourcepos=\"118:1-118:288\"\u003e割り算だけ「を」を省けないようにしてるのは、コメントのとおりです。数の部分は数字の並びを取る正規表現なので、「を」を省いていいことにすると、「1005で割る」の 1005 を 100 と 5 に分けて読めちゃいます。\u003c/p\u003e\n\u003cp data-sourcepos=\"120:1-120:187\"\u003e英語も同じで、\"divide 10 by 2\" や \"subtract 5 from 10\" は動詞が前に来るので、先に \"10 over 2\"、\"10 minus 5\" の形に並べ替えてから記号に直してます。\u003c/p\u003e\n\u003cp data-sourcepos=\"122:1-122:492\"\u003e並べ替えた所は、カッコで囲んでおきます。「2かける100に30を足す」のように前に言葉が付いても、並べ替えた所をひとまとまりで計算して \u003ccode\u003e2×(100+30)\u003c/code\u003e にするためです。全体を囲むだけのカッコになったら最後に外すので、「100に30を足す」は \u003ccode\u003e100+30\u003c/code\u003e のままです。「100に30を足して、2をかける」と続けて言ったときは、前の答えに次をかけて \u003ccode\u003e(100+30)×2\u003c/code\u003e にしてます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"124:1-124:69\"\u003e\n\u003cspan id=\"知らない言葉をはさんだ数は-でつないでおく\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%9F%A5%E3%82%89%E3%81%AA%E3%81%84%E8%A8%80%E8%91%89%E3%82%92%E3%81%AF%E3%81%95%E3%82%93%E3%81%A0%E6%95%B0%E3%81%AF-%E3%81%A7%E3%81%A4%E3%81%AA%E3%81%84%E3%81%A7%E3%81%8A%E3%81%8F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e知らない言葉をはさんだ数は、× でつないでおく\u003c/h2\u003e\n\u003cp data-sourcepos=\"126:1-126:146\"\u003e「3個で120円」みたいに、式にできない言葉をはさんで数が並んだときは、× でつないで \u003ccode\u003e3×120\u003c/code\u003e にしてます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"128:1-146:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpokenMath.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e/// 数（か % ・閉じカッコ）の後に、次の数か開きカッコが来たとき。\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// 「の」でつながっていれば ×（100の10%）。何もはさまない (3+4)2 や 2(3+4) はそのまま（計算のときに掛け算）。\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// それ以外は、聞き間違いかもしれないが × でつなぐ（式は画面に出るので、違っていれば直せる）。\u003c/span\u003e\n\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003ejoinFollowingValue\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eisOpen\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eprevious\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eoutput\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elast\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eprevious\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisCalcDigit\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003eprevious\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"%\"\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003eprevious\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\")\"\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"nf\"\u003eisJoinedByNo\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eskipped\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ejoinIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eoutput\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e\n        \u003cspan class=\"n\"\u003eoutput\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eappend\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"×\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eskipped\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEmpty\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eprevious\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\")\"\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eisOpen\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"n\"\u003eprevious\u003c/span\u003e \u003cspan class=\"o\"\u003e!=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"%\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)))\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"c1\"\u003e// 「1000円から10%引き」「1000円10%引き」も、後ろの「引き」「増し」で − / + に直す\u003c/span\u003e\n        \u003cspan class=\"c1\"\u003e// （2026-10-03 「の」のときだけ直していて、1000×10% になっていた）\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ejoinIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eoutput\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e\n        \u003cspan class=\"n\"\u003eoutput\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eappend\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"×\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"148:1-148:301\"\u003e聞き間違いかもしれない式でも、画面に出しておけば、式の中の数字をタップしてその場で直せます。逆に何も出さないと、どこで聞き間違えたのかも分からないんですよね。なので、捨てずに × でつなぐことにしました。\u003c/p\u003e\n\u003cp data-sourcepos=\"150:1-150:375\"\u003e「の」でつながってるとき（「100の10%」）は意味としても掛け算なので、これも × です。× を入れたときは、その位置を \u003ccode\u003ejoinIndex\u003c/code\u003e に覚えておきます。後ろに「引き」「増し」が来たら、そこを − や + に書き換えます。「1000円の10%引き」は \u003ccode\u003e1000−10%\u003c/code\u003e になって、答えは 900 です。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"152:1-152:18\"\u003e\n\u003cspan id=\"ハマった所\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%8F%E3%83%9E%E3%81%A3%E3%81%9F%E6%89%80\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eハマった所\u003c/h2\u003e\n\u003cp data-sourcepos=\"154:1-154:294\"\u003e1 つ目は、「1 万 2000」が \u003ccode\u003e1×10000×2000\u003c/code\u003e になってた件です。数と「万」のあいだに空白があると、「1」「万」「2000」が別々の数として読まれて、上の × の決まりでつながってました。先に空白を詰めるようにしてます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"156:1-160:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpokenMath.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e// 数と「万」「千」などのあいだの空白も詰める（2026-10-03 「1 万 2000」が 1×10000×2000 になっていた）\u003c/span\u003e\n\u003cspan class=\"n\"\u003etext\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003etext\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ereplacingOccurrences\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003eof\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"err\"\u003e#\u003c/span\u003e\u003cspan class=\"s\"\u003e\"(?\u0026lt;=[0-9])\u003c/span\u003e\u003cspan class=\"err\"\u003e\\\u003c/span\u003e\u003cspan class=\"s\"\u003es+(?=[万億兆千百十])|(?\u0026lt;=[万億兆千])\u003c/span\u003e\u003cspan class=\"err\"\u003e\\\u003c/span\u003e\u003cspan class=\"s\"\u003es+(?=[0-9])\"\u003c/span\u003e\u003cspan class=\"err\"\u003e#\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003ewith\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eoptions\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eregularExpression\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"162:1-162:244\"\u003e2 つ目は、実機で \"10 divide 2\" って言ったら \u003ccode\u003e10×2\u003c/code\u003e になったやつです。by の無い \"divide\" が辞書に無くて、知らない言葉として × でつながってました。by 無しの形を辞書に足してます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"164:1-166:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpokenMath.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"p\"\u003e([\u003c/span\u003e\u003cspan class=\"s\"\u003e\"divided\"\u003c/span\u003e\u003cspan class=\"p\"\u003e],\u003c/span\u003e \u003cspan class=\"s\"\u003e\"÷\"\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"p\"\u003e([\u003c/span\u003e\u003cspan class=\"s\"\u003e\"divide\"\u003c/span\u003e\u003cspan class=\"p\"\u003e],\u003c/span\u003e \u003cspan class=\"s\"\u003e\"÷\"\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"168:1-168:505\"\u003e3 つ目は、「5ひくマイナス3」が \u003ccode\u003e5−3\u003c/code\u003e になってた件です。声の式も貼り付けも、キーを押したときと同じ決まりを通して 1 文字ずつ入れてます。その決まりの 1 つが「演算子の後に演算子を押したら置き換える」で、\u003ccode\u003e5−−3\u003c/code\u003e の 2 つ目の − が 1 つ目を置き換えちゃってました。足す・引くの後のマイナスは負の数として、カッコで包んで \u003ccode\u003e5−(−3)\u003c/code\u003e で入れるようにしてます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"170:1-178:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpokenMath.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e// 「ひくマイナス3」「minus negative 2」「たすマイナス3」: 足す・引くの後のマイナスは負の数なので、\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e// 言った通り 5−(−3) とカッコで包む。包まないと、キーの決まり（演算子の後の演算子は置き換える）で\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e// 5−3 になっていた（2026-10-03）。× ÷ の後（5×−3）はそのまま入るので包まない\u003c/span\u003e\n\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003esymbol\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"−\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eprevious\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eoutput\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elast\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eprevious\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"+\"\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003eprevious\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"s\"\u003e\"−\"\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003enegativeOpenIndex\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eoutput\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e\n    \u003cspan class=\"n\"\u003eoutput\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eappend\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"(\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"180:1-180:205\"\u003eほかにも、「ゼロたす1」の「ゼロ」を数字に直してなくて 0 が落ちてた、みたいな細かいのがいくつかありました。直したものはテストに残してます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"182:1-187:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSpokenMathTests.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kt\"\u003eXCTAssertEqual\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nf\"\u003eexpression\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"1 万 2000\"\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"s\"\u003e\"12000\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"kt\"\u003eXCTAssertEqual\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nf\"\u003eexpression\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"10 divide 2\"\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"s\"\u003e\"10÷2\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"kt\"\u003eXCTAssertEqual\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nf\"\u003eexpression\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"5ひくマイナス3\"\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"s\"\u003e\"5−(−3)\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"kt\"\u003eXCTAssertEqual\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nf\"\u003eexpression\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"100から30引いたら\"\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e \u003cspan class=\"s\"\u003e\"100−30\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003ch2 data-sourcepos=\"189:1-189:15\"\u003e\n\u003cspan id=\"おわりに\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%8A%E3%82%8F%E3%82%8A%E3%81%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eおわりに\u003c/h2\u003e\n\u003cp data-sourcepos=\"191:1-191:447\"\u003e日本語は 4 桁、英語は 3 桁でまとまる、っていう数の読み方の違いを、そのまま変数の分け方にしてます。テストで確かめてるのは、聞き取った文字を式に直すところまでです。聞き取りがどんな文字を返すかは、言語や端末で変わります。実際に話してみて式にならない言い方を見つけたら、辞書と正規表現に足していってます。\u003c/p\u003e\n\u003cp data-sourcepos=\"193:1-193:87\"\u003e式にならない言い方があったら、教えてもらえると助かります！\u003c/p\u003e\n\u003cp data-sourcepos=\"195:1-195:150\"\u003eアプリ: \u003ca href=\"https://apps.apple.com/app/apple-store/id6818703251?pt=129553935\u0026amp;ct=qiita\u0026amp;mt=8\" rel=\"nofollow noopener\" target=\"_blank\"\u003eイコールいらず電卓 - 音声入力（App Store）\u003c/a\u003e\u003c/p\u003e\n","body":"## はじめに\n\niPhone の電卓アプリを作りました。声で式を入れられて、聞き間違えた数字はタップで直せて、「=」キーが無くて打つたびに答えが出る電卓です。\n\n今って音声入力の時代じゃないですか。なので、電卓の右下にいつもある「=」のボタンを、思い切ってマイクのボタンに変えました。ホーム画面とロック画面のウィジェット、コントロールセンター、アクションボタンからも、1回押すだけで声の入力が始まります。\n\n今回はそのうちの声の部分の話で、聞き取った「12万8000 かける 3」とか \"128,000 times 3\" を、`128000×3` っていう式に直すところをどう作ったか書いていきます。\n\nコードは Claude（AI）と一緒に書きました。載せてるコードはアプリの実物から抜き出したもので、例はどれも実際にそのコードに通して出た式です。\n\n## ざっくり全体の流れ\n\n聞き取りそのものは Apple の音声認識（`SFSpeechRecognizer`）にお任せしてます。計算でよく使う言葉（たす・かける・divided by など）を渡して聞き取りやすくしていて、端末の中で聞き取れる言語なら端末の中だけで処理してます。\n\n```swift:SpeechInput.swift\n// 計算の言葉を聞き取りやすくする（効き目は言語と端末しだい）\nrequest.contextualStrings = Self.mathWords\nif recognizer.supportsOnDeviceRecognition {\n    request.requiresOnDeviceRecognition = true\n}\n```\n\n返ってくるのはただの文字なので、そこから式を作る部分（`SpokenMath`）は自前で書いてます。聞き取りが終わったら、式にできる言葉があればカーソルの位置に入れて、1 つも無ければ「式にできませんでした」と出す感じです。\n\n```swift:CalculatorView.swift\nspeech.onFinish = { text in\n    let expression = SpokenMath.expression(from: text)\n    if expression.isEmpty {\n        voiceNotice = .noCalculation\n    } else {\n        model.insert(expression: String(expression))\n    }\n}\n```\n\n`SpokenMath.expression(from:)` の中では、文字を何回かに分けて書き換えてます。\n\n```swift:SpokenMath.swift\ntext = reorderingJapanese(text)\ntext = numberWordsToDigits(in: text)\ntext = applying(englishSentences, to: text)\nreturn scan(Array(replaceEnglishWords(in: text)))\n```\n\nこの前に、全角を半角にしたり、日本語の言葉のあいだの空白を詰めたりしてます。そのあとは、日本語の言い方を並べ替える、英語の数の言葉を数字にする、英語の言い方を並べ替える、演算子の言葉を記号にする、の順です。最後の `scan` で頭から 1 文字ずつ読んで式を組み立てます。「12万8000」みたいな日本語の数は、この `scan` の中で読んでます。\n\n## 日本語の数は 4 桁ごとに読んでいく\n\n日本語の数って、4 桁ごとに「万」「億」「兆」が付いて、その中に「千」「百」「十」が入るんですよね。なので、読んでる途中の数を 3 つに分けて持ってます。`total` は「万」以上で決まった分、`section` は今の 4 桁のまとまり、`pending` は直前に読んだ数字です。\n\n```swift:SpokenMath.swift\nif let unit = smallUnits[character] {\n    section += (pending ?? 1) * unit\n    pending = nil\n    i += 1\n    continue\n}\n\nif let unit = bigUnits[character] {\n    section += pending ?? 0\n    pending = nil\n    total += (section.isZero ? 1 : section) * unit\n    section = 0\n    i += 1\n    continue\n}\n```\n\n「千」「百」「十」が来たら、直前の数（無ければ 1。「千」だけなら 1000）をかけて `section` に足します。「万」「億」「兆」が来たら、`section` に単位をかけて `total` に移します。\n\n「12万8000」なら、12 を読んで、「万」で `total` が 120000 になって、8000 を読んで、最後に全部足して 128000 です。「三千二百五十」も同じところを通って 3250 になります。数字と漢字が混ざってても、漢数字だけでも同じように読めます。\n\n## 英語の数は 3 桁ごとに読んでいく\n\n英語は thousand・million ごとに 3 桁でまとまるので、こっちは `total` と、今の 3 桁の `current` の 2 つで読んでます。\n\n```swift:SpokenMath.swift\nif value == 100 {\n    current = max(current, 1) * 100\n} else if value \u003e= 1000 {\n    total += max(current, 1) * value\n    current = 0\n} else {\n    // 「five three」のように並べた数は 1 つにまとめない（8 にしない）\n    let lastTwo = NSDecimalNumber(decimal: current).intValue % 100\n    if (value \u003c 10 ? lastTwo % 10 != 0 : lastTwo != 0) { break }\n    current += value\n}\n```\n\n\"two thousand twenty four\" なら、two で 2、thousand で `total` が 2000、twenty と four で `current` が 24 になって、合わせて 2024 です。\"one hundred and five\" の and は、hundred とかの後で、次も数のときだけ読み飛ばしてます。\n\nここで気をつけたのが最後の `else` の中です。そのまま足していくと、\"five three\" が 8 になっちゃうんですよね。\"twenty four\" の four は、20 の 1 の位が空いてるので足していいです。\"five three\" の three は、5 の 1 の位がもう埋まってるので、足さずにそこで数を切ります。なので \"five three\" は 5 と 3 の 2 つの数になって、後で書く決まりで `5×3` になります。\n\nもう 1 つ、\"twenty-five\" のハイフンは引き算じゃないので、英字にはさまれたハイフンは先に空白に直してます。\n\n## 動詞が後ろに来る言い方は並べ替える\n\n「100に30を足す」って、動詞が最後に来ますよね。頭から読むと「100」「30」「足す」の順なので、言葉を記号に直すだけだと式にならないんです。なので先に正規表現で「100+30」の形に並べ替えてます。\n\n```swift:SpokenMath.swift\n// 話し言葉では「を」を省くことが多いので、無くても読む（「100から30引いて」「100に30足して」）。\n// 割り算の「を」は 2 つの数の区切りなので省かない（「1005で割る」を 100÷5 にしない）\nprivate static let japaneseSentences: [(String, String)] = {\n    let n = japaneseNumber\n    let open = String(softOpen), close = String(softClose) + String(gap)\n    return [\n        (n + \"[にと]\\\\s*\" + n + \"を?\\\\s*\" + japaneseAdd, open + \"$1+$2\" + close),\n        (n + \"から\\\\s*\" + n + \"を?\\\\s*\" + japaneseSubtract, open + \"$1−$2\" + close),\n        (n + \"[にと]\\\\s*\" + n + \"を?\\\\s*\" + japaneseMultiply, open + \"$1×$2\" + close),\n        (n + \"を\\\\s*\" + n + \"で\\\\s*\" + japaneseDivide, open + \"$1÷$2\" + close),\n    ]\n}()\n```\n\n`n` は数（後ろに「円」「個」とかが付いてもいい）、`japaneseAdd` は「足す」「足して」「足したら」などの形をまとめた正規表現です。最初は「引いたら」「割った」みたいな形を読めなくて、「100から30引いたら」が `100×30` になってました（× になる理由は次の節で）。\n\n割り算だけ「を」を省けないようにしてるのは、コメントのとおりです。数の部分は数字の並びを取る正規表現なので、「を」を省いていいことにすると、「1005で割る」の 1005 を 100 と 5 に分けて読めちゃいます。\n\n英語も同じで、\"divide 10 by 2\" や \"subtract 5 from 10\" は動詞が前に来るので、先に \"10 over 2\"、\"10 minus 5\" の形に並べ替えてから記号に直してます。\n\n並べ替えた所は、カッコで囲んでおきます。「2かける100に30を足す」のように前に言葉が付いても、並べ替えた所をひとまとまりで計算して `2×(100+30)` にするためです。全体を囲むだけのカッコになったら最後に外すので、「100に30を足す」は `100+30` のままです。「100に30を足して、2をかける」と続けて言ったときは、前の答えに次をかけて `(100+30)×2` にしてます。\n\n## 知らない言葉をはさんだ数は、× でつないでおく\n\n「3個で120円」みたいに、式にできない言葉をはさんで数が並んだときは、× でつないで `3×120` にしてます。\n\n```swift:SpokenMath.swift\n/// 数（か % ・閉じカッコ）の後に、次の数か開きカッコが来たとき。\n/// 「の」でつながっていれば ×（100の10%）。何もはさまない (3+4)2 や 2(3+4) はそのまま（計算のときに掛け算）。\n/// それ以外は、聞き間違いかもしれないが × でつなぐ（式は画面に出るので、違っていれば直せる）。\nfunc joinFollowingValue(isOpen: Bool) {\n    guard let previous = output.last, previous.isCalcDigit || previous == \"%\" || previous == \")\" else {\n        return\n    }\n    if isJoinedByNo(skipped) {\n        joinIndex = output.count\n        output.append(\"×\")\n    } else if !(skipped.isEmpty \u0026\u0026 (previous == \")\" || (isOpen \u0026\u0026 previous != \"%\"))) {\n        // 「1000円から10%引き」「1000円10%引き」も、後ろの「引き」「増し」で − / + に直す\n        // （2026-10-03 「の」のときだけ直していて、1000×10% になっていた）\n        joinIndex = output.count\n        output.append(\"×\")\n    }\n}\n```\n\n聞き間違いかもしれない式でも、画面に出しておけば、式の中の数字をタップしてその場で直せます。逆に何も出さないと、どこで聞き間違えたのかも分からないんですよね。なので、捨てずに × でつなぐことにしました。\n\n「の」でつながってるとき（「100の10%」）は意味としても掛け算なので、これも × です。× を入れたときは、その位置を `joinIndex` に覚えておきます。後ろに「引き」「増し」が来たら、そこを − や + に書き換えます。「1000円の10%引き」は `1000−10%` になって、答えは 900 です。\n\n## ハマった所\n\n1 つ目は、「1 万 2000」が `1×10000×2000` になってた件です。数と「万」のあいだに空白があると、「1」「万」「2000」が別々の数として読まれて、上の × の決まりでつながってました。先に空白を詰めるようにしてます。\n\n```swift:SpokenMath.swift\n// 数と「万」「千」などのあいだの空白も詰める（2026-10-03 「1 万 2000」が 1×10000×2000 になっていた）\ntext = text.replacingOccurrences(\n    of: #\"(?\u003c=[0-9])\\s+(?=[万億兆千百十])|(?\u003c=[万億兆千])\\s+(?=[0-9])\"#, with: \"\", options: .regularExpression)\n```\n\n2 つ目は、実機で \"10 divide 2\" って言ったら `10×2` になったやつです。by の無い \"divide\" が辞書に無くて、知らない言葉として × でつながってました。by 無しの形を辞書に足してます。\n\n```swift:SpokenMath.swift\n([\"divided\"], \"÷\"), ([\"divide\"], \"÷\"),\n```\n\n3 つ目は、「5ひくマイナス3」が `5−3` になってた件です。声の式も貼り付けも、キーを押したときと同じ決まりを通して 1 文字ずつ入れてます。その決まりの 1 つが「演算子の後に演算子を押したら置き換える」で、`5−−3` の 2 つ目の − が 1 つ目を置き換えちゃってました。足す・引くの後のマイナスは負の数として、カッコで包んで `5−(−3)` で入れるようにしてます。\n\n```swift:SpokenMath.swift\n// 「ひくマイナス3」「minus negative 2」「たすマイナス3」: 足す・引くの後のマイナスは負の数なので、\n// 言った通り 5−(−3) とカッコで包む。包まないと、キーの決まり（演算子の後の演算子は置き換える）で\n// 5−3 になっていた（2026-10-03）。× ÷ の後（5×−3）はそのまま入るので包まない\nif symbol == \"−\", let previous = output.last, previous == \"+\" || previous == \"−\" {\n    negativeOpenIndex = output.count\n    output.append(\"(\")\n}\n```\n\nほかにも、「ゼロたす1」の「ゼロ」を数字に直してなくて 0 が落ちてた、みたいな細かいのがいくつかありました。直したものはテストに残してます。\n\n```swift:SpokenMathTests.swift\nXCTAssertEqual(expression(\"1 万 2000\"), \"12000\")\nXCTAssertEqual(expression(\"10 divide 2\"), \"10÷2\")\nXCTAssertEqual(expression(\"5ひくマイナス3\"), \"5−(−3)\")\nXCTAssertEqual(expression(\"100から30引いたら\"), \"100−30\")\n```\n\n## おわりに\n\n日本語は 4 桁、英語は 3 桁でまとまる、っていう数の読み方の違いを、そのまま変数の分け方にしてます。テストで確かめてるのは、聞き取った文字を式に直すところまでです。聞き取りがどんな文字を返すかは、言語や端末で変わります。実際に話してみて式にならない言い方を見つけたら、辞書と正規表現に足していってます。\n\n式にならない言い方があったら、教えてもらえると助かります！\n\nアプリ: [イコールいらず電卓 - 音声入力（App Store）](https://apps.apple.com/app/apple-store/id6818703251?pt=129553935\u0026ct=qiita\u0026mt=8)\n\n","coediting":false,"comments_count":0,"created_at":"2026-10-17T11:29:22+09:00","group":null,"id":"682b04eaf52cf0680e1a","likes_count":0,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Swift","versions":[]},{"name":"iOS","versions":[]},{"name":"音声認識","versions":[]},{"name":"正規表現","versions":[]},{"name":"個人開発","versions":[]}],"title":"声で言った「12万8000 かける 3」を Swift で式に直してみた（日本語と英語の数の読み方）","updated_at":"2026-10-19T08:58:58+09:00","url":"https://qiita.com/SEpei/items/682b04eaf52cf0680e1a","user":{"description":"価値あるエンジニアへ！","facebook_id":"","followees_count":4,"followers_count":1,"github_login_name":"jumpei-kurata","id":"SEpei","items_count":2,"linkedin_id":"","location":"埼玉県","name":"jumpei","organization":"","permanent_id":2522812,"profile_image_url":"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2522812/profile-images/1644374598","team_only":false,"twitter_screen_name":null,"website_url":""},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch2 data-sourcepos=\"1:1-1:15\"\u003e\n\u003cspan id=\"はじめに\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eはじめに\u003c/h2\u003e\n\u003cp data-sourcepos=\"3:1-3:125\"\u003eSwiftを一度も書いたことがない状態から、iPhoneアプリを作ってApp Storeの審査に提出しました。\u003c/p\u003e\n\u003cp data-sourcepos=\"5:1-5:122\"\u003e普段はエンジニアとして社内ITの仕事をしていますが、iOSアプリ開発は完全に初めてです。\u003c/p\u003e\n\u003cp data-sourcepos=\"7:1-7:140\"\u003e副業として小さなアプリをいくつか出してみたいと思い、Claude Codeに手伝ってもらいながら作りました。\u003c/p\u003e\n\u003cp data-sourcepos=\"9:1-9:84\"\u003e今回は、審査に出すまでの流れを紹介していこうと思います。\u003c/p\u003e\n\u003cp data-sourcepos=\"11:1-11:75\"\u003e審査結果はまだ出ていないので、続編で書く予定です。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"13:1-13:21\"\u003e\n\u003cspan id=\"作ったアプリ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BD%9C%E3%81%A3%E3%81%9F%E3%82%A2%E3%83%97%E3%83%AA\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e作ったアプリ\u003c/h2\u003e\n\u003cp data-sourcepos=\"15:1-15:55\"\u003e作ったのは「Brewnap」というアプリです。\u003c/p\u003e\n\u003cp data-sourcepos=\"17:1-17:148\"\u003eコーヒーを飲んでから昼寝する「コーヒーナップ」が好きで、コーヒー記録と仮眠タイマーを1つにしました。\u003c/p\u003e\n\u003ctable data-sourcepos=\"19:1-23:74\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"19:1-19:19\"\u003e\n\u003cth data-sourcepos=\"19:2-19:9\"\u003e機能\u003c/th\u003e\n\u003cth data-sourcepos=\"19:11-19:18\"\u003e内容\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"21:1-21:109\"\u003e\n\u003ctd data-sourcepos=\"21:2-21:24\"\u003eカフェイン記録\u003c/td\u003e\n\u003ctd data-sourcepos=\"21:26-21:108\"\u003e飲んだドリンクを記録し、体内のカフェイン量の推移を表示\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"22:1-22:64\"\u003e\n\u003ctd data-sourcepos=\"22:2-22:21\"\u003e仮眠タイマー\u003c/td\u003e\n\u003ctd data-sourcepos=\"22:23-22:63\"\u003eコーヒーナップ用のタイマー\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"23:1-23:74\"\u003e\n\u003ctd data-sourcepos=\"23:2-23:21\"\u003eウィジェット\u003c/td\u003e\n\u003ctd data-sourcepos=\"23:23-23:73\"\u003eホーム画面ウィジェットとLive Activity\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"25:1-25:106\"\u003eカフェインの減り方の計算は、EFSAのガイドラインや論文をもとにしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"27:1-27:95\"\u003e適当な数字にしないための裏付け探しも、AIに手伝ってもらいました。\u003c/p\u003e\n\u003cp data-sourcepos=\"29:1-29:114\"\u003e収益モデルはフリーミアムで、傾向グラフやHealthKit連携などをPro機能にしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"31:1-31:140\"\u003e\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F2418172%2Fe96404cf-1ed8-434a-969a-76b605dada9b.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=cc895314f264bb99c02f0c146425071f\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F2418172%2Fe96404cf-1ed8-434a-969a-76b605dada9b.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=cc895314f264bb99c02f0c146425071f\" alt=\"ja-1-nowのコピー.png\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F2418172%2Fe96404cf-1ed8-434a-969a-76b605dada9b.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=a8d2d823b7067af007b6b1e8a173324f 1x\" data-canonical-src=\"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2418172/e96404cf-1ed8-434a-969a-76b605dada9b.png\" loading=\"lazy\"\u003e\u003c/a\u003e\u003c/p\u003e\n\u003ch2 data-sourcepos=\"33:1-33:18\"\u003e\n\u003cspan id=\"開発の流れ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E9%96%8B%E7%99%BA%E3%81%AE%E6%B5%81%E3%82%8C\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e開発の流れ\u003c/h2\u003e\n\u003cp data-sourcepos=\"35:1-35:123\"\u003eいきなりコードを書いてもらうのではなく、先に仕様を固めてから実装をお願いしました。\u003c/p\u003e\n\u003cp data-sourcepos=\"37:1-37:113\"\u003eSwiftがわからなくても迷子にならなかったのは、この進め方のおかげな気がします。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"39:1-39:28\"\u003e\n\u003cspan id=\"1-構想と競合調査\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#1-%E6%A7%8B%E6%83%B3%E3%81%A8%E7%AB%B6%E5%90%88%E8%AA%BF%E6%9F%BB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e1. 構想と競合調査\u003c/h3\u003e\n\u003cp data-sourcepos=\"41:1-41:120\"\u003eClaudeと相談しながらアイデアを出し、似たアプリを調べて差別化できる点を決めました。\u003c/p\u003e\n\u003cp data-sourcepos=\"43:1-43:93\"\u003eアプリ名も、同じ名前のアプリがないか確認してから決めています。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"45:1-45:25\"\u003e\n\u003cspan id=\"2-モックアップ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#2-%E3%83%A2%E3%83%83%E3%82%AF%E3%82%A2%E3%83%83%E3%83%97\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e2. モックアップ\u003c/h3\u003e\n\u003cp data-sourcepos=\"47:1-47:103\"\u003e主要な画面をHTMLのモックアップにして、見た目と操作感を先に決めました。\u003c/p\u003e\n\u003cp data-sourcepos=\"49:1-49:90\"\u003e実物に近いものを触れるので、イメージのずれが少なかったです。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"51:1-51:40\"\u003e\n\u003cspan id=\"3-claudemdに仕様をまとめる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#3-claudemd%E3%81%AB%E4%BB%95%E6%A7%98%E3%82%92%E3%81%BE%E3%81%A8%E3%82%81%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e3. CLAUDE.mdに仕様をまとめる\u003c/h3\u003e\n\u003cp data-sourcepos=\"53:1-53:54\"\u003e決めたことは全部CLAUDE.mdに書きました。\u003c/p\u003e\n\u003cp data-sourcepos=\"55:1-55:104\"\u003eCLAUDE.mdは、Claude Codeが毎回自動で読んでくれる仕様書のようなファイルです。\u003c/p\u003e\n\u003cp data-sourcepos=\"57:1-57:162\"\u003e配色、データの持ち方、計算式、アイコンのSVG、実装する順番まで書いておくと、作業を中断しても話が噛み合います。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"59:1-59:27\"\u003e\n\u003cspan id=\"4-claude-codeで実装\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#4-claude-code%E3%81%A7%E5%AE%9F%E8%A3%85\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e4. Claude Codeで実装\u003c/h3\u003e\n\u003cp data-sourcepos=\"61:1-61:83\"\u003eMacBookのXcodeプロジェクトでClaude Codeに実装してもらいました。\u003c/p\u003e\n\u003cp data-sourcepos=\"63:1-63:51\"\u003e自分はビルドと動作確認が中心です。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"65:1-65:15\"\u003e\n\u003cspan id=\"動作環境\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%8B%95%E4%BD%9C%E7%92%B0%E5%A2%83\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e動作環境\u003c/h2\u003e\n\u003cp data-sourcepos=\"67:1-67:42\"\u003eMacBookのXcodeで開発しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"69:1-69:45\"\u003e外部SDKや広告は入れていません。\u003c/p\u003e\n\u003cul data-sourcepos=\"71:1-76:0\"\u003e\n\u003cli data-sourcepos=\"71:1-71:32\"\u003eiOS 26以上（iPhoneのみ）\u003c/li\u003e\n\u003cli data-sourcepos=\"72:1-72:9\"\u003eSwiftUI\u003c/li\u003e\n\u003cli data-sourcepos=\"73:1-73:11\"\u003eSwiftData\u003c/li\u003e\n\u003cli data-sourcepos=\"74:1-74:12\"\u003eStoreKit 2\u003c/li\u003e\n\u003cli data-sourcepos=\"75:1-76:0\"\u003eWidget Extension / Live Activity\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 data-sourcepos=\"77:1-77:44\"\u003e\n\u003cspan id=\"swift未経験でつまずいたところ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#swift%E6%9C%AA%E7%B5%8C%E9%A8%93%E3%81%A7%E3%81%A4%E3%81%BE%E3%81%9A%E3%81%84%E3%81%9F%E3%81%A8%E3%81%93%E3%82%8D\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eSwift未経験でつまずいたところ\u003c/h2\u003e\n\u003cp data-sourcepos=\"79:1-79:76\"\u003eSwiftの文法よりも、Xcodeの設定まわりの方が大変でした。\u003c/p\u003e\n\u003cp data-sourcepos=\"81:1-81:130\"\u003eコードはClaude Codeが書いてくれますが、Xcodeの画面で設定する部分は自分で触る必要があります。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"83:1-83:13\"\u003e\n\u003cspan id=\"bundle-id\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#bundle-id\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eBundle ID\u003c/h3\u003e\n\u003cp data-sourcepos=\"85:1-85:35\"\u003eアプリを識別するIDです。\u003c/p\u003e\n\u003cp data-sourcepos=\"87:1-87:82\"\u003eドメインを逆にした \u003ccode\u003ecc.pochica.Brewnap\u003c/code\u003e のような形にしました。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"89:1-89:20\"\u003e\n\u003cspan id=\"widget-extension\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#widget-extension\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eWidget Extension\u003c/h3\u003e\n\u003cp data-sourcepos=\"91:1-91:81\"\u003eウィジェットは本体とは別のターゲットとして追加します。\u003c/p\u003e\n\u003cp data-sourcepos=\"93:1-93:54\"\u003e最初はここがよくわかりませんでした。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"95:1-95:14\"\u003e\n\u003cspan id=\"app-groups\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#app-groups\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eApp Groups\u003c/h3\u003e\n\u003cp data-sourcepos=\"97:1-97:87\"\u003e本体アプリとウィジェットでデータを共有するための設定です。\u003c/p\u003e\n\u003cp data-sourcepos=\"99:1-99:84\"\u003eこれを設定しないと、ウィジェットに記録が表示されません。\u003c/p\u003e\n\u003cp data-sourcepos=\"101:1-101:105\"\u003eこのあたりは、Claudeに手順を聞いて自分でクリックしていく方が確実でした。\u003c/p\u003e\n\u003cp data-sourcepos=\"103:1-103:102\"\u003e何がどこで設定されているのか理解しながら進められたのも良かったです。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"105:1-105:18\"\u003e\n\u003cspan id=\"申請の準備\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%94%B3%E8%AB%8B%E3%81%AE%E6%BA%96%E5%82%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e申請の準備\u003c/h2\u003e\n\u003cp data-sourcepos=\"107:1-107:87\"\u003eApp Storeに出すには、アイコンとスクリーンショットが必要です。\u003c/p\u003e\n\u003cp data-sourcepos=\"109:1-109:93\"\u003eデザインは得意ではないので、Pythonで自動生成するようにしました。\u003c/p\u003e\n\u003ctable data-sourcepos=\"111:1-114:97\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"111:1-111:22\"\u003e\n\u003cth data-sourcepos=\"111:2-111:9\"\u003e画像\u003c/th\u003e\n\u003cth data-sourcepos=\"111:11-111:21\"\u003e作り方\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"113:1-113:98\"\u003e\n\u003ctd data-sourcepos=\"113:2-113:15\"\u003eアイコン\u003c/td\u003e\n\u003ctd data-sourcepos=\"113:17-113:97\"\u003eSVGのシンボルとグラデーションの色から1024×1024のPNGを生成\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"114:1-114:97\"\u003e\n\u003ctd data-sourcepos=\"114:2-114:30\"\u003eスクリーンショット\u003c/td\u003e\n\u003ctd data-sourcepos=\"114:32-114:96\"\u003eシミュレータの画面に端末の枠と見出しを合成\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"116:1-116:81\"\u003e見出しは日本語と英語を設定ファイルで切り替えられます。\u003c/p\u003e\n\u003cp data-sourcepos=\"118:1-118:63\"\u003eこのツール自体もClaudeに作ってもらいました。\u003c/p\u003e\n\u003cp data-sourcepos=\"120:1-120:84\"\u003eアプリを増やしていく予定なので、一度作っておくと楽です。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"122:1-122:27\"\u003e\n\u003cspan id=\"審査に出してみた\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AF%A9%E6%9F%BB%E3%81%AB%E5%87%BA%E3%81%97%E3%81%A6%E3%81%BF%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e審査に出してみた\u003c/h2\u003e\n\u003cp data-sourcepos=\"124:1-124:82\"\u003e提出してしばらくすると、App Reviewからメールが届きました。\u003c/p\u003e\n\u003cp data-sourcepos=\"126:1-126:108\"\u003e「Guideline 2.1 - Information Needed」と書いてあり、ステータスは「Changes needed」です。\u003c/p\u003e\n\u003cp data-sourcepos=\"128:1-128:90\"\u003eリジェクトかと思って焦りましたが、そうではありませんでした。\u003c/p\u003e\n\u003cp data-sourcepos=\"130:1-130:147\"\u003eアプリの不具合ではなく、開発者アカウントの審査履歴が少ないため、追加の情報がほしいという内容です。\u003c/p\u003e\n\u003cp data-sourcepos=\"132:1-132:69\"\u003e初めての個人開発者にはよくあることらしいです。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"134:1-134:25\"\u003e\n\u003cspan id=\"求められた情報\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%B1%82%E3%82%81%E3%82%89%E3%82%8C%E3%81%9F%E6%83%85%E5%A0%B1\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e求められた情報\u003c/h3\u003e\n\u003col data-sourcepos=\"136:1-143:0\"\u003e\n\u003cli data-sourcepos=\"136:1-136:27\"\u003eアプリの画面収録\u003c/li\u003e\n\u003cli data-sourcepos=\"137:1-137:42\"\u003eアプリの目的と想定ユーザー\u003c/li\u003e\n\u003cli data-sourcepos=\"138:1-138:33\"\u003e機能を試すための手順\u003c/li\u003e\n\u003cli data-sourcepos=\"139:1-139:39\"\u003e利用している外部サービス\u003c/li\u003e\n\u003cli data-sourcepos=\"140:1-140:24\"\u003e地域による違い\u003c/li\u003e\n\u003cli data-sourcepos=\"141:1-141:33\"\u003e規制業種の場合の書類\u003c/li\u003e\n\u003cli data-sourcepos=\"142:1-143:0\"\u003eアプリ内課金の概要\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"144:1-144:96\"\u003e該当しない項目も「N/A」と書いて、すべてに答えるのが良いそうです。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"146:1-146:22\"\u003e\n\u003cspan id=\"返信のしかた\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%BF%94%E4%BF%A1%E3%81%AE%E3%81%97%E3%81%8B%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e返信のしかた\u003c/h3\u003e\n\u003cp data-sourcepos=\"148:1-148:52\"\u003e返信はApp Reviewのページから行います。\u003c/p\u003e\n\u003cp data-sourcepos=\"150:1-150:106\"\u003e同じ内容を「App Review情報」のメモ欄にも残しておくと、次回以降に使えます。\u003c/p\u003e\n\u003cp data-sourcepos=\"152:1-152:84\"\u003e情報を送るだけなら、再提出ボタンを押す必要はありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"154:1-154:87\"\u003e英語のメールの和訳と返信文も、Claudeに手伝ってもらいました。\u003c/p\u003e\n\u003cp data-sourcepos=\"156:1-156:117\"\u003e英語の審査対応はハードルが高いと思っていましたが、思ったより何とかなりました。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"158:1-158:18\"\u003e\n\u003cspan id=\"やってみて\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%84%E3%81%A3%E3%81%A6%E3%81%BF%E3%81%A6\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eやってみて\u003c/h2\u003e\n\u003cp data-sourcepos=\"160:1-160:84\"\u003eアプリを作ること自体は、思っていたよりずっと簡単でした。\u003c/p\u003e\n\u003cp data-sourcepos=\"162:1-162:101\"\u003eSwift未経験でも、仕様を先に固めておけば実装はかなり進めてもらえます。\u003c/p\u003e\n\u003cp data-sourcepos=\"164:1-164:120\"\u003e一方で、ストア画像や説明文、審査対応など、コード以外の作業が意外と多かったです。\u003c/p\u003e\n\u003cp data-sourcepos=\"166:1-166:117\"\u003eそして一番の課題は、作ったアプリをどうやって見つけてもらうかだと感じています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"168:1-168:15\"\u003e\n\u003cspan id=\"おわりに\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%8A%E3%82%8F%E3%82%8A%E3%81%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eおわりに\u003c/h2\u003e\n\u003cp data-sourcepos=\"170:1-170:63\"\u003e最後までご覧いただきありがとうございます。\u003c/p\u003e\n\u003cp data-sourcepos=\"172:1-172:113\"\u003e今回は、Swift未経験の状態からiPhoneアプリを作って審査に出すまでを紹介しました。\u003c/p\u003e\n\u003cp data-sourcepos=\"174:1-174:102\"\u003e同じ流れで、観葉植物の水やり記録アプリ「Potlog」も審査に出しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"176:1-176:63\"\u003e審査の結果は、また続編で書こうと思います。\u003c/p\u003e\n\u003cp data-sourcepos=\"178:1-178:118\"\u003eiOSアプリ開発は初心者のため、情報に誤りがある可能性があります。 ご了承ください。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"180:1-180:22\"\u003e\n\u003cspan id=\"pochicaについて\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#pochica%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003ePochicaについて\u003c/h2\u003e\n\u003cp data-sourcepos=\"182:1-182:19\"\u003e\u003ciframe id=\"qiita-embed-content__7b30f8dd03066d7eeef8f3baced3eaab\" src=\"https://qiita.com/embed-contents/link-card#qiita-embed-content__7b30f8dd03066d7eeef8f3baced3eaab\" data-content=\"https%3A%2F%2Fpochica.cc%2F\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"width:100%;\" height=\"29\"\u003e\n\u003c/iframe\u003e\n\u003c/p\u003e\n","body":"## はじめに\n\nSwiftを一度も書いたことがない状態から、iPhoneアプリを作ってApp Storeの審査に提出しました。\n\n普段はエンジニアとして社内ITの仕事をしていますが、iOSアプリ開発は完全に初めてです。\n\n副業として小さなアプリをいくつか出してみたいと思い、Claude Codeに手伝ってもらいながら作りました。\n\n今回は、審査に出すまでの流れを紹介していこうと思います。\n\n審査結果はまだ出ていないので、続編で書く予定です。\n\n## 作ったアプリ\n\n作ったのは「Brewnap」というアプリです。\n\nコーヒーを飲んでから昼寝する「コーヒーナップ」が好きで、コーヒー記録と仮眠タイマーを1つにしました。\n\n| 機能 | 内容 |\n| --- | --- |\n| カフェイン記録 | 飲んだドリンクを記録し、体内のカフェイン量の推移を表示 |\n| 仮眠タイマー | コーヒーナップ用のタイマー |\n| ウィジェット | ホーム画面ウィジェットとLive Activity |\n\nカフェインの減り方の計算は、EFSAのガイドラインや論文をもとにしています。\n\n適当な数字にしないための裏付け探しも、AIに手伝ってもらいました。\n\n収益モデルはフリーミアムで、傾向グラフやHealthKit連携などをPro機能にしています。\n\n![ja-1-nowのコピー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2418172/e96404cf-1ed8-434a-969a-76b605dada9b.png)\n\n## 開発の流れ\n\nいきなりコードを書いてもらうのではなく、先に仕様を固めてから実装をお願いしました。\n\nSwiftがわからなくても迷子にならなかったのは、この進め方のおかげな気がします。\n\n### 1. 構想と競合調査\n\nClaudeと相談しながらアイデアを出し、似たアプリを調べて差別化できる点を決めました。\n\nアプリ名も、同じ名前のアプリがないか確認してから決めています。\n\n### 2. モックアップ\n\n主要な画面をHTMLのモックアップにして、見た目と操作感を先に決めました。\n\n実物に近いものを触れるので、イメージのずれが少なかったです。\n\n### 3. CLAUDE.mdに仕様をまとめる\n\n決めたことは全部CLAUDE.mdに書きました。\n\nCLAUDE.mdは、Claude Codeが毎回自動で読んでくれる仕様書のようなファイルです。\n\n配色、データの持ち方、計算式、アイコンのSVG、実装する順番まで書いておくと、作業を中断しても話が噛み合います。\n\n### 4. Claude Codeで実装\n\nMacBookのXcodeプロジェクトでClaude Codeに実装してもらいました。\n\n自分はビルドと動作確認が中心です。\n\n## 動作環境\n\nMacBookのXcodeで開発しています。\n\n外部SDKや広告は入れていません。\n\n- iOS 26以上（iPhoneのみ）\n- SwiftUI\n- SwiftData\n- StoreKit 2\n- Widget Extension / Live Activity\n\n## Swift未経験でつまずいたところ\n\nSwiftの文法よりも、Xcodeの設定まわりの方が大変でした。\n\nコードはClaude Codeが書いてくれますが、Xcodeの画面で設定する部分は自分で触る必要があります。\n\n### Bundle ID\n\nアプリを識別するIDです。\n\nドメインを逆にした `cc.pochica.Brewnap` のような形にしました。\n\n### Widget Extension\n\nウィジェットは本体とは別のターゲットとして追加します。\n\n最初はここがよくわかりませんでした。\n\n### App Groups\n\n本体アプリとウィジェットでデータを共有するための設定です。\n\nこれを設定しないと、ウィジェットに記録が表示されません。\n\nこのあたりは、Claudeに手順を聞いて自分でクリックしていく方が確実でした。\n\n何がどこで設定されているのか理解しながら進められたのも良かったです。\n\n## 申請の準備\n\nApp Storeに出すには、アイコンとスクリーンショットが必要です。\n\nデザインは得意ではないので、Pythonで自動生成するようにしました。\n\n| 画像 | 作り方 |\n| --- | --- |\n| アイコン | SVGのシンボルとグラデーションの色から1024×1024のPNGを生成 |\n| スクリーンショット | シミュレータの画面に端末の枠と見出しを合成 |\n\n見出しは日本語と英語を設定ファイルで切り替えられます。\n\nこのツール自体もClaudeに作ってもらいました。\n\nアプリを増やしていく予定なので、一度作っておくと楽です。\n\n## 審査に出してみた\n\n提出してしばらくすると、App Reviewからメールが届きました。\n\n「Guideline 2.1 - Information Needed」と書いてあり、ステータスは「Changes needed」です。\n\nリジェクトかと思って焦りましたが、そうではありませんでした。\n\nアプリの不具合ではなく、開発者アカウントの審査履歴が少ないため、追加の情報がほしいという内容です。\n\n初めての個人開発者にはよくあることらしいです。\n\n### 求められた情報\n\n1. アプリの画面収録\n2. アプリの目的と想定ユーザー\n3. 機能を試すための手順\n4. 利用している外部サービス\n5. 地域による違い\n6. 規制業種の場合の書類\n7. アプリ内課金の概要\n\n該当しない項目も「N/A」と書いて、すべてに答えるのが良いそうです。\n\n### 返信のしかた\n\n返信はApp Reviewのページから行います。\n\n同じ内容を「App Review情報」のメモ欄にも残しておくと、次回以降に使えます。\n\n情報を送るだけなら、再提出ボタンを押す必要はありません。\n\n英語のメールの和訳と返信文も、Claudeに手伝ってもらいました。\n\n英語の審査対応はハードルが高いと思っていましたが、思ったより何とかなりました。\n\n## やってみて\n\nアプリを作ること自体は、思っていたよりずっと簡単でした。\n\nSwift未経験でも、仕様を先に固めておけば実装はかなり進めてもらえます。\n\n一方で、ストア画像や説明文、審査対応など、コード以外の作業が意外と多かったです。\n\nそして一番の課題は、作ったアプリをどうやって見つけてもらうかだと感じています。\n\n## おわりに\n\n最後までご覧いただきありがとうございます。\n\n今回は、Swift未経験の状態からiPhoneアプリを作って審査に出すまでを紹介しました。\n\n同じ流れで、観葉植物の水やり記録アプリ「Potlog」も審査に出しています。\n\n審査の結果は、また続編で書こうと思います。\n\niOSアプリ開発は初心者のため、情報に誤りがある可能性があります。 ご了承ください。\n\n## Pochicaについて\n\nhttps://pochica.cc/\n","coediting":false,"comments_count":0,"created_at":"2026-10-16T16:10:15+09:00","group":null,"id":"6b66b5f27158e7067ff9","likes_count":1,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Swift","versions":[]},{"name":"iOS","versions":[]},{"name":"ClaudeCode","versions":[]},{"name":"個人開発","versions":[]},{"name":"AppStore","versions":[]}],"title":"【Swift未経験】Claude CodeでiPhoneアプリを作って審査に出すまで","updated_at":"2026-10-16T16:10:15+09:00","url":"https://qiita.com/masa1124/items/6b66b5f27158e7067ff9","user":{"description":"夢を持ち続ける社会人","facebook_id":"","followees_count":1,"followers_count":2,"github_login_name":"masa2122","id":"masa1124","items_count":8,"linkedin_id":"","location":"","name":"Masa","organization":"","permanent_id":2418172,"profile_image_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/2418172/9a67d35b98d82c1fd0d86a4555d09ad363ced4dc/x_large.png?1676105122","team_only":false,"twitter_screen_name":null,"website_url":"https://pochica.cc/"},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch2 data-sourcepos=\"2:1-2:24\"\u003e\n\u003cspan id=\"エルビス演算子\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%A8%E3%83%AB%E3%83%93%E3%82%B9%E6%BC%94%E7%AE%97%E5%AD%90\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eエルビス演算子\u003c/h2\u003e\n\u003cp data-sourcepos=\"3:1-3:8\"\u003e\u003cem\u003eKotlin\u003c/em\u003e\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"Kotlin\" data-sourcepos=\"4:1-6:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eval\u003c/span\u003e \u003cspan class=\"py\"\u003evalue\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003enullable\u003c/span\u003e \u003cspan class=\"o\"\u003e?:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\"\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"7:1-7:7\"\u003e\u003cem\u003eSwift\u003c/em\u003e\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"8:1-10:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003enullable\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\"\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"12:1-12:24\"\u003e\n\u003cspan id=\"強制アンラップ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%BC%B7%E5%88%B6%E3%82%A2%E3%83%B3%E3%83%A9%E3%83%83%E3%83%97\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e強制アンラップ\u003c/h2\u003e\n\u003cp data-sourcepos=\"13:1-13:8\"\u003e\u003cem\u003eKotlin\u003c/em\u003e\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"Kotlin\" data-sourcepos=\"14:1-16:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eval\u003c/span\u003e \u003cspan class=\"py\"\u003evalue\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003enullable\u003c/span\u003e\u003cspan class=\"o\"\u003e!!\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"17:1-17:7\"\u003e\u003cem\u003eSwift\u003c/em\u003e\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"18:1-20:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003enullable\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"22:1-22:24\"\u003e\n\u003cspan id=\"レシーバー記述\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%AC%E3%82%B7%E3%83%BC%E3%83%90%E3%83%BC%E8%A8%98%E8%BF%B0\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eレシーバー記述\u003c/h2\u003e\n\u003cp data-sourcepos=\"23:1-23:8\"\u003e\u003cem\u003eKotlin\u003c/em\u003e\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"Kotlin\" data-sourcepos=\"24:1-27:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"n\"\u003eitems\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eforEach\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003eitem\u003c/span\u003e \u003cspan class=\"p\"\u003e-\u0026gt;\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"28:1-28:7\"\u003e\u003cem\u003eSwift\u003c/em\u003e\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"29:1-32:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"n\"\u003eitems\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eforEach\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003eitem\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"34:1-34:33\"\u003e\n\u003cspan id=\"クロージャの暗黙引数\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%AF%E3%83%AD%E3%83%BC%E3%82%B8%E3%83%A3%E3%81%AE%E6%9A%97%E9%BB%99%E5%BC%95%E6%95%B0\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eクロージャの暗黙引数\u003c/h2\u003e\n\u003cp data-sourcepos=\"35:1-35:8\"\u003e\u003cem\u003eKotlin\u003c/em\u003e\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"36:1-38:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e{ onSuccess(it) }\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"39:1-39:7\"\u003e\u003cem\u003eSwift\u003c/em\u003e\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"40:1-42:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nf\"\u003eonSucess\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n","body":"\n## エルビス演算子\n*Kotlin*\n```Kotlin\nval value = nullable ?: \"\"\n```\n*Swift*\n```swift\nlet value = nullable ?? \"\"\n```\n\n## 強制アンラップ\n*Kotlin*\n```Kotlin\nval value = nullable!!\n```\n*Swift*\n```swift\nlet value = nullable!\n```\n\n## レシーバー記述\n*Kotlin*\n```Kotlin\nitems.forEach { item -\u003e\n}\n```\n*Swift*\n```swift\nitems.forEach { item in\n}\n```\n\n## クロージャの暗黙引数\n*Kotlin*\n```\n{ onSuccess(it) }\n```\n*Swift*\n```swift\n{ onSucess($0) }\n```\n","coediting":false,"comments_count":0,"created_at":"2026-10-16T14:07:10+09:00","group":null,"id":"618800ed37562c9ffcf6","likes_count":0,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Android","versions":[]},{"name":"iOS","versions":[]},{"name":"Kotlin","versions":[]},{"name":"Swift","versions":[]}],"title":"Kotlin vs Swift チートシート","updated_at":"2026-10-16T14:07:10+09:00","url":"https://qiita.com/gunny517/items/618800ed37562c9ffcf6","user":{"description":"Android enginer.","facebook_id":"","followees_count":0,"followers_count":0,"github_login_name":"gunny517","id":"gunny517","items_count":5,"linkedin_id":"","location":"Kanagawa Japan","name":"mitsuru araki","organization":"","permanent_id":20596,"profile_image_url":"https://qiita-image-store.s3.amazonaws.com/0/20596/profile-images/1473683058","team_only":false,"twitter_screen_name":"gunny517","website_url":""},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003cp data-sourcepos=\"1:1-3:85\"\u003e6502 は、1 秒に 100 万回ほどクロックが動く。\u003cbr\u003e\nそこに LED をつないでも、人の目には「点いている」か「点いていない」かしか見えない。\u003cbr\u003e\nバスで何が起きているかを見るには、CPU を遅くするしかない。\u003c/p\u003e\n\u003cp data-sourcepos=\"5:1-7:146\"\u003eGlow65 は、その「遅くする」部分を作った 6502 のエミュレータである。\u003cbr\u003e\nSPEED スイッチで 1 Hz から 1 MHz まで速度を選べて、STEP スイッチを押すと、バスアクセスが 1 回ずつ進む。\u003cbr\u003e\nアドレスバス、データバス、レジスタ、フラグが、昔のコンピュータのフロントパネルのように LED で光る。\u003c/p\u003e\n\u003cp data-sourcepos=\"9:1-9:99\"\u003e\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fmasakai.github.io%2FGlow65%2Fassets%2Ffrontpanel.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=840946f61da4314a936847207e5fc90f\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fmasakai.github.io%2FGlow65%2Fassets%2Ffrontpanel.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=840946f61da4314a936847207e5fc90f\" alt=\"Glow65 のフロントパネルと端末\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fmasakai.github.io%2FGlow65%2Fassets%2Ffrontpanel.png?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=666f5d44c9ed3b3d7457e711d14114e2 1x\" data-canonical-src=\"https://masakai.github.io/Glow65/assets/frontpanel.png\" loading=\"lazy\"\u003e\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"11:1-12:140\"\u003eこの記事では、設計書の草案を書いた 10 月 11 日から、公証済みの macOS 版を配った 15 日までの経緯を書く。\u003cbr\u003e\n後半は、エミュレータより手間がかかった学習用の教材(マニュアルとチュートリアル)の作り方である。\u003c/p\u003e\n\u003cp data-sourcepos=\"14:1-15:132\"\u003e開発は Claude Code と一緒に進めた。\u003cbr\u003e\n設計書、実装、テスト、教材の下書きを依頼して、仕上がりは実行結果とテストで確かめている。\u003c/p\u003e\n\u003cul data-sourcepos=\"17:1-20:0\"\u003e\n\u003cli data-sourcepos=\"17:1-17:66\"\u003eリポジトリ: \u003ca href=\"https://github.com/Masakai/Glow65\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://github.com/Masakai/Glow65\u003c/a\u003e (MIT License)\u003c/li\u003e\n\u003cli data-sourcepos=\"18:1-18:84\"\u003eランディングページ(紹介動画つき): \u003ca href=\"https://masakai.github.io/Glow65/\" class=\"autolink\" rel=\"nofollow noopener\" target=\"_blank\"\u003ehttps://masakai.github.io/Glow65/\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"19:1-20:0\"\u003eマニュアルとチュートリアルの PDF: ランディングページの「マニュアル」から、日本語版と英語版をダウンロードできる\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 data-sourcepos=\"21:1-21:27\"\u003e\n\u003cspan id=\"作ったものの構成\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BD%9C%E3%81%A3%E3%81%9F%E3%82%82%E3%81%AE%E3%81%AE%E6%A7%8B%E6%88%90\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e作ったものの構成\u003c/h2\u003e\n\u003cp data-sourcepos=\"23:1-23:57\"\u003eSwift のパッケージは三つに分かれている。\u003c/p\u003e\n\u003cul data-sourcepos=\"25:1-28:0\"\u003e\n\u003cli data-sourcepos=\"25:1-25:101\"\u003e\n\u003ccode\u003eCore6502\u003c/code\u003e は CPU 本体で、NMOS 6502 と、WDC W65C02S 相当の 65C02 を切り替えられる\u003c/li\u003e\n\u003cli data-sourcepos=\"26:1-26:148\"\u003e\n\u003ccode\u003eGlow65\u003c/code\u003e は、メモリ、UART、バスを持つ「ボード」である。CPU を一定の速さで走らせるランナーも、ここにある\u003c/li\u003e\n\u003cli data-sourcepos=\"27:1-28:0\"\u003e\n\u003ccode\u003eGlow65App\u003c/code\u003e は、SwiftUI のフロントパネルと端末である。ほかに、端末だけで動く \u003ccode\u003eglow65-cli\u003c/code\u003e がある\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"29:1-29:57\"\u003eアドレス空間は、次のように割り当てた。\u003c/p\u003e\n\u003ctable data-sourcepos=\"31:1-37:97\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"31:1-31:25\"\u003e\n\u003cth data-sourcepos=\"31:2-31:15\"\u003eアドレス\u003c/th\u003e\n\u003cth data-sourcepos=\"31:17-31:24\"\u003e内容\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"33:1-33:29\"\u003e\n\u003ctd data-sourcepos=\"33:2-33:16\"\u003e\u003ccode\u003e$0000-$7FFF\u003c/code\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"33:18-33:28\"\u003eRAM 32 KB\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"34:1-34:73\"\u003e\n\u003ctd data-sourcepos=\"34:2-34:16\"\u003e\u003ccode\u003e$8000-$9FFF\u003c/code\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"34:18-34:72\"\u003eGAME65G(後述の、GAME 言語のインタプリタ)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"35:1-35:66\"\u003e\n\u003ctd data-sourcepos=\"35:2-35:16\"\u003e\u003ccode\u003e$A000-$A001\u003c/code\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"35:18-35:65\"\u003eUART(MC6850 風。状態と制御、データ)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"36:1-36:35\"\u003e\n\u003ctd data-sourcepos=\"36:2-36:16\"\u003e\u003ccode\u003e$C000-$EFFF\u003c/code\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"36:18-36:34\"\u003eMicrosoft BASIC\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"37:1-37:97\"\u003e\n\u003ctd data-sourcepos=\"37:2-37:16\"\u003e\u003ccode\u003e$F000-$FFFF\u003c/code\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"37:18-37:96\"\u003e自作のモニタ(ベクタと入出力のジャンプテーブルを含む)\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"39:1-40:35\"\u003eSwift のコードは約 3,400 行、モニタと GAME65G のアセンブラは約 2,200 行になった。\u003cbr\u003e\nテスト関数は 199 個ある。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"42:1-42:44\"\u003e\n\u003cspan id=\"実機の-rom-を使わないと決めた\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AE%9F%E6%A9%9F%E3%81%AE-rom-%E3%82%92%E4%BD%BF%E3%82%8F%E3%81%AA%E3%81%84%E3%81%A8%E6%B1%BA%E3%82%81%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e実機の ROM を使わないと決めた\u003c/h2\u003e\n\u003cp data-sourcepos=\"44:1-46:124\"\u003e最初に決めたのは、Apple II などの実機の ROM を載せないことだった。\u003cbr\u003e\nROM の著作権の問題を、最初から避けたかった。\u003cbr\u003e\nソフトは、MIT License で公開されている Microsoft BASIC for 6502 v1.1 と、自作のモニタだけにした。\u003c/p\u003e\n\u003cp data-sourcepos=\"48:1-50:69\"\u003eこの判断で、BASIC を自分でビルドする作業が必要になった。\u003cbr\u003e\n公開されている \u003ccode\u003em6502.asm\u003c/code\u003e は、約 6,950 行の 1 ファイルである。\u003cbr\u003e\n設計書の段階で、これが最大のリスクだと書いた。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"52:1-52:49\"\u003e\n\u003cspan id=\"klaus-dormann-のテストで-cpu-を固める\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#klaus-dormann-%E3%81%AE%E3%83%86%E3%82%B9%E3%83%88%E3%81%A7-cpu-%E3%82%92%E5%9B%BA%E3%82%81%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eKlaus Dormann のテストで CPU を固める\u003c/h2\u003e\n\u003cp data-sourcepos=\"54:1-56:94\"\u003eCPU コアは、公式の 151 命令、10 進モード、IRQ、NMI、BRK を実装した。\u003cbr\u003e\n正しさの確認には、Klaus Dormann の 6502 機能テストを使った。\u003cbr\u003e\nこのテストは、30,646,177 命令、96,241,367 サイクルを実行して合格する。\u003c/p\u003e\n\u003cp data-sourcepos=\"58:1-60:209\"\u003eテストは GPLv3 なので、リポジトリには入れていない。\u003cbr\u003e\n手元の \u003ccode\u003eTests/Fixtures/\u003c/code\u003e に置いて、CI では自動でスキップする設計にした。\u003cbr\u003e\nこの数字は、65C02 を足したときにも使った。65C02 を足したとき、NMOS の経路が壊れていないことを、同じ 30,646,177 命令と 96,241,367 サイクルで確かめられた。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"62:1-62:58\"\u003e\n\u003cspan id=\"macro-10-で書かれた-basic-を-ca65-に変換する\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#macro-10-%E3%81%A7%E6%9B%B8%E3%81%8B%E3%82%8C%E3%81%9F-basic-%E3%82%92-ca65-%E3%81%AB%E5%A4%89%E6%8F%9B%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eMACRO-10 で書かれた BASIC を ca65 に変換する\u003c/h2\u003e\n\u003cp data-sourcepos=\"64:1-67:102\"\u003e\u003ccode\u003em6502.asm\u003c/code\u003e は、PDP-10 のアセンブラ(MACRO-10)の構文で書かれている。\u003cbr\u003e\n\u003ccode\u003eIFE\u003c/code\u003e と \u003ccode\u003eIFN\u003c/code\u003e による条件コンパイルが約 300 か所、マクロの \u003ccode\u003eDEFINE\u003c/code\u003e が 31 個ある。\u003cbr\u003e\n\u003ccode\u003eLDAI\u003c/code\u003e や \u003ccode\u003eLDADY\u003c/code\u003e のような独自のニーモニックは、\u003ccode\u003eSEARCH M6502\u003c/code\u003e で読み込むマクロライブラリが定義するのだが、そのライブラリはリポジトリに入っていない。\u003cbr\u003e\nそのため、使われ方から意味を推測して、マクロを作り直す必要があった。\u003c/p\u003e\n\u003cp data-sourcepos=\"69:1-72:159\"\u003e選んだのは、Python の変換スクリプトで、MACRO-10 の構文を ca65(cc65 のアセンブラ)の構文に直す方法である。\u003cbr\u003e\n標準ライブラリだけで動く。\u003cbr\u003e\n\u003ccode\u003emist64/msbasic\u003c/code\u003e という、cc65 向けに復元した版が既にあることも知っていた。\u003cbr\u003e\nただ、Microsoft の MIT での公開とは別系統の復元物で、権利関係を確かめられなかったので、ソースには使わなかった。\u003c/p\u003e\n\u003cp data-sourcepos=\"74:1-74:54\"\u003e変換で苦労した点は、次の五つである。\u003c/p\u003e\n\u003col data-sourcepos=\"76:1-81:0\"\u003e\n\u003cli data-sourcepos=\"76:1-76:146\"\u003eシンボルは先頭の 6 文字だけが有効で、\u003ccode\u003eRESTORE\u003c/code\u003e と \u003ccode\u003eRESTOR\u003c/code\u003e が同じになる。全シンボルを 6 文字に切り詰めた\u003c/li\u003e\n\u003cli data-sourcepos=\"77:1-77:147\"\u003e数学パッケージの区間は \u003ccode\u003eRADIX 8\u003c/code\u003e で、接頭辞のない数が 8 進になる。\u003ccode\u003e8\u003c/code\u003e や \u003ccode\u003e9\u003c/code\u003e を含む数だけ 10 進として扱う\u003c/li\u003e\n\u003cli data-sourcepos=\"78:1-78:129\"\u003e\n\u003ccode\u003e\u0026lt;\u003c/code\u003e と \u003ccode\u003e\u0026gt;\u003c/code\u003e が、式の括弧とブロックの区切りを兼ねている。文字単位の構文解析で対応を付けた\u003c/li\u003e\n\u003cli data-sourcepos=\"79:1-79:218\"\u003e次の命令を飛ばすために、\u003ccode\u003eBIT\u003c/code\u003e 命令の 1 バイト目(\u003ccode\u003e$24\u003c/code\u003e か \u003ccode\u003e$2C\u003c/code\u003e)を埋め込む技が、\u003ccode\u003eXWD\u003c/code\u003e という疑似命令で書かれている。これをオペコードの 1 バイトに読み替えた\u003c/li\u003e\n\u003cli data-sourcepos=\"80:1-81:0\"\u003eゼロページに置く自己書き換えコードは、ゼロページ命令になることを確かめた(\u003ccode\u003eINC CHRGET+7\u003c/code\u003e が \u003ccode\u003eE6 CA\u003c/code\u003e)\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"82:1-82:141\"\u003e機種ごとの設定は、\u003ccode\u003ePATCHES\u003c/code\u003e という辞書で差し替えている(以下は抜粋で、全 8 項目はスクリプトにある)。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"python\" data-sourcepos=\"84:1-90:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"n\"\u003ePATCHES\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"s\"\u003eREALIO=4\u003c/span\u003e\u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"s\"\u003eREALIO=2\u003c/span\u003e\u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e              \u003cspan class=\"c1\"\u003e# OSI 構成(6850 系 ACIA)を基にする\n\u003c/span\u003e    \u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"s\"\u003eROMLOC=^O20000\u003c/span\u003e\u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"s\"\u003eROMLOC=49152\u003c/span\u003e\u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e    \u003cspan class=\"c1\"\u003e# BASIC ROM は $C000 から\n\u003c/span\u003e    \u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"s\"\u003eOUTCH==^O177013\u003c/span\u003e\u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"s\"\u003eOUTCH==61443\u003c/span\u003e\u003cspan class=\"sh\"\u003e\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e   \u003cspan class=\"c1\"\u003e# モニタの文字出力($F003)\n\u003c/span\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"92:1-94:79\"\u003eOSI の構成を基にしたのは、MC6850 系の UART を前提にしていて、ボードの UART と作りが同じだからだ。\u003cbr\u003e\n三角関数(SIN、COS、TAN、ATN)を持つことも理由である。\u003cbr\u003e\nKIM-1 の構成は、ROM を節約するために三角関数を持たない。\u003c/p\u003e\n\u003cp data-sourcepos=\"96:1-97:130\"\u003eROM の領域は、当初 \u003ccode\u003e$C000-$DFFF\u003c/code\u003e の 8 KB を見込んでいた。\u003cbr\u003e\n実測すると、コードだけで 8,632 バイトあって収まらなかったので、\u003ccode\u003e$C000-$EFFF\u003c/code\u003e の 12 KB に広げた。\u003c/p\u003e\n\u003cp data-sourcepos=\"99:1-102:42\"\u003e変換した ROM は、\u003ccode\u003eOK\u003c/code\u003e のプロンプトまで到達した。\u003cbr\u003e\n\u003ccode\u003ePRINT\u003c/code\u003e、\u003ccode\u003eFOR/NEXT\u003c/code\u003e、文字列関数、\u003ccode\u003eSQR\u003c/code\u003e と \u003ccode\u003eSIN\u003c/code\u003e、\u003ccode\u003ePEEK\u003c/code\u003e と \u003ccode\u003ePOKE\u003c/code\u003e、エラー表示は動いている。\u003cbr\u003e\nただし、原典に対応するバイナリがないので、元の ROM との突き合わせはできていない。\u003cbr\u003e\n確認は、動作テストで行った。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"104:1-104:48\"\u003e\n\u003cspan id=\"led-で-cpu-を見せる表示を設計する\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#led-%E3%81%A7-cpu-%E3%82%92%E8%A6%8B%E3%81%9B%E3%82%8B%E8%A1%A8%E7%A4%BA%E3%82%92%E8%A8%AD%E8%A8%88%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eLED で CPU を見せる表示を設計する\u003c/h2\u003e\n\u003cp data-sourcepos=\"106:1-109:85\"\u003eフロントパネルは、速度によって表示の方法を変えている。\u003cbr\u003e\n1 kHz 以下では、命令を実行してから、そのバスアクセスを 1 サイクルずつ LED に再生する。\u003cbr\u003e\n100 kHz 以上では、バスアクセスごとに各ビットの点灯回数を積算して、平均の明るさにする。\u003cbr\u003e\nどちらの場合も、LED には残光を付けて、60 Hz で更新している。\u003c/p\u003e\n\u003cp data-sourcepos=\"111:1-113:131\"\u003e再生という作りのために、LED が示す値には癖がある。\u003cbr\u003e\n命令を丸ごと実行してからバスアクセスを再生するので、再生中のレジスタの LED は、命令を実行する前の値を示す。\u003cbr\u003e\nパネルの「INSTRUCTION AT PC」に出す逆アセンブルも、同じ考え方で、再生している命令を表示する。\u003c/p\u003e\n\u003cp data-sourcepos=\"115:1-117:55\"\u003eこの表示のために、バスを読むとき、I/O に触らない \u003ccode\u003epeek\u003c/code\u003e を作った。\u003cbr\u003e\nUART のデータレジスタを表示のために読むと、受信したバイトが消えて、入力を取りこぼす。\u003cbr\u003e\n\u003ccode\u003epeek\u003c/code\u003e は I/O の領域に対して \u003ccode\u003e$FF\u003c/code\u003e を返す。\u003c/p\u003e\n\u003cp data-sourcepos=\"119:1-123:112\"\u003e癖の中で、いちばん気づきにくかったのは IRQ の LED である。\u003cbr\u003e\nLED は「命令を実行する前の IRQ 線の状態」を示す。\u003cbr\u003e\nそのため、\u003ccode\u003eLDA $A001\u003c/code\u003e で受信データを読んで要求を下げた STEP では、LED がまだ点いている。\u003cbr\u003e\n次の STEP で、やっと消える。\u003cbr\u003e\nこの挙動は、実際の画面を LED 1 つずつ読んで、教材に書いているときに気づいた。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"125:1-125:39\"\u003e\n\u003cspan id=\"画面を台本で動かして撮る\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%94%BB%E9%9D%A2%E3%82%92%E5%8F%B0%E6%9C%AC%E3%81%A7%E5%8B%95%E3%81%8B%E3%81%97%E3%81%A6%E6%92%AE%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e画面を台本で動かして撮る\u003c/h2\u003e\n\u003cp data-sourcepos=\"127:1-127:111\"\u003eアプリには、時刻つきの操作を並べて渡す \u003ccode\u003e--script\u003c/code\u003e という起動オプションがある。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"129:1-131:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eGlow65App --script \"1.2:poweron;3.2:input=g 0300\\r;8.0:speed=10\" --load tutorial/counter.bin@0300\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"133:1-137:168\"\u003e1.2 秒で電源を入れ、3.2 秒で \u003ccode\u003eg 0300\u003c/code\u003e と入力し、8 秒で SPEED を 10 Hz に落とす。\u003cbr\u003e\nマニュアルの画面写真も、紹介動画の画面も、この台本で動かしたアプリを \u003ccode\u003escreencapture\u003c/code\u003e で撮っている。\u003cbr\u003e\n台本があるので、パネルを直すたびに撮り直せる。\u003cbr\u003e\nNMI ボタンと CPU スイッチを足したあとは、実際に動画を撮り直した。\u003cbr\u003e\n同じ理由で、ランディングページには、動画の画面が実際のアプリの録画で、操作は台本で自動実行していると書いてある。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"139:1-139:24\"\u003e\n\u003cspan id=\"割り込みと-65c02\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%89%B2%E3%82%8A%E8%BE%BC%E3%81%BF%E3%81%A8-65c02\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e割り込みと 65C02\u003c/h2\u003e\n\u003cp data-sourcepos=\"141:1-142:91\"\u003e割り込みの教材を書くために、割り込みを足した。\u003cbr\u003e\n割り込み源は、パネルの NMI ボタンと、UART の受信割り込みである。\u003c/p\u003e\n\u003cp data-sourcepos=\"144:1-147:122\"\u003eROM の \u003ccode\u003e$FFFA\u003c/code\u003e と \u003ccode\u003e$FFFE\u003c/code\u003e のベクタは、書き換えられない。\u003cbr\u003e\nそこで、モニタの割り込みルーチンが、RAM の \u003ccode\u003e$03F8\u003c/code\u003e(NMI)と \u003ccode\u003e$03FA\u003c/code\u003e(IRQ と BRK)を経由して \u003ccode\u003ejmp (...)\u003c/code\u003e する形にした。\u003cbr\u003e\nリセットの時点で、両方を \u003ccode\u003erti\u003c/code\u003e だけのルーチンに初期化する。\u003cbr\u003e\nユーザーのプログラムは、この RAM のベクタを書き換えるだけで、割り込みを受けられる。\u003c/p\u003e\n\u003cp data-sourcepos=\"149:1-152:103\"\u003e65C02 には、基本の命令に加えて、Rockwell と WDC の拡張を入れた。\u003cbr\u003e\nKlaus の 65C02 拡張オペコードのテストが \u003ccode\u003eBBR\u003c/code\u003e、\u003ccode\u003eBBS\u003c/code\u003e、\u003ccode\u003eRMB\u003c/code\u003e、\u003ccode\u003eSMB\u003c/code\u003e を含むので、これらを入れた。\u003cbr\u003e\n\u003ccode\u003eWAI\u003c/code\u003e と \u003ccode\u003eSTP\u003c/code\u003e は、このテストの対象外なので、単体のテストで確かめている。\u003cbr\u003e\nCPU のスイッチはパネルにあり、電源が切れているときだけ切り替えられる。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"154:1-154:30\"\u003e\n\u003cspan id=\"学習用の教材を作る\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AD%A6%E7%BF%92%E7%94%A8%E3%81%AE%E6%95%99%E6%9D%90%E3%82%92%E4%BD%9C%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e学習用の教材を作る\u003c/h2\u003e\n\u003cp data-sourcepos=\"156:1-158:63\"\u003eエミュレータは、使い方が分からなければ、学習には使えない。\u003cbr\u003e\n教材は、読んで理解する「マニュアル」と、手を動かす「チュートリアル」に分けた。\u003cbr\u003e\nどちらも、読者は中学生以上を想定している。\u003c/p\u003e\n\u003cp data-sourcepos=\"160:1-160:87\"\u003eマニュアルは、日本語版と英語版があり、次の 8 章に分かれる。\u003c/p\u003e\n\u003col data-sourcepos=\"162:1-170:0\"\u003e\n\u003cli data-sourcepos=\"162:1-162:17\"\u003e6502 の歴史\u003c/li\u003e\n\u003cli data-sourcepos=\"163:1-163:92\"\u003eコンピュータの基本(ビットとバイト、16 進数、メモリとアドレス)\u003c/li\u003e\n\u003cli data-sourcepos=\"164:1-164:63\"\u003eCPU の中身(レジスタ、バス、フェッチと実行)\u003c/li\u003e\n\u003cli data-sourcepos=\"165:1-165:33\"\u003eアドレッシングモード\u003c/li\u003e\n\u003cli data-sourcepos=\"166:1-166:76\"\u003e命令リファレンス(公式の 151 命令と、65C02 の追加命令)\u003c/li\u003e\n\u003cli data-sourcepos=\"167:1-167:42\"\u003eフラグ、スタック、割り込み\u003c/li\u003e\n\u003cli data-sourcepos=\"168:1-168:28\"\u003eメモリマップと I/O\u003c/li\u003e\n\u003cli data-sourcepos=\"169:1-170:0\"\u003e用語集と索引\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"171:1-172:158\"\u003eチュートリアルは 7 章で、短いプログラムを動かしながら進む。\u003cbr\u003e\nLED カウンタに始まり、足し算とキャリー、UART、キーボード、BASIC からの \u003ccode\u003eUSR\u003c/code\u003e、割り込み、65C02 への切り替えと続く。\u003c/p\u003e\n\u003cp data-sourcepos=\"174:1-175:107\"\u003e最初のチュートリアルは、この記事の冒頭に戻る。\u003cbr\u003e\n題材は、A レジスタに 1 を足し続けて、メモリに書く 5 行のプログラムである。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"177:1-183:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e        lda #$00        ; A = 0\nloop:   sta COUNTER     ; A をメモリに書く(COUNTER = $03F0)\n        clc             ; 足し算の前にキャリーフラグを消す\n        adc #$01        ; A = A + 1\n        jmp loop        ; 最初に戻る\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"185:1-189:103\"\u003eループ 1 周は 11 サイクルで、1 MHz なら 1 秒に約 9 万回まわる。\u003cbr\u003e\nパネルが LED を更新するのは 1 秒に 60 回ほどなので、A の LED は、でたらめに光って見える。\u003cbr\u003e\nSPEED を 10 Hz に落とすと、ループの 1 周が 1 秒強になる。\u003cbr\u003e\nアドレスバスの LED が \u003ccode\u003e$0300\u003c/code\u003e、\u003ccode\u003e$0301\u003c/code\u003e、\u003ccode\u003e$0302\u003c/code\u003e と歩いていくのが見えて、\u003ccode\u003esta\u003c/code\u003e の瞬間にだけ WRITE が点き、データバスに A の値が出る。\u003cbr\u003e\nCPU を遅くすれば見える、という冒頭の話が、そのまま第 1 章になっている。\u003c/p\u003e\n\u003cp data-sourcepos=\"191:1-192:173\"\u003eチュートリアルの本文にある数値や出力は、章ごとのテストで確かめている。\u003cbr\u003e\nたとえば第 6 章には、NMI を 3 回押すとカウンタが 3 になって \u003ccode\u003e!!!\u003c/code\u003e が出る、という本文の説明をそのまま実行するテストがある。\u003c/p\u003e\n\u003cp data-sourcepos=\"194:1-198:66\"\u003e書き方の方針は、先に決めた。\u003cbr\u003e\n報告書にしない。\u003cbr\u003e\n調査の経緯や、出典の格付けは、本文に書かない。\u003cbr\u003e\n確認できなかったことは、書かずに省く。\u003cbr\u003e\n読み物としての寄り道は、コラムの枠に入れる。\u003c/p\u003e\n\u003cp data-sourcepos=\"200:1-201:84\"\u003e方針だけでは、事実のずれは防げない。\u003cbr\u003e\nそこで、機械で確かめられるものは、スクリプトで確かめた。\u003c/p\u003e\n\u003cul data-sourcepos=\"203:1-206:0\"\u003e\n\u003cli data-sourcepos=\"203:1-203:222\"\u003e図は、Python のスクリプトで生成する。図は SVG で 30 種類あり、日本語版と英語版の 2 組を、同じスクリプトで生成する。文字列は、日本語と英語の対で持っている\u003c/li\u003e\n\u003cli data-sourcepos=\"204:1-204:221\"\u003eオペコード表とフラグ表は、\u003ccode\u003eOpcodeTable.swift\u003c/code\u003e から生成して、本文の決まった位置に書き込む。\u003ccode\u003e--check\u003c/code\u003e を付けると、本文の表が実装と一致しているかを確かめられる\u003c/li\u003e\n\u003cli data-sourcepos=\"205:1-206:0\"\u003eリンクは、相対リンクと見出しのアンカーを検査する。アンカーの規則は、GitHub 上の表示と照らして、134 見出しで一致を確認した\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"207:1-210:118\"\u003e画面の説明は、推測では書いていない。\u003cbr\u003e\nアプリを \u003ccode\u003e--script\u003c/code\u003e で操作して画面を撮り、LED を 1 つずつ読んで、その値を本文に書く。\u003cbr\u003e\n先に書いた IRQ の LED の癖は、この手順で見つかった。\u003cbr\u003e\n\u003ccode\u003eWAI\u003c/code\u003e と \u003ccode\u003eSTP\u003c/code\u003e の使い方の例も、モニタを載せたボードで実行して、自動テストに入れた。\u003c/p\u003e\n\u003cp data-sourcepos=\"212:1-214:80\"\u003e歴史の章では、数字と日付を、引用した出典に照らし直した。\u003cbr\u003e\nたとえば、Motorola との和解の時期を、「1976 年の春」と書き直した。\u003cbr\u003e\n参照した 2 つの記事で、3 月と 4 月に分かれていたからだ。\u003c/p\u003e\n\u003cp data-sourcepos=\"216:1-217:96\"\u003e英語版は、日本語版の忠実な翻訳にした。\u003cbr\u003e\n章ごとに担当を分けて並行で訳し、用語の対応表を全員で共通にした。\u003c/p\u003e\n\u003cp data-sourcepos=\"219:1-220:200\"\u003e最後に、ダウンロードしやすくするため、マニュアルとチュートリアルを日本語と英語の PDF にした。\u003cbr\u003e\n章ごとに pandoc で HTML にして、表紙、目次、第 1 部のマニュアル、第 2 部のチュートリアルの順につなぎ、Chrome のヘッドレス印刷で PDF にしている。\u003c/p\u003e\n\u003cp data-sourcepos=\"222:1-224:88\"\u003e日本語版は 225 ページ、英語版は 211 ページで、しおりと章をまたぐリンクが入る。\u003cbr\u003e\nマニュアルだけだった時点の日本語版は、約 12 MB あった。\u003cbr\u003e\n画面写真を JPEG に変えて、PDF を再圧縮すると、約 6 MB になった。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"226:1-226:63\"\u003e\n\u003cspan id=\"二つ目の言語-game65g-を仕様から書き起こす\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BA%8C%E3%81%A4%E7%9B%AE%E3%81%AE%E8%A8%80%E8%AA%9E-game65g-%E3%82%92%E4%BB%95%E6%A7%98%E3%81%8B%E3%82%89%E6%9B%B8%E3%81%8D%E8%B5%B7%E3%81%93%E3%81%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e二つ目の言語 GAME65G を、仕様から書き起こす\u003c/h2\u003e\n\u003cp data-sourcepos=\"228:1-230:90\"\u003eGAME 言語は、大西博氏が 1978 年に月刊アスキーで発表した、BASIC を簡略化した、記号で書く言語である。\u003cbr\u003e\nBASIC に続く 2 本目の言語として、そのインタプリタ GAME65G を Glow65 に載せた。\u003cbr\u003e\nROM の領域は \u003ccode\u003e$8000-$9FFF\u003c/code\u003e の 8 KB で、モニタで \u003ccode\u003eT\u003c/code\u003e と打つと起動する。\u003c/p\u003e\n\u003cp data-sourcepos=\"232:1-234:30\"\u003e1979 年 10 月号の月刊アスキーの特集には、鈴木仁志氏の PET 用の GAME65 が載っている。\u003cbr\u003e\nそのコードは使っていない。\u003cbr\u003e\n著作権の理由である。\u003c/p\u003e\n\u003cp data-sourcepos=\"236:1-240:126\"\u003e一方で、著作権法 10 条 3 項は、プログラム言語、規約、解法に、プログラムの著作物の保護が及ばないと定めている。\u003cbr\u003e\nGAME の命令の文法と意味は、これにあたると判断した(法的な助言ではない)。\u003cbr\u003e\nそこで、使う資料を月刊アスキーの 2 本の記事(命令表と、GAME65 の追加の演算子の説明)だけに絞った。\u003cbr\u003e\nほかの GAME 処理系のソースは読まず、テスト用の GAME プログラムも自作した。\u003cbr\u003e\n名前を GAME65 にしなかったのは、別物であることを \u003ccode\u003eNOTICE.md\u003c/code\u003e と起動バナーに書くためである。\u003c/p\u003e\n\u003cp data-sourcepos=\"242:1-244:42\"\u003e例として、357 行の Star Trek を GAME で書き、\u003ccode\u003eexamples/startrek.gam\u003c/code\u003e に置いた。\u003cbr\u003e\nSend Text File で端末に送ると、遊べる。\u003cbr\u003e\n自動テストでも動かしている。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"246:1-246:39\"\u003e\n\u003cspan id=\"公開する前に確認したこと\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%85%AC%E9%96%8B%E3%81%99%E3%82%8B%E5%89%8D%E3%81%AB%E7%A2%BA%E8%AA%8D%E3%81%97%E3%81%9F%E3%81%93%E3%81%A8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e公開する前に確認したこと\u003c/h2\u003e\n\u003cp data-sourcepos=\"248:1-248:57\"\u003e公開の前に、リポジトリの棚卸しをした。\u003c/p\u003e\n\u003cul data-sourcepos=\"250:1-255:0\"\u003e\n\u003cli data-sourcepos=\"250:1-250:104\"\u003egitleaks で、全履歴と作業ツリーに秘密情報が混ざっていないことを確かめた\u003c/li\u003e\n\u003cli data-sourcepos=\"251:1-251:231\"\u003eコミットの著者のメールアドレスを、\u003ccode\u003egit filter-repo\u003c/code\u003e で GitHub の noreply アドレスに書き換えて、\u003ccode\u003emain\u003c/code\u003e に強制プッシュした。書き換え前のコミットは、古い SHA で GitHub に残る\u003c/li\u003e\n\u003cli data-sourcepos=\"252:1-252:116\"\u003e\n\u003ccode\u003emain\u003c/code\u003e に、強制プッシュと削除を禁止するルールセットを付けた。\u003ccode\u003eSECURITY.md\u003c/code\u003e も置いた\u003c/li\u003e\n\u003cli data-sourcepos=\"253:1-253:99\"\u003eCI は、アクションをコミットの SHA で固定して、\u003ccode\u003epermissions\u003c/code\u003e を最小にした\u003c/li\u003e\n\u003cli data-sourcepos=\"254:1-255:0\"\u003e入力の処理は、読み込む前にファイルの大きさを調べて、65,536 バイトを超えるものを断るようにした\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"256:1-258:101\"\u003eクレジットは、事実のとおりに書いた。\u003cbr\u003e\nこのシステムは、全体が自作ではない。\u003cbr\u003e\nMicrosoft BASIC を同梱していることを、起動バナーと \u003ccode\u003eNOTICE.md\u003c/code\u003e に書いている。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"260:1-260:56\"\u003e\n\u003cspan id=\"配布-公証した-macos-版とipad-版の準備\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E9%85%8D%E5%B8%83-%E5%85%AC%E8%A8%BC%E3%81%97%E3%81%9F-macos-%E7%89%88%E3%81%A8ipad-%E7%89%88%E3%81%AE%E6%BA%96%E5%82%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e配布: 公証した macOS 版と、iPad 版の準備\u003c/h2\u003e\n\u003cp data-sourcepos=\"262:1-265:105\"\u003emacOS 版は、Developer ID で署名して、Apple の公証を通し、zip にして GitHub の Releases に置いた。\u003cbr\u003e\n配布用の \u003ccode\u003e.app\u003c/code\u003e は、\u003ccode\u003eswift build\u003c/code\u003e の成果物を手で組み立てて作っている。\u003cbr\u003e\n日本語と英語の切り替えは、\u003ccode\u003eLocalizable.strings\u003c/code\u003e で、システムの言語に従う。\u003cbr\u003e\nパネルのラベル(POWER、STEP など)は、実機の印刷を模して、英語のままにした。\u003c/p\u003e\n\u003cp data-sourcepos=\"267:1-270:147\"\u003eiPad 版は、パネルと端末の SwiftUI のコードを、そのまま共有している。\u003cbr\u003e\nXcodeGen でプロジェクトを作った。ファイルの選択、クリップボード、画面のキーボードからの入力、画面の縮小は、iPad 向けに足したり置き換えたりしている。\u003cbr\u003e\nパネルは、画面の幅に合わせて縮める作りなので、iPhone は対象にしていない。\u003cbr\u003e\niPad 版は、TestFlight のテスト版として公開している(リンクは \u003ca href=\"https://github.com/Masakai/Glow65#download\" rel=\"nofollow noopener\" target=\"_blank\"\u003eREADME\u003c/a\u003e にある)。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"272:1-272:27\"\u003e\n\u003cspan id=\"できていないこと\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%A7%E3%81%8D%E3%81%A6%E3%81%84%E3%81%AA%E3%81%84%E3%81%93%E3%81%A8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eできていないこと\u003c/h2\u003e\n\u003cp data-sourcepos=\"274:1-283:125\"\u003eダミーリードとダミーライトは、再現していない。\u003cbr\u003e\nそのため、実機のバスには現れるアクセスが、パネルには出ない命令がある。\u003cbr\u003e\n割り込みを受け付けるときのバスアクセスは 5 回で、実機の 7 サイクルのうち、ダミーリードの分が抜ける。\u003cbr\u003e\nBASIC は、\u003ccode\u003eGET\u003c/code\u003e、\u003ccode\u003eWAIT\u003c/code\u003e、\u003ccode\u003eON GOTO\u003c/code\u003e などを、網羅的には確かめていない。\u003cbr\u003e\n実機の 6502 との突き合わせも、していない。\u003cbr\u003e\nBASIC の \u003ccode\u003eSAVE\u003c/code\u003e と \u003ccode\u003eLOAD\u003c/code\u003e はなく、GAME65G にも \u003ccode\u003eLOAD\u003c/code\u003e と \u003ccode\u003eSAVE\u003c/code\u003e がない。\u003cbr\u003e\nその代わりに、テキストで書いたプログラムを、端末へ送る入口を用意した。\u003cbr\u003e\nアプリでは、クリップボードの貼り付けと、ファイルの送信(File → Send Text File…)で、BASIC や GAME65G のプログラムを入れる。\u003cbr\u003e\nコマンドラインの \u003ccode\u003eglow65-cli\u003c/code\u003e では、\u003ccode\u003e--send-text\u003c/code\u003e でファイルを送る。\u003cbr\u003e\niPad 版は、横向きの表示と、「ファイル」アプリからの読み込みを、実機で確かめていない。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"285:1-285:33\"\u003e\n\u003cspan id=\"数日で形になった理由\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%95%B0%E6%97%A5%E3%81%A7%E5%BD%A2%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F%E7%90%86%E7%94%B1\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e数日で形になった理由\u003c/h2\u003e\n\u003cp data-sourcepos=\"287:1-289:177\"\u003e設計書の草案を書いたのが 10 月 11 日で、最初のコミットが 12 日である。\u003cbr\u003e\n15 日には、macOS 版の公証済みの配布物と、日英の PDF ができていた。\u003cbr\u003e\n速かったのは、「何を作るか」を人が決め、「できたものが正しいか」を機械が確かめる、という分担にしたからだと考えている。\u003c/p\u003e\n\u003cp data-sourcepos=\"291:1-295:163\"\u003e実装、図の生成、表の生成、翻訳の下書きは、Claude Code に任せた。\u003cbr\u003e\n任せたものは、テストとスクリプトで、機械的に確かめられる形にした。\u003cbr\u003e\nKlaus のテストや 199 個のテスト、表を生成して照合するスクリプトがあるので、出力の正否を、読まずに機械で判定できた。\u003cbr\u003e\n他の進め方とは、比べていない。\u003cbr\u003e\n一方で、権利の扱い(実機の ROM を使わない、既存の移植を使わない)、公開の範囲、教材の書き方の方針は、人が決めた。\u003c/p\u003e\n\u003cp data-sourcepos=\"297:1-298:84\"\u003e設計書は、決めたこと、確認したこと、確認できていないことを、日付つきで足していった。\u003cbr\u003e\nこの記事の数字も、その設計書とリポジトリから拾っている。\u003c/p\u003e\n\u003cp data-sourcepos=\"300:1-304:119\"\u003e最後に、冒頭の問いに戻る。\u003cbr\u003e\nGlow65 を起動して、SPEED を 1 Hz に落とし、チュートリアルの 11 バイトを読み込んで \u003ccode\u003eg 0300\u003c/code\u003e と打つ。\u003cbr\u003e\nSTEP を押すたびに、\u003ccode\u003e$0300\u003c/code\u003e を読む、\u003ccode\u003e$0301\u003c/code\u003e を読む、と、バスアクセスが 1 回ずつ LED に出る。\u003cbr\u003e\nリポジトリとランディングページは、冒頭のリンクから見られる。\u003cbr\u003e\n6502 の動きを LED で見たい人や、CPU の教材を探している人に、使ってもらえれば嬉しい。\u003c/p\u003e\n","body":"6502 は、1 秒に 100 万回ほどクロックが動く。\nそこに LED をつないでも、人の目には「点いている」か「点いていない」かしか見えない。\nバスで何が起きているかを見るには、CPU を遅くするしかない。\n\nGlow65 は、その「遅くする」部分を作った 6502 のエミュレータである。\nSPEED スイッチで 1 Hz から 1 MHz まで速度を選べて、STEP スイッチを押すと、バスアクセスが 1 回ずつ進む。\nアドレスバス、データバス、レジスタ、フラグが、昔のコンピュータのフロントパネルのように LED で光る。\n\n![Glow65 のフロントパネルと端末](https://masakai.github.io/Glow65/assets/frontpanel.png)\n\nこの記事では、設計書の草案を書いた 10 月 11 日から、公証済みの macOS 版を配った 15 日までの経緯を書く。\n後半は、エミュレータより手間がかかった学習用の教材(マニュアルとチュートリアル)の作り方である。\n\n開発は Claude Code と一緒に進めた。\n設計書、実装、テスト、教材の下書きを依頼して、仕上がりは実行結果とテストで確かめている。\n\n- リポジトリ: https://github.com/Masakai/Glow65 (MIT License)\n- ランディングページ(紹介動画つき): https://masakai.github.io/Glow65/\n- マニュアルとチュートリアルの PDF: ランディングページの「マニュアル」から、日本語版と英語版をダウンロードできる\n\n## 作ったものの構成\n\nSwift のパッケージは三つに分かれている。\n\n- `Core6502` は CPU 本体で、NMOS 6502 と、WDC W65C02S 相当の 65C02 を切り替えられる\n- `Glow65` は、メモリ、UART、バスを持つ「ボード」である。CPU を一定の速さで走らせるランナーも、ここにある\n- `Glow65App` は、SwiftUI のフロントパネルと端末である。ほかに、端末だけで動く `glow65-cli` がある\n\nアドレス空間は、次のように割り当てた。\n\n| アドレス | 内容 |\n|---|---|\n| `$0000-$7FFF` | RAM 32 KB |\n| `$8000-$9FFF` | GAME65G(後述の、GAME 言語のインタプリタ) |\n| `$A000-$A001` | UART(MC6850 風。状態と制御、データ) |\n| `$C000-$EFFF` | Microsoft BASIC |\n| `$F000-$FFFF` | 自作のモニタ(ベクタと入出力のジャンプテーブルを含む) |\n\nSwift のコードは約 3,400 行、モニタと GAME65G のアセンブラは約 2,200 行になった。\nテスト関数は 199 個ある。\n\n## 実機の ROM を使わないと決めた\n\n最初に決めたのは、Apple II などの実機の ROM を載せないことだった。\nROM の著作権の問題を、最初から避けたかった。\nソフトは、MIT License で公開されている Microsoft BASIC for 6502 v1.1 と、自作のモニタだけにした。\n\nこの判断で、BASIC を自分でビルドする作業が必要になった。\n公開されている `m6502.asm` は、約 6,950 行の 1 ファイルである。\n設計書の段階で、これが最大のリスクだと書いた。\n\n## Klaus Dormann のテストで CPU を固める\n\nCPU コアは、公式の 151 命令、10 進モード、IRQ、NMI、BRK を実装した。\n正しさの確認には、Klaus Dormann の 6502 機能テストを使った。\nこのテストは、30,646,177 命令、96,241,367 サイクルを実行して合格する。\n\nテストは GPLv3 なので、リポジトリには入れていない。\n手元の `Tests/Fixtures/` に置いて、CI では自動でスキップする設計にした。\nこの数字は、65C02 を足したときにも使った。65C02 を足したとき、NMOS の経路が壊れていないことを、同じ 30,646,177 命令と 96,241,367 サイクルで確かめられた。\n\n## MACRO-10 で書かれた BASIC を ca65 に変換する\n\n`m6502.asm` は、PDP-10 のアセンブラ(MACRO-10)の構文で書かれている。\n`IFE` と `IFN` による条件コンパイルが約 300 か所、マクロの `DEFINE` が 31 個ある。\n`LDAI` や `LDADY` のような独自のニーモニックは、`SEARCH M6502` で読み込むマクロライブラリが定義するのだが、そのライブラリはリポジトリに入っていない。\nそのため、使われ方から意味を推測して、マクロを作り直す必要があった。\n\n選んだのは、Python の変換スクリプトで、MACRO-10 の構文を ca65(cc65 のアセンブラ)の構文に直す方法である。\n標準ライブラリだけで動く。\n`mist64/msbasic` という、cc65 向けに復元した版が既にあることも知っていた。\nただ、Microsoft の MIT での公開とは別系統の復元物で、権利関係を確かめられなかったので、ソースには使わなかった。\n\n変換で苦労した点は、次の五つである。\n\n1. シンボルは先頭の 6 文字だけが有効で、`RESTORE` と `RESTOR` が同じになる。全シンボルを 6 文字に切り詰めた\n2. 数学パッケージの区間は `RADIX 8` で、接頭辞のない数が 8 進になる。`8` や `9` を含む数だけ 10 進として扱う\n3. `\u003c` と `\u003e` が、式の括弧とブロックの区切りを兼ねている。文字単位の構文解析で対応を付けた\n4. 次の命令を飛ばすために、`BIT` 命令の 1 バイト目(`$24` か `$2C`)を埋め込む技が、`XWD` という疑似命令で書かれている。これをオペコードの 1 バイトに読み替えた\n5. ゼロページに置く自己書き換えコードは、ゼロページ命令になることを確かめた(`INC CHRGET+7` が `E6 CA`)\n\n機種ごとの設定は、`PATCHES` という辞書で差し替えている(以下は抜粋で、全 8 項目はスクリプトにある)。\n\n```python\nPATCHES = {\n    \"REALIO=4\": \"REALIO=2\",              # OSI 構成(6850 系 ACIA)を基にする\n    \"ROMLOC=^O20000\": \"ROMLOC=49152\",    # BASIC ROM は $C000 から\n    \"OUTCH==^O177013\": \"OUTCH==61443\",   # モニタの文字出力($F003)\n}\n```\n\nOSI の構成を基にしたのは、MC6850 系の UART を前提にしていて、ボードの UART と作りが同じだからだ。\n三角関数(SIN、COS、TAN、ATN)を持つことも理由である。\nKIM-1 の構成は、ROM を節約するために三角関数を持たない。\n\nROM の領域は、当初 `$C000-$DFFF` の 8 KB を見込んでいた。\n実測すると、コードだけで 8,632 バイトあって収まらなかったので、`$C000-$EFFF` の 12 KB に広げた。\n\n変換した ROM は、`OK` のプロンプトまで到達した。\n`PRINT`、`FOR/NEXT`、文字列関数、`SQR` と `SIN`、`PEEK` と `POKE`、エラー表示は動いている。\nただし、原典に対応するバイナリがないので、元の ROM との突き合わせはできていない。\n確認は、動作テストで行った。\n\n## LED で CPU を見せる表示を設計する\n\nフロントパネルは、速度によって表示の方法を変えている。\n1 kHz 以下では、命令を実行してから、そのバスアクセスを 1 サイクルずつ LED に再生する。\n100 kHz 以上では、バスアクセスごとに各ビットの点灯回数を積算して、平均の明るさにする。\nどちらの場合も、LED には残光を付けて、60 Hz で更新している。\n\n再生という作りのために、LED が示す値には癖がある。\n命令を丸ごと実行してからバスアクセスを再生するので、再生中のレジスタの LED は、命令を実行する前の値を示す。\nパネルの「INSTRUCTION AT PC」に出す逆アセンブルも、同じ考え方で、再生している命令を表示する。\n\nこの表示のために、バスを読むとき、I/O に触らない `peek` を作った。\nUART のデータレジスタを表示のために読むと、受信したバイトが消えて、入力を取りこぼす。\n`peek` は I/O の領域に対して `$FF` を返す。\n\n癖の中で、いちばん気づきにくかったのは IRQ の LED である。\nLED は「命令を実行する前の IRQ 線の状態」を示す。\nそのため、`LDA $A001` で受信データを読んで要求を下げた STEP では、LED がまだ点いている。\n次の STEP で、やっと消える。\nこの挙動は、実際の画面を LED 1 つずつ読んで、教材に書いているときに気づいた。\n\n## 画面を台本で動かして撮る\n\nアプリには、時刻つきの操作を並べて渡す `--script` という起動オプションがある。\n\n```\nGlow65App --script \"1.2:poweron;3.2:input=g 0300\\r;8.0:speed=10\" --load tutorial/counter.bin@0300\n```\n\n1.2 秒で電源を入れ、3.2 秒で `g 0300` と入力し、8 秒で SPEED を 10 Hz に落とす。\nマニュアルの画面写真も、紹介動画の画面も、この台本で動かしたアプリを `screencapture` で撮っている。\n台本があるので、パネルを直すたびに撮り直せる。\nNMI ボタンと CPU スイッチを足したあとは、実際に動画を撮り直した。\n同じ理由で、ランディングページには、動画の画面が実際のアプリの録画で、操作は台本で自動実行していると書いてある。\n\n## 割り込みと 65C02\n\n割り込みの教材を書くために、割り込みを足した。\n割り込み源は、パネルの NMI ボタンと、UART の受信割り込みである。\n\nROM の `$FFFA` と `$FFFE` のベクタは、書き換えられない。\nそこで、モニタの割り込みルーチンが、RAM の `$03F8`(NMI)と `$03FA`(IRQ と BRK)を経由して `jmp (...)` する形にした。\nリセットの時点で、両方を `rti` だけのルーチンに初期化する。\nユーザーのプログラムは、この RAM のベクタを書き換えるだけで、割り込みを受けられる。\n\n65C02 には、基本の命令に加えて、Rockwell と WDC の拡張を入れた。\nKlaus の 65C02 拡張オペコードのテストが `BBR`、`BBS`、`RMB`、`SMB` を含むので、これらを入れた。\n`WAI` と `STP` は、このテストの対象外なので、単体のテストで確かめている。\nCPU のスイッチはパネルにあり、電源が切れているときだけ切り替えられる。\n\n## 学習用の教材を作る\n\nエミュレータは、使い方が分からなければ、学習には使えない。\n教材は、読んで理解する「マニュアル」と、手を動かす「チュートリアル」に分けた。\nどちらも、読者は中学生以上を想定している。\n\nマニュアルは、日本語版と英語版があり、次の 8 章に分かれる。\n\n1. 6502 の歴史\n2. コンピュータの基本(ビットとバイト、16 進数、メモリとアドレス)\n3. CPU の中身(レジスタ、バス、フェッチと実行)\n4. アドレッシングモード\n5. 命令リファレンス(公式の 151 命令と、65C02 の追加命令)\n6. フラグ、スタック、割り込み\n7. メモリマップと I/O\n8. 用語集と索引\n\nチュートリアルは 7 章で、短いプログラムを動かしながら進む。\nLED カウンタに始まり、足し算とキャリー、UART、キーボード、BASIC からの `USR`、割り込み、65C02 への切り替えと続く。\n\n最初のチュートリアルは、この記事の冒頭に戻る。\n題材は、A レジスタに 1 を足し続けて、メモリに書く 5 行のプログラムである。\n\n```\n        lda #$00        ; A = 0\nloop:   sta COUNTER     ; A をメモリに書く(COUNTER = $03F0)\n        clc             ; 足し算の前にキャリーフラグを消す\n        adc #$01        ; A = A + 1\n        jmp loop        ; 最初に戻る\n```\n\nループ 1 周は 11 サイクルで、1 MHz なら 1 秒に約 9 万回まわる。\nパネルが LED を更新するのは 1 秒に 60 回ほどなので、A の LED は、でたらめに光って見える。\nSPEED を 10 Hz に落とすと、ループの 1 周が 1 秒強になる。\nアドレスバスの LED が `$0300`、`$0301`、`$0302` と歩いていくのが見えて、`sta` の瞬間にだけ WRITE が点き、データバスに A の値が出る。\nCPU を遅くすれば見える、という冒頭の話が、そのまま第 1 章になっている。\n\nチュートリアルの本文にある数値や出力は、章ごとのテストで確かめている。\nたとえば第 6 章には、NMI を 3 回押すとカウンタが 3 になって `!!!` が出る、という本文の説明をそのまま実行するテストがある。\n\n書き方の方針は、先に決めた。\n報告書にしない。\n調査の経緯や、出典の格付けは、本文に書かない。\n確認できなかったことは、書かずに省く。\n読み物としての寄り道は、コラムの枠に入れる。\n\n方針だけでは、事実のずれは防げない。\nそこで、機械で確かめられるものは、スクリプトで確かめた。\n\n- 図は、Python のスクリプトで生成する。図は SVG で 30 種類あり、日本語版と英語版の 2 組を、同じスクリプトで生成する。文字列は、日本語と英語の対で持っている\n- オペコード表とフラグ表は、`OpcodeTable.swift` から生成して、本文の決まった位置に書き込む。`--check` を付けると、本文の表が実装と一致しているかを確かめられる\n- リンクは、相対リンクと見出しのアンカーを検査する。アンカーの規則は、GitHub 上の表示と照らして、134 見出しで一致を確認した\n\n画面の説明は、推測では書いていない。\nアプリを `--script` で操作して画面を撮り、LED を 1 つずつ読んで、その値を本文に書く。\n先に書いた IRQ の LED の癖は、この手順で見つかった。\n`WAI` と `STP` の使い方の例も、モニタを載せたボードで実行して、自動テストに入れた。\n\n歴史の章では、数字と日付を、引用した出典に照らし直した。\nたとえば、Motorola との和解の時期を、「1976 年の春」と書き直した。\n参照した 2 つの記事で、3 月と 4 月に分かれていたからだ。\n\n英語版は、日本語版の忠実な翻訳にした。\n章ごとに担当を分けて並行で訳し、用語の対応表を全員で共通にした。\n\n最後に、ダウンロードしやすくするため、マニュアルとチュートリアルを日本語と英語の PDF にした。\n章ごとに pandoc で HTML にして、表紙、目次、第 1 部のマニュアル、第 2 部のチュートリアルの順につなぎ、Chrome のヘッドレス印刷で PDF にしている。\n\n日本語版は 225 ページ、英語版は 211 ページで、しおりと章をまたぐリンクが入る。\nマニュアルだけだった時点の日本語版は、約 12 MB あった。\n画面写真を JPEG に変えて、PDF を再圧縮すると、約 6 MB になった。\n\n## 二つ目の言語 GAME65G を、仕様から書き起こす\n\nGAME 言語は、大西博氏が 1978 年に月刊アスキーで発表した、BASIC を簡略化した、記号で書く言語である。\nBASIC に続く 2 本目の言語として、そのインタプリタ GAME65G を Glow65 に載せた。\nROM の領域は `$8000-$9FFF` の 8 KB で、モニタで `T` と打つと起動する。\n\n1979 年 10 月号の月刊アスキーの特集には、鈴木仁志氏の PET 用の GAME65 が載っている。\nそのコードは使っていない。\n著作権の理由である。\n\n一方で、著作権法 10 条 3 項は、プログラム言語、規約、解法に、プログラムの著作物の保護が及ばないと定めている。\nGAME の命令の文法と意味は、これにあたると判断した(法的な助言ではない)。\nそこで、使う資料を月刊アスキーの 2 本の記事(命令表と、GAME65 の追加の演算子の説明)だけに絞った。\nほかの GAME 処理系のソースは読まず、テスト用の GAME プログラムも自作した。\n名前を GAME65 にしなかったのは、別物であることを `NOTICE.md` と起動バナーに書くためである。\n\n例として、357 行の Star Trek を GAME で書き、`examples/startrek.gam` に置いた。\nSend Text File で端末に送ると、遊べる。\n自動テストでも動かしている。\n\n## 公開する前に確認したこと\n\n公開の前に、リポジトリの棚卸しをした。\n\n- gitleaks で、全履歴と作業ツリーに秘密情報が混ざっていないことを確かめた\n- コミットの著者のメールアドレスを、`git filter-repo` で GitHub の noreply アドレスに書き換えて、`main` に強制プッシュした。書き換え前のコミットは、古い SHA で GitHub に残る\n- `main` に、強制プッシュと削除を禁止するルールセットを付けた。`SECURITY.md` も置いた\n- CI は、アクションをコミットの SHA で固定して、`permissions` を最小にした\n- 入力の処理は、読み込む前にファイルの大きさを調べて、65,536 バイトを超えるものを断るようにした\n\nクレジットは、事実のとおりに書いた。\nこのシステムは、全体が自作ではない。\nMicrosoft BASIC を同梱していることを、起動バナーと `NOTICE.md` に書いている。\n\n## 配布: 公証した macOS 版と、iPad 版の準備\n\nmacOS 版は、Developer ID で署名して、Apple の公証を通し、zip にして GitHub の Releases に置いた。\n配布用の `.app` は、`swift build` の成果物を手で組み立てて作っている。\n日本語と英語の切り替えは、`Localizable.strings` で、システムの言語に従う。\nパネルのラベル(POWER、STEP など)は、実機の印刷を模して、英語のままにした。\n\niPad 版は、パネルと端末の SwiftUI のコードを、そのまま共有している。\nXcodeGen でプロジェクトを作った。ファイルの選択、クリップボード、画面のキーボードからの入力、画面の縮小は、iPad 向けに足したり置き換えたりしている。\nパネルは、画面の幅に合わせて縮める作りなので、iPhone は対象にしていない。\niPad 版は、TestFlight のテスト版として公開している(リンクは [README](https://github.com/Masakai/Glow65#download) にある)。\n\n## できていないこと\n\nダミーリードとダミーライトは、再現していない。\nそのため、実機のバスには現れるアクセスが、パネルには出ない命令がある。\n割り込みを受け付けるときのバスアクセスは 5 回で、実機の 7 サイクルのうち、ダミーリードの分が抜ける。\nBASIC は、`GET`、`WAIT`、`ON GOTO` などを、網羅的には確かめていない。\n実機の 6502 との突き合わせも、していない。\nBASIC の `SAVE` と `LOAD` はなく、GAME65G にも `LOAD` と `SAVE` がない。\nその代わりに、テキストで書いたプログラムを、端末へ送る入口を用意した。\nアプリでは、クリップボードの貼り付けと、ファイルの送信(File → Send Text File…)で、BASIC や GAME65G のプログラムを入れる。\nコマンドラインの `glow65-cli` では、`--send-text` でファイルを送る。\niPad 版は、横向きの表示と、「ファイル」アプリからの読み込みを、実機で確かめていない。\n\n## 数日で形になった理由\n\n設計書の草案を書いたのが 10 月 11 日で、最初のコミットが 12 日である。\n15 日には、macOS 版の公証済みの配布物と、日英の PDF ができていた。\n速かったのは、「何を作るか」を人が決め、「できたものが正しいか」を機械が確かめる、という分担にしたからだと考えている。\n\n実装、図の生成、表の生成、翻訳の下書きは、Claude Code に任せた。\n任せたものは、テストとスクリプトで、機械的に確かめられる形にした。\nKlaus のテストや 199 個のテスト、表を生成して照合するスクリプトがあるので、出力の正否を、読まずに機械で判定できた。\n他の進め方とは、比べていない。\n一方で、権利の扱い(実機の ROM を使わない、既存の移植を使わない)、公開の範囲、教材の書き方の方針は、人が決めた。\n\n設計書は、決めたこと、確認したこと、確認できていないことを、日付つきで足していった。\nこの記事の数字も、その設計書とリポジトリから拾っている。\n\n最後に、冒頭の問いに戻る。\nGlow65 を起動して、SPEED を 1 Hz に落とし、チュートリアルの 11 バイトを読み込んで `g 0300` と打つ。\nSTEP を押すたびに、`$0300` を読む、`$0301` を読む、と、バスアクセスが 1 回ずつ LED に出る。\nリポジトリとランディングページは、冒頭のリンクから見られる。\n6502 の動きを LED で見たい人や、CPU の教材を探している人に、使ってもらえれば嬉しい。\n","coediting":false,"comments_count":5,"created_at":"2026-10-15T14:28:50+09:00","group":null,"id":"9e147c4226c6f949563f","likes_count":1,"private":false,"reactions_count":0,"stocks_count":2,"tags":[{"name":"Swift","versions":[]},{"name":"6502","versions":[]},{"name":"エミュレータ","versions":[]},{"name":"SwiftUI","versions":[]},{"name":"ClaudeCode","versions":[]}],"title":"CPU を遅くすれば、中が見える。Swift で 6502 のエミュレータ Glow65 を作り、教材まで公開した","updated_at":"2026-10-16T14:12:24+09:00","url":"https://qiita.com/masakai/items/9e147c4226c6f949563f","user":{"description":"中小製造業むけのITコンサルティング会社を経営しております。\r\nコンサルティングのかたわら、お客様向けの業務システムの開発もやっております。\r\nhttps://apps.genbaflow.comというデジタル日報管理システム（クラウドサービス）の開発・運営もやっております。\r\n\r\nフライフィッシングが趣味でしたが、昨今は月光天文台で観望会のボランティアもやっております。","facebook_id":"masanori.sakai/","followees_count":9,"followers_count":62,"github_login_name":"Masakai","id":"masakai","items_count":37,"linkedin_id":"","location":"静岡県三島市","name":"坂井 正徳","organization":"株式会社　リバーランズ・コンサルティング","permanent_id":87948,"profile_image_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/87948/24dd3a25edcd0123962baba48ecf3ef87971108b/x_large.png?1772982924","team_only":false,"twitter_screen_name":"masakai","website_url":"https://riverruns-consulting.com"},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003cp data-sourcepos=\"1:1-1:126\"\u003e以前、\u003ca href=\"https://qiita.com/mitsuharu_e/items/c2f7893a2c974dd5fc77\" id=\"reference-51651af8b420599ced25\"\u003eRedux Saga for Swift\u003c/a\u003e という記事を書きました。\u003c/p\u003e\n\u003cp data-sourcepos=\"3:1-3:56\"\u003e\u003ciframe id=\"qiita-embed-content__67b1810315383517dec8d2e7ce9ab341\" src=\"https://qiita.com/embed-contents/link-card#qiita-embed-content__67b1810315383517dec8d2e7ce9ab341\" data-content=\"https%3A%2F%2Fqiita.com%2Fmitsuharu_e%2Fitems%2Fc2f7893a2c974dd5fc77\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"width:100%;\" height=\"29\"\u003e\n\u003c/iframe\u003e\n\u003c/p\u003e\n\u003cp data-sourcepos=\"5:1-5:338\"\u003eその記事で、ReSwift\u003csup\u003e\u003ca href=\"#fn-reswift\" id=\"fnref-reswift\"\u003e1\u003c/a\u003e\u003c/sup\u003e の上で動く Redux Saga の自作ライブラリ ReSwift-Saga\u003csup\u003e\u003ca href=\"#fn-reswift-saga\" id=\"fnref-reswift-saga\"\u003e2\u003c/a\u003e\u003c/sup\u003e を紹介しました。そのライブラリは機能も少なく、エラー処理やテストも今後の課題として残したままでした。やり残しを抱えたまま、気づけば Swift 6 の時代です。\u003c/p\u003e\n\u003cp data-sourcepos=\"7:1-7:388\"\u003eSwift 6 では、非同期処理まわりが大きく強化されました。async / await と構造化並行性で、非同期の処理を上から順に書けて、キャンセルも親から子へ伝わります。さらに strict concurrency によって、スレッドをまたいで受け渡す値が Sendable かどうかをコンパイラが検査するようになりました。\u003c/p\u003e\n\u003cp data-sourcepos=\"9:1-9:308\"\u003eSaga がしていることを並べてみると、Action を待ち、処理をして、結果を Action で返し、ときには処理をキャンセルしたり並行に動かしたりします。受け渡すのは値としての Action と State です。どれも Swift 6 が得意とする領域です。\u003c/p\u003e\n\u003cp data-sourcepos=\"11:1-11:471\"\u003eというわけで、Redux 本体も含めて Swift 6 向けにまるごと作り直したのが swift-redux-saga\u003csup\u003e\u003ca href=\"#fn-swift-redux-saga\" id=\"fnref-swift-redux-saga\"\u003e3\u003c/a\u003e\u003c/sup\u003e です。企画と設計は私、設計補助と主な実装は Claude Code、品質管理と実装少々は Codex の共作した個人の趣味ライブラリです。Swift 6 ならいいものが作れそうと思って、AI で実現させました。今は 0.2.1 を公開していて、Swift Package Manager で導入できます。\u003c/p\u003e\n\u003cp data-sourcepos=\"13:1-13:45\"\u003e\u003ciframe id=\"qiita-embed-content__08fca4801a51ebbe36f112e13f03e095\" src=\"https://qiita.com/embed-contents/link-card#qiita-embed-content__08fca4801a51ebbe36f112e13f03e095\" data-content=\"https%3A%2F%2Fgithub.com%2Fmitsuharu%2Fswift-redux-saga\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"width:100%;\" height=\"29\"\u003e\n\u003c/iframe\u003e\n\u003c/p\u003e\n\u003cp data-sourcepos=\"15:1-15:346\"\u003eこの記事では、Redux と Redux Saga のおさらいをしてから、なぜ今 Swift で Redux なのか、他のライブラリはどういう状況なのかを整理し、swift-redux-saga の使い方を紹介します。ライブラリの内部実装の話はしませんが、興味あれば、ソースコードを見てください。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"17:1-17:33\"\u003e\n\u003cspan id=\"redux-そして-redux-saga-へ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#redux-%E3%81%9D%E3%81%97%E3%81%A6-redux-saga-%E3%81%B8\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eRedux そして Redux Saga へ\u003c/h2\u003e\n\u003cp data-sourcepos=\"19:1-19:76\"\u003e簡単に Redux というアーキテクチャについて説明します。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"21:1-21:9\"\u003e\n\u003cspan id=\"redux\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#redux\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eRedux\u003c/h3\u003e\n\u003cp data-sourcepos=\"23:1-23:295\"\u003eRedux\u003csup\u003e\u003ca href=\"#fn-redux\" id=\"fnref-redux\"\u003e4\u003c/a\u003e\u003c/sup\u003e は、JavaScript で生まれた状態管理のための予測可能な状態コンテナです。アプリの状態を 1 か所にまとめ、状態の変更を決まった経路に限定することで、状態がいつ、なぜ変わったのかを追えるようにします。\u003c/p\u003e\n\u003cp data-sourcepos=\"25:1-25:39\"\u003e登場するのは次の部品です。\u003c/p\u003e\n\u003cul data-sourcepos=\"27:1-37:0\"\u003e\n\u003cli data-sourcepos=\"27:1-29:70\"\u003eState\n\u003cul data-sourcepos=\"28:3-29:70\"\u003e\n\u003cli data-sourcepos=\"28:3-28:40\"\u003eアプリの状態を表す値です\u003c/li\u003e\n\u003cli data-sourcepos=\"29:3-29:70\"\u003e画面に表示するデータはすべてここから読みます\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"30:1-31:100\"\u003eAction\n\u003cul data-sourcepos=\"31:3-31:100\"\u003e\n\u003cli data-sourcepos=\"31:3-31:100\"\u003e「ボタンが押された」「データが届いた」のような出来事を表す値です\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"32:1-34:49\"\u003eReducer\n\u003cul data-sourcepos=\"33:3-34:49\"\u003e\n\u003cli data-sourcepos=\"33:3-33:68\"\u003e今の State と Action から次の State を作る関数です\u003c/li\u003e\n\u003cli data-sourcepos=\"34:3-34:49\"\u003e副作用（通信など）は持ちません\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"35:1-37:0\"\u003eStore\n\u003cul data-sourcepos=\"36:3-37:0\"\u003e\n\u003cli data-sourcepos=\"36:3-37:0\"\u003eState を 1 つだけ持ち、受け取った Action を Reducer に通して State を更新します\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"38:1-38:474\"\u003eView は State を読んで表示し、ユーザーの操作を Action として Store に送ります（dispatch）。State を書き換えられるのは Reducer だけなので、State の変化はすべて Action の列で説明できます。データが一方向にしか流れないことが、Redux のいちばん大事な性質です。開発において重要な考えである Single source of truth（信頼できる唯一の情報源）が実現されます。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"40:1-40:17\"\u003e\n\u003cspan id=\"redux-toolkit\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#redux-toolkit\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eRedux Toolkit\u003c/h3\u003e\n\u003cp data-sourcepos=\"42:1-42:388\"\u003e素の Redux で書くと、Action の型の定数、Action を作る関数、Reducer の \u003ccode\u003eswitch\u003c/code\u003e 文と、同じ機能のためのコードがあちこちに分かれ、量も増えがちでした。この手間を減らすために Redux 公式が出しているのが Redux Toolkit\u003csup\u003e\u003ca href=\"#fn-rtk\" id=\"fnref-rtk\"\u003e5\u003c/a\u003e\u003c/sup\u003e（RTK）で、今は Redux のコードを書くときの公式の推奨になっています。\u003c/p\u003e\n\u003cp data-sourcepos=\"44:1-44:511\"\u003e中心になるのは \u003ccode\u003ecreateSlice\u003c/code\u003e です。1 つの機能の State の初期値と、Action ごとの State の更新処理をまとめて書くと、Reducer と Action を作る関数がいっしょに生成されます。ほかにも、State から表示用の値を計算して結果をキャッシュする \u003ccode\u003ecreateSelector\u003c/code\u003e、ID で引ける一覧を扱う \u003ccode\u003ecreateEntityAdapter\u003c/code\u003e などがそろっています。swift-redux-saga の Redux 本体は、この Redux Toolkit を手本にしたものです。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"46:1-46:14\"\u003e\n\u003cspan id=\"redux-saga\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#redux-saga\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eRedux Saga\u003c/h3\u003e\n\u003cp data-sourcepos=\"48:1-48:308\"\u003eRedux はシンプルである一方、通信のような副作用をどこに書くのかは決めていません。Reducer は純粋な関数でなければならないので通信はできませんし、View で通信してから結果を dispatch すると、ロジックが View に散らばります。\u003c/p\u003e\n\u003cp data-sourcepos=\"50:1-50:470\"\u003eそこで登場するのが Redux Saga です。Saga はアプリの中で副作用を個別に実行する独立したスレッドのようなイメージです。Saga は Store の外側で Action を待ち受け、Action が届いたら通信などの処理をして、結果を新しい Action として Store に戻します。View は Action を送るだけ、Reducer は State を作るだけ、Saga は副作用だけ、と役割がきれいに分かれます。\u003c/p\u003e\n\u003cp data-sourcepos=\"52:1-52:275\"\u003e一般的に副作用の実装はアーキテクチャにロックインされてしまいます。しかしながら、Redux Saga は副作用を関数で定義するので、他のアーキテクチャへの移植性は比較的高いというメリットもあります。\u003c/p\u003e\n\u003cp data-sourcepos=\"54:1-54:310\"\u003e\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fraw.githubusercontent.com%2Fmitsuharu%2Fswift-redux-saga%2Fmain%2Fdocs%2Fimages%2Fflow.svg?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=be427f96639e056c3c883ef0bc13f4f8\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fraw.githubusercontent.com%2Fmitsuharu%2Fswift-redux-saga%2Fmain%2Fdocs%2Fimages%2Fflow.svg?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=be427f96639e056c3c883ef0bc13f4f8\" alt=\"Action が Middleware と Reducer を通って Store の State になり、View が State を表示する。Saga は Middleware から Action を受け取り、API などを呼んで、結果の Action を put で戻す\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fraw.githubusercontent.com%2Fmitsuharu%2Fswift-redux-saga%2Fmain%2Fdocs%2Fimages%2Fflow.svg?ixlib=rb-4.1.1\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=224f5726806ed6d1bd4360cc53832c37 1x\" data-canonical-src=\"https://raw.githubusercontent.com/mitsuharu/swift-redux-saga/main/docs/images/flow.svg\" loading=\"lazy\"\u003e\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"56:1-56:234\"\u003e実線が Redux の通常の流れで、Action は一直線に Reducer まで進みます。点線が Saga の流れで、Saga は通常の流れの外側で Action を受け取り、処理の結果を流れの先頭に戻します。\u003c/p\u003e\n\u003cp data-sourcepos=\"58:1-58:593\"\u003eSaga の中では、\u003ccode\u003etake\u003c/code\u003e（Action を待つ）、\u003ccode\u003eput\u003c/code\u003e（Action を発行する）、\u003ccode\u003ecall\u003c/code\u003e（関数を呼ぶ）、\u003ccode\u003efork\u003c/code\u003e（別の Saga を並行して動かす）といった命令を組み合わせて処理を書き、これらの命令は Effect と呼ばれています。よく使う組み合わせはヘルパーとして用意されていて、たとえば \u003ccode\u003etakeLatest\u003c/code\u003e は、Action が届くたびに処理を始め、前の処理が終わっていなければキャンセルします。検索欄の入力のように、古い結果が要らなくなる処理を短く書けます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"60:1-60:38\"\u003e\n\u003cspan id=\"なぜ今-swift-で-redux-なのか\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AA%E3%81%9C%E4%BB%8A-swift-%E3%81%A7-redux-%E3%81%AA%E3%81%AE%E3%81%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eなぜ今 Swift で Redux なのか\u003c/h2\u003e\n\u003cp data-sourcepos=\"62:1-62:271\"\u003eSwift で Redux を使う試みは以前からあり、ReSwift はその代表です。それでも今あらためて作り直したのは、Swift の言語とフレームワークの側が、ここ数年で Redux と相性のよい形に変わってきたためです。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"64:1-64:21\"\u003e\n\u003cspan id=\"swift-concurrency\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#swift-concurrency\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eSwift Concurrency\u003c/h3\u003e\n\u003cp data-sourcepos=\"66:1-66:370\"\u003eredux-saga は JavaScript のジェネレーター関数（\u003ccode\u003efunction*\u003c/code\u003e と \u003ccode\u003eyield\u003c/code\u003e）を使って、非同期の処理を上から順に書けるようにしています。Swift にジェネレーターはありませんが、\u003ccode\u003easync\u003c/code\u003e / \u003ccode\u003eawait\u003c/code\u003e があります。\u003ccode\u003eyield take(...)\u003c/code\u003e を \u003ccode\u003etry await ctx.take(...)\u003c/code\u003e と書けば、ほぼ同じ感覚で読めるはずです。\u003c/p\u003e\n\u003cp data-sourcepos=\"68:1-68:295\"\u003eここで、Swift では AsyncStream の \u003ccode\u003econtinuation.yield(_:)\u003c/code\u003e のように \u003ccode\u003eyield\u003c/code\u003e という名前は出てきます。しかし、これは非同期のシーケンスに値を流すメソッドで、関数を途中で止めて値を返すジェネレーターの yield とは別物です。\u003c/p\u003e\n\u003cp data-sourcepos=\"70:1-70:341\"\u003eさらに、Swift の構造化並行性では、親のタスクをキャンセルすると子のタスクにもキャンセルが伝わります。\u003ccode\u003efork\u003c/code\u003e した Saga を親ごと止める、\u003ccode\u003etakeLatest\u003c/code\u003e で古い処理を止める、といった redux-saga のキャンセルの考え方を、言語の仕組みのまま実現できます。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"72:1-72:34\"\u003e\n\u003cspan id=\"swift-6-の-strict-concurrency\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#swift-6-%E3%81%AE-strict-concurrency\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eSwift 6 の strict concurrency\u003c/h3\u003e\n\u003cp data-sourcepos=\"74:1-74:259\"\u003eSwift 6\u003csup\u003e\u003ca href=\"#fn-swift6\" id=\"fnref-swift6\"\u003e6\u003c/a\u003e\u003c/sup\u003e からは、スレッドをまたいで受け渡す値が安全かどうか（\u003ccode\u003eSendable\u003c/code\u003e かどうか）をコンパイラが検査します。共有された可変の状態を持つ設計ほど、この検査を通すのに苦労します。\u003c/p\u003e\n\u003cp data-sourcepos=\"76:1-76:383\"\u003e一方、Redux の State は不変の値として受け渡され、変更は Reducer の中だけで起き、Action もただの値です。Swift の struct と enum で書けば、State と Action は自然に \u003ccode\u003eSendable\u003c/code\u003e になります。Redux の「値と純粋関数で状態を扱う」という考え方は、Swift 6 が求める書き方と同じ方向を向いています。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"78:1-78:27\"\u003e\n\u003cspan id=\"swiftui-と-observation\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#swiftui-%E3%81%A8-observation\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eSwiftUI と Observation\u003c/h3\u003e\n\u003cp data-sourcepos=\"80:1-80:263\"\u003eSwiftUI は、React と同じく「状態から画面を作る」宣言的な UI です。そして iOS 17 から使える Observation（\u003ccode\u003e@Observable\u003c/code\u003e）で、View が読んだプロパティが変わったときだけ再描画されるようになりました。\u003c/p\u003e\n\u003cp data-sourcepos=\"82:1-82:356\"\u003eReSwift の頃は、\u003ccode\u003eStoreSubscriber\u003c/code\u003e に準拠して購読し、\u003ccode\u003enewState(state:)\u003c/code\u003e で受け取り、同じ値なら無視する設定をして……と手間がかかりました。今は View の中で \u003ccode\u003estore.count\u003c/code\u003e と書くだけで済みます。UIKit でも、iOS 26 からは Observation による自動追跡が既定で有効になりました。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"84:1-84:39\"\u003e\n\u003cspan id=\"他のライブラリの開発状況\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BB%96%E3%81%AE%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E3%81%AE%E9%96%8B%E7%99%BA%E7%8A%B6%E6%B3%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e他のライブラリの開発状況\u003c/h2\u003e\n\u003cp data-sourcepos=\"86:1-86:85\"\u003e以下は 2026 年 10 月時点で、各リポジトリを確認した内容です。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"88:1-88:11\"\u003e\n\u003cspan id=\"reswift\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#reswift\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eReSwift\u003c/h3\u003e\n\u003cp data-sourcepos=\"90:1-90:636\"\u003eReSwift は、Swift で Redux を使うライブラリとして長く使われてきました。ただ、最新のリリースは 2023 年 1 月の 6.1.1 で、最後のコミットは 2024 年 4 月の README の更新です。\u003ccode\u003ePackage.swift\u003c/code\u003e の \u003ccode\u003eswift-tools-version\u003c/code\u003e は 5.7 のままで、ソースに \u003ccode\u003eSendable\u003c/code\u003e は出てきません。Swift 6 言語モードのアプリから使うと、Store や Action の受け渡しが安全であることを、使う側で保証しなければなりません。非同期処理の拡張である ReSwift-Thunk\u003csup\u003e\u003ca href=\"#fn-reswift-thunk\" id=\"fnref-reswift-thunk\"\u003e7\u003c/a\u003e\u003c/sup\u003e も、最新のリリースは 2020 年 10 月の 2.0.1 です。\u003c/p\u003e\n\u003cp data-sourcepos=\"92:1-92:136\"\u003e私の旧ライブラリ ReSwift-Saga は ReSwift の上で動いていたので、ReSwift ごと置き換えることにしました。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"94:1-94:31\"\u003e\n\u003cspan id=\"the-composable-architecture\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#the-composable-architecture\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eThe Composable Architecture\u003c/h3\u003e\n\u003cp data-sourcepos=\"96:1-96:216\"\u003eThe Composable Architecture\u003csup\u003e\u003ca href=\"#fn-tca\" id=\"fnref-tca\"\u003e8\u003c/a\u003e\u003c/sup\u003e（TCA）は、Point-Free が開発している、Redux の考え方を取り入れたアーキテクチャです。開発は活発で、2026 年 8 月に 1.26.2 が出ています。\u003c/p\u003e\n\u003cp data-sourcepos=\"98:1-98:478\"\u003eTCA は状態管理だけでなく、依存性の注入、画面遷移、テストまでを一体で提供するフレームワークで、swift-dependencies や swift-case-paths など Point-Free の多くのパッケージに依存しています。完成度は高いのですが、アプリの書き方全体を TCA に合わせることになります。副作用も TCA 独自の Effect で書くため、redux-saga とは書き方がだいぶ違うものになります。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"100:1-100:14\"\u003e\n\u003cspan id=\"reactorkit\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#reactorkit\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eReactorKit\u003c/h3\u003e\n\u003cp data-sourcepos=\"102:1-102:389\"\u003eReactorKit\u003csup\u003e\u003ca href=\"#fn-reactorkit\" id=\"fnref-reactorkit\"\u003e9\u003c/a\u003e\u003c/sup\u003e も、Action を受けて State を作る単方向の設計を取るライブラリです。2026 年もコミットは続いていますが、最新のリリースは 2022 年 1 月の 3.2.0 で、RxSwift に依存しています。Swift Concurrency と Observation を前提にした今の SwiftUI の書き方とは、組み合わせにくい面があります。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"104:1-104:29\"\u003e\n\u003cspan id=\"javascript-の-redux-saga\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#javascript-%E3%81%AE-redux-saga\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eJavaScript の redux-saga\u003c/h3\u003e\n\u003cp data-sourcepos=\"106:1-106:647\"\u003e本家の redux-saga\u003csup\u003e\u003ca href=\"#fn-redux-saga-repo\" id=\"fnref-redux-saga-repo\"\u003e10\u003c/a\u003e\u003c/sup\u003e も今もメンテナンスされていて、2026 年 7 月に 1.5.1 が出ています。ただし、Redux 公式のドキュメント\u003csup\u003e\u003ca href=\"#fn-side-effects\" id=\"fnref-side-effects\"\u003e11\u003c/a\u003e\u003c/sup\u003eは、データの取得には Saga ではなく RTK Query を、Action への反応には listener middleware を勧めていて、Saga は listener で足りないときに使うもの、という位置づけです。一方で、Saga は「バックグラウンドスレッドのような振る舞いや、debounce やキャンセルを必要とする、とても複雑な非同期のワークフロー」に向いている、とも書かれています。\u003c/p\u003e\n\u003cp data-sourcepos=\"108:1-108:483\"\u003e私もこの評価には納得していて、通信して結果を表示するだけの処理に Saga は大げさだと思います。それでも、ログインの間だけ同期を動かす、入力が止まってから検索する、保存の順番を守る、といった時間の絡む処理は、モバイルアプリでは日常的に出てくるものです。こうした処理を読みやすく書くために、Swift にも Saga があってよいと考えています。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"110:1-110:52\"\u003e\n\u003cspan id=\"swift-6-対応の-redux-と-saga-はなかった\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#swift-6-%E5%AF%BE%E5%BF%9C%E3%81%AE-redux-%E3%81%A8-saga-%E3%81%AF%E3%81%AA%E3%81%8B%E3%81%A3%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eSwift 6 対応の Redux と Saga はなかった\u003c/h3\u003e\n\u003cp data-sourcepos=\"112:1-112:242\"\u003eSwift 6 にきちんと対応した軽い Redux と、redux-saga と同じ書き方で副作用を書ける仕組みの両方を満たすライブラリは、見つかりませんでした。それなら自分で作るしかありません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"114:1-114:32\"\u003e\n\u003cspan id=\"swift-redux-saga-を使おう\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#swift-redux-saga-%E3%82%92%E4%BD%BF%E3%81%8A%E3%81%86\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eswift-redux-saga を使おう\u003c/h2\u003e\n\u003cp data-sourcepos=\"116:1-116:445\"\u003eswift-redux-saga は、Swift 6 言語モードで警告なしにビルドできる Redux と Redux Saga の Swift Package です。Redux 本体は前に紹介した Redux Toolkit を参考にしていて、\u003ccode\u003ecreateSlice\u003c/code\u003e や \u003ccode\u003ecreateSelector\u003c/code\u003e に当たる機能もあります。マクロ用の swift-syntax を除いて外部のライブラリには依存していません。動作環境は Swift 6.2（Xcode 26）以降、iOS 17 以降です。\u003c/p\u003e\n\u003cp data-sourcepos=\"118:1-118:95\"\u003e導入は \u003ccode\u003ePackage.swift\u003c/code\u003e か、Xcode の File \u0026gt; Add Package Dependencies から行います。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"120:1-124:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"nv\"\u003edependencies\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n  \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003epackage\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eurl\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"https://github.com/mitsuharu/swift-redux-saga.git\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003efrom\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"0.2.1\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"126:1-126:207\"\u003eRedux と Saga を使うなら \u003ccode\u003eReduxSaga\u003c/code\u003e を、SwiftUI の補助も使うなら \u003ccode\u003eReduxSwiftUI\u003c/code\u003e をターゲットに追加します。Saga だけを他の状態管理と組み合わせても使えます。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"128:1-128:70\"\u003e\n\u003cspan id=\"アーキテクチャにロックインされないように作る\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%A2%E3%83%BC%E3%82%AD%E3%83%86%E3%82%AF%E3%83%81%E3%83%A3%E3%81%AB%E3%83%AD%E3%83%83%E3%82%AF%E3%82%A4%E3%83%B3%E3%81%95%E3%82%8C%E3%81%AA%E3%81%84%E3%82%88%E3%81%86%E3%81%AB%E4%BD%9C%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eアーキテクチャにロックインされないように作る\u003c/h3\u003e\n\u003cp data-sourcepos=\"130:1-130:707\"\u003eswift-redux-saga を作るときにいちばん意識したのは、アプリがこのライブラリにロックインされないことです。状態管理のライブラリは、一度アプリ全体に広げると簡単には外せません。メンテナンスが止まったり、Swift の大きな変更に追いつけなくなったりすると、アプリごと身動きが取れなくなります。ReSwift の上に ReSwift-Saga を載せていた私自身が、Swift 6 を前にしてまさにその状況になりました。旧ライブラリの記事でもアーキテクチャとの接点を小さくする方針を書きましたが、今回はそれを設計の前提に据えました。\u003c/p\u003e\n\u003cp data-sourcepos=\"132:1-132:637\"\u003eそのために、通信や保存などのビジネスロジック（UseCase や Repository）は、このライブラリを import せずに書けるようにしました。Saga は「Action を受けて、ビジネスロジックを呼び、結果を Action で返す」だけの薄い層にとどめます。Saga から呼ぶ \u003ccode\u003ecall\u003c/code\u003e は、Action を受け取る関数に限らず、どんな async 関数でも呼べます。ビジネスロジックが必要とする依存は、Saga を作るときに引数で渡す形です。こうしておけば、ビジネスロジックの側は Action も State も知らずに済みます。\u003c/p\u003e\n\u003cp data-sourcepos=\"134:1-134:99\"\u003eサンプルアプリは、この方針に沿って 3 つのターゲットに分けています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"text\" data-sourcepos=\"136:1-140:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003eDomain       ライブラリに依存しない（Model / Repository / UseCase）\nAppFeature   Domain + swift-redux-saga（State / Action / Reducer / Saga）\nApp          SwiftUI や UIKit の画面と、Store の組み立て\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"142:1-142:534\"\u003eライブラリを入れ替えることになっても、書き直すのは \u003ccode\u003eAppFeature\u003c/code\u003e の State・Action・Reducer・Saga だけで、\u003ccode\u003eDomain\u003c/code\u003e はそのまま使えます。\u003ccode\u003eAppFeature\u003c/code\u003e は UI のフレームワークにも依存しないので、Saga や Reducer のテストはアプリを起動せずに実行できます。小さなアプリなら 1 つのターゲットにまとめてもかまいません。それでも分けておけば、ロジックが View に紛れ込みにくくなるという利点もあります。\u003c/p\u003e\n\u003cp data-sourcepos=\"144:1-144:380\"\u003eライブラリ自体も、使う範囲を選べるように分けています。Saga は Redux 本体に依存しておらず、\u003ccode\u003eSaga\u003c/code\u003e のプロダクトだけを追加すれば、他の状態管理と組み合わせて使えます。画面側も、Store を直接読んでもよいし、MVVM の ViewModel を挟んでもよく、どちらかに決め打ちしていません。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"146:1-146:28\"\u003e\n\u003cspan id=\"カウンターを作る\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%AB%E3%82%A6%E3%83%B3%E3%82%BF%E3%83%BC%E3%82%92%E4%BD%9C%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eカウンターを作る\u003c/h3\u003e\n\u003cp data-sourcepos=\"148:1-148:144\"\u003eまずは定番の、ボタンでカウントを増やし、「読み込み」で値をサーバーから取ってくるカウンターです。\u003c/p\u003e\n\u003cp data-sourcepos=\"150:1-150:141\"\u003eState と Action と Reducer は、\u003ccode\u003eSlice\u003c/code\u003e という入れ物にまとめます。Redux Toolkit の \u003ccode\u003ecreateSlice\u003c/code\u003e に当たるものです。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"152:1-179:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eRedux\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003eenum\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eSlice\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eSendable\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eEquatable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n    \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eisLoading\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n  \u003cspan class=\"kd\"\u003eenum\u003c/span\u003e \u003cspan class=\"kt\"\u003eAction\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eSendable\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eEquatable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"n\"\u003eincrement\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"n\"\u003efetch\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"nf\"\u003efetched\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eInt\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n  \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003einitialState\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\n  \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003ereduce\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003einto\u003c/span\u003e \u003cspan class=\"nv\"\u003estate\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003einout\u003c/span\u003e \u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eaction\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eAction\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eswitch\u003c/span\u003e \u003cspan class=\"n\"\u003eaction\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003estate\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003efetch\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003estate\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisLoading\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003efetched\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e):\u003c/span\u003e\n      \u003cspan class=\"n\"\u003estate\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisLoading\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n      \u003cspan class=\"n\"\u003estate\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003evalue\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"181:1-181:442\"\u003eJavaScript の Reducer は新しい State を返しますが、ここでは \u003ccode\u003einout\u003c/code\u003e で受け取った State を直接書き換えます。Swift の struct は値型なので、書き換えても呼び出し元の State が勝手に変わることはなく、Reducer は純粋な関数のままです。Action を enum にしておけば、\u003ccode\u003eswitch\u003c/code\u003e で扱い忘れた case がコンパイルエラーになるのも嬉しいところです。\u003c/p\u003e\n\u003cp data-sourcepos=\"183:1-183:397\"\u003eReSwift を使ったことがある方は、ここで少し書き方が違うと感じるかもしれません。swift-redux-saga の Redux 本体は ReSwift ではなく Redux Toolkit を手本にしているためです。ReSwift では、Action は \u003ccode\u003eAction\u003c/code\u003e プロトコルに準拠した struct で、Reducer は Optional の State を受け取って新しい State を返す関数でした。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"185:1-197:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e// ReSwift の書き方\u003c/span\u003e\n\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eIncrementCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eAction\u003c/span\u003e \u003cspan class=\"p\"\u003e{}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003ecounterReducer\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eaction\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eAction\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003estate\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounterState\u003c/span\u003e\u003cspan class=\"p\"\u003e?)\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounterState\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003estate\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003estate\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounterState\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n  \u003cspan class=\"k\"\u003eswitch\u003c/span\u003e \u003cspan class=\"n\"\u003eaction\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"k\"\u003eas\u003c/span\u003e \u003cspan class=\"kt\"\u003eIncrementCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003estate\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n  \u003cspan class=\"k\"\u003edefault\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003ebreak\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003estate\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"199:1-199:774\"\u003eswift-redux-saga では、Action を enum にし、機能ごとの State・Action・Reducer を \u003ccode\u003eSlice\u003c/code\u003e にまとめます。初期値は \u003ccode\u003einitialState\u003c/code\u003e として別に持つので、Reducer が Optional の State を扱うこともありません。複数の Slice は \u003ccode\u003eReducer.slice\u003c/code\u003e でアプリ全体の Reducer にまとめます。Redux Toolkit の \u003ccode\u003ecreateSlice\u003c/code\u003e を使ったことがあれば、こちらのほうが見慣れた形だと思います。State の変化の受け取り方も違い、ReSwift の \u003ccode\u003eStoreSubscriber\u003c/code\u003e の代わりに、後で紹介する Observation で読むだけです。ReSwift からの書き換え方は、\u003ca href=\"https://github.com/mitsuharu/swift-redux-saga/blob/0.2.1/docs/migration.md\" rel=\"nofollow noopener\" target=\"_blank\"\u003e移行ガイド\u003c/a\u003eに対応表を載せています。\u003c/p\u003e\n\u003cp data-sourcepos=\"201:1-201:215\"\u003e次に Saga を書きます。前の節で書いたとおり、通信の処理そのものはライブラリを import しない普通の Swift のコードとして用意し、Saga を作るときに渡します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"203:1-213:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e// ライブラリを import しない\u003c/span\u003e\n\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounterRepository\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eSendable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eload\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kd\"\u003e@Sendable\u003c/span\u003e \u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eInt\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003eextension\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounterRepository\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"c1\"\u003e// 本番の実装（fetchCountFromServer はアプリの通信処理）。テストでは別の実装を渡す。\u003c/span\u003e\n  \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elive\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounterRepository\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"nf\"\u003efetchCountFromServer\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"215:1-234:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eSaga\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounterSagas\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eSendable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003erepository\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounterRepository\u003c/span\u003e\n\n  \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eroot\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eSaga\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eAction\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kt\"\u003eSaga\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"counter\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n      \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etakeLatest\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eaction\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efetch\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n        \u003cspan class=\"k\"\u003edo\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n          \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecall\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003erepository\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eload\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n          \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eput\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003efetched\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003ecatch\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n          \u003cspan class=\"c1\"\u003e// 失敗も Action にして返す\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n      \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"236:1-236:424\"\u003eEffect は \u003ccode\u003ectx\u003c/code\u003e のメソッドとして呼び、redux-saga で \u003ccode\u003eyield\u003c/code\u003e を書く場所に \u003ccode\u003eawait\u003c/code\u003e を書きます。この Saga は \u003ccode\u003e.fetch\u003c/code\u003e が届くたびに通信し、結果を \u003ccode\u003e.fetched\u003c/code\u003e として Store に戻すものです。通信中にもう一度 \u003ccode\u003e.fetch\u003c/code\u003e が届けば、\u003ccode\u003etakeLatest\u003c/code\u003e が前の通信をキャンセルします。redux-saga の \u003ccode\u003etakeLatest(FETCH, fetchCount)\u003c/code\u003e とほぼ同じ見た目ですね。\u003c/p\u003e\n\u003cp data-sourcepos=\"238:1-238:120\"\u003eStore は、Saga を Store につなぐ \u003ccode\u003eSagaMiddleware\u003c/code\u003e を渡して作り、作った後で Saga を起動します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"240:1-254:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eReduxSaga\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@MainActor\u003c/span\u003e\n\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003emakeStore\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eStore\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eAction\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esagaMiddleware\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eSagaMiddleware\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eAction\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n  \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003estore\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eStore\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003einitialState\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003einitialState\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003ereducer\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ereducer\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003emiddleware\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003esagaMiddleware\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e)\u003c/span\u003e\n  \u003cspan class=\"n\"\u003esagaMiddleware\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003erun\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eCounterSagas\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003erepository\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elive\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eroot\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n  \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003estore\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"256:1-256:100\"\u003eSwiftUI の View は、State のプロパティを読み、Action を dispatch するだけです。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"258:1-273:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eReduxSwiftUI\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounterView\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eView\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"kd\"\u003e@Environment\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eStore\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eAction\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;.\u003c/span\u003e\u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003estore\u003c/span\u003e\n\n  \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kd\"\u003esome\u003c/span\u003e \u003cspan class=\"kt\"\u003eView\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kt\"\u003eVStack\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n      \u003cspan class=\"kt\"\u003eText\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"\u003c/span\u003e\u003cspan class=\"se\"\u003e\\(\u003c/span\u003e\u003cspan class=\"n\"\u003estore\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e\u003cspan class=\"se\"\u003e)\u003c/span\u003e\u003cspan class=\"s\"\u003e\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n      \u003cspan class=\"kt\"\u003eButton\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"+1\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003estore\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003edispatch\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n      \u003cspan class=\"kt\"\u003eButton\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"読み込み\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003estore\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003edispatch\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003efetch\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003edisabled\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003estore\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisLoading\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"275:1-275:247\"\u003e\u003ccode\u003estore.count\u003c/code\u003e を読んだ View は、\u003ccode\u003ecount\u003c/code\u003e が変わったときだけ再描画されます。購読の登録や解除は書きません。ReSwift の \u003ccode\u003eStoreSubscriber\u003c/code\u003e を書いていた頃と比べると、ずいぶん短くなりました。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"277:1-277:46\"\u003e\n\u003cspan id=\"ログインと-todo-を組み合わせる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%AD%E3%82%B0%E3%82%A4%E3%83%B3%E3%81%A8-todo-%E3%82%92%E7%B5%84%E3%81%BF%E5%90%88%E3%82%8F%E3%81%9B%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eログインと ToDo を組み合わせる\u003c/h3\u003e\n\u003cp data-sourcepos=\"279:1-279:220\"\u003eリポジトリの \u003ccode\u003eExamples/\u003c/code\u003e には、ログインと ToDo の 2 つの機能を持つサンプルアプリが、SwiftUI 版と UIKit 版の両方で入っています。ここからはその中身を紹介します。\u003c/p\u003e\n\u003cp data-sourcepos=\"281:1-281:176\"\u003eログインの Saga は次のとおりです。ログインのボタンが押されたら通信し、成功すれば \u003ccode\u003e.loggedIn\u003c/code\u003e、失敗すれば \u003ccode\u003e.failed\u003c/code\u003e を返します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"283:1-300:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eroot\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eSaga\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e\u003cspan class=\"kt\"\u003eAuthFeature\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eAuthFeature\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eAction\u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"kt\"\u003eSaga\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"auth\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n    \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etakeLeading\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003ecase\u003c/span\u003e\u003cspan class=\"p\"\u003e(\\\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eloginTapped\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003ename\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n      \u003cspan class=\"k\"\u003edo\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003euser\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecall\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003euseCase\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elogin\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n          \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eput\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003efailed\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"Enter your name.\"\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n          \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eput\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eloggedIn\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003euser\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n      \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003ecatch\u003c/span\u003e \u003cspan class=\"k\"\u003eis\u003c/span\u003e \u003cspan class=\"kt\"\u003eCancellationError\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n      \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003ecatch\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eput\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003efailed\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eerror\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003elocalizedDescription\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n      \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"302:1-302:428\"\u003e\u003ccode\u003etakeLeading\u003c/code\u003e は、処理中に届いた同じ Action を無視するヘルパーで、ボタンの連打対策に使っています。\u003ccode\u003e.case(\\.loginTapped)\u003c/code\u003e は、\u003ccode\u003eloginTapped(name:)\u003c/code\u003e という case のときだけ反応して、関連値の \u003ccode\u003ename\u003c/code\u003e を取り出して渡す、という意味です。enum の case から値を取り出すコードは、\u003ccode\u003e@Slice\u003c/code\u003e や \u003ccode\u003e@ActionCases\u003c/code\u003e というマクロで生成しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"304:1-304:340\"\u003eアプリ全体の State は、機能ごとの State をプロパティとして並べただけの struct です。ログインの Saga はログインの State と Action の型のまま書き、親につなぐときに型を付け替えます。そのため、機能ごとのコードはアプリ全体の型を知らずに書けます。\u003c/p\u003e\n\u003cp data-sourcepos=\"306:1-306:256\"\u003eSaga がいちばん役に立つのは、機能をまたぐ流れを書くときです。サンプルでは、ToDo の同期をログインしている間だけ動かしています。ルートの Saga を簡略化すると、次のようになります。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"308:1-315:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003ewhile\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etake\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003ecase\u003c/span\u003e\u003cspan class=\"p\"\u003e(\\\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eauth\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eloggedIn\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n  \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esession\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003efork\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003etodoSagas\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eroot\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003estate\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e\\\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etodo\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eaction\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e\\\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etodo\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eembed\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eAppAction\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etodo\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n  \u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etake\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003ecase\u003c/span\u003e\u003cspan class=\"p\"\u003e(\\\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eauth\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eloggedOut\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n  \u003cspan class=\"n\"\u003ectx\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecancel\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esession\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"317:1-317:490\"\u003eはい、無限ループです。ログインを待ち、ToDo の Saga を動かし、ログアウトを待って止める、という流れがそのままコードになっています。\u003ccode\u003ectx.cancel(session)\u003c/code\u003e で止めると、ToDo の Saga が実行中だった読み込みや保存もまとめてキャンセルされます。同じことを ViewModel のフラグや Combine の購読で書くと、どこで何を止めるかのコードがあちこちに散らばりがちでした。\u003c/p\u003e\n\u003cp data-sourcepos=\"319:1-319:397\"\u003eToDo の Saga では、検索語の入力が 300 ミリ秒止まってから反映する \u003ccode\u003edebounce\u003c/code\u003e や、保存の順番を守るために Action を一度溜めておく \u003ccode\u003eactionChannel\u003c/code\u003e も使っています。Effect の一覧は、redux-saga との対応表も含めて \u003ca href=\"https://github.com/mitsuharu/swift-redux-saga/blob/0.2.1/docs/saga-effects.md\" rel=\"nofollow noopener\" target=\"_blank\"\u003eSaga の Effect\u003c/a\u003e にまとめています。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"321:1-321:49\"\u003e\n\u003cspan id=\"swiftui-では-useselector-のように読む\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#swiftui-%E3%81%A7%E3%81%AF-useselector-%E3%81%AE%E3%82%88%E3%81%86%E3%81%AB%E8%AA%AD%E3%82%80\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eSwiftUI では useSelector のように読む\u003c/h3\u003e\n\u003cp data-sourcepos=\"323:1-323:380\"\u003eReact Redux を使ったことがある方なら、\u003ccode\u003euseSelector\u003c/code\u003e と \u003ccode\u003euseDispatch\u003c/code\u003e で、読みたい値と送る関数だけを宣言する書き方に馴染みがあると思います。swift-redux-saga では、\u003ccode\u003e@SelectState\u003c/code\u003e と \u003ccode\u003e@DispatchAction\u003c/code\u003e というプロパティラッパーで同じように書けます。次のコードはサンプルのログイン画面です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"325:1-343:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003estruct\u003c/span\u003e \u003cspan class=\"kt\"\u003eLoginView\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eView\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"kd\"\u003e@SelectState\u003c/span\u003e\u003cspan class=\"p\"\u003e(\\\u003c/span\u003e\u003cspan class=\"kt\"\u003eRootFeature\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eauth\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisLoggingIn\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eisLoggingIn\u003c/span\u003e\n  \u003cspan class=\"kd\"\u003e@SelectState\u003c/span\u003e\u003cspan class=\"p\"\u003e(\\\u003c/span\u003e\u003cspan class=\"kt\"\u003eRootFeature\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eState\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eauth\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eerrorMessage\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eerrorMessage\u003c/span\u003e\n  \u003cspan class=\"kd\"\u003e@DispatchAction\u003c/span\u003e \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003edispatch\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eRootFeature\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eAction\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eVoid\u003c/span\u003e\n  \u003cspan class=\"kd\"\u003e@State\u003c/span\u003e \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ename\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\"\u003c/span\u003e\n\n  \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ebody\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kd\"\u003esome\u003c/span\u003e \u003cspan class=\"kt\"\u003eView\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kt\"\u003eForm\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n      \u003cspan class=\"kt\"\u003eTextField\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"Name\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003etext\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003e$name\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n      \u003cspan class=\"kt\"\u003eButton\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"Log in\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nf\"\u003edispatch\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eauth\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eloginTapped\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e)))\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003edisabled\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eisLoggingIn\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n      \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eerrorMessage\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"kt\"\u003eText\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eerrorMessage\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eforegroundStyle\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ered\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n      \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"345:1-345:352\"\u003eView は Store の型を知らず、読む値と送る Action だけを知っています。\u003ccode\u003e@SelectState\u003c/code\u003e は読んだ値が変わったときだけ再描画するので、ToDo の一覧が更新されてもこの画面は描き直されません。入力中の名前は画面の中でしか使わないので、View の \u003ccode\u003e@State\u003c/code\u003e に置きました。\u003c/p\u003e\n\u003cp data-sourcepos=\"347:1-347:463\"\u003e画面の状態をすべて Store に入れる必要はありません。サンプルでは、設定画面のように Store の値を表示して操作するだけの画面は Store を直接使い、入力中の文字列など画面特有の状態がある ToDo の画面は MVVM を経由しています。ViewModel は Store の値の写しを持たず、計算プロパティで Store を読むので、データは Store の 1 か所にだけあります。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"349:1-349:37\"\u003e\n\u003cspan id=\"テストは結果を確かめる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%86%E3%82%B9%E3%83%88%E3%81%AF%E7%B5%90%E6%9E%9C%E3%82%92%E7%A2%BA%E3%81%8B%E3%82%81%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eテストは結果を確かめる\u003c/h3\u003e\n\u003cp data-sourcepos=\"351:1-351:531\"\u003eredux-saga では、ジェネレーターから Effect を 1 つずつ取り出して、期待どおりの Effect かを確かめるテストがよく書かれます。残念ながら Swift にジェネレーターはないので、この書き方はできません。代わりに swift-redux-saga では、Saga を実際に動かし、発行された Action と State の変化を確かめます。サンプルのテストから、ログインすると ToDo が読み込まれることを確かめる部分を抜き出します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"353:1-370:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003e@Test\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003etodosAreLoadedOnlyAfterLoggingIn\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003estore\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003emakeStore\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\n  \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003estore\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esend\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eauth\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eloginTapped\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"me\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)))\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eauth\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisLoggingIn\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"n\"\u003estore\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ereceive\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eauth\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eloggedIn\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eUser\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"me\"\u003c/span\u003e\u003cspan class=\"p\"\u003e))))\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eauth\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisLoggingIn\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eauth\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003euser\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eUser\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ename\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s\"\u003e\"me\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"n\"\u003estore\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ereceive\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etodo\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003erefresh\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etodo\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisLoading\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"n\"\u003estore\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ereceive\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etodo\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eloaded\u003c/span\u003e\u003cspan class=\"p\"\u003e([\u003c/span\u003e\u003cspan class=\"n\"\u003emilk\u003c/span\u003e\u003cspan class=\"p\"\u003e],\u003c/span\u003e \u003cspan class=\"nv\"\u003egeneration\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e)))\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etodo\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisLoading\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etodo\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etodos\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eids\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003emilk\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n    \u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etodo\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etodos\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eentities\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003emilk\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003emilk\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n  \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n  \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003estore\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003efinish\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"372:1-372:481\"\u003e\u003ccode\u003esend\u003c/code\u003e で Action を送り、\u003ccode\u003ereceive\u003c/code\u003e で Saga が発行した Action を順に確かめます。後ろのクロージャには、期待する State の変化を書きます。\u003ccode\u003eTestStore\u003c/code\u003e はすべての Saga が次の Effect で止まるまで待ってから確かめるので、\u003ccode\u003esleep\u003c/code\u003e で待つ必要がなく、マシンの速さでテストの結果が変わりません。\u003ccode\u003edebounce\u003c/code\u003e のような時間の絡む処理は、\u003ccode\u003eTestClock\u003c/code\u003e で時間を進めて確かめます。\u003c/p\u003e\n\u003cp data-sourcepos=\"374:1-374:433\"\u003e結果を確かめるテストは、Saga の中の書き方を変えても、振る舞いが同じなら壊れません。Redux 公式のドキュメントは、Saga のテストが実装の細部に依存しやすいことを欠点に挙げていますが、この書き方ならその心配は小さくなります。旧ライブラリでやり残していたテストも、これでようやく書けるようになりました。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"376:1-376:18\"\u003e\n\u003cspan id=\"評価と考察\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%A9%95%E4%BE%A1%E3%81%A8%E8%80%83%E5%AF%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e評価と考察\u003c/h2\u003e\n\u003cp data-sourcepos=\"378:1-378:646\"\u003eサンプルアプリを作ってみていちばんよかったのは、時間の絡む処理を読めるコードで書けたことです。ログイン中だけ同期を動かす処理も、入力が止まってから検索する処理も、Saga を開けば流れが上から順に書いてあります。ViewModel に書いていた頃のように、フラグやタスクの参照をいくつも持ち回る必要はありません。キャンセルが親から子へ伝わるので、止め忘れも起きにくくなりました。View の処理も Action を送るだけになり、とてもシンプルになって満足しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"380:1-380:467\"\u003eSwift らしく書けるのも気に入っています。Action を enum にすれば Reducer の扱い忘れはコンパイルエラーになり、State と Action は \u003ccode\u003eSendable\u003c/code\u003e な値なので Swift 6 の検査にも素直に通ります。\u003ccode\u003eyield\u003c/code\u003e を \u003ccode\u003eawait\u003c/code\u003e に置き換えただけの見た目なので、redux-saga を知っている人なら対応表を見ればすぐに読めますし、知らない人には普通の async 関数に見えるはずです。\u003c/p\u003e\n\u003cp data-sourcepos=\"382:1-382:343\"\u003eロックインを避ける構成も、実際に使ってみて安心感がありました。サンプルの \u003ccode\u003eDomain\u003c/code\u003e は swift-redux-saga を一度も import していない（import しているのは Foundation だけ）ので、このライブラリをやめる日が来ても、捨てるコードの範囲がはっきりしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"384:1-384:354\"\u003eただ、どのアプリにも勧められるわけではありません。Action を enum で定義し、Reducer と Saga に分けて書くので、画面が 1 つで通信も 1 つというアプリでは、コードが増えるだけです。Redux と Saga の考え方を知らない人には、慣れるまでの学習コストもかかります。\u003c/p\u003e\n\u003cp data-sourcepos=\"386:1-386:677\"\u003eXcode 26 の既定の設定にも注意が要ります。Xcode 26 で作ったアプリのターゲットでは default MainActor isolation が有効になっていて、型や関数が暗黙にメインアクターに属します。Saga はメインアクターの外で動くので、State や Action、Saga には \u003ccode\u003enonisolated\u003c/code\u003e を付けて宣言しなければなりません。付け忘れたときのエラーメッセージから原因を読み取りにくいので、README に例を載せました。State と Saga を UI とは別のターゲットに置けば、そのターゲットではこの設定を無効にでき、\u003ccode\u003enonisolated\u003c/code\u003e を書かずに済みます。\u003c/p\u003e\n\u003cp data-sourcepos=\"388:1-388:299\"\u003eTCA と比べると、swift-redux-saga は状態管理と副作用だけに絞った小さなライブラリです。画面遷移や依存性の注入は、アプリで好きな方法を選べます。逆に、そこまでまとめて面倒を見てほしいなら TCA の方が向いています。\u003c/p\u003e\n\u003cp data-sourcepos=\"390:1-390:288\"\u003e旧記事でも書いたとおり、このアーキテクチャはニッチだと自認しています。それでも、Redux ベースのアーキテクチャに興味がある方や、時間の絡む処理の書き方に悩んでいる方には、試す価値があると思います。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"392:1-392:12\"\u003e\n\u003cspan id=\"まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eまとめ\u003c/h2\u003e\n\u003cp data-sourcepos=\"394:1-394:390\"\u003e前回の記事では、ReSwift の上に Saga の考え方を取り入れるところまでで精一杯でした。今回は Swift Concurrency、Swift 6 の strict concurrency、Observation がそろったので、Redux 本体から作り直しました。\u003ccode\u003eput\u003c/code\u003e や \u003ccode\u003etakeLatest\u003c/code\u003e、エラー処理、テストまで用意して、ようやく redux-saga と呼べる形になりました。\u003c/p\u003e\n\u003cp data-sourcepos=\"396:1-396:483\"\u003eはじめての方は、カウンターのアプリを作りながら順に説明する\u003ca href=\"https://github.com/mitsuharu/swift-redux-saga/blob/0.2.1/docs/getting-started.md\" rel=\"nofollow noopener\" target=\"_blank\"\u003eはじめかた\u003c/a\u003eから読んでみてください。ReSwift や ReSwift-Saga を使っている方向けには、\u003ca href=\"https://github.com/mitsuharu/swift-redux-saga/blob/0.2.1/docs/migration.md\" rel=\"nofollow noopener\" target=\"_blank\"\u003e移行ガイド\u003c/a\u003eも用意しています。感想や不具合の報告は、GitHub の Issue でお待ちしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"398:1-398:130\"\u003eSwift で書く Redux Saga に、iOS アプリ開発者の方が少しでも興味を持っていただけたら嬉しいです。\u003c/p\u003e\n\u003csection class=\"footnotes\"\u003e\n\u003col\u003e\n\u003cli id=\"fn-reswift\"\u003e\n\u003cp data-sourcepos=\"401:13-401:57\"\u003e\u003ca href=\"https://github.com/ReSwift/ReSwift\" rel=\"nofollow noopener\" target=\"_blank\"\u003eReSwift\u003c/a\u003e \u003ca href=\"#fnref-reswift\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-reswift-saga\"\u003e\n\u003cp data-sourcepos=\"402:18-402:119\"\u003e\u003ca href=\"https://github.com/mitsuharu/ReSwift-Saga\" rel=\"nofollow noopener\" target=\"_blank\"\u003eReSwift-Saga\u003c/a\u003e（旧ライブラリ。現在は非推奨） \u003ca href=\"#fnref-reswift-saga\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-swift-redux-saga\"\u003e\n\u003cp data-sourcepos=\"403:22-403:86\"\u003e\u003ca href=\"https://github.com/mitsuharu/swift-redux-saga\" rel=\"nofollow noopener\" target=\"_blank\"\u003eswift-redux-saga\u003c/a\u003e \u003ca href=\"#fnref-swift-redux-saga\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-redux\"\u003e\n\u003cp data-sourcepos=\"404:11-404:40\"\u003e\u003ca href=\"https://redux.js.org/\" rel=\"nofollow noopener\" target=\"_blank\"\u003eRedux\u003c/a\u003e \u003ca href=\"#fnref-redux\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-rtk\"\u003e\n\u003cp data-sourcepos=\"412:9-412:54\"\u003e\u003ca href=\"https://redux-toolkit.js.org/\" rel=\"nofollow noopener\" target=\"_blank\"\u003eRedux Toolkit\u003c/a\u003e \u003ca href=\"#fnref-rtk\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-swift6\"\u003e\n\u003cp data-sourcepos=\"405:12-405:79\"\u003e\u003ca href=\"https://www.swift.org/blog/announcing-swift-6/\" rel=\"nofollow noopener\" target=\"_blank\"\u003eAnnouncing Swift 6\u003c/a\u003e \u003ca href=\"#fnref-swift6\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-reswift-thunk\"\u003e\n\u003cp data-sourcepos=\"406:19-406:75\"\u003e\u003ca href=\"https://github.com/ReSwift/ReSwift-Thunk\" rel=\"nofollow noopener\" target=\"_blank\"\u003eReSwift-Thunk\u003c/a\u003e \u003ca href=\"#fnref-reswift-thunk\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-tca\"\u003e\n\u003cp data-sourcepos=\"407:9-407:99\"\u003e\u003ca href=\"https://github.com/pointfreeco/swift-composable-architecture\" rel=\"nofollow noopener\" target=\"_blank\"\u003eThe Composable Architecture\u003c/a\u003e \u003ca href=\"#fnref-tca\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-reactorkit\"\u003e\n\u003cp data-sourcepos=\"409:16-409:69\"\u003e\u003ca href=\"https://github.com/ReactorKit/ReactorKit\" rel=\"nofollow noopener\" target=\"_blank\"\u003eReactorKit\u003c/a\u003e \u003ca href=\"#fnref-reactorkit\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-redux-saga-repo\"\u003e\n\u003cp data-sourcepos=\"410:21-410:86\"\u003e\u003ca href=\"https://github.com/redux-saga/redux-saga\" rel=\"nofollow noopener\" target=\"_blank\"\u003eredux-saga（GitHub）\u003c/a\u003e \u003ca href=\"#fnref-redux-saga-repo\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003cli id=\"fn-side-effects\"\u003e\n\u003cp data-sourcepos=\"411:18-411:101\"\u003e\u003ca href=\"https://redux.js.org/usage/side-effects-approaches\" rel=\"nofollow noopener\" target=\"_blank\"\u003eRedux: Side Effects Approaches\u003c/a\u003e \u003ca href=\"#fnref-side-effects\" class=\"\"\u003e↩\u003c/a\u003e\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003c/section\u003e\n","body":"以前、[Redux Saga for Swift](https://qiita.com/mitsuharu_e/items/c2f7893a2c974dd5fc77) という記事を書きました。\n\nhttps://qiita.com/mitsuharu_e/items/c2f7893a2c974dd5fc77\n\nその記事で、ReSwift[^reswift] の上で動く Redux Saga の自作ライブラリ ReSwift-Saga[^reswift-saga] を紹介しました。そのライブラリは機能も少なく、エラー処理やテストも今後の課題として残したままでした。やり残しを抱えたまま、気づけば Swift 6 の時代です。\n\nSwift 6 では、非同期処理まわりが大きく強化されました。async / await と構造化並行性で、非同期の処理を上から順に書けて、キャンセルも親から子へ伝わります。さらに strict concurrency によって、スレッドをまたいで受け渡す値が Sendable かどうかをコンパイラが検査するようになりました。\n\nSaga がしていることを並べてみると、Action を待ち、処理をして、結果を Action で返し、ときには処理をキャンセルしたり並行に動かしたりします。受け渡すのは値としての Action と State です。どれも Swift 6 が得意とする領域です。\n\nというわけで、Redux 本体も含めて Swift 6 向けにまるごと作り直したのが swift-redux-saga[^swift-redux-saga] です。企画と設計は私、設計補助と主な実装は Claude Code、品質管理と実装少々は Codex の共作した個人の趣味ライブラリです。Swift 6 ならいいものが作れそうと思って、AI で実現させました。今は 0.2.1 を公開していて、Swift Package Manager で導入できます。\n\nhttps://github.com/mitsuharu/swift-redux-saga\n\nこの記事では、Redux と Redux Saga のおさらいをしてから、なぜ今 Swift で Redux なのか、他のライブラリはどういう状況なのかを整理し、swift-redux-saga の使い方を紹介します。ライブラリの内部実装の話はしませんが、興味あれば、ソースコードを見てください。\n\n## Redux そして Redux Saga へ\n\n簡単に Redux というアーキテクチャについて説明します。\n\n### Redux\n\nRedux[^redux] は、JavaScript で生まれた状態管理のための予測可能な状態コンテナです。アプリの状態を 1 か所にまとめ、状態の変更を決まった経路に限定することで、状態がいつ、なぜ変わったのかを追えるようにします。\n\n登場するのは次の部品です。\n\n- State\n  - アプリの状態を表す値です\n  - 画面に表示するデータはすべてここから読みます\n- Action\n  - 「ボタンが押された」「データが届いた」のような出来事を表す値です\n- Reducer\n  - 今の State と Action から次の State を作る関数です\n  - 副作用（通信など）は持ちません\n- Store\n  - State を 1 つだけ持ち、受け取った Action を Reducer に通して State を更新します\n\nView は State を読んで表示し、ユーザーの操作を Action として Store に送ります（dispatch）。State を書き換えられるのは Reducer だけなので、State の変化はすべて Action の列で説明できます。データが一方向にしか流れないことが、Redux のいちばん大事な性質です。開発において重要な考えである Single source of truth（信頼できる唯一の情報源）が実現されます。\n\n### Redux Toolkit\n\n素の Redux で書くと、Action の型の定数、Action を作る関数、Reducer の `switch` 文と、同じ機能のためのコードがあちこちに分かれ、量も増えがちでした。この手間を減らすために Redux 公式が出しているのが Redux Toolkit[^rtk]（RTK）で、今は Redux のコードを書くときの公式の推奨になっています。\n\n中心になるのは `createSlice` です。1 つの機能の State の初期値と、Action ごとの State の更新処理をまとめて書くと、Reducer と Action を作る関数がいっしょに生成されます。ほかにも、State から表示用の値を計算して結果をキャッシュする `createSelector`、ID で引ける一覧を扱う `createEntityAdapter` などがそろっています。swift-redux-saga の Redux 本体は、この Redux Toolkit を手本にしたものです。\n\n### Redux Saga\n\nRedux はシンプルである一方、通信のような副作用をどこに書くのかは決めていません。Reducer は純粋な関数でなければならないので通信はできませんし、View で通信してから結果を dispatch すると、ロジックが View に散らばります。\n\nそこで登場するのが Redux Saga です。Saga はアプリの中で副作用を個別に実行する独立したスレッドのようなイメージです。Saga は Store の外側で Action を待ち受け、Action が届いたら通信などの処理をして、結果を新しい Action として Store に戻します。View は Action を送るだけ、Reducer は State を作るだけ、Saga は副作用だけ、と役割がきれいに分かれます。\n\n一般的に副作用の実装はアーキテクチャにロックインされてしまいます。しかしながら、Redux Saga は副作用を関数で定義するので、他のアーキテクチャへの移植性は比較的高いというメリットもあります。\n\n![Action が Middleware と Reducer を通って Store の State になり、View が State を表示する。Saga は Middleware から Action を受け取り、API などを呼んで、結果の Action を put で戻す](https://raw.githubusercontent.com/mitsuharu/swift-redux-saga/main/docs/images/flow.svg)\n\n実線が Redux の通常の流れで、Action は一直線に Reducer まで進みます。点線が Saga の流れで、Saga は通常の流れの外側で Action を受け取り、処理の結果を流れの先頭に戻します。\n\nSaga の中では、`take`（Action を待つ）、`put`（Action を発行する）、`call`（関数を呼ぶ）、`fork`（別の Saga を並行して動かす）といった命令を組み合わせて処理を書き、これらの命令は Effect と呼ばれています。よく使う組み合わせはヘルパーとして用意されていて、たとえば `takeLatest` は、Action が届くたびに処理を始め、前の処理が終わっていなければキャンセルします。検索欄の入力のように、古い結果が要らなくなる処理を短く書けます。\n\n## なぜ今 Swift で Redux なのか\n\nSwift で Redux を使う試みは以前からあり、ReSwift はその代表です。それでも今あらためて作り直したのは、Swift の言語とフレームワークの側が、ここ数年で Redux と相性のよい形に変わってきたためです。\n\n### Swift Concurrency\n\nredux-saga は JavaScript のジェネレーター関数（`function*` と `yield`）を使って、非同期の処理を上から順に書けるようにしています。Swift にジェネレーターはありませんが、`async` / `await` があります。`yield take(...)` を `try await ctx.take(...)` と書けば、ほぼ同じ感覚で読めるはずです。\n\nここで、Swift では AsyncStream の `continuation.yield(_:)` のように `yield` という名前は出てきます。しかし、これは非同期のシーケンスに値を流すメソッドで、関数を途中で止めて値を返すジェネレーターの yield とは別物です。\n\nさらに、Swift の構造化並行性では、親のタスクをキャンセルすると子のタスクにもキャンセルが伝わります。`fork` した Saga を親ごと止める、`takeLatest` で古い処理を止める、といった redux-saga のキャンセルの考え方を、言語の仕組みのまま実現できます。\n\n### Swift 6 の strict concurrency\n\nSwift 6[^swift6] からは、スレッドをまたいで受け渡す値が安全かどうか（`Sendable` かどうか）をコンパイラが検査します。共有された可変の状態を持つ設計ほど、この検査を通すのに苦労します。\n\n一方、Redux の State は不変の値として受け渡され、変更は Reducer の中だけで起き、Action もただの値です。Swift の struct と enum で書けば、State と Action は自然に `Sendable` になります。Redux の「値と純粋関数で状態を扱う」という考え方は、Swift 6 が求める書き方と同じ方向を向いています。\n\n### SwiftUI と Observation\n\nSwiftUI は、React と同じく「状態から画面を作る」宣言的な UI です。そして iOS 17 から使える Observation（`@Observable`）で、View が読んだプロパティが変わったときだけ再描画されるようになりました。\n\nReSwift の頃は、`StoreSubscriber` に準拠して購読し、`newState(state:)` で受け取り、同じ値なら無視する設定をして……と手間がかかりました。今は View の中で `store.count` と書くだけで済みます。UIKit でも、iOS 26 からは Observation による自動追跡が既定で有効になりました。\n\n## 他のライブラリの開発状況\n\n以下は 2026 年 10 月時点で、各リポジトリを確認した内容です。\n\n### ReSwift\n\nReSwift は、Swift で Redux を使うライブラリとして長く使われてきました。ただ、最新のリリースは 2023 年 1 月の 6.1.1 で、最後のコミットは 2024 年 4 月の README の更新です。`Package.swift` の `swift-tools-version` は 5.7 のままで、ソースに `Sendable` は出てきません。Swift 6 言語モードのアプリから使うと、Store や Action の受け渡しが安全であることを、使う側で保証しなければなりません。非同期処理の拡張である ReSwift-Thunk[^reswift-thunk] も、最新のリリースは 2020 年 10 月の 2.0.1 です。\n\n私の旧ライブラリ ReSwift-Saga は ReSwift の上で動いていたので、ReSwift ごと置き換えることにしました。\n\n### The Composable Architecture\n\nThe Composable Architecture[^tca]（TCA）は、Point-Free が開発している、Redux の考え方を取り入れたアーキテクチャです。開発は活発で、2026 年 8 月に 1.26.2 が出ています。\n\nTCA は状態管理だけでなく、依存性の注入、画面遷移、テストまでを一体で提供するフレームワークで、swift-dependencies や swift-case-paths など Point-Free の多くのパッケージに依存しています。完成度は高いのですが、アプリの書き方全体を TCA に合わせることになります。副作用も TCA 独自の Effect で書くため、redux-saga とは書き方がだいぶ違うものになります。\n\n### ReactorKit\n\nReactorKit[^reactorkit] も、Action を受けて State を作る単方向の設計を取るライブラリです。2026 年もコミットは続いていますが、最新のリリースは 2022 年 1 月の 3.2.0 で、RxSwift に依存しています。Swift Concurrency と Observation を前提にした今の SwiftUI の書き方とは、組み合わせにくい面があります。\n\n### JavaScript の redux-saga\n\n本家の redux-saga[^redux-saga-repo] も今もメンテナンスされていて、2026 年 7 月に 1.5.1 が出ています。ただし、Redux 公式のドキュメント[^side-effects]は、データの取得には Saga ではなく RTK Query を、Action への反応には listener middleware を勧めていて、Saga は listener で足りないときに使うもの、という位置づけです。一方で、Saga は「バックグラウンドスレッドのような振る舞いや、debounce やキャンセルを必要とする、とても複雑な非同期のワークフロー」に向いている、とも書かれています。\n\n私もこの評価には納得していて、通信して結果を表示するだけの処理に Saga は大げさだと思います。それでも、ログインの間だけ同期を動かす、入力が止まってから検索する、保存の順番を守る、といった時間の絡む処理は、モバイルアプリでは日常的に出てくるものです。こうした処理を読みやすく書くために、Swift にも Saga があってよいと考えています。\n\n### Swift 6 対応の Redux と Saga はなかった\n\nSwift 6 にきちんと対応した軽い Redux と、redux-saga と同じ書き方で副作用を書ける仕組みの両方を満たすライブラリは、見つかりませんでした。それなら自分で作るしかありません。\n\n## swift-redux-saga を使おう\n\nswift-redux-saga は、Swift 6 言語モードで警告なしにビルドできる Redux と Redux Saga の Swift Package です。Redux 本体は前に紹介した Redux Toolkit を参考にしていて、`createSlice` や `createSelector` に当たる機能もあります。マクロ用の swift-syntax を除いて外部のライブラリには依存していません。動作環境は Swift 6.2（Xcode 26）以降、iOS 17 以降です。\n\n導入は `Package.swift` か、Xcode の File \u003e Add Package Dependencies から行います。\n\n```swift\ndependencies: [\n  .package(url: \"https://github.com/mitsuharu/swift-redux-saga.git\", from: \"0.2.1\")\n]\n```\n\nRedux と Saga を使うなら `ReduxSaga` を、SwiftUI の補助も使うなら `ReduxSwiftUI` をターゲットに追加します。Saga だけを他の状態管理と組み合わせても使えます。\n\n### アーキテクチャにロックインされないように作る\n\nswift-redux-saga を作るときにいちばん意識したのは、アプリがこのライブラリにロックインされないことです。状態管理のライブラリは、一度アプリ全体に広げると簡単には外せません。メンテナンスが止まったり、Swift の大きな変更に追いつけなくなったりすると、アプリごと身動きが取れなくなります。ReSwift の上に ReSwift-Saga を載せていた私自身が、Swift 6 を前にしてまさにその状況になりました。旧ライブラリの記事でもアーキテクチャとの接点を小さくする方針を書きましたが、今回はそれを設計の前提に据えました。\n\nそのために、通信や保存などのビジネスロジック（UseCase や Repository）は、このライブラリを import せずに書けるようにしました。Saga は「Action を受けて、ビジネスロジックを呼び、結果を Action で返す」だけの薄い層にとどめます。Saga から呼ぶ `call` は、Action を受け取る関数に限らず、どんな async 関数でも呼べます。ビジネスロジックが必要とする依存は、Saga を作るときに引数で渡す形です。こうしておけば、ビジネスロジックの側は Action も State も知らずに済みます。\n\nサンプルアプリは、この方針に沿って 3 つのターゲットに分けています。\n\n```\nDomain       ライブラリに依存しない（Model / Repository / UseCase）\nAppFeature   Domain + swift-redux-saga（State / Action / Reducer / Saga）\nApp          SwiftUI や UIKit の画面と、Store の組み立て\n```\n\nライブラリを入れ替えることになっても、書き直すのは `AppFeature` の State・Action・Reducer・Saga だけで、`Domain` はそのまま使えます。`AppFeature` は UI のフレームワークにも依存しないので、Saga や Reducer のテストはアプリを起動せずに実行できます。小さなアプリなら 1 つのターゲットにまとめてもかまいません。それでも分けておけば、ロジックが View に紛れ込みにくくなるという利点もあります。\n\nライブラリ自体も、使う範囲を選べるように分けています。Saga は Redux 本体に依存しておらず、`Saga` のプロダクトだけを追加すれば、他の状態管理と組み合わせて使えます。画面側も、Store を直接読んでもよいし、MVVM の ViewModel を挟んでもよく、どちらかに決め打ちしていません。\n\n### カウンターを作る\n\nまずは定番の、ボタンでカウントを増やし、「読み込み」で値をサーバーから取ってくるカウンターです。\n\nState と Action と Reducer は、`Slice` という入れ物にまとめます。Redux Toolkit の `createSlice` に当たるものです。\n\n```swift\nimport Redux\n\nenum Counter: Slice {\n  struct State: Sendable, Equatable {\n    var count = 0\n    var isLoading = false\n  }\n\n  enum Action: Sendable, Equatable {\n    case increment\n    case fetch\n    case fetched(Int)\n  }\n\n  static let initialState = State()\n\n  static func reduce(into state: inout State, action: Action) {\n    switch action {\n    case .increment: state.count += 1\n    case .fetch: state.isLoading = true\n    case .fetched(let value):\n      state.isLoading = false\n      state.count = value\n    }\n  }\n}\n```\n\nJavaScript の Reducer は新しい State を返しますが、ここでは `inout` で受け取った State を直接書き換えます。Swift の struct は値型なので、書き換えても呼び出し元の State が勝手に変わることはなく、Reducer は純粋な関数のままです。Action を enum にしておけば、`switch` で扱い忘れた case がコンパイルエラーになるのも嬉しいところです。\n\nReSwift を使ったことがある方は、ここで少し書き方が違うと感じるかもしれません。swift-redux-saga の Redux 本体は ReSwift ではなく Redux Toolkit を手本にしているためです。ReSwift では、Action は `Action` プロトコルに準拠した struct で、Reducer は Optional の State を受け取って新しい State を返す関数でした。\n\n```swift\n// ReSwift の書き方\nstruct IncrementCounter: Action {}\n\nfunc counterReducer(action: Action, state: CounterState?) -\u003e CounterState {\n  var state = state ?? CounterState()\n  switch action {\n  case _ as IncrementCounter: state.count += 1\n  default: break\n  }\n  return state\n}\n```\n\nswift-redux-saga では、Action を enum にし、機能ごとの State・Action・Reducer を `Slice` にまとめます。初期値は `initialState` として別に持つので、Reducer が Optional の State を扱うこともありません。複数の Slice は `Reducer.slice` でアプリ全体の Reducer にまとめます。Redux Toolkit の `createSlice` を使ったことがあれば、こちらのほうが見慣れた形だと思います。State の変化の受け取り方も違い、ReSwift の `StoreSubscriber` の代わりに、後で紹介する Observation で読むだけです。ReSwift からの書き換え方は、[移行ガイド](https://github.com/mitsuharu/swift-redux-saga/blob/0.2.1/docs/migration.md)に対応表を載せています。\n\n次に Saga を書きます。前の節で書いたとおり、通信の処理そのものはライブラリを import しない普通の Swift のコードとして用意し、Saga を作るときに渡します。\n\n```swift\n// ライブラリを import しない\nstruct CounterRepository: Sendable {\n  let load: @Sendable () async throws -\u003e Int\n}\n\nextension CounterRepository {\n  // 本番の実装（fetchCountFromServer はアプリの通信処理）。テストでは別の実装を渡す。\n  static let live = CounterRepository { try await fetchCountFromServer() }\n}\n```\n\n```swift\nimport Saga\n\nstruct CounterSagas: Sendable {\n  let repository: CounterRepository\n\n  var root: Saga\u003cCounter.State, Counter.Action\u003e {\n    Saga(\"counter\") { ctx in\n      ctx.takeLatest(.action(.fetch)) { ctx, _ in\n        do {\n          let value = try await ctx.call(repository.load)\n          await ctx.put(.fetched(value))\n        } catch {\n          // 失敗も Action にして返す\n        }\n      }\n    }\n  }\n}\n```\n\nEffect は `ctx` のメソッドとして呼び、redux-saga で `yield` を書く場所に `await` を書きます。この Saga は `.fetch` が届くたびに通信し、結果を `.fetched` として Store に戻すものです。通信中にもう一度 `.fetch` が届けば、`takeLatest` が前の通信をキャンセルします。redux-saga の `takeLatest(FETCH, fetchCount)` とほぼ同じ見た目ですね。\n\nStore は、Saga を Store につなぐ `SagaMiddleware` を渡して作り、作った後で Saga を起動します。\n\n```swift\nimport ReduxSaga\n\n@MainActor\nfunc makeStore() -\u003e Store\u003cCounter.State, Counter.Action\u003e {\n  let sagaMiddleware = SagaMiddleware\u003cCounter.State, Counter.Action\u003e()\n  let store = Store(\n    initialState: Counter.initialState,\n    reducer: Counter.reducer,\n    middleware: [sagaMiddleware]\n  )\n  sagaMiddleware.run(CounterSagas(repository: .live).root)\n  return store\n}\n```\n\nSwiftUI の View は、State のプロパティを読み、Action を dispatch するだけです。\n\n```swift\nimport ReduxSwiftUI\n\nstruct CounterView: View {\n  @Environment(Store\u003cCounter.State, Counter.Action\u003e.self) private var store\n\n  var body: some View {\n    VStack {\n      Text(\"\\(store.count)\")\n      Button(\"+1\") { store.dispatch(.increment) }\n      Button(\"読み込み\") { store.dispatch(.fetch) }\n        .disabled(store.isLoading)\n    }\n  }\n}\n```\n\n`store.count` を読んだ View は、`count` が変わったときだけ再描画されます。購読の登録や解除は書きません。ReSwift の `StoreSubscriber` を書いていた頃と比べると、ずいぶん短くなりました。\n\n### ログインと ToDo を組み合わせる\n\nリポジトリの `Examples/` には、ログインと ToDo の 2 つの機能を持つサンプルアプリが、SwiftUI 版と UIKit 版の両方で入っています。ここからはその中身を紹介します。\n\nログインの Saga は次のとおりです。ログインのボタンが押されたら通信し、成功すれば `.loggedIn`、失敗すれば `.failed` を返します。\n\n```swift\npublic var root: Saga\u003cAuthFeature.State, AuthFeature.Action\u003e {\n  Saga(\"auth\") { ctx in\n    ctx.takeLeading(.case(\\.loginTapped)) { ctx, name in\n      do {\n        guard let user = try await ctx.call(useCase.login, name) else {\n          await ctx.put(.failed(\"Enter your name.\"))\n          return\n        }\n        await ctx.put(.loggedIn(user))\n      } catch is CancellationError {\n      } catch {\n        await ctx.put(.failed(error.localizedDescription))\n      }\n    }\n  }\n}\n```\n\n`takeLeading` は、処理中に届いた同じ Action を無視するヘルパーで、ボタンの連打対策に使っています。`.case(\\.loginTapped)` は、`loginTapped(name:)` という case のときだけ反応して、関連値の `name` を取り出して渡す、という意味です。enum の case から値を取り出すコードは、`@Slice` や `@ActionCases` というマクロで生成しています。\n\nアプリ全体の State は、機能ごとの State をプロパティとして並べただけの struct です。ログインの Saga はログインの State と Action の型のまま書き、親につなぐときに型を付け替えます。そのため、機能ごとのコードはアプリ全体の型を知らずに書けます。\n\nSaga がいちばん役に立つのは、機能をまたぐ流れを書くときです。サンプルでは、ToDo の同期をログインしている間だけ動かしています。ルートの Saga を簡略化すると、次のようになります。\n\n```swift\nwhile true {\n  _ = try await ctx.take(.case(\\.auth?.loggedIn))\n  let session = ctx.fork(todoSagas.root, state: \\.todo, action: \\.todo, embed: AppAction.todo)\n  _ = try await ctx.take(.case(\\.auth?.loggedOut))\n  ctx.cancel(session)\n}\n```\n\nはい、無限ループです。ログインを待ち、ToDo の Saga を動かし、ログアウトを待って止める、という流れがそのままコードになっています。`ctx.cancel(session)` で止めると、ToDo の Saga が実行中だった読み込みや保存もまとめてキャンセルされます。同じことを ViewModel のフラグや Combine の購読で書くと、どこで何を止めるかのコードがあちこちに散らばりがちでした。\n\nToDo の Saga では、検索語の入力が 300 ミリ秒止まってから反映する `debounce` や、保存の順番を守るために Action を一度溜めておく `actionChannel` も使っています。Effect の一覧は、redux-saga との対応表も含めて [Saga の Effect](https://github.com/mitsuharu/swift-redux-saga/blob/0.2.1/docs/saga-effects.md) にまとめています。\n\n### SwiftUI では useSelector のように読む\n\nReact Redux を使ったことがある方なら、`useSelector` と `useDispatch` で、読みたい値と送る関数だけを宣言する書き方に馴染みがあると思います。swift-redux-saga では、`@SelectState` と `@DispatchAction` というプロパティラッパーで同じように書けます。次のコードはサンプルのログイン画面です。\n\n```swift\nstruct LoginView: View {\n  @SelectState(\\RootFeature.State.auth.isLoggingIn) private var isLoggingIn\n  @SelectState(\\RootFeature.State.auth.errorMessage) private var errorMessage\n  @DispatchAction private var dispatch: (RootFeature.Action) -\u003e Void\n  @State private var name = \"\"\n\n  var body: some View {\n    Form {\n      TextField(\"Name\", text: $name)\n      Button(\"Log in\") { dispatch(.auth(.loginTapped(name: name))) }\n        .disabled(isLoggingIn)\n      if let errorMessage {\n        Text(errorMessage).foregroundStyle(.red)\n      }\n    }\n  }\n}\n```\n\nView は Store の型を知らず、読む値と送る Action だけを知っています。`@SelectState` は読んだ値が変わったときだけ再描画するので、ToDo の一覧が更新されてもこの画面は描き直されません。入力中の名前は画面の中でしか使わないので、View の `@State` に置きました。\n\n画面の状態をすべて Store に入れる必要はありません。サンプルでは、設定画面のように Store の値を表示して操作するだけの画面は Store を直接使い、入力中の文字列など画面特有の状態がある ToDo の画面は MVVM を経由しています。ViewModel は Store の値の写しを持たず、計算プロパティで Store を読むので、データは Store の 1 か所にだけあります。\n\n### テストは結果を確かめる\n\nredux-saga では、ジェネレーターから Effect を 1 つずつ取り出して、期待どおりの Effect かを確かめるテストがよく書かれます。残念ながら Swift にジェネレーターはないので、この書き方はできません。代わりに swift-redux-saga では、Saga を実際に動かし、発行された Action と State の変化を確かめます。サンプルのテストから、ログインすると ToDo が読み込まれることを確かめる部分を抜き出します。\n\n```swift\n@Test func todosAreLoadedOnlyAfterLoggingIn() async throws {\n  let store = makeStore()\n\n  try await store.send(.auth(.loginTapped(name: \"me\"))) { $0.auth.isLoggingIn = true }\n  try store.receive(.auth(.loggedIn(User(name: \"me\")))) {\n    $0.auth.isLoggingIn = false\n    $0.auth.user = User(name: \"me\")\n  }\n  try store.receive(.todo(.refresh)) { $0.todo.isLoading = true }\n  try store.receive(.todo(.loaded([milk], generation: 0))) {\n    $0.todo.isLoading = false\n    $0.todo.todos.ids = [milk.id]\n    $0.todo.todos.entities = [milk.id: milk]\n  }\n  try await store.finish()\n}\n```\n\n`send` で Action を送り、`receive` で Saga が発行した Action を順に確かめます。後ろのクロージャには、期待する State の変化を書きます。`TestStore` はすべての Saga が次の Effect で止まるまで待ってから確かめるので、`sleep` で待つ必要がなく、マシンの速さでテストの結果が変わりません。`debounce` のような時間の絡む処理は、`TestClock` で時間を進めて確かめます。\n\n結果を確かめるテストは、Saga の中の書き方を変えても、振る舞いが同じなら壊れません。Redux 公式のドキュメントは、Saga のテストが実装の細部に依存しやすいことを欠点に挙げていますが、この書き方ならその心配は小さくなります。旧ライブラリでやり残していたテストも、これでようやく書けるようになりました。\n\n## 評価と考察\n\nサンプルアプリを作ってみていちばんよかったのは、時間の絡む処理を読めるコードで書けたことです。ログイン中だけ同期を動かす処理も、入力が止まってから検索する処理も、Saga を開けば流れが上から順に書いてあります。ViewModel に書いていた頃のように、フラグやタスクの参照をいくつも持ち回る必要はありません。キャンセルが親から子へ伝わるので、止め忘れも起きにくくなりました。View の処理も Action を送るだけになり、とてもシンプルになって満足しています。\n\nSwift らしく書けるのも気に入っています。Action を enum にすれば Reducer の扱い忘れはコンパイルエラーになり、State と Action は `Sendable` な値なので Swift 6 の検査にも素直に通ります。`yield` を `await` に置き換えただけの見た目なので、redux-saga を知っている人なら対応表を見ればすぐに読めますし、知らない人には普通の async 関数に見えるはずです。\n\nロックインを避ける構成も、実際に使ってみて安心感がありました。サンプルの `Domain` は swift-redux-saga を一度も import していない（import しているのは Foundation だけ）ので、このライブラリをやめる日が来ても、捨てるコードの範囲がはっきりしています。\n\nただ、どのアプリにも勧められるわけではありません。Action を enum で定義し、Reducer と Saga に分けて書くので、画面が 1 つで通信も 1 つというアプリでは、コードが増えるだけです。Redux と Saga の考え方を知らない人には、慣れるまでの学習コストもかかります。\n\nXcode 26 の既定の設定にも注意が要ります。Xcode 26 で作ったアプリのターゲットでは default MainActor isolation が有効になっていて、型や関数が暗黙にメインアクターに属します。Saga はメインアクターの外で動くので、State や Action、Saga には `nonisolated` を付けて宣言しなければなりません。付け忘れたときのエラーメッセージから原因を読み取りにくいので、README に例を載せました。State と Saga を UI とは別のターゲットに置けば、そのターゲットではこの設定を無効にでき、`nonisolated` を書かずに済みます。\n\nTCA と比べると、swift-redux-saga は状態管理と副作用だけに絞った小さなライブラリです。画面遷移や依存性の注入は、アプリで好きな方法を選べます。逆に、そこまでまとめて面倒を見てほしいなら TCA の方が向いています。\n\n旧記事でも書いたとおり、このアーキテクチャはニッチだと自認しています。それでも、Redux ベースのアーキテクチャに興味がある方や、時間の絡む処理の書き方に悩んでいる方には、試す価値があると思います。\n\n## まとめ\n\n前回の記事では、ReSwift の上に Saga の考え方を取り入れるところまでで精一杯でした。今回は Swift Concurrency、Swift 6 の strict concurrency、Observation がそろったので、Redux 本体から作り直しました。`put` や `takeLatest`、エラー処理、テストまで用意して、ようやく redux-saga と呼べる形になりました。\n\nはじめての方は、カウンターのアプリを作りながら順に説明する[はじめかた](https://github.com/mitsuharu/swift-redux-saga/blob/0.2.1/docs/getting-started.md)から読んでみてください。ReSwift や ReSwift-Saga を使っている方向けには、[移行ガイド](https://github.com/mitsuharu/swift-redux-saga/blob/0.2.1/docs/migration.md)も用意しています。感想や不具合の報告は、GitHub の Issue でお待ちしています。\n\nSwift で書く Redux Saga に、iOS アプリ開発者の方が少しでも興味を持っていただけたら嬉しいです。\n\n[^redux-saga]: [redux-saga](https://redux-saga.js.org/)\n[^reswift]: [ReSwift](https://github.com/ReSwift/ReSwift)\n[^reswift-saga]: [ReSwift-Saga](https://github.com/mitsuharu/ReSwift-Saga)（旧ライブラリ。現在は非推奨）\n[^swift-redux-saga]: [swift-redux-saga](https://github.com/mitsuharu/swift-redux-saga)\n[^redux]: [Redux](https://redux.js.org/)\n[^swift6]: [Announcing Swift 6](https://www.swift.org/blog/announcing-swift-6/)\n[^reswift-thunk]: [ReSwift-Thunk](https://github.com/ReSwift/ReSwift-Thunk)\n[^tca]: [The Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture)\n[^tca2]: [Beta preview: ComposableArchitecture 2.0](https://pointfree.co/blog/posts/206-beta-preview-composablearchitecture-2-0)\n[^reactorkit]: [ReactorKit](https://github.com/ReactorKit/ReactorKit)\n[^redux-saga-repo]: [redux-saga（GitHub）](https://github.com/redux-saga/redux-saga)\n[^side-effects]: [Redux: Side Effects Approaches](https://redux.js.org/usage/side-effects-approaches)\n[^rtk]: [Redux Toolkit](https://redux-toolkit.js.org/)\n","coediting":false,"comments_count":0,"created_at":"2026-10-13T22:45:52+09:00","group":null,"id":"9058b82267f03fbfea30","likes_count":0,"private":false,"reactions_count":0,"stocks_count":1,"tags":[{"name":"Swift","versions":[]},{"name":"redux","versions":[]},{"name":"redux-toolkit","versions":[]},{"name":"redux-saga","versions":[]}],"title":"Swift 6 で Redux と Redux Saga を作り直した","updated_at":"2026-10-13T22:45:52+09:00","url":"https://qiita.com/mitsuharu_e/items/9058b82267f03fbfea30","user":{"description":"","facebook_id":"","followees_count":9,"followers_count":30,"github_login_name":"mitsuharu","id":"mitsuharu_e","items_count":54,"linkedin_id":"","location":"","name":"Mitsuharu Emoto","organization":"","permanent_id":24345,"profile_image_url":"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24345/profile-images/1558946969","team_only":false,"twitter_screen_name":"mitsuharu_e","website_url":"https://mitsuharu.github.io/"},"page_views_count":null,"team_membership":null,"organization_url_name":"yumemi","slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch2 data-sourcepos=\"1:1-1:15\"\u003e\n\u003cspan id=\"はじめに\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eはじめに\u003c/h2\u003e\n\u003cp data-sourcepos=\"3:1-3:130\"\u003eSwift Concurrencyを学習していると、\u003ccode\u003easync/await\u003c/code\u003eや\u003ccode\u003eTask\u003c/code\u003eとともに\u003ccode\u003eactor\u003c/code\u003eというキーワードを目にします。\u003c/p\u003e\n\u003cp data-sourcepos=\"5:1-5:191\"\u003eActorは、複数のタスクから共有されるデータを安全に扱うための仕組みです。しかし、次のような疑問を持つ方もいるのではないでしょうか。\u003c/p\u003e\n\u003cul data-sourcepos=\"7:1-10:0\"\u003e\n\u003cli data-sourcepos=\"7:1-7:46\"\u003eActorは何のために存在するのか？\u003c/li\u003e\n\u003cli data-sourcepos=\"8:1-8:45\"\u003e通常の\u003ccode\u003eclass\u003c/code\u003eとは何が違うのか？\u003c/li\u003e\n\u003cli data-sourcepos=\"9:1-10:0\"\u003eなぜActorのメソッドを呼び出す際に\u003ccode\u003eawait\u003c/code\u003eが必要なのか？\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"11:1-11:176\"\u003e本記事では、これらの疑問を解消できるように、データ競合の基本からActorの使い方、注意点までをコード例を交えて解説します。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"13:1-13:20\"\u003e\n\u003cspan id=\"1-actorとは\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#1-actor%E3%81%A8%E3%81%AF\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e1. Actorとは？\u003c/h2\u003e\n\u003cp data-sourcepos=\"15:1-15:106\"\u003eActorとは、Swift Concurrencyで共有された可変状態を安全に扱うための参照型です。\u003c/p\u003e\n\u003cp data-sourcepos=\"17:1-17:182\"\u003e通常のクラスと同様にプロパティやメソッドを定義できますが、Actorには、隔離された状態へのアクセスを制御する仕組みがあります。\u003c/p\u003e\n\u003cp data-sourcepos=\"19:1-19:132\"\u003eこれにより、複数のタスクから同じデータにアクセスする際のデータ競合を防ぎやすくなります。\u003c/p\u003e\n\u003cp data-sourcepos=\"21:1-21:63\"\u003eActorは、\u003ccode\u003eactor\u003c/code\u003eキーワードを使って定義します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"23:1-31:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eactor\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"33:1-33:92\"\u003eこの例では、\u003ccode\u003eCounter\u003c/code\u003eというActorがカウンターの値を保持しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"35:1-35:145\"\u003e\u003ccode\u003ecount\u003c/code\u003eは\u003ccode\u003eCounter\u003c/code\u003eの内部で管理されており、\u003ccode\u003eprivate\u003c/code\u003eによって外部から直接アクセスできないようにしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"37:1-37:72\"\u003eでは、なぜこのような仕組みが必要なのでしょうか。\u003c/p\u003e\n\u003cp data-sourcepos=\"39:1-39:54\"\u003eまずはデータ競合について確認します。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"41:1-41:30\"\u003e\n\u003cspan id=\"2-データ競合とは\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#2-%E3%83%87%E3%83%BC%E3%82%BF%E7%AB%B6%E5%90%88%E3%81%A8%E3%81%AF\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e2. データ競合とは？\u003c/h2\u003e\n\u003cp data-sourcepos=\"43:1-43:216\"\u003eデータ競合（Data Race）とは、複数の処理が同じメモリ領域に対して、適切な同期なしに競合するアクセス（少なくとも一方が書き込み）を行う状態を指します。\u003c/p\u003e\n\u003cp data-sourcepos=\"45:1-45:54\"\u003e例えば、次のようなクラスを考えます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"47:1-55:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003efinal\u003c/span\u003e \u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"57:1-57:227\"\u003e1つのタスクから使う分には問題なく動作します。しかし、複数のタスクから同じインスタンスの\u003ccode\u003eincrement()\u003c/code\u003eを同時に呼び出すと、更新結果が失われる可能性があります。\u003c/p\u003e\n\u003cp data-sourcepos=\"59:1-59:100\"\u003e\u003ccode\u003ecount += 1\u003c/code\u003eは単純に見えますが、概念的には次の3つの処理を含んでいます。\u003c/p\u003e\n\u003col data-sourcepos=\"61:1-64:0\"\u003e\n\u003cli data-sourcepos=\"61:1-61:34\"\u003e現在の\u003ccode\u003ecount\u003c/code\u003eを読み取る\u003c/li\u003e\n\u003cli data-sourcepos=\"62:1-62:40\"\u003e読み取った値に1を加算する\u003c/li\u003e\n\u003cli data-sourcepos=\"63:1-64:0\"\u003e加算した値を書き戻す\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"65:1-65:232\"\u003e\u003ccode\u003ecount\u003c/code\u003eが0のとき、2つの処理がほぼ同時に値を読み取ると、両方が0を読み取り、それぞれ1を書き戻す可能性があります。本来は2になるはずの値が1になってしまうわけです。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"67:1-67:28\"\u003e\n\u003cspan id=\"swift6とデータ競合\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#swift6%E3%81%A8%E3%83%87%E3%83%BC%E3%82%BF%E7%AB%B6%E5%90%88\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eSwift6とデータ競合\u003c/h3\u003e\n\u003cp data-sourcepos=\"69:1-69:162\"\u003eSwift6では、並行処理の安全性チェックが強化され、危険なデータ共有をコンパイル時に検出できるようになっています。\u003c/p\u003e\n\u003cp data-sourcepos=\"71:1-71:240\"\u003eただし、すべてのデータ競合を無条件に検出できるわけではありません。例えば、\u003ccode\u003e@unchecked Sendable\u003c/code\u003eを使うと、型の安全性についてプログラマー自身が責任を負うことになります。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"73:1-73:25\"\u003e\n\u003cspan id=\"実際に壊れる例\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AE%9F%E9%9A%9B%E3%81%AB%E5%A3%8A%E3%82%8C%E3%82%8B%E4%BE%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e実際に壊れる例\u003c/h3\u003e\n\u003cp data-sourcepos=\"75:1-75:93\"\u003e実際にデータ競合が発生する可能性のあるコードを見てみましょう。\u003c/p\u003e\n\u003cp data-sourcepos=\"77:1-77:66\"\u003e以下は、Swift5で検証することを想定した例です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"79:1-101:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003efinal\u003c/span\u003e \u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"kt\"\u003eUnsafeCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kd\"\u003e@unchecked\u003c/span\u003e \u003cspan class=\"kt\"\u003eSendable\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003erunUnsafe\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecounter\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eUnsafeCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\n    \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"nf\"\u003ewithTaskGroup\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eof\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eVoid\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003egroup\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n        \u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"o\"\u003e..\u0026lt;\u003c/span\u003e\u003cspan class=\"mi\"\u003e1000\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"n\"\u003egroup\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eaddTask\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n                \u003cspan class=\"n\"\u003ecounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n            \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"103:1-103:103\"\u003e\u003ccode\u003ewithTaskGroup\u003c/code\u003eを使い、1000個の子タスクから同じカウンターを更新しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"105:1-105:263\"\u003eこのコードでは、複数のタスクが同期なしに\u003ccode\u003ecount\u003c/code\u003eを読み書きするため、データ競合が発生する可能性があります。実行結果が1000未満になる場合もありますが、必ず発生するわけではありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"107:1-107:72\"\u003eここで登場する\u003ccode\u003e@unchecked Sendable\u003c/code\u003eについて説明します。\u003c/p\u003e\n\u003cp data-sourcepos=\"109:1-109:295\"\u003e\u003ccode\u003eSendable\u003c/code\u003eは、並行実行の境界を越えて値を安全に受け渡せることを表す仕組みです。\u003ccode\u003e@unchecked Sendable\u003c/code\u003eを付けると、コンパイラによる安全性の検証を一部省略し、プログラマーがその型の安全性を保証することになります。\u003c/p\u003e\n\u003cp data-sourcepos=\"111:1-111:159\"\u003e今回はデータ競合の仕組みを説明するために使用していますが、実際のコードで安易に使用するべきではありません。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"113:1-113:24\"\u003e\n\u003cspan id=\"actorで解決する\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#actor%E3%81%A7%E8%A7%A3%E6%B1%BA%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eActorで解決する\u003c/h3\u003e\n\u003cp data-sourcepos=\"115:1-115:71\"\u003e同じ処理をActorで実装すると、次のようになります。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"117:1-143:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eactor\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e+=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003ecurrentCount\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eInt\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003ecount\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003erunSafe\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecounter\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\n    \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"nf\"\u003ewithTaskGroup\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eof\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eVoid\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003egroup\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n        \u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"o\"\u003e..\u0026lt;\u003c/span\u003e\u003cspan class=\"mi\"\u003e1000\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"n\"\u003egroup\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eaddTask\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n                \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ecounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n            \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ecounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecurrentCount\u003c/span\u003e\u003cspan class=\"p\"\u003e())\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 1000\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"145:1-145:148\"\u003eActorは、隔離された状態へのアクセスを制御し、同じActorの処理が同時にその状態を変更しないようにします。\u003c/p\u003e\n\u003cp data-sourcepos=\"147:1-147:150\"\u003eこの例では、\u003ccode\u003eincrement()\u003c/code\u003eの処理がそれぞれ完結するため、1000回の加算が正しく反映され、結果は1000になります。\u003c/p\u003e\n\u003cp data-sourcepos=\"149:1-149:110\"\u003eこのように、Actorを利用することで、共有された可変状態を安全に管理できます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"151:1-151:28\"\u003e\n\u003cspan id=\"3-classとactorの違い\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#3-class%E3%81%A8actor%E3%81%AE%E9%81%95%E3%81%84\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e3. classとactorの違い\u003c/h2\u003e\n\u003cp data-sourcepos=\"153:1-153:155\"\u003e\u003ccode\u003eclass\u003c/code\u003eを\u003ccode\u003eactor\u003c/code\u003eに変更すると、管理している状態へのアクセスがActor Isolation（アクター隔離）によって保護されます。\u003c/p\u003e\n\u003cp data-sourcepos=\"155:1-155:149\"\u003eActor Isolationとは、Actorが管理する状態に対して、どこからどのようにアクセスできるかを制御する仕組みです。\u003c/p\u003e\n\u003cp data-sourcepos=\"157:1-157:39\"\u003e主な違いを表にまとめます。\u003c/p\u003e\n\u003ctable data-sourcepos=\"159:1-166:88\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"159:1-159:30\"\u003e\n\u003cth data-sourcepos=\"159:2-159:9\"\u003e項目\u003c/th\u003e\n\u003cth data-sourcepos=\"159:11-159:19\"\u003e\u003ccode\u003eclass\u003c/code\u003e\u003c/th\u003e\n\u003cth data-sourcepos=\"159:21-159:29\"\u003e\u003ccode\u003eactor\u003c/code\u003e\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"161:1-161:40\"\u003e\n\u003ctd data-sourcepos=\"161:2-161:15\"\u003e型の種類\u003c/td\u003e\n\u003ctd data-sourcepos=\"161:17-161:27\"\u003e参照型\u003c/td\u003e\n\u003ctd data-sourcepos=\"161:29-161:39\"\u003e参照型\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"162:1-162:112\"\u003e\n\u003ctd data-sourcepos=\"162:2-162:33\"\u003e状態へのアクセス制御\u003c/td\u003e\n\u003ctd data-sourcepos=\"162:35-162:66\"\u003e自動では同期されない\u003c/td\u003e\n\u003ctd data-sourcepos=\"162:68-162:111\"\u003eActor Isolationによって保護される\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"163:1-163:113\"\u003e\n\u003ctd data-sourcepos=\"163:2-163:57\"\u003e外部からの隔離されたメソッド呼び出し\u003c/td\u003e\n\u003ctd data-sourcepos=\"163:59-163:87\"\u003e通常は同期呼び出し\u003c/td\u003e\n\u003ctd data-sourcepos=\"163:89-163:112\"\u003e原則\u003ccode\u003eawait\u003c/code\u003eが必要\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"164:1-164:37\"\u003e\n\u003ctd data-sourcepos=\"164:2-164:18\"\u003eクラス継承\u003c/td\u003e\n\u003ctd data-sourcepos=\"164:20-164:27\"\u003e可能\u003c/td\u003e\n\u003ctd data-sourcepos=\"164:29-164:36\"\u003e不可\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"165:1-165:71\"\u003e\n\u003ctd data-sourcepos=\"165:2-165:13\"\u003e\u003ccode\u003eSendable\u003c/code\u003e\u003c/td\u003e\n\u003ctd data-sourcepos=\"165:15-165:49\"\u003e条件を満たせば準拠可能\u003c/td\u003e\n\u003ctd data-sourcepos=\"165:51-165:70\"\u003e暗黙的に準拠\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"166:1-166:88\"\u003e\n\u003ctd data-sourcepos=\"166:2-166:15\"\u003e主な用途\u003c/td\u003e\n\u003ctd data-sourcepos=\"166:17-166:54\"\u003e一般的なオブジェクト管理\u003c/td\u003e\n\u003ctd data-sourcepos=\"166:56-166:87\"\u003e共有状態の安全な管理\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003cp data-sourcepos=\"168:1-168:133\"\u003e\u003ccode\u003eclass\u003c/code\u003eでも、ロックなどを利用して自分で排他制御を実装すれば、安全に共有状態を管理できます。\u003c/p\u003e\n\u003cp data-sourcepos=\"170:1-170:143\"\u003e一方、Actorでは、言語が提供する隔離の仕組みを利用して、共有状態へのアクセスを安全に管理できます。\u003c/p\u003e\n\u003cp data-sourcepos=\"172:1-172:110\"\u003eただし、Actorを使えばすべての処理が自動的に非同期になるわけではありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"174:1-174:262\"\u003eActorの内部では、自身の隔離されたプロパティや同期メソッドに通常どおりアクセスできます。一方、Actorの外部から隔離された状態にアクセスする場合は、非同期の呼び出しが必要になります。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"176:1-176:23\"\u003e\n\u003cspan id=\"4-actorの使い方\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#4-actor%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e4. Actorの使い方\u003c/h2\u003e\n\u003cp data-sourcepos=\"178:1-178:83\"\u003eActorのインスタンスは、通常のクラスと同様に生成できます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"180:1-182:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecounter\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"184:1-184:64\"\u003eメソッドを呼び出すときは、\u003ccode\u003eawait\u003c/code\u003eを付けます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"186:1-193:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kt\"\u003eTask\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ecounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ecounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecurrentCount\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 1\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"195:1-195:111\"\u003eActorの外部から隔離されたメソッドを呼び出す場合、原則として\u003ccode\u003eawait\u003c/code\u003eが必要です。\u003c/p\u003e\n\u003cp data-sourcepos=\"197:1-197:220\"\u003e\u003ccode\u003eincrement()\u003c/code\u003eだけでなく、値を取得する\u003ccode\u003ecurrentCount()\u003c/code\u003eにも必要です。これは、Actorの状態にアクセスするために、呼び出し元の処理が中断される可能性があるためです。\u003c/p\u003e\n\u003cp data-sourcepos=\"199:1-199:187\"\u003eなお、\u003ccode\u003eTask\u003c/code\u003eは非同期処理を開始するために使用しています。すでに\u003ccode\u003easync\u003c/code\u003e関数の中にいる場合は、新しい\u003ccode\u003eTask\u003c/code\u003eを作成する必要はありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"201:1-201:63\"\u003e例えば、次のように呼び出すこともできます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"203:1-207:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eincrementCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"nv\"\u003ecounter\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ecounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eincrement\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"209:1-209:38\"\u003e\n\u003cspan id=\"5-なぜawaitが必要なのか\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#5-%E3%81%AA%E3%81%9Cawait%E3%81%8C%E5%BF%85%E8%A6%81%E3%81%AA%E3%81%AE%E3%81%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e5. なぜawaitが必要なのか？\u003c/h2\u003e\n\u003cp data-sourcepos=\"211:1-211:39\"\u003e次のコードを考えてみます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"213:1-218:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eprintCount\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ecounter\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ecounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecurrentCount\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"c1\"\u003e// コンパイルエラー\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"220:1-220:51\"\u003eこのコードはコンパイルできません。\u003c/p\u003e\n\u003cp data-sourcepos=\"222:1-222:141\"\u003e\u003ccode\u003ecurrentCount()\u003c/code\u003eはActorに隔離されたメソッドなので、外部から呼び出す際には非同期の呼び出しが必要です。\u003c/p\u003e\n\u003cp data-sourcepos=\"224:1-224:86\"\u003e関数を\u003ccode\u003easync\u003c/code\u003eに変更し、呼び出しに\u003ccode\u003eawait\u003c/code\u003eを付けると解決します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"226:1-231:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eprintCount\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ecounter\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCounter\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecount\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"n\"\u003ecounter\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ecurrentCount\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecount\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"233:1-233:219\"\u003e\u003ccode\u003eawait\u003c/code\u003eは、呼び出し先のActorが他の処理を実行中であれば、呼び出し元が中断される可能性があることを示しています。必ず実際に中断されるわけではありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"235:1-235:238\"\u003eまた、Actorの内部から自身の隔離された同期メソッドを呼び出す場合は、通常\u003ccode\u003eawait\u003c/code\u003eは不要です。ただし、\u003ccode\u003easync\u003c/code\u003eメソッドを呼び出す場合は、Actorの内外を問わず\u003ccode\u003eawait\u003c/code\u003eが必要です。\u003c/p\u003e\n\u003cp data-sourcepos=\"237:1-237:201\"\u003eこのように、\u003ccode\u003eawait\u003c/code\u003eは「ここで中断される可能性がある」という目印であり、Actorの隔離境界を越えてアクセスする際にも重要な役割を持っています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"239:1-239:35\"\u003e\n\u003cspan id=\"6-actorを使う際の注意点\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#6-actor%E3%82%92%E4%BD%BF%E3%81%86%E9%9A%9B%E3%81%AE%E6%B3%A8%E6%84%8F%E7%82%B9\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e6. Actorを使う際の注意点\u003c/h2\u003e\n\u003cp data-sourcepos=\"241:1-241:134\"\u003eActorはデータ競合を防ぐために便利ですが、すべての問題を自動的に解決するわけではありません。\u003c/p\u003e\n\u003cp data-sourcepos=\"243:1-243:64\"\u003e特に覚えておきたいポイントを2つ紹介します。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"245:1-245:49\"\u003e\n\u003cspan id=\"61-actorは特定のスレッドではない\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#61-actor%E3%81%AF%E7%89%B9%E5%AE%9A%E3%81%AE%E3%82%B9%E3%83%AC%E3%83%83%E3%83%89%E3%81%A7%E3%81%AF%E3%81%AA%E3%81%84\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e6.1 Actorは特定のスレッドではない\u003c/h3\u003e\n\u003cp data-sourcepos=\"247:1-247:190\"\u003eActorは、特定のスレッドを占有して処理を実行する仕組みではありません。Actorの役割は、隔離された状態へのアクセスを制御することです。\u003c/p\u003e\n\u003cp data-sourcepos=\"249:1-249:174\"\u003eそのため、通常のActorのメソッドが毎回同じスレッドで実行される保証はなく、\u003ccode\u003eawait\u003c/code\u003eの前後でスレッドが変わることもあります。\u003c/p\u003e\n\u003cp data-sourcepos=\"251:1-251:122\"\u003eUIの更新など、メインアクターで隔離された処理が必要な場合は、\u003ccode\u003e@MainActor\u003c/code\u003eを使用します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"253:1-262:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003e@MainActor\u003c/span\u003e\n\u003cspan class=\"kd\"\u003efinal\u003c/span\u003e \u003cspan class=\"kd\"\u003eclass\u003c/span\u003e \u003cspan class=\"kt\"\u003eViewModel\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003etitle\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"\"\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eupdateTitle\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003etitle\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"更新しました\"\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"264:1-264:164\"\u003e\u003ccode\u003e@MainActor\u003c/code\u003eは、宣言をメインアクターに隔離するための仕組みです。通常のActorと同様に、状態へのアクセスを制御します。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"266:1-266:65\"\u003e\n\u003cspan id=\"62-awaitをまたぐ処理には注意するreentrancy\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#62-await%E3%82%92%E3%81%BE%E3%81%9F%E3%81%90%E5%87%A6%E7%90%86%E3%81%AB%E3%81%AF%E6%B3%A8%E6%84%8F%E3%81%99%E3%82%8Breentrancy\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e6.2 awaitをまたぐ処理には注意する（Reentrancy）\u003c/h3\u003e\n\u003cp data-sourcepos=\"268:1-268:186\"\u003eActorのメソッドが\u003ccode\u003eawait\u003c/code\u003eで中断されている間、同じActorの別の処理が実行されることがあります。これをReentrancy（再入可能性）と呼びます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"270:1-286:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eactor\u003c/span\u003e \u003cspan class=\"kt\"\u003eExample\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eupdate\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"k\"\u003easync\u003c/span\u003e \u003cspan class=\"k\"\u003ethrows\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\n\n        \u003cspan class=\"k\"\u003etry\u003c/span\u003e \u003cspan class=\"k\"\u003eawait\u003c/span\u003e \u003cspan class=\"kt\"\u003eTask\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esleep\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003efor\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eseconds\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n\n        \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003evalue\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 1とは限らない\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003ereset\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003evalue\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"288:1-288:150\"\u003e\u003ccode\u003eupdate()\u003c/code\u003eが\u003ccode\u003eTask.sleep\u003c/code\u003eで中断されている間に、別のタスクから\u003ccode\u003ereset()\u003c/code\u003eが呼ばれると、\u003ccode\u003evalue\u003c/code\u003eは0に書き換えられます。\u003c/p\u003e\n\u003cp data-sourcepos=\"290:1-290:81\"\u003eそのため、\u003ccode\u003eprint(value)\u003c/code\u003eの時点で値が1であるとは限りません。\u003c/p\u003e\n\u003cp data-sourcepos=\"292:1-292:147\"\u003eActorは隔離された状態への同時アクセスを防ぎますが、\u003ccode\u003eawait\u003c/code\u003eをまたぐ処理全体の整合性までは保証しません。\u003c/p\u003e\n\u003cp data-sourcepos=\"294:1-294:173\"\u003e対策としては、\u003ccode\u003eawait\u003c/code\u003eの後に前提条件を再確認する、\u003ccode\u003eawait\u003c/code\u003eをまたがずに状態の更新を完結させる、といった方法が考えられます。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"296:1-296:15\"\u003e\n\u003cspan id=\"7-まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#7-%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e7. まとめ\u003c/h2\u003e\n\u003cp data-sourcepos=\"298:1-298:103\"\u003e本記事では、SwiftのActorについて、基本的な仕組みと使い方を解説しました。\u003c/p\u003e\n\u003cul data-sourcepos=\"300:1-305:0\"\u003e\n\u003cli data-sourcepos=\"300:1-300:76\"\u003eActorは、共有された可変状態を安全に扱うための参照型\u003c/li\u003e\n\u003cli data-sourcepos=\"301:1-301:84\"\u003e\n\u003ccode\u003eclass\u003c/code\u003eと異なり、状態へのアクセスがActor Isolationで保護される\u003c/li\u003e\n\u003cli data-sourcepos=\"302:1-302:96\"\u003e外部から隔離された状態にアクセスする場合、原則として\u003ccode\u003eawait\u003c/code\u003eが必要\u003c/li\u003e\n\u003cli data-sourcepos=\"303:1-303:67\"\u003eActorは特定のスレッドを占有する仕組みではない\u003c/li\u003e\n\u003cli data-sourcepos=\"304:1-305:0\"\u003e\n\u003ccode\u003eawait\u003c/code\u003eをまたぐ処理では、Reentrancyを考慮する必要がある\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"306:1-306:100\"\u003eActorは、Swift Concurrencyにおいてデータ競合を防ぐための重要な仕組みです。\u003c/p\u003e\n\u003cp data-sourcepos=\"308:1-308:114\"\u003eまずは簡単なサンプルコードで、\u003ccode\u003eclass\u003c/code\u003eとActorの違いを実際に確認してみてください。\u003c/p\u003e\n\u003cp data-sourcepos=\"310:1-310:151\"\u003e次のステップとして、\u003ccode\u003e@MainActor\u003c/code\u003eや\u003ccode\u003eSendable\u003c/code\u003e、\u003ccode\u003enonisolated\u003c/code\u003eについて学ぶと、Swift Concurrencyの理解がさらに深まります。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"312:1-312:15\"\u003e\n\u003cspan id=\"参考資料\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%8F%82%E8%80%83%E8%B3%87%E6%96%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e参考資料\u003c/h2\u003e\n\u003cul data-sourcepos=\"314:1-316:106\"\u003e\n\u003cli data-sourcepos=\"314:1-314:143\"\u003e\u003ca href=\"https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/\" rel=\"nofollow noopener\" target=\"_blank\"\u003eThe Swift Programming Language — Concurrency\u003c/a\u003e\u003c/li\u003e\n\u003cli data-sourcepos=\"315:1-315:147\"\u003e\u003ca href=\"https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/dataracesafety/\" rel=\"nofollow noopener\" target=\"_blank\"\u003eSwift 6 Migration Guide — Data Race Safety\u003c/a\u003e\u003c/li\u003e\n\u003cli data-sourcepos=\"316:1-316:106\"\u003e\u003ca href=\"https://developer.apple.com/documentation/swift/mainactor\" rel=\"nofollow noopener\" target=\"_blank\"\u003eApple Developer Documentation — MainActor\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n","body":"## はじめに\n\nSwift Concurrencyを学習していると、`async/await`や`Task`とともに`actor`というキーワードを目にします。\n\nActorは、複数のタスクから共有されるデータを安全に扱うための仕組みです。しかし、次のような疑問を持つ方もいるのではないでしょうか。\n\n- Actorは何のために存在するのか？\n- 通常の`class`とは何が違うのか？\n- なぜActorのメソッドを呼び出す際に`await`が必要なのか？\n\n本記事では、これらの疑問を解消できるように、データ競合の基本からActorの使い方、注意点までをコード例を交えて解説します。\n\n## 1. Actorとは？\n\nActorとは、Swift Concurrencyで共有された可変状態を安全に扱うための参照型です。\n\n通常のクラスと同様にプロパティやメソッドを定義できますが、Actorには、隔離された状態へのアクセスを制御する仕組みがあります。\n\nこれにより、複数のタスクから同じデータにアクセスする際のデータ競合を防ぎやすくなります。\n\nActorは、`actor`キーワードを使って定義します。\n\n```swift\nactor Counter {\n    private var count = 0\n\n    func increment() {\n        count += 1\n    }\n}\n```\n\nこの例では、`Counter`というActorがカウンターの値を保持しています。\n\n`count`は`Counter`の内部で管理されており、`private`によって外部から直接アクセスできないようにしています。\n\nでは、なぜこのような仕組みが必要なのでしょうか。\n\nまずはデータ競合について確認します。\n\n## 2. データ競合とは？\n\nデータ競合（Data Race）とは、複数の処理が同じメモリ領域に対して、適切な同期なしに競合するアクセス（少なくとも一方が書き込み）を行う状態を指します。\n\n例えば、次のようなクラスを考えます。\n\n```swift\nfinal class Counter {\n    var count = 0\n\n    func increment() {\n        count += 1\n    }\n}\n```\n\n1つのタスクから使う分には問題なく動作します。しかし、複数のタスクから同じインスタンスの`increment()`を同時に呼び出すと、更新結果が失われる可能性があります。\n\n`count += 1`は単純に見えますが、概念的には次の3つの処理を含んでいます。\n\n1. 現在の`count`を読み取る\n2. 読み取った値に1を加算する\n3. 加算した値を書き戻す\n\n`count`が0のとき、2つの処理がほぼ同時に値を読み取ると、両方が0を読み取り、それぞれ1を書き戻す可能性があります。本来は2になるはずの値が1になってしまうわけです。\n\n### Swift6とデータ競合\n\nSwift6では、並行処理の安全性チェックが強化され、危険なデータ共有をコンパイル時に検出できるようになっています。\n\nただし、すべてのデータ競合を無条件に検出できるわけではありません。例えば、`@unchecked Sendable`を使うと、型の安全性についてプログラマー自身が責任を負うことになります。\n\n### 実際に壊れる例\n\n実際にデータ競合が発生する可能性のあるコードを見てみましょう。\n\n以下は、Swift5で検証することを想定した例です。\n\n```swift\nfinal class UnsafeCounter: @unchecked Sendable {\n    var count = 0\n\n    func increment() {\n        count += 1\n    }\n}\n\nfunc runUnsafe() async {\n    let counter = UnsafeCounter()\n\n    await withTaskGroup(of: Void.self) { group in\n        for _ in 0..\u003c1000 {\n            group.addTask {\n                counter.increment()\n            }\n        }\n    }\n\n    print(counter.count)\n}\n```\n\n`withTaskGroup`を使い、1000個の子タスクから同じカウンターを更新しています。\n\nこのコードでは、複数のタスクが同期なしに`count`を読み書きするため、データ競合が発生する可能性があります。実行結果が1000未満になる場合もありますが、必ず発生するわけではありません。\n\nここで登場する`@unchecked Sendable`について説明します。\n\n`Sendable`は、並行実行の境界を越えて値を安全に受け渡せることを表す仕組みです。`@unchecked Sendable`を付けると、コンパイラによる安全性の検証を一部省略し、プログラマーがその型の安全性を保証することになります。\n\n今回はデータ競合の仕組みを説明するために使用していますが、実際のコードで安易に使用するべきではありません。\n\n### Actorで解決する\n\n同じ処理をActorで実装すると、次のようになります。\n\n```swift\nactor Counter {\n    private var count = 0\n\n    func increment() {\n        count += 1\n    }\n\n    func currentCount() -\u003e Int {\n        count\n    }\n}\n\nfunc runSafe() async {\n    let counter = Counter()\n\n    await withTaskGroup(of: Void.self) { group in\n        for _ in 0..\u003c1000 {\n            group.addTask {\n                await counter.increment()\n            }\n        }\n    }\n\n    print(await counter.currentCount()) // 1000\n}\n```\n\nActorは、隔離された状態へのアクセスを制御し、同じActorの処理が同時にその状態を変更しないようにします。\n\nこの例では、`increment()`の処理がそれぞれ完結するため、1000回の加算が正しく反映され、結果は1000になります。\n\nこのように、Actorを利用することで、共有された可変状態を安全に管理できます。\n\n## 3. classとactorの違い\n\n`class`を`actor`に変更すると、管理している状態へのアクセスがActor Isolation（アクター隔離）によって保護されます。\n\nActor Isolationとは、Actorが管理する状態に対して、どこからどのようにアクセスできるかを制御する仕組みです。\n\n主な違いを表にまとめます。\n\n| 項目 | `class` | `actor` |\n|---|---|---|\n| 型の種類 | 参照型 | 参照型 |\n| 状態へのアクセス制御 | 自動では同期されない | Actor Isolationによって保護される |\n| 外部からの隔離されたメソッド呼び出し | 通常は同期呼び出し | 原則`await`が必要 |\n| クラス継承 | 可能 | 不可 |\n| `Sendable` | 条件を満たせば準拠可能 | 暗黙的に準拠 |\n| 主な用途 | 一般的なオブジェクト管理 | 共有状態の安全な管理 |\n\n`class`でも、ロックなどを利用して自分で排他制御を実装すれば、安全に共有状態を管理できます。\n\n一方、Actorでは、言語が提供する隔離の仕組みを利用して、共有状態へのアクセスを安全に管理できます。\n\nただし、Actorを使えばすべての処理が自動的に非同期になるわけではありません。\n\nActorの内部では、自身の隔離されたプロパティや同期メソッドに通常どおりアクセスできます。一方、Actorの外部から隔離された状態にアクセスする場合は、非同期の呼び出しが必要になります。\n\n## 4. Actorの使い方\n\nActorのインスタンスは、通常のクラスと同様に生成できます。\n\n```swift\nlet counter = Counter()\n```\n\nメソッドを呼び出すときは、`await`を付けます。\n\n```swift\nTask {\n    await counter.increment()\n\n    let count = await counter.currentCount()\n    print(count) // 1\n}\n```\n\nActorの外部から隔離されたメソッドを呼び出す場合、原則として`await`が必要です。\n\n`increment()`だけでなく、値を取得する`currentCount()`にも必要です。これは、Actorの状態にアクセスするために、呼び出し元の処理が中断される可能性があるためです。\n\nなお、`Task`は非同期処理を開始するために使用しています。すでに`async`関数の中にいる場合は、新しい`Task`を作成する必要はありません。\n\n例えば、次のように呼び出すこともできます。\n\n```swift\nfunc incrementCounter(_ counter: Counter) async {\n    await counter.increment()\n}\n```\n\n## 5. なぜawaitが必要なのか？\n\n次のコードを考えてみます。\n\n```swift\nfunc printCount(counter: Counter) {\n    let count = counter.currentCount() // コンパイルエラー\n    print(count)\n}\n```\n\nこのコードはコンパイルできません。\n\n`currentCount()`はActorに隔離されたメソッドなので、外部から呼び出す際には非同期の呼び出しが必要です。\n\n関数を`async`に変更し、呼び出しに`await`を付けると解決します。\n\n```swift\nfunc printCount(counter: Counter) async {\n    let count = await counter.currentCount()\n    print(count)\n}\n```\n\n`await`は、呼び出し先のActorが他の処理を実行中であれば、呼び出し元が中断される可能性があることを示しています。必ず実際に中断されるわけではありません。\n\nまた、Actorの内部から自身の隔離された同期メソッドを呼び出す場合は、通常`await`は不要です。ただし、`async`メソッドを呼び出す場合は、Actorの内外を問わず`await`が必要です。\n\nこのように、`await`は「ここで中断される可能性がある」という目印であり、Actorの隔離境界を越えてアクセスする際にも重要な役割を持っています。\n\n## 6. Actorを使う際の注意点\n\nActorはデータ競合を防ぐために便利ですが、すべての問題を自動的に解決するわけではありません。\n\n特に覚えておきたいポイントを2つ紹介します。\n\n### 6.1 Actorは特定のスレッドではない\n\nActorは、特定のスレッドを占有して処理を実行する仕組みではありません。Actorの役割は、隔離された状態へのアクセスを制御することです。\n\nそのため、通常のActorのメソッドが毎回同じスレッドで実行される保証はなく、`await`の前後でスレッドが変わることもあります。\n\nUIの更新など、メインアクターで隔離された処理が必要な場合は、`@MainActor`を使用します。\n\n```swift\n@MainActor\nfinal class ViewModel {\n    var title = \"\"\n\n    func updateTitle() {\n        title = \"更新しました\"\n    }\n}\n```\n\n`@MainActor`は、宣言をメインアクターに隔離するための仕組みです。通常のActorと同様に、状態へのアクセスを制御します。\n\n### 6.2 awaitをまたぐ処理には注意する（Reentrancy）\n\nActorのメソッドが`await`で中断されている間、同じActorの別の処理が実行されることがあります。これをReentrancy（再入可能性）と呼びます。\n\n```swift\nactor Example {\n    var value = 0\n\n    func update() async throws {\n        value = 1\n\n        try await Task.sleep(for: .seconds(1))\n\n        print(value) // 1とは限らない\n    }\n\n    func reset() {\n        value = 0\n    }\n}\n```\n\n`update()`が`Task.sleep`で中断されている間に、別のタスクから`reset()`が呼ばれると、`value`は0に書き換えられます。\n\nそのため、`print(value)`の時点で値が1であるとは限りません。\n\nActorは隔離された状態への同時アクセスを防ぎますが、`await`をまたぐ処理全体の整合性までは保証しません。\n\n対策としては、`await`の後に前提条件を再確認する、`await`をまたがずに状態の更新を完結させる、といった方法が考えられます。\n\n## 7. まとめ\n\n本記事では、SwiftのActorについて、基本的な仕組みと使い方を解説しました。\n\n- Actorは、共有された可変状態を安全に扱うための参照型\n- `class`と異なり、状態へのアクセスがActor Isolationで保護される\n- 外部から隔離された状態にアクセスする場合、原則として`await`が必要\n- Actorは特定のスレッドを占有する仕組みではない\n- `await`をまたぐ処理では、Reentrancyを考慮する必要がある\n\nActorは、Swift Concurrencyにおいてデータ競合を防ぐための重要な仕組みです。\n\nまずは簡単なサンプルコードで、`class`とActorの違いを実際に確認してみてください。\n\n次のステップとして、`@MainActor`や`Sendable`、`nonisolated`について学ぶと、Swift Concurrencyの理解がさらに深まります。\n\n## 参考資料\n\n- [The Swift Programming Language — Concurrency](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/)\n- [Swift 6 Migration Guide — Data Race Safety](https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/dataracesafety/)\n- [Apple Developer Documentation — MainActor](https://developer.apple.com/documentation/swift/mainactor)\n","coediting":false,"comments_count":0,"created_at":"2026-10-13T22:08:28+09:00","group":null,"id":"fbf269cc2d371575ddbd","likes_count":0,"private":false,"reactions_count":0,"stocks_count":1,"tags":[{"name":"Swift","versions":[]},{"name":"concurrency","versions":[]}],"title":"【Swift】Actorとは？データ競合を防ぐ仕組みをコードで理解する","updated_at":"2026-10-13T22:08:28+09:00","url":"https://qiita.com/snow_swallow/items/fbf269cc2d371575ddbd","user":{"description":"","facebook_id":"","followees_count":1,"followers_count":2,"github_login_name":null,"id":"snow_swallow","items_count":14,"linkedin_id":"","location":"","name":"snow swallow","organization":"","permanent_id":3326710,"profile_image_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/3326710/43b6a06bedd719bbb76881b1612ff9f5e59e3d88/x_large.png?1683010267","team_only":false,"twitter_screen_name":null,"website_url":""},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003cp data-sourcepos=\"1:1-3:261\"\u003eGoogle Meet で画面を共有するとき、相手にはスライドだけを見せ、自分の窓にだけ台本（発表者ノート）を出したい。そのためのMacアプリを、Xcodeプロジェクトを作らずに \u003ccode\u003eswiftc\u003c/code\u003e で直接ビルドして作りました。\u003cbr\u003e\nKeynote と PowerPoint の無いMacで使うのが前提です。入力には、Chrome headless でPDFにしている \u003ccode\u003esection.slide\u003c/code\u003e 構成のHTMLをそのまま使います。\u003cbr\u003e\nSwift 4ファイル・約1,200行で、半日で完了条件まで通りました。完了条件は、2ウィンドウ表示・発表者側だけにノート・数字キーで非表示スライドへジャンプ・\u003ccode\u003e./build.sh\u003c/code\u003e 一発でビルドの4つです。\u003c/p\u003e\n\u003cp data-sourcepos=\"5:1-5:30\"\u003e以下の順で書きます。\u003c/p\u003e\n\u003cul data-sourcepos=\"7:1-10:0\"\u003e\n\u003cli data-sourcepos=\"7:1-7:104\"\u003e元のHTMLをパースし直さず、CSSとJSを差し込むだけでスライドを描かせる設計\u003c/li\u003e\n\u003cli data-sourcepos=\"8:1-8:56\"\u003e2つの WKWebView の同期とキー入力の取り方\u003c/li\u003e\n\u003cli data-sourcepos=\"9:1-10:0\"\u003e途中で詰まった4か所（WKWebView のサイズ・\u003ccode\u003eNSScreen.main\u003c/code\u003e・UserDefaults のドメイン・信号機ボタン）\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 data-sourcepos=\"11:1-11:9\"\u003e\n\u003cspan id=\"前提\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%89%8D%E6%8F%90\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e前提\u003c/h2\u003e\n\u003cul data-sourcepos=\"13:1-19:0\"\u003e\n\u003cli data-sourcepos=\"13:1-13:7\"\u003emacOS\u003c/li\u003e\n\u003cli data-sourcepos=\"14:1-14:81\"\u003eSwift（\u003ccode\u003eswiftc\u003c/code\u003e で直接ビルド。Xcodeプロジェクトは作らない）\u003c/li\u003e\n\u003cli data-sourcepos=\"15:1-15:20\"\u003eAppKit + WKWebView\u003c/li\u003e\n\u003cli data-sourcepos=\"16:1-16:81\"\u003e入力: \u003ccode\u003esection.slide\u003c/code\u003e を並べたHTML（PDF化にも使っているもの）\u003c/li\u003e\n\u003cli data-sourcepos=\"17:1-17:131\"\u003eビルドは \u003ccode\u003e./build.sh\u003c/code\u003e 1本。検品用に \u003ccode\u003e./build.sh --snapshot \u0026lt;slides.html\u0026gt;\u003c/code\u003e でアプリ自身にPNGを書き出させる\u003c/li\u003e\n\u003cli data-sourcepos=\"18:1-19:0\"\u003eアプリ名は「みせる」。先に作った姉妹アプリ「よむ」と同じ作り方\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"20:1-20:34\"\u003e構成は2ウィンドウです。\u003c/p\u003e\n\u003ctable data-sourcepos=\"22:1-25:124\"\u003e\n\u003cthead\u003e\n\u003ctr data-sourcepos=\"22:1-22:55\"\u003e\n\u003cth data-sourcepos=\"22:2-22:18\"\u003eウィンドウ\u003c/th\u003e\n\u003cth data-sourcepos=\"22:20-22:33\"\u003e置き場所\u003c/th\u003e\n\u003cth data-sourcepos=\"22:35-22:54\"\u003e表示するもの\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr data-sourcepos=\"24:1-24:100\"\u003e\n\u003ctd data-sourcepos=\"24:2-24:12\"\u003e観客側\u003c/td\u003e\n\u003ctd data-sourcepos=\"24:14-24:68\"\u003e外部ディスプレイ（Meet で共有する窓）\u003c/td\u003e\n\u003ctd data-sourcepos=\"24:70-24:99\"\u003e今のスライド1枚だけ\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr data-sourcepos=\"25:1-25:124\"\u003e\n\u003ctd data-sourcepos=\"25:2-25:15\"\u003e発表者側\u003c/td\u003e\n\u003ctd data-sourcepos=\"25:17-25:69\"\u003e内蔵ディスプレイ（カメラのある側）\u003c/td\u003e\n\u003ctd data-sourcepos=\"25:71-25:123\"\u003e今のスライド・次のスライド・ノート\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003ch2 data-sourcepos=\"27:1-27:52\"\u003e\n\u003cspan id=\"手順1-htmlに注入してwebkitに描かせる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%89%8B%E9%A0%861-html%E3%81%AB%E6%B3%A8%E5%85%A5%E3%81%97%E3%81%A6webkit%E3%81%AB%E6%8F%8F%E3%81%8B%E3%81%9B%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e手順1: HTMLに注入してWebKitに描かせる\u003c/h2\u003e\n\u003cp data-sourcepos=\"29:1-29:116\"\u003e設計の中心は「元のHTMLを解析し直さない」ことです。手を入れるのは2か所だけです。\u003c/p\u003e\n\u003col data-sourcepos=\"31:1-33:0\"\u003e\n\u003cli data-sourcepos=\"31:1-31:43\"\u003e\n\u003ccode\u003e\u0026lt;/head\u0026gt;\u003c/code\u003e の直前にCSSを差し込む\u003c/li\u003e\n\u003cli data-sourcepos=\"32:1-33:0\"\u003e\n\u003ccode\u003e\u0026lt;/body\u0026gt;\u003c/code\u003e の直前にJSを差し込む\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"34:1-34:151\"\u003e元のCSSがそのまま効くので、PDFと同じ見た目のまま表示できます。レイアウトエンジンは1行も書いていません。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"36:1-36:13\"\u003e\n\u003cspan id=\"観客側\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%A6%B3%E5%AE%A2%E5%81%B4\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e観客側\u003c/h3\u003e\n\u003cp data-sourcepos=\"38:1-38:90\"\u003e\u003ccode\u003e.slide\u003c/code\u003e を絶対配置で重ね、今の1枚だけ \u003ccode\u003evisibility: visible\u003c/code\u003e にします。\u003c/p\u003e\n\u003cp data-sourcepos=\"40:1-40:291\"\u003eここで \u003ccode\u003edisplay\u003c/code\u003e を切り替える方法は使えませんでした。デッキ側に \u003ccode\u003e.cover{display:flex}\u003c/code\u003e のような指定があると、\u003ccode\u003edisplay\u003c/code\u003e を上書きした時点で表紙のレイアウトが壊れます。\u003ccode\u003evisibility\u003c/code\u003e なら元の \u003ccode\u003edisplay\u003c/code\u003e に触れずに済みます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"css\" data-sourcepos=\"42:1-45:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c\"\u003e/* 観客側: 今の1枚以外は visibility で隠す（display は触らない） */\u003c/span\u003e\n\u003cspan class=\"nc\"\u003e.slide\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nl\"\u003evisibility\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"nb\"\u003ehidden\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"47:1-47:92\"\u003e今の1枚を \u003ccode\u003evisibility: visible\u003c/code\u003e に戻す処理は、注入したJSが担当します。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"49:1-49:16\"\u003e\n\u003cspan id=\"発表者側\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E7%99%BA%E8%A1%A8%E8%80%85%E5%81%B4\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e発表者側\u003c/h3\u003e\n\u003cp data-sourcepos=\"51:1-51:190\"\u003e\u003ccode\u003e.slide\u003c/code\u003e をいったん退避し、今と次のスライドを \u003ccode\u003ecloneNode\u003c/code\u003e で複製して並べます。複製なので元のCSSが当たり、観客側と同じ見た目になります。\u003c/p\u003e\n\u003cp data-sourcepos=\"53:1-53:289\"\u003e発表者側のUI（ノート欄など）は \u003ccode\u003ediv\u003c/code\u003e と \u003ccode\u003espan\u003c/code\u003e だけで組みました。それでも、デッキ側のCSSにある \u003ccode\u003ep{font-size:17px}\u003c/code\u003e や \u003ccode\u003eh1{…}\u003c/code\u003e がノート本文に当たってしまいます。発表者UIの中では \u003ccode\u003einherit !important\u003c/code\u003e で打ち消しました。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"css\" data-sourcepos=\"55:1-58:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c\"\u003e/* 発表者UIの中ではデッキ側の文字指定を打ち消す */\u003c/span\u003e\n\u003cspan class=\"nt\"\u003efont-size\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nt\"\u003einherit\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"nt\"\u003eimportant\u003c/span\u003e\u003cspan class=\"o\"\u003e;\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch2 data-sourcepos=\"60:1-60:58\"\u003e\n\u003cspan id=\"手順2-構造はjsが読み進行はswiftが持つ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%89%8B%E9%A0%862-%E6%A7%8B%E9%80%A0%E3%81%AFjs%E3%81%8C%E8%AA%AD%E3%81%BF%E9%80%B2%E8%A1%8C%E3%81%AFswift%E3%81%8C%E6%8C%81%E3%81%A4\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e手順2: 構造はJSが読み、進行はSwiftが持つ\u003c/h2\u003e\n\u003cp data-sourcepos=\"62:1-62:42\"\u003e役割を次のように分けました。\u003c/p\u003e\n\u003cul data-sourcepos=\"64:1-65:0\"\u003e\n\u003cli data-sourcepos=\"64:1-65:0\"\u003e\n\u003cstrong\u003eJS → Swift\u003c/strong\u003e: 読み込み後、デッキの構造を1回だけ送る\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"json\" data-sourcepos=\"66:1-68:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\u003cspan class=\"nl\"\u003e\"type\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s2\"\u003e\"deck\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nl\"\u003e\"slides\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e[{\u003c/span\u003e\u003cspan class=\"nl\"\u003e\"n\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nl\"\u003e\"title\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s2\"\u003e\"...\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nl\"\u003e\"hidden\"\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\u003cspan class=\"p\"\u003e}]}\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cul data-sourcepos=\"70:1-71:0\"\u003e\n\u003cli data-sourcepos=\"70:1-71:0\"\u003e\n\u003cstrong\u003eSwift → JS\u003c/strong\u003e: ページ送りのたびに、2つの WKWebView 両方へ同じ関数を呼ぶ\u003c/li\u003e\n\u003c/ul\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"javascript\" data-sourcepos=\"72:1-74:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"nx\"\u003emiseru\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003ego\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003ei\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"76:1-76:179\"\u003e2つの窓は同じプロセスの中にあります。そのため同期は「同じ関数を2回呼ぶ」だけで済み、通信や状態の突き合わせは要りません。\u003c/p\u003e\n\u003cp data-sourcepos=\"78:1-78:264\"\u003e\u003ccode\u003ehidden\u003c/code\u003e は非表示スライドの印です。通常のページ送りでは飛ばし、数字キーで番号を指定したときだけ表示します。質疑応答用の補足スライドを、聞かれたときだけ出す使い方を想定しています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"80:1-80:72\"\u003e\n\u003cspan id=\"手順3-キー入力はウィンドウでなくアプリで受ける\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%89%8B%E9%A0%863-%E3%82%AD%E3%83%BC%E5%85%A5%E5%8A%9B%E3%81%AF%E3%82%A6%E3%82%A3%E3%83%B3%E3%83%89%E3%82%A6%E3%81%A7%E3%81%AA%E3%81%8F%E3%82%A2%E3%83%97%E3%83%AA%E3%81%A7%E5%8F%97%E3%81%91%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e手順3: キー入力はウィンドウでなくアプリで受ける\u003c/h2\u003e\n\u003cp data-sourcepos=\"82:1-82:245\"\u003eどちらの窓が前面にあっても、また WKWebView がフォーカスを持っていても、同じキーで操作できるようにします。そのため、キーの受け口をウィンドウからアプリ全体に移しました。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"84:1-89:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kt\"\u003eNSEvent\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eaddLocalMonitorForEvents\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ematching\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ekeyDown\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003eevent\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// ⌘⌃⌥ が付いたキーはメニューへ流し、それ以外はスライド操作として消費する\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e...\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"91:1-91:174\"\u003e修飾キー（⌘⌃⌥）付きのキーだけは素通しにしてメニューへ流します。全部を握りつぶすと ⌘Q などが効かなくなるためです。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"93:1-93:33\"\u003e\n\u003cspan id=\"起きたこと落とし穴\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%B5%B7%E3%81%8D%E3%81%9F%E3%81%93%E3%81%A8%E8%90%BD%E3%81%A8%E3%81%97%E7%A9%B4\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e起きたこと／落とし穴\u003c/h2\u003e\n\u003ch3 data-sourcepos=\"95:1-95:53\"\u003e\n\u003cspan id=\"1-wkwebview-が窓より-16090-小さいまま\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#1-wkwebview-%E3%81%8C%E7%AA%93%E3%82%88%E3%82%8A-16090-%E5%B0%8F%E3%81%95%E3%81%84%E3%81%BE%E3%81%BE\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e1. WKWebView が窓より 160×90 小さいまま\u003c/h3\u003e\n\u003cp data-sourcepos=\"97:1-97:138\"\u003eスクリーンショットで気づきました。表示領域が窓より 160×90 小さく、右下に隙間ができていました。\u003c/p\u003e\n\u003cp data-sourcepos=\"99:1-99:39\"\u003e原因は次の順番にあります。\u003c/p\u003e\n\u003col data-sourcepos=\"101:1-104:0\"\u003e\n\u003cli data-sourcepos=\"101:1-101:83\"\u003e\n\u003ccode\u003ewindow.contentView = v\u003c/code\u003e の時点で、\u003ccode\u003ev\u003c/code\u003e は窓の大きさに揃えられる\u003c/li\u003e\n\u003cli data-sourcepos=\"102:1-102:86\"\u003eその後に \u003ccode\u003eaddSubview(web)\u003c/code\u003e した \u003ccode\u003eweb\u003c/code\u003e は、初期フレームのまま残る\u003c/li\u003e\n\u003cli data-sourcepos=\"103:1-104:0\"\u003e\n\u003ccode\u003eautoresizingMask\u003c/code\u003e は「以後の差分」を追うだけで、最初のズレは直さない\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"105:1-105:136\"\u003e「よむ」では \u003ccode\u003eweb\u003c/code\u003e を最初から窓と同じ寸法で作っていたので、同じ問題は表に出ていませんでした。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"107:1-107:74\"\u003e\n\u003cspan id=\"2-nsscreenmain-が起動直後に外部ディスプレイを返す\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#2-nsscreenmain-%E3%81%8C%E8%B5%B7%E5%8B%95%E7%9B%B4%E5%BE%8C%E3%81%AB%E5%A4%96%E9%83%A8%E3%83%87%E3%82%A3%E3%82%B9%E3%83%97%E3%83%AC%E3%82%A4%E3%82%92%E8%BF%94%E3%81%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e2. \u003ccode\u003eNSScreen.main\u003c/code\u003e が起動直後に外部ディスプレイを返す\u003c/h3\u003e\n\u003cp data-sourcepos=\"109:1-109:302\"\u003e発表者の窓を「主画面」に置いたつもりが外部ディスプレイに出て、観客の窓が内蔵ディスプレイに来ました。手元の環境では、起動直後のキーウィンドウが無いタイミングで \u003ccode\u003eNSScreen.main\u003c/code\u003e が外部ディスプレイを返しました。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"111:1-111:93\"\u003e\n\u003cspan id=\"3-swiftc-直ビルドの生バイナリは-userdefaults-のドメインが別になる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#3-swiftc-%E7%9B%B4%E3%83%93%E3%83%AB%E3%83%89%E3%81%AE%E7%94%9F%E3%83%90%E3%82%A4%E3%83%8A%E3%83%AA%E3%81%AF-userdefaults-%E3%81%AE%E3%83%89%E3%83%A1%E3%82%A4%E3%83%B3%E3%81%8C%E5%88%A5%E3%81%AB%E3%81%AA%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e3. \u003ccode\u003eswiftc\u003c/code\u003e 直ビルドの生バイナリは UserDefaults のドメインが別になる\u003c/h3\u003e\n\u003cp data-sourcepos=\"113:1-113:119\"\u003e検証中に、古いウィンドウ座標が復元されて「置き場所がおかしい」と30分迷いました。\u003c/p\u003e\n\u003cp data-sourcepos=\"115:1-115:363\"\u003e\u003ccode\u003eswiftc\u003c/code\u003e で作った生バイナリを直接実行すると、UserDefaults のドメインが実行ファイル名（\u003ccode\u003emiseru-bin\u003c/code\u003e）になります。\u003ccode\u003e.app\u003c/code\u003e として起動したときのドメインは \u003ccode\u003ecom.webharu.miseru\u003c/code\u003e です。2つは別物なので、生バイナリで試したときの座標が \u003ccode\u003e.app\u003c/code\u003e 側からは見えず、その逆も起きます。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"117:1-117:57\"\u003e\n\u003cspan id=\"4-meet-の窓リストに信号機ボタンが写る\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#4-meet-%E3%81%AE%E7%AA%93%E3%83%AA%E3%82%B9%E3%83%88%E3%81%AB%E4%BF%A1%E5%8F%B7%E6%A9%9F%E3%83%9C%E3%82%BF%E3%83%B3%E3%81%8C%E5%86%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e4. Meet の窓リストに信号機ボタンが写る\u003c/h3\u003e\n\u003cp data-sourcepos=\"119:1-119:146\"\u003eMeet で共有する窓を選ぶ一覧に、観客側の窓の信号機ボタン（閉じる・最小化・最大化）が写り込みました。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"121:1-121:9\"\u003e\n\u003cspan id=\"対処\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AF%BE%E5%87%A6\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e対処\u003c/h2\u003e\n\u003ch3 data-sourcepos=\"123:1-123:26\"\u003e\n\u003cspan id=\"wkwebview-のサイズ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#wkwebview-%E3%81%AE%E3%82%B5%E3%82%A4%E3%82%BA\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eWKWebView のサイズ\u003c/h3\u003e\n\u003cp data-sourcepos=\"125:1-125:76\"\u003e窓に載せる直前に、フレームを contentView に合わせます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"127:1-129:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"n\"\u003eweb\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eframe\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003econtentView\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ebounds\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch3 data-sourcepos=\"131:1-131:52\"\u003e\n\u003cspan id=\"内蔵ディスプレイを発表者用に固定\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%86%85%E8%94%B5%E3%83%87%E3%82%A3%E3%82%B9%E3%83%97%E3%83%AC%E3%82%A4%E3%82%92%E7%99%BA%E8%A1%A8%E8%80%85%E7%94%A8%E3%81%AB%E5%9B%BA%E5%AE%9A\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e内蔵ディスプレイを発表者用に固定\u003c/h3\u003e\n\u003cp data-sourcepos=\"133:1-133:265\"\u003e\u003ccode\u003eNSScreen.main\u003c/code\u003e に頼らず、\u003ccode\u003eCGDisplayIsBuiltin\u003c/code\u003e で内蔵ディスプレイを判定して発表者の窓を置きます。内蔵ディスプレイはカメラのある側なので、台本を見ながらでも目線がカメラから大きく外れません。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"135:1-138:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e// 各 NSScreen のディスプレイIDに対して CGDisplayIsBuiltin で内蔵かどうかを判定する\u003c/span\u003e\n\u003cspan class=\"kt\"\u003eCGDisplayIsBuiltin\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003edisplayID\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch3 data-sourcepos=\"140:1-140:32\"\u003e\n\u003cspan id=\"userdefaults-のドメイン\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#userdefaults-%E3%81%AE%E3%83%89%E3%83%A1%E3%82%A4%E3%83%B3\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eUserDefaults のドメイン\u003c/h3\u003e\n\u003cp data-sourcepos=\"142:1-142:75\"\u003e検証の前に、両方のドメインを消してから起動します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"144:1-147:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003edefaults delete miseru-bin\ndefaults delete com.webharu.miseru\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch3 data-sourcepos=\"149:1-149:22\"\u003e\n\u003cspan id=\"信号機ボタン\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E4%BF%A1%E5%8F%B7%E6%A9%9F%E3%83%9C%E3%82%BF%E3%83%B3\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e信号機ボタン\u003c/h3\u003e\n\u003cp data-sourcepos=\"151:1-151:108\"\u003eタイトルバーを内容に重ねて隠し、マウスが乗ったときだけボタンを出します。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"153:1-158:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e// styleMask に .fullSizeContentView を足す\u003c/span\u003e\n\u003cspan class=\"n\"\u003ewindow\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etitleVisibility\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehidden\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e// tracking area でマウスの出入りを拾い、乗っている間だけ表示する\u003c/span\u003e\n\u003cspan class=\"n\"\u003ewindow\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003estandardWindowButton\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecloseButton\u003c/span\u003e\u003cspan class=\"p\"\u003e)?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisHidden\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch3 data-sourcepos=\"160:1-160:10\"\u003e\n\u003cspan id=\"検品\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%A4%9C%E5%93%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e検品\u003c/h3\u003e\n\u003cp data-sourcepos=\"162:1-162:90\"\u003e検品は目視ではなく、アプリ自身にPNGを書き出させて行いました。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"bash\" data-sourcepos=\"164:1-166:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e./build.sh \u003cspan class=\"nt\"\u003e--snapshot\u003c/span\u003e \u0026lt;slides.html\u0026gt;\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"168:1-168:221\"\u003e撮るのは2窓ぶんです。通常のページ送りに加え、数字キーで非表示スライドへ飛んだ後の状態も撮ります。上記のWKWebView のサイズずれも、このPNGで見つけました。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"170:1-170:12\"\u003e\n\u003cspan id=\"まとめ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%BE%E3%81%A8%E3%82%81\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eまとめ\u003c/h2\u003e\n\u003cul data-sourcepos=\"172:1-176:141\"\u003e\n\u003cli data-sourcepos=\"172:1-172:192\"\u003e「HTMLに注入してWebKitに描かせる」方式にすると、レイアウトを自前で組まずにプレゼン機能を作れる。元のCSSが効くので、PDFと見た目が揃う\u003c/li\u003e\n\u003cli data-sourcepos=\"173:1-173:149\"\u003e構造の読み取りはJS、進行管理はSwiftに分ける。同一プロセスの2窓なら、同期は同じ関数を2回呼ぶだけで済む\u003c/li\u003e\n\u003cli data-sourcepos=\"174:1-174:125\"\u003eキーは \u003ccode\u003eNSEvent.addLocalMonitorForEvents\u003c/code\u003e でアプリ全体から拾う。修飾キー付きだけメニューへ流す\u003c/li\u003e\n\u003cli data-sourcepos=\"175:1-175:115\"\u003e\n\u003ccode\u003eaddSubview\u003c/code\u003e した WKWebView は \u003ccode\u003eautoresizingMask\u003c/code\u003e 任せにせず、載せる直前に \u003ccode\u003eframe\u003c/code\u003e を合わせる\u003c/li\u003e\n\u003cli data-sourcepos=\"176:1-176:141\"\u003e\n\u003ccode\u003eswiftc\u003c/code\u003e 直ビルドで検証するときは、生バイナリと \u003ccode\u003e.app\u003c/code\u003e で UserDefaults のドメインが別になる点に注意する\u003c/li\u003e\n\u003c/ul\u003e\n","body":"Google Meet で画面を共有するとき、相手にはスライドだけを見せ、自分の窓にだけ台本（発表者ノート）を出したい。そのためのMacアプリを、Xcodeプロジェクトを作らずに `swiftc` で直接ビルドして作りました。\nKeynote と PowerPoint の無いMacで使うのが前提です。入力には、Chrome headless でPDFにしている `section.slide` 構成のHTMLをそのまま使います。\nSwift 4ファイル・約1,200行で、半日で完了条件まで通りました。完了条件は、2ウィンドウ表示・発表者側だけにノート・数字キーで非表示スライドへジャンプ・`./build.sh` 一発でビルドの4つです。\n\n以下の順で書きます。\n\n- 元のHTMLをパースし直さず、CSSとJSを差し込むだけでスライドを描かせる設計\n- 2つの WKWebView の同期とキー入力の取り方\n- 途中で詰まった4か所（WKWebView のサイズ・`NSScreen.main`・UserDefaults のドメイン・信号機ボタン）\n\n## 前提\n\n- macOS\n- Swift（`swiftc` で直接ビルド。Xcodeプロジェクトは作らない）\n- AppKit + WKWebView\n- 入力: `section.slide` を並べたHTML（PDF化にも使っているもの）\n- ビルドは `./build.sh` 1本。検品用に `./build.sh --snapshot \u003cslides.html\u003e` でアプリ自身にPNGを書き出させる\n- アプリ名は「みせる」。先に作った姉妹アプリ「よむ」と同じ作り方\n\n構成は2ウィンドウです。\n\n| ウィンドウ | 置き場所 | 表示するもの |\n|---|---|---|\n| 観客側 | 外部ディスプレイ（Meet で共有する窓） | 今のスライド1枚だけ |\n| 発表者側 | 内蔵ディスプレイ（カメラのある側） | 今のスライド・次のスライド・ノート |\n\n## 手順1: HTMLに注入してWebKitに描かせる\n\n設計の中心は「元のHTMLを解析し直さない」ことです。手を入れるのは2か所だけです。\n\n1. `\u003c/head\u003e` の直前にCSSを差し込む\n2. `\u003c/body\u003e` の直前にJSを差し込む\n\n元のCSSがそのまま効くので、PDFと同じ見た目のまま表示できます。レイアウトエンジンは1行も書いていません。\n\n### 観客側\n\n`.slide` を絶対配置で重ね、今の1枚だけ `visibility: visible` にします。\n\nここで `display` を切り替える方法は使えませんでした。デッキ側に `.cover{display:flex}` のような指定があると、`display` を上書きした時点で表紙のレイアウトが壊れます。`visibility` なら元の `display` に触れずに済みます。\n\n```css\n/* 観客側: 今の1枚以外は visibility で隠す（display は触らない） */\n.slide { visibility: hidden; }\n```\n\n今の1枚を `visibility: visible` に戻す処理は、注入したJSが担当します。\n\n### 発表者側\n\n`.slide` をいったん退避し、今と次のスライドを `cloneNode` で複製して並べます。複製なので元のCSSが当たり、観客側と同じ見た目になります。\n\n発表者側のUI（ノート欄など）は `div` と `span` だけで組みました。それでも、デッキ側のCSSにある `p{font-size:17px}` や `h1{…}` がノート本文に当たってしまいます。発表者UIの中では `inherit !important` で打ち消しました。\n\n```css\n/* 発表者UIの中ではデッキ側の文字指定を打ち消す */\nfont-size: inherit !important;\n```\n\n## 手順2: 構造はJSが読み、進行はSwiftが持つ\n\n役割を次のように分けました。\n\n- **JS → Swift**: 読み込み後、デッキの構造を1回だけ送る\n\n```json\n{\"type\": \"deck\", \"slides\": [{\"n\": 1, \"title\": \"...\", \"hidden\": false}]}\n```\n\n- **Swift → JS**: ページ送りのたびに、2つの WKWebView 両方へ同じ関数を呼ぶ\n\n```javascript\nmiseru.go(i)\n```\n\n2つの窓は同じプロセスの中にあります。そのため同期は「同じ関数を2回呼ぶ」だけで済み、通信や状態の突き合わせは要りません。\n\n`hidden` は非表示スライドの印です。通常のページ送りでは飛ばし、数字キーで番号を指定したときだけ表示します。質疑応答用の補足スライドを、聞かれたときだけ出す使い方を想定しています。\n\n## 手順3: キー入力はウィンドウでなくアプリで受ける\n\nどちらの窓が前面にあっても、また WKWebView がフォーカスを持っていても、同じキーで操作できるようにします。そのため、キーの受け口をウィンドウからアプリ全体に移しました。\n\n```swift\nNSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in\n    // ⌘⌃⌥ が付いたキーはメニューへ流し、それ以外はスライド操作として消費する\n    ...\n}\n```\n\n修飾キー（⌘⌃⌥）付きのキーだけは素通しにしてメニューへ流します。全部を握りつぶすと ⌘Q などが効かなくなるためです。\n\n## 起きたこと／落とし穴\n\n### 1. WKWebView が窓より 160×90 小さいまま\n\nスクリーンショットで気づきました。表示領域が窓より 160×90 小さく、右下に隙間ができていました。\n\n原因は次の順番にあります。\n\n1. `window.contentView = v` の時点で、`v` は窓の大きさに揃えられる\n2. その後に `addSubview(web)` した `web` は、初期フレームのまま残る\n3. `autoresizingMask` は「以後の差分」を追うだけで、最初のズレは直さない\n\n「よむ」では `web` を最初から窓と同じ寸法で作っていたので、同じ問題は表に出ていませんでした。\n\n### 2. `NSScreen.main` が起動直後に外部ディスプレイを返す\n\n発表者の窓を「主画面」に置いたつもりが外部ディスプレイに出て、観客の窓が内蔵ディスプレイに来ました。手元の環境では、起動直後のキーウィンドウが無いタイミングで `NSScreen.main` が外部ディスプレイを返しました。\n\n### 3. `swiftc` 直ビルドの生バイナリは UserDefaults のドメインが別になる\n\n検証中に、古いウィンドウ座標が復元されて「置き場所がおかしい」と30分迷いました。\n\n`swiftc` で作った生バイナリを直接実行すると、UserDefaults のドメインが実行ファイル名（`miseru-bin`）になります。`.app` として起動したときのドメインは `com.webharu.miseru` です。2つは別物なので、生バイナリで試したときの座標が `.app` 側からは見えず、その逆も起きます。\n\n### 4. Meet の窓リストに信号機ボタンが写る\n\nMeet で共有する窓を選ぶ一覧に、観客側の窓の信号機ボタン（閉じる・最小化・最大化）が写り込みました。\n\n## 対処\n\n### WKWebView のサイズ\n\n窓に載せる直前に、フレームを contentView に合わせます。\n\n```swift\nweb.frame = contentView.bounds\n```\n\n### 内蔵ディスプレイを発表者用に固定\n\n`NSScreen.main` に頼らず、`CGDisplayIsBuiltin` で内蔵ディスプレイを判定して発表者の窓を置きます。内蔵ディスプレイはカメラのある側なので、台本を見ながらでも目線がカメラから大きく外れません。\n\n```swift\n// 各 NSScreen のディスプレイIDに対して CGDisplayIsBuiltin で内蔵かどうかを判定する\nCGDisplayIsBuiltin(displayID)\n```\n\n### UserDefaults のドメイン\n\n検証の前に、両方のドメインを消してから起動します。\n\n```bash\ndefaults delete miseru-bin\ndefaults delete com.webharu.miseru\n```\n\n### 信号機ボタン\n\nタイトルバーを内容に重ねて隠し、マウスが乗ったときだけボタンを出します。\n\n```swift\n// styleMask に .fullSizeContentView を足す\nwindow.titleVisibility = .hidden\n// tracking area でマウスの出入りを拾い、乗っている間だけ表示する\nwindow.standardWindowButton(.closeButton)?.isHidden = false\n```\n\n### 検品\n\n検品は目視ではなく、アプリ自身にPNGを書き出させて行いました。\n\n```bash\n./build.sh --snapshot \u003cslides.html\u003e\n```\n\n撮るのは2窓ぶんです。通常のページ送りに加え、数字キーで非表示スライドへ飛んだ後の状態も撮ります。上記のWKWebView のサイズずれも、このPNGで見つけました。\n\n## まとめ\n\n- 「HTMLに注入してWebKitに描かせる」方式にすると、レイアウトを自前で組まずにプレゼン機能を作れる。元のCSSが効くので、PDFと見た目が揃う\n- 構造の読み取りはJS、進行管理はSwiftに分ける。同一プロセスの2窓なら、同期は同じ関数を2回呼ぶだけで済む\n- キーは `NSEvent.addLocalMonitorForEvents` でアプリ全体から拾う。修飾キー付きだけメニューへ流す\n- `addSubview` した WKWebView は `autoresizingMask` 任せにせず、載せる直前に `frame` を合わせる\n- `swiftc` 直ビルドで検証するときは、生バイナリと `.app` で UserDefaults のドメインが別になる点に注意する\n","coediting":false,"comments_count":0,"created_at":"2026-10-13T15:19:44+09:00","group":null,"id":"8eb7a03fff077da809de","likes_count":0,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Swift","versions":[]},{"name":"macOS","versions":[]},{"name":"WKWebView","versions":[]},{"name":"AppKit","versions":[]},{"name":"JavaScript","versions":[]}],"title":"【Swift】Xcodeなしで発表者ノート付きプレゼンアプリを作る（WKWebView2窓同期）","updated_at":"2026-10-13T15:20:16+09:00","url":"https://qiita.com/kusarikakemron/items/8eb7a03fff077da809de","user":{"description":"初心者でもWebflowを使えるか知りたいですか？本記事ではWeb初心者の方でもスグにプロのようなWebサイトを作れる方法を解説しています｡記事を読みながら手を動かしてWebサイト作ってみましょう｡","facebook_id":"","followees_count":1,"followers_count":1,"github_login_name":null,"id":"kusarikakemron","items_count":11,"linkedin_id":"","location":"","name":"metana","organization":"","permanent_id":2523637,"profile_image_url":"https://lh3.googleusercontent.com/a/AATXAJxzxgakG72qy2hsHtL6PRiNwFgreKMqnNpzhtb1=s50-mo","team_only":false,"twitter_screen_name":null,"website_url":"https://www.non-haru.com"},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null},{"rendered_body":"\u003ch2 data-sourcepos=\"2:1-2:30\"\u003e\n\u003cspan id=\"対象読者と動作環境\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AF%BE%E8%B1%A1%E8%AA%AD%E8%80%85%E3%81%A8%E5%8B%95%E4%BD%9C%E7%92%B0%E5%A2%83\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e対象読者と動作環境\u003c/h2\u003e\n\u003cp data-sourcepos=\"4:1-4:459\"\u003emacOS で「他のアプリがシステムカーソルを消したか」を判定したい人、Swift から SDK 経由では呼べない C 関数を \u003ccode\u003edlsym\u003c/code\u003e で呼ぶ方法を知りたい人を対象にしています。題材はメニューバー常駐アプリ「Neon Pointer」の \u003ca href=\"https://github.com/Tanishi-z/NeonPointer/releases/tag/v1.3.0\" rel=\"nofollow noopener\" target=\"_blank\"\u003ev1.3.0\u003c/a\u003e で追加した「カーソルが消えたらネオンも隠す」モードです（既定は OFF）。\u003c/p\u003e\n\u003cp data-sourcepos=\"6:1-6:88\"\u003eアプリの動作環境とビルド設定（\u003ccode\u003eproject.yml\u003c/code\u003e）は次のとおりです。\u003c/p\u003e\n\u003cul data-sourcepos=\"8:1-11:0\"\u003e\n\u003cli data-sourcepos=\"8:1-8:52\"\u003emacOS 14 以上（\u003ccode\u003edeploymentTarget\u003c/code\u003e が \u003ccode\u003e14.0\u003c/code\u003e）\u003c/li\u003e\n\u003cli data-sourcepos=\"9:1-9:39\"\u003eSwift 5.0（\u003ccode\u003eSWIFT_VERSION: \"5.0\"\u003c/code\u003e）\u003c/li\u003e\n\u003cli data-sourcepos=\"10:1-11:0\"\u003eXcodeGen（ビルド時のみ）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"12:1-12:75\"\u003e記事中の検証スクリプトは、次の環境で実行しました。\u003c/p\u003e\n\u003cul data-sourcepos=\"14:1-17:0\"\u003e\n\u003cli data-sourcepos=\"14:1-14:41\"\u003emacOS 26.6.2（Apple Silicon / arm64）\u003c/li\u003e\n\u003cli data-sourcepos=\"15:1-15:42\"\u003eXcode 27.0（27A266a）、macOS 27.0 SDK\u003c/li\u003e\n\u003cli data-sourcepos=\"16:1-17:0\"\u003eApple Swift 6.4（\u003ccode\u003eswiftc\u003c/code\u003e で直接コンパイル）\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"18:1-18:376\"\u003eリポジトリは \u003ca href=\"https://github.com/Tanishi-z/NeonPointer\" rel=\"nofollow noopener\" target=\"_blank\"\u003eTanishi-z/NeonPointer\u003c/a\u003e です。なぜ2つの判定を重ねたのか、既定を OFF にした判断などの設計の話は、ペアの \u003ca href=\"https://zenn.dev/tanishi_z/articles/31-neonpointer-cursor-hidden-design\" rel=\"nofollow noopener\" target=\"_blank\"\u003eZenn 版\u003c/a\u003eで扱っています。この記事はコード、手順、実際の出力に絞ります。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"20:1-20:40\"\u003e\n\u003cspan id=\"systemcursorvisibility-の全体像\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#systemcursorvisibility-%E3%81%AE%E5%85%A8%E4%BD%93%E5%83%8F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003ccode\u003eSystemCursorVisibility\u003c/code\u003e の全体像\u003c/h2\u003e\n\u003cp data-sourcepos=\"22:1-22:112\"\u003e判定は新規ファイル \u003ccode\u003eSystemCursorVisibility.swift\u003c/code\u003e の25行にまとまっています。全文です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"24:1-50:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSystemCursorVisibility.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eAppKit\u003c/span\u003e\n\n\u003cspan class=\"c1\"\u003e/// システムカーソルが表示中かどうかを返す。\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// ブラウザの全画面動画などは `CGDisplayHideCursor` ではなく、1x1 の透明な\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// カーソル画像に差し替えて隠すため、現在のカーソル画像の大きさで判定する。\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// `CGCursorIsVisible` は非推奨で SDK のヘッダから外れているため `dlsym` で引き、\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// こちらの方法で隠すアプリにも備える。シンボルが無ければ画像の判定だけを使う。\u003c/span\u003e\n\u003cspan class=\"kd\"\u003e@MainActor\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eenum\u003c/span\u003e \u003cspan class=\"kt\"\u003eSystemCursorVisibility\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003etypealias\u003c/span\u003e \u003cspan class=\"kt\"\u003eIsVisibleFunction\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kd\"\u003e@convention\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ec\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eInt32\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eisVisibleFunction\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eIsVisibleFunction\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ehandle\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003edlopen\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kc\"\u003enil\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eRTLD_NOW\u003c/span\u003e\u003cspan class=\"p\"\u003e),\u003c/span\u003e\n              \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esymbol\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003edlsym\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ehandle\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s\"\u003e\"CGCursorIsVisible\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"nf\"\u003eunsafeBitCast\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esymbol\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eto\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eIsVisibleFunction\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}()\u003c/span\u003e\n\n    \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eisVisible\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eisVisibleFunction\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nf\"\u003eisVisibleFunction\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esize\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSCursor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrentSystem\u003c/span\u003e\u003cspan class=\"p\"\u003e?\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eimage\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esize\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003esize\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ewidth\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003esize\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eheight\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;=\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n            \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"52:1-52:47\"\u003e\u003ccode\u003eisVisible()\u003c/code\u003e は次の順に判定します。\u003c/p\u003e\n\u003col data-sourcepos=\"54:1-57:0\"\u003e\n\u003cli data-sourcepos=\"54:1-54:70\"\u003e\n\u003ccode\u003eCGCursorIsVisible\u003c/code\u003e が取れていて、0 を返したら非表示\u003c/li\u003e\n\u003cli data-sourcepos=\"55:1-55:82\"\u003e\n\u003ccode\u003eNSCursor.currentSystem\u003c/code\u003e の画像が幅・高さとも 1 以下なら非表示\u003c/li\u003e\n\u003cli data-sourcepos=\"56:1-57:0\"\u003eどちらにも当てはまらなければ表示中\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp data-sourcepos=\"58:1-58:311\"\u003eどちらかの手がかりが取れないとき（シンボルが無い、\u003ccode\u003ecurrentSystem\u003c/code\u003e が \u003ccode\u003enil\u003c/code\u003e）は、その判定を飛ばします。どちらも取れなければ \u003ccode\u003etrue\u003c/code\u003e（表示中）を返します。判定に失敗したときは、ネオンを出し続ける側に倒れるということです。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"60:1-60:84\"\u003e\n\u003cspan id=\"dlopendlsym-と-conventionc-で-swift-から呼べない関数を呼ぶ\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#dlopendlsym-%E3%81%A8-conventionc-%E3%81%A7-swift-%E3%81%8B%E3%82%89%E5%91%BC%E3%81%B9%E3%81%AA%E3%81%84%E9%96%A2%E6%95%B0%E3%82%92%E5%91%BC%E3%81%B6\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003ccode\u003edlopen\u003c/code\u003e/\u003ccode\u003edlsym\u003c/code\u003e と \u003ccode\u003e@convention(c)\u003c/code\u003e で Swift から呼べない関数を呼ぶ\u003c/h2\u003e\n\u003ch3 data-sourcepos=\"62:1-62:64\"\u003e\n\u003cspan id=\"swift-から直接呼ぶとコンパイルエラーになる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#swift-%E3%81%8B%E3%82%89%E7%9B%B4%E6%8E%A5%E5%91%BC%E3%81%B6%E3%81%A8%E3%82%B3%E3%83%B3%E3%83%91%E3%82%A4%E3%83%AB%E3%82%A8%E3%83%A9%E3%83%BC%E3%81%AB%E3%81%AA%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eSwift から直接呼ぶとコンパイルエラーになる\u003c/h3\u003e\n\u003cp data-sourcepos=\"64:1-64:185\"\u003eコードのコメントには「SDK のヘッダから外れている」とあります。ただ、手元の macOS 27.0 SDK を grep すると、宣言自体は残っていました。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"console\" data-sourcepos=\"66:1-69:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"gp\"\u003e$\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"nb\"\u003egrep\u003c/span\u003e \u003cspan class=\"nt\"\u003e-rn\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"CGCursorIsVisible\"\u003c/span\u003e \u003cspan class=\"s2\"\u003e\"\u003c/span\u003e\u003cspan class=\"si\"\u003e$(\u003c/span\u003excrun \u003cspan class=\"nt\"\u003e--show-sdk-path\u003c/span\u003e\u003cspan class=\"si\"\u003e)\u003c/span\u003e\u003cspan class=\"s2\"\u003e/System/Library/Frameworks/CoreGraphics.framework/Headers/\"\u003c/span\u003e\n\u003cspan class=\"go\"\u003e/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/CoreGraphics.framework/Headers/CGRemoteOperation.h:203:CG_EXTERN boolean_t CGCursorIsVisible(void)\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"c\" data-sourcepos=\"71:1-76:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eCGRemoteOperation.h\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"cm\"\u003e/* Return true if the mouse cursor is visible, false otherwise. */\u003c/span\u003e\n\n\u003cspan class=\"n\"\u003eCG_EXTERN\u003c/span\u003e \u003cspan class=\"n\"\u003eboolean_t\u003c/span\u003e \u003cspan class=\"nf\"\u003eCGCursorIsVisible\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003evoid\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"n\"\u003eAPI_DEPRECATED\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"No longer supported\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003emacos\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e10\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"mi\"\u003e3\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"mi\"\u003e10\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"mi\"\u003e9\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"78:1-78:96\"\u003e宣言は残っていますが、Swift から直接呼ぶとコンパイルが通りません。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"80:1-83:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003edirect.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eCoreGraphics\u003c/span\u003e\n\u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kt\"\u003eCGCursorIsVisible\u003c/span\u003e\u003cspan class=\"p\"\u003e())\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"console\" data-sourcepos=\"85:1-100:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"gp\"\u003e$\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003eswiftc direct.swift \u003cspan class=\"nt\"\u003e-o\u003c/span\u003e direct\n\u003cspan class=\"go\"\u003edirect.swift:2:7: error: 'CGCursorIsVisible()' is unavailable in macOS: No longer supported\n1 | import CoreGraphics\n2 | print(CGCursorIsVisible())\n  |       `- error: 'CGCursorIsVisible()' is unavailable in macOS: No longer supported\n3 | \n\n/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.0.sdk/System/Library/Frameworks/CoreGraphics.framework/Headers/CGRemoteOperation.h:203:21: note: 'CGCursorIsVisible()' has been explicitly marked unavailable here\n201 | /* Return true if the mouse cursor is visible, false otherwise. */\n202 | \n203 | CG_EXTERN boolean_t CGCursorIsVisible(void)\n    |                     `- note: 'CGCursorIsVisible()' has been explicitly marked unavailable here\n\u003c/span\u003e\u003cspan class=\"gp\"\u003e204 |     API_DEPRECATED(\"No longer supported\", macos(10.3,10.9));\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003cspan class=\"go\"\u003e205 | \n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"102:1-102:453\"\u003e\u003ccode\u003e-target arm64-apple-macos14.0\u003c/code\u003e を付けても同じエラーでした。Apple のドキュメントでも \u003ca href=\"https://developer.apple.com/documentation/coregraphics/cgcursorisvisible()\" rel=\"nofollow noopener\" target=\"_blank\"\u003e\u003ccode\u003eCGCursorIsVisible()\u003c/code\u003e\u003c/a\u003e は非推奨で、「There is no replacement.」とされています。一方、シンボル自体は CoreGraphics の \u003ccode\u003e.tbd\u003c/code\u003e に \u003ccode\u003e_CGCursorIsVisible\u003c/code\u003e として載っています。そこで、実行時にシンボルを探して呼びます。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"104:1-104:55\"\u003e\n\u003cspan id=\"dlopennil-rtld_now-で得られるハンドル\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#dlopennil-rtld_now-%E3%81%A7%E5%BE%97%E3%82%89%E3%82%8C%E3%82%8B%E3%83%8F%E3%83%B3%E3%83%89%E3%83%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003ccode\u003edlopen(nil, RTLD_NOW)\u003c/code\u003e で得られるハンドル\u003c/h3\u003e\n\u003cp data-sourcepos=\"106:1-106:285\"\u003e\u003ca href=\"https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dlopen.3.html\" rel=\"nofollow noopener\" target=\"_blank\"\u003edlopen(3)\u003c/a\u003e には、\u003ccode\u003epath\u003c/code\u003e に NULL を渡したときの挙動が次のように書かれています（手元の macOS 26.6 の man ページも同じ文面です）。\u003c/p\u003e\n\u003cblockquote data-sourcepos=\"108:1-108:92\"\u003e\n\u003cp data-sourcepos=\"108:3-108:92\"\u003eIf a null pointer is passed in path, dlopen() returns a handle equivalent to RTLD_DEFAULT.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp data-sourcepos=\"110:1-110:515\"\u003e\u003ccode\u003eRTLD_DEFAULT\u003c/code\u003e を渡した \u003ccode\u003edlsym\u003c/code\u003e は、プロセスに読み込まれたすべての Mach-O イメージ（\u003ccode\u003eRTLD_LOCAL\u003c/code\u003e で読み込まれたものを除く）を、読み込まれた順に探します（\u003ca href=\"https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dlsym.3.html\" rel=\"nofollow noopener\" target=\"_blank\"\u003edlsym(3)\u003c/a\u003e）。NeonPointer は AppKit をリンクしているので、CoreGraphics もプロセス内に読み込まれています。そのため、\u003ccode\u003eCGCursorIsVisible\u003c/code\u003e も見つかります。\u003c/p\u003e\n\u003cp data-sourcepos=\"112:1-112:343\"\u003e同じ man ページには、\u003ccode\u003eRTLD_DEFAULT\u003c/code\u003e での検索は「costly」なので避けるべきだとも書かれています。\u003ccode\u003eisVisibleFunction\u003c/code\u003e は \u003ccode\u003estatic let\u003c/code\u003e なので、初期化クロージャは最初にアクセスされたときに1回だけ走ります。毎フレームの判定で検索を繰り返すことはありません。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"114:1-114:59\"\u003e\n\u003cspan id=\"conventionc-の関数型に-unsafebitcast-する\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#conventionc-%E3%81%AE%E9%96%A2%E6%95%B0%E5%9E%8B%E3%81%AB-unsafebitcast-%E3%81%99%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003ccode\u003e@convention(c)\u003c/code\u003e の関数型に \u003ccode\u003eunsafeBitCast\u003c/code\u003e する\u003c/h3\u003e\n\u003cp data-sourcepos=\"116:1-116:180\"\u003e\u003ccode\u003edlsym\u003c/code\u003e が返すのは \u003ccode\u003eUnsafeMutableRawPointer\u003c/code\u003e です。これを C の関数ポインタとして呼べるよう、\u003ccode\u003e@convention(c)\u003c/code\u003e の関数型に \u003ccode\u003eunsafeBitCast\u003c/code\u003e します。\u003c/p\u003e\n\u003cp data-sourcepos=\"118:1-118:480\"\u003e戻り値の型 \u003ccode\u003eboolean_t\u003c/code\u003e は、SDK の \u003ccode\u003emach/arm/boolean.h\u003c/code\u003e では \u003ccode\u003eint\u003c/code\u003e、\u003ccode\u003emach/i386/boolean.h\u003c/code\u003e では x86_64 向けに \u003ccode\u003eunsigned int\u003c/code\u003e と定義されています。どちらも 32 ビットです。コードは 0 と比べるだけなので、\u003ccode\u003eInt32\u003c/code\u003e で受けて問題ありません。なお、型を間違えても \u003ccode\u003eunsafeBitCast\u003c/code\u003e はコンパイルを通してしまいます。シグネチャはヘッダの宣言と照らし合わせて決める必要があります。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"120:1-120:28\"\u003e\n\u003cspan id=\"実際に呼んでみる\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%AE%9F%E9%9A%9B%E3%81%AB%E5%91%BC%E3%82%93%E3%81%A7%E3%81%BF%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e実際に呼んでみる\u003c/h3\u003e\n\u003cp data-sourcepos=\"122:1-122:198\"\u003eNeonPointer と同じ手順でシンボルを引き、\u003ccode\u003eNSCursor.currentSystem\u003c/code\u003e の画像サイズと合わせて出力するスクリプトです。scratch ディレクトリで実行しました。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"124:1-144:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eprobe.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eimport\u003c/span\u003e \u003cspan class=\"kt\"\u003eAppKit\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003etypealias\u003c/span\u003e \u003cspan class=\"kt\"\u003eIsVisibleFunction\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kd\"\u003e@convention\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ec\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e \u003cspan class=\"kt\"\u003eInt32\u003c/span\u003e\n\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ehandle\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003edlopen\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"kc\"\u003enil\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"kt\"\u003eRTLD_NOW\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"dlopen(nil):\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003ehandle\u003c/span\u003e \u003cspan class=\"k\"\u003eas\u003c/span\u003e \u003cspan class=\"kt\"\u003eAny\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esymbol\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ehandle\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eflatMap\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nf\"\u003edlsym\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s\"\u003e\"CGCursorIsVisible\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"dlsym CGCursorIsVisible:\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003esymbol\u003c/span\u003e \u003cspan class=\"k\"\u003eas\u003c/span\u003e \u003cspan class=\"kt\"\u003eAny\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\n\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esymbol\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ef\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nf\"\u003eunsafeBitCast\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esymbol\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eto\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eIsVisibleFunction\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"CGCursorIsVisible() =\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nf\"\u003ef\u003c/span\u003e\u003cspan class=\"p\"\u003e())\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecursor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSCursor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrentSystem\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"NSCursor.currentSystem image.size =\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eimage\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esize\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s\"\u003e\"hotSpot =\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehotSpot\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003eprint\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"NSCursor.currentSystem = nil\"\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"console\" data-sourcepos=\"146:1-152:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"gp\"\u003e$\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003eswiftc probe.swift \u003cspan class=\"nt\"\u003e-o\u003c/span\u003e probe \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e ./probe\n\u003cspan class=\"go\"\u003edlopen(nil): Optional(0xfffffffffffffffe)\ndlsym CGCursorIsVisible: Optional(0x000000018e3d3c68)\nCGCursorIsVisible() = 1\nNSCursor.currentSystem image.size = (28.0, 40.0) hotSpot = (5.0, 5.0)\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"154:1-154:317\"\u003e\u003ccode\u003edlopen(nil)\u003c/code\u003e が返した \u003ccode\u003e0xfffffffffffffffe\u003c/code\u003e は -2 で、\u003ccode\u003edlfcn.h\u003c/code\u003e の \u003ccode\u003e#define RTLD_DEFAULT ((void *) -2)\u003c/code\u003e と一致します。カーソルが見えている状態では、\u003ccode\u003eCGCursorIsVisible()\u003c/code\u003e は 1 を返しました。画像サイズは、実行した瞬間のカーソルの形によって変わります。\u003c/p\u003e\n\u003cp data-sourcepos=\"156:1-156:234\"\u003eシンボルが見つからない場合は、\u003ccode\u003edlsym\u003c/code\u003e が \u003ccode\u003enil\u003c/code\u003e を返すだけでクラッシュはしません。同じハンドルで存在しない名前 \u003ccode\u003eCGCursorIsVisibleXXX\u003c/code\u003e を引き、\u003ccode\u003edlerror()\u003c/code\u003e を表示した結果です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"console\" data-sourcepos=\"158:1-161:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"go\"\u003emissing symbol: nil\ndlerror: dlsym(RTLD_DEFAULT, CGCursorIsVisibleXXX): symbol not found\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003ch3 data-sourcepos=\"163:1-163:46\"\u003e\n\u003cspan id=\"非表示状態の値は取れなかった\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E9%9D%9E%E8%A1%A8%E7%A4%BA%E7%8A%B6%E6%85%8B%E3%81%AE%E5%80%A4%E3%81%AF%E5%8F%96%E3%82%8C%E3%81%AA%E3%81%8B%E3%81%A3%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e非表示状態の値は取れなかった\u003c/h3\u003e\n\u003cp data-sourcepos=\"165:1-165:361\"\u003eカーソルが非表示のときに 0 が返ることも確かめたかったのですが、今回の環境では取れませんでした。\u003ccode\u003eprobe.swift\u003c/code\u003e と同じ方法で値を読み、間に \u003ccode\u003eCGDisplayHideCursor(CGMainDisplayID())\u003c/code\u003e と 0.2 秒の待ちを挟むスクリプトをターミナルから実行しました。値は変わりませんでした。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"console\" data-sourcepos=\"167:1-173:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"gp\"\u003e$\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e./hide\n\u003cspan class=\"go\"\u003ebefore: CGCursorIsVisible=1 currentSystem.image.size=(28.0, 40.0)\n\u003c/span\u003e\u003cspan class=\"gp\"\u003eCGDisplayHideCursor -\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e0\n\u003cspan class=\"go\"\u003eafter hide: CGCursorIsVisible=1 currentSystem.image.size=(28.0, 40.0)\nafter show: CGCursorIsVisible=1 currentSystem.image.size=(28.0, 40.0)\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"175:1-175:515\"\u003e\u003ca href=\"https://developer.apple.com/documentation/coregraphics/cgdisplayhidecursor(_:)\" rel=\"nofollow noopener\" target=\"_blank\"\u003e\u003ccode\u003eCGDisplayHideCursor\u003c/code\u003e のドキュメント\u003c/a\u003eには「In most cases, the caller must be the foreground application to affect the cursor.」とあります。ターミナルから起動したプロセスは前面のアプリではないので、これに該当したと考えられます。フルスクリーン動画の再生中に 0 や 1x1 が返ることは、今回は検証しておらず、出力として示せていません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"177:1-177:61\"\u003e\n\u003cspan id=\"nscursorcurrentsystem-の画像サイズによる判定\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#nscursorcurrentsystem-%E3%81%AE%E7%94%BB%E5%83%8F%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%AB%E3%82%88%E3%82%8B%E5%88%A4%E5%AE%9A\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003ccode\u003eNSCursor.currentSystem\u003c/code\u003e の画像サイズによる判定\u003c/h2\u003e\n\u003cp data-sourcepos=\"179:1-179:283\"\u003e\u003ca href=\"https://developer.apple.com/documentation/appkit/nscursor/currentsystem\" rel=\"nofollow noopener\" target=\"_blank\"\u003e\u003ccode\u003eNSCursor.currentSystem\u003c/code\u003e\u003c/a\u003e は、どのアプリが設定したかに関係なく、いまシステムに表示されているカーソルを返します。ドキュメントの説明は次のとおりです。\u003c/p\u003e\n\u003cblockquote data-sourcepos=\"181:1-181:149\"\u003e\n\u003cp data-sourcepos=\"181:3-181:149\"\u003eThis method returns the current system cursor regardless of which application set the cursor, and whether Cocoa or Carbon APIs were used to set it.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp data-sourcepos=\"183:1-183:283\"\u003eこの画像サイズを見るのは、ブラウザが \u003ccode\u003eCGDisplayHideCursor\u003c/code\u003e を使わずにカーソルを消すことがあるからです。CSS の \u003ccode\u003ecursor: none\u003c/code\u003e に対して、Chromium と WebKit はどちらも 1x1 の空の \u003ccode\u003eNSImage\u003c/code\u003e からカーソルを作っています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"objc\" data-sourcepos=\"185:1-193:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eChromium ui/base/cocoa/cursor_utils.mm\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"n\"\u003eNSCursor\u003c/span\u003e\u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"nf\"\u003eNoneNSCursor\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n  \u003cspan class=\"k\"\u003estatic\u003c/span\u003e \u003cspan class=\"n\"\u003eNSCursor\u003c/span\u003e\u003cspan class=\"o\"\u003e*\u003c/span\u003e \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[[\u003c/span\u003e\u003cspan class=\"n\"\u003eNSCursor\u003c/span\u003e \u003cspan class=\"nf\"\u003ealloc\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\n      \u003cspan class=\"nl\"\u003einitWithImage:\u003c/span\u003e\u003cspan class=\"p\"\u003e[[\u003c/span\u003e\u003cspan class=\"n\"\u003eNSImage\u003c/span\u003e \u003cspan class=\"nf\"\u003ealloc\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"nf\"\u003einitWithSize\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"n\"\u003eNSMakeSize\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e)]\u003c/span\u003e\n            \u003cspan class=\"nl\"\u003ehotSpot:\u003c/span\u003e\u003cspan class=\"nf\"\u003eNSZeroPoint\u003c/span\u003e\u003cspan class=\"p\"\u003e];\u003c/span\u003e\n\n  \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"n\"\u003ecursor\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"objc\" data-sourcepos=\"195:1-203:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eWebKit Source/WebCore/platform/mac/CursorMac.mm\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e    \u003cspan class=\"k\"\u003ecase\u003c/span\u003e \u003cspan class=\"n\"\u003eType\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e\u003cspan class=\"n\"\u003eNone\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e\n\u003cspan class=\"cp\"\u003e#if ENABLE(CUSTOM_CURSOR_SUPPORT)\n\u003c/span\u003e        \u003cspan class=\"n\"\u003em_platformCursor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eadoptNS\u003c/span\u003e\u003cspan class=\"p\"\u003e([[\u003c/span\u003e\u003cspan class=\"n\"\u003eNSCursor\u003c/span\u003e \u003cspan class=\"nf\"\u003ealloc\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"nf\"\u003einitWithImage\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"n\"\u003eadoptNS\u003c/span\u003e\u003cspan class=\"p\"\u003e([[\u003c/span\u003e\u003cspan class=\"n\"\u003eNSImage\u003c/span\u003e \u003cspan class=\"nf\"\u003ealloc\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"nf\"\u003einitWithSize\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"n\"\u003eNSMakeSize\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"p\"\u003e)]).\u003c/span\u003e\u003cspan class=\"n\"\u003eget\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"nf\"\u003ehotSpot\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"n\"\u003eNSZeroPoint\u003c/span\u003e\u003cspan class=\"p\"\u003e]);\u003c/span\u003e\n\u003cspan class=\"cp\"\u003e#else\n\u003c/span\u003e        \u003cspan class=\"n\"\u003em_platformCursor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"n\"\u003eNSCursor\u003c/span\u003e \u003cspan class=\"nf\"\u003earrowCursor\u003c/span\u003e\u003cspan class=\"p\"\u003e];\u003c/span\u003e\n\u003cspan class=\"cp\"\u003e#endif\n\u003c/span\u003e        \u003cspan class=\"k\"\u003ebreak\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"205:1-205:723\"\u003e出典は \u003ca href=\"https://chromium.googlesource.com/chromium/src/+/main/ui/base/cocoa/cursor_utils.mm\" rel=\"nofollow noopener\" target=\"_blank\"\u003eChromium の cursor_utils.mm\u003c/a\u003e と \u003ca href=\"https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/mac/CursorMac.mm\" rel=\"nofollow noopener\" target=\"_blank\"\u003eWebKit の CursorMac.mm\u003c/a\u003e です（いずれも 2026-10-12 時点の main）。カーソル画像の差し替えでは、隠すカウンタは動かないはずです。\u003ccode\u003eCGCursorIsVisible\u003c/code\u003e だけではこのケースを捉えられないので、画像サイズでも判定します。冒頭に載せたコードのコメントも、同じ理由を挙げています。判定の条件は \u003ccode\u003esize.width \u0026lt;= 1, size.height \u0026lt;= 1\u003c/code\u003e で、幅と高さの両方が 1 以下のときだけ非表示とみなします。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"207:1-207:50\"\u003e\n\u003cspan id=\"currentsystem-も非推奨になっている\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#currentsystem-%E3%82%82%E9%9D%9E%E6%8E%A8%E5%A5%A8%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003ccode\u003ecurrentSystem\u003c/code\u003e も非推奨になっている\u003c/h3\u003e\n\u003cp data-sourcepos=\"209:1-209:283\"\u003e注意点があります。macOS 27.0 SDK の \u003ccode\u003eNSCursor.h\u003c/code\u003e では、\u003ccode\u003ecurrentSystemCursor\u003c/code\u003e（Swift 名 \u003ccode\u003ecurrentSystem\u003c/code\u003e）が非推奨のカテゴリに移っていました（Apple のドキュメントのページでは「macOS 27.0 で deprecated」と表示されています）。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"objc\" data-sourcepos=\"211:1-215:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eNSCursor.h（macOS 27.0 SDK）\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003e@interface\u003c/span\u003e \u003cspan class=\"nc\"\u003eNSCursor\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nl\"\u003eDeprecated\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"c1\"\u003e/// This property will always be `nil` in a future version of macOS.\u003c/span\u003e\n\u003cspan class=\"k\"\u003e@property\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eclass\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003ereadonly\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003enullable\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003estrong\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"n\"\u003eNSCursor\u003c/span\u003e \u003cspan class=\"o\"\u003e*\u003c/span\u003e\u003cspan class=\"n\"\u003ecurrentSystemCursor\u003c/span\u003e \u003cspan class=\"nf\"\u003eAPI_DEPRECATED\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"No longer recommended. Use ScreenCaptureKit to capture the screen. Use the `showsCursor` property on `SCStreamConfiguration` to control whether or not to include the cursor in the capture. Or, use `NSCursor.currentCursor` if needing to just get the current cursor for this application.\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003emacos\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e10\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"mi\"\u003e6\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eAPI_TO_BE_DEPRECATED\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"217:1-217:427\"\u003e\u003ccode\u003eAPI_TO_BE_DEPRECATED\u003c/code\u003e なので、\u003ccode\u003e-target arm64-apple-macos27.0\u003c/code\u003e でコンパイルしても警告は出ませんでした。ただ、コメントのとおり将来 \u003ccode\u003enil\u003c/code\u003e が返るようになると、\u003ccode\u003eif let size = NSCursor.currentSystem?.image.size\u003c/code\u003e が成り立たなくなります。そのときは画像サイズの判定が素通りになり、\u003ccode\u003eisVisible()\u003c/code\u003e は \u003ccode\u003eCGCursorIsVisible\u003c/code\u003e の結果だけで決まります。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"219:1-219:70\"\u003e\n\u003cspan id=\"毎フレームの判定と-suppressionchanged-による再描画\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E6%AF%8E%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E3%81%AE%E5%88%A4%E5%AE%9A%E3%81%A8-suppressionchanged-%E3%81%AB%E3%82%88%E3%82%8B%E5%86%8D%E6%8F%8F%E7%94%BB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e毎フレームの判定と \u003ccode\u003esuppressionChanged\u003c/code\u003e による再描画\u003c/h2\u003e\n\u003cp data-sourcepos=\"221:1-221:430\"\u003e判定は \u003ccode\u003eOverlayController.handleTick\u003c/code\u003e で毎 tick 呼びます。tick は \u003ccode\u003eCursorTracker\u003c/code\u003e が \u003ccode\u003eCADisplayLink\u003c/code\u003e で回しているもので、仕組みは \u003ca href=\"https://qiita.com/tanishi-z/items/62a1de75bf3b959c685a\" id=\"reference-c3e053dcb2fccf8c152f\"\u003ev1.0.0 の実装記事\u003c/a\u003eで、tick を毎フレーム呼ぶようにした経緯は \u003ca href=\"https://qiita.com/tanishi-z/items/2c6cd23b22785d36a0c6\" id=\"reference-bb9b1eb0e3149a038804\"\u003ev1.2.0 の実装記事\u003c/a\u003eで書きました。v1.3.0 の \u003ccode\u003ehandleTick\u003c/code\u003e です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"223:1-251:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eOverlayController.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"c1\"\u003e/// システムカーソルが隠れていて、ネオンも隠すべき状態か。\u003c/span\u003e\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003eisCursorSuppressed\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003ehandleTick\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003elocation\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eCGPoint\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eflags\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eNSEvent\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"kt\"\u003eModifierFlags\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esuppressed\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"kt\"\u003eSystemCursorVisibility\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eisVisible\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003esuppressionChanged\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003esuppressed\u003c/span\u003e \u003cspan class=\"o\"\u003e!=\u003c/span\u003e \u003cspan class=\"n\"\u003eisCursorSuppressed\u003c/span\u003e\n    \u003cspan class=\"n\"\u003eisCursorSuppressed\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003esuppressed\u003c/span\u003e\n\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eshouldDraw\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEnabled\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etrailEnabled\u003c/span\u003e\n        \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etrailModifier\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eisActive\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003ein\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eflags\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003esuppressed\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elocationChanged\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003elocation\u003c/span\u003e \u003cspan class=\"o\"\u003e!=\u003c/span\u003e \u003cspan class=\"n\"\u003elastCursorLocation\u003c/span\u003e\n\n    \u003cspan class=\"c1\"\u003e// カーソルが静止していて、描画中でも軌跡が残ってもいなければ何もすることがない。\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003eshouldDraw\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003etrail\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisDrawing\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003etrail\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003estrokes\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEmpty\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003elocationChanged\u003c/span\u003e \u003cspan class=\"o\"\u003e||\u003c/span\u003e \u003cspan class=\"n\"\u003esuppressionChanged\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003enow\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eCACurrentMediaTime\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n    \u003cspan class=\"n\"\u003etrail\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esetDrawing\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003eshouldDraw\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eat\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"n\"\u003eshouldDraw\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003elocationChanged\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n        \u003cspan class=\"n\"\u003etrail\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eappend\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003elocation\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"n\"\u003etrail\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eprune\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003ehold\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etrailHoldDuration\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003efade\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003etrailFadeDuration\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\n    \u003cspan class=\"n\"\u003elastCursorLocation\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003elocation\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003ereposition\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"253:1-253:41\"\u003ev1.2.0 からの変更点は3つです。\u003c/p\u003e\n\u003cp data-sourcepos=\"255:1-255:295\"\u003e1つ目は \u003ccode\u003esuppressed\u003c/code\u003e の計算です。\u003ccode\u003esettings.hideWhenCursorHidden \u0026amp;\u0026amp; ...\u003c/code\u003e の短絡評価により、設定が OFF なら \u003ccode\u003eSystemCursorVisibility.isVisible()\u003c/code\u003e は呼ばれません。\u003ccode\u003estatic let\u003c/code\u003e の初期化も走らないので、OFF のままなら \u003ccode\u003edlopen\u003c/code\u003e も実行されません。\u003c/p\u003e\n\u003cp data-sourcepos=\"257:1-257:550\"\u003e2つ目は guard への \u003ccode\u003esuppressionChanged\u003c/code\u003e の追加です。v1.2.0 の guard は、カーソルが静止していて軌跡も無い tick を早期 return で捨てていました。フルスクリーン動画では、カーソルを止めたままカーソルが消えます。この状態変化は \u003ccode\u003elocationChanged\u003c/code\u003e にも軌跡の条件にも現れません。前回の値と比べた \u003ccode\u003esuppressionChanged\u003c/code\u003e を条件に足すことで、表示と非表示が切り替わった tick だけは \u003ccode\u003ereposition()\u003c/code\u003e まで進むようにしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"259:1-259:304\"\u003e3つ目は \u003ccode\u003eshouldDraw\u003c/code\u003e への \u003ccode\u003e!suppressed\u003c/code\u003e の追加です。隠している間は修飾キーを押していても新しい線を描きません。\u003ccode\u003etrail.setDrawing(false, at:)\u003c/code\u003e が呼ばれるので、描いている途中の線は確定し、通常どおりフェードアウトしていきます。\u003c/p\u003e\n\u003ch3 data-sourcepos=\"261:1-261:35\"\u003e\n\u003cspan id=\"判定1回あたりのコスト\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E5%88%A4%E5%AE%9A1%E5%9B%9E%E3%81%82%E3%81%9F%E3%82%8A%E3%81%AE%E3%82%B3%E3%82%B9%E3%83%88\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e判定1回あたりのコスト\u003c/h3\u003e\n\u003cp data-sourcepos=\"263:1-263:220\"\u003e設定を ON にすると、毎フレーム2つの関数を呼ぶことになります。scratch で1000回ずつ呼んで、1回あたりの時間を測りました（\u003ccode\u003eswiftc -O\u003c/code\u003e、\u003ccode\u003eCACurrentMediaTime()\u003c/code\u003e で計測）。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"console\" data-sourcepos=\"265:1-270:3\"\u003e\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"gp\"\u003e$\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003efor \u003c/span\u003ei \u003cspan class=\"k\"\u003ein \u003c/span\u003e1 2 3\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"k\"\u003edo\u003c/span\u003e ./bench\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"k\"\u003edone\u003c/span\u003e\n\u003cspan class=\"go\"\u003eCGCursorIsVisible: 27.59 us/call, NSCursor.currentSystem.image.size: 56.68 us/call (acc=1000)\nCGCursorIsVisible: 17.53 us/call, NSCursor.currentSystem.image.size: 31.46 us/call (acc=1000)\nCGCursorIsVisible: 11.39 us/call, NSCursor.currentSystem.image.size: 19.75 us/call (acc=1000)\n\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003c/div\u003e\n\u003cp data-sourcepos=\"272:1-272:381\"\u003e実行ごとのばらつきは大きいものの、どちらも数十マイクロ秒の範囲でした。一番遅かった1回目でも、2つ合わせて約 84 マイクロ秒です。120Hz のディスプレイの1フレーム（約 8.3ms）に対して約1%にあたります。測定はこの環境での値で、他の機種で同じになるかは確かめていません。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"274:1-274:51\"\u003e\n\u003cspan id=\"ビューではなく描画だけを隠す理由\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%83%93%E3%83%A5%E3%83%BC%E3%81%A7%E3%81%AF%E3%81%AA%E3%81%8F%E6%8F%8F%E7%94%BB%E3%81%A0%E3%81%91%E3%82%92%E9%9A%A0%E3%81%99%E7%90%86%E7%94%B1\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eビューではなく描画だけを隠す理由\u003c/h2\u003e\n\u003cp data-sourcepos=\"276:1-276:44\"\u003e隠し方は \u003ccode\u003ereposition()\u003c/code\u003e の1行です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"278:1-292:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eOverlayController.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e \u003cspan class=\"k\"\u003ein\u003c/span\u003e \u003cspan class=\"n\"\u003eoverlays\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ecursorOnScreen\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epanel\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eframe\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eintersects\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecursorRect\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003etrailOnScreen\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003etrailRect\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emap\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epanel\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eframe\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eintersects\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$0\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// どちらの外接矩形もそのスクリーンに掛からないなら、合成コストを避けるため隠す。\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// カーソルが隠れているときもビュー自体は隠さない。隠すとディスプレイリンクが止まり、復帰を検知できなくなる。\u003c/span\u003e\n    \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eview\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisHidden\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003ecursorOnScreen\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003etrailOnScreen\u003c/span\u003e\n    \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eview\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003esetCursorVisible\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecursorOnScreen\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026amp;\u0026amp;\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003eisCursorSuppressed\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eview\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eupdate\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n        \u003cspan class=\"nv\"\u003ecursorLocation\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003elastCursorLocation\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n        \u003cspan class=\"nv\"\u003escreenOrigin\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003epanel\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eframe\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eorigin\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"n\"\u003eoverlay\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eview\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eupdateTrail\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003erenderItems\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"294:1-294:124\"\u003e\u003ccode\u003esetCursorVisible\u003c/code\u003e は \u003ccode\u003eNeonCursorView\u003c/code\u003e の中で、ネオンのリングのレイヤーだけを隠すメソッドです。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"296:1-300:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eNeonCursorView.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003esetCursorVisible\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003e_\u003c/span\u003e \u003cspan class=\"nv\"\u003evisible\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003eneonLayer\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisHidden\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003evisible\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"302:1-302:276\"\u003e\u003ccode\u003eisHidden\u003c/code\u003e の条件には \u003ccode\u003eisCursorSuppressed\u003c/code\u003e を入れていません。ビュー全体を隠したほうが合成コストは減りそうに見えます。それでもビューを残す理由が、コメントにある「ディスプレイリンクが止まる」です。\u003c/p\u003e\n\u003cp data-sourcepos=\"304:1-304:148\"\u003e\u003ccode\u003eCursorTracker\u003c/code\u003e は、スクリーンごとのビューから \u003ccode\u003edisplayLink(target:selector:)\u003c/code\u003e でディスプレイリンクを作っています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"306:1-314:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eCursorTracker.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003estart\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eguard\u003c/span\u003e \u003cspan class=\"n\"\u003edisplayLink\u003c/span\u003e \u003cspan class=\"o\"\u003e==\u003c/span\u003e \u003cspan class=\"kc\"\u003enil\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ehostView\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003elink\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003ehostView\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003edisplayLink\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003etarget\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003eself\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eselector\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"k\"\u003e#selector\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003etick\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n    \u003cspan class=\"n\"\u003elink\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eadd\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eto\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003emain\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eforMode\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecommon\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"n\"\u003edisplayLink\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003elink\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003etick\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"316:1-316:197\"\u003e\u003ca href=\"https://developer.apple.com/documentation/appkit/nsview/displaylink(target:selector:)\" rel=\"nofollow noopener\" target=\"_blank\"\u003e\u003ccode\u003eNSView.displayLink(target:selector:)\u003c/code\u003e のドキュメント\u003c/a\u003eには、次のように書かれています。\u003c/p\u003e\n\u003cblockquote data-sourcepos=\"318:1-318:81\"\u003e\n\u003cp data-sourcepos=\"318:3-318:81\"\u003eIf the view is hidden, or not on any display, the callback will not be invoked.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp data-sourcepos=\"320:1-320:709\"\u003eカーソルが乗っていないスクリーンのビューは、もともと \u003ccode\u003eisHidden = true\u003c/code\u003e です（この間引きは \u003ca href=\"https://qiita.com/tanishi-z/items/4c99095bb64a6b5598d1\" id=\"reference-414a8330da77348750ae\"\u003ev1.1.0 の実装記事\u003c/a\u003eで書きました）。そのため、ディスプレイリンクのコールバックも止まっています。tick を回しているのは、カーソルが乗っているスクリーンのビューだけです。ここでカーソルが消えたからとそのビューまで隠すと、すべてのディスプレイリンクが止まります。tick が来なければ \u003ccode\u003eSystemCursorVisibility.isVisible()\u003c/code\u003e も呼ばれず、カーソルが戻ってもネオンは隠れたままになります。\u003c/p\u003e\n\u003cp data-sourcepos=\"322:1-322:226\"\u003eそこでビューは表示したまま、\u003ccode\u003eneonLayer\u003c/code\u003e だけを隠しています。何も描いていないビューが1枚残りますが、そのぶんの合成コストと引き換えに tick を回し続けています。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"324:1-324:18\"\u003e\n\u003cspan id=\"設定の追加\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%A8%AD%E5%AE%9A%E3%81%AE%E8%BF%BD%E5%8A%A0\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e設定の追加\u003c/h2\u003e\n\u003cp data-sourcepos=\"326:1-326:155\"\u003e\u003ccode\u003eSettingsStore\u003c/code\u003e に \u003ccode\u003ehideWhenCursorHidden\u003c/code\u003e を追加しました。保存先は他の設定と同じく \u003ccode\u003eUserDefaults\u003c/code\u003e で、既定値は \u003ccode\u003efalse\u003c/code\u003e です。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"328:1-353:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSettingsStore.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003eenum\u003c/span\u003e \u003cspan class=\"kt\"\u003eKey\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eisEnabled\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"isEnabled\"\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ehideWhenCursorHidden\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s\"\u003e\"hideWhenCursorHidden\"\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// ...\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003eenum\u003c/span\u003e \u003cspan class=\"kt\"\u003eDefault\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003eisEnabled\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\n    \u003cspan class=\"kd\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003ehideWhenCursorHidden\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// ...\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003e@Published\u003c/span\u003e \u003cspan class=\"k\"\u003evar\u003c/span\u003e \u003cspan class=\"nv\"\u003ehideWhenCursorHidden\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003edidSet\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"nf\"\u003epersist\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eforKey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eKey\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\n\u003cspan class=\"c1\"\u003e// init 内\u003c/span\u003e\n\u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003edefaults\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003eobject\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003eforKey\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"kt\"\u003eKey\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"k\"\u003eas?\u003c/span\u003e \u003cspan class=\"kt\"\u003eBool\u003c/span\u003e\n    \u003cspan class=\"p\"\u003e??\u003c/span\u003e \u003cspan class=\"kt\"\u003eDefault\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e\n\n\u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eresetToDefaults\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eDefault\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// ...\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"355:1-355:397\"\u003e\u003ccode\u003edefaults.object(forKey:) as? Bool ?? 既定値\u003c/code\u003e という読み方は、既存の設定項目と同じです。\u003ccode\u003ebool(forKey:)\u003c/code\u003e はキーが無いと \u003ccode\u003efalse\u003c/code\u003e を返すので、「未設定」と「false を保存した」を区別できません。既定値が \u003ccode\u003efalse\u003c/code\u003e のこの項目では、どちらで書いても結果は同じです。\u003ccode\u003eresetToDefaults()\u003c/code\u003e の対象にも含めています。\u003c/p\u003e\n\u003cp data-sourcepos=\"357:1-357:105\"\u003e設定画面には、「ログイン時に自動的に起動」の下にトグルを足しています。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"359:1-364:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eSettingsView.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kt\"\u003eToggle\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s\"\u003e\"カーソルが消えたらネオンも隠す\"\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003eisOn\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003e$settings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003etoggleStyle\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"k\"\u003eswitch\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003efont\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ecallout\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nf\"\u003edisabled\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eisEnabled\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"366:1-366:275\"\u003e全体の「ネオンポインタを表示」が OFF のときは、トグルを \u003ccode\u003edisabled\u003c/code\u003e にします。このときはネオン自体が出ておらず、ディスプレイリンクも止まっているので、この設定を変えても見た目は変わりません。\u003c/p\u003e\n\u003cp data-sourcepos=\"368:1-368:181\"\u003e最後に、設定を OFF にしたときの後始末です。\u003ccode\u003epersist\u003c/code\u003e は値を保存したあと \u003ccode\u003edidChange\u003c/code\u003e を送り、\u003ccode\u003eOverlayController.applySettings()\u003c/code\u003e が呼ばれます。\u003c/p\u003e\n\u003cdiv class=\"code-frame\" data-lang=\"swift\" data-sourcepos=\"370:1-377:3\"\u003e\n\u003cdiv class=\"code-lang\"\u003e\u003cspan class=\"bold\"\u003eOverlayController.swift\u003c/span\u003e\u003c/div\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre\u003e\u003ccode\u003e\u003cspan class=\"kd\"\u003eprivate\u003c/span\u003e \u003cspan class=\"kd\"\u003efunc\u003c/span\u003e \u003cspan class=\"nf\"\u003eapplySettings\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n    \u003cspan class=\"k\"\u003elet\u003c/span\u003e \u003cspan class=\"nv\"\u003econfiguration\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kt\"\u003eNeonConfiguration\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003esettings\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n    \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"n\"\u003esettings\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003ehideWhenCursorHidden\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003eisCursorSuppressed\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"kc\"\u003efalse\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n    \u003cspan class=\"c1\"\u003e// ...\u003c/span\u003e\n    \u003cspan class=\"nf\"\u003ereposition\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\n\u003c/div\u003e\n\u003cp data-sourcepos=\"379:1-379:470\"\u003eカーソルが消えている最中に設定を OFF にした場合を考えます。ここで \u003ccode\u003eisCursorSuppressed\u003c/code\u003e を戻さないと、\u003ccode\u003eapplySettings()\u003c/code\u003e の最後の \u003ccode\u003ereposition()\u003c/code\u003e は古い \u003ccode\u003etrue\u003c/code\u003e のまま \u003ccode\u003esetCursorVisible(false)\u003c/code\u003e を通します。ネオンが戻るのは、次の tick で \u003ccode\u003esuppressionChanged\u003c/code\u003e が立ったときです。\u003ccode\u003eapplySettings()\u003c/code\u003e の中で先に \u003ccode\u003efalse\u003c/code\u003e に戻しておけば、その場の \u003ccode\u003ereposition()\u003c/code\u003e でネオンが戻ります。\u003c/p\u003e\n\u003cp data-sourcepos=\"381:1-381:90\"\u003eバージョンは \u003ccode\u003eproject.yml\u003c/code\u003e の \u003ccode\u003eMARKETING_VERSION\u003c/code\u003e を \u003ccode\u003e1.3.0\u003c/code\u003e に上げました。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"383:1-383:30\"\u003e\n\u003cspan id=\"neonpointer-の関連記事\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#neonpointer-%E3%81%AE%E9%96%A2%E9%80%A3%E8%A8%98%E4%BA%8B\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003eNeonPointer の関連記事\u003c/h2\u003e\n\u003cul data-sourcepos=\"385:1-391:189\"\u003e\n\u003cli data-sourcepos=\"385:1-385:216\"\u003eZenn（この記事のペア）: \u003ca href=\"https://zenn.dev/tanishi_z/articles/31-neonpointer-cursor-hidden-design\" rel=\"nofollow noopener\" target=\"_blank\"\u003eカーソルが「消えた」ことをどう知るか ― 公式 API がない状態を判定する設計\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"386:1-386:177\"\u003eZenn: \u003ca href=\"https://zenn.dev/tanishi_z/articles/15-neonpointer-cursor-overlay-design\" rel=\"nofollow noopener\" target=\"_blank\"\u003eカーソルを見失う問題を、権限を要求しないオーバーレイで解決する\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"387:1-387:142\"\u003eQiita: \u003ca href=\"https://qiita.com/tanishi-z/items/62a1de75bf3b959c685a\"\u003e常駐アプリでクリック透過のネオンカーソルを実装する\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"388:1-388:257\"\u003eZenn: \u003ca href=\"https://zenn.dev/tanishi_z/articles/17-neonpointer-multi-display-overlay-design\" rel=\"nofollow noopener\" target=\"_blank\"\u003e1枚のパネルでカーソルを追う設計を、複数ディスプレイでレイヤーだけ動かす構造に作り替えた（＋ログイン時自動起動）\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"389:1-389:250\"\u003eQiita: \u003ca href=\"https://qiita.com/tanishi-z/items/4c99095bb64a6b5598d1\"\u003eスクリーンごとにパネルを固定し、レイヤーだけ動かしてマルチディスプレイに対応する実装（＋SMAppServiceによるログイン時自動起動）\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"390:1-390:216\"\u003eZenn: \u003ca href=\"https://zenn.dev/tanishi_z/articles/19-neonpointer-modifier-key-trail-design\" rel=\"nofollow noopener\" target=\"_blank\"\u003emacOSアプリで新しい権限を求めないために、イベント監視ではなく状態のポーリングを選んだ話\u003c/a\u003e\n\u003c/li\u003e\n\u003cli data-sourcepos=\"391:1-391:189\"\u003eQiita: \u003ca href=\"https://qiita.com/tanishi-z/items/2c6cd23b22785d36a0c6\"\u003eNSEvent.modifierFlagsのポーリングとCAShapeLayerの多重シャドウで「動く軌跡」を描く（macOS/Swift）\u003c/a\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n","body":"\n## 対象読者と動作環境\n\nmacOS で「他のアプリがシステムカーソルを消したか」を判定したい人、Swift から SDK 経由では呼べない C 関数を `dlsym` で呼ぶ方法を知りたい人を対象にしています。題材はメニューバー常駐アプリ「Neon Pointer」の [v1.3.0](https://github.com/Tanishi-z/NeonPointer/releases/tag/v1.3.0) で追加した「カーソルが消えたらネオンも隠す」モードです（既定は OFF）。\n\nアプリの動作環境とビルド設定（`project.yml`）は次のとおりです。\n\n- macOS 14 以上（`deploymentTarget` が `14.0`）\n- Swift 5.0（`SWIFT_VERSION: \"5.0\"`）\n- XcodeGen（ビルド時のみ）\n\n記事中の検証スクリプトは、次の環境で実行しました。\n\n- macOS 26.6.2（Apple Silicon / arm64）\n- Xcode 27.0（27A266a）、macOS 27.0 SDK\n- Apple Swift 6.4（`swiftc` で直接コンパイル）\n\nリポジトリは [Tanishi-z/NeonPointer](https://github.com/Tanishi-z/NeonPointer) です。なぜ2つの判定を重ねたのか、既定を OFF にした判断などの設計の話は、ペアの [Zenn 版](https://zenn.dev/tanishi_z/articles/31-neonpointer-cursor-hidden-design)で扱っています。この記事はコード、手順、実際の出力に絞ります。\n\n## `SystemCursorVisibility` の全体像\n\n判定は新規ファイル `SystemCursorVisibility.swift` の25行にまとまっています。全文です。\n\n```swift:SystemCursorVisibility.swift\nimport AppKit\n\n/// システムカーソルが表示中かどうかを返す。\n/// ブラウザの全画面動画などは `CGDisplayHideCursor` ではなく、1x1 の透明な\n/// カーソル画像に差し替えて隠すため、現在のカーソル画像の大きさで判定する。\n/// `CGCursorIsVisible` は非推奨で SDK のヘッダから外れているため `dlsym` で引き、\n/// こちらの方法で隠すアプリにも備える。シンボルが無ければ画像の判定だけを使う。\n@MainActor\nenum SystemCursorVisibility {\n    private typealias IsVisibleFunction = @convention(c) () -\u003e Int32\n\n    private static let isVisibleFunction: IsVisibleFunction? = {\n        guard let handle = dlopen(nil, RTLD_NOW),\n              let symbol = dlsym(handle, \"CGCursorIsVisible\") else { return nil }\n        return unsafeBitCast(symbol, to: IsVisibleFunction.self)\n    }()\n\n    static func isVisible() -\u003e Bool {\n        if let isVisibleFunction, isVisibleFunction() == 0 { return false }\n        if let size = NSCursor.currentSystem?.image.size, size.width \u003c= 1, size.height \u003c= 1 {\n            return false\n        }\n        return true\n    }\n}\n```\n\n`isVisible()` は次の順に判定します。\n\n1. `CGCursorIsVisible` が取れていて、0 を返したら非表示\n2. `NSCursor.currentSystem` の画像が幅・高さとも 1 以下なら非表示\n3. どちらにも当てはまらなければ表示中\n\nどちらかの手がかりが取れないとき（シンボルが無い、`currentSystem` が `nil`）は、その判定を飛ばします。どちらも取れなければ `true`（表示中）を返します。判定に失敗したときは、ネオンを出し続ける側に倒れるということです。\n\n## `dlopen`/`dlsym` と `@convention(c)` で Swift から呼べない関数を呼ぶ\n\n### Swift から直接呼ぶとコンパイルエラーになる\n\nコードのコメントには「SDK のヘッダから外れている」とあります。ただ、手元の macOS 27.0 SDK を grep すると、宣言自体は残っていました。\n\n```console\n$ grep -rn \"CGCursorIsVisible\" \"$(xcrun --show-sdk-path)/System/Library/Frameworks/CoreGraphics.framework/Headers/\"\n/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/CoreGraphics.framework/Headers/CGRemoteOperation.h:203:CG_EXTERN boolean_t CGCursorIsVisible(void)\n```\n\n```c:CGRemoteOperation.h\n/* Return true if the mouse cursor is visible, false otherwise. */\n\nCG_EXTERN boolean_t CGCursorIsVisible(void)\n    API_DEPRECATED(\"No longer supported\", macos(10.3,10.9));\n```\n\n宣言は残っていますが、Swift から直接呼ぶとコンパイルが通りません。\n\n```swift:direct.swift\nimport CoreGraphics\nprint(CGCursorIsVisible())\n```\n\n```console\n$ swiftc direct.swift -o direct\ndirect.swift:2:7: error: 'CGCursorIsVisible()' is unavailable in macOS: No longer supported\n1 | import CoreGraphics\n2 | print(CGCursorIsVisible())\n  |       `- error: 'CGCursorIsVisible()' is unavailable in macOS: No longer supported\n3 | \n\n/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.0.sdk/System/Library/Frameworks/CoreGraphics.framework/Headers/CGRemoteOperation.h:203:21: note: 'CGCursorIsVisible()' has been explicitly marked unavailable here\n201 | /* Return true if the mouse cursor is visible, false otherwise. */\n202 | \n203 | CG_EXTERN boolean_t CGCursorIsVisible(void)\n    |                     `- note: 'CGCursorIsVisible()' has been explicitly marked unavailable here\n204 |     API_DEPRECATED(\"No longer supported\", macos(10.3,10.9));\n205 | \n```\n\n`-target arm64-apple-macos14.0` を付けても同じエラーでした。Apple のドキュメントでも [`CGCursorIsVisible()`](https://developer.apple.com/documentation/coregraphics/cgcursorisvisible()) は非推奨で、「There is no replacement.」とされています。一方、シンボル自体は CoreGraphics の `.tbd` に `_CGCursorIsVisible` として載っています。そこで、実行時にシンボルを探して呼びます。\n\n### `dlopen(nil, RTLD_NOW)` で得られるハンドル\n\n[dlopen(3)](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dlopen.3.html) には、`path` に NULL を渡したときの挙動が次のように書かれています（手元の macOS 26.6 の man ページも同じ文面です）。\n\n\u003e If a null pointer is passed in path, dlopen() returns a handle equivalent to RTLD_DEFAULT.\n\n`RTLD_DEFAULT` を渡した `dlsym` は、プロセスに読み込まれたすべての Mach-O イメージ（`RTLD_LOCAL` で読み込まれたものを除く）を、読み込まれた順に探します（[dlsym(3)](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dlsym.3.html)）。NeonPointer は AppKit をリンクしているので、CoreGraphics もプロセス内に読み込まれています。そのため、`CGCursorIsVisible` も見つかります。\n\n同じ man ページには、`RTLD_DEFAULT` での検索は「costly」なので避けるべきだとも書かれています。`isVisibleFunction` は `static let` なので、初期化クロージャは最初にアクセスされたときに1回だけ走ります。毎フレームの判定で検索を繰り返すことはありません。\n\n### `@convention(c)` の関数型に `unsafeBitCast` する\n\n`dlsym` が返すのは `UnsafeMutableRawPointer` です。これを C の関数ポインタとして呼べるよう、`@convention(c)` の関数型に `unsafeBitCast` します。\n\n戻り値の型 `boolean_t` は、SDK の `mach/arm/boolean.h` では `int`、`mach/i386/boolean.h` では x86_64 向けに `unsigned int` と定義されています。どちらも 32 ビットです。コードは 0 と比べるだけなので、`Int32` で受けて問題ありません。なお、型を間違えても `unsafeBitCast` はコンパイルを通してしまいます。シグネチャはヘッダの宣言と照らし合わせて決める必要があります。\n\n### 実際に呼んでみる\n\nNeonPointer と同じ手順でシンボルを引き、`NSCursor.currentSystem` の画像サイズと合わせて出力するスクリプトです。scratch ディレクトリで実行しました。\n\n```swift:probe.swift\nimport AppKit\n\ntypealias IsVisibleFunction = @convention(c) () -\u003e Int32\n\nlet handle = dlopen(nil, RTLD_NOW)\nprint(\"dlopen(nil):\", handle as Any)\nlet symbol = handle.flatMap { dlsym($0, \"CGCursorIsVisible\") }\nprint(\"dlsym CGCursorIsVisible:\", symbol as Any)\n\nif let symbol {\n    let f = unsafeBitCast(symbol, to: IsVisibleFunction.self)\n    print(\"CGCursorIsVisible() =\", f())\n}\n\nif let cursor = NSCursor.currentSystem {\n    print(\"NSCursor.currentSystem image.size =\", cursor.image.size, \"hotSpot =\", cursor.hotSpot)\n} else {\n    print(\"NSCursor.currentSystem = nil\")\n}\n```\n\n```console\n$ swiftc probe.swift -o probe \u0026\u0026 ./probe\ndlopen(nil): Optional(0xfffffffffffffffe)\ndlsym CGCursorIsVisible: Optional(0x000000018e3d3c68)\nCGCursorIsVisible() = 1\nNSCursor.currentSystem image.size = (28.0, 40.0) hotSpot = (5.0, 5.0)\n```\n\n`dlopen(nil)` が返した `0xfffffffffffffffe` は -2 で、`dlfcn.h` の `#define RTLD_DEFAULT ((void *) -2)` と一致します。カーソルが見えている状態では、`CGCursorIsVisible()` は 1 を返しました。画像サイズは、実行した瞬間のカーソルの形によって変わります。\n\nシンボルが見つからない場合は、`dlsym` が `nil` を返すだけでクラッシュはしません。同じハンドルで存在しない名前 `CGCursorIsVisibleXXX` を引き、`dlerror()` を表示した結果です。\n\n```console\nmissing symbol: nil\ndlerror: dlsym(RTLD_DEFAULT, CGCursorIsVisibleXXX): symbol not found\n```\n\n### 非表示状態の値は取れなかった\n\nカーソルが非表示のときに 0 が返ることも確かめたかったのですが、今回の環境では取れませんでした。`probe.swift` と同じ方法で値を読み、間に `CGDisplayHideCursor(CGMainDisplayID())` と 0.2 秒の待ちを挟むスクリプトをターミナルから実行しました。値は変わりませんでした。\n\n```console\n$ ./hide\nbefore: CGCursorIsVisible=1 currentSystem.image.size=(28.0, 40.0)\nCGDisplayHideCursor -\u003e 0\nafter hide: CGCursorIsVisible=1 currentSystem.image.size=(28.0, 40.0)\nafter show: CGCursorIsVisible=1 currentSystem.image.size=(28.0, 40.0)\n```\n\n[`CGDisplayHideCursor` のドキュメント](https://developer.apple.com/documentation/coregraphics/cgdisplayhidecursor(_:))には「In most cases, the caller must be the foreground application to affect the cursor.」とあります。ターミナルから起動したプロセスは前面のアプリではないので、これに該当したと考えられます。フルスクリーン動画の再生中に 0 や 1x1 が返ることは、今回は検証しておらず、出力として示せていません。\n\n## `NSCursor.currentSystem` の画像サイズによる判定\n\n[`NSCursor.currentSystem`](https://developer.apple.com/documentation/appkit/nscursor/currentsystem) は、どのアプリが設定したかに関係なく、いまシステムに表示されているカーソルを返します。ドキュメントの説明は次のとおりです。\n\n\u003e This method returns the current system cursor regardless of which application set the cursor, and whether Cocoa or Carbon APIs were used to set it.\n\nこの画像サイズを見るのは、ブラウザが `CGDisplayHideCursor` を使わずにカーソルを消すことがあるからです。CSS の `cursor: none` に対して、Chromium と WebKit はどちらも 1x1 の空の `NSImage` からカーソルを作っています。\n\n```objc:Chromium ui/base/cocoa/cursor_utils.mm\nNSCursor* NoneNSCursor() {\n  static NSCursor* cursor = [[NSCursor alloc]\n      initWithImage:[[NSImage alloc] initWithSize:NSMakeSize(1, 1)]\n            hotSpot:NSZeroPoint];\n\n  return cursor;\n}\n```\n\n```objc:WebKit Source/WebCore/platform/mac/CursorMac.mm\n    case Type::None:\n#if ENABLE(CUSTOM_CURSOR_SUPPORT)\n        m_platformCursor = adoptNS([[NSCursor alloc] initWithImage:adoptNS([[NSImage alloc] initWithSize:NSMakeSize(1, 1)]).get() hotSpot:NSZeroPoint]);\n#else\n        m_platformCursor = [NSCursor arrowCursor];\n#endif\n        break;\n```\n\n出典は [Chromium の cursor_utils.mm](https://chromium.googlesource.com/chromium/src/+/main/ui/base/cocoa/cursor_utils.mm) と [WebKit の CursorMac.mm](https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/mac/CursorMac.mm) です（いずれも 2026-10-12 時点の main）。カーソル画像の差し替えでは、隠すカウンタは動かないはずです。`CGCursorIsVisible` だけではこのケースを捉えられないので、画像サイズでも判定します。冒頭に載せたコードのコメントも、同じ理由を挙げています。判定の条件は `size.width \u003c= 1, size.height \u003c= 1` で、幅と高さの両方が 1 以下のときだけ非表示とみなします。\n\n### `currentSystem` も非推奨になっている\n\n注意点があります。macOS 27.0 SDK の `NSCursor.h` では、`currentSystemCursor`（Swift 名 `currentSystem`）が非推奨のカテゴリに移っていました（Apple のドキュメントのページでは「macOS 27.0 で deprecated」と表示されています）。\n\n```objc:NSCursor.h（macOS 27.0 SDK）\n@interface NSCursor (Deprecated)\n/// This property will always be `nil` in a future version of macOS.\n@property (class, readonly, nullable, strong) NSCursor *currentSystemCursor API_DEPRECATED(\"No longer recommended. Use ScreenCaptureKit to capture the screen. Use the `showsCursor` property on `SCStreamConfiguration` to control whether or not to include the cursor in the capture. Or, use `NSCursor.currentCursor` if needing to just get the current cursor for this application.\", macos(10.6, API_TO_BE_DEPRECATED));\n```\n\n`API_TO_BE_DEPRECATED` なので、`-target arm64-apple-macos27.0` でコンパイルしても警告は出ませんでした。ただ、コメントのとおり将来 `nil` が返るようになると、`if let size = NSCursor.currentSystem?.image.size` が成り立たなくなります。そのときは画像サイズの判定が素通りになり、`isVisible()` は `CGCursorIsVisible` の結果だけで決まります。\n\n## 毎フレームの判定と `suppressionChanged` による再描画\n\n判定は `OverlayController.handleTick` で毎 tick 呼びます。tick は `CursorTracker` が `CADisplayLink` で回しているもので、仕組みは [v1.0.0 の実装記事](https://qiita.com/tanishi-z/items/62a1de75bf3b959c685a)で、tick を毎フレーム呼ぶようにした経緯は [v1.2.0 の実装記事](https://qiita.com/tanishi-z/items/2c6cd23b22785d36a0c6)で書きました。v1.3.0 の `handleTick` です。\n\n```swift:OverlayController.swift\n/// システムカーソルが隠れていて、ネオンも隠すべき状態か。\nprivate var isCursorSuppressed = false\n\nprivate func handleTick(location: CGPoint, flags: NSEvent.ModifierFlags) {\n    let suppressed = settings.hideWhenCursorHidden \u0026\u0026 !SystemCursorVisibility.isVisible()\n    let suppressionChanged = suppressed != isCursorSuppressed\n    isCursorSuppressed = suppressed\n\n    let shouldDraw = settings.isEnabled \u0026\u0026 settings.trailEnabled\n        \u0026\u0026 settings.trailModifier.isActive(in: flags) \u0026\u0026 !suppressed\n    let locationChanged = location != lastCursorLocation\n\n    // カーソルが静止していて、描画中でも軌跡が残ってもいなければ何もすることがない。\n    guard shouldDraw || trail.isDrawing || !trail.strokes.isEmpty || locationChanged || suppressionChanged else {\n        return\n    }\n\n    let now = CACurrentMediaTime()\n    trail.setDrawing(shouldDraw, at: now)\n    if shouldDraw, locationChanged {\n        trail.append(location)\n    }\n    trail.prune(now: now, hold: settings.trailHoldDuration, fade: settings.trailFadeDuration)\n\n    lastCursorLocation = location\n    reposition()\n}\n```\n\nv1.2.0 からの変更点は3つです。\n\n1つ目は `suppressed` の計算です。`settings.hideWhenCursorHidden \u0026\u0026 ...` の短絡評価により、設定が OFF なら `SystemCursorVisibility.isVisible()` は呼ばれません。`static let` の初期化も走らないので、OFF のままなら `dlopen` も実行されません。\n\n2つ目は guard への `suppressionChanged` の追加です。v1.2.0 の guard は、カーソルが静止していて軌跡も無い tick を早期 return で捨てていました。フルスクリーン動画では、カーソルを止めたままカーソルが消えます。この状態変化は `locationChanged` にも軌跡の条件にも現れません。前回の値と比べた `suppressionChanged` を条件に足すことで、表示と非表示が切り替わった tick だけは `reposition()` まで進むようにしています。\n\n3つ目は `shouldDraw` への `!suppressed` の追加です。隠している間は修飾キーを押していても新しい線を描きません。`trail.setDrawing(false, at:)` が呼ばれるので、描いている途中の線は確定し、通常どおりフェードアウトしていきます。\n\n### 判定1回あたりのコスト\n\n設定を ON にすると、毎フレーム2つの関数を呼ぶことになります。scratch で1000回ずつ呼んで、1回あたりの時間を測りました（`swiftc -O`、`CACurrentMediaTime()` で計測）。\n\n```console\n$ for i in 1 2 3; do ./bench; done\nCGCursorIsVisible: 27.59 us/call, NSCursor.currentSystem.image.size: 56.68 us/call (acc=1000)\nCGCursorIsVisible: 17.53 us/call, NSCursor.currentSystem.image.size: 31.46 us/call (acc=1000)\nCGCursorIsVisible: 11.39 us/call, NSCursor.currentSystem.image.size: 19.75 us/call (acc=1000)\n```\n\n実行ごとのばらつきは大きいものの、どちらも数十マイクロ秒の範囲でした。一番遅かった1回目でも、2つ合わせて約 84 マイクロ秒です。120Hz のディスプレイの1フレーム（約 8.3ms）に対して約1%にあたります。測定はこの環境での値で、他の機種で同じになるかは確かめていません。\n\n## ビューではなく描画だけを隠す理由\n\n隠し方は `reposition()` の1行です。\n\n```swift:OverlayController.swift\nfor overlay in overlays {\n    let cursorOnScreen = overlay.panel.frame.intersects(cursorRect)\n    let trailOnScreen = trailRect.map { overlay.panel.frame.intersects($0) } ?? false\n    // どちらの外接矩形もそのスクリーンに掛からないなら、合成コストを避けるため隠す。\n    // カーソルが隠れているときもビュー自体は隠さない。隠すとディスプレイリンクが止まり、復帰を検知できなくなる。\n    overlay.view.isHidden = !cursorOnScreen \u0026\u0026 !trailOnScreen\n    overlay.view.setCursorVisible(cursorOnScreen \u0026\u0026 !isCursorSuppressed)\n    overlay.view.update(\n        cursorLocation: lastCursorLocation,\n        screenOrigin: overlay.panel.frame.origin\n    )\n    overlay.view.updateTrail(renderItems)\n}\n```\n\n`setCursorVisible` は `NeonCursorView` の中で、ネオンのリングのレイヤーだけを隠すメソッドです。\n\n```swift:NeonCursorView.swift\nfunc setCursorVisible(_ visible: Bool) {\n    neonLayer.isHidden = !visible\n}\n```\n\n`isHidden` の条件には `isCursorSuppressed` を入れていません。ビュー全体を隠したほうが合成コストは減りそうに見えます。それでもビューを残す理由が、コメントにある「ディスプレイリンクが止まる」です。\n\n`CursorTracker` は、スクリーンごとのビューから `displayLink(target:selector:)` でディスプレイリンクを作っています。\n\n```swift:CursorTracker.swift\nfunc start() {\n    guard displayLink == nil, let hostView else { return }\n    let link = hostView.displayLink(target: self, selector: #selector(tick))\n    link.add(to: .main, forMode: .common)\n    displayLink = link\n    tick()\n}\n```\n\n[`NSView.displayLink(target:selector:)` のドキュメント](https://developer.apple.com/documentation/appkit/nsview/displaylink(target:selector:))には、次のように書かれています。\n\n\u003e If the view is hidden, or not on any display, the callback will not be invoked.\n\nカーソルが乗っていないスクリーンのビューは、もともと `isHidden = true` です（この間引きは [v1.1.0 の実装記事](https://qiita.com/tanishi-z/items/4c99095bb64a6b5598d1)で書きました）。そのため、ディスプレイリンクのコールバックも止まっています。tick を回しているのは、カーソルが乗っているスクリーンのビューだけです。ここでカーソルが消えたからとそのビューまで隠すと、すべてのディスプレイリンクが止まります。tick が来なければ `SystemCursorVisibility.isVisible()` も呼ばれず、カーソルが戻ってもネオンは隠れたままになります。\n\nそこでビューは表示したまま、`neonLayer` だけを隠しています。何も描いていないビューが1枚残りますが、そのぶんの合成コストと引き換えに tick を回し続けています。\n\n## 設定の追加\n\n`SettingsStore` に `hideWhenCursorHidden` を追加しました。保存先は他の設定と同じく `UserDefaults` で、既定値は `false` です。\n\n```swift:SettingsStore.swift\nprivate enum Key {\n    static let isEnabled = \"isEnabled\"\n    static let hideWhenCursorHidden = \"hideWhenCursorHidden\"\n    // ...\n}\n\nprivate enum Default {\n    static let isEnabled = true\n    static let hideWhenCursorHidden = false\n    // ...\n}\n\n@Published var hideWhenCursorHidden: Bool {\n    didSet { persist(hideWhenCursorHidden, forKey: Key.hideWhenCursorHidden) }\n}\n\n// init 内\nhideWhenCursorHidden = defaults.object(forKey: Key.hideWhenCursorHidden) as? Bool\n    ?? Default.hideWhenCursorHidden\n\nfunc resetToDefaults() {\n    hideWhenCursorHidden = Default.hideWhenCursorHidden\n    // ...\n}\n```\n\n`defaults.object(forKey:) as? Bool ?? 既定値` という読み方は、既存の設定項目と同じです。`bool(forKey:)` はキーが無いと `false` を返すので、「未設定」と「false を保存した」を区別できません。既定値が `false` のこの項目では、どちらで書いても結果は同じです。`resetToDefaults()` の対象にも含めています。\n\n設定画面には、「ログイン時に自動的に起動」の下にトグルを足しています。\n\n```swift:SettingsView.swift\nToggle(\"カーソルが消えたらネオンも隠す\", isOn: $settings.hideWhenCursorHidden)\n    .toggleStyle(.switch)\n    .font(.callout)\n    .disabled(!settings.isEnabled)\n```\n\n全体の「ネオンポインタを表示」が OFF のときは、トグルを `disabled` にします。このときはネオン自体が出ておらず、ディスプレイリンクも止まっているので、この設定を変えても見た目は変わりません。\n\n最後に、設定を OFF にしたときの後始末です。`persist` は値を保存したあと `didChange` を送り、`OverlayController.applySettings()` が呼ばれます。\n\n```swift:OverlayController.swift\nprivate func applySettings() {\n    let configuration = NeonConfiguration(settings: settings)\n    if !settings.hideWhenCursorHidden { isCursorSuppressed = false }\n    // ...\n    reposition()\n}\n```\n\nカーソルが消えている最中に設定を OFF にした場合を考えます。ここで `isCursorSuppressed` を戻さないと、`applySettings()` の最後の `reposition()` は古い `true` のまま `setCursorVisible(false)` を通します。ネオンが戻るのは、次の tick で `suppressionChanged` が立ったときです。`applySettings()` の中で先に `false` に戻しておけば、その場の `reposition()` でネオンが戻ります。\n\nバージョンは `project.yml` の `MARKETING_VERSION` を `1.3.0` に上げました。\n\n## NeonPointer の関連記事\n\n- Zenn（この記事のペア）: [カーソルが「消えた」ことをどう知るか ― 公式 API がない状態を判定する設計](https://zenn.dev/tanishi_z/articles/31-neonpointer-cursor-hidden-design)\n- Zenn: [カーソルを見失う問題を、権限を要求しないオーバーレイで解決する](https://zenn.dev/tanishi_z/articles/15-neonpointer-cursor-overlay-design)\n- Qiita: [常駐アプリでクリック透過のネオンカーソルを実装する](https://qiita.com/tanishi-z/items/62a1de75bf3b959c685a)\n- Zenn: [1枚のパネルでカーソルを追う設計を、複数ディスプレイでレイヤーだけ動かす構造に作り替えた（＋ログイン時自動起動）](https://zenn.dev/tanishi_z/articles/17-neonpointer-multi-display-overlay-design)\n- Qiita: [スクリーンごとにパネルを固定し、レイヤーだけ動かしてマルチディスプレイに対応する実装（＋SMAppServiceによるログイン時自動起動）](https://qiita.com/tanishi-z/items/4c99095bb64a6b5598d1)\n- Zenn: [macOSアプリで新しい権限を求めないために、イベント監視ではなく状態のポーリングを選んだ話](https://zenn.dev/tanishi_z/articles/19-neonpointer-modifier-key-trail-design)\n- Qiita: [NSEvent.modifierFlagsのポーリングとCAShapeLayerの多重シャドウで「動く軌跡」を描く（macOS/Swift）](https://qiita.com/tanishi-z/items/2c6cd23b22785d36a0c6)\n","coediting":false,"comments_count":1,"created_at":"2026-10-12T11:49:59+09:00","group":null,"id":"417c9a7198c797e040e4","likes_count":1,"private":false,"reactions_count":0,"stocks_count":0,"tags":[{"name":"Swift","versions":[]},{"name":"macOS","versions":[]},{"name":"AppKit","versions":[]},{"name":"Cocoa","versions":[]},{"name":"個人開発","versions":[]}],"title":"macOS でシステムカーソルが非表示かを判定する ― dlsym で CGCursorIsVisible を引き、NSCursor.currentSystem の画像サイズで補う（Swift）","updated_at":"2026-10-12T11:49:59+09:00","url":"https://qiita.com/tanishi-z/items/417c9a7198c797e040e4","user":{"description":"気ままにのんびりやってます。\r\nZennの記事もご覧ください。https://zenn.dev/tanishi_z","facebook_id":"","followees_count":1,"followers_count":0,"github_login_name":"Tanishi-z","id":"tanishi-z","items_count":15,"linkedin_id":"","location":"","name":"","organization":"","permanent_id":4477713,"profile_image_url":"https://avatars.githubusercontent.com/u/170227795?v=4","team_only":false,"twitter_screen_name":null,"website_url":""},"page_views_count":null,"team_membership":null,"organization_url_name":null,"slide":false,"posting_campaign_uuid":null}]