diff --git a/bun.lock b/bun.lock index e8a15a5ae..0e1c0d7cf 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "source-map-support": "^0.5.21", "streamdown": "^1.4.0", "undici": "^7.16.0", + "web-push": "^3.6.7", "write-file-atomic": "^6.0.0", "ws": "^8.18.3", "zod": "^4.1.11", @@ -61,6 +62,7 @@ "@types/minimist": "^1.2.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/web-push": "^3.6.4", "@types/write-file-atomic": "^4.0.3", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.44.1", @@ -901,6 +903,8 @@ "@types/wait-on": ["@types/wait-on@5.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@types/write-file-atomic": ["@types/write-file-atomic@4.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -1007,7 +1011,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], @@ -1061,6 +1065,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -1115,6 +1121,8 @@ "bluebird-lst": ["bluebird-lst@1.0.9", "", { "dependencies": { "bluebird": "^3.5.5" } }, "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw=="], + "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="], + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], @@ -1137,6 +1145,8 @@ "buffer-equal": ["buffer-equal@1.0.1", "", {}, "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "builder-util": ["builder-util@24.13.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "4.0.0", "bluebird-lst": "^1.0.9", "builder-util-runtime": "9.2.4", "chalk": "^4.1.2", "cross-spawn": "^7.0.3", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "is-ci": "^3.0.0", "js-yaml": "^4.1.0", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0" } }, "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA=="], @@ -1443,6 +1453,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -1749,7 +1761,9 @@ "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], - "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -1991,6 +2005,10 @@ "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="], + "katex": ["katex@0.16.25", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -2227,6 +2245,8 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -2869,6 +2889,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -3091,6 +3113,8 @@ "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "builder-util/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "caching-transform/write-file-atomic": ["write-file-atomic@3.0.3", "", { "dependencies": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", "signal-exit": "^3.0.2", "typedarray-to-buffer": "^3.1.5" } }, "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q=="], "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -3195,6 +3219,8 @@ "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "istanbul-lib-processinfo/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], @@ -3577,6 +3603,8 @@ "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "builder-util/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "caching-transform/write-file-atomic/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "concurrently/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/package.json b/package.json index 06988e12c..b4e4e797b 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "source-map-support": "^0.5.21", "streamdown": "^1.4.0", "undici": "^7.16.0", + "web-push": "^3.6.7", "write-file-atomic": "^6.0.0", "ws": "^8.18.3", "zod": "^4.1.11", @@ -102,6 +103,7 @@ "@types/minimist": "^1.2.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/web-push": "^3.6.4", "@types/write-file-atomic": "^4.0.3", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.44.1", diff --git a/public/service-worker.js b/public/service-worker.js index 4ac48d421..dc79d03c4 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -51,3 +51,48 @@ self.addEventListener('fetch', (event) => { ); }); +// Push event - show notification +self.addEventListener('push', (event) => { + if (!event.data) { + return; + } + + try { + const data = event.data.json(); + + event.waitUntil( + self.registration.showNotification(data.title, { + body: data.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: data.workspaceId, + data: { workspaceId: data.workspaceId }, + }) + ); + } catch (error) { + console.error('Error handling push event:', error); + } +}); + +// Notification click - focus app and navigate to workspace +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // If a window is already open, focus it + for (const client of clientList) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + return client.focus(); + } + } + // Otherwise open a new window + if (clients.openWindow) { + return clients.openWindow(`/?workspace=${event.notification.data.workspaceId}`); + } + }) + ); +}); + + diff --git a/src/App.stories.tsx b/src/App.stories.tsx index fe34d49e4..193c7808b 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -85,6 +85,11 @@ function setupMockAPI(options: { install: () => undefined, onStatus: () => () => undefined, }, + notification: { + subscribePush: () => Promise.resolve(undefined), + unsubscribePush: () => Promise.resolve(undefined), + getVapidKey: () => Promise.resolve(null), + }, ...options.apiOverrides, }; diff --git a/src/browser/api.ts b/src/browser/api.ts index 4be41e43d..7937a8c86 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -270,6 +270,13 @@ const webApi: IPCApi = { return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void); }, }, + notification: { + subscribePush: (workspaceId, subscription) => + invokeIPC(IPC_CHANNELS.NOTIFICATION_SUBSCRIBE_PUSH, workspaceId, subscription), + unsubscribePush: (workspaceId, endpoint) => + invokeIPC(IPC_CHANNELS.NOTIFICATION_UNSUBSCRIBE_PUSH, workspaceId, endpoint), + getVapidKey: () => invokeIPC(IPC_CHANNELS.NOTIFICATION_GET_VAPID_KEY), + }, }; if (typeof window.api === "undefined") { diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index ce3a3ffb0..6dad3fe8d 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -48,6 +48,11 @@ export const IPC_CHANNELS = { UPDATE_STATUS: "update:status", UPDATE_STATUS_SUBSCRIBE: "update:status:subscribe", + // Notification channels + NOTIFICATION_SUBSCRIBE_PUSH: "notification:subscribePush", + NOTIFICATION_UNSUBSCRIBE_PUSH: "notification:unsubscribePush", + NOTIFICATION_GET_VAPID_KEY: "notification:getVapidKey", + // Dynamic channel prefixes WORKSPACE_CHAT_PREFIX: "workspace:chat:", WORKSPACE_METADATA: "workspace:metadata", diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 8a543b7e6..a60506012 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -78,6 +78,12 @@ export function getRuntimeKey(projectPath: string): string { */ export const USE_1M_CONTEXT_KEY = "use1MContext"; +/** + * Get the localStorage key for completion notification preference (global) + * Format: "notifications:completionEnabled" + */ +export const NOTIFICATION_ENABLED_KEY = "notifications:completionEnabled"; + /** * Get the localStorage key for the preferred compaction model (global) * Format: "preferredCompactionModel" diff --git a/src/debug/agentSessionCli.ts b/src/debug/agentSessionCli.ts index 5d0db5089..9ebb2fb38 100644 --- a/src/debug/agentSessionCli.ts +++ b/src/debug/agentSessionCli.ts @@ -9,6 +9,7 @@ import { HistoryService } from "@/services/historyService"; import { PartialService } from "@/services/partialService"; import { AIService } from "@/services/aiService"; import { InitStateManager } from "@/services/initStateManager"; +import { NotificationService } from "@/services/NotificationService"; import { AgentSession, type AgentSessionChatEvent } from "@/services/agentSession"; import { isCaughtUpMessage, @@ -211,6 +212,7 @@ async function main(): Promise { const partialService = new PartialService(config, historyService); const aiService = new AIService(config, historyService, partialService); const initStateManager = new InitStateManager(config); + const notificationService = new NotificationService(config.rootDir, true); ensureProvidersConfig(config); const session = new AgentSession({ @@ -220,6 +222,7 @@ async function main(): Promise { partialService, aiService, initStateManager, + notificationService, }); session.ensureMetadata({ diff --git a/src/main-server.ts b/src/main-server.ts index 626371be9..e813b2e02 100644 --- a/src/main-server.ts +++ b/src/main-server.ts @@ -109,7 +109,7 @@ app.use(express.json({ limit: "50mb" })); // Initialize config and IPC service const config = new Config(); -const ipcMainService = new IpcMain(config); +const ipcMainService = new IpcMain(config, false); // false = not desktop (web/mobile) // Track WebSocket clients and their subscriptions const clients: Clients = new Map(); diff --git a/src/preload.ts b/src/preload.ts index dfb2ad6b7..6027fdc8e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -142,6 +142,13 @@ const api: IPCApi = { }; }, }, + notification: { + subscribePush: (workspaceId: string, subscription: unknown) => + ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_SUBSCRIBE_PUSH, workspaceId, subscription), + unsubscribePush: (workspaceId: string, endpoint: string) => + ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_UNSUBSCRIBE_PUSH, workspaceId, endpoint), + getVapidKey: () => ipcRenderer.invoke(IPC_CHANNELS.NOTIFICATION_GET_VAPID_KEY), + }, }; // Expose the API along with platform/versions diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts new file mode 100644 index 000000000..2d362cc39 --- /dev/null +++ b/src/services/NotificationService.ts @@ -0,0 +1,164 @@ +import * as fs from "fs"; +import * as path from "path"; +import webpush from "web-push"; +import type { PushSubscription, VapidKeys, NotificationPayload } from "../types/notification.js"; +import { log } from "./log"; + +/** + * NotificationService manages completion notifications for both desktop and web/mobile. + * - Desktop: Shows Electron Notification + * - Web/Mobile: Sends web push notifications to subscribed clients + */ +export class NotificationService { + private readonly isDesktop: boolean; + private vapidKeys: VapidKeys | null = null; + private subscriptions = new Map(); // workspaceId -> subscriptions + private readonly vapidKeysPath: string; + + constructor(configDir: string, isDesktop: boolean) { + this.isDesktop = isDesktop; + this.vapidKeysPath = path.join(configDir, "vapid.json"); + + // Load or generate VAPID keys for web push + if (!isDesktop) { + this.initializeVapidKeys(); + } + } + + /** + * Initialize VAPID keys for web push authentication + * Generates new keys if they don't exist, otherwise loads from disk + * Note: Uses sync fs methods during startup initialization (before async operations start) + */ + private initializeVapidKeys(): void { + try { + // eslint-disable-next-line local/no-sync-fs-methods -- Startup initialization needs sync + if (fs.existsSync(this.vapidKeysPath)) { + // eslint-disable-next-line local/no-sync-fs-methods -- Startup initialization needs sync + const keysJson = fs.readFileSync(this.vapidKeysPath, "utf-8"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- JSON parse is safe for VAPID keys + this.vapidKeys = JSON.parse(keysJson); + log.info("Loaded existing VAPID keys"); + } else { + const keys = webpush.generateVAPIDKeys(); + this.vapidKeys = { + publicKey: keys.publicKey, + privateKey: keys.privateKey, + }; + // eslint-disable-next-line local/no-sync-fs-methods -- Startup initialization needs sync + fs.writeFileSync(this.vapidKeysPath, JSON.stringify(this.vapidKeys, null, 2)); + log.info("Generated and saved new VAPID keys"); + } + + // Configure web-push with VAPID details + if (this.vapidKeys) { + webpush.setVapidDetails( + "mailto:support@cmux.io", + this.vapidKeys.publicKey, + this.vapidKeys.privateKey + ); + } + } catch (error) { + log.error("Failed to initialize VAPID keys:", error); + } + } + + /** + * Get the public VAPID key for client-side subscription + */ + getVapidPublicKey(): string | null { + return this.vapidKeys?.publicKey ?? null; + } + + /** + * Subscribe a client to push notifications + */ + subscribePush(workspaceId: string, subscription: PushSubscription): void { + const existing = this.subscriptions.get(workspaceId) ?? []; + + // Check if subscription already exists (by endpoint) + const isDuplicate = existing.some((sub) => sub.endpoint === subscription.endpoint); + if (isDuplicate) { + log.debug(`Subscription already exists for workspace ${workspaceId}`); + return; + } + + existing.push(subscription); + this.subscriptions.set(workspaceId, existing); + log.info(`Added push subscription for workspace ${workspaceId}`); + } + + /** + * Unsubscribe a client from push notifications + */ + unsubscribePush(workspaceId: string, endpoint: string): void { + const existing = this.subscriptions.get(workspaceId) ?? []; + const filtered = existing.filter((sub) => sub.endpoint !== endpoint); + + if (filtered.length < existing.length) { + this.subscriptions.set(workspaceId, filtered); + log.info(`Removed push subscription for workspace ${workspaceId}`); + } + } + + /** + * Send a completion notification + * Desktop: Shows Electron notification directly (no push needed) + * Web/Mobile: Sends push notification to all subscribed clients + */ + async sendCompletionNotification(workspaceId: string, workspaceName: string): Promise { + if (this.isDesktop) { + // Desktop: Show native Electron notification + try { + // Dynamic import required: can't statically import electron in server mode + // eslint-disable-next-line no-restricted-syntax -- Dynamic import necessary for server compatibility + const electron = await import("electron"); + const notification = new electron.Notification({ + title: "Completion", + body: `${workspaceName} has finished`, + }); + notification.show(); + log.debug(`Showed desktop notification for workspace ${workspaceId}`); + } catch (error) { + log.error("Failed to show desktop notification:", error); + } + return; + } + + const subscriptions = this.subscriptions.get(workspaceId) ?? []; + if (subscriptions.length === 0) { + log.debug(`No push subscriptions for workspace ${workspaceId}`); + return; + } + + const payload: NotificationPayload = { + title: "Completion", + body: `${workspaceName} has finished`, + workspaceId, + }; + + const payloadString = JSON.stringify(payload); + + // Send to all subscriptions, removing invalid ones + const sendPromises = subscriptions.map(async (subscription) => { + try { + await webpush.sendNotification(subscription, payloadString); + log.debug(`Sent push notification for workspace ${workspaceId}`); + return { success: true, subscription }; + } catch (error) { + log.error(`Failed to send push notification, removing subscription:`, error); + return { success: false, subscription }; + } + }); + + const results = await Promise.allSettled(sendPromises); + + // Remove failed subscriptions - filter by original index to preserve correct mapping + const validSubscriptions = subscriptions.filter((_, index) => { + const result = results[index]; + return result.status === "fulfilled" && result.value.success; + }); + + this.subscriptions.set(workspaceId, validSubscriptions); + } +} diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index ed2d34547..022f7b42b 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -7,6 +7,7 @@ import type { AIService } from "@/services/aiService"; import type { HistoryService } from "@/services/historyService"; import type { PartialService } from "@/services/partialService"; import type { InitStateManager } from "@/services/initStateManager"; +import type { NotificationService } from "@/services/NotificationService"; import type { WorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceChatMessage, StreamErrorMessage, SendMessageOptions } from "@/types/ipc"; import type { SendMessageError } from "@/types/errors"; @@ -39,6 +40,7 @@ interface AgentSessionOptions { partialService: PartialService; aiService: AIService; initStateManager: InitStateManager; + notificationService: NotificationService; } export class AgentSession { @@ -48,6 +50,7 @@ export class AgentSession { private readonly partialService: PartialService; private readonly aiService: AIService; private readonly initStateManager: InitStateManager; + private readonly notificationService: NotificationService; private readonly emitter = new EventEmitter(); private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; @@ -57,8 +60,15 @@ export class AgentSession { constructor(options: AgentSessionOptions) { assert(options, "AgentSession requires options"); - const { workspaceId, config, historyService, partialService, aiService, initStateManager } = - options; + const { + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + notificationService, + } = options; assert(typeof workspaceId === "string", "workspaceId must be a string"); const trimmedWorkspaceId = workspaceId.trim(); @@ -70,6 +80,7 @@ export class AgentSession { this.partialService = partialService; this.aiService = aiService; this.initStateManager = initStateManager; + this.notificationService = notificationService; this.attachAiListeners(); this.attachInitListeners(); @@ -393,7 +404,11 @@ export class AgentSession { forward("stream-start", (payload) => this.emitChatEvent(payload)); forward("stream-delta", (payload) => this.emitChatEvent(payload)); - forward("stream-end", (payload) => this.emitChatEvent(payload)); + forward("stream-end", (payload) => { + this.emitChatEvent(payload); + // Trigger completion notification (server-side so it works when app is closed) + void this.notificationService.sendCompletionNotification(this.workspaceId, this.workspaceId); + }); forward("tool-call-start", (payload) => this.emitChatEvent(payload)); forward("tool-call-delta", (payload) => this.emitChatEvent(payload)); forward("tool-call-end", (payload) => this.emitChatEvent(payload)); diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index c73644150..de4c6d0fe 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -16,7 +16,9 @@ import { AIService } from "@/services/aiService"; import { HistoryService } from "@/services/historyService"; import { PartialService } from "@/services/partialService"; import { AgentSession } from "@/services/agentSession"; +import { NotificationService } from "@/services/NotificationService"; import type { CmuxMessage } from "@/types/message"; +import type { PushSubscription } from "@/types/notification"; import { log } from "@/services/log"; import { IPC_CHANNELS, getChatChannel } from "@/constants/ipc-constants"; import type { SendMessageError } from "@/types/errors"; @@ -52,6 +54,8 @@ export class IpcMain { private readonly aiService: AIService; private readonly bashService: BashExecutionService; private readonly initStateManager: InitStateManager; + private readonly notificationService: NotificationService; + private readonly isDesktop: boolean; private readonly sessions = new Map(); private readonly sessionSubscriptions = new Map< string, @@ -130,13 +134,15 @@ export class IpcMain { } private registered = false; - constructor(config: Config) { + constructor(config: Config, isDesktop = true) { this.config = config; + this.isDesktop = isDesktop; this.historyService = new HistoryService(config); this.partialService = new PartialService(config, this.historyService); this.aiService = new AIService(config, this.historyService, this.partialService); this.bashService = new BashExecutionService(); this.initStateManager = new InitStateManager(config); + this.notificationService = new NotificationService(config.rootDir, isDesktop); } private getOrCreateSession(workspaceId: string): AgentSession { @@ -156,6 +162,7 @@ export class IpcMain { partialService: this.partialService, aiService: this.aiService, initStateManager: this.initStateManager, + notificationService: this.notificationService, }); const chatUnsubscribe = session.onChatEvent((event) => { @@ -221,6 +228,7 @@ export class IpcMain { this.registerWindowHandlers(ipcMain); this.registerWorkspaceHandlers(ipcMain); this.registerProviderHandlers(ipcMain); + this.registerNotificationHandlers(ipcMain); this.registerProjectHandlers(ipcMain); this.registerSubscriptionHandlers(ipcMain); this.registered = true; @@ -1172,6 +1180,41 @@ export class IpcMain { }); } + private registerNotificationHandlers(ipcMain: ElectronIpcMain): void { + // Get VAPID public key for push subscription + ipcMain.handle(IPC_CHANNELS.NOTIFICATION_GET_VAPID_KEY, () => { + return this.notificationService.getVapidPublicKey(); + }); + + // Subscribe to push notifications + ipcMain.handle( + IPC_CHANNELS.NOTIFICATION_SUBSCRIBE_PUSH, + (_event, workspaceId: string, subscription: unknown) => { + try { + this.notificationService.subscribePush(workspaceId, subscription as PushSubscription); + } catch (error) { + log.error("Failed to subscribe to push notifications:", error); + } + } + ); + + // Unsubscribe from push notifications + ipcMain.handle( + IPC_CHANNELS.NOTIFICATION_UNSUBSCRIBE_PUSH, + (_event, workspaceId: string, endpoint: string) => { + try { + this.notificationService.unsubscribePush(workspaceId, endpoint); + } catch (error) { + log.error("Failed to unsubscribe from push notifications:", error); + } + } + ); + + // Note: NOTIFICATION_SEND handler intentionally omitted + // Notifications are now triggered server-side in AgentSession on stream-end + // This ensures push notifications work even when the app is closed (PWA/mobile) + } + private registerProjectHandlers(ipcMain: ElectronIpcMain): void { ipcMain.handle(IPC_CHANNELS.PROJECT_CREATE, (_event, projectPath: string) => { try { diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 609fbbe5f..04eb0bda4 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -154,6 +154,8 @@ export class WorkspaceStore { this.states.bump(workspaceId); this.checkAndBumpRecencyIfChanged(); this.finalizeUsageStats(workspaceId, (data as { metadata?: never }).metadata); + // Note: Completion notifications are now triggered server-side in AgentSession + // to support push notifications when the app is closed }, "stream-abort": (workspaceId, aggregator, data) => { aggregator.clearTokenState((data as { messageId: string }).messageId); diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 7ae90ee34..900329a39 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -294,6 +294,12 @@ export interface IPCApi { install(): void; onStatus(callback: (status: UpdateStatus) => void): () => void; }; + notification: { + subscribePush(workspaceId: string, subscription: unknown): Promise; + unsubscribePush(workspaceId: string, endpoint: string): Promise; + getVapidKey(): Promise; + // Note: send() method removed - notifications triggered server-side in AgentSession + }; } // Update status type (matches updater service) diff --git a/src/types/notification.ts b/src/types/notification.ts new file mode 100644 index 000000000..59f69eda1 --- /dev/null +++ b/src/types/notification.ts @@ -0,0 +1,32 @@ +/** + * Notification types for completion notifications + */ + +/** + * Push notification subscription object + * Standard Web Push API subscription format + */ +export interface PushSubscription { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +/** + * Notification payload sent to service worker + */ +export interface NotificationPayload { + title: string; + body: string; + workspaceId: string; +} + +/** + * VAPID keys for web push authentication + */ +export interface VapidKeys { + publicKey: string; + privateKey: string; +} diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 6a24e2644..f86f54faa 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -6,6 +6,9 @@ import { CUSTOM_EVENTS } from "@/constants/events"; import type { ProjectConfig } from "@/config"; import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { BranchListResult } from "@/types/ipc"; +import { updatePersistedState, readPersistedState } from "@/hooks/usePersistedState"; +import { NOTIFICATION_ENABLED_KEY } from "@/constants/storage"; +import { subscribeToPush } from "@/utils/notifications/pushSubscription"; export interface BuildSourcesParams { projects: Map; @@ -49,6 +52,7 @@ const section = { navigation: "Navigation", chat: "Chat", mode: "Modes & Model", + settings: "Settings", help: "Help", projects: "Projects", }; @@ -414,6 +418,40 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi return list; }); + // Settings + actions.push(() => { + const notificationsEnabled = readPersistedState(NOTIFICATION_ENABLED_KEY, false); + + return [ + { + id: "settings:toggle-notifications", + title: notificationsEnabled + ? "Disable Completion Notifications" + : "Enable Completion Notifications", + subtitle: notificationsEnabled ? "Currently enabled" : "Get notified when streams complete", + section: section.settings, + run: async () => { + const newValue = !notificationsEnabled; + updatePersistedState(NOTIFICATION_ENABLED_KEY, newValue); + + // If enabling on web, request permission and subscribe + if (newValue && typeof navigator !== "undefined" && "serviceWorker" in navigator) { + // For web/mobile, subscribe to push notifications for the current workspace + const selectedWorkspace = p.selectedWorkspace; + if (selectedWorkspace) { + const result = await subscribeToPush(selectedWorkspace.workspaceId); + if (!result.success) { + // Revert preference on failure + updatePersistedState(NOTIFICATION_ENABLED_KEY, false); + alert(`Failed to enable notifications: ${result.error ?? "Unknown error"}`); + } + } + } + }, + }, + ]; + }); + // Help / Docs actions.push(() => [ { diff --git a/src/utils/notifications/pushSubscription.ts b/src/utils/notifications/pushSubscription.ts new file mode 100644 index 000000000..c9c0ba95a --- /dev/null +++ b/src/utils/notifications/pushSubscription.ts @@ -0,0 +1,100 @@ +/** + * Push subscription utilities for web/mobile notifications + */ + +/** + * Convert VAPID key from base64 to Uint8Array for subscription + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +/** + * Subscribe to push notifications for a workspace + * @param workspaceId - Workspace to subscribe to + * @returns Success status and optional error message + */ +export async function subscribeToPush( + workspaceId: string +): Promise<{ success: boolean; error?: string }> { + // Check if browser supports notifications + if (!("Notification" in window)) { + return { success: false, error: "Notifications not supported" }; + } + + // Check if service worker is supported + if (!("serviceWorker" in navigator)) { + return { success: false, error: "Service workers not supported" }; + } + + // Request permission if not granted + let permission = Notification.permission; + if (permission === "default") { + permission = await Notification.requestPermission(); + } + + if (permission !== "granted") { + return { success: false, error: "Notification permission denied" }; + } + + try { + // Get VAPID key from backend + const vapidKey = await window.api.notification.getVapidKey(); + if (!vapidKey) { + return { success: false, error: "VAPID key not available" }; + } + + // Get service worker registration + const registration = await navigator.serviceWorker.ready; + + // Subscribe to push + const applicationServerKey = urlBase64ToUint8Array(vapidKey); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey as BufferSource, + }); + + // Send subscription to backend + await window.api.notification.subscribePush(workspaceId, subscription.toJSON()); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } +} + +/** + * Unsubscribe from push notifications for a workspace + */ +export async function unsubscribeFromPush( + workspaceId: string +): Promise<{ success: boolean; error?: string }> { + try { + if (!("serviceWorker" in navigator)) { + return { success: false, error: "Service workers not supported" }; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + await window.api.notification.unsubscribePush(workspaceId, subscription.endpoint); + await subscription.unsubscribe(); + } + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } +}