fix: stabilize web push branch prep

Co-authored-by: Eduardo Cruz <eduardo@eduardocruz.com>
This commit is contained in:
Val Alexander
2026-04-25 04:22:27 -05:00
parent 9eb761a654
commit d3cb046b8a
6 changed files with 180 additions and 104 deletions

View File

@@ -2,13 +2,16 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import webPush from "web-push";
import {
broadcastWebPush,
clearWebPushSubscription,
clearWebPushSubscriptionByEndpoint,
listWebPushSubscriptions,
loadWebPushSubscription,
registerWebPushSubscription,
resolveVapidKeys,
sendWebPushNotification,
} from "./push-web.js";
// Stub resolveStateDir so tests use a temp directory.
@@ -31,6 +34,7 @@ vi.mock("web-push", () => ({
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "push-web-test-"));
vi.clearAllMocks();
});
afterEach(async () => {
@@ -178,3 +182,46 @@ describe("subscription CRUD", () => {
).rejects.toThrow("invalid push subscription keys");
});
});
describe("sending", () => {
const keys = { p256dh: "p256dh-key", auth: "auth-key" };
it("configures VAPID details for direct sends", async () => {
const sub = await registerWebPushSubscription({
endpoint: "https://push.example.com/direct",
keys,
baseDir: tmpDir,
});
const result = await sendWebPushNotification(sub, { title: "Direct" });
expect(result.ok).toBe(true);
expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledTimes(1);
expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledWith(
"mailto:openclaw@localhost",
"test-public-key-base64url",
"test-private-key-base64url",
);
expect(vi.mocked(webPush.sendNotification)).toHaveBeenCalledTimes(1);
});
it("configures VAPID details once before broadcasting to subscribers", async () => {
await registerWebPushSubscription({
endpoint: "https://push.example.com/a",
keys,
baseDir: tmpDir,
});
await registerWebPushSubscription({
endpoint: "https://push.example.com/b",
keys,
baseDir: tmpDir,
});
const results = await broadcastWebPush({ title: "Broadcast" }, tmpDir);
expect(results).toHaveLength(2);
expect(results.every((result) => result.ok)).toBe(true);
expect(vi.mocked(webPush.setVapidDetails)).toHaveBeenCalledTimes(1);
expect(vi.mocked(webPush.sendNotification)).toHaveBeenCalledTimes(2);
});
});

View File

