From a2afcb971d683fc5cb7a30fae3eb055d2e4759f8 Mon Sep 17 00:00:00 2001 From: Jarek Kowalski Date: Tue, 21 Jan 2025 20:58:53 -0800 Subject: [PATCH] feat(kopiaui): support for desktop notifications (#4352) --- app/public/electron.js | 42 ++++++++++++++++++++++++++++++++++ app/public/notifications.js | 35 +++++++++++++++++++++++++++++ app/public/server.js | 45 ++++++++++++++++++++++++++----------- 3 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 app/public/notifications.js diff --git a/app/public/electron.js b/app/public/electron.js index 5726c4636..33135b88d 100644 --- a/app/public/electron.js +++ b/app/public/electron.js @@ -3,6 +3,7 @@ import pkg from "electron-updater"; const autoUpdater = pkg.autoUpdater; import { iconsPath, publicPath, selectByOS } from './utils.js'; import { toggleLaunchAtStartup, willLaunchAtStartup, refreshWillLaunchAtStartup } from './auto-launch.js'; +import { setNotificationLevel, getNotificationLevel } from './notifications.js'; import { serverForRepo } from './server.js'; import { loadConfigs, allConfigs, deleteConfigIfDisconnected, addNewConfig, configDir, isFirstRun, isPortableConfig } from './config.js'; @@ -223,6 +224,7 @@ ipcMain.handle('browse-dir', async (_event, path) => { ipcMain.on('server-status-updated', updateTrayContextMenu); ipcMain.on('launch-at-startup-updated', updateTrayContextMenu); +ipcMain.on('notification-config-updated', updateTrayContextMenu); let updateAvailableInfo = null; let updateDownloadStatusInfo = ""; @@ -466,8 +468,41 @@ app.on('ready', () => { } }) +function showRepoNotification(e) { + const nl = getNotificationLevel(); + if (nl === 0) { + // notifications disabled + return; + } + + if (e.severity < 10 && nl === 1) { + // non-important notifications disabled. + return; + } + + let urgency = "normal"; + + if (e.severity < 0) { + urgency = "low"; + } else if (e.severity >= 10) { // warnings and errors + urgency = "critical"; + } else { + urgency = "normal"; + } + + const notification = new Notification({ + title: e.notification.subject, + body: e.notification.body, + urgency: urgency + }); + + notification.on('click', () => showRepoWindow(e.repositoryID)); + notification.show(); +} + ipcMain.addListener('config-list-updated-event', () => updateTrayContextMenu()); ipcMain.addListener('status-updated-event', () => updateTrayContextMenu()); +ipcMain.addListener('repo-notification-event', showRepoNotification); function addAnotherRepository() { const repoID = addNewConfig(); @@ -530,6 +565,8 @@ function updateTrayContextMenu() { autoUpdateMenuItems.push({ label: "KopiaUI is up-to-date: " + app.getVersion(), enabled: false }); } + const nl = getNotificationLevel(); + let template = defaultReposTemplates.concat(additionalReposTemplates).concat([ { type: 'separator' }, { label: 'Connect To Another Repository...', click: addAnotherRepository }, @@ -538,6 +575,11 @@ function updateTrayContextMenu() { ]).concat(autoUpdateMenuItems).concat([ { type: 'separator' }, { label: 'Launch At Startup', type: 'checkbox', click: toggleLaunchAtStartup, checked: willLaunchAtStartup() }, + { label: 'Notifications', type: 'submenu', submenu: [ + { label: 'Enabled', type: 'radio', click: () => setNotificationLevel(2), checked: nl === 2 }, + { label: 'Warnings And Errors', type: 'radio', click: () => setNotificationLevel(1), checked: nl === 1 }, + { label: 'Disabled', type: 'radio', click: () => setNotificationLevel(0), checked: nl === 0 }, + ] }, { label: 'Quit', role: 'quit' }, ]); diff --git a/app/public/notifications.js b/app/public/notifications.js new file mode 100644 index 000000000..9cd7cc18b --- /dev/null +++ b/app/public/notifications.js @@ -0,0 +1,35 @@ +import { ipcMain } from 'electron'; +import { configDir } from './config.js'; + +const path = await import('path'); +const fs = await import('fs'); + +const LevelDisabled = 0; +const LevelDefault = 1; +const LevelAll = 2; + +let level = -1; + +export function getNotificationLevel() { + if (level === -1) { + try { + const cfg = fs.readFileSync(path.join(configDir(), 'notifications.json')); + return JSON.parse(cfg).level; + } catch (e) { + level = LevelDefault; + } + } + + return level; +} + +export function setNotificationLevel(l) { + level = l; + if (level < LevelDisabled || level > LevelAll) { + level = LevelDefault; + } + + fs.writeFileSync(path.join(configDir(), 'notifications.json'), JSON.stringify({ level: l })); + + ipcMain.emit('notification-config-updated'); +} diff --git a/app/public/server.js b/app/public/server.js index 0b191026e..8bae227b6 100644 --- a/app/public/server.js +++ b/app/public/server.js @@ -41,9 +41,11 @@ function newServerForRepo(repoID) { '--random-server-control-password', '--tls-generate-cert', '--async-repo-connect', + '--error-notifications=always', + '--kopiaui-notifications', // will print notification JSON to stderr '--shutdown-on-stdin', // shutdown the server when parent dies '--address=127.0.0.1:0'); - + args.push("--config-file", path.resolve(configDir(), repoID + ".config")); if (isPortableConfig()) { @@ -67,7 +69,7 @@ function newServerForRepo(repoID) { const statusUpdated = this.raiseStatusUpdatedEvent.bind(this); - const pollInterval = 3000; + const pollInterval = 3000; function pollOnce() { if (!runningServerAddress || !runningServerCertificate || !runningServerPassword || !runningServerControlPassword) { @@ -80,13 +82,13 @@ function newServerForRepo(repoID) { port: parseInt(new URL(runningServerAddress).port), method: "GET", path: "/api/v1/control/status", - timeout: pollInterval, + timeout: pollInterval, headers: { 'Authorization': 'Basic ' + Buffer.from("server-control" + ':' + runningServerControlPassword).toString('base64') - } + } }, (resp) => { if (resp.statusCode === 200) { - resp.on('data', x => { + resp.on('data', x => { try { const newDetails = JSON.parse(x); if (JSON.stringify(newDetails) != JSON.stringify(runningServerStatusDetails)) { @@ -101,7 +103,7 @@ function newServerForRepo(repoID) { log.warn('error fetching status', resp.statusMessage); } }); - req.on('error', (e)=>{ + req.on('error', (e) => { log.info('error fetching status', e); }); req.end(); @@ -159,6 +161,14 @@ function newServerForRepo(repoID) { runningServerAddress = value; this.raiseStatusUpdatedEvent(); break; + + case "NOTIFICATION": + try { + this.raiseNotificationEvent(JSON.parse(value)); + } catch (e) { + log.warn('unable to parse notification JSON', e); + } + break; } } @@ -231,6 +241,15 @@ function newServerForRepo(repoID) { ipcMain.emit('status-updated-event', args); }, + + raiseNotificationEvent(notification) { + const args = { + repoID: repoID, + notification: notification, + }; + + ipcMain.emit('repo-notification-event', args); + }, }; }; @@ -243,13 +262,13 @@ ipcMain.on('status-fetch', (event, args) => { }) export function serverForRepo(repoID) { - let s = servers[repoID]; - if (s) { - return s; - } - - s = newServerForRepo(repoID); - servers[repoID] = s; + let s = servers[repoID]; + if (s) { return s; } + s = newServerForRepo(repoID); + servers[repoID] = s; + return s; +} +