From e67da1538cfac574ec20a99d26ad2141ea4c2c16 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:00:17 +0000 Subject: [PATCH] iOS/Gateway: wake disconnected iOS nodes via APNs before invoke (#20332) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 7751f9c5311484ce05ab9529b450b7937646c68f Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + apps/ios/README.md | 133 ++++++---- apps/ios/Sources/Model/NodeAppModel.swift | 55 ++++ apps/ios/Sources/OpenClawApp.swift | 18 ++ .../server-methods/nodes.invoke-wake.test.ts | 246 ++++++++++++++++++ src/gateway/server-methods/nodes.ts | 112 +++++++- src/infra/push-apns.test.ts | 101 ++++++- src/infra/push-apns.ts | 131 ++++++++-- 8 files changed, 724 insertions(+), 73 deletions(-) create mode 100644 src/gateway/server-methods/nodes.invoke-wake.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9044e8ac36..18e0f96cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- iOS/Gateway: wake disconnected iOS nodes via APNs before `nodes.invoke` and auto-reconnect gateway sessions on silent push wake to reduce invoke failures while the app is backgrounded. (#20332) Thanks @mbelinky. - iOS/APNs: add push registration and notification-signing configuration for node delivery. (#20308) Thanks @mbelinky. - Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky. - iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky. diff --git a/apps/ios/README.md b/apps/ios/README.md index f7fe696d86..b870bdcea5 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1,73 +1,110 @@ -# OpenClaw (iOS) +# OpenClaw iOS (Super Alpha) -This is an **alpha** iOS app that connects to an OpenClaw Gateway as a `role: node`. +NO TEST FLIGHT AVAILABLE AT THIS POINT -Expect rough edges: +This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`. -- UI and onboarding are changing quickly. -- Background behavior is not stable yet (foreground app is the supported mode right now). -- Permissions are opt-in and the app should be treated as sensitive while we harden it. +## Distribution Status -## What It Does +NO TEST FLIGHT AVAILABLE AT THIS POINT -- Connects to a Gateway over `ws://` / `wss://` -- Pairs a new device (approved from your bot) -- Exposes phone services as node commands (camera, location, photos, calendar, reminders, etc; gated by iOS permissions) -- Provides Talk + Chat surfaces (alpha) +- Current distribution: local/manual deploy from source via Xcode. +- App Store flow is not part of the current internal development path. -## Pairing (Recommended Flow) +## Super-Alpha Disclaimer -If your Gateway has the `device-pair` plugin installed: +- Breaking changes are expected. +- UI and onboarding flows can change without migration guarantees. +- Foreground use is the only reliable mode right now. +- Treat this build as sensitive while permissions and background behavior are still being hardened. -1. In Telegram, message your bot: `/pair` -2. Copy the **setup code** message -3. On iOS: OpenClaw → Settings → Gateway → paste setup code → Connect -4. Back in Telegram: `/pair approve` +## Exact Xcode Manual Deploy Flow -## Build And Run - -Prereqs: - -- Xcode (current stable) -- `pnpm` -- `xcodegen` - -From the repo root: +1. Prereqs: + - Xcode 16+ + - `pnpm` + - `xcodegen` + - Apple Development signing set up in Xcode +2. From repo root: ```bash pnpm install +./scripts/ios-configure-signing.sh +cd apps/ios +xcodegen generate +open OpenClaw.xcodeproj +``` + +3. In Xcode: + - Scheme: `OpenClaw` + - Destination: connected iPhone (recommended for real behavior) + - Build configuration: `Debug` + - Run (`Product` -> `Run`) +4. If signing fails on a personal team: + - Use unique local bundle IDs via `apps/ios/LocalSigning.xcconfig`. + - Start from `apps/ios/LocalSigning.xcconfig.example`. + +Shortcut command (same flow + open project): + +```bash pnpm ios:open ``` -`pnpm ios:open` now runs `scripts/ios-configure-signing.sh` before `xcodegen`: +## APNs Expectations For Local/Manual Builds -- If `IOS_DEVELOPMENT_TEAM` is set, it uses that team. -- Otherwise it prefers the canonical OpenClaw team (`Y5PE65HELJ`) when that team exists locally. -- If not present, it picks the first non-personal team from your Xcode account (falls back to personal team if needed). -- It writes the selected team to `apps/ios/.local-signing.xcconfig` (local-only, gitignored). +- The app calls `registerForRemoteNotifications()` at launch. +- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`. +- APNs token registration to gateway happens only after gateway connection (`push.apns.register`). +- Your selected team/profile must support Push Notifications for the app bundle ID you are signing. +- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`). +- Debug builds register as APNs sandbox; Release builds use production. -Then in Xcode: +## What Works Now (Concrete) -1. Select the `OpenClaw` scheme -2. Select a simulator or a connected device -3. Run +- Pairing via setup code flow (`/pair` then `/pair approve` in Telegram). +- Gateway connection via discovery or manual host/port with TLS fingerprint trust prompt. +- Chat + Talk surfaces through the operator gateway session. +- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications. +- Share extension deep-link forwarding into the connected gateway session. -If you're using a personal Apple Development team, you may still need to change the bundle identifier in Xcode to a unique value so signing succeeds. +## Known Issues / Limitations / Problems -## Build From CLI +- Foreground-first: iOS can suspend sockets in background; reconnect recovery is still being tuned. +- Background command limits are strict: `canvas.*`, `camera.*`, `screen.*`, and `talk.*` are blocked when backgrounded. +- Background location requires `Always` location permission. +- Pairing/auth errors intentionally pause reconnect loops until a human fixes auth/pairing state. +- Voice Wake and Talk contend for the same microphone; Talk suppresses wake capture while active. +- APNs reliability depends on local signing/provisioning/topic alignment. +- Expect rough UX edges and occasional reconnect churn during active development. -```bash -pnpm ios:build -``` +## Current In-Progress Workstream -## Tests +Automatic wake/reconnect hardening: -```bash -cd apps/ios -xcodegen generate -xcodebuild test -project OpenClaw.xcodeproj -scheme OpenClaw -destination "platform=iOS Simulator,name=iPhone 17" -``` +- improve wake/resume behavior across scene transitions +- reduce dead-socket states after background -> foreground +- tighten node/operator session reconnect coordination +- reduce manual recovery steps after transient network failures -## Shared Code +## Debugging Checklist -- `apps/shared/OpenClawKit` contains the shared transport/types used by the iOS app. +1. Confirm build/signing baseline: + - regenerate project (`xcodegen generate`) + - verify selected team + bundle IDs +2. In app `Settings -> Gateway`: + - confirm status text, server, and remote address + - verify whether status shows pairing/auth gating +3. If pairing is required: + - run `/pair approve` from Telegram, then reconnect +4. If discovery is flaky: + - enable `Discovery Debug Logs` + - inspect `Settings -> Gateway -> Discovery Logs` +5. If network path is unclear: + - switch to manual host/port + TLS in Gateway Advanced settings +6. In Xcode console, filter for subsystem/category signals: + - `ai.openclaw.ios` + - `GatewayDiag` + - `APNs registration failed` +7. Validate background expectations: + - repro in foreground first + - then test background transitions and confirm reconnect on return diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4d57e28bc6..6b8885dfcb 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -41,6 +41,7 @@ private final class NotificationInvokeLatch: @unchecked Sendable { @Observable final class NodeAppModel { private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") + private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") enum CameraHUDKind { case photo case recording @@ -2125,6 +2126,15 @@ extension NodeAppModel { await self.registerAPNsTokenIfNeeded() } + func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool { + guard Self.isSilentPushPayload(userInfo) else { + self.pushWakeLogger.info("Ignored APNs payload: not silent push") + return false + } + self.pushWakeLogger.info("Silent push received; attempting reconnect if needed") + return await self.reconnectGatewaySessionsForSilentPushIfNeeded() + } + func updateAPNsDeviceToken(_ tokenData: Data) { let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() let trimmed = tokenHex.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2170,6 +2180,51 @@ extension NodeAppModel { // Best-effort only. } } + + private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool { + guard let apsAny = userInfo["aps"] else { return false } + if let aps = apsAny as? [AnyHashable: Any] { + return Self.hasContentAvailable(aps["content-available"]) + } + if let aps = apsAny as? [String: Any] { + return Self.hasContentAvailable(aps["content-available"]) + } + return false + } + + private static func hasContentAvailable(_ value: Any?) -> Bool { + if let number = value as? NSNumber { + return number.intValue == 1 + } + if let text = value as? String { + return text.trimmingCharacters(in: .whitespacesAndNewlines) == "1" + } + return false + } + + private func reconnectGatewaySessionsForSilentPushIfNeeded() async -> Bool { + guard self.isBackgrounded else { + self.pushWakeLogger.info("Wake no-op: app not backgrounded") + return false + } + guard self.gatewayAutoReconnectEnabled else { + self.pushWakeLogger.info("Wake no-op: auto reconnect disabled") + return false + } + guard self.activeGatewayConnectConfig != nil else { + self.pushWakeLogger.info("Wake no-op: no active gateway config") + return false + } + + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + self.operatorConnected = false + self.gatewayConnected = false + self.gatewayStatusText = "Reconnecting…" + self.talkMode.updateGatewayConnected(false) + self.pushWakeLogger.info("Wake reconnect trigger applied") + return true + } } #if DEBUG diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 4c5f3874ce..091c1b90fd 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -39,6 +39,24 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { self.logger.error("APNs registration failed: \(error.localizedDescription, privacy: .public)") } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) + { + self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)") + Task { @MainActor in + guard let appModel = self.appModel else { + self.logger.info("APNs wake skipped: appModel unavailable") + completionHandler(.noData) + return + } + let handled = await appModel.handleSilentPushWake(userInfo) + self.logger.info("APNs wake handled=\(handled, privacy: .public)") + completionHandler(handled ? .newData : .noData) + } + } } @main diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts new file mode 100644 index 0000000000..b919c94399 --- /dev/null +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorCodes } from "../protocol/index.js"; +import { nodeHandlers } from "./nodes.js"; + +const mocks = vi.hoisted(() => ({ + loadConfig: vi.fn(() => ({})), + resolveNodeCommandAllowlist: vi.fn(() => []), + isNodeCommandAllowed: vi.fn(() => ({ ok: true })), + sanitizeNodeInvokeParamsForForwarding: vi.fn(({ rawParams }: { rawParams: unknown }) => ({ + ok: true, + params: rawParams, + })), + loadApnsRegistration: vi.fn(), + resolveApnsAuthConfigFromEnv: vi.fn(), + sendApnsBackgroundWake: vi.fn(), +})); + +vi.mock("../../config/config.js", () => ({ + loadConfig: mocks.loadConfig, +})); + +vi.mock("../node-command-policy.js", () => ({ + resolveNodeCommandAllowlist: mocks.resolveNodeCommandAllowlist, + isNodeCommandAllowed: mocks.isNodeCommandAllowed, +})); + +vi.mock("../node-invoke-sanitize.js", () => ({ + sanitizeNodeInvokeParamsForForwarding: mocks.sanitizeNodeInvokeParamsForForwarding, +})); + +vi.mock("../../infra/push-apns.js", () => ({ + loadApnsRegistration: mocks.loadApnsRegistration, + resolveApnsAuthConfigFromEnv: mocks.resolveApnsAuthConfigFromEnv, + sendApnsBackgroundWake: mocks.sendApnsBackgroundWake, +})); + +type RespondCall = [ + boolean, + unknown?, + { + code?: number; + message?: string; + details?: unknown; + }?, +]; + +type TestNodeSession = { + nodeId: string; + commands: string[]; +}; + +function makeNodeInvokeParams(overrides?: Partial>) { + return { + nodeId: "ios-node-1", + command: "camera.capture", + params: { quality: "high" }, + timeoutMs: 5000, + idempotencyKey: "idem-node-invoke", + ...overrides, + }; +} + +async function invokeNode(params: { + nodeRegistry: { + get: (nodeId: string) => TestNodeSession | undefined; + invoke: (payload: { + nodeId: string; + command: string; + params?: unknown; + timeoutMs?: number; + idempotencyKey?: string; + }) => Promise<{ + ok: boolean; + payload?: unknown; + payloadJSON?: string | null; + error?: { code?: string; message?: string } | null; + }>; + }; + requestParams?: Partial>; +}) { + const respond = vi.fn(); + await nodeHandlers["node.invoke"]({ + params: makeNodeInvokeParams(params.requestParams), + respond: respond as never, + context: { + nodeRegistry: params.nodeRegistry, + execApprovalManager: undefined, + } as never, + client: null, + req: { type: "req", id: "req-node-invoke", method: "node.invoke" }, + isWebchatConnect: () => false, + }); + return respond; +} + +describe("node.invoke APNs wake path", () => { + beforeEach(() => { + mocks.loadConfig.mockReset(); + mocks.loadConfig.mockReturnValue({}); + mocks.resolveNodeCommandAllowlist.mockReset(); + mocks.resolveNodeCommandAllowlist.mockReturnValue([]); + mocks.isNodeCommandAllowed.mockReset(); + mocks.isNodeCommandAllowed.mockReturnValue({ ok: true }); + mocks.sanitizeNodeInvokeParamsForForwarding.mockReset(); + mocks.sanitizeNodeInvokeParamsForForwarding.mockImplementation( + ({ rawParams }: { rawParams: unknown }) => ({ ok: true, params: rawParams }), + ); + mocks.loadApnsRegistration.mockReset(); + mocks.resolveApnsAuthConfigFromEnv.mockReset(); + mocks.sendApnsBackgroundWake.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps the existing not-connected response when wake path is unavailable", async () => { + mocks.loadApnsRegistration.mockResolvedValue(null); + + const nodeRegistry = { + get: vi.fn(() => undefined), + invoke: vi.fn().mockResolvedValue({ ok: true }), + }; + + const respond = await invokeNode({ nodeRegistry }); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.UNAVAILABLE); + expect(call?.[2]?.message).toBe("node not connected"); + expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled(); + expect(nodeRegistry.invoke).not.toHaveBeenCalled(); + }); + + it("wakes and retries invoke after the node reconnects", async () => { + vi.useFakeTimers(); + mocks.loadApnsRegistration.mockResolvedValue({ + nodeId: "ios-node-reconnect", + token: "abcd1234abcd1234abcd1234abcd1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, + }); + mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + }, + }); + mocks.sendApnsBackgroundWake.mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + }); + + let connected = false; + const session: TestNodeSession = { nodeId: "ios-node-reconnect", commands: ["camera.capture"] }; + const nodeRegistry = { + get: vi.fn((nodeId: string) => { + if (nodeId !== "ios-node-reconnect") { + return undefined; + } + return connected ? session : undefined; + }), + invoke: vi.fn().mockResolvedValue({ + ok: true, + payload: { ok: true }, + payloadJSON: '{"ok":true}', + }), + }; + + const invokePromise = invokeNode({ + nodeRegistry, + requestParams: { nodeId: "ios-node-reconnect", idempotencyKey: "idem-reconnect" }, + }); + setTimeout(() => { + connected = true; + }, 300); + + await vi.advanceTimersByTimeAsync(4_000); + const respond = await invokePromise; + + expect(mocks.sendApnsBackgroundWake).toHaveBeenCalledTimes(1); + expect(nodeRegistry.invoke).toHaveBeenCalledTimes(1); + expect(nodeRegistry.invoke).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "ios-node-reconnect", + command: "camera.capture", + }), + ); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(true); + expect(call?.[1]).toMatchObject({ ok: true, nodeId: "ios-node-reconnect" }); + }); + + it("throttles repeated wake attempts for the same disconnected node", async () => { + vi.useFakeTimers(); + mocks.loadApnsRegistration.mockResolvedValue({ + nodeId: "ios-node-throttle", + token: "abcd1234abcd1234abcd1234abcd1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, + }); + mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + }, + }); + mocks.sendApnsBackgroundWake.mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + }); + + const nodeRegistry = { + get: vi.fn(() => undefined), + invoke: vi.fn().mockResolvedValue({ ok: true }), + }; + + const first = invokeNode({ + nodeRegistry, + requestParams: { nodeId: "ios-node-throttle", idempotencyKey: "idem-throttle-1" }, + }); + await vi.advanceTimersByTimeAsync(4_000); + await first; + + const second = invokeNode({ + nodeRegistry, + requestParams: { nodeId: "ios-node-throttle", idempotencyKey: "idem-throttle-2" }, + }); + await vi.advanceTimersByTimeAsync(4_000); + await second; + + expect(mocks.sendApnsBackgroundWake).toHaveBeenCalledTimes(1); + expect(nodeRegistry.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 9d7df8ea4b..1ea705365e 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -8,6 +8,11 @@ import { requestNodePairing, verifyNodeToken, } from "../../infra/node-pairing.js"; +import { + loadApnsRegistration, + resolveApnsAuthConfigFromEnv, + sendApnsBackgroundWake, +} from "../../infra/push-apns.js"; import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js"; import { sanitizeNodeInvokeParamsForForwarding } from "../node-invoke-sanitize.js"; import { @@ -34,6 +39,17 @@ import { } from "./nodes.helpers.js"; import type { GatewayRequestHandlers } from "./types.js"; +const NODE_WAKE_RECONNECT_WAIT_MS = 3_000; +const NODE_WAKE_RECONNECT_POLL_MS = 150; +const NODE_WAKE_THROTTLE_MS = 15_000; + +type NodeWakeState = { + lastWakeAtMs: number; + inFlight?: Promise; +}; + +const nodeWakeById = new Map(); + function isNodeEntry(entry: { role?: string; roles?: string[] }) { if (entry.role === "node") { return true; @@ -44,6 +60,77 @@ function isNodeEntry(entry: { role?: string; roles?: string[] }) { return false; } +async function delayMs(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function maybeWakeNodeWithApns(nodeId: string): Promise { + const state = nodeWakeById.get(nodeId) ?? { lastWakeAtMs: 0 }; + nodeWakeById.set(nodeId, state); + + if (state.inFlight) { + return await state.inFlight; + } + + const now = Date.now(); + if (state.lastWakeAtMs > 0 && now - state.lastWakeAtMs < NODE_WAKE_THROTTLE_MS) { + return true; + } + + state.inFlight = (async () => { + try { + const registration = await loadApnsRegistration(nodeId); + if (!registration) { + return false; + } + + const auth = await resolveApnsAuthConfigFromEnv(process.env); + if (!auth.ok) { + return false; + } + + state.lastWakeAtMs = Date.now(); + await sendApnsBackgroundWake({ + auth: auth.value, + registration, + nodeId, + wakeReason: "node.invoke", + }); + } catch { + // Best-effort wake only. + if (state.lastWakeAtMs === 0) { + return false; + } + } + return true; + })(); + + try { + return await state.inFlight; + } finally { + state.inFlight = undefined; + } +} + +async function waitForNodeReconnect(params: { + nodeId: string; + context: { nodeRegistry: { get: (nodeId: string) => unknown } }; + timeoutMs?: number; + pollMs?: number; +}): Promise { + const timeoutMs = Math.max(250, params.timeoutMs ?? NODE_WAKE_RECONNECT_WAIT_MS); + const pollMs = Math.max(50, params.pollMs ?? NODE_WAKE_RECONNECT_POLL_MS); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (params.context.nodeRegistry.get(params.nodeId)) { + return true; + } + await delayMs(pollMs); + } + return Boolean(params.context.nodeRegistry.get(params.nodeId)); +} + export const nodeHandlers: GatewayRequestHandlers = { "node.pair.request": async ({ params, respond, context }) => { if (!validateNodePairRequestParams(params)) { @@ -383,16 +470,23 @@ export const nodeHandlers: GatewayRequestHandlers = { } await respondUnavailableOnThrow(respond, async () => { - const nodeSession = context.nodeRegistry.get(nodeId); + let nodeSession = context.nodeRegistry.get(nodeId); if (!nodeSession) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "node not connected", { - details: { code: "NOT_CONNECTED" }, - }), - ); - return; + const wakeAvailable = await maybeWakeNodeWithApns(nodeId); + if (wakeAvailable) { + await waitForNodeReconnect({ nodeId, context }); + } + nodeSession = context.nodeRegistry.get(nodeId); + if (!nodeSession) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "node not connected", { + details: { code: "NOT_CONNECTED" }, + }), + ); + return; + } } const cfg = loadConfig(); const allowlist = resolveNodeCommandAllowlist(cfg, nodeSession); diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index bdfc017f88..7265a52157 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -1,15 +1,21 @@ +import { generateKeyPairSync } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { loadApnsRegistration, normalizeApnsEnvironment, registerApnsToken, resolveApnsAuthConfigFromEnv, + sendApnsAlert, + sendApnsBackgroundWake, } from "./push-apns.js"; const tempDirs: string[] = []; +const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) + .privateKey.export({ format: "pem", type: "pkcs8" }) + .toString(); async function makeTempDir(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-")); @@ -92,3 +98,96 @@ describe("push APNs env config", () => { expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID"); }); }); + +describe("push APNs send semantics", () => { + it("sends alert pushes with alert headers and payload", async () => { + const send = vi.fn().mockResolvedValue({ + status: 200, + apnsId: "apns-alert-id", + body: "", + }); + + const result = await sendApnsAlert({ + auth: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: testAuthPrivateKey, + }, + registration: { + nodeId: "ios-node-alert", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, + }, + nodeId: "ios-node-alert", + title: "Wake", + body: "Ping", + requestSender: send, + }); + + expect(send).toHaveBeenCalledTimes(1); + const sent = send.mock.calls[0]?.[0]; + expect(sent?.pushType).toBe("alert"); + expect(sent?.priority).toBe("10"); + expect(sent?.payload).toMatchObject({ + aps: { + alert: { title: "Wake", body: "Ping" }, + sound: "default", + }, + openclaw: { + kind: "push.test", + nodeId: "ios-node-alert", + }, + }); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + }); + + it("sends background wake pushes with silent payload semantics", async () => { + const send = vi.fn().mockResolvedValue({ + status: 200, + apnsId: "apns-wake-id", + body: "", + }); + + const result = await sendApnsBackgroundWake({ + auth: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: testAuthPrivateKey, + }, + registration: { + nodeId: "ios-node-wake", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "production", + updatedAtMs: 1, + }, + nodeId: "ios-node-wake", + wakeReason: "node.invoke", + requestSender: send, + }); + + expect(send).toHaveBeenCalledTimes(1); + const sent = send.mock.calls[0]?.[0]; + expect(sent?.pushType).toBe("background"); + expect(sent?.priority).toBe("5"); + expect(sent?.payload).toMatchObject({ + aps: { + "content-available": 1, + }, + openclaw: { + kind: "node.wake", + reason: "node.invoke", + nodeId: "ios-node-wake", + }, + }); + const sentPayload = sent?.payload as { aps?: { alert?: unknown; sound?: unknown } } | undefined; + const aps = sentPayload?.aps; + expect(aps?.alert).toBeUndefined(); + expect(aps?.sound).toBeUndefined(); + expect(result.ok).toBe(true); + expect(result.environment).toBe("production"); + }); +}); diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index 45ca5a665d..8b0e672894 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -35,6 +35,33 @@ export type ApnsPushAlertResult = { environment: ApnsEnvironment; }; +export type ApnsPushWakeResult = { + ok: boolean; + status: number; + apnsId?: string; + reason?: string; + tokenSuffix: string; + topic: string; + environment: ApnsEnvironment; +}; + +type ApnsPushType = "alert" | "background"; + +type ApnsRequestParams = { + token: string; + topic: string; + environment: ApnsEnvironment; + bearerToken: string; + payload: object; + timeoutMs: number; + pushType: ApnsPushType; + priority: "10" | "5"; +}; + +type ApnsRequestResponse = { status: number; apnsId?: string; body: string }; + +type ApnsRequestSender = (params: ApnsRequestParams) => Promise; + type ApnsRegistrationState = { registrationsByNodeId: Record; }; @@ -277,7 +304,9 @@ async function sendApnsRequest(params: { bearerToken: string; payload: object; timeoutMs: number; -}): Promise<{ status: number; apnsId?: string; body: string }> { + pushType: ApnsPushType; + priority: "10" | "5"; +}): Promise { const authority = params.environment === "production" ? "https://api.push.apple.com" @@ -313,8 +342,8 @@ async function sendApnsRequest(params: { ":path": requestPath, authorization: `bearer ${params.bearerToken}`, "apns-topic": params.topic, - "apns-push-type": "alert", - "apns-priority": "10", + "apns-push-type": params.pushType, + "apns-priority": params.priority, "apns-expiration": "0", "content-type": "application/json", "content-length": Buffer.byteLength(body).toString(), @@ -351,6 +380,29 @@ async function sendApnsRequest(params: { }); } +function resolveApnsTimeoutMs(timeoutMs: number | undefined): number { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) + ? Math.max(1000, Math.trunc(timeoutMs)) + : DEFAULT_APNS_TIMEOUT_MS; +} + +function toApnsPushResult(params: { + response: ApnsRequestResponse; + token: string; + topic: string; + environment: ApnsEnvironment; +}): ApnsPushWakeResult { + return { + ok: params.response.status === 200, + status: params.response.status, + apnsId: params.response.apnsId, + reason: parseReason(params.response.body), + tokenSuffix: params.token.slice(-8), + topic: params.topic, + environment: params.environment, + }; +} + export async function sendApnsAlert(params: { auth: ApnsAuthConfig; registration: ApnsRegistration; @@ -358,6 +410,7 @@ export async function sendApnsAlert(params: { title: string; body: string; timeoutMs?: number; + requestSender?: ApnsRequestSender; }): Promise { const token = normalizeApnsToken(params.registration.token); if (!isLikelyApnsToken(token)) { @@ -385,25 +438,73 @@ export async function sendApnsAlert(params: { }, }; - const response = await sendApnsRequest({ + const sender = params.requestSender ?? sendApnsRequest; + const response = await sender({ token, topic, environment, bearerToken, payload, - timeoutMs: - typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) - ? Math.max(1000, Math.trunc(params.timeoutMs)) - : DEFAULT_APNS_TIMEOUT_MS, + timeoutMs: resolveApnsTimeoutMs(params.timeoutMs), + pushType: "alert", + priority: "10", }); - return { - ok: response.status === 200, - status: response.status, - apnsId: response.apnsId, - reason: parseReason(response.body), - tokenSuffix: token.slice(-8), + return toApnsPushResult({ + response, + token, topic, environment, - }; + }); +} + +export async function sendApnsBackgroundWake(params: { + auth: ApnsAuthConfig; + registration: ApnsRegistration; + nodeId: string; + wakeReason?: string; + timeoutMs?: number; + requestSender?: ApnsRequestSender; +}): Promise { + const token = normalizeApnsToken(params.registration.token); + if (!isLikelyApnsToken(token)) { + throw new Error("invalid APNs token"); + } + const topic = normalizeTopic(params.registration.topic); + if (!topic) { + throw new Error("topic required"); + } + const environment = params.registration.environment; + const bearerToken = getApnsBearerToken(params.auth); + + const payload = { + aps: { + "content-available": 1, + }, + openclaw: { + kind: "node.wake", + reason: params.wakeReason ?? "node.invoke", + nodeId: params.nodeId, + ts: Date.now(), + }, + }; + + const sender = params.requestSender ?? sendApnsRequest; + const response = await sender({ + token, + topic, + environment, + bearerToken, + payload, + timeoutMs: resolveApnsTimeoutMs(params.timeoutMs), + pushType: "background", + priority: "5", + }); + + return toApnsPushResult({ + response, + token, + topic, + environment, + }); }