mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:40:42 +00:00
fix: stabilize web push branch prep
Co-authored-by: Eduardo Cruz <eduardo@eduardocruz.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -374,6 +374,10 @@ type ConfigTabOverrides = Pick<
|
||||
| "includeVirtualSections"
|
||||
| "settingsLayout"
|
||||
| "onBackToQuick"
|
||||
| "webPush"
|
||||
| "onWebPushSubscribe"
|
||||
| "onWebPushUnsubscribe"
|
||||
| "onWebPushTest"
|
||||
>
|
||||
>;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user