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:
Mariano
2026-02-18 21:00:17 +00:00
committed by GitHub
parent 750276fa36
commit e67da1538c
8 changed files with 724 additions and 73 deletions

View File

@@ -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");
});
});