mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
iOS/Gateway: wake disconnected iOS nodes via APNs before invoke (#20332)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 7751f9c531
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,7 @@ private final class NotificationInvokeLatch<T: Sendable>: @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
|
||||
|
||||
@@ -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
|
||||
|
||||
246
src/gateway/server-methods/nodes.invoke-wake.test.ts
Normal file
246
src/gateway/server-methods/nodes.invoke-wake.test.ts
Normal file
@@ -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<Record<string, unknown>>) {
|
||||
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<Record<string, unknown>>;
|
||||
}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>;
|
||||
};
|
||||
|
||||
const nodeWakeById = new Map<string, NodeWakeState>();
|
||||
|
||||
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<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function maybeWakeNodeWithApns(nodeId: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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);
|
||||
|
||||
@@ -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<string> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ApnsRequestResponse>;
|
||||
|
||||
type ApnsRegistrationState = {
|
||||
registrationsByNodeId: Record<string, ApnsRegistration>;
|
||||
};
|
||||
@@ -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<ApnsRequestResponse> {
|
||||
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<ApnsPushAlertResult> {
|
||||
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<ApnsPushWakeResult> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user