import * as url from 'url'; import * as _ from 'lodash'; import * as Electron from 'electron'; import {AnimationQueue} from '../utils/animation.queue'; import { NotificationChannel, NotificationWindowChannel, } from '../types/channel.type'; export interface Template { filePath?: string; html?: string; } export interface DefaultOptions { appIcon?: string; optionsForBrowserWindow?: Electron.BrowserWindowConstructorOptions; displayTime?: number; template: Template; padding?: { item?: number; bottom?: number; }; animation?: { step: number; timeForStep: number; }; windowPool?: { min?: number; max?: number; }; } export const defaultOptions: DefaultOptions = { optionsForBrowserWindow: { alwaysOnTop: true, skipTaskbar: true, resizable: false, show: false, frame: false, transparent: true, acceptFirstMouse: true, }, displayTime: 5000, padding: { item: 10, bottom: 10, }, animation: { step: 5, timeForStep: 20, }, windowPool: { min: 0, max: 7, }, template: { html: ` \n \n \n
\n \n \n
\n \n

\n
\n
X
\n
\n \n `, }, }; export type OnShow4NotificationWindow = () => Promise; export type OnClose4NotificationWindow = () => Promise; export type OnClick4NotificationWindow = () => Promise; export type OnMessage4NotificationWindow = ( channel: string, ...args: any[] ) => Promise; export interface Notification { id?: number; optionsForBrowserWindow?: Omit< Electron.BrowserWindowConstructorOptions, 'width' >; displayTime?: number; template: Template; data?: any; onShow?: OnShow4NotificationWindow; onClose?: OnClose4NotificationWindow; onClick?: OnClick4NotificationWindow; onMessage?: OnMessage4NotificationWindow; } class WindowPooler { private readonly __windows: Electron.BrowserWindow[]; private handle: any; constructor(private readonly __minSize: number) { this.__minSize = 0 > this.__minSize ? 0 : this.__minSize; this.__windows = []; } get length() { return this.__windows.length; } push(...items: Electron.BrowserWindow[]): number { const length = this.__windows.push(...items); if (this.__minSize < length) { this.__start(); } return length; } pop(): Electron.BrowserWindow | undefined { if (!this.__windows || 0 === this.__windows.length) { return undefined; } return this.__windows.pop(); } closeAll() { while (this.__windows.length > 0) { const w = this.__windows.pop(); if (!!w) { w.close(); } } } private __start() { setTimeout(() => { while (this.__minSize < this.__windows.length) { const w = this.__windows.pop(); if (!!w) { w.close(); } } }, 3000); } } interface Activated { notification: Notification; browserWindow: Electron.BrowserWindow; } export class NotificationService { private readonly __defaultOptions: DefaultOptions; private __animationQueue = new AnimationQueue(); private __windowPooler: WindowPooler; private __notificationId = 0; private __width = 0; private __availableHeight = 0; private __paddingBottom: number; private __paddingItem: number; private __basePosition: Electron.Point; private __activated: Activated[] = []; private __delayQueue: Notification[] = []; private __closed = new Map(); constructor(options?: DefaultOptions) { this.__defaultOptions = _.assignIn(defaultOptions, options); this.__windowPooler = new WindowPooler( !!this.__defaultOptions.windowPool?.min ? this.__defaultOptions.windowPool?.min : 0 ); this.__basePosition = {x: 0, y: 0}; this.__paddingBottom = !!this.__defaultOptions.padding?.bottom ? this.__defaultOptions.padding?.bottom : 0; this.__paddingItem = !!this.__defaultOptions.padding?.item ? this.__defaultOptions.padding?.item : 0; } init(): void { this.__initDimension(); this.__initListeners(); } destroy(): void { this.__destroyListeners(); } push(notification: Notification): void { notification.id = this.__nextNotificationId(); this.__animationQueue.push({ animate: this.__show, context: this, args: [notification], }); } closeAll(): void { this.__animationQueue.clear(); this.__activated.forEach(a => { a.browserWindow.close(); }); this.__activated = []; this.__delayQueue = []; this.__closed.clear(); this.__windowPooler.closeAll(); } private __show( notification: Notification ): Promise { const __this = this; return new Promise( (resolve, reject) => { const nextPosition = __this.__nextPosition(notification); if (!!nextPosition) { __this .__getWindow(notification) .then(browserWindow => { browserWindow.setPosition(nextPosition.x, nextPosition.y); __this.__activated.push({ notification, browserWindow, }); const displayTime = !!notification.displayTime ? notification.displayTime : !!__this.__defaultOptions.displayTime ? __this.__defaultOptions.displayTime : 5000; let timeoutId: any; const onCloseFnc = __this.__onCloseFnc( browserWindow, notification, () => timeoutId ); const onCloseFncGraceful = __this.__onCloseFnc4Graceful( onCloseFnc ); timeoutId = setTimeout(() => { if (browserWindow.isDestroyed()) { return; } onCloseFncGraceful('timeout'); }, displayTime); if (!!notification.onShow) { notification.onShow(); } browserWindow.webContents.send( NotificationChannel.ready4NotificationWindow ); browserWindow.showInactive(); return resolve(browserWindow); }) .catch(reason => {}); } else { __this.__delayQueue.push(notification); return resolve(undefined); } } ); } private __getWindow( notification: Notification ): Promise { const __this = this; return new Promise((resolve, reject) => { if (0 < __this.__windowPooler.length) { const browserWindow = __this.__windowPooler.pop(); if (!!browserWindow) { resolve(browserWindow); } } else { const constructorOptions = _.assignIn( this.__defaultOptions.optionsForBrowserWindow, notification.optionsForBrowserWindow, { width: this.__width, title: 'Notification', } ); const browserWindow = new Electron.BrowserWindow(constructorOptions); browserWindow.setVisibleOnAllWorkspaces(true); browserWindow.loadURL(__this.__templateUrl(notification)); browserWindow.webContents.once('did-finish-load', () => { browserWindow.webContents.send( NotificationChannel.init4NotificationWindow, notification.data ); resolve(browserWindow); }); } }); } private __nextNotificationId(): number { this.__notificationId++; if (Number.MAX_SAFE_INTEGER < this.__notificationId) { this.__notificationId = 0; } return this.__notificationId; } private __nextPosition( notification: Notification ): Electron.Point | undefined { const itemTotalHeight = this.__activated.reduce((a, b) => { return a + b.browserWindow.getBounds().height; }, 0); const itemWidth = this.__width; const itemHeight = !!notification.optionsForBrowserWindow?.height ? notification.optionsForBrowserWindow?.height : !!this.__defaultOptions.optionsForBrowserWindow?.height ? this.__defaultOptions.optionsForBrowserWindow?.height : 10; const totalHeight = itemTotalHeight + this.__paddingBottom + this.__paddingItem * (1 < this.__activated.length ? this.__activated.length - 1 : 0); const nextTotalHeight = totalHeight + itemHeight + this.__paddingItem; if (this.__availableHeight < nextTotalHeight) { return undefined; } if (!!notification.optionsForBrowserWindow) { notification.optionsForBrowserWindow.height = itemHeight; } return { x: this.__basePosition.x - this.__width, y: this.__basePosition.y - nextTotalHeight, }; } private __onCloseFnc4Graceful(onClose: OnClose4NotificationWindow) { const __this = this; return (reason: any): void => { if (!reason) { reason = 'closedGracefully'; } __this.__animationQueue.push({ animate: onClose, context: __this, args: [reason], }); }; } private __onCloseFnc( browserWindow: Electron.BrowserWindow, notification: Notification, timeoutIdFnc?: () => any ) { const __this = this; return (): Promise => { return new Promise((resolve, reject) => { if (browserWindow.isDestroyed()) { return resolve(); } const notificationId = !!notification.id ? notification.id : 0; if (__this.__closed.has(notificationId)) { __this.__closed.delete(notificationId); return resolve(); } else { __this.__closed.set(notificationId, true); } browserWindow.webContents.send( NotificationChannel.reset4NotificationWindow ); if (!!timeoutIdFnc) { clearTimeout(timeoutIdFnc()); } const i = __this.__activated.findIndex( w => w.browserWindow.id === browserWindow.id ); __this.__activated.splice(i, 1); __this.__windowPooler.push(browserWindow); browserWindow.hide(); __this.__check4Queued(); __this.__animate4Move(i); }); }; } private __findActivatedByNotificationId(id: number): Activated | undefined { const i = this.__activated.findIndex(a => a.notification.id === id); if (-1 === i) { return undefined; } return this.__activated[i]; } private __findActivatedByBrowserWindowId(id: number): Activated | undefined { const i = this.__activated.findIndex(a => a.browserWindow.id === id); if (-1 === i) { return undefined; } return this.__activated[i]; } private __check4Queued(): void { if ( 0 < this.__delayQueue.length && !!this.__nextPosition(this.__delayQueue[0]) ) { this.__animationQueue.push({ animate: this.__show, context: this, args: [this.__delayQueue.shift()], }); } } private __animate4Move(startIndex: number): Promise { const __this = this; return new Promise(async (resolve, reject) => { if (startIndex >= __this.__activated.length || -1 === startIndex) { return resolve(); } const indexes: number[] = []; for (let i = startIndex; i < __this.__activated.length; i++) { indexes.push(i); } await Promise.all( indexes.map(async index => { await __this.__animateFnc4Move(index); }) ); return resolve(); }); } private __animateFnc4Move(index: number): Promise { const __this = this; return new Promise((resolve, reject) => { const activated = __this.__activated[index]; const startY = activated.browserWindow.getPosition()[1]; const endY = startY + (!!activated.notification.optionsForBrowserWindow?.height ? activated.notification.optionsForBrowserWindow?.height : 10); const animationStep = !!__this.__defaultOptions.animation?.step ? __this.__defaultOptions.animation?.step : 5; const animationTimeForStep = !!this.__defaultOptions.animation ?.timeForStep ? this.__defaultOptions.animation?.timeForStep : 20; const step = (endY - startY) / animationStep; let currentStep = 1; const intervalId = setInterval(() => { if (currentStep === animationStep) { activated.browserWindow.setPosition(__this.__basePosition.x, endY); clearInterval(intervalId); return resolve(); } activated.browserWindow.setPosition( __this.__basePosition.x, Math.trunc(startY + currentStep * step) ); currentStep++; }, animationTimeForStep); }); } private __templateUrl(notification: Notification): string { const filePath2Url = (filePath: string) => { return url.format({ protocol: 'file:', pathname: filePath, slashes: true, }); }; const html2Url = (html: string) => { return 'data:text/html,' + encodeURIComponent(html); }; if (!!notification.template.filePath) { return filePath2Url(notification.template.filePath); } if (!!notification.template.html) { return html2Url(notification.template.html); } if (!!this.__defaultOptions.template.filePath) { return filePath2Url(this.__defaultOptions.template.filePath); } if (!!this.__defaultOptions.template.html) { return html2Url(this.__defaultOptions.template.html); } return !!defaultOptions.template.html ? defaultOptions.template.html : ''; } private __initDimension(): void { const display = Electron.screen.getPrimaryDisplay(); this.__basePosition = { x: display.bounds.x + display.workArea.x + display.workAreaSize.width, y: display.bounds.y + display.workArea.y + display.workAreaSize.height, }; this.__width = !!this.__defaultOptions.optionsForBrowserWindow?.width && this.__defaultOptions.optionsForBrowserWindow?.width < display.workAreaSize.width ? this.__defaultOptions.optionsForBrowserWindow?.width : display.workAreaSize.width; this.__availableHeight = display.workAreaSize.height; } private __initListeners(): void { Electron.ipcMain.on( NotificationWindowChannel.onClose, this.__onClose4NotificationWindow.bind(this) ); Electron.ipcMain.on( NotificationWindowChannel.onClick, this.__onClick4NotificationWindow.bind(this) ); Electron.ipcMain.on( NotificationWindowChannel.onMessage, this.__onMessage4NotificationWindow.bind(this) ); Electron.screen.on( 'display-added', this.__onDisplayAdded4ElectronScreen.bind(this) ); Electron.screen.on( 'display-removed', this.__onDisplayRemoved4ElectronScreen.bind(this) ); Electron.screen.on( 'display-metrics-changed', this.__onDisplayMetricsChanged4ElectronScreen.bind(this) ); } private __destroyListeners(): void { Electron.ipcMain.off( NotificationWindowChannel.onClose, this.__onClose4NotificationWindow.bind(this) ); Electron.ipcMain.off( NotificationWindowChannel.onClick, this.__onClick4NotificationWindow.bind(this) ); Electron.ipcMain.off( NotificationWindowChannel.onMessage, this.__onMessage4NotificationWindow.bind(this) ); Electron.screen.off( 'display-added', this.__onDisplayAdded4ElectronScreen.bind(this) ); Electron.screen.off( 'display-removed', this.__onDisplayRemoved4ElectronScreen.bind(this) ); Electron.screen.off( 'display-metrics-changed', this.__onDisplayMetricsChanged4ElectronScreen.bind(this) ); } private __onClose4NotificationWindow( event: Electron.IpcMainEvent, notificationId: number ): void { const activated = this.__findActivatedByNotificationId(notificationId); if (!activated || !activated.notification.onClose) { return; } activated.notification.onClose(); } private __onClick4NotificationWindow( event: Electron.IpcMainEvent, notificationId: number ): void { const activated = this.__findActivatedByNotificationId(notificationId); if (!activated || !activated.notification.onClick) { return; } activated.notification.onClick(); } private __onMessage4NotificationWindow( event: Electron.IpcMainEvent, notificationId: number, channel: string, ...args: any[] ): void { const activated = this.__findActivatedByNotificationId(notificationId); if (!activated || !activated.notification.onMessage) { return; } activated.notification.onMessage(channel, ...args); } private __onDisplayAdded4ElectronScreen( event: Electron.Event, newDisplay: Electron.Display ): void { this.__initDimension(); } private __onDisplayRemoved4ElectronScreen( event: Electron.Event, oldDisplay: Electron.Display ): void { this.__initDimension(); } private __onDisplayMetricsChanged4ElectronScreen( event: Electron.Event, display: Electron.Display, changedMetrics: string[] // bounds, workArea, scaleFactor, rotation ): void { this.__initDimension(); } }