feat: add Control UI PWA web push support (#44590)

Adds browser PWA manifest and service worker support for the Control UI, plus gateway RPC methods and persisted Web Push subscription handling.

Maintainer verification:
- OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test src/infra/push-web.test.ts src/gateway/server-methods/push.test.ts src/gateway/control-ui.test.ts src/gateway/protocol/push.test.ts
- pnpm check:changed passed before final GitHub update-branch merge commit
- pnpm build

Source head: 0720024368
This commit is contained in:
Eduardo Cruz
2026-04-25 07:03:00 -03:00
committed by GitHub
parent 385da2db60
commit 21b7ad5805
21 changed files with 1451 additions and 155 deletions

View File

@@ -1,2 +1,21 @@
import "./styles.css";
import "./ui/app.ts";
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 (!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) {
void r.unregister();
}
});
}

View File

@@ -103,6 +103,7 @@ type GatewayHost = {
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
updateAvailable: UpdateAvailable | null;
reconcileWebPushState?: () => Promise<void> | void;
};
type GatewayHostWithDeferredSessionMessageReload = GatewayHost & {
@@ -339,6 +340,8 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
void loadNodes(host as unknown as NodesState, { quiet: true });
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.reconcileWebPushState?.();
},
onClose: ({ code, reason, error }) => {
if (host.client !== client) {

View File

@@ -312,7 +312,14 @@ function dismissUpdateBanner(updateAvailable: unknown) {
}
}
const COMMUNICATION_SECTION_KEYS = ["channels", "messages", "broadcast", "talk", "audio"] as const;
const COMMUNICATION_SECTION_KEYS = [
"channels",
"messages",
"broadcast",
"__notifications__",
"talk",
"audio",
] as const;
const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const;
const AUTOMATION_SECTION_KEYS = [
"commands",
@@ -367,6 +374,10 @@ type ConfigTabOverrides = Pick<
| "includeVirtualSections"
| "settingsLayout"
| "onBackToQuick"
| "webPush"
| "onWebPushSubscribe"
| "onWebPushUnsubscribe"
| "onWebPushTest"
>
>;
@@ -1065,6 +1076,16 @@ export function renderApp(state: AppViewState) {
onSubsectionChange: (section) => (state.communicationsActiveSubsection = section),
navRootLabel: "Communication",
includeSections: [...COMMUNICATION_SECTION_KEYS],
includeVirtualSections: true,
webPush: {
supported: state.webPushSupported,
permission: state.webPushPermission,
subscribed: state.webPushSubscribed,
loading: state.webPushLoading,
},
onWebPushSubscribe: () => state.handleWebPushSubscribe(),
onWebPushUnsubscribe: () => state.handleWebPushUnsubscribe(),
onWebPushTest: () => state.handleWebPushTest(),
});
case "appearance":
return renderConfigTab({

View File

@@ -451,4 +451,11 @@ export type AppViewState = {
handleOpenSidebar: (content: SidebarContent) => void;
handleCloseSidebar: () => void;
handleSplitRatioChange: (ratio: number) => void;
webPushSupported: boolean;
webPushPermission: NotificationPermission | "unsupported";
webPushSubscribed: boolean;
webPushLoading: boolean;
handleWebPushSubscribe: () => Promise<void>;
handleWebPushUnsubscribe: () => Promise<void>;
handleWebPushTest: () => Promise<void>;
};

View File

@@ -504,6 +504,11 @@ export class OpenClawApp extends LitElement {
@state() debugCallResult: string | null = null;
@state() debugCallError: string | null = null;
@state() webPushSupported = false;
@state() webPushPermission: NotificationPermission | "unsupported" = "unsupported";
@state() webPushSubscribed = false;
@state() webPushLoading = false;
@state() logsLoading = false;
@state() logsError: string | null = null;
@state() logsFile: string | null = null;
@@ -574,6 +579,7 @@ export class OpenClawApp extends LitElement {
};
document.addEventListener("keydown", this.globalKeydownHandler);
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
void this.initWebPushState();
}
protected firstUpdated() {
@@ -948,6 +954,97 @@ export class OpenClawApp extends LitElement {
this.applySettings({ ...this.settings, splitRatio: newRatio });
}
private async initWebPushState() {
const supported =
"serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
this.webPushSupported = supported;
this.webPushPermission = supported ? Notification.permission : "unsupported";
if (supported) {
try {
const { getExistingSubscription } = await import("./push-subscription.ts");
const existing = await getExistingSubscription();
this.webPushSubscribed = existing !== null;
} catch {
// ignore — just means we can't check
}
}
}
/** Re-register local push subscription with the gateway after connect. */
async reconcileWebPushState() {
if (!this.client) {
return;
}
try {
// Always check PushManager directly — initWebPushState may not have finished
// yet if gateway connected quickly.
const { getExistingSubscription } = await import("./push-subscription.ts");
const existing = await getExistingSubscription();
if (!existing) {
return;
}
this.webPushSubscribed = true;
const subJson = existing.toJSON();
if (subJson.endpoint && subJson.keys?.p256dh && subJson.keys?.auth) {
await this.client.request("push.web.subscribe", {
endpoint: subJson.endpoint,
keys: { p256dh: subJson.keys.p256dh, auth: subJson.keys.auth },
});
}
} catch {
// Best-effort — don't block if gateway is unreachable.
}
}
async handleWebPushSubscribe() {
if (!this.client || this.webPushLoading) {
return;
}
this.webPushLoading = true;
try {
const { subscribeToWebPush } = await import("./push-subscription.ts");
await subscribeToWebPush(this.client);
this.webPushSubscribed = true;
this.webPushPermission = Notification.permission;
} catch (err) {
this.lastError = String(err);
} finally {
this.webPushLoading = false;
// Always refresh permission state — catches denied prompts too.
if ("Notification" in window) {
this.webPushPermission = Notification.permission;
}
}
}
async handleWebPushUnsubscribe() {
if (!this.client || this.webPushLoading) {
return;
}
this.webPushLoading = true;
try {
const { unsubscribeFromWebPush } = await import("./push-subscription.ts");
await unsubscribeFromWebPush(this.client);
this.webPushSubscribed = false;
} catch (err) {
this.lastError = String(err);
} finally {
this.webPushLoading = false;
}
}
async handleWebPushTest() {
if (!this.client) {
return;
}
try {
const { sendTestWebPush } = await import("./push-subscription.ts");
await sendTestWebPush(this.client);
} catch (err) {
this.lastError = String(err);
}
}
render() {
return renderApp(this as unknown as AppViewState);
}

View File

@@ -0,0 +1,141 @@
import type { GatewayBrowserClient } from "./gateway.ts";
export type WebPushState = {
supported: boolean;
permission: NotificationPermission | "unsupported";
subscribed: boolean;
loading: boolean;
};
/** Timeout (ms) for service-worker readiness. */
const SW_READY_TIMEOUT = 10_000;
/**
* Await service-worker readiness with a timeout so callers don't hang
* indefinitely when registration fails or sw.js is unreachable.
*/
function swReady(): Promise<ServiceWorkerRegistration> {
return Promise.race([
navigator.serviceWorker.ready,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Service worker not ready (timed out)")), SW_READY_TIMEOUT),
),
]);
}
/**
* URL-safe base64 string to Uint8Array (for applicationServerKey).
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const output = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
output[i] = raw.charCodeAt(i);
}
return output;
}
/**
* Check if the browser already has an active push subscription.
*/
export async function getExistingSubscription(): Promise<PushSubscription | null> {
if (!("serviceWorker" in navigator)) {
return null;
}
const registration = await swReady();
return await registration.pushManager.getSubscription();
}
/**
* Subscribe to web push notifications.
* Requests notification permission if not already granted, fetches VAPID key
* from the gateway, subscribes with the PushManager, and registers with the
* gateway. If gateway registration fails, the local PushManager subscription
* is rolled back to avoid local/server state divergence.
*/
export async function subscribeToWebPush(
client: GatewayBrowserClient,
): Promise<{ subscriptionId: string }> {
// Request permission.
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error(`Notification permission ${permission}`);
}
// Get VAPID public key from gateway.
const vapidRes = await client.request("push.web.vapidPublicKey", {});
const vapidPublicKey = (vapidRes as { vapidPublicKey: string }).vapidPublicKey;
if (!vapidPublicKey) {
throw new Error("Failed to retrieve VAPID public key");
}
// Subscribe via PushManager.
const registration = await swReady();
const pushSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey).buffer as ArrayBuffer,
});
const subJson = pushSubscription.toJSON();
if (!subJson.endpoint || !subJson.keys?.p256dh || !subJson.keys?.auth) {
throw new Error("Invalid push subscription from browser");
}
// Register with gateway — roll back local subscription on failure.
try {
const registerRes = await client.request("push.web.subscribe", {
endpoint: subJson.endpoint,
keys: {
p256dh: subJson.keys.p256dh,
auth: subJson.keys.auth,
},
});
return registerRes as { subscriptionId: string };
} catch (err) {
// Gateway registration failed — unsubscribe locally to keep state consistent.
try {
await pushSubscription.unsubscribe();
} catch {
// Best-effort rollback.
}
throw err;
}
}
/**
* Unsubscribe from web push notifications.
* Always unsubscribes locally even if the gateway request fails, to avoid
* leaving the browser subscribed with no server-side record.
*/
export async function unsubscribeFromWebPush(client: GatewayBrowserClient): Promise<void> {
const registration = await swReady();
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
// Notify gateway (best-effort — always unsubscribe locally afterward).
try {
await client.request("push.web.unsubscribe", {
endpoint: subscription.endpoint,
});
} catch {
// Gateway may be unreachable; still unsubscribe locally.
}
await subscription.unsubscribe();
}
}
/**
* Send a test web push notification via the gateway.
*/
export async function sendTestWebPush(
client: GatewayBrowserClient,
options?: { title?: string; body?: string },
): Promise<void> {
await client.request("push.web.test", {
title: options?.title,
body: options?.body,
});
}

