import * as path from 'path'; import * as url from 'url'; import { AnimationQueue } from '../utils/animation-queue'; import { ElectronNotificationOptions, DefaultElectronNotificationOptions } from '../models/electron-notification-options'; import { screen, BrowserWindow, ipcMain, IpcMainEvent, shell } from 'electron'; import { ElectronNotification } from '../models/electron-notification'; import { ElectronNotificationEventType } from '../types/event.type'; import { Channel } from '../types/channel.type'; import { ElectronWebContentsChannel } from '@ucap-webmessenger/electron-core'; const onClickElectronNotification = 'onClickElectronNotification'; const onCloseElectronNotification = 'onCloseElectronNotification'; interface ENPoint { x: number; y: number; } interface ENDimension { width: number; height: number; } export class ElectronNotificationService { private animationQueue: AnimationQueue; private customOptions: ElectronNotificationOptions; private nextInsertPosition: ENPoint; private totalDimension: ENDimension; private firstPosition: ENPoint; private lowerRightCornerPosition: ENPoint; private maxVisibleNotifications: number; private activeNotifications: BrowserWindow[]; private inactiveWindows: BrowserWindow[]; private notificationQueue: ElectronNotification[]; private closedNotifications: Map; private latestId: number; private templateUrl: string; constructor(options?: ElectronNotificationOptions) { this.customOptions = { ...DefaultElectronNotificationOptions }; if (!!options) { this.customOptions = { ...this.customOptions, ...options }; } this.setup(); this.setupEvents(); } set options(options: ElectronNotificationOptions) { if (!!options) { this.customOptions = { ...this.customOptions, ...options }; } this.calcDimensions(); } get options(): ElectronNotificationOptions { return this.customOptions; } set templatePath(templatePath: string) { if (!!templatePath) { this.customOptions.templatePath = templatePath; this.updateTemplatePath(); } } get templatePath(): string { if (!this.templateUrl) { this.updateTemplatePath(); } return this.templateUrl; } notify(notification: ElectronNotification): number { notification.id = this.latestId++; this.animationQueue.push({ context: this, func: this.showNotification, args: [notification] }); return notification.id; } dispose(): void { this.animationQueue.clear(); this.activeNotifications.forEach(window => window.close()); this.inactiveWindows.forEach(window => window.close()); } closeAll(): void { this.animationQueue.clear(); this.activeNotifications.forEach(window => window.close()); this.inactiveWindows.forEach(window => window.close()); this.setup(); } private setup(): void { this.nextInsertPosition = { x: 0, y: 0 }; this.totalDimension = { width: 0, height: 0 }; this.firstPosition = { x: 0, y: 0 }; this.activeNotifications = []; this.inactiveWindows = []; this.notificationQueue = []; this.closedNotifications = new Map(); this.latestId = 0; this.animationQueue = new AnimationQueue(); const display = screen.getPrimaryDisplay(); this.lowerRightCornerPosition = { x: display.bounds.x + display.workArea.x + display.workAreaSize.width, y: display.bounds.y + display.workArea.y + display.workAreaSize.height }; this.calcDimensions(); this.maxVisibleNotifications = Math.floor( display.workAreaSize.height / this.totalDimension.height ); this.maxVisibleNotifications = 7 < this.maxVisibleNotifications ? 7 : this.maxVisibleNotifications; } private setupEvents(): void { const self = this; ipcMain.on( Channel.close, ( event: IpcMainEvent, windowId: number, notification: ElectronNotification ) => { const onClose = self.buildCloseNotification( BrowserWindow.fromId(windowId), notification ); self.buildCloseNotificationSafely(onClose)('close'); } ); ipcMain.on( Channel.click, ( event: IpcMainEvent, windowId: number, notification: ElectronNotification ) => { if (!!notification.url) { shell.openExternal(notification.url); } const notificationWindow = BrowserWindow.fromId(windowId); if ( notificationWindow && notificationWindow[onClickElectronNotification] ) { const onClose = self.buildCloseNotification( BrowserWindow.fromId(windowId), notification ); notificationWindow[onClickElectronNotification]({ type: ElectronNotificationEventType.Click, id: notification.id, close: self.buildCloseNotificationSafely(onClose) }); delete notificationWindow[onClickElectronNotification]; } } ); } private calcDimensions() { this.totalDimension = { width: this.customOptions.width + this.customOptions.padding, height: this.customOptions.height + this.customOptions.padding }; this.firstPosition = { x: this.lowerRightCornerPosition.x - this.totalDimension.width, y: this.lowerRightCornerPosition.y - this.totalDimension.height }; this.nextInsertPosition = { x: this.firstPosition.x, y: this.firstPosition.y }; } private calcInsertPosition() { if (this.activeNotifications.length < this.maxVisibleNotifications) { this.nextInsertPosition.y = this.lowerRightCornerPosition.y - this.totalDimension.height * (this.activeNotifications.length + 1); } } private updateTemplatePath() { try { import('fs') .then(fs => { fs.statSync(this.customOptions.templatePath).isFile(); this.templateUrl = url.format({ pathname: this.customOptions.templatePath, protocol: 'file:', slashes: true }); }) .catch(reason => { throw reason; }); } catch (e) { console.log( 'electron-notify: Could not find template ("' + this.customOptions.templatePath + '").' ); console.log( 'electron-notify: To use a different template you need to correct the config.templatePath or simply adapt config.htmlTemplate' ); } } private showNotification(notification: ElectronNotification): Promise { const self = this; return new Promise((resolve, reject) => { if (this.activeNotifications.length < this.maxVisibleNotifications) { self.getWindow().then(notificationWindow => { self.calcInsertPosition(); notificationWindow.setPosition( self.nextInsertPosition.x, self.nextInsertPosition.y ); self.activeNotifications.push(notificationWindow); const displayTime = !!notification.displayTime ? notification.displayTime : self.customOptions.displayTime; let timeoutId: any; const onClose = self.buildCloseNotification( notificationWindow, notification, () => timeoutId ); const onCloseNotificationSafely = self.buildCloseNotificationSafely( onClose ); timeoutId = setTimeout(() => { if (notificationWindow.isDestroyed()) { return; } onCloseNotificationSafely('timeout'); }, displayTime); if (!!notification.onShow) { notification.onShow({ type: ElectronNotificationEventType.Show, id: notification.id, close: onCloseNotificationSafely }); } if (!!notification.onClose) { notificationWindow[onClickElectronNotification] = notification.onClick; } else { delete notificationWindow[onClickElectronNotification]; } if (!!notification.onClose) { notificationWindow[onCloseElectronNotification] = notification.onClose; } else { delete notificationWindow[onCloseElectronNotification]; } notificationWindow.webContents.send( Channel.browserWindowSetContents, notification ); notificationWindow.showInactive(); resolve(notificationWindow); }); } else { self.notificationQueue.push(notification); resolve(); } }); } private buildCloseNotification( notificationWindow: BrowserWindow, notification: ElectronNotification, timeoutIdFunc?: () => number ) { const self = this; return (e: ElectronNotificationEventType): Promise => { if (notificationWindow.isDestroyed()) { return; } if (self.closedNotifications.has(notification.id)) { self.closedNotifications.delete(notification.id); return new Promise(resolve => { resolve(); }); } else { self.closedNotifications.set(notification.id, true); } if (!!notificationWindow[onCloseElectronNotification]) { notificationWindow[onCloseElectronNotification]({ type: e, id: notification.id }); delete notificationWindow[onCloseElectronNotification]; } notificationWindow.webContents.send(Channel.reset); if (!!timeoutIdFunc) { clearTimeout(timeoutIdFunc()); } const i = self.activeNotifications.indexOf(notificationWindow); self.activeNotifications.splice(i, 1); self.inactiveWindows.push(notificationWindow); notificationWindow.hide(); self.checkForQueuedNotifications(); return self.moveOneDown(i); }; } private buildCloseNotificationSafely( onClose: (e: ElectronNotificationEventType) => any ) { const self = this; return (reason: any) => { if (!reason) { reason = 'closedByAPI'; } self.animationQueue.push({ context: self, func: onClose, args: [reason] }); }; } private checkForQueuedNotifications(): void { if ( 0 < this.notificationQueue.length && this.activeNotifications.length < this.maxVisibleNotifications ) { this.animationQueue.push({ context: this, func: this.showNotification, args: [this.notificationQueue.shift()] }); } } private getWindow(): Promise { const slef = this; return new Promise((resolve, reject) => { if (0 < slef.inactiveWindows.length) { resolve(slef.inactiveWindows.pop()); } else { const windowProperties = slef.customOptions.defaultWindow; windowProperties.width = slef.customOptions.width; windowProperties.height = slef.customOptions.height; const notificationWindow = new BrowserWindow(windowProperties); notificationWindow.setVisibleOnAllWorkspaces(true); notificationWindow.loadURL(slef.templatePath); notificationWindow.webContents.on( ElectronWebContentsChannel.DidFinishLoad, () => { // Done notificationWindow.webContents.send( Channel.loadConfig, slef.customOptions ); resolve(notificationWindow); } ); notificationWindow.webContents.on( ElectronWebContentsChannel.DevtoolsOpened, () => { notificationWindow.webContents.closeDevTools(); } ); } }); } private moveOneDown(startPos: number): Promise { const self = this; return new Promise(async (resolve, reject) => { if (startPos >= self.activeNotifications.length || -1 === startPos) { resolve(); return; } const aryNotificationPos: number[] = []; for (let i = startPos; i < self.activeNotifications.length; i++) { aryNotificationPos.push(i); } await Promise.all( aryNotificationPos.map(async index => { await self.moveNotificationAnimation(index); }) ); resolve(); }); } private moveNotificationAnimation(index: number): Promise { const self = this; return new Promise((resolve, reject) => { const notificationWindow = self.activeNotifications[index]; const newY = self.lowerRightCornerPosition.y - self.totalDimension.height * (index + 1); const startY = notificationWindow.getPosition()[1]; const step = (newY - startY) / self.customOptions.animationSteps; let curStep = 1; const animationInterval = setInterval(() => { // Abort condition if (curStep === self.customOptions.animationSteps) { notificationWindow.setPosition(self.firstPosition.x, newY); clearInterval(animationInterval); return resolve(); } // Move one step down notificationWindow.setPosition( self.firstPosition.x, Math.trunc(startY + curStep * step) ); curStep++; }, self.customOptions.animationStepMs); }); } }