@@ -152,10 +152,10 @@ export async function registerWebPushSubscription(
const { endpoint, keys, baseDir } = params;
if (!isValidEndpoint(endpoint)) {
throw new Error("Invalid push subscription endpoint: must be an HTTPS URL under 2048 chars");
throw new Error("invalid push subscription endpoint: must be an HTTPS URL under 2048 chars");
}
if (!isValidKey(keys.p256dh) || !isValidKey(keys.auth)) {
throw new Error("Invalid push subscription keys: must be non-empty strings under 512 chars");
throw new Error("invalid push subscription keys: must be non-empty strings under 512 chars");
}
return await withLock(async () => {
@@ -238,13 +238,25 @@ export type WebPushPayload = {
url?: string;
};
function applyVapidDetails(keys: VapidKeyPair): void {
webPush.setVapidDetails(keys.subject, keys.publicKey, keys.privateKey);
}
export async function sendWebPushNotification(
subscription: WebPushSubscription,
payload: WebPushPayload,
vapidKeys?: VapidKeyPair,
): Promise<WebPushSendResult> {
const keys = vapidKeys ?? (await resolveVapidKeys());
applyVapidDetails(keys);
return sendPreparedWebPushNotification(subscription, payload);
}
async function sendPreparedWebPushNotification(
subscription: WebPushSubscription,
payload: WebPushPayload,
): Promise<WebPushSendResult> {
const pushSubscription = {
endpoint: subscription.endpoint,
keys: {
@@ -267,7 +279,7 @@ export async function sendWebPushNotification(
: undefined;
const message =
typeof err === "object" && err !== null && "message" in err
? String((err as { message: string }).message)
? (err as { message: string }).message
: "unknown error";
return {
ok: false,
@@ -290,10 +302,10 @@ export async function broadcastWebPush(
const vapidKeys = await resolveVapidKeys(baseDir);
// Set VAPID details once before fanning out concurrent sends.
webPush.setVapidDetails(vapidKeys.subject, vapidKeys.publicKey, vapidKeys.privateKey);
applyVapidDetails(vapidKeys);
const results = await Promise.allSettled(
subscriptions.map((sub) => sendWebPushNotification(sub, payload, vapidKeys)),
subscriptions.map((sub) => sendPreparedWebPushNotification(sub, payload)),
);
const mapped = results.map((r, i) =>

View File

@@ -1,9 +1,17 @@
import "./styles.css";
import "./ui/app.ts";
if (import.meta.env?.PROD && "serviceWorker" in navigator) {
type ViteImportMeta = ImportMeta & {
readonly env?: {
readonly PROD?: boolean;
};
};
const isProd = (import.meta as ViteImportMeta).env?.PROD === true;
if (isProd && "serviceWorker" in navigator) {
void navigator.serviceWorker.register("./sw.js");
} else if (!import.meta.env?.PROD && "serviceWorker" in navigator) {
} else if (!isProd && "serviceWorker" in navigator) {
// Unregister any leftover dev SW to avoid stale cache issues.
void navigator.serviceWorker.getRegistrations().then((registrations) => {
for (const r of registrations) {

View File

@@ -103,6 +103,7 @@ type GatewayHost = {
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
updateAvailable: UpdateAvailable | null;
reconcileWebPushState?: () => Promise<void> | void;
};
type GatewayHostWithDeferredSessionMessageReload = GatewayHost & {
@@ -340,7 +341,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
void loadDevices(host as unknown as DevicesState, { quiet: true });
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
// Re-run push reconciliation now that the gateway client is available.
void (host as unknown as OpenClawApp).reconcileWebPushState();
void host.reconcileWebPushState?.();
},
onClose: ({ code, reason, error }) => {
if (host.client !== client) {

View File

@@ -374,6 +374,10 @@ type ConfigTabOverrides = Pick<
| "includeVirtualSections"
| "settingsLayout"
| "onBackToQuick"
| "webPush"
| "onWebPushSubscribe"
| "onWebPushUnsubscribe"
| "onWebPushTest"
>
>;

View File

@@ -657,7 +657,9 @@ function renderNotificationsSection(props: ConfigProps) {
<div class="settings-info-grid">
<div class="settings-info-row">
<span class="settings-info-row__label">Browser support</span>
<span class="settings-info-row__value">${push.supported ? "Available" : "Not supported"}</span>
<span class="settings-info-row__value"
>${push.supported ? "Available" : "Not supported"}</span
>
</div>
<div class="settings-info-row">
<span class="settings-info-row__label">Permission</span>
@@ -710,7 +712,8 @@ function renderNotificationsSection(props: ConfigProps) {
? html`
<div class="settings-appearance__section">
<p class="settings-appearance__hint">
Notifications are blocked. Update your browser site permissions to allow notifications.
Notifications are blocked. Update your browser site permissions to allow
notifications.
</p>
</div>
`
@@ -969,15 +972,16 @@ export function renderConfig(props: ConfigProps) {
const schemaProps = analysis.schema?.properties ?? {};
const VIRTUAL_SECTIONS = new Set(["__appearance__", "__notifications__"]);
const visibleCategories = SECTION_CATEGORIES.map((cat) => ({
...cat,
sections: cat.sections.filter(
(s) =>
((includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps) &&
(!include || include.has(s.key)) &&
(!exclude || !exclude.has(s.key)),
),
})).filter((cat) => cat.sections.length > 0);
const visibleCategories = SECTION_CATEGORIES.map((cat) =>
Object.assign({}, cat, {
sections: cat.sections.filter(
(s) =>
((includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps) &&
(!include || include.has(s.key)) &&
(!exclude || !exclude.has(s.key)),
),
}),
).filter((cat) => cat.sections.length > 0);
// Catch any schema keys not in our categories
const extraSections = Object.keys(schemaProps)
@@ -1421,97 +1425,97 @@ export function renderConfig(props: ConfigProps) {
? includeVirtualSections
? renderNotificationsSection(props)
: nothing
: formMode === "form"
? html`
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
${props.schemaLoading
? html`
<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema…</span>
</div>
`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
rawAvailable,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
revealSensitive:
props.activeSection === "env" ? envSensitiveVisible : false,
isSensitivePathRevealed,
onToggleSensitivePath: (path) => {
toggleSensitivePathReveal(path);
requestUpdate();
},
})}
`
: (() => {
const sensitiveCount = countSensitiveConfigValues(
props.formValue,
[],
props.uiHints,
);
const blurred = sensitiveCount > 0 && !cvs.rawRevealed;
return html`
${formUnsafe
: formMode === "form"
? html`
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
${props.schemaLoading
? html`
<div class="callout info" style="margin-bottom: 12px">
Your config contains fields the form editor can't safely represent. Use
Raw mode to edit those entries.
<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema…</span>
</div>
`
: nothing}
<div class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
Raw config (JSON/JSON5)
${sensitiveCount > 0
? html`
<span class="pill pill--sm"
>${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"}
${blurred ? "redacted" : "visible"}</span
>
<button
class="btn btn--icon config-raw-toggle ${blurred ? "" : "active"}"
title=${blurred
? "Reveal sensitive values"
: "Hide sensitive values"}
aria-label="Toggle raw config redaction"
aria-pressed=${!blurred}
@click=${() => {
cvs.rawRevealed = !cvs.rawRevealed;
requestUpdate();
}}
>
${blurred ? icons.eyeOff : icons.eye}
</button>
`
: nothing}
</span>
${blurred
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
rawAvailable,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
revealSensitive:
props.activeSection === "env" ? envSensitiveVisible : false,
isSensitivePathRevealed,
onToggleSensitivePath: (path) => {
toggleSensitivePathReveal(path);
requestUpdate();
},
})}
`
: (() => {
const sensitiveCount = countSensitiveConfigValues(
props.formValue,
[],
props.uiHints,
);
const blurred = sensitiveCount > 0 && !cvs.rawRevealed;
return html`
${formUnsafe
? html`
<div class="callout info" style="margin-top: 12px">
${sensitiveCount} sensitive value${sensitiveCount === 1 ? "" : "s"}
hidden. Use the reveal button above to edit the raw config.
<div class="callout info" style="margin-bottom: 12px">
Your config contains fields the form editor can't safely represent.
Use Raw mode to edit those entries.
</div>
`
: html`
<textarea
placeholder="Raw config (JSON/JSON5)"
.value=${props.raw}
@input=${(e: Event) => {
props.onRawChange((e.target as HTMLTextAreaElement).value);
}}
></textarea>
`}
</div>
`;
})()}
: nothing}
<div class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
Raw config (JSON/JSON5)
${sensitiveCount > 0
? html`
<span class="pill pill--sm"
>${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"}
${blurred ? "redacted" : "visible"}</span
>
<button
class="btn btn--icon config-raw-toggle ${blurred ? "" : "active"}"
title=${blurred
? "Reveal sensitive values"
: "Hide sensitive values"}
aria-label="Toggle raw config redaction"
aria-pressed=${!blurred}
@click=${() => {
cvs.rawRevealed = !cvs.rawRevealed;
requestUpdate();
}}
>
${blurred ? icons.eyeOff : icons.eye}
</button>
`
: nothing}
</span>
${blurred
? html`
<div class="callout info" style="margin-top: 12px">
${sensitiveCount} sensitive value${sensitiveCount === 1 ? "" : "s"}
hidden. Use the reveal button above to edit the raw config.
</div>
`
: html`
<textarea
placeholder="Raw config (JSON/JSON5)"
.value=${props.raw}
@input=${(e: Event) => {
props.onRawChange((e.target as HTMLTextAreaElement).value);
}}
></textarea>
`}
</div>
`;
})()}
</div>
${props.issues.length > 0