660 lines
17 KiB
TypeScript
660 lines
17 KiB
TypeScript
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: `
|
|
<html>\n
|
|
<head></head>\n
|
|
<body style="overflow: hidden; -webkit-user-select: none;">\n
|
|
<div id="container">\n
|
|
<img src="" id="appIcon" />\n
|
|
<img src="" id="image" />\n
|
|
<div id="text">\n
|
|
<b id="title"></b>\n
|
|
<p id="message"></p>\n
|
|
</div>\n
|
|
<div id="close">X</div>\n
|
|
</div>\n
|
|
</body>\n
|
|
</html>
|
|
`,
|
|
},
|
|
};
|
|
|
|
export type OnShow4NotificationWindow = () => Promise<void>;
|
|
export type OnClose4NotificationWindow = () => Promise<void>;
|
|
export type OnClick4NotificationWindow = () => Promise<void>;
|
|
export type OnMessage4NotificationWindow = (
|
|
channel: string,
|
|
...args: any[]
|
|
) => Promise<void>;
|
|
|
|
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<number, boolean>();
|
|
|
|
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<Electron.BrowserWindow | undefined> {
|
|
const __this = this;
|
|
return new Promise<Electron.BrowserWindow | undefined>(
|
|
(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<Electron.BrowserWindow> {
|
|
const __this = this;
|
|
return new Promise<Electron.BrowserWindow>((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<void> => {
|
|
return new Promise<void>((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<void> {
|
|
const __this = this;
|
|
return new Promise<void>(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<void> {
|
|
const __this = this;
|
|
|
|
return new Promise<void>((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();
|
|
}
|
|
}
|