feat: add desktop notifications and skills updates

This commit is contained in:
Simos Mikelatos
2026-06-26 10:25:47 +00:00
parent e6c6f89dda
commit 63f3c3941d
32 changed files with 1693 additions and 328 deletions

View File

@@ -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?.();
}
}

View File

@@ -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.'));

View File

@@ -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();

View File

@@ -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));
},

View File

@@ -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;

View File

@@ -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);

View File

@@ -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';

View File

@@ -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);

View File

@@ -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<string, unknown> | 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<string, unknown> | null | undefined): string | null {
if (!metadata || typeof metadata !== 'object') return null;
return JSON.stringify(metadata);
}
function parseMetadata(metadataJson: string | null): Record<string, unknown> {
if (!metadataJson) return {};
try {
const parsed = JSON.parse(metadataJson);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
} 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,
};

View File

@@ -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<string, any>) : {};
const sourceChannels = source.channels && typeof source.channels === 'object'
? source.channels as Record<string, unknown>
: {};
const extraChannels = Object.fromEntries(
Object.entries(sourceChannels)
.filter(([key, channelValue]) => !['inApp', 'webPush', 'desktop', 'sound'].includes(key) && typeof channelValue === 'boolean')
) as Record<string, boolean>;
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);
},
};

View File

@@ -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.

View File

@@ -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';

View File

@@ -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;

View File

@@ -0,0 +1,124 @@
import type { WebSocket } from 'ws';
import { notificationChannelEndpointsDb } from '@/modules/database/index.js';
const DESKTOP_CHANNEL = 'desktop';
const clientsByUserId = new Map<number, Map<string, WebSocket>>();
const clientBySocket = new WeakMap<WebSocket, { userId: number; endpointId: string }>();
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<string, WebSocket> | 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 };
}

View File

@@ -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
};

View File

@@ -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);
});
}

View File

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

View File

@@ -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);
},
};

View File

@@ -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<ProviderSkillSource[]>;
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {

View File

@@ -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,
);
});

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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<ProviderSkill[]>;
removeSkill(
input: ProviderSkillRemoveInput,
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
}
// ---------------------------

View File

@@ -361,6 +361,10 @@ export type ProviderSkillCreateInput = {
entries: ProviderSkillCreateEntry[];
};
export type ProviderSkillRemoveInput = {
directoryName: string;
};
/**
* Normalized skill record returned by provider skill adapters.
*

View File

@@ -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: {

View File

@@ -30,6 +30,7 @@ export type NotificationPreferencesState = {
channels: {
inApp: boolean;
webPush: boolean;
desktop: boolean;
sound: boolean;
};
events: {

View File

@@ -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<DesktopNotificationsState | null>(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}
/>
)}

View File

@@ -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({
<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" />
<Bell className="h-5 w-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>
) : (
<div className="flex items-center gap-3">
<button
type="button"
disabled={isPushLoading}
onClick={() => {
if (isPushSubscribed) {
onDisablePush();
} else {
onEnablePush();
}
}}
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isPushSubscribed
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{isPushLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isPushSubscribed ? (
<BellOff className="w-4 h-4" />
) : (
<BellRing className="w-4 h-4" />
{isDesktop ? (
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<h4 className="font-medium text-foreground">
{t('notifications.desktop.title', { defaultValue: 'Notify this desktop app' })}
</h4>
{desktopNotifications?.supported === false ? (
<p className="text-sm text-muted-foreground">
{t('notifications.desktop.unsupported', { defaultValue: 'Desktop notifications are not supported on this system.' })}
</p>
) : (
<div className="space-y-2">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
if (desktopNotifications?.enabled) {
onDisableDesktopNotifications?.();
} else {
onEnableDesktopNotifications?.();
}
}}
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
desktopNotifications?.enabled
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{desktopNotifications?.enabled ? (
<BellOff className="h-4 w-4" />
) : (
<BellRing className="h-4 w-4" />
)}
{desktopNotifications?.enabled
? t('notifications.desktop.disable', { defaultValue: 'Disable desktop notifications' })
: t('notifications.desktop.enable', { defaultValue: 'Enable desktop notifications' })}
</button>
{desktopNotifications?.enabled && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.desktop.enabled', { defaultValue: 'Desktop notifications are enabled' })}
</span>
)}
</div>
{desktopNotifications?.lastError && (
<p className="text-sm text-red-600 dark:text-red-400">{desktopNotifications.lastError}</p>
)}
{isPushLoading
? t('notifications.webPush.loading')
: isPushSubscribed
? t('notifications.webPush.disable')
: t('notifications.webPush.enable')}
</button>
{isPushSubscribed && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.webPush.enabled')}
</span>
)}
</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="space-y-4 rounded-lg border border-border bg-card 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>
) : (
<div className="flex items-center gap-3">
<button
type="button"
disabled={isPushLoading}
onClick={() => {
if (isPushSubscribed) {
onDisablePush();
} else {
onEnablePush();
}
}}
className={`inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
isPushSubscribed
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}`}
>
{isPushLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isPushSubscribed ? (
<BellOff className="h-4 w-4" />
) : (
<BellRing className="h-4 w-4" />
)}
{isPushLoading
? t('notifications.webPush.loading')
: isPushSubscribed
? t('notifications.webPush.disable')
: t('notifications.webPush.enable')}
</button>
{isPushSubscribed && (
<span className="text-sm text-green-600 dark:text-green-400">
{t('notifications.webPush.enabled')}
</span>
)}
</div>
)}
</div>
)}
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -133,7 +197,7 @@ export default function NotificationsSettingsTab({
</Button>
</div>
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
<div className="space-y-4 rounded-lg border border-border bg-card 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">
@@ -149,7 +213,7 @@ export default function NotificationsSettingsTab({
},
})
}
className="w-4 h-4"
className="h-4 w-4"
/>
{t('notifications.events.actionRequired')}
</label>
@@ -167,7 +231,7 @@ export default function NotificationsSettingsTab({
},
})
}
className="w-4 h-4"
className="h-4 w-4"
/>
{t('notifications.events.stop')}
</label>
@@ -185,7 +249,7 @@ export default function NotificationsSettingsTab({
},
})
}
className="w-4 h-4"
className="h-4 w-4"
/>
{t('notifications.events.error')}
</label>

View File

@@ -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<NotificationPermission | 'unsupported'>(() => {
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;

View File

@@ -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.",