import url from 'url'; import fse from 'fs-extra'; import * as Electron from 'electron'; import log from 'electron-log'; import { WebContentsChannel } from '@ucap/electron-core'; import { AnimationQueue } from '../utils/animation-queue'; import { NotifyWindowOptions, DefaultNotifyWindowOptions } from '../models/notify-window-options'; import { NotifyWindow } from '../models/notify-window'; import { NotifyWindowEventType } from '../types/event.type'; import { Channel } from '../types/channel.type'; const onClickNotifyWindow = 'onClickNotifyWindow'; const onCloseNotifyWindow = 'onCloseNotifyWindow'; interface ENPoint { x: number; y: number; } interface ENDimension { width: number; height: number; } class BrowserWindowPooler { private readonly inactiveWindows: Electron.BrowserWindow[]; constructor(private readonly minSize: number) { this.minSize = 0 > this.minSize ? 0 : this.minSize; this.inactiveWindows = []; } get length() { return this.inactiveWindows.length; } push(...items: Electron.BrowserWindow[]): number { const length = this.inactiveWindows.push(...items); if (this.minSize < length) { this.start(); } return length; } pop(): Electron.BrowserWindow { if (!this.inactiveWindows || 0 === this.inactiveWindows.length) { return undefined; } return this.inactiveWindows.pop(); } closeAll() { while (this.inactiveWindows.length > 0) { const w = this.inactiveWindows.pop(); w.close(); } } private start() { setTimeout(() => { while (this.minSize < this.inactiveWindows.length) { const w = this.inactiveWindows.pop(); w.close(); } }, 3000); } } export class NotifyWindowService { private animationQueue: AnimationQueue; private customOptions: NotifyWindowOptions; private nextInsertPosition: ENPoint; private totalDimension: ENDimension; private firstPosition: ENPoint; private lowerRightCornerPosition: ENPoint; private maxVisibleNotifications: number; private activeNotifications: Electron.BrowserWindow[]; private browserWindowPooler: BrowserWindowPooler; private notificationQueue: NotifyWindow[]; private closedNotifications: Map; private latestId: number; private templateUrl: string; constructor(options?: NotifyWindowOptions) { this.customOptions = { ...DefaultNotifyWindowOptions }; if (!!options) { this.customOptions = { ...this.customOptions, ...options }; } this.setup(); this.setupEvents(); } set options(options: NotifyWindowOptions) { if (!!options) { this.customOptions = { ...this.customOptions, ...options }; } this.calcDimensions(); } get options(): NotifyWindowOptions { 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: NotifyWindow): 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.browserWindowPooler.closeAll(); } closeAll(): void { this.animationQueue.clear(); this.activeNotifications.forEach((window) => window.close()); this.browserWindowPooler.closeAll(); 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.browserWindowPooler = new BrowserWindowPooler( this.options.browserWindowPool.min ); this.notificationQueue = []; this.closedNotifications = new Map(); this.latestId = 0; this.animationQueue = new AnimationQueue(); const display = Electron.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 = this.options.browserWindowPool.max < this.maxVisibleNotifications ? this.options.browserWindowPool.max : this.maxVisibleNotifications; } private setupEvents(): void { const self = this; Electron.ipcMain.on( Channel.close, ( event: Electron.IpcMainEvent, windowId: number, sNotification: string ) => { const notification: NotifyWindow = JSON.parse(sNotification); const onClose = self.buildCloseNotification( Electron.BrowserWindow.fromId(windowId), notification ); self.buildCloseNotificationSafely(onClose)('close'); } ); Electron.ipcMain.on( Channel.click, ( event: Electron.IpcMainEvent, windowId: number, sNotification: string ) => { const notification: NotifyWindow = JSON.parse(sNotification); if (!!notification.url) { Electron.shell.openExternal(notification.url); } const notificationWindow = Electron.BrowserWindow.fromId(windowId); if (notificationWindow && notificationWindow[onClickNotifyWindow]) { const onClose = self.buildCloseNotification( Electron.BrowserWindow.fromId(windowId), notification ); notificationWindow[onClickNotifyWindow]({ type: NotifyWindowEventType.Click, id: notification.id, close: self.buildCloseNotificationSafely(onClose) }); delete notificationWindow[onClickNotifyWindow]; } } ); } 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 { fse.statSync(this.customOptions.templatePath).isFile(); this.templateUrl = url.format({ pathname: this.customOptions.templatePath, protocol: 'file:', slashes: true }); } catch (e) { log.error( 'electron-notify: Could not find template ("' + this.customOptions.templatePath + '").' ); log.error( 'electron-notify: To use a different template you need to correct the config.templatePath or simply adapt config.htmlTemplate' ); } } private showNotification(notification: NotifyWindow): 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: NotifyWindowEventType.Show, id: notification.id, close: onCloseNotificationSafely }); } if (!!notification.onClick) { notificationWindow[onClickNotifyWindow] = notification.onClick; } else { delete notificationWindow[onClickNotifyWindow]; } if (!!notification.onClose) { notificationWindow[onCloseNotifyWindow] = notification.onClose; } else { delete notificationWindow[onCloseNotifyWindow]; } notificationWindow.webContents.send( Channel.browserWindowSetContents, JSON.stringify(notification) ); notificationWindow.showInactive(); resolve(notificationWindow); }); } else { self.notificationQueue.push(notification); resolve(); } }); } private buildCloseNotification( notificationWindow: Electron.BrowserWindow, notification: NotifyWindow, timeoutIdFunc?: () => number ) { const self = this; return (e: NotifyWindowEventType): 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[onCloseNotifyWindow]) { notificationWindow[onCloseNotifyWindow]({ type: e, id: notification.id }); delete notificationWindow[onCloseNotifyWindow]; } notificationWindow.webContents.send(Channel.reset); if (!!timeoutIdFunc) { clearTimeout(timeoutIdFunc()); } const i = self.activeNotifications.indexOf(notificationWindow); self.activeNotifications.splice(i, 1); self.browserWindowPooler.push(notificationWindow); notificationWindow.hide(); self.checkForQueuedNotifications(); return self.moveOneDown(i); }; } private buildCloseNotificationSafely( onClose: (e: NotifyWindowEventType) => 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.browserWindowPooler.length) { resolve(slef.browserWindowPooler.pop()); } else { const windowProperties = slef.customOptions.defaultWindow; windowProperties.width = slef.customOptions.width; windowProperties.height = slef.customOptions.height; const notificationWindow = new Electron.BrowserWindow({ ...windowProperties, title: 'Notification' }); notificationWindow.setVisibleOnAllWorkspaces(true); notificationWindow.loadURL(slef.templatePath); notificationWindow.webContents.on( WebContentsChannel.didFinishLoad, () => { // Done notificationWindow.webContents.send( Channel.loadConfig, slef.customOptions ); resolve(notificationWindow); } ); notificationWindow.webContents.on( WebContentsChannel.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); }); } }