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
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();
}
}