マルチプラットフォーム対応パターン
リポジトリ: openclaw/openclaw 分析日: 2026-02-14
概要
Node.js/Bun をメインランタイムとする CLI/Gateway と、Swift(macOS/iOS)・Kotlin(Android)のネイティブアプリが WebSocket プロトコルで接続するクロスプラットフォームアーキテクチャを分析する。TypeScript で定義した Zod スキーマからコード生成で Swift モデルを導出し、プロトコルの Single Source of Truth を確保する手法は、異なる言語圏を横断するプロジェクトに広く応用できる。3,300 超のソースファイルが 5 つの言語/ランタイムに跨る大規模プロジェクトとして、コード共有・同期・分岐の戦略が特に注目に値する。
背景にある原則
Single Source of Truth でプロトコルを管理すべき(言語横断のスキーマドリフトを排除するため): TypeScript の Zod/TypeBox スキーマ群(
src/gateway/protocol/schema/*.ts)を唯一の定義元とし、scripts/protocol-gen.tsで JSON Schema、scripts/protocol-gen-swift.tsで Swift のCodable構造体を生成する。CI でprotocol:checkを実行し、生成物と git の差分があればビルドを失敗させることでドリフトを防止している。- 根拠:
package.json:77のprotocol:checkスクリプト、scripts/protocol-gen-swift.ts:1-244
- 根拠:
共有コードは「プロトコル層」と「ロジック層」を分離すべき(依存の粒度を制御するため): Swift の共有パッケージ
OpenClawKitは 3 つのターゲット(OpenClawProtocol,OpenClawKit,OpenClawChatUI)に分割されている。プロトコル定義のみ必要なクライアントはOpenClawProtocolだけに依存でき、UI コンポーネントまで必要な場合はOpenClawChatUIを追加する。- 根拠:
apps/shared/OpenClawKit/Package.swift:11-14
- 根拠:
プラットフォーム固有コードは「同じ契約、異なる実装」で管理すべき(API の対称性を保つため): 各プラットフォームが同じコマンド名前空間(
canvas.*,camera.*,location.*等)を実装し、同じ JSON ペイロードを送受信する。iOS は Swift protocol で抽象化(NodeServiceProtocols.swift)、Android は Handler クラス群(CameraHandler,LocationHandler等)で委譲する。- 根拠:
apps/ios/Sources/Services/NodeServiceProtocols.swift:6-60,apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt:11-23
- 根拠:
バージョン番号は全プラットフォームで同期すべき(デプロイ時の不一致を防ぐため): AGENTS.md にバージョン同期箇所が明示されている(
package.json,build.gradle.kts:versionName,Info.plist:CFBundleShortVersionString等)。全箇所が2026.2.13で統一されている。- 根拠:
AGENTS.md:144-145,package.json:3,apps/android/app/build.gradle.kts:25,apps/ios/project.yml:84
- 根拠:
実例と分析
1. Zod/TypeBox からの Swift コード生成パイプライン
プロトコルの Single Source of Truth は TypeScript の TypeBox スキーマ(src/gateway/protocol/schema/ 配下、約 260 エントリ)。scripts/protocol-gen-swift.ts がこれを走査し、各スキーマの type と properties から Swift の Codable, Sendable 構造体を自動生成する。
生成物は 2 箇所に同時出力される:
apps/macos/Sources/OpenClawProtocol/GatewayModels.swift-- macOS アプリ直接配置apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift-- 共有パッケージ(iOS も参照)
// apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift:1-2
// Generated by scripts/protocol-gen-swift.ts — do not edit by hand
// swiftlint:disable file_lengthCI は pnpm protocol:check で生成後に git diff --exit-code を実行し、手動編集の混入を検出する:
// package.json:77
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift"Android(Kotlin)はコード生成パイプラインに含まれておらず、コマンド定数を手動で管理している(OpenClawProtocolConstants.kt)。ただしコマンド名は文字列リテラルであり、スキーマの型構造には依存しないため、整合性リスクは限定的。
2. 共有 Swift パッケージによる iOS/macOS コード共有
apps/shared/OpenClawKit/ は Swift Package Manager パッケージとして、macOS(Package.swift 直接依存)と iOS(project.yml 経由の xcodegen)の両方から参照される。
// apps/shared/OpenClawKit/Package.swift:6-9
let package = Package(
name: "OpenClawKit",
platforms: [
.iOS(.v18),
.macOS(.v15),
],共有される主な機能:
- プロトコルモデル (
OpenClawProtocol): 自動生成のGatewayModels.swift+AnyCodable.swift - ゲートウェイセッション (
OpenClawKit):GatewayNodeSession.swift-- WebSocket 接続管理、invoke タイムアウト処理 - チャット UI (
OpenClawChatUI):ChatViewModel.swift,ChatView.swift等
Android は Gradle の assets.srcDir で共有リソースファイルを直接参照する:
// apps/android/app/build.gradle.kts:16-17
assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources"))これにより tool-display.json などの設定ファイルが Swift/Kotlin 間で同一ファイルとして共有される。
3. コマンドディスパッチの対称的実装
iOS と Android はともに「コマンド名によるルーティング」パターンを採用しているが、実装手法はプラットフォームのイディオムに合わせて異なる。
iOS: NodeCapabilityRouter がコマンド文字列からハンドラーへのマップを保持:
// apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift:5-25
@MainActor
final class NodeCapabilityRouter {
enum RouterError: Error {
case unknownCommand
case handlerUnavailable
}
typealias Handler = (BridgeInvokeRequest) async throws -> BridgeInvokeResponse
private let handlers: [String: Handler]
func handle(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
guard let handler = handlers[request.command] else {
throw RouterError.unknownCommand
}
return try await handler(request)
}
}Android: InvokeDispatcher が when 式で分岐:
// apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt:56-174
return when (command) {
OpenClawCanvasCommand.Present.rawValue -> { /* ... */ }
OpenClawCameraCommand.Snap.rawValue -> cameraHandler.handleSnap(paramsJson)
OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson)
else -> GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
}両者とも入口でフォアグラウンド制約やケイパビリティ有効チェックを行い、同じエラーコード体系(NODE_BACKGROUND_UNAVAILABLE, CAMERA_DISABLED 等)を返す。
4. ロジック重複の管理: BonjourEscapes と A2UI Action
同一ロジックが Swift と Kotlin で独立実装される箇所がある。これは「コード生成では扱えないが、ロジックの一致が必要な領域」に相当する。
BonjourEscapes -- mDNS の \DDD エスケープをデコードするユーティリティ:
- Swift:
apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift - Kotlin:
apps/android/app/src/main/java/ai/openclaw/android/gateway/BonjourEscapes.kt
CanvasA2UIAction -- UI アクションのメッセージフォーマットとディスパッチ:
- Swift:
apps/shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift:69-81 - Kotlin:
apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawCanvasA2UIAction.kt:38-58
両者は同じ文字列フォーマット CANVAS_A2UI action=... session=... surface=... を生成し、同じ sanitizeTagValue ロジック(英数字とアンダースコア以外を _ に置換)を実装する。テストも各プラットフォームで個別に存在する(BonjourEscapesTest.kt, OpenClawCanvasA2UIActionTest.kt)。
5. デュアルセッション(Operator/Node)アーキテクチャ
iOS と Android の両方が Gateway に対して 2 本の WebSocket 接続を維持する:
- Operator セッション: チャット、設定、音声操作のための読み書き
- Node セッション: デバイスケイパビリティ(カメラ、位置情報等)の invoke 処理
// apps/ios/Sources/Model/NodeAppModel.swift:87-89
private let nodeGateway = GatewayNodeSession()
private let operatorGateway = GatewayNodeSession()// apps/android/app/src/main/java/ai/openclaw/android/NodeRuntime.kt:206-266
private val operatorSession = GatewaySession(/* ... onConnected, onDisconnected, onEvent */)
private val nodeSession = GatewaySession(/* ... onInvoke */)この分離により、チャットの読み込みがデバイスの invoke タイムアウトに影響しない。
パターンカタログ
Code Generator パターン (分類: 生成)
- 解決する問題: 異なる言語間でのプロトコル定義の同期
- 適用条件: TypeScript が Single Source of Truth として機能し、他言語のモデルが自動生成可能な場合
- コード例:
scripts/protocol-gen-swift.ts:113-156(emitStruct関数が TypeBox スキーマから Swift struct を生成) - 注意点: 生成対象が増えるほど CI チェックが重要(
protocol:check)。Kotlin は未対応。
Strategy パターン (分類: 振る舞い)
- 解決する問題: プラットフォーム固有のデバイス操作を統一インターフェースで扱う
- 適用条件: 同じコマンド契約を異なる OS API で実装する場合
- コード例:
apps/ios/Sources/Services/NodeServiceProtocols.swift:6-60 - 注意点: Android は protocol/interface でなく具象 Handler クラスに委譲しており、テスタビリティの差がある
Good Patterns
- CI による生成物の差分チェック:
protocol:checkでgit diff --exit-codeを実行し、生成ファイルの手動編集を検出する。コード生成パイプラインの信頼性を CI で担保する優れた手法。
// package.json:77
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift"- 共有リソースの参照による重複排除: Android が Gradle の
assets.srcDirで Swift パッケージ内のリソースディレクトリを直接参照し、tool-display.jsonを単一ファイルとして管理する。
// apps/android/app/build.gradle.kts:16-17
sourceSets {
getByName("main") {
assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources"))
}
}- エラーコードの文字列ベース統一:
NOT_LINKED,UNAVAILABLE,INVALID_REQUEST等のエラーコードを Swift のenum ErrorCode: String, Codableと Kotlin/TypeScript の文字列定数で共有する。JSON シリアライズ可能かつヒューマンリーダブル。
// apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift:7-13
public enum ErrorCode: String, Codable, Sendable {
case notLinked = "NOT_LINKED"
case notPaired = "NOT_PAIRED"
case agentTimeout = "AGENT_TIMEOUT"
case invalidRequest = "INVALID_REQUEST"
case unavailable = "UNAVAILABLE"
}AnyCodableラッパーによる動的 JSON のハンドリング: プロトコルのペイロードは動的な JSON 構造を含むため、AnyCodableラッパー(apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift)で型安全な Codable と動的構造を橋渡しする。
Anti-Patterns / 注意点
- コード生成の対象が一部プラットフォームに限定される問題: Swift はコード生成対象だが、Kotlin は手動管理。コマンド追加時に Android 側の
OpenClawProtocolConstants.ktの更新を忘れるリスクがある。
Bad(現状 -- Android は手動管理):
// apps/android/app/src/main/java/ai/openclaw/android/protocol/OpenClawProtocolConstants.kt
enum class OpenClawCanvasCommand(val rawValue: String) {
Present("canvas.present"), // 手動で追加・同期
Hide("canvas.hide"),
// ...
}Better(コード生成を Kotlin にも拡張):
scripts/protocol-gen-kotlin.ts // TypeBox -> Kotlin enum/data class 生成
protocol:check:kotlin // CI で差分チェック- 重複ロジックの同期コストが増大するリスク:
BonjourEscapes,CanvasA2UIAction,ToolDisplay等のロジックが Swift/Kotlin で独立実装されている。機能追加時に片方のプラットフォームだけ更新される可能性がある。テストの並列維持でカバーしているが、フォーマット文字列の変更など見落としやすい。
導出ルール
[MUST]クロスプラットフォームプロトコルは単一言語のスキーマを Source of Truth とし、他言語のモデルはコード生成で導出する- 根拠: openclaw は TypeBox スキーマから Swift モデルを生成し、CI の
protocol:checkでgit diff --exit-codeによる差分検出を行うことで、3 言語間のプロトコルドリフトを防止している(scripts/protocol-gen-swift.ts,package.json:77)
- 根拠: openclaw は TypeBox スキーマから Swift モデルを生成し、CI の
[MUST]コード生成物はリポジトリにコミットし、CI で「再生成 + diff なし」を検証する- 根拠: 生成物をコミットすることで IDE の補完やビルドが生成ステップなしに動作し、
git diff --exit-codeで手動編集の混入を自動検出できる(package.json:77)
- 根拠: 生成物をコミットすることで IDE の補完やビルドが生成ステップなしに動作し、
[SHOULD]共有パッケージはプロトコル層・ロジック層・UI 層に分割し、依存の粒度を制御する- 根拠:
OpenClawKitはOpenClawProtocol(生成モデルのみ)、OpenClawKit(ビジネスロジック)、OpenClawChatUI(SwiftUI)の 3 ターゲットに分割され、プロトコル定義のみ必要なクライアントが UI 依存を持たないようにしている(apps/shared/OpenClawKit/Package.swift:11-14)
- 根拠:
[SHOULD]プラットフォーム間で共有するリソースファイルは物理的に単一ファイルとし、ビルドシステムの参照で配信する- 根拠: Android は
assets.srcDirで Swift パッケージ内のtool-display.jsonを直接参照し、ファイルコピーによる二重管理を回避している(apps/android/app/build.gradle.kts:16-17)
- 根拠: Android は
[SHOULD]バージョン番号の同期箇所はドキュメント(AGENTS.md 等)に列挙し、"bump version everywhere" の定義を明確にする- 根拠:
AGENTS.md:144-145がpackage.json,build.gradle.kts,Info.plist等のバージョン箇所を列挙し、全プラットフォームで2026.2.13を維持している
- 根拠:
[SHOULD]複数プラットフォームで同一ロジックを実装する場合、各プラットフォームに対称的なユニットテストを配置する- 根拠:
BonjourEscapesが Swift/Kotlin で独立実装されているが、それぞれにテストが存在する(BonjourEscapesTest.kt, OpenClawKit テストターゲット)
- 根拠:
[AVOID]コード生成の対象プラットフォームを一部に限定したまま放置する(生成対象外のプラットフォームで同期漏れが発生する)- 根拠: Swift モデルは自動生成されるが、Kotlin の
OpenClawProtocolConstants.ktは手動管理のため、コマンド追加時に更新忘れのリスクがある
- 根拠: Swift モデルは自動生成されるが、Kotlin の
適用チェックリスト
- [ ] プロトコル定義の Single Source of Truth を決定し、他言語のモデルをコード生成で導出しているか
- [ ] コード生成物をリポジトリにコミットし、CI で再生成 + diff チェックを実行しているか
- [ ] 共有パッケージの依存グラフを確認し、プロトコル層が UI やプラットフォーム固有コードに依存していないか
- [ ] 全プラットフォームのバージョン番号同期箇所をドキュメントに列挙しているか
- [ ] プラットフォーム間で重複実装しているロジックを特定し、対称的なテストを配置しているか
- [ ] リソースファイル(JSON 設定、アセット等)がコピーではなく参照で共有されているか
- [ ] エラーコード体系がプラットフォーム間で統一されているか