mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 09:11:26 +00: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:
@@ -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