diff --git a/src/infra/push-web.test.ts b/src/infra/push-web.test.ts index 31fd692d950..e043a04869f 100644 --- a/src/infra/push-web.test.ts +++ b/src/infra/push-web.test.ts @@ -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); + }); +}); diff --git a/src/infra/push-web.ts b/src/infra/push-web.ts index 7cec29330ff..135f575d434 100644 --- a/src/infra/push-web.ts +++ b/src/infra/push-web.ts @@ -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 { const keys = vapidKeys ?? (await resolveVapidKeys()); + applyVapidDetails(keys); + return sendPreparedWebPushNotification(subscription, payload); +} + +async function sendPreparedWebPushNotification( + subscription: WebPushSubscription, + payload: WebPushPayload, +): Promise { 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) => diff --git a/ui/src/main.ts b/ui/src/main.ts index bd7f5d8f408..ac37a40db44 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -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) { diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 852d28b5518..c5abbd4f3b6 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -103,6 +103,7 @@ type GatewayHost = { execApprovalQueue: ExecApprovalRequest[]; execApprovalError: string | null; updateAvailable: UpdateAvailable | null; + reconcileWebPushState?: () => Promise | 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[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) { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c898cf95238..3e2b795ff0b 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -374,6 +374,10 @@ type ConfigTabOverrides = Pick< | "includeVirtualSections" | "settingsLayout" | "onBackToQuick" + | "webPush" + | "onWebPushSubscribe" + | "onWebPushUnsubscribe" + | "onWebPushTest" > >; diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index b4e7e562d46..044010a7791 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -657,7 +657,9 @@ function renderNotificationsSection(props: ConfigProps) {
Browser support - ${push.supported ? "Available" : "Not supported"} + ${push.supported ? "Available" : "Not supported"}
Permission @@ -710,7 +712,8 @@ function renderNotificationsSection(props: ConfigProps) { ? html`

- Notifications are blocked. Update your browser site permissions to allow notifications. + Notifications are blocked. Update your browser site permissions to allow + notifications.

` @@ -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` -
-
- Loading schema… -
- ` - : 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` -
- Your config contains fields the form editor can't safely represent. Use - Raw mode to edit those entries. +
+
+ Loading schema…
` - : nothing} -
- - Raw config (JSON/JSON5) - ${sensitiveCount > 0 - ? html` - ${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"} - ${blurred ? "redacted" : "visible"} - - ` - : nothing} - - ${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` -
- ${sensitiveCount} sensitive value${sensitiveCount === 1 ? "" : "s"} - hidden. Use the reveal button above to edit the raw config. +
+ Your config contains fields the form editor can't safely represent. + Use Raw mode to edit those entries.
` - : html` - - `} -
- `; - })()} + : nothing} +
+ + Raw config (JSON/JSON5) + ${sensitiveCount > 0 + ? html` + ${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"} + ${blurred ? "redacted" : "visible"} + + ` + : nothing} + + ${blurred + ? html` +
+ ${sensitiveCount} sensitive value${sensitiveCount === 1 ? "" : "s"} + hidden. Use the reveal button above to edit the raw config. +
+ ` + : html` + + `} +
+ `; + })()}
${props.issues.length > 0