diff --git a/electron/desktopNotifications.js b/electron/desktopNotifications.js new file mode 100644 index 00000000..befc4465 --- /dev/null +++ b/electron/desktopNotifications.js @@ -0,0 +1,378 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Notification } from 'electron'; +import WebSocket from 'ws'; + +const RECONNECT_MIN_MS = 1000; +const RECONNECT_MAX_MS = 30000; +const TARGET_REGISTER_TIMEOUT_MS = 8000; + +function toNotificationsWsUrl(httpUrl) { + try { + const parsed = new URL(httpUrl); + parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:'; + parsed.pathname = '/desktop-notifications'; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return null; + } +} + +function readJsonMessage(raw) { + try { + return JSON.parse(String(raw)); + } catch { + return null; + } +} + +async function requestJson(url, { method = 'POST', body = null, headers = {} } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TARGET_REGISTER_TIMEOUT_MS); + try { + const response = await fetch(url, { + method, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + ...(body == null ? {} : { body: JSON.stringify(body) }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || `Request failed with status ${response.status}`); + } + return payload; + } finally { + clearTimeout(timeout); + } +} + +export class DesktopNotificationsController { + constructor({ + settingsPath, + appVersion, + appName, + getDeviceId, + getAccountEmail, + getRunningEnvironmentUrls, + getApiKey, + getAuthToken, + getIconPath, + openNotificationTarget, + onChange, + }) { + this.settingsPath = settingsPath; + this.appVersion = appVersion; + this.appName = appName; + this.getDeviceId = getDeviceId; + this.getAccountEmail = getAccountEmail; + this.getRunningEnvironmentUrls = getRunningEnvironmentUrls; + this.getApiKey = getApiKey; + this.getAuthToken = getAuthToken; + this.getIconPath = getIconPath; + this.openNotificationTarget = openNotificationTarget; + this.onChange = onChange; + this.settings = { enabled: false }; + this.connections = new Map(); + this.lastEvent = null; + this.lastError = null; + } + + getState() { + const connectedTargets = []; + for (const [url, connection] of this.connections.entries()) { + if (connection.ws?.readyState === WebSocket.OPEN) { + connectedTargets.push(url); + } + } + + return { + enabled: this.settings.enabled, + supported: Notification.isSupported(), + targetCount: this.connections.size, + connectedCount: connectedTargets.length, + connectedTargets, + lastEvent: this.lastEvent, + lastError: this.lastError, + }; + } + + async loadSettings() { + try { + const raw = await fs.readFile(this.settingsPath, 'utf8'); + const stored = JSON.parse(raw); + this.settings = { enabled: Boolean(stored.enabled) }; + } catch { + this.settings = { enabled: false }; + } + return this.settings; + } + + async saveSettings(next) { + const enabled = Boolean(next?.enabled); + if (!enabled && this.settings.enabled) { + await this.disableCurrentTargets(); + } + this.settings = { enabled }; + await fs.mkdir(path.dirname(this.settingsPath), { recursive: true }); + await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8'); + await this.sync(); + this.onChange?.(); + return this.settings; + } + + async sync() { + if (!this.settings.enabled) { + this.stop(); + this.lastEvent = 'disabled'; + this.onChange?.(); + return; + } + + if (!Notification.isSupported()) { + this.stop(); + this.lastEvent = 'unsupported'; + this.lastError = 'Native notifications are not supported on this system.'; + this.onChange?.(); + return; + } + + const deviceId = this.getDeviceId?.(); + if (!deviceId) { + this.stop(); + this.lastEvent = 'missing-device'; + this.lastError = 'Connect a CloudCLI account before enabling desktop notifications.'; + this.onChange?.(); + return; + } + + const targets = (this.getRunningEnvironmentUrls?.() || []) + .map((httpUrl) => ({ + httpUrl, + wsUrl: toNotificationsWsUrl(httpUrl), + })) + .filter((target) => target.wsUrl); + + const nextWsUrls = new Set(targets.map((target) => target.wsUrl)); + for (const [wsUrl, connection] of this.connections.entries()) { + if (!nextWsUrls.has(wsUrl)) { + this.closeConnection(connection); + this.connections.delete(wsUrl); + } + } + + for (const target of targets) { + if (!this.connections.has(target.wsUrl)) { + void this.connect(target).catch((error) => { + this.lastEvent = 'connect-error'; + this.lastError = error instanceof Error ? error.message : String(error); + this.onChange?.(); + }); + } + } + + this.lastEvent = targets.length ? 'sync' : 'no-targets'; + this.onChange?.(); + } + + async connect(target, attempt = 0) { + const existing = this.connections.get(target.wsUrl); + if (existing?.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(existing.ws.readyState)) { + return; + } + + const connection = { + ...target, + ws: null, + reconnectTimer: null, + closed: false, + attempt, + }; + this.connections.set(target.wsUrl, connection); + + const headers = await this.getTargetAuthHeaders(target.httpUrl); + if (connection.closed || this.connections.get(target.wsUrl) !== connection) { + return; + } + + const ws = new WebSocket(target.wsUrl, { headers: Object.keys(headers).length ? headers : undefined }); + connection.ws = ws; + + ws.on('open', async () => { + try { + await this.registerTarget(target.httpUrl); + ws.send(JSON.stringify({ + type: 'register', + deviceId: this.getDeviceId?.(), + label: this.getAccountEmail?.() || this.appName, + platform: process.platform, + appVersion: this.appVersion, + })); + connection.attempt = 0; + this.lastEvent = 'connected'; + this.lastError = null; + this.onChange?.(); + } catch (error) { + this.lastEvent = 'register-error'; + this.lastError = error instanceof Error ? error.message : String(error); + this.onChange?.(); + try { ws.close(); } catch {} + } + }); + + ws.on('message', (raw) => this.handleMessage(target, ws, raw)); + ws.on('close', () => this.scheduleReconnect(target.wsUrl)); + ws.on('error', (error) => { + this.lastEvent = 'socket-error'; + this.lastError = error instanceof Error ? error.message : String(error); + this.onChange?.(); + }); + } + + async registerTarget(httpUrl) { + const url = new URL('/api/notifications/endpoints/current', httpUrl).toString(); + await requestJson(url, { + method: 'POST', + headers: await this.getTargetAuthHeaders(httpUrl), + body: { + channel: 'desktop', + endpointId: this.getDeviceId?.(), + label: this.getAccountEmail?.() || this.appName, + metadata: { + platform: process.platform, + appVersion: this.appVersion, + }, + enabled: true, + }, + }); + } + + async disableCurrentTargets() { + const deviceId = this.getDeviceId?.(); + if (!deviceId) return; + + const targets = new Set([ + ...[...this.connections.values()].map((connection) => connection.httpUrl).filter(Boolean), + ...(this.getRunningEnvironmentUrls?.() || []), + ]); + + const results = await Promise.allSettled([...targets].map(async (httpUrl) => { + const url = new URL(`/api/notifications/endpoints/desktop/${encodeURIComponent(deviceId)}`, httpUrl).toString(); + await requestJson(url, { + method: 'PATCH', + headers: await this.getTargetAuthHeaders(httpUrl), + body: { enabled: false }, + }); + })); + + const rejected = results.find((result) => result.status === 'rejected'); + if (rejected) { + this.lastEvent = 'disable-endpoint-error'; + this.lastError = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason); + } + } + + async getTargetAuthHeaders(httpUrl) { + const headers = {}; + const apiKey = this.getApiKey?.(); + if (apiKey) { + headers['X-API-Key'] = apiKey; + } + + const authToken = await Promise.resolve(this.getAuthToken?.(httpUrl)).catch(() => null); + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + return headers; + } + + handleMessage(target, ws, raw) { + const message = readJsonMessage(raw); + if (!message || message.type !== 'notification' || !message.payload) { + return; + } + + const shown = this.showNativeNotification(target, message.payload); + if (shown && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'notification_ack', + id: message.id || message.payload?.data?.tag || null, + action: 'shown', + })); + } + } + + showNativeNotification(target, payload) { + if (!Notification.isSupported()) return false; + + const notification = new Notification({ + title: payload.title || this.appName, + body: payload.body || '', + icon: this.getIconPath?.(), + silent: false, + }); + + notification.on('click', () => { + void this.openNotificationTarget?.({ + environmentUrl: target.httpUrl, + sessionId: payload.data?.sessionId || null, + provider: payload.data?.provider || null, + }).catch((error) => { + this.lastEvent = 'click-error'; + this.lastError = error instanceof Error ? error.message : String(error); + this.onChange?.(); + }); + }); + + notification.show(); + this.lastEvent = 'notification-shown'; + this.lastError = null; + this.onChange?.(); + return true; + } + + scheduleReconnect(wsUrl) { + const connection = this.connections.get(wsUrl); + if (!connection || connection.closed || !this.settings.enabled) { + return; + } + + const attempt = connection.attempt + 1; + connection.attempt = attempt; + const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * (2 ** Math.min(attempt, 5))); + connection.reconnectTimer = setTimeout(() => { + if (!this.connections.has(wsUrl) || !this.settings.enabled) return; + void this.connect({ + httpUrl: connection.httpUrl, + wsUrl: connection.wsUrl, + }, attempt).catch((error) => { + this.lastEvent = 'connect-error'; + this.lastError = error instanceof Error ? error.message : String(error); + this.onChange?.(); + }); + }, delay); + this.lastEvent = 'reconnecting'; + this.onChange?.(); + } + + closeConnection(connection) { + connection.closed = true; + if (connection.reconnectTimer) { + clearTimeout(connection.reconnectTimer); + connection.reconnectTimer = null; + } + try { connection.ws?.close(); } catch {} + } + + stop() { + for (const connection of this.connections.values()) { + this.closeConnection(connection); + } + this.connections.clear(); + this.onChange?.(); + } +} diff --git a/electron/desktopWindow.js b/electron/desktopWindow.js index 4e66c068..e186d202 100644 --- a/electron/desktopWindow.js +++ b/electron/desktopWindow.js @@ -3,6 +3,7 @@ import { BrowserWindow, Menu, Tray, clipboard, nativeImage, nativeTheme, session import { ViewHost } from './viewHost.js'; const TITLEBAR_HEIGHT = 44; +const AUTH_TOKEN_STORAGE_KEY = 'auth-token'; // TODO: Re-enable Computer Use menus after fixing the MCP server connection // between the desktop app and the web UI. const COMPUTER_USE_MENUS_ENABLED = false; @@ -249,6 +250,16 @@ export class DesktopWindowManager { return this.getDesktopState(); } + async navigateActiveView(url) { + const navigated = await this.viewHost.navigateActiveView(url); + this.emitDesktopState(); + return navigated; + } + + async readAuthTokenForTarget(url) { + return this.viewHost.readLocalStorageValueForOrigin(url, AUTH_TOKEN_STORAGE_KEY); + } + openActiveTabDevTools() { if (this.viewHost.openActiveViewDevTools()) return; void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before opening active tab DevTools.')); diff --git a/electron/main.js b/electron/main.js index f2808faf..9a519e9f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { CloudController } from './cloud.js'; import { ComputerAgentController } from './computerAgent.js'; import { DesktopWindowManager } from './desktopWindow.js'; +import { DesktopNotificationsController } from './desktopNotifications.js'; import { LocalServerController } from './localServer.js'; import { TabsController } from './tabs.js'; @@ -30,6 +31,7 @@ let desktopWindow = null; let localServer = null; let cloud = null; let computerAgent = null; +let desktopNotifications = null; let isQuitting = false; let isRefreshingCloud = false; let pendingCloudConnectStartedAt = 0; @@ -65,6 +67,10 @@ function getComputerUseSettingsPath() { return path.join(app.getPath('userData'), 'computer-use-settings.json'); } +function getDesktopNotificationsSettingsPath() { + return path.join(app.getPath('userData'), 'desktop-notifications-settings.json'); +} + function getRunningEnvironmentUrls() { return cloud.getEnvironments() .filter((environment) => environment.status === 'running') @@ -146,6 +152,7 @@ function getDesktopState() { activeTabId: tabs.activeTabId, environments: cloud.getEnvironments().map(serializeEnvironment), computerUse: computerAgent?.getState() || { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 }, + desktopNotifications: desktopNotifications?.getState() || { enabled: false, supported: false, connectedCount: 0, targetCount: 0 }, computerUsePermissions: getComputerUsePermissions(), }; } @@ -364,6 +371,7 @@ async function refreshCloudEnvironments({ showErrors = false } = {}) { } finally { isRefreshingCloud = false; void computerAgent?.sync().catch((error) => console.error('[ComputerAgent] sync failed:', error?.message || error)); + void desktopNotifications?.sync().catch((error) => console.error('[DesktopNotifications] sync failed:', error?.message || error)); syncDesktopState(); } } @@ -718,8 +726,57 @@ async function openEnvironmentInDesktop(environment) { return getDesktopState(); } +function findEnvironmentByUrl(environmentUrl) { + const targetOrigin = (() => { + try { + return new URL(environmentUrl).origin; + } catch { + return null; + } + })(); + if (!targetOrigin) return null; + + return cloud.getEnvironments().find((environment) => { + try { + return new URL(cloud.getEnvironmentUrl(environment)).origin === targetOrigin; + } catch { + return false; + } + }) || null; +} + +async function openNotificationTarget({ environmentUrl, sessionId = null }) { + const window = desktopWindow?.getMainWindow(); + if (window) { + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); + } + + const environment = findEnvironmentByUrl(environmentUrl); + if (environment) { + await openEnvironmentInDesktop(environment); + } else { + const parsed = new URL(environmentUrl); + await desktopWindow.showTarget({ + kind: 'remote', + name: parsed.hostname, + url: parsed.origin, + }); + } + + const targetUrl = new URL(sessionId ? `/session/${encodeURIComponent(sessionId)}` : '/', environmentUrl).toString(); + await desktopWindow.navigateActiveView(targetUrl); + return getDesktopState(); +} + +async function getEnvironmentAuthToken(environmentUrl) { + return desktopWindow?.readAuthTokenForTarget(environmentUrl) || null; +} + async function clearCloudAccount() { await cloud.clearCloudAccount(); + desktopNotifications?.stop(); const removedTabs = tabs.removeByKind('remote'); for (const tab of removedTabs) { desktopWindow?.destroyTabView(tab.id); @@ -800,6 +857,10 @@ function registerIpcHandlers() { return getDesktopState(); }); ipcMain.handle('cloudcli-desktop:update-computer-use', async (_event, settings) => updateComputerUse(settings)); + ipcMain.handle('cloudcli-desktop:update-desktop-notifications', async (_event, settings) => { + await desktopNotifications?.saveSettings(settings); + return getDesktopState(); + }); ipcMain.handle('cloudcli-desktop:request-computer-use-permission', async (_event, permission) => requestComputerUsePermission(permission)); ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings()); ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings()); @@ -839,6 +900,7 @@ function registerAppEvents() { app.on('before-quit', () => { computerAgent?.stop(); + desktopNotifications?.stop(); }); app.on('before-quit', (event) => { @@ -896,6 +958,7 @@ async function createDesktopWindow() { stopEnvironment, updateDesktopSetting, copyLocalWebUrl, + openNotificationTarget, }, }); @@ -963,10 +1026,24 @@ async function bootstrap() { promptConsent: promptComputerUseConsent, onChange: syncDesktopState, }); + desktopNotifications = new DesktopNotificationsController({ + settingsPath: getDesktopNotificationsSettingsPath(), + appVersion: app.getVersion(), + appName: APP_NAME, + getDeviceId: () => cloud.getAccount()?.deviceId || '', + getAccountEmail: () => cloud.getAccount()?.email || null, + getRunningEnvironmentUrls, + getApiKey: () => cloud.getAccount()?.apiKey || '', + getAuthToken: getEnvironmentAuthToken, + getIconPath: getWindowIconPath, + openNotificationTarget, + onChange: syncDesktopState, + }); await localServer.loadDesktopSettings(); await cloud.loadCloudAccount(); await computerAgent.loadSettings(); + await desktopNotifications.loadSettings(); registerProtocolHandler(); registerIpcHandlers(); diff --git a/electron/preload.cjs b/electron/preload.cjs index 82b0affc..0a7c93d6 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -1,5 +1,33 @@ const { contextBridge, ipcRenderer } = require('electron'); +function isCloudCliAppOrigin(location) { + if (location.protocol === 'file:') return true; + + if (location.protocol === 'http:') { + return location.hostname === '127.0.0.1' || location.hostname === 'localhost'; + } + + return location.protocol === 'https:' && ( + location.hostname === 'cloudcli.ai' || location.hostname.endsWith('.cloudcli.ai') + ); +} + +function onDesktopStateUpdated(callback) { + const listener = (_event, state) => callback(state); + ipcRenderer.on('cloudcli-desktop:state-updated', listener); + return () => { + ipcRenderer.removeListener('cloudcli-desktop:state-updated', listener); + }; +} + +if (isCloudCliAppOrigin(window.location)) { + contextBridge.exposeInMainWorld('cloudcliDesktopNotifications', { + getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'), + update: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-desktop-notifications', settings), + onStateUpdated: onDesktopStateUpdated, + }); +} + if (window.location.protocol === 'file:') { contextBridge.exposeInMainWorld('cloudcliDesktop', { connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'), @@ -27,9 +55,7 @@ if (window.location.protocol === 'file:') { switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId), closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId), updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value), - onStateUpdated: (callback) => { - ipcRenderer.on('cloudcli-desktop:state-updated', (_event, state) => callback(state)); - }, + onStateUpdated: onDesktopStateUpdated, onLauncherCommand: (callback) => { ipcRenderer.on('cloudcli-desktop:launcher-command', (_event, command) => callback(command)); }, diff --git a/electron/viewHost.js b/electron/viewHost.js index 43679a51..afec270c 100644 --- a/electron/viewHost.js +++ b/electron/viewHost.js @@ -135,6 +135,38 @@ export class ViewHost { return true; } + async readLocalStorageValueForOrigin(originUrl, key) { + let targetOrigin; + try { + targetOrigin = new URL(originUrl).origin; + } catch { + return null; + } + + for (const view of this.tabViews.values()) { + if (!view || view.webContents.isDestroyed()) continue; + let viewOrigin; + try { + viewOrigin = new URL(view.webContents.getURL()).origin; + } catch { + continue; + } + if (viewOrigin !== targetOrigin) continue; + + try { + const value = await view.webContents.executeJavaScript( + `window.localStorage.getItem(${JSON.stringify(key)})`, + true + ); + return typeof value === 'string' && value ? value : null; + } catch { + return null; + } + } + + return null; + } + getTabViewDiagnostics() { const mainWindow = this.getMainWindow(); const attachedViews = new Set(); @@ -257,6 +289,15 @@ export class ViewHost { return true; } + async navigateActiveView(url) { + const view = this.getActiveView(); + if (!view) return false; + await loadUrlWithTimeout(view.webContents, url); + view.__cloudcliLoadedUrl = url; + view.__cloudcliStartupHtml = null; + return true; + } + destroyTabView(tabId) { const view = this.tabViews.get(tabId); if (!view) return; diff --git a/server/index.js b/server/index.js index f3b95716..05d9e559 100755 --- a/server/index.js +++ b/server/index.js @@ -57,6 +57,7 @@ import commandsRoutes from './routes/commands.js'; import settingsRoutes from './routes/settings.js'; import agentRoutes from './routes/agent.js'; import projectModuleRoutes from './modules/projects/projects.routes.js'; +import notificationRoutes from './modules/notifications/notifications.routes.js'; import userRoutes from './routes/user.js'; import geminiRoutes from './routes/gemini.js'; import pluginsRoutes from './routes/plugins.js'; @@ -78,7 +79,6 @@ const __dirname = getModuleDir(import.meta.url); // The server source runs from /server, while the compiled output runs from /dist-server/server. // Resolving the app root once keeps every repo-level lookup below aligned across both layouts. const APP_ROOT = findAppRoot(__dirname); -const packageJson = JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')); const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; // Version of the code that is actually running, captured once at process // startup. This intentionally does NOT re-read package.json per request: after @@ -172,7 +172,6 @@ app.use(express.urlencoded({ limit: '50mb', extended: true })); app.get('/health', (req, res) => { res.json({ status: 'ok', - version: packageJson.version, timestamp: new Date().toISOString(), installMode, version: RUNNING_VERSION @@ -206,6 +205,8 @@ app.use('/api/commands', authenticateToken, commandsRoutes); // Settings API Routes (protected) app.use('/api/settings', authenticateToken, settingsRoutes); +app.use('/api/notifications', authenticateToken, notificationRoutes); + // User API Routes (protected) app.use('/api/user', authenticateToken, userRoutes); diff --git a/server/modules/database/index.ts b/server/modules/database/index.ts index da87740c..104e17ac 100644 --- a/server/modules/database/index.ts +++ b/server/modules/database/index.ts @@ -4,6 +4,7 @@ export { apiKeysDb } from '@/modules/database/repositories/api-keys.js'; export { appConfigDb } from '@/modules/database/repositories/app-config.js'; export { credentialsDb } from '@/modules/database/repositories/credentials.js'; export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js'; +export { notificationChannelEndpointsDb } from '@/modules/database/repositories/notification-channel-endpoints.js'; export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js'; export { projectsDb } from '@/modules/database/repositories/projects.db.js'; export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js'; diff --git a/server/modules/database/migrations.ts b/server/modules/database/migrations.ts index 05db26bb..4d234690 100644 --- a/server/modules/database/migrations.ts +++ b/server/modules/database/migrations.ts @@ -3,6 +3,7 @@ import { Database } from 'better-sqlite3'; import { APP_CONFIG_TABLE_SCHEMA_SQL, LAST_SCANNED_AT_SQL, + NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL, PROJECTS_TABLE_SCHEMA_SQL, PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL, SESSIONS_TABLE_SCHEMA_SQL, @@ -440,6 +441,9 @@ export const runMigrations = (db: Database) => { db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL); db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL); db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)'); + db.exec(NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL); + db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled)'); db.exec(PROJECTS_TABLE_SCHEMA_SQL); rebuildProjectsTableWithPrimaryKeySchema(db); diff --git a/server/modules/database/repositories/notification-channel-endpoints.ts b/server/modules/database/repositories/notification-channel-endpoints.ts new file mode 100644 index 00000000..f35eb84e --- /dev/null +++ b/server/modules/database/repositories/notification-channel-endpoints.ts @@ -0,0 +1,153 @@ +import { getConnection } from '@/modules/database/connection.js'; + +type NotificationChannelEndpointRow = { + id: number; + user_id: number; + channel: string; + endpoint_id: string; + label: string | null; + metadata_json: string | null; + enabled: number; + last_seen_at: string; + created_at: string; + updated_at: string; +}; + +type UpsertNotificationChannelEndpointInput = { + userId: number; + channel: string; + endpointId: string; + label?: string | null; + metadata?: Record | null; + enabled?: boolean; +}; + +function normalizeRequiredText(value: unknown): string { + if (typeof value !== 'string') return ''; + return value.trim(); +} + +function normalizeNullableText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim(); + return normalized || null; +} + +function serializeMetadata(metadata: Record | null | undefined): string | null { + if (!metadata || typeof metadata !== 'object') return null; + return JSON.stringify(metadata); +} + +function parseMetadata(metadataJson: string | null): Record { + if (!metadataJson) return {}; + try { + const parsed = JSON.parse(metadataJson); + return parsed && typeof parsed === 'object' ? parsed as Record : {}; + } catch { + return {}; + } +} + +export const notificationChannelEndpointsDb = { + upsertEndpoint(input: UpsertNotificationChannelEndpointInput): NotificationChannelEndpointRow { + const channel = normalizeRequiredText(input.channel); + const endpointId = normalizeRequiredText(input.endpointId); + if (!channel) throw new Error('channel is required'); + if (!endpointId) throw new Error('endpointId is required'); + + const enabled = input.enabled === false ? 0 : 1; + const db = getConnection(); + db.prepare( + `INSERT INTO notification_channel_endpoints ( + user_id, + channel, + endpoint_id, + label, + metadata_json, + enabled, + last_seen_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(user_id, channel, endpoint_id) DO UPDATE SET + label = excluded.label, + metadata_json = excluded.metadata_json, + enabled = excluded.enabled, + last_seen_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP` + ).run( + input.userId, + channel, + endpointId, + normalizeNullableText(input.label), + serializeMetadata(input.metadata), + enabled + ); + + return notificationChannelEndpointsDb.getEndpoint(input.userId, channel, endpointId)!; + }, + + getEndpoint(userId: number, channel: string, endpointId: string): NotificationChannelEndpointRow | null { + const db = getConnection(); + const row = db.prepare( + `SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at + FROM notification_channel_endpoints + WHERE user_id = ? AND channel = ? AND endpoint_id = ?` + ).get( + userId, + normalizeRequiredText(channel), + normalizeRequiredText(endpointId) + ) as NotificationChannelEndpointRow | undefined; + return row || null; + }, + + getEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] { + const db = getConnection(); + return db.prepare( + `SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at + FROM notification_channel_endpoints + WHERE user_id = ? AND channel = ? + ORDER BY last_seen_at DESC` + ).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[]; + }, + + getEnabledEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] { + const db = getConnection(); + return db.prepare( + `SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at + FROM notification_channel_endpoints + WHERE user_id = ? AND channel = ? AND enabled = 1 + ORDER BY last_seen_at DESC` + ).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[]; + }, + + setEndpointEnabled(userId: number, channel: string, endpointId: string, enabled: boolean): boolean { + const db = getConnection(); + const result = db.prepare( + `UPDATE notification_channel_endpoints + SET enabled = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? AND channel = ? AND endpoint_id = ?` + ).run(enabled ? 1 : 0, userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId)); + return result.changes > 0; + }, + + touchEndpoint(userId: number, channel: string, endpointId: string): boolean { + const db = getConnection(); + const result = db.prepare( + `UPDATE notification_channel_endpoints + SET last_seen_at = CURRENT_TIMESTAMP + WHERE user_id = ? AND channel = ? AND endpoint_id = ?` + ).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId)); + return result.changes > 0; + }, + + removeEndpoint(userId: number, channel: string, endpointId: string): boolean { + const db = getConnection(); + const result = db.prepare( + 'DELETE FROM notification_channel_endpoints WHERE user_id = ? AND channel = ? AND endpoint_id = ?' + ).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId)); + return result.changes > 0; + }, + + parseMetadata, +}; diff --git a/server/modules/database/repositories/notification-preferences.ts b/server/modules/database/repositories/notification-preferences.ts index 469b58f0..1406b8e9 100644 --- a/server/modules/database/repositories/notification-preferences.ts +++ b/server/modules/database/repositories/notification-preferences.ts @@ -10,7 +10,9 @@ type NotificationPreferences = { channels: { inApp: boolean; webPush: boolean; + desktop: boolean; sound: boolean; + [key: string]: boolean; }; events: { actionRequired: boolean; @@ -23,6 +25,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { channels: { inApp: false, webPush: false, + desktop: false, sound: true, }, events: { @@ -34,11 +37,20 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { function normalizeNotificationPreferences(value: unknown): NotificationPreferences { const source = value && typeof value === 'object' ? (value as Record) : {}; + const sourceChannels = source.channels && typeof source.channels === 'object' + ? source.channels as Record + : {}; + const extraChannels = Object.fromEntries( + Object.entries(sourceChannels) + .filter(([key, channelValue]) => !['inApp', 'webPush', 'desktop', 'sound'].includes(key) && typeof channelValue === 'boolean') + ) as Record; return { channels: { + ...extraChannels, inApp: source.channels?.inApp === true, webPush: source.channels?.webPush === true, + desktop: source.channels?.desktop === true, sound: source.channels?.sound !== false, }, events: { @@ -103,4 +115,3 @@ export const notificationPreferencesDb = { return notificationPreferencesDb.updateNotificationPreferences(userId, preferences); }, }; - diff --git a/server/modules/database/schema.ts b/server/modules/database/schema.ts index f4cc1b53..a02cfeba 100644 --- a/server/modules/database/schema.ts +++ b/server/modules/database/schema.ts @@ -69,6 +69,23 @@ CREATE TABLE IF NOT EXISTS push_subscriptions ( ); `; +export const NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS notification_channel_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + channel TEXT NOT NULL, + endpoint_id TEXT NOT NULL, + label TEXT, + metadata_json TEXT, + enabled BOOLEAN DEFAULT 1, + last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, channel, endpoint_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +`; + export const PROJECTS_TABLE_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS projects ( project_id TEXT PRIMARY KEY NOT NULL, @@ -144,6 +161,10 @@ ${VAPID_KEYS_TABLE_SCHEMA_SQL} ${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL} CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id); +${NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL} +CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel); +CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled); + ${PROJECTS_TABLE_SCHEMA_SQL} -- NOTE: These indexes are created in migrations after legacy table-shape repairs. -- Creating them here can fail on upgraded installs where projects lacks those columns. diff --git a/server/modules/notifications/index.ts b/server/modules/notifications/index.ts new file mode 100644 index 00000000..8607e57e --- /dev/null +++ b/server/modules/notifications/index.ts @@ -0,0 +1,13 @@ +export { + buildNotificationPayload, + createNotificationEvent, + notifyUserIfEnabled, + notifyRunFailed, + notifyRunStopped, +} from '@/modules/notifications/services/notification-orchestrator.service.js'; +export { + registerDesktopNotificationClient, + sendDesktopNotification, + unregisterDesktopNotificationClient, +} from '@/modules/notifications/services/desktop-notification-clients.service.js'; +export { handleDesktopNotificationsConnection } from '@/modules/notifications/websocket/desktop-notifications-websocket.service.js'; diff --git a/server/modules/notifications/notifications.routes.ts b/server/modules/notifications/notifications.routes.ts new file mode 100644 index 00000000..1f85616b --- /dev/null +++ b/server/modules/notifications/notifications.routes.ts @@ -0,0 +1,127 @@ +import express from 'express'; + +import { notificationChannelEndpointsDb, notificationPreferencesDb } from '@/modules/database/index.js'; + +const router = express.Router(); + +function readText(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function sanitizeEndpoint(endpoint: any) { + return { + id: endpoint.id, + channel: endpoint.channel, + endpointId: endpoint.endpoint_id, + label: endpoint.label, + metadata: notificationChannelEndpointsDb.parseMetadata(endpoint.metadata_json), + enabled: Boolean(endpoint.enabled), + lastSeenAt: endpoint.last_seen_at, + createdAt: endpoint.created_at, + updatedAt: endpoint.updated_at, + }; +} + +function readUserId(req: express.Request): number { + const userId = Number((req as any).user?.id); + if (!Number.isInteger(userId) || userId <= 0) { + throw new Error('Authenticated user is missing'); + } + return userId; +} + +function updateChannelPreference(userId: number, channel: string): unknown { + const currentPrefs = notificationPreferencesDb.getPreferences(userId); + const hasEnabledEndpoint = notificationChannelEndpointsDb.getEnabledEndpoints(userId, channel).length > 0; + return notificationPreferencesDb.updatePreferences(userId, { + ...currentPrefs, + channels: { ...currentPrefs.channels, [channel]: hasEnabledEndpoint }, + }); +} + +router.get('/endpoints', (req, res) => { + try { + const channel = readText(req.query.channel); + if (!channel) { + return res.status(400).json({ error: 'channel is required' }); + } + + const userId = readUserId(req); + const endpoints = notificationChannelEndpointsDb + .getEndpoints(userId, channel) + .map(sanitizeEndpoint); + return res.json({ success: true, endpoints }); + } catch (error) { + console.error('Error fetching notification endpoints:', error); + return res.status(500).json({ error: 'Failed to fetch notification endpoints' }); + } +}); + +router.post('/endpoints/current', (req, res) => { + try { + const { channel, endpointId, label, metadata = {}, enabled = true } = req.body || {}; + const normalizedChannel = readText(channel); + const normalizedEndpointId = readText(endpointId); + if (!normalizedChannel || !normalizedEndpointId) { + return res.status(400).json({ error: 'channel and endpointId are required' }); + } + + const userId = readUserId(req); + const endpoint = notificationChannelEndpointsDb.upsertEndpoint({ + userId, + channel: normalizedChannel, + endpointId: normalizedEndpointId, + label, + metadata: metadata && typeof metadata === 'object' ? metadata : {}, + enabled: enabled !== false, + }); + + const preferences = updateChannelPreference(userId, normalizedChannel); + return res.json({ success: true, endpoint: sanitizeEndpoint(endpoint), preferences }); + } catch (error) { + console.error('Error registering notification endpoint:', error); + return res.status(500).json({ error: 'Failed to register notification endpoint' }); + } +}); + +router.patch('/endpoints/:channel/:endpointId', (req, res) => { + try { + const { channel, endpointId } = req.params; + const { enabled } = req.body || {}; + if (typeof enabled !== 'boolean') { + return res.status(400).json({ error: 'enabled must be a boolean' }); + } + + const userId = readUserId(req); + const updated = notificationChannelEndpointsDb.setEndpointEnabled(userId, channel, endpointId, enabled); + if (!updated) { + return res.status(404).json({ error: 'Notification endpoint not found' }); + } + + const endpoint = notificationChannelEndpointsDb.getEndpoint(userId, channel, endpointId); + const preferences = updateChannelPreference(userId, channel); + return res.json({ success: true, endpoint: endpoint ? sanitizeEndpoint(endpoint) : null, preferences }); + } catch (error) { + console.error('Error updating notification endpoint:', error); + return res.status(500).json({ error: 'Failed to update notification endpoint' }); + } +}); + +router.delete('/endpoints/:channel/:endpointId', (req, res) => { + try { + const { channel, endpointId } = req.params; + const userId = readUserId(req); + const removed = notificationChannelEndpointsDb.removeEndpoint(userId, channel, endpointId); + if (!removed) { + return res.status(404).json({ error: 'Notification endpoint not found' }); + } + + const preferences = updateChannelPreference(userId, channel); + return res.json({ success: true, preferences }); + } catch (error) { + console.error('Error removing notification endpoint:', error); + return res.status(500).json({ error: 'Failed to remove notification endpoint' }); + } +}); + +export default router; diff --git a/server/modules/notifications/services/desktop-notification-clients.service.ts b/server/modules/notifications/services/desktop-notification-clients.service.ts new file mode 100644 index 00000000..3d4c8969 --- /dev/null +++ b/server/modules/notifications/services/desktop-notification-clients.service.ts @@ -0,0 +1,124 @@ +import type { WebSocket } from 'ws'; + +import { notificationChannelEndpointsDb } from '@/modules/database/index.js'; + +const DESKTOP_CHANNEL = 'desktop'; + +const clientsByUserId = new Map>(); +const clientBySocket = new WeakMap(); + +function normalizeUserId(userId: unknown): number | null { + const numeric = Number(userId); + return Number.isInteger(numeric) && numeric > 0 ? numeric : null; +} + +function normalizeEndpointId(endpointId: unknown): string { + if (typeof endpointId !== 'string') return ''; + return endpointId.trim(); +} + +function getUserClients(userId: unknown, create = false): Map | null { + const normalizedUserId = normalizeUserId(userId); + if (!normalizedUserId) return null; + let clients = clientsByUserId.get(normalizedUserId); + if (!clients && create) { + clients = new Map(); + clientsByUserId.set(normalizedUserId, clients); + } + return clients || null; +} + +export function registerDesktopNotificationClient({ + userId, + deviceId, + label = null, + platform = null, + appVersion = null, + ws, +}: { + userId: number; + deviceId: string; + label?: string | null; + platform?: string | null; + appVersion?: string | null; + ws: WebSocket; +}) { + const normalizedUserId = normalizeUserId(userId); + const endpointId = normalizeEndpointId(deviceId); + if (!normalizedUserId || !endpointId) { + return false; + } + + const endpoint = notificationChannelEndpointsDb.upsertEndpoint({ + userId: normalizedUserId, + channel: DESKTOP_CHANNEL, + endpointId, + label, + metadata: { platform, appVersion }, + enabled: true, + }); + + const clients = getUserClients(normalizedUserId, true)!; + const previous = clients.get(endpointId); + if (previous && previous !== ws && previous.readyState === previous.OPEN) { + previous.close(4000, 'Device reconnected'); + } + + clients.set(endpointId, ws); + clientBySocket.set(ws, { userId: normalizedUserId, endpointId }); + return endpoint; +} + +export function unregisterDesktopNotificationClient(ws: WebSocket): void { + const registration = clientBySocket.get(ws); + if (!registration) return; + + const clients = getUserClients(registration.userId); + if (clients?.get(registration.endpointId) === ws) { + clients.delete(registration.endpointId); + if (clients.size === 0) { + clientsByUserId.delete(registration.userId); + } + } + clientBySocket.delete(ws); +} + +export function sendDesktopNotification(userId: unknown, payload: unknown): { attempted: number; sent: number } { + const normalizedUserId = normalizeUserId(userId); + if (!normalizedUserId) return { attempted: 0, sent: 0 }; + + const clients = getUserClients(normalizedUserId); + if (!clients?.size) return { attempted: 0, sent: 0 }; + + const enabledEndpointIds = new Set( + notificationChannelEndpointsDb + .getEnabledEndpoints(normalizedUserId, DESKTOP_CHANNEL) + .map((endpoint) => endpoint.endpoint_id) + ); + + const message = JSON.stringify({ + type: 'notification', + id: typeof (payload as any)?.data?.tag === 'string' ? (payload as any).data.tag : `${Date.now()}`, + payload, + }); + + let attempted = 0; + let sent = 0; + for (const [endpointId, ws] of clients.entries()) { + if (!enabledEndpointIds.has(endpointId)) continue; + attempted += 1; + if (ws.readyState !== ws.OPEN) { + unregisterDesktopNotificationClient(ws); + continue; + } + try { + ws.send(message); + notificationChannelEndpointsDb.touchEndpoint(normalizedUserId, DESKTOP_CHANNEL, endpointId); + sent += 1; + } catch { + unregisterDesktopNotificationClient(ws); + } + } + + return { attempted, sent }; +} diff --git a/server/modules/notifications/services/notification-orchestrator.service.js b/server/modules/notifications/services/notification-orchestrator.service.js new file mode 100644 index 00000000..b9a1c6ee --- /dev/null +++ b/server/modules/notifications/services/notification-orchestrator.service.js @@ -0,0 +1,288 @@ +import webPush from 'web-push'; + +import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '@/modules/database/index.js'; +import { sendDesktopNotification as sendDesktopNotificationToClients } from '@/modules/notifications/services/desktop-notification-clients.service.js'; + +const KIND_TO_PREF_KEY = { + action_required: 'actionRequired', + stop: 'stop', + error: 'error' +}; + +const PROVIDER_LABELS = { + claude: 'Claude', + cursor: 'Cursor', + codex: 'Codex', + gemini: 'Gemini', + system: 'System' +}; + +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 isNotificationEventEnabled(preferences, event) { + const prefEventKey = KIND_TO_PREF_KEY[event.kind]; + const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true; + + return 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 normalizeErrorMessage(error) { + if (typeof error === 'string') { + return error; + } + + if (error && typeof error.message === 'string') { + return error.message; + } + + if (error == null) { + return 'Unknown error'; + } + + return String(error); +} + +function normalizeSessionName(sessionName) { + if (typeof sessionName !== 'string') { + return null; + } + + const normalized = sessionName.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return null; + } + + return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; +} + +function rowMatchesProvider(row, provider) { + return row && (!provider || row.provider === provider); +} + +function resolveSessionRow(sessionId, provider) { + if (!sessionId) { + return null; + } + + const appSessionRow = sessionsDb.getSessionById(sessionId); + if (rowMatchesProvider(appSessionRow, provider)) { + return appSessionRow; + } + + const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId); + if (rowMatchesProvider(providerSessionRow, provider)) { + return providerSessionRow; + } + + return null; +} + +function normalizeNotificationSession(event) { + if (!event?.sessionId || !event.provider || event.provider === 'system') { + return event; + } + + const row = resolveSessionRow(event.sessionId, event.provider); + if (!row || row.session_id === event.sessionId) { + return event; + } + + return { + ...event, + sessionId: row.session_id + }; +} + +function resolveSessionName(event) { + const explicitSessionName = normalizeSessionName(event.meta?.sessionName); + if (explicitSessionName) { + return explicitSessionName; + } + + if (!event.sessionId || !event.provider) { + return null; + } + + return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider)); +} + +function buildNotificationPayload(event) { + const normalizedEvent = normalizeNotificationSession(event); + const CODE_MAP = { + 'permission.required': normalizedEvent.meta?.toolName + ? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval` + : 'Action Required: A tool needs your approval', + 'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped', + 'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error', + 'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification', + 'push.enabled': 'Push notifications are now enabled!' + }; + const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant'; + const sessionName = resolveSessionName(normalizedEvent); + const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification'; + + return { + title: sessionName || 'CloudCLI', + body: `${providerLabel}: ${message}`, + data: { + sessionId: normalizedEvent.sessionId || null, + code: normalizedEvent.code, + provider: normalizedEvent.provider || null, + sessionName, + tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.code}` + } + }; +} + +function sendWebPushPayload(userId, payload) { + const subscriptions = pushSubscriptionsDb.getSubscriptions(userId); + if (!subscriptions.length) return Promise.resolve(); + + const serializedPayload = JSON.stringify(payload); + return Promise.allSettled( + subscriptions.map((sub) => + webPush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.keys_p256dh, + auth: sub.keys_auth + } + }, + serializedPayload + ) + ) + ).then((results) => { + results.forEach((result, index) => { + if (result.status === 'rejected') { + const statusCode = result.reason?.statusCode; + if (statusCode === 410 || statusCode === 404) { + pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint); + } + } + }); + }); +} + +const notificationChannels = [ + { + id: 'webPush', + // TODO: Web push still uses push_subscriptions. Do not remove that table until + // browser push subscriptions are migrated into notification_channel_endpoints. + isEnabled: (preferences) => Boolean(preferences?.channels?.webPush), + send: ({ userId, payload }) => sendWebPushPayload(userId, payload) + }, + { + id: 'desktop', + isEnabled: (preferences) => Boolean(preferences?.channels?.desktop), + send: ({ userId, payload }) => sendDesktopNotificationToClients(userId, payload) + } +]; + +function notifyUserIfEnabled({ userId, event }) { + if (!userId || !event) { + return; + } + + const normalizedEvent = normalizeNotificationSession(event); + const preferences = notificationPreferencesDb.getPreferences(userId); + if (!isNotificationEventEnabled(preferences, normalizedEvent)) { + return; + } + if (isDuplicate(normalizedEvent)) { + return; + } + + const payload = buildNotificationPayload(normalizedEvent); + for (const channel of notificationChannels) { + if (!channel.isEnabled(preferences)) { + continue; + } + Promise.resolve(channel.send({ userId, event: normalizedEvent, payload })).catch((err) => { + console.error(`Notification channel "${channel.id}" send error:`, err); + }); + } +} + +function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) { + notifyUserIfEnabled({ + userId, + event: createNotificationEvent({ + provider, + sessionId, + kind: 'stop', + code: 'run.stopped', + meta: { stopReason, sessionName }, + severity: 'info', + dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}` + }) + }); +} + +function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) { + const errorMessage = normalizeErrorMessage(error); + + notifyUserIfEnabled({ + userId, + event: createNotificationEvent({ + provider, + sessionId, + kind: 'error', + code: 'run.failed', + meta: { error: errorMessage, sessionName }, + severity: 'error', + dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}` + }) + }); +} + +export { + buildNotificationPayload, + createNotificationEvent, + notifyUserIfEnabled, + notifyRunStopped, + notifyRunFailed +}; diff --git a/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts b/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts new file mode 100644 index 00000000..67bc02f4 --- /dev/null +++ b/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts @@ -0,0 +1,109 @@ +import type { WebSocket } from 'ws'; + +import { + registerDesktopNotificationClient, + unregisterDesktopNotificationClient, +} from '@/modules/notifications/services/desktop-notification-clients.service.js'; +import type { AuthenticatedWebSocketRequest } from '@/shared/types.js'; +import { parseIncomingJsonObject } from '@/shared/utils.js'; + +type DesktopNotificationRegisterMessage = { + type?: unknown; + kind?: unknown; + deviceId?: unknown; + label?: unknown; + platform?: unknown; + appVersion?: unknown; +}; + +function readRequestUserId(request: AuthenticatedWebSocketRequest): number | null { + const user = request.user; + const rawUserId = typeof user?.id === 'number' || typeof user?.id === 'string' + ? user.id + : typeof user?.userId === 'number' || typeof user?.userId === 'string' + ? user.userId + : null; + const numericUserId = Number(rawUserId); + return Number.isInteger(numericUserId) && numericUserId > 0 ? numericUserId : null; +} + +function readOptionalString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim(); + return normalized || null; +} + +function sendJson(ws: WebSocket, payload: unknown): void { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify(payload)); + } +} + +export function handleDesktopNotificationsConnection( + ws: WebSocket, + request: AuthenticatedWebSocketRequest +): void { + const userId = readRequestUserId(request); + if (!userId) { + ws.close(1008, 'Missing authenticated user'); + return; + } + + let registered = false; + + ws.on('message', (rawMessage) => { + const data = parseIncomingJsonObject(rawMessage) as DesktopNotificationRegisterMessage | null; + if (!data) { + return; + } + + const type = typeof data.type === 'string' ? data.type : typeof data.kind === 'string' ? data.kind : ''; + if (type === 'notification_ack') { + return; + } + + if (type !== 'register' || registered) { + return; + } + + const deviceId = readOptionalString(data.deviceId); + if (!deviceId) { + sendJson(ws, { + type: 'error', + code: 'DEVICE_ID_REQUIRED', + message: 'Desktop notification registration requires deviceId.', + }); + ws.close(1008, 'Missing deviceId'); + return; + } + + const device = registerDesktopNotificationClient({ + userId, + deviceId, + label: readOptionalString(data.label), + platform: readOptionalString(data.platform), + appVersion: readOptionalString(data.appVersion), + ws, + }); + + if (!device) { + ws.close(1011, 'Registration failed'); + return; + } + + registered = true; + sendJson(ws, { + type: 'registered', + deviceId: device.endpoint_id, + enabled: Boolean(device.enabled), + }); + }); + + ws.on('close', () => { + unregisterDesktopNotificationClient(ws); + }); + + ws.on('error', () => { + unregisterDesktopNotificationClient(ws); + }); +} diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 9b9fb576..7fa7947e 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -430,6 +430,17 @@ router.post( }), ); +router.delete( + '/:provider/skills/:directoryName', + asyncHandler(async (req: Request, res: Response) => { + const provider = parseProvider(req.params.provider); + const result = await providerSkillsService.removeProviderSkill(provider, { + directoryName: readPathParam(req.params.directoryName, 'directoryName'), + }); + res.json(createApiSuccessResponse(result)); + }), +); + // ----------------- MCP routes ----------------- router.get( '/:provider/mcp/servers', diff --git a/server/modules/providers/services/skills.service.ts b/server/modules/providers/services/skills.service.ts index 9bb1c052..bbb2c7da 100644 --- a/server/modules/providers/services/skills.service.ts +++ b/server/modules/providers/services/skills.service.ts @@ -3,6 +3,7 @@ import type { ProviderSkill, ProviderSkillCreateInput, ProviderSkillListOptions, + ProviderSkillRemoveInput, } from '@/shared/types.js'; export const providerSkillsService = { @@ -27,4 +28,12 @@ export const providerSkillsService = { const provider = providerRegistry.resolveProvider(providerName); return provider.skills.addSkills(input); }, + + async removeProviderSkill( + providerName: string, + input: ProviderSkillRemoveInput, + ): Promise<{ removed: boolean; provider: string; directoryName: string }> { + const provider = providerRegistry.resolveProvider(providerName); + return provider.skills.removeSkill(input); + }, }; diff --git a/server/modules/providers/shared/skills/skills.provider.ts b/server/modules/providers/shared/skills/skills.provider.ts index 20e7b3c5..032aea30 100644 --- a/server/modules/providers/shared/skills/skills.provider.ts +++ b/server/modules/providers/shared/skills/skills.provider.ts @@ -1,10 +1,11 @@ import path from 'node:path'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdir, rm, stat, writeFile } from 'node:fs/promises'; import type { IProviderSkills } from '@/shared/interfaces.js'; import type { LLMProvider, ProviderSkillCreateInput, + ProviderSkillRemoveInput, ProviderSkill, ProviderSkillListOptions, ProviderSkillSource, @@ -236,6 +237,48 @@ export abstract class SkillsProvider implements IProviderSkills { return pendingInstalls.map((install) => install.skill); } + async removeSkill( + input: ProviderSkillRemoveInput, + ): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }> { + const globalSkillSource = await this.getGlobalSkillSource(); + if (!globalSkillSource) { + throw new AppError(`${this.provider} does not support managed global skills.`, { + code: 'PROVIDER_SKILLS_WRITE_UNSUPPORTED', + statusCode: 400, + }); + } + + const directoryName = normalizeSkillDirectoryName(input.directoryName); + if (!directoryName) { + throw new AppError('Skill directoryName is required.', { + code: 'PROVIDER_SKILL_DIRECTORY_REQUIRED', + statusCode: 400, + }); + } + + const skillDirectoryPath = path.join(globalSkillSource.rootDir, directoryName); + const resolvedRoot = path.resolve(globalSkillSource.rootDir); + const resolvedSkillDirectoryPath = path.resolve(skillDirectoryPath); + if ( + resolvedSkillDirectoryPath !== resolvedRoot + && !resolvedSkillDirectoryPath.startsWith(`${resolvedRoot}${path.sep}`) + ) { + throw new AppError('Skill directory must stay inside the managed skill root.', { + code: 'PROVIDER_SKILL_DIRECTORY_INVALID', + statusCode: 400, + }); + } + + const removed = await stat(resolvedSkillDirectoryPath) + .then((stats) => stats.isDirectory()) + .catch(() => false); + if (removed) { + await rm(resolvedSkillDirectoryPath, { recursive: true, force: true }); + } + + return { removed, provider: this.provider, directoryName }; + } + protected abstract getSkillSources(workspacePath: string): Promise; protected async getGlobalSkillSource(): Promise { diff --git a/server/modules/providers/tests/skills.test.ts b/server/modules/providers/tests/skills.test.ts index 79a3d9af..147366cc 100644 --- a/server/modules/providers/tests/skills.test.ts +++ b/server/modules/providers/tests/skills.test.ts @@ -662,6 +662,19 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor'); assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true); + const removedCodexSkill = await providerSkillsService.removeProviderSkill('codex', { + directoryName: 'uploaded-codex-folder', + }); + assert.equal(removedCodexSkill.removed, true); + assert.equal(removedCodexSkill.provider, 'codex'); + assert.equal(removedCodexSkill.directoryName, 'uploaded-codex-folder'); + await assert.rejects(fs.stat(path.dirname(createdCodexSkill.sourcePath)), { code: 'ENOENT' }); + + const removedMissingSkill = await providerSkillsService.removeProviderSkill('codex', { + directoryName: 'uploaded-codex-folder', + }); + assert.equal(removedMissingSkill.removed, false); + await assert.rejects( providerSkillsService.addProviderSkills('codex', { entries: [ @@ -701,4 +714,11 @@ test('providerSkillsService rejects managed skill creation for opencode', { conc }), /does not support managed global skills/i, ); + + await assert.rejects( + providerSkillsService.removeProviderSkill('opencode', { + directoryName: 'opencode-global-dir', + }), + /does not support managed global skills/i, + ); }); diff --git a/server/modules/websocket/services/websocket-server.service.ts b/server/modules/websocket/services/websocket-server.service.ts index ec9aa229..aa1be2b9 100644 --- a/server/modules/websocket/services/websocket-server.service.ts +++ b/server/modules/websocket/services/websocket-server.service.ts @@ -7,6 +7,7 @@ import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-au import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js'; import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js'; import { handleDesktopAgentConnection } from '@/modules/websocket/services/desktop-agent-websocket.service.js'; +import { handleDesktopNotificationsConnection } from '@/modules/notifications/index.js'; import type { AuthenticatedWebSocketRequest } from '@/shared/types.js'; type WebSocketServerDependencies = { @@ -69,6 +70,11 @@ export function createWebSocketServer( return; } + if (pathname === '/desktop-notifications') { + handleDesktopNotificationsConnection(ws, incomingRequest); + return; + } + if (pathname.startsWith('/plugin-ws/')) { handlePluginWsProxy(ws, pathname, dependencies.getPluginPort); return; diff --git a/server/routes/settings.js b/server/routes/settings.js index 75406c54..5eeb50b8 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -1,6 +1,11 @@ import express from 'express'; -import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../modules/database/index.js'; +import { + apiKeysDb, + credentialsDb, + notificationPreferencesDb, + pushSubscriptionsDb, +} from '../modules/database/index.js'; import { getPublicKey } from '../services/vapid-keys.js'; import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js'; diff --git a/server/services/notification-orchestrator.js b/server/services/notification-orchestrator.js index f25fffb3..16e0dce0 100644 --- a/server/services/notification-orchestrator.js +++ b/server/services/notification-orchestrator.js @@ -1,268 +1,7 @@ -import webPush from 'web-push'; - -import { notificationPreferencesDb, pushSubscriptionsDb, sessionsDb } from '../modules/database/index.js'; - -const KIND_TO_PREF_KEY = { - action_required: 'actionRequired', - stop: 'stop', - error: 'error' -}; - -const PROVIDER_LABELS = { - claude: 'Claude', - cursor: 'Cursor', - codex: 'Codex', - gemini: 'Gemini', - system: 'System' -}; - -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 normalizeErrorMessage(error) { - if (typeof error === 'string') { - return error; - } - - if (error && typeof error.message === 'string') { - return error.message; - } - - if (error == null) { - return 'Unknown error'; - } - - return String(error); -} - -function normalizeSessionName(sessionName) { - if (typeof sessionName !== 'string') { - return null; - } - - const normalized = sessionName.replace(/\s+/g, ' ').trim(); - if (!normalized) { - return null; - } - - return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; -} - -function rowMatchesProvider(row, provider) { - return row && (!provider || row.provider === provider); -} - -function resolveSessionRow(sessionId, provider) { - if (!sessionId) { - return null; - } - - const appSessionRow = sessionsDb.getSessionById(sessionId); - if (rowMatchesProvider(appSessionRow, provider)) { - return appSessionRow; - } - - const providerSessionRow = sessionsDb.getSessionByProviderSessionId(sessionId); - if (rowMatchesProvider(providerSessionRow, provider)) { - return providerSessionRow; - } - - return null; -} - -function normalizeNotificationSession(event) { - if (!event?.sessionId || !event.provider || event.provider === 'system') { - return event; - } - - const row = resolveSessionRow(event.sessionId, event.provider); - if (!row || row.session_id === event.sessionId) { - return event; - } - - return { - ...event, - sessionId: row.session_id - }; -} - -function resolveSessionName(event) { - const explicitSessionName = normalizeSessionName(event.meta?.sessionName); - if (explicitSessionName) { - return explicitSessionName; - } - - if (!event.sessionId || !event.provider) { - return null; - } - - return normalizeSessionName(sessionsDb.getSessionName(event.sessionId, event.provider)); -} - -function buildPushBody(event) { - const normalizedEvent = normalizeNotificationSession(event); - const CODE_MAP = { - 'permission.required': normalizedEvent.meta?.toolName - ? `Action Required: Tool "${normalizedEvent.meta.toolName}" needs approval` - : 'Action Required: A tool needs your approval', - 'run.stopped': normalizedEvent.meta?.stopReason || 'Run Stopped: The run has stopped', - 'run.failed': normalizedEvent.meta?.error ? `Run Failed: ${normalizedEvent.meta.error}` : 'Run Failed: The run encountered an error', - 'agent.notification': normalizedEvent.meta?.message ? String(normalizedEvent.meta.message) : 'You have a new notification', - 'push.enabled': 'Push notifications are now enabled!' - }; - const providerLabel = PROVIDER_LABELS[normalizedEvent.provider] || 'Assistant'; - const sessionName = resolveSessionName(normalizedEvent); - const message = CODE_MAP[normalizedEvent.code] || 'You have a new notification'; - - return { - title: sessionName || 'CloudCLI', - body: `${providerLabel}: ${message}`, - data: { - sessionId: normalizedEvent.sessionId || null, - code: normalizedEvent.code, - provider: normalizedEvent.provider || null, - sessionName, - tag: `${normalizedEvent.provider || 'assistant'}:${normalizedEvent.sessionId || 'none'}:${normalizedEvent.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 normalizedEvent = normalizeNotificationSession(event); - const preferences = notificationPreferencesDb.getPreferences(userId); - if (!shouldSendPush(preferences, normalizedEvent)) { - return; - } - if (isDuplicate(normalizedEvent)) { - return; - } - - sendWebPush(userId, normalizedEvent).catch((err) => { - console.error('Web push send error:', err); - }); -} - -function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) { - notifyUserIfEnabled({ - userId, - event: createNotificationEvent({ - provider, - sessionId, - kind: 'stop', - code: 'run.stopped', - meta: { stopReason, sessionName }, - severity: 'info', - dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}` - }) - }); -} - -function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) { - const errorMessage = normalizeErrorMessage(error); - - notifyUserIfEnabled({ - userId, - event: createNotificationEvent({ - provider, - sessionId, - kind: 'error', - code: 'run.failed', - meta: { error: errorMessage, sessionName }, - severity: 'error', - dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}` - }) - }); -} - export { + buildNotificationPayload, createNotificationEvent, notifyUserIfEnabled, notifyRunStopped, - notifyRunFailed -}; + notifyRunFailed, +} from '../modules/notifications/services/notification-orchestrator.service.js'; diff --git a/server/services/tests/notification-orchestrator.test.js b/server/services/tests/notification-orchestrator.test.js index d470765f..eddda46a 100644 --- a/server/services/tests/notification-orchestrator.test.js +++ b/server/services/tests/notification-orchestrator.test.js @@ -13,9 +13,9 @@ import { pushSubscriptionsDb, sessionsDb, userDb, -} from '../modules/database/index.js'; +} from '../../modules/database/index.js'; -import { notifyRunStopped } from './notification-orchestrator.js'; +import { notifyRunStopped } from '../notification-orchestrator.js'; async function withIsolatedDatabase(runTest) { const previousDatabasePath = process.env.DATABASE_PATH; diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index ff5974c4..86f36844 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -13,6 +13,7 @@ import type { ProviderMcpServer, ProviderSessionActiveModelChange, ProviderSkillCreateInput, + ProviderSkillRemoveInput, UpsertProviderMcpServerInput, } from '@/shared/types.js'; @@ -111,6 +112,10 @@ export interface IProviderSkills { * records that were written. */ addSkills(input: ProviderSkillCreateInput): Promise; + + removeSkill( + input: ProviderSkillRemoveInput, + ): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>; } // --------------------------- diff --git a/server/shared/types.ts b/server/shared/types.ts index 94f6d7f9..5d411efe 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -361,6 +361,10 @@ export type ProviderSkillCreateInput = { entries: ProviderSkillCreateEntry[]; }; +export type ProviderSkillRemoveInput = { + directoryName: string; +}; + /** * Normalized skill record returned by provider skill adapters. * diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index 8321776f..dc7cf4d4 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -110,6 +110,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState => channels: { inApp: true, webPush: false, + desktop: false, sound: true, }, events: { @@ -128,6 +129,7 @@ const normalizeNotificationPreferences = ( channels: { inApp: preferences?.channels?.inApp ?? defaults.channels.inApp, webPush: preferences?.channels?.webPush ?? defaults.channels.webPush, + desktop: preferences?.channels?.desktop ?? defaults.channels.desktop, sound: preferences?.channels?.sound ?? defaults.channels.sound, }, events: { diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index 9f44d3e4..e91484ab 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -30,6 +30,7 @@ export type NotificationPreferencesState = { channels: { inApp: boolean; webPush: boolean; + desktop: boolean; sound: boolean; }; events: { diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 3feaae2d..2ae5efb8 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -1,3 +1,4 @@ +import { useEffect, useMemo, useState } from 'react'; import { X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -18,8 +19,22 @@ import { useSettingsController } from '../hooks/useSettingsController'; import { useWebPush } from '../../../hooks/useWebPush'; import type { SettingsProps } from '../types/types'; +type DesktopNotificationsState = { + enabled: boolean; + supported: boolean; + connectedCount?: number; + targetCount?: number; + lastError?: string | null; +}; + function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) { const { t } = useTranslation('settings'); + const desktopNotificationsBridge = useMemo(() => ( + typeof window === 'undefined' + ? null + : ((window as any).cloudcliDesktopNotifications || null) + ), []); + const [desktopNotificationsState, setDesktopNotificationsState] = useState(null); const { activeTab, setActiveTab, @@ -75,6 +90,45 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set }); }; + useEffect(() => { + if (!desktopNotificationsBridge) return undefined; + let mounted = true; + desktopNotificationsBridge.getState().then((state: any) => { + if (mounted) { + setDesktopNotificationsState(state?.desktopNotifications || null); + } + }).catch(() => {}); + const unsubscribe = desktopNotificationsBridge.onStateUpdated?.((state: any) => { + if (mounted) { + setDesktopNotificationsState(state?.desktopNotifications || null); + } + }); + return () => { + mounted = false; + unsubscribe?.(); + }; + }, [desktopNotificationsBridge]); + + const handleEnableDesktopNotifications = async () => { + if (!desktopNotificationsBridge) return; + const state = await desktopNotificationsBridge.update({ enabled: true }); + setDesktopNotificationsState(state?.desktopNotifications || null); + setNotificationPreferences({ + ...notificationPreferences, + channels: { ...notificationPreferences.channels, desktop: true }, + }); + }; + + const handleDisableDesktopNotifications = async () => { + if (!desktopNotificationsBridge) return; + const state = await desktopNotificationsBridge.update({ enabled: false }); + setDesktopNotificationsState(state?.desktopNotifications || null); + setNotificationPreferences({ + ...notificationPreferences, + channels: { ...notificationPreferences.channels, desktop: false }, + }); + }; + if (!isOpen) { return null; } @@ -155,6 +209,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set isPushLoading={isPushLoading} onEnablePush={handleEnablePush} onDisablePush={handleDisablePush} + isDesktop={Boolean(desktopNotificationsBridge)} + desktopNotifications={desktopNotificationsState} + onEnableDesktopNotifications={handleEnableDesktopNotifications} + onDisableDesktopNotifications={handleDisableDesktopNotifications} /> )} diff --git a/src/components/settings/view/tabs/NotificationsSettingsTab.tsx b/src/components/settings/view/tabs/NotificationsSettingsTab.tsx index 0519d5e9..588a516e 100644 --- a/src/components/settings/view/tabs/NotificationsSettingsTab.tsx +++ b/src/components/settings/view/tabs/NotificationsSettingsTab.tsx @@ -13,6 +13,16 @@ type NotificationsSettingsTabProps = { isPushLoading: boolean; onEnablePush: () => void; onDisablePush: () => void; + isDesktop?: boolean; + desktopNotifications?: { + enabled: boolean; + supported: boolean; + connectedCount?: number; + targetCount?: number; + lastError?: string | null; + } | null; + onEnableDesktopNotifications?: () => void; + onDisableDesktopNotifications?: () => void; }; export default function NotificationsSettingsTab({ @@ -23,6 +33,10 @@ export default function NotificationsSettingsTab({ isPushLoading, onEnablePush, onDisablePush, + isDesktop = false, + desktopNotifications = null, + onEnableDesktopNotifications, + onDisableDesktopNotifications, }: NotificationsSettingsTabProps) { const { t } = useTranslation('settings'); @@ -33,57 +47,107 @@ export default function NotificationsSettingsTab({
- +

{t('notifications.title')}

{t('notifications.description')}

-
-

{t('notifications.webPush.title')}

- {!pushSupported ? ( -

{t('notifications.webPush.unsupported')}

- ) : pushDenied ? ( -

{t('notifications.webPush.denied')}

- ) : ( -
- + {desktopNotifications?.enabled && ( + + {t('notifications.desktop.enabled', { defaultValue: 'Desktop notifications are enabled' })} + + )} +
+ {desktopNotifications?.lastError && ( +

{desktopNotifications.lastError}

)} - {isPushLoading - ? t('notifications.webPush.loading') - : isPushSubscribed - ? t('notifications.webPush.disable') - : t('notifications.webPush.enable')} - - {isPushSubscribed && ( - - {t('notifications.webPush.enabled')} - - )} -
- )} -
+ + )} + + ) : ( +
+

{t('notifications.webPush.title')}

+ {!pushSupported ? ( +

{t('notifications.webPush.unsupported')}

+ ) : pushDenied ? ( +

{t('notifications.webPush.denied')}

+ ) : ( +
+ + {isPushSubscribed && ( + + {t('notifications.webPush.enabled')} + + )} +
+ )} +
+ )}
@@ -133,7 +197,7 @@ export default function NotificationsSettingsTab({
-
+

{t('notifications.events.title')}

@@ -167,7 +231,7 @@ export default function NotificationsSettingsTab({ }, }) } - className="w-4 h-4" + className="h-4 w-4" /> {t('notifications.events.stop')} @@ -185,7 +249,7 @@ export default function NotificationsSettingsTab({ }, }) } - className="w-4 h-4" + className="h-4 w-4" /> {t('notifications.events.error')} diff --git a/src/hooks/useWebPush.ts b/src/hooks/useWebPush.ts index b5e365ae..a1ce4130 100644 --- a/src/hooks/useWebPush.ts +++ b/src/hooks/useWebPush.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; + import { authenticatedFetch } from '../utils/api'; type WebPushState = { @@ -22,7 +23,12 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array { export function useWebPush(): WebPushState { const [permission, setPermission] = useState(() => { - if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) { + if ( + typeof window === 'undefined' + || Boolean((window as any).cloudcliDesktopNotifications) + || !('Notification' in window) + || !('serviceWorker' in navigator) + ) { return 'unsupported'; } return Notification.permission; diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 58d84172..9cd56f66 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -105,14 +105,21 @@ "title": "Notifications", "description": "Control which notification events you receive.", "webPush": { - "title": "Web Push Notifications", - "enable": "Enable Push Notifications", - "disable": "Disable Push Notifications", - "enabled": "Push notifications are enabled", + "title": "Notify this browser", + "enable": "Enable notifications", + "disable": "Disable notifications", + "enabled": "Notifications are enabled for this browser", "loading": "Updating...", "unsupported": "Push notifications are not supported in this browser.", "denied": "Push notifications are blocked. Please allow them in your browser settings." }, + "desktop": { + "title": "Notify this desktop app", + "enable": "Enable notifications", + "disable": "Disable notifications", + "enabled": "Notifications are enabled for this desktop app", + "unsupported": "Desktop notifications are not supported on this system." + }, "sound": { "title": "Sound", "description": "Play a short tone when a chat run finishes or needs tool approval.",