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

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