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

@@ -1642,6 +1642,7 @@
"tslog": "^4.10.2",
"typebox": "1.1.31",
"undici": "8.1.0",
"web-push": "^3.6.7",
"ws": "^8.20.0",
"yaml": "^2.8.3",
"zod": "^4.3.6"

115
pnpm-lock.yaml generated
View File

@@ -135,6 +135,9 @@ importers:
undici:
specifier: 8.1.0
version: 8.1.0
web-push:
specifier: ^3.6.7
version: 3.6.7
ws:
specifier: ^8.20.0
version: 8.20.0
@@ -2242,105 +2245,89 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -2532,28 +2519,24 @@ packages:
engines: {node: '>= 18'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@lancedb/lancedb-linux-arm64-musl@0.27.2':
resolution: {integrity: sha512-bK5Mc50EvwGZaaiym5CoPu8Y4GNSyEEvTQ0dTC2AUIm83qdQu1rGw6kkYtc/rTH/hbvAvPQot4agHDZfMVxfYw==}
engines: {node: '>= 18'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@lancedb/lancedb-linux-x64-gnu@0.27.2':
resolution: {integrity: sha512-qe+ML0YmPru0o84f33RBHqoNk6zsHBjiXTLKsEBDiiFYKks/XMsrkKy9NQYcTxShBrg/nx/MLzCzd7dihqgNYw==}
engines: {node: '>= 18'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@lancedb/lancedb-linux-x64-musl@0.27.2':
resolution: {integrity: sha512-ZpX6Oxn06qvzAdm+D/gNb3SRp/A9lgRAPvPg6nnMmSQk5XamC/hbGO07uK1wwop7nlqXUH/thk4is2y2ieWdTw==}
engines: {node: '>= 18'}
cpu: [x64]
os: [linux]
libc: [musl]
'@lancedb/lancedb-win32-arm64-msvc@0.27.2':
resolution: {integrity: sha512-4ffpFvh49MiUtkdFJOmBytXEbgUPXORphTOuExnJAgT1VAKwQcu4ZzdsgNoK6mumKBaU+pYQU/MedNkgTzx/Lw==}
@@ -2649,35 +2632,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-arm64-musl@0.3.3':
resolution: {integrity: sha512-o1paj2+zmAQ/LaPS85XJCxhNowNQpxYM2cGY6pWvB5Kqmz6hZjl6CzDg5tbf1hZkn/Em6jpOaE2UtMxKdELBDA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@mariozechner/clipboard-linux-riscv64-gnu@0.3.3':
resolution: {integrity: sha512-dkEhE4ekePJwMbBq9HP1//CFMNmDzA/iV9AXqBfvL5CWmmDIRXqh4A3YZt3tWO/HdMerX+xNCEiR7WiOsIG+UA==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-x64-gnu@0.3.3':
resolution: {integrity: sha512-lT2yANtTLlEtFBIH3uGoRa/CQas/eBoLNi3qr9axQFoRgF4RGPSJ66yHOSnMECBneTIb1Iqv3UxokTfX27CdoQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@mariozechner/clipboard-linux-x64-musl@0.3.3':
resolution: {integrity: sha512-saq/MCB0QHK/7ZZLjAZ0QkbY944dyjOsur8gneGCfMitt+GOiE1CU4OUipHC4b6x8UDY9bRLsR4aBaxu22OFPA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@mariozechner/clipboard-win32-arm64-msvc@0.3.3':
resolution: {integrity: sha512-cGuvSj0/2X2w983yEcKw+i+r1EBej6ZZIN+fXG3eY2G/HaIQpbXpLvMxKyZ9LKtbZx+Z6q/gELEoSBMLML6BaQ==}
@@ -2794,35 +2772,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.99':
resolution: {integrity: sha512-Z+6nyLdJXWzLPVxi4H6g9TJop4DwN3KSgHWto5JCbZV5/uKoVqcSynPs0tGlUHOoWI8S8tEvJspz51GQkvr07w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.99':
resolution: {integrity: sha512-jAnfOUv4IO1l8Levk5t85oVtEBOXLa07KnIUgWo1CDlPxiqpxS3uBfiE38Lvj/CQgHaNF6Nxk/SaemwLgsVJgw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.99':
resolution: {integrity: sha512-mIkXw3fGmbYyFjSmfWEvty4jN+rwEOmv0+Dy9bRvvTzLYWCgm3RMgUEQVfAKFw96nIRFnyNZiK83KNQaVVFjng==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.99':
resolution: {integrity: sha512-f3Uz2P0RgrtBHISxZqr6yiYXJlTDyCVBumDacxo+4AmSg7z0HiqYZKGWC/gszq3fbPhyQUya1W2AEteKxT9Y6A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.99':
resolution: {integrity: sha512-XE6KUkfqRsCNejcoRMiMr3RaUeObxNf6y7dut3hrq2rn7PzfRTZgrjF1F/B2C7FcdgqY/vSHWpQeMuNz1vTNHg==}
@@ -3101,56 +3074,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.46.0':
resolution: {integrity: sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.46.0':
resolution: {integrity: sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.46.0':
resolution: {integrity: sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.46.0':
resolution: {integrity: sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.46.0':
resolution: {integrity: sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.46.0':
resolution: {integrity: sha512-q0JPsTMyJNjYrBvYFDz4WbVsafNZaPCZv4RnFypRotLqpKROtBZcEaXQW4eb9YmvLU3NckVemLJnzkSZSdmOxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.46.0':
resolution: {integrity: sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.46.0':
resolution: {integrity: sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==}
@@ -3253,56 +3218,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.61.0':
resolution: {integrity: sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.61.0':
resolution: {integrity: sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.61.0':
resolution: {integrity: sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.61.0':
resolution: {integrity: sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.61.0':
resolution: {integrity: sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.61.0':
resolution: {integrity: sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.61.0':
resolution: {integrity: sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.61.0':
resolution: {integrity: sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==}
@@ -3446,84 +3403,72 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.16':
resolution: {integrity: sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16':
resolution: {integrity: sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16':
resolution: {integrity: sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.16':
resolution: {integrity: sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.16':
resolution: {integrity: sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.16':
resolution: {integrity: sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==}
@@ -3892,28 +3837,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-arm64-musl@0.1.11':
resolution: {integrity: sha512-e6pX6Hiabtz99q+H/YHNkm9JVlpqN8HGh0qPib8G2+UY4/SSH8WvqWipk3v581dMy2oyCHt7MOoY1aU1P1N/xA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@snazzah/davey-linux-x64-gnu@0.1.11':
resolution: {integrity: sha512-TW5bSoqChOJMbvsDb4wAATYrxmAXuNnse7wFNVSAJUaZKSeRfZbu3UAiPWSNn7GwLwSfU6hg322KZUn8IWCuvg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-x64-musl@0.1.11':
resolution: {integrity: sha512-5j6Pmc+Wzv5lSxVP6quA7teYRJXibkZqQyYGfTDnTsUOO5dPpcojpqlXlkhyvsA1OAQTj4uxbOCciN3cVWwzug==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@snazzah/davey-wasm32-wasi@0.1.11':
resolution: {integrity: sha512-rKOwZ/0J8lp+4VEyOdMDBRP9KR+PksZpa9V1Qn0veMzy4FqTVKthkxwGqewheFe0SFg9fdvt798l/PBFrfDeZw==}
@@ -4394,6 +4335,9 @@ packages:
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
assert-never@1.4.0:
resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==}
@@ -4532,6 +4476,9 @@ packages:
bmp-ts@1.0.9:
resolution: {integrity: sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==}
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@@ -5380,6 +5327,10 @@ packages:
resolution: {integrity: sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==}
engines: {node: '>= 20'}
http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
@@ -5686,28 +5637,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -6033,10 +5980,16 @@ packages:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -7363,6 +7316,11 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
hasBin: true
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@@ -11104,6 +11062,13 @@ snapshots:
asap@2.0.6: {}
asn1.js@5.4.1:
dependencies:
bn.js: 4.12.3
inherits: 2.0.4
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
assert-never@1.4.0: {}
assertion-error@2.0.1: {}
@@ -11227,6 +11192,8 @@ snapshots:
bmp-ts@1.0.9: {}
bn.js@4.12.3: {}
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -12225,6 +12192,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
http_ece@1.2.0: {}
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
@@ -13100,10 +13069,14 @@ snapshots:
mimic-fn@2.1.0: {}
minimalistic-assert@1.0.1: {}
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.5
minimist@1.2.8: {}
minipass@7.1.3: {}
minizlib@3.1.0:
@@ -14577,6 +14550,16 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
web-push@3.6.7:
dependencies:
asn1.js: 5.4.1
http_ece: 1.2.0
https-proxy-agent: 7.0.6
jws: 4.0.1
minimist: 1.2.8
transitivePeerDependencies:
- supports-color
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}

View File

@@ -46,6 +46,7 @@ export function buildControlUiCspHeader(opts?: { inlineScriptHashes?: string[] }
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob:",
"font-src 'self' https://fonts.gstatic.com",
"worker-src 'self'",
"connect-src 'self' ws: wss:",
].join("; ");
}

View File

@@ -103,6 +103,8 @@ function contentTypeForExt(ext: string): string {
return "image/x-icon";
case ".txt":
return "text/plain; charset=utf-8";
case ".webmanifest":
return "application/manifest+json; charset=utf-8";
default:
return "application/octet-stream";
}
@@ -128,6 +130,7 @@ const STATIC_ASSET_EXTENSIONS = new Set([
".webp",
".ico",
".txt",
".webmanifest",
]);
export type ControlUiAvatarResolution =

View File

@@ -146,6 +146,10 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"doctor.memory.repairDreamingArtifacts",
"doctor.memory.dedupeDreamDiary",
"push.test",
"push.web.vapidPublicKey",
"push.web.subscribe",
"push.web.unsubscribe",
"push.web.test",
"node.pending.enqueue",
],
[ADMIN_SCOPE]: [

View File

@@ -197,6 +197,14 @@ import {
type PushTestParams,
PushTestParamsSchema,
PushTestResultSchema,
type WebPushVapidPublicKeyParams,
WebPushVapidPublicKeyParamsSchema,
type WebPushSubscribeParams,
WebPushSubscribeParamsSchema,
type WebPushUnsubscribeParams,
WebPushUnsubscribeParamsSchema,
type WebPushTestParams,
WebPushTestParamsSchema,
type PresenceEntry,
PresenceEntrySchema,
ProtocolSchemas,
@@ -366,6 +374,16 @@ export const validateNodePendingEnqueueParams = ajv.compile<NodePendingEnqueuePa
NodePendingEnqueueParamsSchema,
);
export const validatePushTestParams = ajv.compile<PushTestParams>(PushTestParamsSchema);
export const validateWebPushVapidPublicKeyParams = ajv.compile<WebPushVapidPublicKeyParams>(
WebPushVapidPublicKeyParamsSchema,
);
export const validateWebPushSubscribeParams = ajv.compile<WebPushSubscribeParams>(
WebPushSubscribeParamsSchema,
);
export const validateWebPushUnsubscribeParams = ajv.compile<WebPushUnsubscribeParams>(
WebPushUnsubscribeParamsSchema,
);
export const validateWebPushTestParams = ajv.compile<WebPushTestParams>(WebPushTestParamsSchema);
export const validateSecretsResolveParams = ajv.compile<SecretsResolveParams>(
SecretsResolveParamsSchema,
);
@@ -581,6 +599,10 @@ export {
WakeParamsSchema,
PushTestParamsSchema,
PushTestResultSchema,
WebPushVapidPublicKeyParamsSchema,
WebPushSubscribeParamsSchema,
WebPushUnsubscribeParamsSchema,
WebPushTestParamsSchema,
NodePairRequestParamsSchema,
NodePairListParamsSchema,
NodePairApproveParamsSchema,
@@ -812,6 +834,10 @@ export type {
LogsTailParams,
LogsTailResult,
PollParams,
WebPushVapidPublicKeyParams,
WebPushSubscribeParams,
WebPushUnsubscribeParams,
WebPushTestParams,
UpdateRunParams,
ChatInjectParams,
};

View File

@@ -26,3 +26,51 @@ export const PushTestResultSchema = Type.Object(
},
{ additionalProperties: false },
);
// --- Web Push schemas ---
const WebPushKeysSchema = Type.Object(
{
p256dh: Type.String({ minLength: 1, maxLength: 512 }),
auth: Type.String({ minLength: 1, maxLength: 512 }),
},
{ additionalProperties: false },
);
export const WebPushVapidPublicKeyParamsSchema = Type.Object({}, { additionalProperties: false });
export const WebPushSubscribeParamsSchema = Type.Object(
{
endpoint: Type.String({ minLength: 1, maxLength: 2048, pattern: "^https://" }),
keys: WebPushKeysSchema,
},
{ additionalProperties: false },
);
export const WebPushUnsubscribeParamsSchema = Type.Object(
{
endpoint: Type.String({ minLength: 1, maxLength: 2048, pattern: "^https://" }),
},
{ additionalProperties: false },
);
export const WebPushTestParamsSchema = Type.Object(
{
title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export type WebPushVapidPublicKeyParams = Record<string, never>;
export type WebPushSubscribeParams = {
endpoint: string;
keys: { p256dh: string; auth: string };
};
export type WebPushUnsubscribeParams = {
endpoint: string;
};
export type WebPushTestParams = {
title?: string;
body?: string;
};

View File

@@ -8,8 +8,22 @@ import {
sendApnsAlert,
shouldClearStoredApnsRegistration,
} from "../../infra/push-apns.js";
import {
broadcastWebPush,
clearWebPushSubscriptionByEndpoint,
registerWebPushSubscription,
resolveVapidKeys,
} from "../../infra/push-web.js";
import { normalizeStringifiedOptionalString } from "../../shared/string-coerce.js";
import { ErrorCodes, errorShape, validatePushTestParams } from "../protocol/index.js";
import {
ErrorCodes,
errorShape,
validatePushTestParams,
validateWebPushSubscribeParams,
validateWebPushTestParams,
validateWebPushUnsubscribeParams,
validateWebPushVapidPublicKeyParams,
} from "../protocol/index.js";
import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js";
import { normalizeTrimmedString } from "./record-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -100,4 +114,82 @@ export const pushHandlers: GatewayRequestHandlers = {
respond(true, result, undefined);
});
},
"push.web.vapidPublicKey": async ({ params, respond }) => {
if (!validateWebPushVapidPublicKeyParams(params)) {
respondInvalidParams({
respond,
method: "push.web.vapidPublicKey",
validator: validateWebPushVapidPublicKeyParams,
});
return;
}
await respondUnavailableOnThrow(respond, async () => {
const vapid = await resolveVapidKeys();
respond(true, { vapidPublicKey: vapid.publicKey }, undefined);
});
},
"push.web.subscribe": async ({ params, respond }) => {
if (!validateWebPushSubscribeParams(params)) {
respondInvalidParams({
respond,
method: "push.web.subscribe",
validator: validateWebPushSubscribeParams,
});
return;
}
await respondUnavailableOnThrow(respond, async () => {
const subscription = await registerWebPushSubscription({
endpoint: params.endpoint,
keys: params.keys,
});
respond(true, { subscriptionId: subscription.subscriptionId }, undefined);
});
},
"push.web.unsubscribe": async ({ params, respond }) => {
if (!validateWebPushUnsubscribeParams(params)) {
respondInvalidParams({
respond,
method: "push.web.unsubscribe",
validator: validateWebPushUnsubscribeParams,
});
return;
}
await respondUnavailableOnThrow(respond, async () => {
const removed = await clearWebPushSubscriptionByEndpoint(params.endpoint);
respond(true, { removed }, undefined);
});
},
"push.web.test": async ({ params, respond }) => {
if (!validateWebPushTestParams(params)) {
respondInvalidParams({
respond,
method: "push.web.test",
validator: validateWebPushTestParams,
});
return;
}
const title = normalizeTrimmedString(params.title) ?? "OpenClaw";
const body = normalizeTrimmedString(params.body) ?? "Web push test notification";
await respondUnavailableOnThrow(respond, async () => {
const results = await broadcastWebPush({ title, body });
if (results.length === 0) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "no web push subscriptions registered"),
);
return;
}
respond(true, { results }, undefined);
});
},
};

227
src/infra/push-web.test.ts Normal file
View File

@@ -0,0 +1,227 @@
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.
let tmpDir: string;
vi.mock("../config/paths.js", () => ({
resolveStateDir: () => tmpDir,
}));
// Stub web-push so we don't make real HTTP requests.
vi.mock("web-push", () => ({
default: {
generateVAPIDKeys: () => ({
publicKey: "test-public-key-base64url",
privateKey: "test-private-key-base64url",
}),
setVapidDetails: vi.fn(),
sendNotification: vi.fn().mockResolvedValue({ statusCode: 201 }),
},
}));
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "push-web-test-"));
vi.clearAllMocks();
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("resolveVapidKeys", () => {
it("generates and persists VAPID keys on first call", async () => {
const keys = await resolveVapidKeys(tmpDir);
expect(keys.publicKey).toBe("test-public-key-base64url");
expect(keys.privateKey).toBe("test-private-key-base64url");
expect(keys.subject).toMatch(/^mailto:/);
// Second call returns same keys.
const keys2 = await resolveVapidKeys(tmpDir);
expect(keys2.publicKey).toBe(keys.publicKey);
expect(keys2.privateKey).toBe(keys.privateKey);
});
it("prefers env vars over persisted keys", async () => {
// Persist keys first.
await resolveVapidKeys(tmpDir);
// Set env overrides.
process.env.OPENCLAW_VAPID_PUBLIC_KEY = "env-public";
process.env.OPENCLAW_VAPID_PRIVATE_KEY = "env-private";
process.env.OPENCLAW_VAPID_SUBJECT = "mailto:env@test.com";
try {
const keys = await resolveVapidKeys(tmpDir);
expect(keys.publicKey).toBe("env-public");
expect(keys.privateKey).toBe("env-private");
expect(keys.subject).toBe("mailto:env@test.com");
} finally {
delete process.env.OPENCLAW_VAPID_PUBLIC_KEY;
delete process.env.OPENCLAW_VAPID_PRIVATE_KEY;
delete process.env.OPENCLAW_VAPID_SUBJECT;
}
});
});
describe("subscription CRUD", () => {
const endpoint = "https://push.example.com/send/abc123";
const keys = { p256dh: "p256dh-key", auth: "auth-key" };
it("registers a new subscription", async () => {
const sub = await registerWebPushSubscription({
endpoint,
keys,
baseDir: tmpDir,
});
expect(sub.subscriptionId).toBeTruthy();
expect(sub.endpoint).toBe(endpoint);
expect(sub.keys.p256dh).toBe("p256dh-key");
expect(sub.keys.auth).toBe("auth-key");
expect(sub.createdAtMs).toBeGreaterThan(0);
});
it("updates an existing subscription with the same endpoint", async () => {
const sub1 = await registerWebPushSubscription({
endpoint,
keys,
baseDir: tmpDir,
});
const sub2 = await registerWebPushSubscription({
endpoint,
keys: { p256dh: "new-p256dh", auth: "new-auth" },
baseDir: tmpDir,
});
// Same subscription ID, same created time, updated keys.
expect(sub2.subscriptionId).toBe(sub1.subscriptionId);
expect(sub2.createdAtMs).toBe(sub1.createdAtMs);
expect(sub2.keys.p256dh).toBe("new-p256dh");
});
it("loads a subscription by ID", async () => {
const sub = await registerWebPushSubscription({
endpoint,
keys,
baseDir: tmpDir,
});
const loaded = await loadWebPushSubscription(sub.subscriptionId, tmpDir);
expect(loaded).not.toBeNull();
expect(loaded!.endpoint).toBe(endpoint);
});
it("returns null for unknown subscription ID", async () => {
const loaded = await loadWebPushSubscription("nonexistent", tmpDir);
expect(loaded).toBeNull();
});
it("lists all subscriptions", async () => {
await registerWebPushSubscription({
endpoint: "https://push.example.com/a",
keys,
baseDir: tmpDir,
});
await registerWebPushSubscription({
endpoint: "https://push.example.com/b",
keys,
baseDir: tmpDir,
});
const list = await listWebPushSubscriptions(tmpDir);
expect(list).toHaveLength(2);
});
it("clears a subscription by ID", async () => {
const sub = await registerWebPushSubscription({
endpoint,
keys,
baseDir: tmpDir,
});
const removed = await clearWebPushSubscription(sub.subscriptionId, tmpDir);
expect(removed).toBe(true);
const list = await listWebPushSubscriptions(tmpDir);
expect(list).toHaveLength(0);
});
it("clears a subscription by endpoint", async () => {
await registerWebPushSubscription({ endpoint, keys, baseDir: tmpDir });
const removed = await clearWebPushSubscriptionByEndpoint(endpoint, tmpDir);
expect(removed).toBe(true);
const list = await listWebPushSubscriptions(tmpDir);
expect(list).toHaveLength(0);
});
it("rejects invalid endpoint", async () => {
await expect(
registerWebPushSubscription({
endpoint: "http://insecure.example.com",
keys,
baseDir: tmpDir,
}),
).rejects.toThrow("invalid push subscription endpoint");
});
it("rejects empty keys", async () => {
await expect(
registerWebPushSubscription({
endpoint,
keys: { p256dh: "", auth: "auth-key" },
baseDir: tmpDir,
}),
).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);
});
});

334
src/infra/push-web.ts Normal file
View File

@@ -0,0 +1,334 @@
import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import webPush from "web-push";
import { resolveStateDir } from "../config/paths.js";
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
// --- Types ---
export type WebPushSubscription = {
subscriptionId: string;
endpoint: string;
keys: { p256dh: string; auth: string };
createdAtMs: number;
updatedAtMs: number;
};
export type WebPushRegistrationState = {
subscriptionsByEndpointHash: Record<string, WebPushSubscription>;
};
export type VapidKeyPair = {
publicKey: string;
privateKey: string;
subject: string;
};
export type WebPushSendResult = {
ok: boolean;
subscriptionId: string;
statusCode?: number;
error?: string;
};
// --- Constants ---
const WEB_PUSH_STATE_FILENAME = "push/web-push-subscriptions.json";
const VAPID_KEYS_FILENAME = "push/vapid-keys.json";
const MAX_ENDPOINT_LENGTH = 2048;
const MAX_KEY_LENGTH = 512;
const DEFAULT_VAPID_SUBJECT = "mailto:openclaw@localhost";
const withLock = createAsyncLock();
// --- Helpers ---
function resolveWebPushStatePath(baseDir?: string): string {
const root = baseDir ?? resolveStateDir();
return path.join(root, WEB_PUSH_STATE_FILENAME);
}
function resolveVapidKeysPath(baseDir?: string): string {
const root = baseDir ?? resolveStateDir();
return path.join(root, VAPID_KEYS_FILENAME);
}
function hashEndpoint(endpoint: string): string {
return createHash("sha256").update(endpoint).digest("hex").slice(0, 32);
}
function isValidEndpoint(endpoint: string): boolean {
if (!endpoint || endpoint.length > MAX_ENDPOINT_LENGTH) {
return false;
}
try {
const url = new URL(endpoint);
return url.protocol === "https:";
} catch {
return false;
}
}
function isValidKey(key: string): boolean {
return typeof key === "string" && key.length > 0 && key.length <= MAX_KEY_LENGTH;
}
// --- State persistence ---
async function loadState(baseDir?: string): Promise<WebPushRegistrationState> {
const filePath = resolveWebPushStatePath(baseDir);
const state = await readJsonFile<WebPushRegistrationState>(filePath);
return state ?? { subscriptionsByEndpointHash: {} };
}
async function persistState(state: WebPushRegistrationState, baseDir?: string): Promise<void> {
const filePath = resolveWebPushStatePath(baseDir);
await writeJsonAtomic(filePath, state, { trailingNewline: true });
}
// --- VAPID keys ---
export async function resolveVapidKeys(baseDir?: string): Promise<VapidKeyPair> {
// Env vars take precedence — allows operators to share a stable VAPID
// identity across multiple gateway instances.
const envPublic = resolveVapidPublicKeyFromEnv();
const envPrivate = resolveVapidPrivateKeyFromEnv();
if (envPublic && envPrivate) {
return {
publicKey: envPublic,
privateKey: envPrivate,
subject: resolveVapidSubjectFromEnv(),
};
}
// Fall back to persisted keys, generating on first use under a lock to
// prevent concurrent bootstraps from writing different keypairs.
return await withLock(async () => {
const filePath = resolveVapidKeysPath(baseDir);
const existing = await readJsonFile<VapidKeyPair>(filePath);
if (existing?.publicKey && existing?.privateKey) {
return {
publicKey: existing.publicKey,
privateKey: existing.privateKey,
// Env var always wins so operators can change subject without deleting vapid-keys.json.
subject: resolveVapidSubjectFromEnv(),
};
}
const keys = webPush.generateVAPIDKeys();
const pair: VapidKeyPair = {
publicKey: keys.publicKey,
privateKey: keys.privateKey,
subject: resolveVapidSubjectFromEnv(),
};
await writeJsonAtomic(filePath, pair, { trailingNewline: true });
return pair;
});
}
function resolveVapidSubjectFromEnv(): string {
return process.env.OPENCLAW_VAPID_SUBJECT || DEFAULT_VAPID_SUBJECT;
}
export function resolveVapidPublicKeyFromEnv(): string | undefined {
return process.env.OPENCLAW_VAPID_PUBLIC_KEY || undefined;
}
export function resolveVapidPrivateKeyFromEnv(): string | undefined {
return process.env.OPENCLAW_VAPID_PRIVATE_KEY || undefined;
}
// --- Subscription CRUD ---
export type RegisterWebPushParams = {
endpoint: string;
keys: { p256dh: string; auth: string };
baseDir?: string;
};
export async function registerWebPushSubscription(
params: RegisterWebPushParams,
): Promise<WebPushSubscription> {
const { endpoint, keys, baseDir } = params;
if (!isValidEndpoint(endpoint)) {
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");
}
return await withLock(async () => {
const state = await loadState(baseDir);
const hash = hashEndpoint(endpoint);
const now = Date.now();
const existing = state.subscriptionsByEndpointHash[hash];
const subscription: WebPushSubscription = {
subscriptionId: existing?.subscriptionId ?? randomUUID(),
endpoint,
keys: { p256dh: keys.p256dh, auth: keys.auth },
createdAtMs: existing?.createdAtMs ?? now,
updatedAtMs: now,
};
state.subscriptionsByEndpointHash[hash] = subscription;
await persistState(state, baseDir);
return subscription;
});
}
export async function loadWebPushSubscription(
subscriptionId: string,
baseDir?: string,
): Promise<WebPushSubscription | null> {
const state = await loadState(baseDir);
for (const sub of Object.values(state.subscriptionsByEndpointHash)) {
if (sub.subscriptionId === subscriptionId) {
return sub;
}
}
return null;
}
export async function listWebPushSubscriptions(baseDir?: string): Promise<WebPushSubscription[]> {
const state = await loadState(baseDir);
return Object.values(state.subscriptionsByEndpointHash);
}
export async function clearWebPushSubscription(
subscriptionId: string,
baseDir?: string,
): Promise<boolean> {
return await withLock(async () => {
const state = await loadState(baseDir);
for (const [hash, sub] of Object.entries(state.subscriptionsByEndpointHash)) {
if (sub.subscriptionId === subscriptionId) {
delete state.subscriptionsByEndpointHash[hash];
await persistState(state, baseDir);
return true;
}
}
return false;
});
}
export async function clearWebPushSubscriptionByEndpoint(
endpoint: string,
baseDir?: string,
): Promise<boolean> {
return await withLock(async () => {
const state = await loadState(baseDir);
const hash = hashEndpoint(endpoint);
if (state.subscriptionsByEndpointHash[hash]) {
delete state.subscriptionsByEndpointHash[hash];
await persistState(state, baseDir);
return true;
}
return false;
});
}
// --- Sending ---
export type WebPushPayload = {
title: string;
body?: string;
tag?: string;
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: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
};
try {
const result = await webPush.sendNotification(pushSubscription, JSON.stringify(payload));
return {
ok: true,
subscriptionId: subscription.subscriptionId,
statusCode: result.statusCode,
};
} catch (err: unknown) {
const statusCode =
typeof err === "object" && err !== null && "statusCode" in err
? (err as { statusCode: number }).statusCode
: undefined;
const message =
typeof err === "object" && err !== null && "message" in err
? (err as { message: string }).message
: "unknown error";
return {
ok: false,
subscriptionId: subscription.subscriptionId,
statusCode,
error: message,
};
}
}
export async function broadcastWebPush(
payload: WebPushPayload,
baseDir?: string,
): Promise<WebPushSendResult[]> {
const subscriptions = await listWebPushSubscriptions(baseDir);
if (subscriptions.length === 0) {
return [];
}
const vapidKeys = await resolveVapidKeys(baseDir);
// Set VAPID details once before fanning out concurrent sends.
applyVapidDetails(vapidKeys);
const results = await Promise.allSettled(
subscriptions.map((sub) => sendPreparedWebPushNotification(sub, payload)),
);
const mapped = results.map((r, i) =>
r.status === "fulfilled"
? r.value
: {
ok: false,
subscriptionId: subscriptions[i].subscriptionId,
error: r.reason instanceof Error ? r.reason.message : "unknown error",
},
);
// Clean up expired subscriptions (HTTP 410 Gone or 404 Not Found) per Web Push spec.
const expiredEndpoints = mapped
.map((result, i) => ({ result, sub: subscriptions[i] }))
.filter(({ result }) => !result.ok && (result.statusCode === 410 || result.statusCode === 404))
.map(({ sub }) => sub.endpoint);
if (expiredEndpoints.length > 0) {
await Promise.allSettled(
expiredEndpoints.map((endpoint) => clearWebPushSubscriptionByEndpoint(endpoint, baseDir)),
);
}
return mapped;
}

30
src/types/web-push.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
declare module "web-push" {
export type PushSubscription = {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
};
export type SendResult = {
statusCode: number;
body: string;
headers: Record<string, string>;
};
export type VAPIDKeys = {
publicKey: string;
privateKey: string;
};
export function generateVAPIDKeys(): VAPIDKeys;
export function setVapidDetails(subject: string, publicKey: string, privateKey: string): void;
export function sendNotification(
subscription: PushSubscription,
payload?: string | Buffer | null,
options?: Record<string, unknown>,
): Promise<SendResult>;
}

View File

@@ -8,6 +8,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="manifest.webmanifest" />
<script>
(function () {
var THEMES = { claw: 1, knot: 1, dash: 1 };

View File

@@ -0,0 +1,27 @@
{
"name": "OpenClaw Control",
"short_name": "OpenClaw",
"description": "Multi-channel AI gateway control panel",
"start_url": "./",
"display": "standalone",
"theme_color": "#0a0a0a",
"background_color": "#0a0a0a",
"icons": [
{
"src": "./favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "./favicon-32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "./apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}

114
ui/public/sw.js Normal file
View File

@@ -0,0 +1,114 @@
// OpenClaw Control Service Worker
// Handles offline caching and push notifications.
const CACHE_NAME = "openclaw-control-v1";
// Minimal app-shell files to precache.
const PRECACHE_URLS = ["./"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
),
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Skip non-GET and cross-origin requests.
if (event.request.method !== "GET" || url.origin !== self.location.origin) {
return;
}
// Skip non-UI routes — API, RPC, and plugin routes should never be cached.
if (
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/rpc") ||
url.pathname.startsWith("/plugins/")
) {
return;
}
// Cache-first for hashed assets; network-first for HTML/other.
if (url.pathname.includes("/assets/")) {
event.respondWith(
caches.match(event.request).then(
(cached) =>
cached ||
fetch(event.request).then((response) => {
if (response.ok) {
const clone = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
}),
),
);
} else {
event.respondWith(
fetch(event.request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request)),
);
}
});
// --- Web Push ---
self.addEventListener("push", (event) => {
if (!event.data) {
return;
}
let data;
try {
data = event.data.json();
} catch {
data = { title: "OpenClaw", body: event.data.text() };
}
const title = data.title || "OpenClaw";
const options = {
body: data.body || "",
icon: "./apple-touch-icon.png",
badge: "./favicon-32.png",
tag: data.tag || "openclaw-notification",
data: { url: data.url || "./" },
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = event.notification.data?.url || "./";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
// Focus an existing window if one is open.
for (const client of clients) {
if (new URL(client.url).pathname === new URL(targetUrl, self.location.origin).pathname) {
return client.focus();
}
}
return self.clients.openWindow(targetUrl);
}),
);
});

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,6 +1421,10 @@ export function renderConfig(props: ConfigProps) {
? includeVirtualSections
? renderAppearanceSection(props)
: nothing
: props.activeSection === "__notifications__"
? includeVirtualSections
? renderNotificationsSection(props)
: nothing
: formMode === "form"
? html`
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
@@ -1349,8 +1466,8 @@ export function renderConfig(props: ConfigProps) {
${formUnsafe
? 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.
Your config contains fields the form editor can't safely represent.
Use Raw mode to edit those entries.
</div>
`
: nothing}