View File

@@ -24,6 +24,13 @@ const BORDER_RADIUS_LABELS: Record<BorderRadiusStop, string> = {
100: "Full",
};
export type WebPushUiState = {
supported: boolean;
permission: NotificationPermission | "unsupported";
subscribed: boolean;
loading: boolean;
};
export type ConfigProps = {
raw: string;
originalRaw: string;
@@ -87,6 +94,10 @@ export type ConfigProps = {
settingsLayout?: "tabs" | "accordion";
/** Callback to navigate back to Quick Settings. Shown in accordion mode. */
onBackToQuick?: () => void;
webPush?: WebPushUiState;
onWebPushSubscribe?: () => void;
onWebPushUnsubscribe?: () => void;
onWebPushTest?: () => void;
onRequestUpdate?: () => void;
};
@@ -612,6 +623,105 @@ function focusCustomThemeImportInput() {
});
}
function renderNotificationsSection(props: ConfigProps) {
const push = props.webPush;
if (!push) {
return html`
<div class="settings-appearance">
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Push Notifications</h3>
<p class="settings-appearance__hint">Not available in this browser.</p>
</div>
</div>
`;
}
const permissionLabel =
push.permission === "granted"
? "Granted"
: push.permission === "denied"
? "Denied"
: push.permission === "default"
? "Not requested"
: "Unsupported";
const statusDot = push.subscribed ? "settings-status-dot--ok" : "";
return html`
<div class="settings-appearance">
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Push Notifications</h3>
<p class="settings-appearance__hint">
Subscribe to receive browser push notifications from your gateway.
</p>
<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
>
</div>
<div class="settings-info-row">
<span class="settings-info-row__label">Permission</span>
<span class="settings-info-row__value">${permissionLabel}</span>
</div>
<div class="settings-info-row">
<span class="settings-info-row__label">Status</span>
<span class="settings-info-row__value">
<span class="settings-status-dot ${statusDot}"></span>
${push.subscribed ? "Subscribed" : "Not subscribed"}
</span>
</div>
</div>
</div>
${push.supported && push.permission !== "denied"
? html`
<div class="settings-appearance__section">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
${push.subscribed
? html`
<button
class="config-bar__btn"
?disabled=${push.loading || !props.connected}
@click=${() => props.onWebPushUnsubscribe?.()}
>
Unsubscribe
</button>
<button
class="config-bar__btn"
?disabled=${push.loading || !props.connected}
@click=${() => props.onWebPushTest?.()}
>
Send test
</button>
`
: html`
<button
class="config-bar__btn config-bar__btn--primary"
?disabled=${push.loading || !props.connected}
@click=${() => props.onWebPushSubscribe?.()}
>
${push.loading ? "Subscribing..." : "Enable notifications"}
</button>
`}
</div>
</div>
`
: push.permission === "denied"
? html`
<div class="settings-appearance__section">
<p class="settings-appearance__hint">
Notifications are blocked. Update your browser site permissions to allow
notifications.
</p>
</div>
`
: nothing}
</div>
`;
}
function renderAppearanceSection(props: ConfigProps) {
const showCustomThemeImport = props.hasCustomTheme || props.customThemeImportExpanded === true;
if (
@@ -861,11 +971,14 @@ export function renderConfig(props: ConfigProps) {
// Build categorised nav from schema - only include sections that exist in the schema
const schemaProps = analysis.schema?.properties ?? {};
const VIRTUAL_SECTIONS = new Set(["__appearance__"]);
const VIRTUAL_SECTIONS = new Set(["__appearance__", "__notifications__"]);
const visibleCategories = SECTION_CATEGORIES.map((cat) =>
Object.assign({}, cat, {
sections: cat.sections.filter(
(s) => (includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps,
(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);
@@ -1308,97 +1421,101 @@ export function renderConfig(props: ConfigProps) {
? includeVirtualSections
? renderAppearanceSection(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
: props.activeSection === "__notifications__"
? includeVirtualSections
? renderNotificationsSection(props)
: nothing
: 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