From ce9af106ffc8945e880e4e0ae8373e0bf8fbc699 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 13:55:35 -0800 Subject: [PATCH] Add macOS iOS Android companion platform client wrappers --- README.md | 4 + docs/api/PROTOCOL.md | 1 + ...n-platform-clients-foundation-checklist.md | 37 +++ docs/plans/state.json | 22 +- src/companion/index.ts | 10 + src/companion/platformClients.test.ts | 109 ++++++++ src/companion/platformClients.ts | 248 ++++++++++++++++++ 7 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-02-16-companion-platform-clients-foundation-checklist.md create mode 100644 src/companion/platformClients.test.ts create mode 100644 src/companion/platformClients.ts diff --git a/README.md b/README.md index dc50cad..d6b071a 100644 --- a/README.md +++ b/README.md @@ -933,6 +933,10 @@ Methods: Companion runtime helper: - `src/companion/runtimeClient.ts` provides a typed Node/WebSocket client for companion runtimes (macOS/iOS/Android workers) with wrappers for `node.register`, `node.capabilities.get`, `node.location.set/get`, `node.status.set`, `node.push_token.set`, `system.capabilities`, and `system.nodes`. +- `src/companion/platformClients.ts` provides platform-focused wrappers: + - `MacOSCompanionClient` (`platform: "macos"`, APNs push registration) + - `IOSCompanionClient` (`platform: "ios"`, APNs push registration) + - `AndroidCompanionClient` (`platform: "android"`, FCM push registration) ## Canvas / A2UI Foundation diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 38bc367..8a17e18 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -1291,3 +1291,4 @@ For more implementation details, see: - Handlers: `src/gateway/handlers/` - Gateway server: `src/gateway/server.ts` - Companion runtime client helper: `src/companion/runtimeClient.ts` +- Platform companion wrappers: `src/companion/platformClients.ts` diff --git a/docs/plans/2026-02-16-companion-platform-clients-foundation-checklist.md b/docs/plans/2026-02-16-companion-platform-clients-foundation-checklist.md new file mode 100644 index 0000000..3200554 --- /dev/null +++ b/docs/plans/2026-02-16-companion-platform-clients-foundation-checklist.md @@ -0,0 +1,37 @@ +# Companion Platform Clients Foundation Checklist (2026-02-16) + +## Scope + +- Build platform-focused companion wrappers on top of `CompanionRuntimeClient` so app runtimes can use stable, platform-specific methods without manual RPC payload shaping. +- Cover macOS, iOS, and Android runtime surfaces. + +## Implementation + +- Added `src/companion/platformClients.ts` with: + - `MacOSCompanionClient` + - `IOSCompanionClient` + - `AndroidCompanionClient` +- Standardized shared operations for each platform client: + - `connect()` / `disconnect()` + - `register()` + - `getCapabilities()` + - `setStatus()` (platform pinned per client) + - `setLocation()` / `getLocation()` + - `getSystemCapabilities()` + - `listNodes()` (platform + role filtered) +- Platform push registration semantics: + - macOS/iOS -> APNs (`node.push_token.set` provider `apns`) + - Android -> FCM (`node.push_token.set` provider `fcm`) + +## Tests + +- Added `src/companion/platformClients.test.ts`. +- Verified wrapper behavior with runtime mock coverage for: + - platform-specific `node.status.set` payloads + - APNs vs FCM push provider enforcement by wrapper + - platform-filtered `system.nodes` queries + +## Docs Updated + +- `README.md` companion section now references `platformClients.ts` wrappers. +- `docs/api/PROTOCOL.md` implementation references now include platform wrappers. diff --git a/docs/plans/state.json b/docs/plans/state.json index 98a3f5b..ed8594f 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -726,6 +726,24 @@ ], "test_status": "pnpm test:run src/companion/runtimeClient.test.ts + pnpm typecheck + pnpm build passing" }, + "companion-platform-clients-foundation": { + "file": "2026-02-16-companion-platform-clients-foundation-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Added platform-focused companion wrappers (`MacOSCompanionClient`, `IOSCompanionClient`, `AndroidCompanionClient`) on top of `CompanionRuntimeClient` with pinned platform status payloads, APNs/FCM push registration helpers, and platform-filtered `system.nodes` queries.", + "files_created": [ + "docs/plans/2026-02-16-companion-platform-clients-foundation-checklist.md", + "src/companion/platformClients.ts", + "src/companion/platformClients.test.ts" + ], + "files_modified": [ + "src/companion/index.ts", + "README.md", + "docs/api/PROTOCOL.md" + ], + "test_status": "pnpm test:run src/companion/platformClients.test.ts src/companion/runtimeClient.test.ts + pnpm typecheck + pnpm build passing" + }, "qmd-backend": { "file": "2026-02-16-qmd-backend-checklist.md", "status": "completed", @@ -3287,7 +3305,7 @@ } }, "overall_progress": { - "total_test_count": 1817, + "total_test_count": 1820, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -3307,7 +3325,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "OpenClaw gap: implement macOS/iOS/Android companion runtime clients on top of `src/companion/runtimeClient.ts`" + "next_up": "OpenClaw gap: integrate companion platform clients into concrete app runtimes and add end-to-end gateway fixture coverage" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/companion/index.ts b/src/companion/index.ts index 92cd25f..2a394ab 100644 --- a/src/companion/index.ts +++ b/src/companion/index.ts @@ -2,6 +2,11 @@ export { CompanionRuntimeClient, GatewayRpcError, } from './runtimeClient.js'; +export { + MacOSCompanionClient, + IOSCompanionClient, + AndroidCompanionClient, +} from './platformClients.js'; export type { CompanionRuntimeClientOptions, @@ -23,3 +28,8 @@ export type { NodeStatus, NodePushSummary, } from './runtimeClient.js'; +export type { + PlatformClientOptions, + RegisterPushTokenInput, + SharedStatusInput, +} from './platformClients.js'; diff --git a/src/companion/platformClients.test.ts b/src/companion/platformClients.test.ts new file mode 100644 index 0000000..b700c57 --- /dev/null +++ b/src/companion/platformClients.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + AndroidCompanionClient, + IOSCompanionClient, + MacOSCompanionClient, +} from './platformClients.js'; +import type { CompanionRuntimeClient } from './runtimeClient.js'; + +function createRuntimeMock(): { + runtime: CompanionRuntimeClient; + connect: ReturnType; + disconnect: ReturnType; + registerNode: ReturnType; + getNodeCapabilities: ReturnType; + setNodeStatus: ReturnType; + setNodeLocation: ReturnType; + getNodeLocation: ReturnType; + setNodePushToken: ReturnType; + getSystemCapabilities: ReturnType; + listSystemNodes: ReturnType; +} { + const connect = vi.fn(async () => undefined); + const disconnect = vi.fn(() => undefined); + const registerNode = vi.fn(async () => ({ registered: true })); + const getNodeCapabilities = vi.fn(async () => ({ node: { id: 'n1', role: 'companion', registeredAt: Date.now() }, protocol: { serverVersion: 1, nodeVersion: 1, negotiatedVersion: 1 }, capabilities: { declared: [], enabled: [], featureGates: {} } })); + const setNodeStatus = vi.fn(async () => ({ updated: true })); + const setNodeLocation = vi.fn(async () => ({ updated: true })); + const getNodeLocation = vi.fn(async () => ({ node: { id: 'n1', role: 'companion' }, location: null })); + const setNodePushToken = vi.fn(async () => ({ updated: true })); + const getSystemCapabilities = vi.fn(async () => ({ protocol: { version: 1 }, nodes: { enabled: true, locationEnabled: true, pushEnabled: true, allowedRoles: ['companion'], registered: true }, featureGates: {} })); + const listSystemNodes = vi.fn(async () => ({ nodes: [], summary: { total: 0 } })); + + const runtime = { + connect, + disconnect, + registerNode, + getNodeCapabilities, + setNodeStatus, + setNodeLocation, + getNodeLocation, + setNodePushToken, + getSystemCapabilities, + listSystemNodes, + } as unknown as CompanionRuntimeClient; + + return { + runtime, + connect, + disconnect, + registerNode, + getNodeCapabilities, + setNodeStatus, + setNodeLocation, + getNodeLocation, + setNodePushToken, + getSystemCapabilities, + listSystemNodes, + }; +} + +describe('platform companion clients', () => { + it('macOS client uses macos platform status and APNs push', async () => { + const mock = createRuntimeMock(); + const client = new MacOSCompanionClient({ runtime: mock.runtime, nodeId: 'mac-node' }); + + await client.connect(); + await client.register(); + await client.setStatus({ appVersion: '1.0.0', powerSource: 'ac' }); + await client.setLocation({ latitude: 10, longitude: 20, source: 'manual' }); + await client.registerPushToken({ token: 'a'.repeat(64), topic: 'dev.flynn.macos', environment: 'production' }); + await client.listNodes(); + client.disconnect(); + + expect(mock.connect).toHaveBeenCalledOnce(); + expect(mock.registerNode).toHaveBeenCalledWith(expect.objectContaining({ nodeId: 'mac-node', role: 'companion' })); + expect(mock.setNodeStatus).toHaveBeenCalledWith(expect.objectContaining({ platform: 'macos' })); + expect(mock.setNodePushToken).toHaveBeenCalledWith(expect.objectContaining({ provider: 'apns', topic: 'dev.flynn.macos' })); + expect(mock.listSystemNodes).toHaveBeenCalledWith({ platform: 'macos', role: 'companion' }); + expect(mock.disconnect).toHaveBeenCalledOnce(); + }); + + it('iOS client uses ios platform status and APNs push', async () => { + const mock = createRuntimeMock(); + const client = new IOSCompanionClient({ runtime: mock.runtime, nodeId: 'ios-node' }); + + await client.register(); + await client.setStatus({ statusText: 'foreground', batteryPct: 52, powerSource: 'battery' }); + await client.registerPushToken({ token: 'b'.repeat(64), topic: 'dev.flynn.ios', environment: 'sandbox' }); + await client.listNodes(); + + expect(mock.setNodeStatus).toHaveBeenCalledWith(expect.objectContaining({ platform: 'ios' })); + expect(mock.setNodePushToken).toHaveBeenCalledWith(expect.objectContaining({ provider: 'apns', environment: 'sandbox' })); + expect(mock.listSystemNodes).toHaveBeenCalledWith({ platform: 'ios', role: 'companion' }); + }); + + it('android client uses android platform status and FCM push', async () => { + const mock = createRuntimeMock(); + const client = new AndroidCompanionClient({ runtime: mock.runtime, nodeId: 'android-node' }); + + await client.register(); + await client.setStatus({ appVersion: '2.0.0', powerSource: 'battery' }); + await client.registerPushToken('c'.repeat(64)); + await client.listNodes(); + + expect(mock.setNodeStatus).toHaveBeenCalledWith(expect.objectContaining({ platform: 'android' })); + expect(mock.setNodePushToken).toHaveBeenCalledWith({ provider: 'fcm', token: 'c'.repeat(64) }); + expect(mock.listSystemNodes).toHaveBeenCalledWith({ platform: 'android', role: 'companion' }); + }); +}); diff --git a/src/companion/platformClients.ts b/src/companion/platformClients.ts new file mode 100644 index 0000000..dd367bd --- /dev/null +++ b/src/companion/platformClients.ts @@ -0,0 +1,248 @@ +import type { + CompanionRuntimeClient, + NodeCapabilitiesResult, + NodeLocationGetResult, + NodeLocationSetResult, + NodeRegisterResult, + NodeStatusSetResult, + NodePushTokenSetResult, + SetNodeLocationInput, + SystemCapabilitiesResult, + SystemNodesResult, +} from './runtimeClient.js'; + +export interface PlatformClientOptions { + runtime: CompanionRuntimeClient; + nodeId: string; + role?: string; + capabilities?: string[]; + protocolVersion?: number; +} + +export interface RegisterPushTokenInput { + token: string; + topic?: string; + environment?: 'sandbox' | 'production'; +} + +export type SharedStatusInput = Omit< + Parameters[0], + 'platform' +>; + +export class MacOSCompanionClient { + private readonly runtime: CompanionRuntimeClient; + private readonly nodeId: string; + private readonly role: string; + private readonly capabilities: string[]; + private readonly protocolVersion?: number; + + constructor(options: PlatformClientOptions) { + this.runtime = options.runtime; + this.nodeId = options.nodeId; + this.role = options.role ?? 'companion'; + this.capabilities = options.capabilities ?? ['ui.canvas', 'node.location.write', 'node.push.register']; + this.protocolVersion = options.protocolVersion; + } + + connect(): Promise { + return this.runtime.connect(); + } + + disconnect(): void { + this.runtime.disconnect(); + } + + register(): Promise { + return this.runtime.registerNode({ + nodeId: this.nodeId, + role: this.role, + protocolVersion: this.protocolVersion, + capabilities: this.capabilities, + }); + } + + getCapabilities(): Promise { + return this.runtime.getNodeCapabilities(); + } + + setStatus(status: SharedStatusInput): Promise { + return this.runtime.setNodeStatus({ + platform: 'macos', + appVersion: status.appVersion, + deviceName: status.deviceName, + statusText: status.statusText, + batteryPct: status.batteryPct, + powerSource: status.powerSource, + }); + } + + setLocation(location: SetNodeLocationInput): Promise { + return this.runtime.setNodeLocation(location); + } + + getLocation(): Promise { + return this.runtime.getNodeLocation(); + } + + registerPushToken(input: RegisterPushTokenInput): Promise { + return this.runtime.setNodePushToken({ + provider: 'apns', + token: input.token, + topic: input.topic, + environment: input.environment, + }); + } + + getSystemCapabilities(): Promise { + return this.runtime.getSystemCapabilities(); + } + + listNodes(): Promise { + return this.runtime.listSystemNodes({ platform: 'macos', role: this.role }); + } +} + +export class IOSCompanionClient { + private readonly runtime: CompanionRuntimeClient; + private readonly nodeId: string; + private readonly role: string; + private readonly capabilities: string[]; + private readonly protocolVersion?: number; + + constructor(options: PlatformClientOptions) { + this.runtime = options.runtime; + this.nodeId = options.nodeId; + this.role = options.role ?? 'companion'; + this.capabilities = options.capabilities ?? ['node.location.write', 'node.push.register']; + this.protocolVersion = options.protocolVersion; + } + + connect(): Promise { + return this.runtime.connect(); + } + + disconnect(): void { + this.runtime.disconnect(); + } + + register(): Promise { + return this.runtime.registerNode({ + nodeId: this.nodeId, + role: this.role, + protocolVersion: this.protocolVersion, + capabilities: this.capabilities, + }); + } + + getCapabilities(): Promise { + return this.runtime.getNodeCapabilities(); + } + + setStatus(status: SharedStatusInput): Promise { + return this.runtime.setNodeStatus({ + platform: 'ios', + appVersion: status.appVersion, + deviceName: status.deviceName, + statusText: status.statusText, + batteryPct: status.batteryPct, + powerSource: status.powerSource, + }); + } + + setLocation(location: SetNodeLocationInput): Promise { + return this.runtime.setNodeLocation(location); + } + + getLocation(): Promise { + return this.runtime.getNodeLocation(); + } + + registerPushToken(input: RegisterPushTokenInput): Promise { + return this.runtime.setNodePushToken({ + provider: 'apns', + token: input.token, + topic: input.topic, + environment: input.environment, + }); + } + + getSystemCapabilities(): Promise { + return this.runtime.getSystemCapabilities(); + } + + listNodes(): Promise { + return this.runtime.listSystemNodes({ platform: 'ios', role: this.role }); + } +} + +export class AndroidCompanionClient { + private readonly runtime: CompanionRuntimeClient; + private readonly nodeId: string; + private readonly role: string; + private readonly capabilities: string[]; + private readonly protocolVersion?: number; + + constructor(options: PlatformClientOptions) { + this.runtime = options.runtime; + this.nodeId = options.nodeId; + this.role = options.role ?? 'companion'; + this.capabilities = options.capabilities ?? ['node.location.write', 'node.push.register']; + this.protocolVersion = options.protocolVersion; + } + + connect(): Promise { + return this.runtime.connect(); + } + + disconnect(): void { + this.runtime.disconnect(); + } + + register(): Promise { + return this.runtime.registerNode({ + nodeId: this.nodeId, + role: this.role, + protocolVersion: this.protocolVersion, + capabilities: this.capabilities, + }); + } + + getCapabilities(): Promise { + return this.runtime.getNodeCapabilities(); + } + + setStatus(status: SharedStatusInput): Promise { + return this.runtime.setNodeStatus({ + platform: 'android', + appVersion: status.appVersion, + deviceName: status.deviceName, + statusText: status.statusText, + batteryPct: status.batteryPct, + powerSource: status.powerSource, + }); + } + + setLocation(location: SetNodeLocationInput): Promise { + return this.runtime.setNodeLocation(location); + } + + getLocation(): Promise { + return this.runtime.getNodeLocation(); + } + + registerPushToken(token: string): Promise { + return this.runtime.setNodePushToken({ + provider: 'fcm', + token, + }); + } + + getSystemCapabilities(): Promise { + return this.runtime.getSystemCapabilities(); + } + + listNodes(): Promise { + return this.runtime.listSystemNodes({ platform: 'android', role: this.role }); + } +}