mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-27 14:15:26 +08:00
feat: add desktop notifications and skills updates
This commit is contained in:
378
electron/desktopNotifications.js
Normal file
378
electron/desktopNotifications.js
Normal 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?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { BrowserWindow, Menu, Tray, clipboard, nativeImage, nativeTheme, session
|
|||||||
import { ViewHost } from './viewHost.js';
|
import { ViewHost } from './viewHost.js';
|
||||||
|
|
||||||
const TITLEBAR_HEIGHT = 44;
|
const TITLEBAR_HEIGHT = 44;
|
||||||
|
const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
|
||||||
// TODO: Re-enable Computer Use menus after fixing the MCP server connection
|
// TODO: Re-enable Computer Use menus after fixing the MCP server connection
|
||||||
// between the desktop app and the web UI.
|
// between the desktop app and the web UI.
|
||||||
const COMPUTER_USE_MENUS_ENABLED = false;
|
const COMPUTER_USE_MENUS_ENABLED = false;
|
||||||
@@ -249,6 +250,16 @@ export class DesktopWindowManager {
|
|||||||
return this.getDesktopState();
|
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() {
|
openActiveTabDevTools() {
|
||||||
if (this.viewHost.openActiveViewDevTools()) return;
|
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.'));
|
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before opening active tab DevTools.'));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { CloudController } from './cloud.js';
|
import { CloudController } from './cloud.js';
|
||||||
import { ComputerAgentController } from './computerAgent.js';
|
import { ComputerAgentController } from './computerAgent.js';
|
||||||
import { DesktopWindowManager } from './desktopWindow.js';
|
import { DesktopWindowManager } from './desktopWindow.js';
|
||||||
|
import { DesktopNotificationsController } from './desktopNotifications.js';
|
||||||
import { LocalServerController } from './localServer.js';
|
import { LocalServerController } from './localServer.js';
|
||||||
import { TabsController } from './tabs.js';
|
import { TabsController } from './tabs.js';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ let desktopWindow = null;
|
|||||||
let localServer = null;
|
let localServer = null;
|
||||||
let cloud = null;
|
let cloud = null;
|
||||||
let computerAgent = null;
|
let computerAgent = null;
|
||||||
|
let desktopNotifications = null;
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
let isRefreshingCloud = false;
|
let isRefreshingCloud = false;
|
||||||
let pendingCloudConnectStartedAt = 0;
|
let pendingCloudConnectStartedAt = 0;
|
||||||
@@ -65,6 +67,10 @@ function getComputerUseSettingsPath() {
|
|||||||
return path.join(app.getPath('userData'), 'computer-use-settings.json');
|
return path.join(app.getPath('userData'), 'computer-use-settings.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDesktopNotificationsSettingsPath() {
|
||||||
|
return path.join(app.getPath('userData'), 'desktop-notifications-settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
function getRunningEnvironmentUrls() {
|
function getRunningEnvironmentUrls() {
|
||||||
return cloud.getEnvironments()
|
return cloud.getEnvironments()
|
||||||
.filter((environment) => environment.status === 'running')
|
.filter((environment) => environment.status === 'running')
|
||||||
@@ -146,6 +152,7 @@ function getDesktopState() {
|
|||||||
activeTabId: tabs.activeTabId,
|
activeTabId: tabs.activeTabId,
|
||||||
environments: cloud.getEnvironments().map(serializeEnvironment),
|
environments: cloud.getEnvironments().map(serializeEnvironment),
|
||||||
computerUse: computerAgent?.getState() || { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 },
|
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(),
|
computerUsePermissions: getComputerUsePermissions(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -364,6 +371,7 @@ async function refreshCloudEnvironments({ showErrors = false } = {}) {
|
|||||||
} finally {
|
} finally {
|
||||||
isRefreshingCloud = false;
|
isRefreshingCloud = false;
|
||||||
void computerAgent?.sync().catch((error) => console.error('[ComputerAgent] sync failed:', error?.message || error));
|
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();
|
syncDesktopState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -718,8 +726,57 @@ async function openEnvironmentInDesktop(environment) {
|
|||||||
return getDesktopState();
|
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() {
|
async function clearCloudAccount() {
|
||||||
await cloud.clearCloudAccount();
|
await cloud.clearCloudAccount();
|
||||||
|
desktopNotifications?.stop();
|
||||||
const removedTabs = tabs.removeByKind('remote');
|
const removedTabs = tabs.removeByKind('remote');
|
||||||
for (const tab of removedTabs) {
|
for (const tab of removedTabs) {
|
||||||
desktopWindow?.destroyTabView(tab.id);
|
desktopWindow?.destroyTabView(tab.id);
|
||||||
@@ -800,6 +857,10 @@ function registerIpcHandlers() {
|
|||||||
return getDesktopState();
|
return getDesktopState();
|
||||||
});
|
});
|
||||||
ipcMain.handle('cloudcli-desktop:update-computer-use', async (_event, settings) => updateComputerUse(settings));
|
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:request-computer-use-permission', async (_event, permission) => requestComputerUsePermission(permission));
|
||||||
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
|
ipcMain.handle('cloudcli-desktop:show-desktop-settings', async () => desktopWindow.showDesktopSettings());
|
||||||
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
|
ipcMain.handle('cloudcli-desktop:show-local-settings', async () => desktopWindow.showLocalSettings());
|
||||||
@@ -839,6 +900,7 @@ function registerAppEvents() {
|
|||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
computerAgent?.stop();
|
computerAgent?.stop();
|
||||||
|
desktopNotifications?.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', (event) => {
|
app.on('before-quit', (event) => {
|
||||||
@@ -896,6 +958,7 @@ async function createDesktopWindow() {
|
|||||||
stopEnvironment,
|
stopEnvironment,
|
||||||
updateDesktopSetting,
|
updateDesktopSetting,
|
||||||
copyLocalWebUrl,
|
copyLocalWebUrl,
|
||||||
|
openNotificationTarget,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -963,10 +1026,24 @@ async function bootstrap() {
|
|||||||
promptConsent: promptComputerUseConsent,
|
promptConsent: promptComputerUseConsent,
|
||||||
onChange: syncDesktopState,
|
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 localServer.loadDesktopSettings();
|
||||||
await cloud.loadCloudAccount();
|
await cloud.loadCloudAccount();
|
||||||
await computerAgent.loadSettings();
|
await computerAgent.loadSettings();
|
||||||
|
await desktopNotifications.loadSettings();
|
||||||
|
|
||||||
registerProtocolHandler();
|
registerProtocolHandler();
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron');
|
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:') {
|
if (window.location.protocol === 'file:') {
|
||||||
contextBridge.exposeInMainWorld('cloudcliDesktop', {
|
contextBridge.exposeInMainWorld('cloudcliDesktop', {
|
||||||
connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'),
|
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),
|
switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId),
|
||||||
closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
|
closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
|
||||||
updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
|
updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
|
||||||
onStateUpdated: (callback) => {
|
onStateUpdated: onDesktopStateUpdated,
|
||||||
ipcRenderer.on('cloudcli-desktop:state-updated', (_event, state) => callback(state));
|
|
||||||
},
|
|
||||||
onLauncherCommand: (callback) => {
|
onLauncherCommand: (callback) => {
|
||||||
ipcRenderer.on('cloudcli-desktop:launcher-command', (_event, command) => callback(command));
|
ipcRenderer.on('cloudcli-desktop:launcher-command', (_event, command) => callback(command));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -135,6 +135,38 @@ export class ViewHost {
|
|||||||
return true;
|
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() {
|
getTabViewDiagnostics() {
|
||||||
const mainWindow = this.getMainWindow();
|
const mainWindow = this.getMainWindow();
|
||||||
const attachedViews = new Set();
|
const attachedViews = new Set();
|
||||||
@@ -257,6 +289,15 @@ export class ViewHost {
|
|||||||
return true;
|
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) {
|
destroyTabView(tabId) {
|
||||||
const view = this.tabViews.get(tabId);
|
const view = this.tabViews.get(tabId);
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import commandsRoutes from './routes/commands.js';
|
|||||||
import settingsRoutes from './routes/settings.js';
|
import settingsRoutes from './routes/settings.js';
|
||||||
import agentRoutes from './routes/agent.js';
|
import agentRoutes from './routes/agent.js';
|
||||||
import projectModuleRoutes from './modules/projects/projects.routes.js';
|
import projectModuleRoutes from './modules/projects/projects.routes.js';
|
||||||
|
import notificationRoutes from './modules/notifications/notifications.routes.js';
|
||||||
import userRoutes from './routes/user.js';
|
import userRoutes from './routes/user.js';
|
||||||
import geminiRoutes from './routes/gemini.js';
|
import geminiRoutes from './routes/gemini.js';
|
||||||
import pluginsRoutes from './routes/plugins.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.
|
// 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.
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
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';
|
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||||
// Version of the code that is actually running, captured once at process
|
// Version of the code that is actually running, captured once at process
|
||||||
// startup. This intentionally does NOT re-read package.json per request: after
|
// 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) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
version: packageJson.version,
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
installMode,
|
installMode,
|
||||||
version: RUNNING_VERSION
|
version: RUNNING_VERSION
|
||||||
@@ -206,6 +205,8 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
|
|||||||
// Settings API Routes (protected)
|
// Settings API Routes (protected)
|
||||||
app.use('/api/settings', authenticateToken, settingsRoutes);
|
app.use('/api/settings', authenticateToken, settingsRoutes);
|
||||||
|
|
||||||
|
app.use('/api/notifications', authenticateToken, notificationRoutes);
|
||||||
|
|
||||||
// User API Routes (protected)
|
// User API Routes (protected)
|
||||||
app.use('/api/user', authenticateToken, userRoutes);
|
app.use('/api/user', authenticateToken, userRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
|
|||||||
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
|
||||||
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
|
||||||
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.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 { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
|
||||||
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
|
||||||
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Database } from 'better-sqlite3';
|
|||||||
import {
|
import {
|
||||||
APP_CONFIG_TABLE_SCHEMA_SQL,
|
APP_CONFIG_TABLE_SCHEMA_SQL,
|
||||||
LAST_SCANNED_AT_SQL,
|
LAST_SCANNED_AT_SQL,
|
||||||
|
NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL,
|
||||||
PROJECTS_TABLE_SCHEMA_SQL,
|
PROJECTS_TABLE_SCHEMA_SQL,
|
||||||
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
|
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
|
||||||
SESSIONS_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(VAPID_KEYS_TABLE_SCHEMA_SQL);
|
||||||
db.exec(PUSH_SUBSCRIPTIONS_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('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);
|
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
|
||||||
rebuildProjectsTableWithPrimaryKeySchema(db);
|
rebuildProjectsTableWithPrimaryKeySchema(db);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -10,7 +10,9 @@ type NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
desktop: boolean;
|
||||||
sound: boolean;
|
sound: boolean;
|
||||||
|
[key: string]: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
@@ -23,6 +25,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: false,
|
inApp: false,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
desktop: false,
|
||||||
sound: true,
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
@@ -34,11 +37,20 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
|
|
||||||
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
|
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
|
||||||
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
|
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 {
|
return {
|
||||||
channels: {
|
channels: {
|
||||||
|
...extraChannels,
|
||||||
inApp: source.channels?.inApp === true,
|
inApp: source.channels?.inApp === true,
|
||||||
webPush: source.channels?.webPush === true,
|
webPush: source.channels?.webPush === true,
|
||||||
|
desktop: source.channels?.desktop === true,
|
||||||
sound: source.channels?.sound !== false,
|
sound: source.channels?.sound !== false,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
@@ -103,4 +115,3 @@ export const notificationPreferencesDb = {
|
|||||||
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 = `
|
export const PROJECTS_TABLE_SCHEMA_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
project_id TEXT PRIMARY KEY NOT NULL,
|
project_id TEXT PRIMARY KEY NOT NULL,
|
||||||
@@ -144,6 +161,10 @@ ${VAPID_KEYS_TABLE_SCHEMA_SQL}
|
|||||||
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
|
${PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL}
|
||||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
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}
|
${PROJECTS_TABLE_SCHEMA_SQL}
|
||||||
-- NOTE: These indexes are created in migrations after legacy table-shape repairs.
|
-- 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.
|
-- Creating them here can fail on upgraded installs where projects lacks those columns.
|
||||||
|
|||||||
13
server/modules/notifications/index.ts
Normal file
13
server/modules/notifications/index.ts
Normal 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';
|
||||||
127
server/modules/notifications/notifications.routes.ts
Normal file
127
server/modules/notifications/notifications.routes.ts
Normal 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;
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 -----------------
|
// ----------------- MCP routes -----------------
|
||||||
router.get(
|
router.get(
|
||||||
'/:provider/mcp/servers',
|
'/:provider/mcp/servers',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ProviderSkill,
|
ProviderSkill,
|
||||||
ProviderSkillCreateInput,
|
ProviderSkillCreateInput,
|
||||||
ProviderSkillListOptions,
|
ProviderSkillListOptions,
|
||||||
|
ProviderSkillRemoveInput,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
export const providerSkillsService = {
|
export const providerSkillsService = {
|
||||||
@@ -27,4 +28,12 @@ export const providerSkillsService = {
|
|||||||
const provider = providerRegistry.resolveProvider(providerName);
|
const provider = providerRegistry.resolveProvider(providerName);
|
||||||
return provider.skills.addSkills(input);
|
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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import path from 'node:path';
|
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 { IProviderSkills } from '@/shared/interfaces.js';
|
||||||
import type {
|
import type {
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
ProviderSkillCreateInput,
|
ProviderSkillCreateInput,
|
||||||
|
ProviderSkillRemoveInput,
|
||||||
ProviderSkill,
|
ProviderSkill,
|
||||||
ProviderSkillListOptions,
|
ProviderSkillListOptions,
|
||||||
ProviderSkillSource,
|
ProviderSkillSource,
|
||||||
@@ -236,6 +237,48 @@ export abstract class SkillsProvider implements IProviderSkills {
|
|||||||
return pendingInstalls.map((install) => install.skill);
|
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 abstract getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]>;
|
||||||
|
|
||||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {
|
protected async getGlobalSkillSource(): Promise<ProviderSkillSource | null> {
|
||||||
|
|||||||
@@ -662,6 +662,19 @@ test('providerSkillsService adds global skills for claude, codex, gemini, and cu
|
|||||||
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
|
const listedCursorSkills = await providerSkillsService.listProviderSkills('cursor');
|
||||||
assert.equal(listedCursorSkills.some((skill) => skill.name === 'cursor-global'), true);
|
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(
|
await assert.rejects(
|
||||||
providerSkillsService.addProviderSkills('codex', {
|
providerSkillsService.addProviderSkills('codex', {
|
||||||
entries: [
|
entries: [
|
||||||
@@ -701,4 +714,11 @@ test('providerSkillsService rejects managed skill creation for opencode', { conc
|
|||||||
}),
|
}),
|
||||||
/does not support managed global skills/i,
|
/does not support managed global skills/i,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
providerSkillsService.removeProviderSkill('opencode', {
|
||||||
|
directoryName: 'opencode-global-dir',
|
||||||
|
}),
|
||||||
|
/does not support managed global skills/i,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { verifyWebSocketClient } from '@/modules/websocket/services/websocket-au
|
|||||||
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
import { handlePluginWsProxy } from '@/modules/websocket/services/plugin-websocket-proxy.service.js';
|
||||||
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
import { handleShellConnection } from '@/modules/websocket/services/shell-websocket.service.js';
|
||||||
import { handleDesktopAgentConnection } from '@/modules/websocket/services/desktop-agent-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';
|
import type { AuthenticatedWebSocketRequest } from '@/shared/types.js';
|
||||||
|
|
||||||
type WebSocketServerDependencies = {
|
type WebSocketServerDependencies = {
|
||||||
@@ -69,6 +70,11 @@ export function createWebSocketServer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === '/desktop-notifications') {
|
||||||
|
handleDesktopNotificationsConnection(ws, incomingRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith('/plugin-ws/')) {
|
if (pathname.startsWith('/plugin-ws/')) {
|
||||||
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
|
handlePluginWsProxy(ws, pathname, dependencies.getPluginPort);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import express from 'express';
|
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 { getPublicKey } from '../services/vapid-keys.js';
|
||||||
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
|
import { createNotificationEvent, notifyUserIfEnabled } from '../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 {
|
export {
|
||||||
|
buildNotificationPayload,
|
||||||
createNotificationEvent,
|
createNotificationEvent,
|
||||||
notifyUserIfEnabled,
|
notifyUserIfEnabled,
|
||||||
notifyRunStopped,
|
notifyRunStopped,
|
||||||
notifyRunFailed
|
notifyRunFailed,
|
||||||
};
|
} from '../modules/notifications/services/notification-orchestrator.service.js';
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
pushSubscriptionsDb,
|
pushSubscriptionsDb,
|
||||||
sessionsDb,
|
sessionsDb,
|
||||||
userDb,
|
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) {
|
async function withIsolatedDatabase(runTest) {
|
||||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ProviderMcpServer,
|
ProviderMcpServer,
|
||||||
ProviderSessionActiveModelChange,
|
ProviderSessionActiveModelChange,
|
||||||
ProviderSkillCreateInput,
|
ProviderSkillCreateInput,
|
||||||
|
ProviderSkillRemoveInput,
|
||||||
UpsertProviderMcpServerInput,
|
UpsertProviderMcpServerInput,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
|
|
||||||
@@ -111,6 +112,10 @@ export interface IProviderSkills {
|
|||||||
* records that were written.
|
* records that were written.
|
||||||
*/
|
*/
|
||||||
addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]>;
|
addSkills(input: ProviderSkillCreateInput): Promise<ProviderSkill[]>;
|
||||||
|
|
||||||
|
removeSkill(
|
||||||
|
input: ProviderSkillRemoveInput,
|
||||||
|
): Promise<{ removed: boolean; provider: LLMProvider; directoryName: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|||||||
@@ -361,6 +361,10 @@ export type ProviderSkillCreateInput = {
|
|||||||
entries: ProviderSkillCreateEntry[];
|
entries: ProviderSkillCreateEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProviderSkillRemoveInput = {
|
||||||
|
directoryName: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalized skill record returned by provider skill adapters.
|
* Normalized skill record returned by provider skill adapters.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: true,
|
inApp: true,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
desktop: false,
|
||||||
sound: true,
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
@@ -128,6 +129,7 @@ const normalizeNotificationPreferences = (
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
||||||
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
||||||
|
desktop: preferences?.channels?.desktop ?? defaults.channels.desktop,
|
||||||
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type NotificationPreferencesState = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
desktop: boolean;
|
||||||
sound: boolean;
|
sound: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -18,8 +19,22 @@ import { useSettingsController } from '../hooks/useSettingsController';
|
|||||||
import { useWebPush } from '../../../hooks/useWebPush';
|
import { useWebPush } from '../../../hooks/useWebPush';
|
||||||
import type { SettingsProps } from '../types/types';
|
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) {
|
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
|
const desktopNotificationsBridge = useMemo(() => (
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? null
|
||||||
|
: ((window as any).cloudcliDesktopNotifications || null)
|
||||||
|
), []);
|
||||||
|
const [desktopNotificationsState, setDesktopNotificationsState] = useState<DesktopNotificationsState | null>(null);
|
||||||
const {
|
const {
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
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) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -155,6 +209,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
isPushLoading={isPushLoading}
|
isPushLoading={isPushLoading}
|
||||||
onEnablePush={handleEnablePush}
|
onEnablePush={handleEnablePush}
|
||||||
onDisablePush={handleDisablePush}
|
onDisablePush={handleDisablePush}
|
||||||
|
isDesktop={Boolean(desktopNotificationsBridge)}
|
||||||
|
desktopNotifications={desktopNotificationsState}
|
||||||
|
onEnableDesktopNotifications={handleEnableDesktopNotifications}
|
||||||
|
onDisableDesktopNotifications={handleDisableDesktopNotifications}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ type NotificationsSettingsTabProps = {
|
|||||||
isPushLoading: boolean;
|
isPushLoading: boolean;
|
||||||
onEnablePush: () => void;
|
onEnablePush: () => void;
|
||||||
onDisablePush: () => 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({
|
export default function NotificationsSettingsTab({
|
||||||
@@ -23,6 +33,10 @@ export default function NotificationsSettingsTab({
|
|||||||
isPushLoading,
|
isPushLoading,
|
||||||
onEnablePush,
|
onEnablePush,
|
||||||
onDisablePush,
|
onDisablePush,
|
||||||
|
isDesktop = false,
|
||||||
|
desktopNotifications = null,
|
||||||
|
onEnableDesktopNotifications,
|
||||||
|
onDisableDesktopNotifications,
|
||||||
}: NotificationsSettingsTabProps) {
|
}: NotificationsSettingsTabProps) {
|
||||||
const { t } = useTranslation('settings');
|
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-6 md:space-y-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<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>
|
<h3 className="text-lg font-medium text-foreground">{t('notifications.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
|
<p className="text-sm text-muted-foreground">{t('notifications.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
{isDesktop ? (
|
||||||
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
{!pushSupported ? (
|
<h4 className="font-medium text-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
|
{t('notifications.desktop.title', { defaultValue: 'Notify this desktop app' })}
|
||||||
) : pushDenied ? (
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
{desktopNotifications?.supported === false ? (
|
||||||
) : (
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-3">
|
{t('notifications.desktop.unsupported', { defaultValue: 'Desktop notifications are not supported on this system.' })}
|
||||||
<button
|
</p>
|
||||||
type="button"
|
) : (
|
||||||
disabled={isPushLoading}
|
<div className="space-y-2">
|
||||||
onClick={() => {
|
<div className="flex items-center gap-3">
|
||||||
if (isPushSubscribed) {
|
<button
|
||||||
onDisablePush();
|
type="button"
|
||||||
} else {
|
onClick={() => {
|
||||||
onEnablePush();
|
if (desktopNotifications?.enabled) {
|
||||||
}
|
onDisableDesktopNotifications?.();
|
||||||
}}
|
} else {
|
||||||
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 ${
|
onEnableDesktopNotifications?.();
|
||||||
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'
|
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'
|
||||||
{isPushLoading ? (
|
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
}`}
|
||||||
) : isPushSubscribed ? (
|
>
|
||||||
<BellOff className="w-4 h-4" />
|
{desktopNotifications?.enabled ? (
|
||||||
) : (
|
<BellOff className="h-4 w-4" />
|
||||||
<BellRing className="w-4 h-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
|
</div>
|
||||||
? t('notifications.webPush.loading')
|
)}
|
||||||
: isPushSubscribed
|
</div>
|
||||||
? t('notifications.webPush.disable')
|
) : (
|
||||||
: t('notifications.webPush.enable')}
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
</button>
|
<h4 className="font-medium text-foreground">{t('notifications.webPush.title')}</h4>
|
||||||
{isPushSubscribed && (
|
{!pushSupported ? (
|
||||||
<span className="text-sm text-green-600 dark:text-green-400">
|
<p className="text-sm text-muted-foreground">{t('notifications.webPush.unsupported')}</p>
|
||||||
{t('notifications.webPush.enabled')}
|
) : pushDenied ? (
|
||||||
</span>
|
<p className="text-sm text-muted-foreground">{t('notifications.webPush.denied')}</p>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
)}
|
<button
|
||||||
</div>
|
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="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">
|
<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>
|
</Button>
|
||||||
</div>
|
</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>
|
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
<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')}
|
{t('notifications.events.actionRequired')}
|
||||||
</label>
|
</label>
|
||||||
@@ -167,7 +231,7 @@ export default function NotificationsSettingsTab({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
{t('notifications.events.stop')}
|
{t('notifications.events.stop')}
|
||||||
</label>
|
</label>
|
||||||
@@ -185,7 +249,7 @@ export default function NotificationsSettingsTab({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
{t('notifications.events.error')}
|
{t('notifications.events.error')}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../utils/api';
|
import { authenticatedFetch } from '../utils/api';
|
||||||
|
|
||||||
type WebPushState = {
|
type WebPushState = {
|
||||||
@@ -22,7 +23,12 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|||||||
|
|
||||||
export function useWebPush(): WebPushState {
|
export function useWebPush(): WebPushState {
|
||||||
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {
|
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 'unsupported';
|
||||||
}
|
}
|
||||||
return Notification.permission;
|
return Notification.permission;
|
||||||
|
|||||||
@@ -105,14 +105,21 @@
|
|||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"description": "Control which notification events you receive.",
|
"description": "Control which notification events you receive.",
|
||||||
"webPush": {
|
"webPush": {
|
||||||
"title": "Web Push Notifications",
|
"title": "Notify this browser",
|
||||||
"enable": "Enable Push Notifications",
|
"enable": "Enable notifications",
|
||||||
"disable": "Disable Push Notifications",
|
"disable": "Disable notifications",
|
||||||
"enabled": "Push notifications are enabled",
|
"enabled": "Notifications are enabled for this browser",
|
||||||
"loading": "Updating...",
|
"loading": "Updating...",
|
||||||
"unsupported": "Push notifications are not supported in this browser.",
|
"unsupported": "Push notifications are not supported in this browser.",
|
||||||
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
"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": {
|
"sound": {
|
||||||
"title": "Sound",
|
"title": "Sound",
|
||||||
"description": "Play a short tone when a chat run finishes or needs tool approval.",
|
"description": "Play a short tone when a chat run finishes or needs tool approval.",
|
||||||
|
|||||||
Reference in New Issue
Block a user