457 lines
13 KiB
TypeScript
Raw Normal View History

2019-11-09 17:29:02 +09:00
import * as path from 'path';
import * as url from 'url';
2019-11-11 15:53:39 +09:00
import * as fse from 'fs-extra';
2019-11-09 17:29:02 +09:00
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<number, boolean>;
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 {
2019-11-11 15:53:39 +09:00
fse.statSync(this.customOptions.templatePath).isFile();
this.templateUrl = url.format({
pathname: this.customOptions.templatePath,
protocol: 'file:',
slashes: true
});
2019-11-09 17:29:02 +09:00
} 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<any> {
const self = this;
return new Promise<any>((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<void> => {
if (notificationWindow.isDestroyed()) {
return;
}
if (self.closedNotifications.has(notification.id)) {
self.closedNotifications.delete(notification.id);
return new Promise<void>(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<BrowserWindow> {
const slef = this;
return new Promise<BrowserWindow>((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<void> {
const self = this;
return new Promise<void>(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<void> {
const self = this;
return new Promise<void>((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);
});
}
}