mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-28 11:37:39 +00:00
feat: introduce notification system and claude notifications
This commit is contained in:
76
package-lock.json
generated
76
package-lock.json
generated
@@ -64,6 +64,7 @@
|
|||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3374,7 +3375,6 @@
|
|||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
@@ -3509,6 +3509,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ast-types": {
|
"node_modules/ast-types": {
|
||||||
"version": "0.13.4",
|
"version": "0.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||||
@@ -3745,6 +3757,12 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||||
|
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
@@ -6323,6 +6341,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-cache-semantics": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
@@ -6364,7 +6391,6 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"agent-base": "^7.1.2",
|
"agent-base": "^7.1.2",
|
||||||
@@ -8276,6 +8302,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
@@ -12753,6 +12785,46 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/web-push/node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/web-push/node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
51
public/sw.js
51
public/sw.js
@@ -22,11 +22,9 @@ self.addEventListener('fetch', event => {
|
|||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request)
|
caches.match(event.request)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
// Return cached response if found
|
|
||||||
if (response) {
|
if (response) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
// Otherwise fetch from network
|
|
||||||
return fetch(event.request);
|
return fetch(event.request);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -46,4 +44,53 @@ self.addEventListener('activate', event => {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push notification event
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = event.data.json();
|
||||||
|
} catch {
|
||||||
|
payload = { title: 'Claude Code UI', body: event.data.text() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: payload.body || '',
|
||||||
|
icon: '/logo.png',
|
||||||
|
badge: '/logo.png',
|
||||||
|
data: payload.data || {},
|
||||||
|
tag: payload.data?.code || 'default',
|
||||||
|
renotify: true
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(payload.title || 'Claude Code UI', options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification click event
|
||||||
|
self.addEventListener('notificationclick', event => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const sessionId = event.notification.data?.sessionId;
|
||||||
|
const urlPath = sessionId ? `/session/${sessionId}` : '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.location.origin)) {
|
||||||
|
client.focus();
|
||||||
|
if (sessionId) {
|
||||||
|
client.navigate(self.location.origin + urlPath);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(urlPath);
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||||
|
import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js';
|
||||||
|
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
const pendingToolApprovals = new Map();
|
const pendingToolApprovals = new Map();
|
||||||
@@ -461,6 +462,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
let tempImagePaths = [];
|
let tempImagePaths = [];
|
||||||
let tempDir = null;
|
let tempDir = null;
|
||||||
|
|
||||||
|
const emitNotification = (event) => {
|
||||||
|
notifyUserIfEnabled({
|
||||||
|
userId: ws?.userId || null,
|
||||||
|
writer: ws,
|
||||||
|
event
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Map CLI options to SDK format
|
// Map CLI options to SDK format
|
||||||
const sdkOptions = mapCliOptionsToSDK(options);
|
const sdkOptions = mapCliOptionsToSDK(options);
|
||||||
@@ -477,6 +486,42 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
tempImagePaths = imageResult.tempImagePaths;
|
tempImagePaths = imageResult.tempImagePaths;
|
||||||
tempDir = imageResult.tempDir;
|
tempDir = imageResult.tempDir;
|
||||||
|
|
||||||
|
sdkOptions.hooks = {
|
||||||
|
Notification: [{
|
||||||
|
matcher: '',
|
||||||
|
hooks: [async (input) => {
|
||||||
|
const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
|
||||||
|
emitNotification(createNotificationEvent({
|
||||||
|
provider: 'claude',
|
||||||
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
kind: 'action_required',
|
||||||
|
code: 'agent.notification',
|
||||||
|
meta: { message },
|
||||||
|
severity: 'warning',
|
||||||
|
requiresUserAction: true,
|
||||||
|
dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
|
||||||
|
}));
|
||||||
|
return {};
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
Stop: [{
|
||||||
|
matcher: '',
|
||||||
|
hooks: [async (input) => {
|
||||||
|
const stopReason = typeof input?.stop_reason === 'string' ? input.stop_reason : 'completed';
|
||||||
|
emitNotification(createNotificationEvent({
|
||||||
|
provider: 'claude',
|
||||||
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
kind: 'stop',
|
||||||
|
code: 'run.stopped',
|
||||||
|
meta: { stopReason },
|
||||||
|
severity: 'info',
|
||||||
|
dedupeKey: `claude:hook:stop:${capturedSessionId || sessionId || 'none'}:${stopReason}`
|
||||||
|
}));
|
||||||
|
return {};
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||||
|
|
||||||
@@ -508,6 +553,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
input,
|
input,
|
||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
|
emitNotification(createNotificationEvent({
|
||||||
|
provider: 'claude',
|
||||||
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
kind: 'action_required',
|
||||||
|
code: 'permission.required',
|
||||||
|
meta: { toolName },
|
||||||
|
severity: 'warning',
|
||||||
|
requiresUserAction: true,
|
||||||
|
dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
|
||||||
|
}));
|
||||||
|
|
||||||
const decision = await waitForToolApproval(requestId, {
|
const decision = await waitForToolApproval(requestId, {
|
||||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||||
@@ -548,10 +603,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
||||||
|
|
||||||
const queryInstance = query({
|
let queryInstance;
|
||||||
prompt: finalCommand,
|
try {
|
||||||
options: sdkOptions
|
queryInstance = query({
|
||||||
});
|
prompt: finalCommand,
|
||||||
|
options: sdkOptions
|
||||||
|
});
|
||||||
|
} catch (hookError) {
|
||||||
|
// Older/newer SDK versions may not accept hook shapes yet.
|
||||||
|
// Keep notification behavior operational via runtime events even if hook registration fails.
|
||||||
|
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
|
||||||
|
delete sdkOptions.hooks;
|
||||||
|
queryInstance = query({
|
||||||
|
prompt: finalCommand,
|
||||||
|
options: sdkOptions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Restore immediately — Query constructor already captured the value
|
// Restore immediately — Query constructor already captured the value
|
||||||
if (prevStreamTimeout !== undefined) {
|
if (prevStreamTimeout !== undefined) {
|
||||||
@@ -653,6 +720,15 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
|
emitNotification(createNotificationEvent({
|
||||||
|
provider: 'claude',
|
||||||
|
sessionId: capturedSessionId || sessionId || null,
|
||||||
|
kind: 'error',
|
||||||
|
code: 'run.failed',
|
||||||
|
meta: { error: error.message },
|
||||||
|
severity: 'error',
|
||||||
|
dedupeKey: `claude:error:${capturedSessionId || sessionId || 'none'}:${error.message}`
|
||||||
|
}));
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,36 @@ const runMigrations = () => {
|
|||||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
preferences_json TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
private_key TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
keys_p256dh TEXT NOT NULL,
|
||||||
|
keys_auth TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
console.log('Database migrations completed successfully');
|
console.log('Database migrations completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error running migrations:', error.message);
|
console.error('Error running migrations:', error.message);
|
||||||
@@ -348,6 +378,116 @@ const credentialsDb = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_NOTIFICATION_PREFERENCES = {
|
||||||
|
channels: {
|
||||||
|
inApp: false,
|
||||||
|
webPush: true
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
actionRequired: true,
|
||||||
|
stop: true,
|
||||||
|
error: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNotificationPreferences = (value) => {
|
||||||
|
const source = value && typeof value === 'object' ? value : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: {
|
||||||
|
inApp: source.channels?.inApp === true,
|
||||||
|
webPush: source.channels?.webPush !== false
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
actionRequired: source.events?.actionRequired !== false,
|
||||||
|
stop: source.events?.stop !== false,
|
||||||
|
error: source.events?.error !== false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationPreferencesDb = {
|
||||||
|
getPreferences: (userId) => {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
|
||||||
|
if (!row) {
|
||||||
|
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
|
||||||
|
).run(userId, JSON.stringify(defaults));
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(row.preferences_json);
|
||||||
|
} catch {
|
||||||
|
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
|
||||||
|
}
|
||||||
|
return normalizeNotificationPreferences(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePreferences: (userId, preferences) => {
|
||||||
|
try {
|
||||||
|
const normalized = normalizeNotificationPreferences(preferences);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
preferences_json = excluded.preferences_json,
|
||||||
|
updated_at = CURRENT_TIMESTAMP`
|
||||||
|
).run(userId, JSON.stringify(normalized));
|
||||||
|
return normalized;
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushSubscriptionsDb = {
|
||||||
|
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(endpoint) DO UPDATE SET
|
||||||
|
user_id = excluded.user_id,
|
||||||
|
keys_p256dh = excluded.keys_p256dh,
|
||||||
|
keys_auth = excluded.keys_auth`
|
||||||
|
).run(userId, endpoint, keysP256dh, keysAuth);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSubscriptions: (userId) => {
|
||||||
|
try {
|
||||||
|
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSubscription: (endpoint) => {
|
||||||
|
try {
|
||||||
|
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAllForUser: (userId) => {
|
||||||
|
try {
|
||||||
|
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Backward compatibility - keep old names pointing to new system
|
// Backward compatibility - keep old names pointing to new system
|
||||||
const githubTokensDb = {
|
const githubTokensDb = {
|
||||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
||||||
@@ -373,5 +513,7 @@ export {
|
|||||||
userDb,
|
userDb,
|
||||||
apiKeysDb,
|
apiKeysDb,
|
||||||
credentialsDb,
|
credentialsDb,
|
||||||
|
notificationPreferencesDb,
|
||||||
|
pushSubscriptionsDb,
|
||||||
githubTokensDb // Backward compatibility
|
githubTokensDb // Backward compatibility
|
||||||
};
|
};
|
||||||
@@ -50,3 +50,30 @@ CREATE TABLE IF NOT EXISTS user_credentials (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||||
|
|
||||||
|
-- User notification preferences (backend-owned, provider-agnostic)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
preferences_json TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- VAPID key pair for Web Push notifications
|
||||||
|
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
private_key TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Browser push subscriptions
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
keys_p256dh TEXT NOT NULL,
|
||||||
|
keys_auth TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import cliAuthRoutes from './routes/cli-auth.js';
|
|||||||
import userRoutes from './routes/user.js';
|
import userRoutes from './routes/user.js';
|
||||||
import codexRoutes from './routes/codex.js';
|
import codexRoutes from './routes/codex.js';
|
||||||
import { initializeDatabase } from './database/db.js';
|
import { initializeDatabase } from './database/db.js';
|
||||||
|
import { configureWebPush } from './services/vapid-keys.js';
|
||||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
import { IS_PLATFORM } from './constants/config.js';
|
import { IS_PLATFORM } from './constants/config.js';
|
||||||
|
|
||||||
@@ -888,7 +889,7 @@ wss.on('connection', (ws, request) => {
|
|||||||
if (pathname === '/shell') {
|
if (pathname === '/shell') {
|
||||||
handleShellConnection(ws);
|
handleShellConnection(ws);
|
||||||
} else if (pathname === '/ws') {
|
} else if (pathname === '/ws') {
|
||||||
handleChatConnection(ws);
|
handleChatConnection(ws, request);
|
||||||
} else {
|
} else {
|
||||||
console.log('[WARN] Unknown WebSocket path:', pathname);
|
console.log('[WARN] Unknown WebSocket path:', pathname);
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -899,9 +900,10 @@ wss.on('connection', (ws, request) => {
|
|||||||
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||||
*/
|
*/
|
||||||
class WebSocketWriter {
|
class WebSocketWriter {
|
||||||
constructor(ws) {
|
constructor(ws, userId = null) {
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
this.sessionId = null;
|
this.sessionId = null;
|
||||||
|
this.userId = userId;
|
||||||
this.isWebSocketWriter = true; // Marker for transport detection
|
this.isWebSocketWriter = true; // Marker for transport detection
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,14 +924,14 @@ class WebSocketWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle chat WebSocket connections
|
// Handle chat WebSocket connections
|
||||||
function handleChatConnection(ws) {
|
function handleChatConnection(ws, request) {
|
||||||
console.log('[INFO] Chat WebSocket connected');
|
console.log('[INFO] Chat WebSocket connected');
|
||||||
|
|
||||||
// Add to connected clients for project updates
|
// Add to connected clients for project updates
|
||||||
connectedClients.add(ws);
|
connectedClients.add(ws);
|
||||||
|
|
||||||
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
||||||
const writer = new WebSocketWriter(ws);
|
const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
|
||||||
|
|
||||||
ws.on('message', async (message) => {
|
ws.on('message', async (message) => {
|
||||||
try {
|
try {
|
||||||
@@ -1918,6 +1920,9 @@ async function startServer() {
|
|||||||
// Initialize authentication database
|
// Initialize authentication database
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
|
||||||
|
// Configure Web Push (VAPID keys)
|
||||||
|
configureWebPush();
|
||||||
|
|
||||||
// Check if running in production mode (dist folder exists)
|
// Check if running in production mode (dist folder exists)
|
||||||
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
||||||
const isProduction = fs.existsSync(distIndexPath);
|
const isProduction = fs.existsSync(distIndexPath);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const authenticateWebSocket = (token) => {
|
|||||||
try {
|
try {
|
||||||
const user = userDb.getFirstUser();
|
const user = userDb.getFirstUser();
|
||||||
if (user) {
|
if (user) {
|
||||||
return { userId: user.id, username: user.username };
|
return { id: user.id, userId: user.id, username: user.username };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -101,7 +101,10 @@ const authenticateWebSocket = (token) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
return decoded;
|
return {
|
||||||
|
...decoded,
|
||||||
|
id: decoded.userId
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('WebSocket token verification error:', error);
|
console.error('WebSocket token verification error:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { apiKeysDb, credentialsDb } from '../database/db.js';
|
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
||||||
|
import { getPublicKey } from '../services/vapid-keys.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -175,4 +176,70 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// Notification Preferences
|
||||||
|
// ===============================
|
||||||
|
|
||||||
|
router.get('/notification-preferences', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const preferences = notificationPreferencesDb.getPreferences(req.user.id);
|
||||||
|
res.json({ success: true, preferences });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notification preferences:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch notification preferences' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/notification-preferences', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});
|
||||||
|
res.json({ success: true, preferences });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving notification preferences:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to save notification preferences' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// Push Subscription Management
|
||||||
|
// ===============================
|
||||||
|
|
||||||
|
router.get('/push/vapid-public-key', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const publicKey = getPublicKey();
|
||||||
|
res.json({ publicKey });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching VAPID public key:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch VAPID public key' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/push/subscribe', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { endpoint, keys } = req.body;
|
||||||
|
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||||
|
return res.status(400).json({ error: 'Missing subscription fields' });
|
||||||
|
}
|
||||||
|
pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving push subscription:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to save push subscription' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/push/unsubscribe', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { endpoint } = req.body;
|
||||||
|
if (!endpoint) {
|
||||||
|
return res.status(400).json({ error: 'Missing endpoint' });
|
||||||
|
}
|
||||||
|
pushSubscriptionsDb.removeSubscription(endpoint);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing push subscription:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to remove push subscription' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
149
server/services/notification-orchestrator.js
Normal file
149
server/services/notification-orchestrator.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import webPush from 'web-push';
|
||||||
|
import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
||||||
|
|
||||||
|
const KIND_TO_PREF_KEY = {
|
||||||
|
action_required: 'actionRequired',
|
||||||
|
stop: 'stop',
|
||||||
|
error: 'error'
|
||||||
|
};
|
||||||
|
|
||||||
|
const recentEventKeys = new Map();
|
||||||
|
const DEDUPE_WINDOW_MS = 20000;
|
||||||
|
|
||||||
|
const cleanupOldEventKeys = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, timestamp] of recentEventKeys.entries()) {
|
||||||
|
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
||||||
|
recentEventKeys.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function shouldSendPush(preferences, event) {
|
||||||
|
const webPushEnabled = Boolean(preferences?.channels?.webPush);
|
||||||
|
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
|
||||||
|
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
|
||||||
|
|
||||||
|
return webPushEnabled && eventEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicate(event) {
|
||||||
|
cleanupOldEventKeys();
|
||||||
|
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
|
||||||
|
if (recentEventKeys.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
recentEventKeys.set(key, Date.now());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNotificationEvent({
|
||||||
|
provider,
|
||||||
|
sessionId = null,
|
||||||
|
kind = 'info',
|
||||||
|
code = 'generic.info',
|
||||||
|
meta = {},
|
||||||
|
severity = 'info',
|
||||||
|
dedupeKey = null,
|
||||||
|
requiresUserAction = false
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
sessionId,
|
||||||
|
kind,
|
||||||
|
code,
|
||||||
|
meta,
|
||||||
|
severity,
|
||||||
|
requiresUserAction,
|
||||||
|
dedupeKey,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPushBody(event) {
|
||||||
|
const CODE_MAP = {
|
||||||
|
'permission.required': {
|
||||||
|
title: 'Action Required',
|
||||||
|
body: event.meta?.toolName
|
||||||
|
? `Tool "${event.meta.toolName}" needs approval`
|
||||||
|
: 'A tool needs your approval'
|
||||||
|
},
|
||||||
|
'run.stopped': {
|
||||||
|
title: 'Run Stopped',
|
||||||
|
body: event.meta?.stopReason || 'The run has stopped'
|
||||||
|
},
|
||||||
|
'run.failed': {
|
||||||
|
title: 'Run Failed',
|
||||||
|
body: event.meta?.error ? String(event.meta.error) : 'The run encountered an error'
|
||||||
|
},
|
||||||
|
'agent.notification': {
|
||||||
|
title: 'Agent Notification',
|
||||||
|
body: event.meta?.message ? String(event.meta.message) : 'You have a new notification'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapped = CODE_MAP[event.code];
|
||||||
|
return {
|
||||||
|
title: mapped?.title || 'Claude Code UI',
|
||||||
|
body: mapped?.body || 'You have a new notification',
|
||||||
|
data: {
|
||||||
|
sessionId: event.sessionId || null,
|
||||||
|
code: event.code
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendWebPush(userId, event) {
|
||||||
|
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
|
||||||
|
if (!subscriptions.length) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(buildPushBody(event));
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
subscriptions.map((sub) =>
|
||||||
|
webPush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: sub.keys_p256dh,
|
||||||
|
auth: sub.keys_auth
|
||||||
|
}
|
||||||
|
},
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up gone subscriptions (410 Gone or 404)
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
const statusCode = result.reason?.statusCode;
|
||||||
|
if (statusCode === 410 || statusCode === 404) {
|
||||||
|
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyUserIfEnabled({ userId, event }) {
|
||||||
|
if (!userId || !event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||||
|
if (!shouldSendPush(preferences, event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDuplicate(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendWebPush(userId, event).catch((err) => {
|
||||||
|
console.error('Web push send error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
createNotificationEvent,
|
||||||
|
notifyUserIfEnabled
|
||||||
|
};
|
||||||
35
server/services/vapid-keys.js
Normal file
35
server/services/vapid-keys.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import webPush from 'web-push';
|
||||||
|
import { db } from '../database/db.js';
|
||||||
|
|
||||||
|
let cachedKeys = null;
|
||||||
|
|
||||||
|
function ensureVapidKeys() {
|
||||||
|
if (cachedKeys) return cachedKeys;
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get();
|
||||||
|
if (row) {
|
||||||
|
cachedKeys = { publicKey: row.public_key, privateKey: row.private_key };
|
||||||
|
return cachedKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = webPush.generateVAPIDKeys();
|
||||||
|
db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey);
|
||||||
|
cachedKeys = keys;
|
||||||
|
return cachedKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicKey() {
|
||||||
|
return ensureVapidKeys().publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureWebPush() {
|
||||||
|
const keys = ensureVapidKeys();
|
||||||
|
webPush.setVapidDetails(
|
||||||
|
'mailto:noreply@claudecodeui.local',
|
||||||
|
keys.publicKey,
|
||||||
|
keys.privateKey
|
||||||
|
);
|
||||||
|
console.log('Web Push notifications configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ensureVapidKeys, getPublicKey, configureWebPush };
|
||||||
@@ -18,6 +18,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
|
|||||||
'git',
|
'git',
|
||||||
'api',
|
'api',
|
||||||
'tasks',
|
'tasks',
|
||||||
|
'notifications',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
McpServer,
|
McpServer,
|
||||||
McpToolsResult,
|
McpToolsResult,
|
||||||
McpTestResult,
|
McpTestResult,
|
||||||
|
NotificationPreferencesState,
|
||||||
ProjectSortOrder,
|
ProjectSortOrder,
|
||||||
SettingsMainTab,
|
SettingsMainTab,
|
||||||
SettingsProject,
|
SettingsProject,
|
||||||
@@ -94,9 +95,14 @@ type CodexSettingsStorage = {
|
|||||||
permissionMode?: CodexPermissionMode;
|
permissionMode?: CodexPermissionMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NotificationPreferencesResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
preferences?: NotificationPreferencesState;
|
||||||
|
};
|
||||||
|
|
||||||
type ActiveLoginProvider = AgentProvider | '';
|
type ActiveLoginProvider = AgentProvider | '';
|
||||||
|
|
||||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks'];
|
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications'];
|
||||||
|
|
||||||
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||||
// Keep backwards compatibility with older callers that still pass "tools".
|
// Keep backwards compatibility with older callers that still pass "tools".
|
||||||
@@ -184,6 +190,18 @@ const createEmptyCursorPermissions = (): CursorPermissionsState => ({
|
|||||||
...DEFAULT_CURSOR_PERMISSIONS,
|
...DEFAULT_CURSOR_PERMISSIONS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createDefaultNotificationPreferences = (): NotificationPreferencesState => ({
|
||||||
|
channels: {
|
||||||
|
inApp: true,
|
||||||
|
webPush: false,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
actionRequired: true,
|
||||||
|
stop: true,
|
||||||
|
error: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
|
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
|
||||||
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||||
const closeTimerRef = useRef<number | null>(null);
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
@@ -203,6 +221,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
|
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
|
||||||
createEmptyCursorPermissions()
|
createEmptyCursorPermissions()
|
||||||
));
|
));
|
||||||
|
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
|
||||||
|
createDefaultNotificationPreferences()
|
||||||
|
));
|
||||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||||
|
|
||||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||||
@@ -655,6 +676,22 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
);
|
);
|
||||||
setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));
|
setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences');
|
||||||
|
if (notificationResponse.ok) {
|
||||||
|
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
||||||
|
if (notificationData.success && notificationData.preferences) {
|
||||||
|
setNotificationPreferences(notificationData.preferences);
|
||||||
|
} else {
|
||||||
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchMcpServers(),
|
fetchMcpServers(),
|
||||||
fetchCursorMcpServers(),
|
fetchCursorMcpServers(),
|
||||||
@@ -664,6 +701,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
setClaudePermissions(createEmptyClaudePermissions());
|
setClaudePermissions(createEmptyClaudePermissions());
|
||||||
setCursorPermissions(createEmptyCursorPermissions());
|
setCursorPermissions(createEmptyCursorPermissions());
|
||||||
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
setCodexPermissionMode('default');
|
setCodexPermissionMode('default');
|
||||||
setProjectSortOrder('name');
|
setProjectSortOrder('name');
|
||||||
}
|
}
|
||||||
@@ -684,7 +722,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
void checkAuthStatus(loginProvider);
|
void checkAuthStatus(loginProvider);
|
||||||
}, [checkAuthStatus, loginProvider]);
|
}, [checkAuthStatus, loginProvider]);
|
||||||
|
|
||||||
const saveSettings = useCallback(() => {
|
const saveSettings = useCallback(async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveStatus(null);
|
setSaveStatus(null);
|
||||||
|
|
||||||
@@ -710,6 +748,14 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
lastUpdated: now,
|
lastUpdated: now,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(notificationPreferences),
|
||||||
|
});
|
||||||
|
if (!notificationResponse.ok) {
|
||||||
|
throw new Error('Failed to save notification preferences');
|
||||||
|
}
|
||||||
|
|
||||||
setSaveStatus('success');
|
setSaveStatus('success');
|
||||||
if (closeTimerRef.current !== null) {
|
if (closeTimerRef.current !== null) {
|
||||||
window.clearTimeout(closeTimerRef.current);
|
window.clearTimeout(closeTimerRef.current);
|
||||||
@@ -730,6 +776,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
cursorPermissions.allowedCommands,
|
cursorPermissions.allowedCommands,
|
||||||
cursorPermissions.disallowedCommands,
|
cursorPermissions.disallowedCommands,
|
||||||
cursorPermissions.skipPermissions,
|
cursorPermissions.skipPermissions,
|
||||||
|
notificationPreferences,
|
||||||
onClose,
|
onClose,
|
||||||
projectSortOrder,
|
projectSortOrder,
|
||||||
]);
|
]);
|
||||||
@@ -805,6 +852,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
setClaudePermissions,
|
setClaudePermissions,
|
||||||
cursorPermissions,
|
cursorPermissions,
|
||||||
setCursorPermissions,
|
setCursorPermissions,
|
||||||
|
notificationPreferences,
|
||||||
|
setNotificationPreferences,
|
||||||
codexPermissionMode,
|
codexPermissionMode,
|
||||||
setCodexPermissionMode,
|
setCodexPermissionMode,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
|
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications';
|
||||||
export type AgentProvider = 'claude' | 'cursor' | 'codex';
|
export type AgentProvider = 'claude' | 'cursor' | 'codex';
|
||||||
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
||||||
export type ProjectSortOrder = 'name' | 'date';
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
@@ -104,6 +104,18 @@ export type ClaudePermissionsState = {
|
|||||||
skipPermissions: boolean;
|
skipPermissions: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NotificationPreferencesState = {
|
||||||
|
channels: {
|
||||||
|
inApp: boolean;
|
||||||
|
webPush: boolean;
|
||||||
|
};
|
||||||
|
events: {
|
||||||
|
actionRequired: boolean;
|
||||||
|
stop: boolean;
|
||||||
|
error: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type CursorPermissionsState = {
|
export type CursorPermissionsState = {
|
||||||
allowedCommands: string[];
|
allowedCommands: string[];
|
||||||
disallowedCommands: string[];
|
disallowedCommands: string[];
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
|||||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||||
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
||||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||||
|
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
||||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||||
import { useSettingsController } from '../hooks/useSettingsController';
|
import { useSettingsController } from '../hooks/useSettingsController';
|
||||||
|
import { useWebPush } from '../../../hooks/useWebPush';
|
||||||
import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types';
|
import type { AgentProvider, SettingsProject, SettingsProps } from '../types/types';
|
||||||
|
|
||||||
type LoginModalProps = {
|
type LoginModalProps = {
|
||||||
@@ -38,6 +40,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
updateCodeEditorSetting,
|
updateCodeEditorSetting,
|
||||||
claudePermissions,
|
claudePermissions,
|
||||||
setClaudePermissions,
|
setClaudePermissions,
|
||||||
|
notificationPreferences,
|
||||||
|
setNotificationPreferences,
|
||||||
cursorPermissions,
|
cursorPermissions,
|
||||||
setCursorPermissions,
|
setCursorPermissions,
|
||||||
codexPermissionMode,
|
codexPermissionMode,
|
||||||
@@ -79,6 +83,30 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
onClose,
|
onClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
permission: pushPermission,
|
||||||
|
isSubscribed: isPushSubscribed,
|
||||||
|
isLoading: isPushLoading,
|
||||||
|
subscribe: pushSubscribe,
|
||||||
|
unsubscribe: pushUnsubscribe,
|
||||||
|
} = useWebPush();
|
||||||
|
|
||||||
|
const handleEnablePush = async () => {
|
||||||
|
await pushSubscribe();
|
||||||
|
setNotificationPreferences({
|
||||||
|
...notificationPreferences,
|
||||||
|
channels: { ...notificationPreferences.channels, webPush: true },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisablePush = async () => {
|
||||||
|
await pushUnsubscribe();
|
||||||
|
setNotificationPreferences({
|
||||||
|
...notificationPreferences,
|
||||||
|
channels: { ...notificationPreferences.channels, webPush: false },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -164,6 +192,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'notifications' && (
|
||||||
|
<NotificationsSettingsTab
|
||||||
|
notificationPreferences={notificationPreferences}
|
||||||
|
onNotificationPreferencesChange={setNotificationPreferences}
|
||||||
|
pushPermission={pushPermission}
|
||||||
|
isPushSubscribed={isPushSubscribed}
|
||||||
|
isPushLoading={isPushLoading}
|
||||||
|
onEnablePush={handleEnablePush}
|
||||||
|
onDisablePush={handleDisablePush}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'api' && (
|
{activeTab === 'api' && (
|
||||||
<div className="space-y-6 md:space-y-8">
|
<div className="space-y-6 md:space-y-8">
|
||||||
<CredentialsSettingsTab />
|
<CredentialsSettingsTab />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const TAB_CONFIG: MainTabConfig[] = [
|
|||||||
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||||
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
||||||
|
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
||||||
@@ -26,7 +27,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
<div className="flex px-4 md:px-6" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
|
<div className="flex px-4 md:px-6 overflow-x-auto scrollbar-hide" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
|
||||||
{TAB_CONFIG.map((tab) => {
|
{TAB_CONFIG.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const isActive = activeTab === tab.id;
|
const isActive = activeTab === tab.id;
|
||||||
@@ -37,7 +38,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
onClick={() => onChange(tab.id)}
|
onClick={() => onChange(tab.id)}
|
||||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||||
isActive
|
isActive
|
||||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
|||||||
129
src/components/settings/view/tabs/NotificationsSettingsTab.tsx
Normal file
129
src/components/settings/view/tabs/NotificationsSettingsTab.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { Bell } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { NotificationPreferencesState } from '../../types/types';
|
||||||
|
|
||||||
|
type NotificationsSettingsTabProps = {
|
||||||
|
notificationPreferences: NotificationPreferencesState;
|
||||||
|
onNotificationPreferencesChange: (value: NotificationPreferencesState) => void;
|
||||||
|
pushPermission: NotificationPermission | 'unsupported';
|
||||||
|
isPushSubscribed: boolean;
|
||||||
|
isPushLoading: boolean;
|
||||||
|
onEnablePush: () => void;
|
||||||
|
onDisablePush: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotificationsSettingsTab({
|
||||||
|
notificationPreferences,
|
||||||
|
onNotificationPreferencesChange,
|
||||||
|
pushPermission,
|
||||||
|
isPushSubscribed,
|
||||||
|
isPushLoading,
|
||||||
|
onEnablePush,
|
||||||
|
onDisablePush,
|
||||||
|
}: NotificationsSettingsTabProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
const pushSupported = pushPermission !== 'unsupported';
|
||||||
|
const pushDenied = pushPermission === 'denied';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 md:space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Bell className="w-5 h-5 text-blue-600" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
|
||||||
|
{!pushSupported ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
|
||||||
|
) : pushDenied ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
||||||
|
) : (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isPushSubscribed}
|
||||||
|
disabled={isPushLoading}
|
||||||
|
onChange={() => {
|
||||||
|
if (isPushSubscribed) {
|
||||||
|
onDisablePush();
|
||||||
|
} else {
|
||||||
|
onEnablePush();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
{isPushLoading
|
||||||
|
? t('notifications.webPush.loading')
|
||||||
|
: isPushSubscribed
|
||||||
|
? t('notifications.webPush.enabled')
|
||||||
|
: t('notifications.webPush.disabled')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationPreferences.events.actionRequired}
|
||||||
|
onChange={(event) =>
|
||||||
|
onNotificationPreferencesChange({
|
||||||
|
...notificationPreferences,
|
||||||
|
events: {
|
||||||
|
...notificationPreferences.events,
|
||||||
|
actionRequired: event.target.checked,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
{t('notifications.events.actionRequired')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationPreferences.events.stop}
|
||||||
|
onChange={(event) =>
|
||||||
|
onNotificationPreferencesChange({
|
||||||
|
...notificationPreferences,
|
||||||
|
events: {
|
||||||
|
...notificationPreferences.events,
|
||||||
|
stop: event.target.checked,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
{t('notifications.events.stop')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationPreferences.events.error}
|
||||||
|
onChange={(event) =>
|
||||||
|
onNotificationPreferencesChange({
|
||||||
|
...notificationPreferences,
|
||||||
|
events: {
|
||||||
|
...notificationPreferences.events,
|
||||||
|
error: event.target.checked,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
{t('notifications.events.error')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -256,6 +256,7 @@ function ClaudePermissions({
|
|||||||
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
|
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
103
src/hooks/useWebPush.ts
Normal file
103
src/hooks/useWebPush.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { authenticatedFetch } from '../utils/api';
|
||||||
|
|
||||||
|
type WebPushState = {
|
||||||
|
permission: NotificationPermission | 'unsupported';
|
||||||
|
isSubscribed: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
subscribe: () => Promise<void>;
|
||||||
|
unsubscribe: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebPush(): WebPushState {
|
||||||
|
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
|
||||||
|
if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
return Notification.permission;
|
||||||
|
});
|
||||||
|
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Check existing subscription on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (permission === 'unsupported') return;
|
||||||
|
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
registration.pushManager.getSubscription().then((sub) => {
|
||||||
|
setIsSubscribed(sub !== null);
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
// SW not ready yet
|
||||||
|
});
|
||||||
|
}, [permission]);
|
||||||
|
|
||||||
|
const subscribe = useCallback(async () => {
|
||||||
|
if (permission === 'unsupported') return;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const perm = await Notification.requestPermission();
|
||||||
|
setPermission(perm);
|
||||||
|
if (perm !== 'granted') return;
|
||||||
|
|
||||||
|
const keyRes = await authenticatedFetch('/api/settings/push/vapid-public-key');
|
||||||
|
const { publicKey } = await keyRes.json();
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subJson = subscription.toJSON();
|
||||||
|
await authenticatedFetch('/api/settings/push/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: subJson.endpoint,
|
||||||
|
keys: subJson.keys,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSubscribed(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Push subscribe failed:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [permission]);
|
||||||
|
|
||||||
|
const unsubscribe = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (subscription) {
|
||||||
|
const endpoint = subscription.endpoint;
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
await authenticatedFetch('/api/settings/push/unsubscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ endpoint }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsSubscribed(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Push unsubscribe failed:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { permission, isSubscribed, isLoading, subscribe, unsubscribe };
|
||||||
|
}
|
||||||
@@ -191,6 +191,36 @@
|
|||||||
"failedToCreateFolder": "Failed to create folder"
|
"failedToCreateFolder": "Failed to create folder"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"genericTool": "a tool",
|
||||||
|
"codes": {
|
||||||
|
"generic": {
|
||||||
|
"info": {
|
||||||
|
"title": "Notification"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"required": {
|
||||||
|
"title": "Action Required",
|
||||||
|
"body": "{{toolName}} is waiting for your decision."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"stopped": {
|
||||||
|
"title": "Run Stopped",
|
||||||
|
"body": "Reason: {{reason}}"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"title": "Run Failed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"notification": {
|
||||||
|
"title": "Agent Notification"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"versionUpdate": {
|
"versionUpdate": {
|
||||||
"title": "Update Available",
|
"title": "Update Available",
|
||||||
"newVersionReady": "A new version is ready",
|
"newVersionReady": "A new version is ready",
|
||||||
|
|||||||
@@ -88,7 +88,26 @@
|
|||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & Tokens",
|
"apiTokens": "API & Tokens",
|
||||||
"tasks": "Tasks"
|
"tasks": "Tasks",
|
||||||
|
"notifications": "Notifications"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"description": "Control which notification events you receive.",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web Push Notifications",
|
||||||
|
"enabled": "Push notifications are enabled",
|
||||||
|
"disabled": "Enable push notifications",
|
||||||
|
"loading": "Updating...",
|
||||||
|
"unsupported": "Push notifications are not supported in this browser.",
|
||||||
|
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Event Types",
|
||||||
|
"actionRequired": "Action required",
|
||||||
|
"stop": "Run stopped",
|
||||||
|
"error": "Run failed"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
@@ -191,6 +191,36 @@
|
|||||||
"failedToCreateFolder": "フォルダの作成に失敗しました"
|
"failedToCreateFolder": "フォルダの作成に失敗しました"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"genericTool": "ツール",
|
||||||
|
"codes": {
|
||||||
|
"generic": {
|
||||||
|
"info": {
|
||||||
|
"title": "通知"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"required": {
|
||||||
|
"title": "対応が必要です",
|
||||||
|
"body": "{{toolName}} があなたの判断を待っています。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"stopped": {
|
||||||
|
"title": "実行が停止しました",
|
||||||
|
"body": "理由: {{reason}}"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"title": "実行に失敗しました"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"notification": {
|
||||||
|
"title": "エージェント通知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"versionUpdate": {
|
"versionUpdate": {
|
||||||
"title": "アップデートのお知らせ",
|
"title": "アップデートのお知らせ",
|
||||||
"newVersionReady": "新しいバージョンが利用可能です",
|
"newVersionReady": "新しいバージョンが利用可能です",
|
||||||
|
|||||||
@@ -88,7 +88,26 @@
|
|||||||
"appearance": "外観",
|
"appearance": "外観",
|
||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & トークン",
|
"apiTokens": "API & トークン",
|
||||||
"tasks": "タスク"
|
"tasks": "タスク",
|
||||||
|
"notifications": "通知"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "通知",
|
||||||
|
"description": "受信する通知イベントを設定します。",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Webプッシュ通知",
|
||||||
|
"enabled": "プッシュ通知は有効です",
|
||||||
|
"disabled": "プッシュ通知を有効にする",
|
||||||
|
"loading": "更新中...",
|
||||||
|
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||||
|
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "イベント種別",
|
||||||
|
"actionRequired": "対応が必要",
|
||||||
|
"stop": "実行停止",
|
||||||
|
"error": "実行失敗"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
@@ -191,6 +191,36 @@
|
|||||||
"failedToCreateFolder": "폴더 생성 실패"
|
"failedToCreateFolder": "폴더 생성 실패"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"genericTool": "도구",
|
||||||
|
"codes": {
|
||||||
|
"generic": {
|
||||||
|
"info": {
|
||||||
|
"title": "알림"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"required": {
|
||||||
|
"title": "작업 필요",
|
||||||
|
"body": "{{toolName}} 에 대한 결정을 기다리고 있습니다."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"stopped": {
|
||||||
|
"title": "실행이 중지되었습니다",
|
||||||
|
"body": "사유: {{reason}}"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"title": "실행 실패"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"notification": {
|
||||||
|
"title": "에이전트 알림"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"versionUpdate": {
|
"versionUpdate": {
|
||||||
"title": "업데이트 가능",
|
"title": "업데이트 가능",
|
||||||
"newVersionReady": "새 버전이 준비되었습니다",
|
"newVersionReady": "새 버전이 준비되었습니다",
|
||||||
|
|||||||
@@ -88,7 +88,26 @@
|
|||||||
"appearance": "외관",
|
"appearance": "외관",
|
||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & 토큰",
|
"apiTokens": "API & 토큰",
|
||||||
"tasks": "작업"
|
"tasks": "작업",
|
||||||
|
"notifications": "알림"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "알림",
|
||||||
|
"description": "수신할 알림 이벤트를 설정합니다.",
|
||||||
|
"webPush": {
|
||||||
|
"title": "웹 푸시 알림",
|
||||||
|
"enabled": "푸시 알림이 활성화되었습니다",
|
||||||
|
"disabled": "푸시 알림 활성화",
|
||||||
|
"loading": "업데이트 중...",
|
||||||
|
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||||
|
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "이벤트 유형",
|
||||||
|
"actionRequired": "작업 필요",
|
||||||
|
"stop": "실행 중지",
|
||||||
|
"error": "실행 실패"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
@@ -191,6 +191,36 @@
|
|||||||
"failedToCreateFolder": "创建文件夹失败"
|
"failedToCreateFolder": "创建文件夹失败"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"genericTool": "工具",
|
||||||
|
"codes": {
|
||||||
|
"generic": {
|
||||||
|
"info": {
|
||||||
|
"title": "通知"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"required": {
|
||||||
|
"title": "需要处理",
|
||||||
|
"body": "{{toolName}} 正在等待你的决策。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"stopped": {
|
||||||
|
"title": "运行已停止",
|
||||||
|
"body": "原因:{{reason}}"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"title": "运行失败"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"notification": {
|
||||||
|
"title": "Agent 通知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"versionUpdate": {
|
"versionUpdate": {
|
||||||
"title": "有可用更新",
|
"title": "有可用更新",
|
||||||
"newVersionReady": "新版本已准备就绪",
|
"newVersionReady": "新版本已准备就绪",
|
||||||
|
|||||||
@@ -88,7 +88,26 @@
|
|||||||
"appearance": "外观",
|
"appearance": "外观",
|
||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API 和令牌",
|
"apiTokens": "API 和令牌",
|
||||||
"tasks": "任务"
|
"tasks": "任务",
|
||||||
|
"notifications": "通知"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "通知",
|
||||||
|
"description": "控制你希望接收的通知事件。",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web 推送通知",
|
||||||
|
"enabled": "推送通知已启用",
|
||||||
|
"disabled": "启用推送通知",
|
||||||
|
"loading": "更新中...",
|
||||||
|
"unsupported": "此浏览器不支持推送通知。",
|
||||||
|
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "事件类型",
|
||||||
|
"actionRequired": "需要处理",
|
||||||
|
"stop": "运行已停止",
|
||||||
|
"error": "运行失败"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
|
|||||||
10
src/main.jsx
10
src/main.jsx
@@ -7,14 +7,10 @@ import 'katex/dist/katex.min.css'
|
|||||||
// Initialize i18n
|
// Initialize i18n
|
||||||
import './i18n/config.js'
|
import './i18n/config.js'
|
||||||
|
|
||||||
// Clean up stale service workers on app load to prevent caching issues after builds
|
// Register service worker for PWA + Web Push support
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
navigator.serviceWorker.register('/sw.js').catch(err => {
|
||||||
registrations.forEach(registration => {
|
console.warn('Service worker registration failed:', err);
|
||||||
registration.unregister();
|
|
||||||
});
|
|
||||||
}).catch(err => {
|
|
||||||
console.warn('Failed to unregister service workers:', err);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user