Vigilare は macOS と iOS の両方を、同じ Apple Reminders 連携と同じタスク管理ロジックの上で動かしています。共通する部分を VigilareKit という Swift Package に切り出して、Models や Stores や Services や UseCases や ViewModels や DesignSystem を共有し、Views だけプラットフォーム別に持つ構造にしました。
vigilare.labee.devVigilare - Floating Reminders for macOS | Always VisibleNever miss a task with Vigilare's floating window for Apple Reminders. Always visible on your Mac, even in fullscreen apps. $9.99 one-time purchase.この記事では、共通化の範囲をどこまで広げるか検討した3案、ViewModels まで共有に振った3つの判断軸、いま VigilareKit に入っているもの・入れなかったものを順に扱います。iOS 版の UI 設計そのものは Vigilare の iOS 版を作っている で扱いました。
共通化で落としたくなかったもの
Vigilare のコアは「Apple Reminders に書いたタスクを、別アプリへ切り替えずに視界に入れる」体験です。macOS と iOS を同じプロダクトとして出すなら、データソースの一致と挙動の一致だけは絶対に崩したくありません。
落としたくなかったのは次の2点です。
- 同じ Apple Reminders を一次データソースに使うこと (アプリ内に独自のタスクストレージを作らない)
- 同じタスク管理ロジック (フィルター、ソート、ステータス遷移、コメント、URL 検出) を両プラットフォームで同じ挙動にすること
逆に、UI 表現はプラットフォームごとに別物として持つ判断を先にしていました。macOS は常時最前面のフローティングウィンドウ、iOS は 3 tab + FAB という体験で、Views を揃えると両方の OS で妥協のある画面になります。
データソースと挙動は共有、UI 表現は別物、というラインを引くと、共通化の範囲をどこまで広げるかが次の問いになりました。
モジュール分割を整理する
レイヤーを並べると、Vigilare のコードは次のように分類できます。
| レイヤー | 役割 | macOS / iOS の差 |
|---|---|---|
| Models | データ構造 (Reminder, List, Status, RecurrenceRule 等) | 差なし |
| Stores | EventKit のラッパー | 差なし |
| Services | Reminder, Preferences, DeepLink | 差なし |
| UseCases | フィルター・ソート・ステータス遷移・コメント | 差なし |
| ViewModels | UI 状態 + 操作 | ロジックは差なし、SwiftUI で View に渡るだけ |
| DesignSystem | Color, Spacing, Typography, Animation | 差なし (SwiftUI Color 経由で表現) |
| Utilities | 日付計算、URL 検出、文字列処理など | 差なし |
| Platform 差吸収 | NSColor / UIColor のような OS 固有型のラッパー | ここだけ差を内側で吸収 |
| Views | 画面 | 別物 (macOS は NSWindow と IconPanel、iOS は TabView と Sheet) |
Models から ViewModels までは UI から独立しているので、macOS と iOS で挙動を変える理由がありません。逆に Views は OS の前提が直接効くので、共有しようとすると両 OS で妥協のある画面ができあがります。Platform 差吸収 (例えば PlatformColor のような) の薄いレイヤーだけが、共有レイヤーと OS 固有 API の境目に立ちます。
3 つのモジュールの関係を描くと次のようになります。
graph TD
macOS["Vigilare (macOS app target)<br/>Views, Windows, AppDelegate<br/>MCP server, Settings ViewModel"]
iOS["VigilareiOS (iOS app target)<br/>Views, App entry"]
Kit["VigilareKit<br/>Models / Stores / Services / UseCases<br/>ViewModels / DesignSystem / Utilities"]
EventKit
macOS --> Kit
iOS --> Kit
Kit --> EventKit
macOS app と iOS app の両 target が VigilareKit に依存し、VigilareKit が EventKit に依存します。逆方向の依存は無く、VigilareKit は macOS app / iOS app のどちらの存在も知りません。VigilareKit の内側にも OS 固有の API は出てこず、NSColor / UIColor のような差分は PlatformColor で吸収します。
共通化の範囲を3案並べた
「同じデータ・同じ挙動」を成立させるための共通化範囲には選択肢がありました。
案A Models だけ共有
Reminder などのデータ構造だけを共有モジュールに入れて、Stores 以上は macOS / iOS で別物として持つ案です。
データの型は揃いますが、EventKit との同期、フィルターやソートの挙動、ステータス遷移のルールを macOS と iOS で2回書くことになります。片側で機能を入れたら片側にも入れる、というメンテナンスが恒常的に発生します。同じ挙動を保つコストが高い割に、共有の恩恵が薄い案です。
案B Stores と Services まで共有
EventKit ラッパー (Stores) と Reminder / Preferences のサービス層まで共有モジュールに入れる案です。データ同期と外部 I/O は揃います。
ただ、フィルターやソートやステータス遷移のロジックは ViewModel か Services のどちらに持たせるかで揺れます。Services に寄せると Services が膨らみ、ViewModel に持たせると macOS と iOS で「ほぼ同じ ViewModel」を2回書く形になります。共有のラインを Services で止めた時点で、その上のレイヤーをどこに置くかが新しい問いとして出てきます。
案C ViewModels と DesignSystem まで共有
Models / Stores / Services / UseCases / ViewModels / DesignSystem を共有モジュールにまとめて、Views だけ macOS / iOS 別に持つ案です。
書く量で見ると、共有モジュールに入る行数が一番多くなります。一方で、両 OS で「同じ挙動」を保つコストは最も低い。Views 以外を共有してしまえば、画面の組み方だけが platform 別に分かれて、ロジックは1箇所に集まります。
3案を比べた結果、案 C を採用しました。共有ラインを高い位置に引くほうが、両 OS で挙動を揃え続けるコストが下がります。
ViewModels まで共有に振った3つの判断軸
案 C を選んだとき、ぶれないように立てた判断軸が3つあります。
1つ目は、UI 状態の管理ロジックも「アプリの挙動」だと捉える軸です。フィルターの適用、ソート、ステータス遷移、編集中の debounce、検索のデバウンス、といったユーザーから見える挙動は、Views ではなく ViewModel に集まります。ここを Views 側に置くと、macOS と iOS で「微妙に違うアプリ」になります。Apple Reminders を扱う1つのアプリとして出す以上、こういう微妙な違いは作らないと決めました。
2つ目は、SwiftUI の View / ViewModel 境界が両 OS で同じだったことです。SwiftUI 自体が macOS と iOS で同じ API を提供するので、ViewModel を共通化しても OS 固有のラッパーがほぼ要りません。NSColor / UIColor のような OS 固有型に触る部分だけ薄いラッパーで吸収すれば済みます。共通化のコストが想定より低かったのが、案 C に踏み込めた直接の理由です。
3つ目は、共通化のラインを高い位置に引くほど、両 OS で挙動を揃えるコストが下がる、という整理です。低い位置に引くと、Stores と Services の上に Views ごとに「ほぼ同じ ViewModel」を書く羽目になります。書く量を増やしても挙動の一致が下がる、という設計は選びにくい。書く量と一致のしやすさが逆相関にならない位置を選んだ結果が ViewModels までの共有でした。
VigilareKit にいま入っているもの・入れなかったもの
切り出しの実体は1コミットの大きな移動でした。Vigilare/Models, Vigilare/Services, Vigilare/Stores, Vigilare/ViewModels, Vigilare/DesignSystem などが VigilareKit/* に移り、xcodeproj 上のターゲット構成は次のようになりました。
Vigilare.xcodeproj/
├── Vigilare/ # macOS app target — Views, Windows, AppDelegate, MCP server, Settings ViewModel
├── VigilareiOS/ # iOS app target — Views, App entry
└── VigilareKit/ # 両 target が import する共通ドメイン層
VigilareKit に入っているのは Models / Stores (EventKit ラッパー) / Services / UseCases / ViewModels / DesignSystem / Utilities / Extensions と、Platform 差を吸収する PlatformColor です。一方で、macOS 固有の MCP server (Claude Code などから Apple Reminders を操作する用の Stdio transport) と Settings 系の ViewModel は macOS app target 側に残しました。MCP の Stdio transport は macOS と iOS でランタイム制約が違うため、ここを慌てて共通化する案ではありません。
FloatingReminderViewModel は名前こそ macOS の floating window 由来ですが、iOS の3 tab + FAB 構成からもそのまま使っています。
入れなかったのは、macOS の NSWindow / IconPanel まわり、iOS の TabView / Sheet 構成、プラットフォーム固有のメニューや Keyboard Shortcut の配線です。これらは OS 固有の API を直接触る場所で、共通化しても得るものがありません。Vigilare 側 (macOS) と VigilareiOS 側 (iOS) にそれぞれ残しました。
NSColor と UIColor の差は PlatformColor という薄いラッパーで吸収しています。SwiftUI Color に橋渡しするだけのレイヤーで、ここに OS 固有のロジックは置きません。共有レイヤーと OS 固有 API の境目を、できるだけ薄く・できるだけ少なくしておく方針です。
いま残っている課題
VigilareKit に切り出した後にも、手を入れる余地が残っています。
1つは名前の整理です。FloatingReminderViewModel の名前が AddReminderViewModel や EditReminderViewModel のような用途に直結した名前と並ぶと、Floating だけ旧称のまま残っているのが目立ちます。リネームすると参照箇所の差分が大きいので後回しにしていますが、追加 ViewModel を作るたびに名前のずれが大きくなる方向の負債です。
2つ目は Utilities の分類です。Converters / DateHelpers / Detectors / Filters / Formatters / Parsers / Sorting / Validators と細かく切ってあるものの、ファイル間で目的が近いものが混ざっている部分があり、共通化の境界線を後で再整理する予定です。Models や UseCases に寄せるべきものが Utilities に紛れて入っているケースも出てきていて、レイヤー分割としては緩い状態です。
