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,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