mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
@@ -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
115
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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("; ");
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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]: [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
227
src/infra/push-web.test.ts
Normal 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
334
src/infra/push-web.ts
Normal 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
30
src/types/web-push.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
27
ui/public/manifest.webmanifest
Normal file
27
ui/public/manifest.webmanifest
Normal 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
114
ui/public/sw.js
Normal 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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
141
ui/src/ui/push-subscription.ts
Normal file
141
ui/src/ui/push-subscription.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user