diff --git a/demo/index.html b/demo/index.html index 5599c2af1..2b6197c20 100644 --- a/demo/index.html +++ b/demo/index.html @@ -132,7 +132,9 @@

Symphony Electron API Demo

num++; - var notf = new ssf.Notification(title, { + var notf = { + id: num, + title, body: (body + ' num=' + num + ' tag=' + tag), image: imageUrl, flash: shouldFlash, @@ -142,24 +144,11 @@

Symphony Electron API Demo

hello: 'hello word' }, tag: tag, - company: company - }); - - notf.addEventListener('click', onclick); - function onclick(event) { - event.target.close(); - alert('notification clicked: ' + event.target.data.hello); - } - - notf.addEventListener('close', onclose); - function onclose() { - alert('notification closed'); + company: company, + method: 'notification', }; - notf.addEventListener('error', onerror); - function onerror(event) { - alert('error=' + event.result); - }; + window.postMessage({ method: 'notification', data: notf }, '*'); }); var badgeCount = 0; @@ -292,5 +281,7 @@

Symphony Electron API Demo

document.location.reload(); }); + window.addEventListener('message', (event) => console.log(event)); + diff --git a/gulpfile.js b/gulpfile.js index 4cd62bc1a..9a118e0be 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -12,7 +12,7 @@ gulp.task('clean', function() { }); gulp.task('compile', function() { - return gulp.src(['src/**/*.ts']) + return gulp.src(['src/**/*.ts', 'src/**/*.tsx']) .pipe(tsc({ project: './tsconfig.json' })) .pipe(gulp.dest('lib/')) }); @@ -35,4 +35,4 @@ gulp.task('copy', function () { }).pipe(gulp.dest('lib/src')) }); -gulp.task('build', gulp.series('clean', 'compile', 'less', 'copy')); \ No newline at end of file +gulp.task('build', gulp.series('clean', 'compile', 'less', 'copy')); diff --git a/package.json b/package.json index dace4d187..d0b1488f5 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "Symphony", "productName": "Symphony", - "version": "4.5.0", - "clientVersion": "1.55.0", + "version": "5.0.0", + "clientVersion": "1.55", "buildNumber": "0", "description": "Symphony desktop app (Foundation ODP)", "author": "Symphony", @@ -16,7 +16,7 @@ "browserify-preload": "browserify -o lib/src/renderer/_preload-main.js -x electron --insert-global-vars=__filename,__dirname lib/src/renderer/preload-main.js", "rebuild": "electron-rebuild -f", "dev": "npm run prebuild && cross-env ELECTRON_DEV=true electron .", - "test": "npm run lint && npm rebuild --build-from-source && cross-env ELECTRON_QA=true jest --config jest.unit.config.json --runInBand && npm run rebuild", + "test": "npm run lint && cross-env ELECTRON_QA=true jest --config jest.unit.config.json --runInBand", "demo-win": "npm run prebuild && cross-env ELECTRON_DEV=true electron . --url=file:///demo/index.html", "demo-mac": "npm run prebuild && cross-env ELECTRON_DEV=true electron . --url=file://$(pwd)/demo/index.html", "unpacked-mac": "npm run prebuild && npm run test && build --mac --dir", diff --git a/spec/screenSharingIndicator.spec.ts b/spec/screenSharingIndicator.spec.ts index 6cd0a2341..8fb535f41 100644 --- a/spec/screenSharingIndicator.spec.ts +++ b/spec/screenSharingIndicator.spec.ts @@ -30,9 +30,11 @@ describe('screen sharing indicator', () => { const closeIpcRendererMock = { cmd: 'close-window', windowType: 'screen-sharing-indicator', + winKey: 'id-123', }; const spy = jest.spyOn(ipcRenderer, sendEventLabel); const wrapper = shallow(React.createElement(ScreenSharingIndicator)); + wrapper.setState({ streamId: 'id-123' }); wrapper.find(customSelector).simulate('click'); expect(spy).lastCalledWith(symphonyAPIEventLabel, closeIpcRendererMock); }); diff --git a/src/app/activity-detection.ts b/src/app/activity-detection.ts index 4cdddb5c3..07cca7ea8 100644 --- a/src/app/activity-detection.ts +++ b/src/app/activity-detection.ts @@ -46,7 +46,9 @@ class ActivityDetection { const idleTimeInMillis = idleTime * 1000; if (idleTimeInMillis < this.idleThreshold) { this.sendActivity(idleTimeInMillis); - if (this.timer) clearInterval(this.timer); + if (this.timer) { + clearInterval(this.timer); + } this.timer = undefined; logger.info(`activity-detection: activity occurred`); return; @@ -78,4 +80,4 @@ class ActivityDetection { const activityDetection = new ActivityDetection(); -export { activityDetection }; \ No newline at end of file +export { activityDetection }; diff --git a/src/app/app-cache-handler.ts b/src/app/app-cache-handler.ts index 8b35e81f8..60157a9a8 100644 --- a/src/app/app-cache-handler.ts +++ b/src/app/app-cache-handler.ts @@ -22,4 +22,4 @@ export const cleanUpAppCache = async (): Promise => { */ export const createAppCacheFile = (): void => { fs.writeFileSync(cacheCheckFilePath, ''); -}; \ No newline at end of file +}; diff --git a/src/app/auto-launch-controller.ts b/src/app/auto-launch-controller.ts index d60460f28..f76051894 100644 --- a/src/app/auto-launch-controller.ts +++ b/src/app/auto-launch-controller.ts @@ -111,4 +111,4 @@ const autoLaunchInstance = new AutoLaunchController(props); export { autoLaunchInstance, -}; \ No newline at end of file +}; diff --git a/src/app/child-window-handler.ts b/src/app/child-window-handler.ts index bb43de4cb..6aabd0330 100644 --- a/src/app/child-window-handler.ts +++ b/src/app/child-window-handler.ts @@ -33,8 +33,12 @@ const getParsedUrl = (configURL: string): Url => { export const handleChildWindow = (webContents: WebContents): void => { const childWindow = (event, newWinUrl, frameName, disposition, newWinOptions): void => { const mainWindow = windowHandler.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed()) return; - if (!windowHandler.url) return; + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + if (!windowHandler.url) { + return; + } if (!newWinOptions.webPreferences) { newWinOptions.webPreferences = {}; @@ -104,7 +108,9 @@ export const handleChildWindow = (webContents: WebContents): void => { childWebContents.once('did-finish-load', async () => { const browserWin: ICustomBrowserWindow = BrowserWindow.fromWebContents(childWebContents) as ICustomBrowserWindow; - if (!browserWin) return; + if (!browserWin) { + return; + } windowHandler.addWindow(newWinKey, browserWin); browserWin.webContents.send('page-load', { isWindowsOS }); // Inserts css on to the window @@ -129,4 +135,4 @@ export const handleChildWindow = (webContents: WebContents): void => { } }; webContents.on('new-window', childWindow); -}; \ No newline at end of file +}; diff --git a/src/app/chrome-flags.ts b/src/app/chrome-flags.ts index 0e6b2e7fa..79ba61c55 100644 --- a/src/app/chrome-flags.ts +++ b/src/app/chrome-flags.ts @@ -63,4 +63,4 @@ export const setChromeFlags = () => { } } } -}; \ No newline at end of file +}; diff --git a/src/app/config-handler.ts b/src/app/config-handler.ts index 5f6138ae7..c5d80a907 100644 --- a/src/app/config-handler.ts +++ b/src/app/config-handler.ts @@ -224,4 +224,4 @@ const config = new Config(); export { config, -}; \ No newline at end of file +}; diff --git a/src/app/crypto-handler.ts b/src/app/crypto-handler.ts index ecde2d818..296fbaf39 100644 --- a/src/app/crypto-handler.ts +++ b/src/app/crypto-handler.ts @@ -10,7 +10,7 @@ import { logger } from '../common/logger'; const TAG_LENGTH = 16; const arch = process.arch === 'ia32'; const winLibraryPath = isDevEnv ? path.join(__dirname, '..', '..', 'library') : path.join(execPath, 'library'); -const macLibraryPath = isDevEnv ? path.join(__dirname, '..', '..', 'library') : path.join(execPath, '..', 'library'); +const macLibraryPath = isDevEnv ? path.join(__dirname, '..', '..', '..', 'library') : path.join(execPath, '..', 'library'); const cryptoLibPath = isMac ? path.join(macLibraryPath, 'cryptoLib.dylib') : @@ -133,4 +133,4 @@ class CryptoLibrary implements ICryptoLib { const cryptoLibrary = new CryptoLibrary(); -export { cryptoLibrary }; \ No newline at end of file +export { cryptoLibrary }; diff --git a/src/app/dialog-handler.ts b/src/app/dialog-handler.ts index 7a1f89ec9..475a2eca3 100644 --- a/src/app/dialog-handler.ts +++ b/src/app/dialog-handler.ts @@ -96,8 +96,12 @@ electron.app.on('certificate-error', (event, webContents, url, error, _certifica */ export const showLoadFailure = (browserWindow: Electron.BrowserWindow, url: string, errorDesc: string, errorCode: number, retryCallback: () => void, showDialog: boolean): void => { let message = url ? `${i18n.t('Error loading URL')()}:\n${url}` : i18n.t('Error loading window')(); - if (errorDesc) message += `\n\n${errorDesc}`; - if (errorCode) message += `\n\nError Code: ${errorCode}`; + if (errorDesc) { + message += `\n\n${errorDesc}`; + } + if (errorCode) { + message += `\n\nError Code: ${errorCode}`; + } // async handle of user input const response = (buttonId: number): void => { @@ -132,4 +136,4 @@ export const showLoadFailure = (browserWindow: Electron.BrowserWindow, url: stri export const showNetworkConnectivityError = (browserWindow: Electron.BrowserWindow, url: string = '', retryCallback: () => void): void => { const errorDesc = i18n.t('Network connectivity has been lost. Check your internet connection.')(); showLoadFailure(browserWindow, url, errorDesc, 0, retryCallback, true); -}; \ No newline at end of file +}; diff --git a/src/app/main-api-handler.ts b/src/app/main-api-handler.ts index eae5bfa28..b218627d0 100644 --- a/src/app/main-api-handler.ts +++ b/src/app/main-api-handler.ts @@ -80,7 +80,9 @@ ipcMain.on(apiName.symphonyApi, (event: Electron.Event, arg: IApiArgs) => { // validates the user bring to front config and activates the wrapper if (typeof arg.reason === 'string' && arg.reason === 'notification') { const shouldBringToFront = config.getConfigFields([ 'bringToFront' ]); - if (shouldBringToFront) activate(arg.windowName, false); + if (shouldBringToFront) { + activate(arg.windowName, false); + } } break; case apiCmds.openScreenPickerWindow: @@ -95,21 +97,6 @@ ipcMain.on(apiName.symphonyApi, (event: Electron.Event, arg: IApiArgs) => { } break; } - /*case ApiCmds.optimizeMemoryConsumption: - if (typeof arg.memory === 'object' - && typeof arg.cpuUsage === 'object' - && typeof arg.memory.workingSetSize === 'number') { - setPreloadMemoryInfo(arg.memory, arg.cpuUsage); - } - break; - case ApiCmds.optimizeMemoryRegister: - setPreloadWindow(event.sender); - break; - case ApiCmds.setIsInMeeting: - if (typeof arg.isInMeeting === 'boolean') { - setIsInMeeting(arg.isInMeeting); - } - break;*/ case apiCmds.setLocale: if (typeof arg.locale === 'string') { updateLocale(arg.locale as LocaleType); @@ -124,12 +111,12 @@ ipcMain.on(apiName.symphonyApi, (event: Electron.Event, arg: IApiArgs) => { screenSnippet.capture(event.sender); break; case apiCmds.closeWindow: - windowHandler.closeWindow(arg.windowType); + windowHandler.closeWindow(arg.windowType, arg.winKey); break; case apiCmds.openScreenSharingIndicator: - const { displayId, id } = arg; - if (typeof displayId === 'string' && typeof id === 'number') { - windowHandler.createScreenSharingIndicatorWindow(event.sender, displayId, id); + const { displayId, id, streamId } = arg; + if (typeof displayId === 'string' && typeof id === 'number' && typeof streamId === 'string') { + windowHandler.createScreenSharingIndicatorWindow(event.sender, displayId, id, streamId); } break; case apiCmds.downloadManagerAction: diff --git a/src/app/main.ts b/src/app/main.ts index 3c72bcebf..1d5792e7a 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -66,7 +66,9 @@ if (!allowMultiInstance) { // Someone tried to run a second instance, we should focus our window. const mainWindow = windowHandler.getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { - if (isMac) return mainWindow.show(); + if (isMac) { + return mainWindow.show(); + } if (mainWindow.isMinimized()) { mainWindow.restore(); } @@ -117,4 +119,4 @@ app.on('activate', () => { * * This event is emitted only on macOS at this moment */ -app.on('open-url', (_event, url) => protocolHandler.sendProtocol(url)); \ No newline at end of file +app.on('open-url', (_event, url) => protocolHandler.sendProtocol(url)); diff --git a/src/app/protocol-handler.ts b/src/app/protocol-handler.ts index c198ad95a..d03f29ba1 100644 --- a/src/app/protocol-handler.ts +++ b/src/app/protocol-handler.ts @@ -69,4 +69,4 @@ class ProtocolHandler { const protocolHandler = new ProtocolHandler(); -export { protocolHandler }; \ No newline at end of file +export { protocolHandler }; diff --git a/src/app/reports-handler.ts b/src/app/reports-handler.ts index 0c71db32b..b366fe851 100644 --- a/src/app/reports-handler.ts +++ b/src/app/reports-handler.ts @@ -131,4 +131,4 @@ export const exportCrashDumps = (): void => { }); } }); -}; \ No newline at end of file +}; diff --git a/src/app/screen-snippet-handler.ts b/src/app/screen-snippet-handler.ts index f4ecb057d..0719dcfe4 100644 --- a/src/app/screen-snippet-handler.ts +++ b/src/app/screen-snippet-handler.ts @@ -53,7 +53,9 @@ class ScreenSnippet { updateAlwaysOnTop(false, false); } // only allow one screen capture at a time. - if (this.child) this.child.kill(); + if (this.child) { + this.child.kill(); + } try { await this.execCmd(this.captureUtil, this.captureUtilArgs); const { message, data, type }: IScreenSnippet = await this.convertFileToData(); @@ -86,7 +88,9 @@ class ScreenSnippet { private execCmd(captureUtil: string, captureUtilArgs: ReadonlyArray): Promise { return new Promise((resolve, reject) => { return this.child = execFile(captureUtil, captureUtilArgs, (error: ExecException | null) => { - if (this.isAlwaysOnTop) updateAlwaysOnTop(true, false); + if (this.isAlwaysOnTop) { + updateAlwaysOnTop(true, false); + } if (error && error.killed) { // processs was killed, just resolve with no data. return reject(error); @@ -140,4 +144,4 @@ class ScreenSnippet { const screenSnippet = new ScreenSnippet(); -export { screenSnippet }; \ No newline at end of file +export { screenSnippet }; diff --git a/src/app/spell-check-handler.ts b/src/app/spell-check-handler.ts index 96da10cfc..034204d82 100644 --- a/src/app/spell-check-handler.ts +++ b/src/app/spell-check-handler.ts @@ -99,4 +99,4 @@ export class SpellChecker { } return menu; } -} \ No newline at end of file +} diff --git a/src/app/window-actions.ts b/src/app/window-actions.ts index e55b2d5ce..e399a2f21 100644 --- a/src/app/window-actions.ts +++ b/src/app/window-actions.ts @@ -1,7 +1,7 @@ import { BrowserWindow } from 'electron'; import { apiName, IBoundsChange, KeyCodes } from '../common/api-interface'; -import { isWindowsOS } from '../common/env'; +import { isMac, isWindowsOS } from '../common/env'; import { throttle } from '../common/utils'; import { config } from './config-handler'; import { ICustomBrowserWindow, windowHandler } from './window-handler'; @@ -14,7 +14,7 @@ export const saveWindowSettings = (): void => { const [ x, y ] = browserWindow.getPosition(); const [ width, height ] = browserWindow.getSize(); if (x && y && width && height) { - browserWindow.webContents.send('boundChanges', { x, y, width, height, windowName: browserWindow.winName } as IBoundsChange); + browserWindow.webContents.send('boundsChange', { x, y, width, height, windowName: browserWindow.winName } as IBoundsChange); if (browserWindow.winName === apiName.mainWindowName) { const isMaximized = browserWindow.isMaximized(); @@ -54,19 +54,26 @@ export const throttledWindowChanges = throttle(saveWindowSettings, 1000); export const activate = (windowName: string, shouldFocus: boolean = true): void => { // Electron-136: don't activate when the app is reloaded programmatically - if (windowHandler.isAutoReload) return; + if (windowHandler.isAutoReload) { + return; + } const windows = windowHandler.getAllWindows(); for (const key in windows) { - if (windows.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(windows, key)) { const window = windows[ key ]; if (window && !window.isDestroyed() && window.winName === windowName) { // Bring the window to the top without focusing // Flash task bar icon in Windows for windows if (!shouldFocus) { - window.moveTop(); - return isWindowsOS ? window.flashFrame(true) : null; + return isMac ? window.showInactive() : window.flashFrame(true); + } + + // Note: On window just focusing will preserve window snapped state + // Hiding the window and just calling the focus() won't display the window + if (isWindowsOS) { + return window.isMinimized() ? window.restore() : window.focus(); } return window.isMinimized() ? window.restore() : window.show(); @@ -130,11 +137,15 @@ export const handleKeyPress = (key: number): void => { * @param window {BrowserWindow} */ export const monitorWindowActions = (window: BrowserWindow): void => { - if (!window || window.isDestroyed()) return; + if (!window || window.isDestroyed()) { + return; + } const eventNames = [ 'move', 'resize', 'maximize', 'unmaximize' ]; eventNames.forEach((event: string) => { - // @ts-ignore - if (window) window.on(event, throttledWindowChanges); + if (window) { + // @ts-ignore + window.on(event, throttledWindowChanges); + } }); window.on('enter-full-screen', enterFullScreen); window.on('leave-full-screen', leaveFullScreen); @@ -146,12 +157,16 @@ export const monitorWindowActions = (window: BrowserWindow): void => { * @param window */ export const removeWindowEventListener = (window: BrowserWindow): void => { - if (!window || window.isDestroyed()) return; + if (!window || window.isDestroyed()) { + return; + } const eventNames = [ 'move', 'resize', 'maximize', 'unmaximize' ]; eventNames.forEach((event: string) => { - // @ts-ignore - if (window) window.removeListener(event, throttledWindowChanges); + if (window) { + // @ts-ignore + window.removeListener(event, throttledWindowChanges); + } }); window.removeListener('enter-full-screen', enterFullScreen); window.removeListener('leave-full-screen', leaveFullScreen); -}; \ No newline at end of file +}; diff --git a/src/app/window-handler.ts b/src/app/window-handler.ts index e395e3fab..33edc89de 100644 --- a/src/app/window-handler.ts +++ b/src/app/window-handler.ts @@ -14,7 +14,7 @@ import { handleChildWindow } from './child-window-handler'; import { config, IConfig } from './config-handler'; import { showNetworkConnectivityError } from './dialog-handler'; import { monitorWindowActions } from './window-actions'; -import { createComponentWindow, getBounds, handleDownloadManager, injectStyles } from './window-utils'; +import { createComponentWindow, getBounds, handleDownloadManager, injectStyles, windowExists } from './window-utils'; interface ICustomBrowserWindowConstructorOpts extends Electron.BrowserWindowConstructorOptions { winKey: string; @@ -189,7 +189,7 @@ export class WindowHandler { // Event needed to hide native menu bar on Windows 10 as we use custom menu bar this.mainWindow.webContents.once('did-start-loading', () => { - if ((this.config.isCustomTitleBar || isWindowsOS) && this.mainWindow && this.windowExists(this.mainWindow)) { + if ((this.config.isCustomTitleBar || isWindowsOS) && this.mainWindow && windowExists(this.mainWindow)) { this.mainWindow.setMenuBarVisibility(false); } }); @@ -204,25 +204,36 @@ export class WindowHandler { // Displays a dialog if network connectivity has been lost const retry = () => { - if (!this.mainWindow) return; - if (!this.isOnline) showNetworkConnectivityError(this.mainWindow, this.url, retry); + if (!this.mainWindow) { + return; + } + if (!this.isOnline) { + showNetworkConnectivityError(this.mainWindow, this.url, retry); + } this.mainWindow.webContents.reload(); }; - if (!this.isOnline && this.mainWindow) showNetworkConnectivityError(this.mainWindow, this.url, retry); + if (!this.isOnline && this.mainWindow) { + showNetworkConnectivityError(this.mainWindow, this.url, retry); + } // early exit if the window has already been destroyed - if (!this.mainWindow || !this.windowExists(this.mainWindow)) return; + if (!this.mainWindow || !windowExists(this.mainWindow)) { + return; + } this.url = this.mainWindow.webContents.getURL(); // Injects custom title bar css into the webContents // only for Window and if it is enabled await injectStyles(this.mainWindow, this.isCustomTitleBarAndWindowOS); - if (this.isCustomTitleBarAndWindowOS) this.mainWindow.webContents.send('initiate-custom-title-bar'); + if (this.isCustomTitleBarAndWindowOS) { + this.mainWindow.webContents.send('initiate-custom-title-bar'); + } this.mainWindow.webContents.send('page-load', { isWindowsOS, locale: i18n.getLocale(), resources: i18n.loadedResources, + origin: this.globalConfig.url, }); this.appMenu = new AppMenu(); @@ -239,9 +250,13 @@ export class WindowHandler { // Handle main window close this.mainWindow.on('close', (event) => { - if (!this.mainWindow || !this.windowExists(this.mainWindow)) return; + if (!this.mainWindow || !windowExists(this.mainWindow)) { + return; + } - if (this.willQuitApp) return this.destroyAllWindow(); + if (this.willQuitApp) { + return this.destroyAllWindow(); + } if (this.config.minimizeOnClose) { event.preventDefault(); @@ -285,16 +300,23 @@ export class WindowHandler { /** * Closes the window from an event emitted by the render processes * - * @param windowType + * @param windowType {WindowTypes} + * @param winKey {string} - Unique ID assigned to the window */ - public closeWindow(windowType: WindowTypes): void { + public closeWindow(windowType: WindowTypes, winKey?: string): void { switch (windowType) { case 'screen-picker': - if (this.screenPickerWindow && this.windowExists(this.screenPickerWindow)) this.screenPickerWindow.close(); + if (this.screenPickerWindow && windowExists(this.screenPickerWindow)) { + this.screenPickerWindow.close(); + } break; case 'screen-sharing-indicator': - if (this.screenSharingIndicatorWindow - && this.windowExists(this.screenSharingIndicatorWindow)) this.screenSharingIndicatorWindow.close(); + if (winKey) { + const browserWindow = this.windows[ winKey ]; + if (browserWindow && windowExists(browserWindow)) { + browserWindow.close(); + } + } break; default: break; @@ -329,7 +351,9 @@ export class WindowHandler { public showLoadingScreen(): void { this.loadingWindow = createComponentWindow('loading-screen', WindowHandler.getLoadingWindowOpts()); this.loadingWindow.webContents.once('did-finish-load', () => { - if (!this.loadingWindow || !this.windowExists(this.loadingWindow)) return; + if (!this.loadingWindow || !windowExists(this.loadingWindow)) { + return; + } this.loadingWindow.webContents.send('data'); }); @@ -342,7 +366,9 @@ export class WindowHandler { public createAboutAppWindow(): void { this.aboutAppWindow = createComponentWindow('about-app'); this.aboutAppWindow.webContents.once('did-finish-load', () => { - if (!this.aboutAppWindow || !this.windowExists(this.aboutAppWindow)) return; + if (!this.aboutAppWindow || !windowExists(this.aboutAppWindow)) { + return; + } this.aboutAppWindow.webContents.send('about-app-data', { buildNumber, clientVersion, version }); }); } @@ -353,7 +379,9 @@ export class WindowHandler { public createMoreInfoWindow(): void { this.moreInfoWindow = createComponentWindow('more-info'); this.moreInfoWindow.webContents.once('did-finish-load', () => { - if (!this.moreInfoWindow || !this.windowExists(this.moreInfoWindow)) return; + if (!this.moreInfoWindow || !windowExists(this.moreInfoWindow)) { + return; + } this.moreInfoWindow.webContents.send('more-info-data'); }); } @@ -367,18 +395,22 @@ export class WindowHandler { */ public createScreenPickerWindow(window: Electron.WebContents, sources: DesktopCapturerSource[], id: number): void { - if (this.screenPickerWindow && this.windowExists(this.screenPickerWindow)) this.screenPickerWindow.close(); + if (this.screenPickerWindow && windowExists(this.screenPickerWindow)) { + this.screenPickerWindow.close(); + } const opts = WindowHandler.getScreenPickerWindowOpts(); this.screenPickerWindow = createComponentWindow('screen-picker', opts); this.screenPickerWindow.webContents.once('did-finish-load', () => { - if (!this.screenPickerWindow || !this.windowExists(this.screenPickerWindow)) return; + if (!this.screenPickerWindow || !windowExists(this.screenPickerWindow)) { + return; + } this.screenPickerWindow.webContents.send('screen-picker-data', { sources, id }); this.addWindow(opts.winKey, this.screenPickerWindow); }); ipcMain.once('screen-source-selected', (_event, source) => { window.send('start-share' + id, source); - if (this.screenPickerWindow && this.windowExists(this.screenPickerWindow)) { + if (this.screenPickerWindow && windowExists(this.screenPickerWindow)) { this.screenPickerWindow.close(); } }); @@ -388,50 +420,6 @@ export class WindowHandler { }); } - /** - * Creates a screen sharing indicator whenever uses start - * sharing the screen - * - * @param screenSharingWebContents {Electron.webContents} - * @param displayId {string} - * @param id {number} - */ - public createScreenSharingIndicatorWindow(screenSharingWebContents: Electron.webContents, displayId: string, id: number): void { - - if (this.screenSharingIndicatorWindow - && this.windowExists(this.screenSharingIndicatorWindow)) this.screenSharingIndicatorWindow.close(); - - const indicatorScreen = - (displayId && electron.screen.getAllDisplays().filter((d) => - displayId.includes(d.id.toString()))[ 0 ]) || electron.screen.getPrimaryDisplay(); - - const screenRect = indicatorScreen.workArea; - let opts = WindowHandler.getScreenSharingIndicatorOpts(); - if (opts.width && opts.height) { - opts = Object.assign({}, opts, { - x: screenRect.x + Math.round((screenRect.width - opts.width) / 2), - y: screenRect.y + screenRect.height - opts.height, - }); - } - this.screenSharingIndicatorWindow = createComponentWindow('screen-sharing-indicator', opts); - this.screenSharingIndicatorWindow.setVisibleOnAllWorkspaces(true); - this.screenSharingIndicatorWindow.webContents.once('did-finish-load', () => { - if (!this.screenSharingIndicatorWindow || !this.windowExists(this.screenSharingIndicatorWindow)) return; - this.screenSharingIndicatorWindow.webContents.send('screen-sharing-indicator-data', { id }); - }); - const stopScreenSharing = (_event, indicatorId) => { - if (id === indicatorId) { - screenSharingWebContents.send('screen-sharing-stopped', id); - } - }; - - this.screenSharingIndicatorWindow.once('close', () => { - ipcMain.removeListener('stop-screen-sharing', stopScreenSharing); - }); - - ipcMain.once('stop-screen-sharing', stopScreenSharing); - } - /** * Creates a Basic auth window whenever the network * requires authentications @@ -450,12 +438,16 @@ export class WindowHandler { this.basicAuthWindow = createComponentWindow('basic-auth', opts); this.basicAuthWindow.setVisibleOnAllWorkspaces(true); this.basicAuthWindow.webContents.once('did-finish-load', () => { - if (!this.basicAuthWindow || !this.windowExists(this.basicAuthWindow)) return; + if (!this.basicAuthWindow || !windowExists(this.basicAuthWindow)) { + return; + } this.basicAuthWindow.webContents.send('basic-auth-data', { hostname, isValidCredentials: isMultipleTries }); }); const closeBasicAuth = (shouldClearSettings = true) => { - if (shouldClearSettings) clearSettings(); - if (this.basicAuthWindow && !this.windowExists(this.basicAuthWindow)) { + if (shouldClearSettings) { + clearSettings(); + } + if (this.basicAuthWindow && !windowExists(this.basicAuthWindow)) { this.basicAuthWindow.close(); this.basicAuthWindow = null; } @@ -476,6 +468,58 @@ export class WindowHandler { ipcMain.once('basic-auth-login', login); } + /** + * Creates a screen sharing indicator whenever uses start + * sharing the screen + * + * @param screenSharingWebContents {Electron.webContents} + * @param displayId {string} - current display id + * @param id {number} - postMessage request id + * @param streamId {string} - MediaStream id + */ + public createScreenSharingIndicatorWindow( + screenSharingWebContents: Electron.webContents, + displayId: string, + id: number, + streamId, + ): void { + const indicatorScreen = + (displayId && electron.screen.getAllDisplays().filter((d) => + displayId.includes(d.id.toString()))[ 0 ]) || electron.screen.getPrimaryDisplay(); + + const screenRect = indicatorScreen.workArea; + // Set stream id as winKey to link stream to the window + let opts = { ...WindowHandler.getScreenSharingIndicatorOpts(), ...{ winKey: streamId } }; + if (opts.width && opts.height) { + opts = Object.assign({}, opts, { + x: screenRect.x + Math.round((screenRect.width - opts.width) / 2), + y: screenRect.y + screenRect.height - opts.height, + }); + } + this.screenSharingIndicatorWindow = createComponentWindow('screen-sharing-indicator', opts); + this.screenSharingIndicatorWindow.setVisibleOnAllWorkspaces(true); + this.screenSharingIndicatorWindow.webContents.once('did-finish-load', () => { + if (!this.screenSharingIndicatorWindow || !windowExists(this.screenSharingIndicatorWindow)) { + return; + } + this.screenSharingIndicatorWindow.webContents.send('screen-sharing-indicator-data', { id, streamId }); + }); + const stopScreenSharing = (_event, indicatorId) => { + if (id === indicatorId) { + screenSharingWebContents.send('screen-sharing-stopped', id); + } + }; + + this.addWindow(opts.winKey, this.screenSharingIndicatorWindow); + + this.screenSharingIndicatorWindow.once('close', () => { + this.removeWindow(streamId); + ipcMain.removeListener('stop-screen-sharing', stopScreenSharing); + }); + + ipcMain.once('stop-screen-sharing', stopScreenSharing); + } + /** * Opens an external url in the system's default browser * @@ -519,14 +563,6 @@ export class WindowHandler { this.mainWindow = null; } - /** - * Checks if window is valid and exists - * - * @param window - * @return boolean - */ - private windowExists = (window: BrowserWindow): boolean => !!window && typeof window.isDestroyed === 'function' && !window.isDestroyed(); - /** * Main window opts */ @@ -542,6 +578,7 @@ export class WindowHandler { nodeIntegration: false, preload: path.join(__dirname, '../renderer/_preload-main.js'), sandbox: true, + contextIsolation: true, }, winKey: getGuid(), }; @@ -550,4 +587,4 @@ export class WindowHandler { const windowHandler = new WindowHandler(); -export { windowHandler }; \ No newline at end of file +export { windowHandler }; diff --git a/src/app/window-utils.ts b/src/app/window-utils.ts index 6d3e0785b..878a7a5e5 100644 --- a/src/app/window-utils.ts +++ b/src/app/window-utils.ts @@ -29,7 +29,9 @@ export const preventWindowNavigation = (browserWindow: Electron.BrowserWindow, i if (browserWindow.isDestroyed() || browserWindow.webContents.isDestroyed() - || winUrl === browserWindow.webContents.getURL()) return; + || winUrl === browserWindow.webContents.getURL()) { + return; + } e.preventDefault(); }; @@ -46,10 +48,12 @@ export const preventWindowNavigation = (browserWindow: Electron.BrowserWindow, i * * @param componentName * @param opts + * @param shouldFocus {boolean} */ export const createComponentWindow = ( componentName: string, opts?: Electron.BrowserWindowConstructorOptions, + shouldFocus: boolean = true, ): BrowserWindow => { const options: Electron.BrowserWindowConstructorOptions = { @@ -67,9 +71,13 @@ export const createComponentWindow = ( }; const browserWindow: ICustomBrowserWindow = new BrowserWindow(options) as ICustomBrowserWindow; - browserWindow.on('ready-to-show', () => browserWindow.show()); + if (shouldFocus) { + browserWindow.once('ready-to-show', () => browserWindow.show()); + } browserWindow.webContents.once('did-finish-load', () => { - if (!browserWindow || browserWindow.isDestroyed()) return; + if (!browserWindow || browserWindow.isDestroyed()) { + return; + } browserWindow.webContents.send('set-locale-resource', { locale: i18n.getLocale(), resource: i18n.loadedResources }); }); browserWindow.setMenu(null as any); @@ -169,7 +177,9 @@ export const updateLocale = (locale: LocaleType): void => { // sets the new locale i18n.setLocale(locale); const appMenu = windowHandler.appMenu; - if (appMenu) appMenu.update(locale); + if (appMenu) { + appMenu.update(locale); + } }; /** @@ -181,7 +191,9 @@ export const showPopupMenu = (opts: Electron.PopupOptions): void => { const { x, y } = mainWindow.isFullScreen() ? { x: 0, y: 0 } : { x: 10, y: -20 }; const popupOpts = { window: mainWindow, x, y }; const appMenu = windowHandler.appMenu; - if (appMenu) appMenu.popupMenu({ ...popupOpts, ...opts }); + if (appMenu) { + appMenu.popupMenu({ ...popupOpts, ...opts }); + } } }; @@ -219,7 +231,9 @@ export const sanitize = (windowName: string): void => { * @return {x?: Number, y?: Number, width: Number, height: Number} */ export const getBounds = (winPos: Electron.Rectangle, defaultWidth: number, defaultHeight: number): Partial => { - if (!winPos) return { width: defaultWidth, height: defaultHeight }; + if (!winPos) { + return { width: defaultWidth, height: defaultHeight }; + } const displays = electron.screen.getAllDisplays(); for (let i = 0, len = displays.length; i < len; i++) { @@ -327,3 +341,11 @@ export const injectStyles = async (mainWindow: BrowserWindow, isCustomTitleBarAn return await readAndInsertCSS(mainWindow, paths); }; + +/** + * Checks if window is valid and exists + * + * @param window {BrowserWindow} + * @return boolean + */ +export const windowExists = (window: BrowserWindow): boolean => !!window && typeof window.isDestroyed === 'function' && !window.isDestroyed(); diff --git a/src/common/animation-queue.ts b/src/common/animation-queue.ts new file mode 100644 index 000000000..9d9c071e2 --- /dev/null +++ b/src/common/animation-queue.ts @@ -0,0 +1,50 @@ +import { logger } from './logger'; + +export class AnimationQueue { + private queue: any[] = []; + private running: boolean = false; + + constructor() { + this.animate = this.animate.bind(this); + } + + /** + * Pushes each animation to a queue + * + * @param object + */ + public push(object) { + if (this.running) { + this.queue.push(object); + } else { + this.running = true; + setTimeout(() => this.animate(object), 0); + } + } + + /** + * Animates an animation that is part of the queue + * @param object + */ + public async animate(object): Promise { + try { + await object.func.apply(null, object.args); + } catch (err) { + logger.error(`animationQueue: encountered an error: ${err} with stack trace: ${err.stack}`); + } finally { + if (this.queue.length > 0) { + // Run next animation + this.animate.call(this, this.queue.shift()); + } else { + this.running = false; + } + } + } + + /** + * Clears the queue + */ + public clear() { + this.queue = []; + } +} diff --git a/src/common/api-interface.ts b/src/common/api-interface.ts index fffff6069..b96628e88 100644 --- a/src/common/api-interface.ts +++ b/src/common/api-interface.ts @@ -1,5 +1,6 @@ export enum apiCmds { isOnline = 'is-online', + getVersionInfo = 'get-version-info', registerLogger = 'register-logger', setBadgeCount = 'set-badge-count', badgeDataUrl = 'badge-data-url', @@ -20,7 +21,11 @@ export enum apiCmds { keyPress = 'key-press', closeWindow = 'close-window', openScreenSharingIndicator = 'open-screen-sharing-indicator', + closeScreenSharingIndicator = 'close-screen-sharing-indicator', downloadManagerAction = 'download-manager-action', + getMediaSource = 'get-media-source', + notification = 'notification', + closeNotification = 'close-notification', } export enum apiName { @@ -43,6 +48,8 @@ export interface IApiArgs { locale: string; keyCode: number; windowType: WindowTypes; + winKey: string; + streamId: string; displayId: string; path: string; type: string; @@ -50,13 +57,6 @@ export interface IApiArgs { export type WindowTypes = 'screen-picker' | 'screen-sharing-indicator'; -/** - * Activity detection - */ -export interface IActivityDetection { - idleTime: number; -} - export interface IBadgeCount { count: number; } @@ -80,10 +80,28 @@ export interface IBoundsChange extends Electron.Rectangle { */ export interface IScreenSharingIndicator { type: string; + requestId: number; reason?: string; } export enum KeyCodes { Esc = 27, Alt = 18, -} \ No newline at end of file +} + +export interface IVersionInfo { + containerIdentifier: string; + containerVer: string; + buildNumber: string; + apiVer: string; + searchApiVer: string; +} + +export interface ILogMsg { + level: LogLevel; + details: any; + showInConsole: boolean; + startTime: number; +} + +export type LogLevel = 'error' | 'warn' | 'info' | 'verbose' | 'debug' | 'silly'; diff --git a/src/common/env.ts b/src/common/env.ts index d12a6f5f3..77bdcb186 100644 --- a/src/common/env.ts +++ b/src/common/env.ts @@ -5,4 +5,4 @@ export const isElectronQA = !!process.env.ELECTRON_QA; export const isMac = (process.platform === 'darwin'); export const isWindowsOS = (process.platform === 'win32'); -export const isNodeEnv = !!process.env.NODE_ENV; \ No newline at end of file +export const isNodeEnv = !!process.env.NODE_ENV; diff --git a/src/common/i18n-preload.ts b/src/common/i18n-preload.ts index c5e79337b..527554642 100644 --- a/src/common/i18n-preload.ts +++ b/src/common/i18n-preload.ts @@ -85,4 +85,4 @@ class Translation { const i18n = new Translation(); -export { i18n }; \ No newline at end of file +export { i18n }; diff --git a/src/common/i18n.ts b/src/common/i18n.ts index fdf1ca800..84af7e385 100644 --- a/src/common/i18n.ts +++ b/src/common/i18n.ts @@ -82,4 +82,4 @@ class Translation { const i18n = new Translation(); -export { i18n }; \ No newline at end of file +export { i18n }; diff --git a/src/common/logger.ts b/src/common/logger.ts index 45da66b5c..9bf00a97d 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -33,13 +33,19 @@ class Logger { this.loggerWindow = null; this.logQueue = []; + // If the user has specified a custom log path use it. + const customLogPathArg = getCommandLineArgs(process.argv, '--logPath=', false); + const customLogsFolder = customLogPathArg && customLogPathArg.substring(customLogPathArg.indexOf('=') + 1); + if (customLogsFolder && fs.existsSync(customLogsFolder)) { + app.setPath('logs', customLogsFolder); + } + this.logPath = app.getPath('logs'); if (!isElectronQA) { - transports.file.file = path.join(this.logPath, 'app.log'); + transports.file.file = path.join(this.logPath, `app_${Date.now()}.log`); transports.file.level = 'debug'; - transports.file.format = '{h}:{i}:{s}:{ms} {text}'; - transports.file.maxSize = 10 * 1024 * 1024; + transports.file.format = '{y}-{m}-{d} {h}:{i}:{s}:{ms} {z} | {level} | {text}'; transports.file.appName = 'Symphony'; } @@ -136,9 +142,15 @@ class Logger { if (this.loggerWindow) { const logMsgs: IClientLogMsg = {}; - if (this.logQueue.length) logMsgs.msgs = this.logQueue; - if (this.desiredLogLevel) logMsgs.logLevel = this.desiredLogLevel; - if (Object.keys(logMsgs).length) this.loggerWindow.send('log', logMsgs); + if (this.logQueue.length) { + logMsgs.msgs = this.logQueue; + } + if (this.desiredLogLevel) { + logMsgs.logLevel = this.desiredLogLevel; + } + if (Object.keys(logMsgs).length) { + this.loggerWindow.send('log', logMsgs); + } } } @@ -198,7 +210,7 @@ class Logger { } if (this.loggerWindow) { - this.loggerWindow.send('log', { msgs: [ logMsg ] }); + this.loggerWindow.send('log', { msgs: [ logMsg ], logLevel: this.desiredLogLevel, showInConsole: this.showInConsole }); } else { this.logQueue.push(logMsg); // don't store more than 100 msgs. keep most recent log msgs. @@ -230,4 +242,4 @@ class Logger { const logger = new Logger(); -export { logger }; \ No newline at end of file +export { logger }; diff --git a/src/common/utils.ts b/src/common/utils.ts index 1e96e484b..5a102751e 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -60,8 +60,12 @@ export const compareVersions = (v1: string, v2: string): number => { const n1 = parseInt(s1[i] || '0', 10); const n2 = parseInt(s2[i] || '0', 10); - if (n1 > n2) return 1; - if (n2 > n1) return -1; + if (n1 > n2) { + return 1; + } + if (n2 > n1) { + return -1; + } } if ([s1[2], s2[2]].every(patch.test.bind(patch))) { @@ -71,11 +75,19 @@ export const compareVersions = (v1: string, v2: string): number => { const p2 = patch.exec(s2[2])[1].split('.').map(tryParse); for (let k = 0; k < Math.max(p1.length, p2.length); k++) { - if (p1[k] === undefined || typeof p2[k] === 'string' && typeof p1[k] === 'number') return -1; - if (p2[k] === undefined || typeof p1[k] === 'string' && typeof p2[k] === 'number') return 1; - - if (p1[k] > p2[k]) return 1; - if (p2[k] > p1[k]) return -1; + if (p1[k] === undefined || typeof p2[k] === 'string' && typeof p1[k] === 'number') { + return -1; + } + if (p2[k] === undefined || typeof p1[k] === 'string' && typeof p2[k] === 'number') { + return 1; + } + + if (p1[k] > p2[k]) { + return 1; + } + if (p2[k] > p1[k]) { + return -1; + } } } else if ([s1[2], s2[2]].some(patch.test.bind(patch))) { return patch.test(s1[2]) ? -1 : 1; @@ -179,7 +191,9 @@ export const throttle = (func: (...args) => void, wait: number): (...args) => vo */ export const formatString = (str: string, data?: object): string => { - if (!str || !data) return str; + if (!str || !data) { + return str; + } for (const key in data) { if (Object.prototype.hasOwnProperty.call(data, key)) { @@ -193,4 +207,4 @@ export const formatString = (str: string, data?: object): string => { } } return str; -}; \ No newline at end of file +}; diff --git a/src/renderer/app-bridge.ts b/src/renderer/app-bridge.ts new file mode 100644 index 000000000..088e98ad5 --- /dev/null +++ b/src/renderer/app-bridge.ts @@ -0,0 +1,196 @@ +import { DesktopCapturerSource, remote } from 'electron'; + +import { + apiCmds, + IBoundsChange, + ILogMsg, + IScreenSharingIndicator, + IScreenSnippet, + LogLevel, +} from '../common/api-interface'; +import { IScreenSourceError } from './desktop-capturer'; +import { SSFApi } from './ssf-api'; + +const ssf = new SSFApi(); +const notification = remote.require('../renderer/notification').notification; + +export default class AppBridge { + + /** + * Validates the incoming postMessage + * events based on the host name + * + * @param event + */ + private static isValidEvent(event): boolean { + if (!event) { + return false; + } + return event.source && event.source === window; + } + + public origin: string; + + private readonly callbackHandlers = { + onMessage: (event) => this.handleMessage(event), + onActivityCallback: (idleTime: number) => this.activityCallback(idleTime), + onScreenSnippetCallback: (arg: IScreenSnippet) => this.screenSnippetCallback(arg), + onRegisterBoundsChangeCallback: (arg: IBoundsChange) => this.registerBoundsChangeCallback(arg), + onRegisterLoggerCallback: (msg: ILogMsg, logLevel: LogLevel, showInConsole: boolean) => + this.registerLoggerCallback(msg, logLevel, showInConsole), + onRegisterProtocolHandlerCallback: (uri: string) => this.protocolHandlerCallback(uri), + onScreenSharingIndicatorCallback: (arg: IScreenSharingIndicator) => this.screenSharingIndicatorCallback(arg), + onMediaSourceCallback: ( + requestId: number | undefined, + error: IScreenSourceError | null, + source: DesktopCapturerSource | undefined, + ): void => this.gotMediaSource(requestId, error, source), + onNotificationCallback: (event, data) => this.notificationCallback(event, data), + }; + + constructor() { + // starts with corporate pod and + // will be updated with the global config url + this.origin = 'https://corporate.symphony.com'; + window.addEventListener('message', this.callbackHandlers.onMessage); + } + + /** + * Switch case that validates and handle + * incoming messages from postMessage + * + * @param event + */ + private handleMessage(event): void { + if (!AppBridge.isValidEvent(event)) { + return; + } + + const { method, data } = event.data; + switch (method) { + case apiCmds.getVersionInfo: + this.broadcastMessage('get-version-info-callback', ssf.getVersionInfo()); + break; + case apiCmds.activate: + ssf.activate(data); + break; + case apiCmds.bringToFront: + const { windowName, reason } = data; + ssf.bringToFront(windowName, reason); + break; + case apiCmds.setBadgeCount: + if (typeof data === 'number') { + ssf.setBadgeCount(data); + } + break; + case apiCmds.setLocale: + if (typeof data === 'string') { + ssf.setLocale(data); + } + break; + case apiCmds.registerActivityDetection: + ssf.registerActivityDetection(data, this.callbackHandlers.onActivityCallback); + break; + case apiCmds.openScreenSnippet: + ssf.openScreenSnippet(this.callbackHandlers.onScreenSnippetCallback); + break; + case apiCmds.registerBoundsChange: + ssf.registerBoundsChange(this.callbackHandlers.onRegisterBoundsChangeCallback); + break; + case apiCmds.registerLogger: + ssf.registerLogger(this.callbackHandlers.onRegisterLoggerCallback); + break; + case apiCmds.registerProtocolHandler: + ssf.registerProtocolHandler(this.callbackHandlers.onRegisterProtocolHandlerCallback); + break; + case apiCmds.openScreenSharingIndicator: + ssf.showScreenSharingIndicator(data, this.callbackHandlers.onScreenSharingIndicatorCallback); + break; + case apiCmds.closeScreenSharingIndicator: + ssf.closeScreenSharingIndicator(data.streamId); + break; + case apiCmds.getMediaSource: + ssf.getMediaSource(data, this.callbackHandlers.onMediaSourceCallback); + break; + case apiCmds.notification: + notification.showNotification(data, this.callbackHandlers.onNotificationCallback); + break; + case apiCmds.closeNotification: + notification.hideNotification(data); + break; + } + } + + /** + * Broadcast user activity + * @param idleTime {number} - system idle tick + */ + private activityCallback = (idleTime: number): void => this.broadcastMessage('activity-callback', idleTime); + + /** + * Broadcast snippet data + * @param arg {IScreenSnippet} + */ + private screenSnippetCallback = (arg: IScreenSnippet): void => this.broadcastMessage('screen-snippet-callback', arg); + + /** + * Broadcast bound changes + * @param arg {IBoundsChange} + */ + private registerBoundsChangeCallback = (arg: IBoundsChange): void => this.broadcastMessage('bound-changes-callback', arg); + + /** + * Broadcast logs + * @param msg {ILogMsg} + * @param logLevel {LogLevel} + * @param showInConsole {boolean} + */ + private registerLoggerCallback(msg: ILogMsg, logLevel: LogLevel, showInConsole: boolean): void { + this.broadcastMessage('logger-callback', { msg, logLevel, showInConsole }); + } + + /** + * Broadcast protocol uri + * @param uri {string} + */ + private protocolHandlerCallback = (uri: string): void => this.broadcastMessage('protocol-callback', uri); + + /** + * Broadcast event that stops screen sharing + * @param arg {IScreenSharingIndicator} + */ + private screenSharingIndicatorCallback(arg: IScreenSharingIndicator): void { + this.broadcastMessage('screen-sharing-indicator-callback', arg); + } + + /** + * Broadcast the user selected source + * @param requestId {number} + * @param error {Error} + * @param source {DesktopCapturerSource} + */ + private gotMediaSource(requestId: number | undefined, error: IScreenSourceError | null, source: DesktopCapturerSource | undefined): void { + this.broadcastMessage('media-source-callback', { requestId, source, error }); + } + + /** + * Broadcast notification events + * + * @param event {string} + * @param data {Object} + */ + private notificationCallback(event, data) { + this.broadcastMessage(event, data); + } + + /** + * Method that broadcast messages to a specific origin via postMessage + * + * @param method {string} + * @param data {any} + */ + private broadcastMessage(method: string, data: any): void { + window.postMessage({ method, data }, this.origin); + } + +} diff --git a/src/renderer/assets/symphony-logo-black.png b/src/renderer/assets/symphony-logo-black.png new file mode 100644 index 000000000..8ba73198e Binary files /dev/null and b/src/renderer/assets/symphony-logo-black.png differ diff --git a/src/renderer/assets/symphony-logo-white.png b/src/renderer/assets/symphony-logo-white.png new file mode 100644 index 000000000..1a4841922 Binary files /dev/null and b/src/renderer/assets/symphony-logo-white.png differ diff --git a/src/renderer/components/about-app.tsx b/src/renderer/components/about-app.tsx index 60e2fae6d..42de6c1c6 100644 --- a/src/renderer/components/about-app.tsx +++ b/src/renderer/components/about-app.tsx @@ -60,4 +60,4 @@ export default class AboutApp extends React.Component<{}, IState> { private updateState(_event, data): void { this.setState(data as IState); } -} \ No newline at end of file +} diff --git a/src/renderer/components/download-manager.tsx b/src/renderer/components/download-manager.tsx index b7fed7334..091ac79c4 100644 --- a/src/renderer/components/download-manager.tsx +++ b/src/renderer/components/download-manager.tsx @@ -77,7 +77,9 @@ export default class DownloadManager extends React.Component<{}, IManagerState> * @param item */ private mapItems(item: IDownloadManager): JSX.Element | undefined { - if (!item) return; + if (!item) { + return; + } const { _id, total, fileName }: IDownloadManager = item; const fileDisplayName = this.getFileDisplayName(fileName); diff --git a/src/renderer/components/loading-screen.tsx b/src/renderer/components/loading-screen.tsx index 2507e8edf..2bc4eaf3c 100644 --- a/src/renderer/components/loading-screen.tsx +++ b/src/renderer/components/loading-screen.tsx @@ -23,4 +23,4 @@ export default class LoadingScreen extends React.PureComponent { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/more-info.tsx b/src/renderer/components/more-info.tsx index a15fd9601..abb061a2a 100644 --- a/src/renderer/components/more-info.tsx +++ b/src/renderer/components/more-info.tsx @@ -27,4 +27,4 @@ export default class MoreInfo extends React.Component<{}, {}> { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/notification-comp.tsx b/src/renderer/components/notification-comp.tsx new file mode 100644 index 000000000..9764a121e --- /dev/null +++ b/src/renderer/components/notification-comp.tsx @@ -0,0 +1,108 @@ +import classNames from 'classnames'; +import { ipcRenderer } from 'electron'; +import * as React from 'react'; + +import { i18n } from '../../common/i18n-preload'; + +const whiteColorRegExp = new RegExp(/^(?:white|#fff(?:fff)?|rgba?\(\s*255\s*,\s*255\s*,\s*255\s*(?:,\s*1\s*)?\))$/i); + +interface IState { + title: string; + company: string; + body: string; + image: string; + id: number; + color: string; +} + +type mouseEventButton = React.MouseEvent; + +export default class NotificationComp extends React.Component<{}, IState> { + + private readonly eventHandlers = { + onClose: (winKey) => (_event: mouseEventButton) => this.close(winKey), + onClick: (data) => (_event: mouseEventButton) => this.click(data), + }; + + constructor(props) { + super(props); + this.state = { + title: '', + company: 'Symphony', + body: '', + image: '', + id: 0, + color: '', + }; + this.updateState = this.updateState.bind(this); + } + + public componentDidMount(): void { + ipcRenderer.on('notification-data', this.updateState); + } + + public componentWillUnmount(): void { + ipcRenderer.removeListener('notification-data', this.updateState); + } + + /** + * Renders the custom title bar + */ + public render(): JSX.Element { + const { title, company, body, image, id, color } = this.state; + const isLightTheme = color ? color.match(whiteColorRegExp) : true; + + const theme = classNames({ light: isLightTheme, dark: !isLightTheme }); + const bgColor = { backgroundColor: color || '#ffffff' }; + + return ( +
+
+ symphony logo +
+
+ {title} + {company} + {body} +
+
+ user profile picture +
+
+ + + + +
+
+ ); + } + + /** + * Invoked when the notification window is clicked + * + * @param id {number} + */ + private click(id: number) { + ipcRenderer.send('notification-clicked', id); + } + + /** + * Closes the notification + * + * @param id {number} + */ + private close(id: number) { + ipcRenderer.send('close-notification', id); + } + + /** + * Sets the About app state + * + * @param _event + * @param data {Object} + */ + private updateState(_event, data): void { + this.setState(data as IState); + } +} diff --git a/src/renderer/components/screen-picker.tsx b/src/renderer/components/screen-picker.tsx index 23e48140c..56ce53ec4 100644 --- a/src/renderer/components/screen-picker.tsx +++ b/src/renderer/components/screen-picker.tsx @@ -62,7 +62,9 @@ export default class ScreenPicker extends React.Component<{}, IState> { public componentDidMount(): void { ipcRenderer.on('screen-picker-data', this.updateState); document.addEventListener('keyup', this.handleKeyUpPress, true); - if (isWindowsOS) document.body.classList.add('ScreenPicker-window-border'); + if (isWindowsOS) { + document.body.classList.add('ScreenPicker-window-border'); + } } public componentWillUnmount(): void { @@ -370,4 +372,4 @@ export default class ScreenPicker extends React.Component<{}, IState> { private updateState(_event, data): void { this.setState(data as IState); } -} \ No newline at end of file +} diff --git a/src/renderer/components/screen-sharing-indicator.tsx b/src/renderer/components/screen-sharing-indicator.tsx index 61bb4f1ec..1d44e98b1 100644 --- a/src/renderer/components/screen-sharing-indicator.tsx +++ b/src/renderer/components/screen-sharing-indicator.tsx @@ -8,6 +8,7 @@ import { i18n } from '../../common/i18n-preload'; interface IState { id: number; + streamId: string; } type mouseEventButton = React.MouseEvent; @@ -25,6 +26,7 @@ export default class ScreenSharingIndicator extends React.Component<{}, IState> super(props); this.state = { id: 0, + streamId: '', }; this.updateState = this.updateState.bind(this); } @@ -70,9 +72,11 @@ export default class ScreenSharingIndicator extends React.Component<{}, IState> * Closes the screen sharing indicator window */ private close(): void { + const { streamId } = this.state; ipcRenderer.send(apiName.symphonyApi, { cmd: apiCmds.closeWindow, windowType: 'screen-sharing-indicator', + winKey: streamId, }); } @@ -85,4 +89,4 @@ export default class ScreenSharingIndicator extends React.Component<{}, IState> private updateState(_event, data): void { this.setState(data as IState); } -} \ No newline at end of file +} diff --git a/src/renderer/components/snack-bar.tsx b/src/renderer/components/snack-bar.tsx index eb21b221c..e68bd29f4 100644 --- a/src/renderer/components/snack-bar.tsx +++ b/src/renderer/components/snack-bar.tsx @@ -66,4 +66,4 @@ export default class SnackBar extends React.Component<{}, IState> { ) :
; } -} \ No newline at end of file +} diff --git a/src/renderer/desktop-capturer.ts b/src/renderer/desktop-capturer.ts index 568c6df05..b3fca0d35 100644 --- a/src/renderer/desktop-capturer.ts +++ b/src/renderer/desktop-capturer.ts @@ -17,7 +17,16 @@ let nextId = 0; let isScreenShareEnabled = true; let screenShareArgv: string; -type CallbackType = (error: Error | null, source?: DesktopCapturerSource) => DesktopCapturerSource | Error; +export interface ICustomSourcesOptions extends SourcesOptions { + requestId?: number; +} + +export interface IScreenSourceError { + name: string; + message: string; +} + +export type CallbackType = (requestId: number | undefined, error: IScreenSourceError | null, source?: DesktopCapturerSource) => void; const getNextId = () => ++nextId; /** @@ -25,7 +34,7 @@ const getNextId = () => ++nextId; * @param options |options.type| can not be empty and has to include 'window' or 'screen'. * @returns {boolean} */ -const isValid = (options: SourcesOptions) => { +const isValid = (options: ICustomSourcesOptions) => { return ((options !== null ? options.types : undefined) !== null) && Array.isArray(options.types); }; @@ -36,19 +45,19 @@ const isValid = (options: SourcesOptions) => { * @param callback {CallbackType} * @returns {*} */ -export const getSource = (options: SourcesOptions, callback: CallbackType) => { +export const getSource = (options: ICustomSourcesOptions, callback: CallbackType) => { let captureWindow; let captureScreen; let id; const sourcesOpts: string[] = []; + const { requestId, ...updatedOptions } = options; if (!isValid(options)) { - callback(new Error('Invalid options')); + callback(requestId, { name: 'Invalid options', message: 'Invalid options' }); return; } captureWindow = includes.call(options.types, 'window'); captureScreen = includes.call(options.types, 'screen'); - const updatedOptions = options; if (!updatedOptions.thumbnailSize) { updatedOptions.thumbnailSize = { height: 150, @@ -83,7 +92,7 @@ export const getSource = (options: SourcesOptions, callback: CallbackType) => { title: `${i18n.t('Permission Denied')()}!`, type: 'error', }); - callback(new Error('Permission Denied')); + callback(requestId, { name: 'Permission Denied', message: 'Permission Denied' }); return; } } @@ -97,11 +106,11 @@ export const getSource = (options: SourcesOptions, callback: CallbackType) => { const filteredSource: DesktopCapturerSource[] = sources.filter((source) => source.name === title); if (Array.isArray(filteredSource) && filteredSource.length > 0) { - return callback(null, filteredSource[ 0 ]); + return callback(requestId, null, filteredSource[ 0 ]); } if (sources.length > 0) { - return callback(null, sources[ 0 ]); + return callback(requestId, null, sources[ 0 ]); } } @@ -122,9 +131,9 @@ export const getSource = (options: SourcesOptions, callback: CallbackType) => { // Cleaning up the event listener to prevent memory leaks if (!source) { ipcRenderer.removeListener('start-share' + id, successCallback); - return callback(new Error('User Cancelled')); + return callback(requestId, { name: 'User Cancelled', message: 'User Cancelled' }); } - return callback(null, source); + return callback(requestId, null, source); }; ipcRenderer.once('start-share' + id, successCallback); return null; @@ -143,4 +152,4 @@ ipcRenderer.on('is-screen-share-enabled', (_event, screenShare) => { if (typeof screenShare === 'boolean' && screenShare) { isScreenShareEnabled = true; } -}); \ No newline at end of file +}); diff --git a/src/renderer/notification-handler.ts b/src/renderer/notification-handler.ts new file mode 100644 index 000000000..f32b0c21b --- /dev/null +++ b/src/renderer/notification-handler.ts @@ -0,0 +1,223 @@ +import * as asyncMap from 'async.map'; +import * as electron from 'electron'; + +import { windowExists } from '../app/window-utils'; +import { isMac } from '../common/env'; + +interface ISettings { + startCorner: startCorner; + height: number; + width: number; + totalHeight: number; + totalWidth: number; + corner: ICorner; + firstPos: ICorner; + maxVisibleNotifications: number; + animationSteps: number; + animationStepMs: number; +} + +interface ICorner { + x: number; + y: number; +} + +type startCorner = 'upper-right' | 'upper-left' | 'lower-right' | 'lower-left'; + +export default class NotificationHandler { + public settings: ISettings; + public nextInsertPos: ICorner = { x: 0, y: 0 }; + + private readonly eventHandlers = { + onSetup: () => this.setupNotificationPosition(), + }; + + private externalDisplay: Electron.Display | undefined; + private displayId: string = ''; + + constructor(opts) { + this.settings = opts as ISettings; + this.setupNotificationPosition(); + + electron.screen.on('display-added', this.eventHandlers.onSetup); + electron.screen.on('display-removed', this.eventHandlers.onSetup); + electron.screen.on('display-metrics-changed', this.eventHandlers.onSetup); + } + + /** + * Sets the position of the notification window + * + * @param window {BrowserWindow} + * @param x {number} + * @param y {number} + */ + public setWindowPosition(window: Electron.BrowserWindow, x: number = 0, y: number = 0) { + if (window && !window.isDestroyed()) { + window.setPosition(parseInt(String(x), 10), parseInt(String(y), 10)); + } + } + + /** + * Initializes / resets the notification positional values + */ + public setupNotificationPosition() { + // This feature only applies to windows + if (isMac) { + return; + } + const screens = electron.screen.getAllDisplays(); + if (screens && screens.length >= 0) { + this.externalDisplay = screens.find((screen) => { + const screenId = screen.id.toString(); + return screenId === this.displayId; + }); + } + + const display = this.externalDisplay || electron.screen.getPrimaryDisplay(); + this.settings.corner.x = display.workArea.x; + this.settings.corner.y = display.workArea.y; + + // update corner x/y based on corner of screen where notification should appear + const workAreaWidth = display.workAreaSize.width; + const workAreaHeight = display.workAreaSize.height; + switch (this.settings.startCorner) { + case 'upper-right': + this.settings.corner.x += workAreaWidth; + break; + case 'lower-right': + this.settings.corner.x += workAreaWidth; + this.settings.corner.y += workAreaHeight; + break; + case 'lower-left': + this.settings.corner.y += workAreaHeight; + break; + case 'upper-left': + default: + // no change needed + break; + } + this.calculateDimensions(); + // Maximum amount of Notifications we can show: + this.settings.maxVisibleNotifications = Math.floor(display.workAreaSize.height / this.settings.totalHeight); + } + + /** + * Find next possible insert position (on top) + */ + public calcNextInsertPos(activeNotificationLength) { + if (activeNotificationLength < this.settings.maxVisibleNotifications) { + switch (this.settings.startCorner) { + case 'upper-right': + case 'upper-left': + this.nextInsertPos.y = this.settings.corner.y + (this.settings.totalHeight * activeNotificationLength); + break; + + default: + case 'lower-right': + case 'lower-left': + this.nextInsertPos.y = this.settings.corner.y - (this.settings.totalHeight * (activeNotificationLength + 1)); + break; + } + } + } + + /** + * Moves the notification by one step + * + * @param startPos {number} + * @param activeNotifications {ICustomBrowserWindow[]} + */ + public moveNotificationDown(startPos, activeNotifications) { + if (startPos >= activeNotifications || startPos === -1) { + return; + } + // Build array with index of affected notifications + const notificationPosArray: number[] = []; + for (let i = startPos; i < activeNotifications.length; i++) { + notificationPosArray.push(i); + } + asyncMap(notificationPosArray, (i, done) => { + // Get notification to move + const notificationWindow = activeNotifications[i]; + + // Calc new y position + let newY; + switch (this.settings.startCorner) { + case 'upper-right': + case 'upper-left': + newY = this.settings.corner.y + (this.settings.totalHeight * i); + break; + default: + case 'lower-right': + case 'lower-left': + newY = this.settings.corner.y - (this.settings.totalHeight * (i + 1)); + break; + } + + if (!windowExists(notificationWindow)) { + return; + } + + // Get startPos, calc step size and start animationInterval + const startY = notificationWindow.getPosition()[1]; + const step = (newY - startY) / this.settings.animationSteps; + let curStep = 1; + const animationInterval = setInterval(() => { + // Abort condition + if (curStep === this.settings.animationSteps) { + this.setWindowPosition(notificationWindow, this.settings.firstPos.x, newY); + clearInterval(animationInterval); + done(null, 'done'); + return; + } + // Move one step down + this.setWindowPosition(notificationWindow, this.settings.firstPos.x, startY + curStep * step); + curStep++; + }, this.settings.animationStepMs); + }); + } + + /** + * Calculates the first and next notification insert position + */ + private calculateDimensions() { + const vertSpace = 8; + + // Calc totalHeight & totalWidth + this.settings.totalHeight = this.settings.height + vertSpace; + this.settings.totalWidth = this.settings.width; + + let firstPosX; + let firstPosY; + switch (this.settings.startCorner) { + case 'upper-right': + firstPosX = this.settings.corner.x - this.settings.totalWidth; + firstPosY = this.settings.corner.y; + break; + case 'lower-right': + firstPosX = this.settings.corner.x - this.settings.totalWidth; + firstPosY = this.settings.corner.y - this.settings.totalHeight; + break; + case 'lower-left': + firstPosX = this.settings.corner.x; + firstPosY = this.settings.corner.y - this.settings.totalHeight; + break; + case 'upper-left': + default: + firstPosX = this.settings.corner.x; + firstPosY = this.settings.corner.y; + break; + } + + // Calc pos of first notification: + this.settings.firstPos = { + x: firstPosX, + y: firstPosY, + }; + + // Set nextInsertPos + this.nextInsertPos.x = this.settings.firstPos.x; + this.nextInsertPos.y = this.settings.firstPos.y; + } + +} diff --git a/src/renderer/notification.ts b/src/renderer/notification.ts new file mode 100644 index 000000000..2b37b54ad --- /dev/null +++ b/src/renderer/notification.ts @@ -0,0 +1,321 @@ +import { ipcMain } from 'electron'; + +import { createComponentWindow, windowExists } from '../app/window-utils'; +import { AnimationQueue } from '../common/animation-queue'; +import { logger } from '../common/logger'; +import NotificationHandler from './notification-handler'; + +// const MAX_QUEUE_SIZE = 30; +const CLEAN_UP_INTERVAL = 60 * 100; +const animationQueue = new AnimationQueue(); + +interface ICustomBrowserWindow extends Electron.BrowserWindow { + notificationData: INotificationData; + displayTimer: NodeJS.Timer; + clientId: number; +} + +interface INotificationData { + id: number; + title: string; + text: string; + image: string; + flash: boolean; + color: string; + tag: string; + sticky: boolean; + company: string; + displayTime: number; +} + +type startCorner = 'upper-right' | 'upper-left' | 'lower-right' | 'lower-left'; + +const notificationSettings = { + startCorner: 'upper-right' as startCorner, + width: 380, + height: 100, + totalHeight: 0, + totalWidth: 0, + corner: { + x: 0, + y: 0, + }, + firstPos: { + x: 0, + y: 0, + }, + templatePath: '', + maxVisibleNotifications: 6, + borderRadius: 5, + displayTime: 5000, + animationSteps: 5, + animationStepMs: 5, + logging: true, +}; + +class Notification extends NotificationHandler { + + private readonly funcHandlers = { + onCleanUpInactiveNotification: () => this.cleanUpInactiveNotification(), + onCreateNotificationWindow: (data: INotificationData) => this.createNotificationWindow(data), + }; + private readonly activeNotifications: Electron.BrowserWindow[] = []; + private readonly inactiveWindows: Electron.BrowserWindow[] = []; + private readonly notificationQueue: INotificationData[] = []; + private readonly notificationCallbacks: any[] = []; + private cleanUpTimer: NodeJS.Timer; + + constructor(opts) { + super(opts); + ipcMain.on('close-notification', (_event, windowId) => { + this.hideNotification(windowId); + }); + + ipcMain.on('notification-clicked', (_event, windowId) => { + this.notificationClicked(windowId); + }); + this.cleanUpTimer = setInterval(this.funcHandlers.onCleanUpInactiveNotification, CLEAN_UP_INTERVAL); + } + + /** + * Displays a new notification + * + * @param data + * @param callback + */ + public showNotification(data: INotificationData, callback): void { + clearInterval(this.cleanUpTimer); + animationQueue.push({ + func: this.funcHandlers.onCreateNotificationWindow, + args: [ data ], + }); + this.notificationCallbacks[ data.id ] = callback; + this.cleanUpTimer = setInterval(this.funcHandlers.onCleanUpInactiveNotification, CLEAN_UP_INTERVAL); + } + + /** + * Creates a new notification window + * + * @param data + */ + public async createNotificationWindow(data): Promise { + + if (data.tag) { + for (let i = 0; i < this.notificationQueue.length; i++) { + if (this.notificationQueue[ i ].tag === data.tag) { + this.notificationQueue[ i ] = data; + return; + } + } + + for (const window of this.activeNotifications) { + const notificationWin = window as ICustomBrowserWindow; + if (window && notificationWin.notificationData.tag === data.tag) { + this.setNotificationContent(notificationWin, data); + return; + } + } + } + + // Checks if number of active notification displayed is greater than or equal to the + // max displayable notification and queues them + if (this.activeNotifications.length >= this.settings.maxVisibleNotifications) { + this.notificationQueue.push(data); + return; + } + + // Checks for the cashed window and use them + if (this.inactiveWindows.length > 0) { + const inactiveWin = this.inactiveWindows[0] as ICustomBrowserWindow; + if (windowExists(inactiveWin)) { + this.inactiveWindows.splice(0, 1); + this.renderNotification(inactiveWin, data); + return; + } + } + + const notificationWindow = createComponentWindow( + 'notification-comp', + this.getNotificationOpts(), + false, + ) as ICustomBrowserWindow; + + notificationWindow.notificationData = data; + notificationWindow.once('closed', () => { + const activeWindowIndex = this.activeNotifications.indexOf(notificationWindow); + const inactiveWindowIndex = this.inactiveWindows.indexOf(notificationWindow); + + if (activeWindowIndex !== -1) { + this.activeNotifications.splice(activeWindowIndex, 1); + } + + if (inactiveWindowIndex !== -1) { + this.inactiveWindows.splice(inactiveWindowIndex, 1); + } + }); + return await this.didFinishLoad(notificationWindow, data); + } + + /** + * Sets the notification contents + * + * @param notificationWindow + * @param data {INotificationData} + */ + public setNotificationContent(notificationWindow: ICustomBrowserWindow, data: INotificationData): void { + notificationWindow.clientId = data.id; + const displayTime = data.displayTime ? data.displayTime : notificationSettings.displayTime; + let timeoutId; + + if (!data.sticky) { + timeoutId = setTimeout(async () => { + await this.hideNotification(notificationWindow.clientId); + }, displayTime); + notificationWindow.displayTimer = timeoutId; + } + + notificationWindow.webContents.send('notification-data', data); + notificationWindow.showInactive(); + } + + /** + * Hides the notification window + * + * @param clientId + */ + public async hideNotification(clientId: number): Promise { + const browserWindow = this.getNotificationWindow(clientId); + if (browserWindow && windowExists(browserWindow)) { + // send empty to reset the state + const pos = this.activeNotifications.indexOf(browserWindow); + this.activeNotifications.splice(pos, 1); + + if (this.inactiveWindows.length < this.settings.maxVisibleNotifications || 5) { + this.inactiveWindows.push(browserWindow); + browserWindow.hide(); + } else { + browserWindow.close(); + } + + this.moveNotificationDown(pos, this.activeNotifications); + + if (this.notificationQueue.length > 0 && this.activeNotifications.length < this.settings.maxVisibleNotifications) { + const notificationData = this.notificationQueue[0]; + this.notificationQueue.splice(0, 1); + animationQueue.push({ + func: this.funcHandlers.onCreateNotificationWindow, + args: [ notificationData ], + }); + } + } + return; + } + + /** + * Handles notification click + * + * @param clientId {number} + */ + public notificationClicked(clientId): void { + const browserWindow = this.getNotificationWindow(clientId); + if (browserWindow && windowExists(browserWindow) && browserWindow.notificationData) { + const data = browserWindow.notificationData; + const callback = this.notificationCallbacks[ clientId ]; + if (typeof callback === 'function') { + this.notificationCallbacks[ clientId ]('notification-clicked', data); + } + this.hideNotification(clientId); + } + } + + /** + * Returns the notification based on the client id + * + * @param clientId {number} + */ + public getNotificationWindow(clientId: number): ICustomBrowserWindow | undefined { + const index: number = this.activeNotifications.findIndex((win) => { + const notificationWindow = win as ICustomBrowserWindow; + return notificationWindow.clientId === clientId; + }); + if (index === -1) { + return; + } + return this.activeNotifications[ index ] as ICustomBrowserWindow; + } + + /** + * Waits for window to load and resolves + * + * @param window + * @param data + */ + private didFinishLoad(window, data) { + return new Promise((resolve) => { + window.webContents.once('did-finish-load', () => { + if (windowExists(window)) { + this.renderNotification(window, data); + } + return resolve(window); + }); + }); + } + + /** + * Calculates all the required attributes and displays the notification + * + * @param notificationWindow {BrowserWindow} + * @param data {INotificationData} + */ + private renderNotification(notificationWindow, data): void { + this.calcNextInsertPos(this.activeNotifications.length); + this.setWindowPosition(notificationWindow, this.nextInsertPos.x, this.nextInsertPos.y); + this.setNotificationContent(notificationWindow, { ...data, windowId: notificationWindow.id }); + this.activeNotifications.push(notificationWindow); + } + + /** + * Closes the active notification after certain period + */ + private cleanUpInactiveNotification() { + logger.info('active notification', this.activeNotifications.length); + logger.info('inactive notification', this.inactiveWindows.length); + if (this.inactiveWindows.length > 0) { + logger.info('cleaning up inactive notification windows', { inactiveNotification: this.inactiveWindows.length }); + this.inactiveWindows.forEach((window) => { + if (windowExists(window)) { + window.close(); + } + }); + logger.info(`Cleaned up inactive notification windows`, { inactiveNotification: this.inactiveWindows.length }); + } + } + + /** + * notification window opts + */ + private getNotificationOpts(): Electron.BrowserWindowConstructorOptions { + return { + width: 380, + height: 100, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + show: false, + frame: false, + transparent: true, + acceptFirstMouse: true, + webPreferences: { + sandbox: true, + nodeIntegration: false, + devTools: true, + }, + }; + } +} + +const notification = new Notification(notificationSettings); + +export { + notification, +}; diff --git a/src/renderer/preload-component.ts b/src/renderer/preload-component.ts index 974637a8d..eea7170d6 100644 --- a/src/renderer/preload-component.ts +++ b/src/renderer/preload-component.ts @@ -7,6 +7,7 @@ import AboutBox from './components/about-app'; import BasicAuth from './components/basic-auth'; import LoadingScreen from './components/loading-screen'; import MoreInfo from './components/more-info'; +import NotificationComp from './components/notification-comp'; import ScreenPicker from './components/screen-picker'; import ScreenSharingIndicator from './components/screen-sharing-indicator'; @@ -17,6 +18,7 @@ const enum components { screenPicker = 'screen-picker', screenSharingIndicator = 'screen-sharing-indicator', basicAuth = 'basic-auth', + notification = 'notification-comp', } const loadStyle = (style) => { @@ -60,6 +62,10 @@ const load = () => { loadStyle(components.basicAuth); component = BasicAuth; break; + case components.notification: + loadStyle(components.notification); + component = NotificationComp; + break; } const element = React.createElement(component); ReactDOM.render(element, document.getElementById('Root')); @@ -70,4 +76,4 @@ document.addEventListener('DOMContentLoaded', load); ipcRenderer.on('set-locale-resource', (_event, data) => { const { locale, resource } = data; i18n.setResource(locale, resource); -}); \ No newline at end of file +}); diff --git a/src/renderer/preload-main.ts b/src/renderer/preload-main.ts index 31e090938..937f9c3da 100644 --- a/src/renderer/preload-main.ts +++ b/src/renderer/preload-main.ts @@ -3,6 +3,7 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { i18n } from '../common/i18n-preload'; +import AppBridge from './app-bridge'; import DownloadManager from './components/download-manager'; import SnackBar from './components/snack-bar'; import WindowsTitleBar from './components/windows-title-bar'; @@ -13,6 +14,7 @@ interface ISSFWindow extends Window { } const ssfWindow: ISSFWindow = window; +const appBridge = new AppBridge(); /** * creates API exposed from electron. @@ -39,7 +41,11 @@ const createAPI = () => { createAPI(); // When the window is completely loaded -ipcRenderer.on('page-load', (_event, { locale, resources }) => { +ipcRenderer.on('page-load', (_event, { locale, resources, origin }) => { + // origin for postMessage targetOrigin communication + if (origin) { + appBridge.origin = origin; + } i18n.setResource(locale, resources); @@ -63,4 +69,4 @@ ipcRenderer.on('initiate-custom-title-bar', () => { const div = document.createElement( 'div' ); document.body.appendChild(div); ReactDOM.render(element, div); -}); \ No newline at end of file +}); diff --git a/src/renderer/ssf-api.ts b/src/renderer/ssf-api.ts index bcfd78b60..255a6a9e7 100644 --- a/src/renderer/ssf-api.ts +++ b/src/renderer/ssf-api.ts @@ -1,12 +1,17 @@ import { ipcRenderer, remote } from 'electron'; +import { buildNumber } from '../../package.json'; import { apiCmds, apiName, - IActivityDetection, IBadgeCount, - IBoundsChange, IScreenSharingIndicator, - IScreenSnippet, KeyCodes, + IBoundsChange, + ILogMsg, + IScreenSharingIndicator, + IScreenSnippet, + IVersionInfo, + KeyCodes, + LogLevel, } from '../common/api-interface'; import { i18n, LocaleType } from '../common/i18n-preload'; import { throttle } from '../common/utils'; @@ -14,19 +19,20 @@ import { getSource } from './desktop-capturer'; let isAltKey: boolean = false; let isMenuOpen: boolean = false; -let nextId = 0; interface ICryptoLib { AESGCMEncrypt: (name: string, base64IV: string, base64AAD: string, base64Key: string, base64In: string) => string | null; AESGCMDecrypt: (base64IV: string, base64AAD: string, base64Key: string, base64In: string) => string | null; } -interface ILocalObject { +export interface ILocalObject { ipcRenderer; - activityDetectionCallback?: (arg: IActivityDetection) => void; + logger?: (msg: ILogMsg, logLevel: LogLevel, showInConsole: boolean) => void; + activityDetectionCallback?: (arg: number) => void; screenSnippetCallback?: (arg: IScreenSnippet) => void; boundsChangeCallback?: (arg: IBoundsChange) => void; screenSharingIndicatorCallback?: (arg: IScreenSharingIndicator) => void; + protocolActionCallback?: (arg: string) => void; } const local: ILocalObject = { @@ -48,6 +54,29 @@ const throttledSetLocale = throttle((locale) => { }); }, 1000); +const throttledActivate = throttle((windowName) => { + local.ipcRenderer.send(apiName.symphonyApi, { + cmd: apiCmds.activate, + windowName, + }); +}, 1000); + +const throttledBringToFront = throttle((windowName, reason) => { + local.ipcRenderer.send(apiName.symphonyApi, { + cmd: apiCmds.bringToFront, + windowName, + reason, + }); +}, 1000); + +const throttledCloseScreenShareIndicator = throttle((streamId) => { + ipcRenderer.send(apiName.symphonyApi, { + cmd: apiCmds.closeWindow, + windowType: 'screen-sharing-indicator', + winKey: streamId, + }); +}, 1000); + let cryptoLib: ICryptoLib | null; try { cryptoLib = remote.require('../app/crypto-handler.js').cryptoLibrary; @@ -75,6 +104,45 @@ export class SSFApi { */ public getMediaSource = getSource; + /** + * Brings window forward and gives focus. + * + * @param {String} windowName - Name of window. Note: main window name is 'main' + */ + public activate(windowName) { + if (typeof windowName === 'string') { + throttledActivate(windowName); + } + } + + /** + * Brings window forward and gives focus. + * + * @param {String} windowName Name of window. Note: main window name is 'main' + * @param {String} reason, The reason for which the window is to be activated + */ + public bringToFront(windowName, reason) { + if (typeof windowName === 'string') { + throttledBringToFront(windowName, reason); + } + } + + /** + * Method that returns various version info + */ + public getVersionInfo(): IVersionInfo { + const appName = remote.app.getName(); + const appVer = remote.app.getVersion(); + + return { + containerIdentifier: appName, + containerVer: appVer, + buildNumber, + apiVer: '2.0.0', + searchApiVer: '3.0.0', + }; + } + /** * Allows JS to register a activity detector that can be used by electron main process. * @@ -82,7 +150,7 @@ export class SSFApi { * @param {Object} activityDetectionCallback - function that can be called accepting * @example registerActivityDetection(40000, func) */ - public registerActivityDetection(period: number, activityDetectionCallback: Partial): void { + public registerActivityDetection(period: number, activityDetectionCallback: (arg: number) => void): void { if (typeof activityDetectionCallback === 'function') { local.activityDetectionCallback = activityDetectionCallback; @@ -101,18 +169,64 @@ export class SSFApi { * only one window can register for bounds change. * @param {Function} callback Function invoked when bounds changes. */ - public registerBoundsChange(callback: () => void): void { + public registerBoundsChange(callback: (arg: IBoundsChange) => void): void { if (typeof callback === 'function') { local.boundsChangeCallback = callback; } } + /** + * Allows JS to register a logger that can be used by electron main process. + * @param {Object} logger function that can be called accepting + * object: { + * logLevel: 'ERROR'|'CONFLICT'|'WARN'|'ACTION'|'INFO'|'DEBUG', + * logDetails: String + * } + */ + public registerLogger(logger) { + if (typeof logger === 'function') { + local.logger = logger; + + // only main window can register + local.ipcRenderer.send(apiName.symphonyApi, { + cmd: apiCmds.registerLogger, + }); + } + } + + /** + * Allows JS to register a protocol handler that can be used by the + * electron main process. + * + * @param protocolHandler {Function} callback will be called when app is + * invoked with registered protocol (e.g., symphony). The callback + * receives a single string argument: full uri that the app was + * invoked with e.g., symphony://?streamId=xyz123&streamType=chatroom + * + * Note: this function should only be called after client app is fully + * able for protocolHandler callback to be invoked. It is possible + * the app was started using protocol handler, in this case as soon as + * this registration func is invoked then the protocolHandler callback + * will be immediately called. + */ + public registerProtocolHandler(protocolHandler) { + if (typeof protocolHandler === 'function') { + + local.protocolActionCallback = protocolHandler; + + local.ipcRenderer.send(apiName.symphonyApi, { + cmd: apiCmds.registerProtocolHandler, + }); + + } + } + /** * Allow user to capture portion of screen * * @param screenSnippetCallback {function} */ - public openScreenSnippet(screenSnippetCallback: Partial): void { + public openScreenSnippet(screenSnippetCallback: (arg: IScreenSnippet) => void): void { if (typeof screenSnippetCallback === 'function') { local.screenSnippetCallback = screenSnippetCallback; @@ -160,35 +274,40 @@ export class SSFApi { * - 'stopRequested' - user clicked "Stop Sharing" button. */ public showScreenSharingIndicator(options, callback): void { - const { stream, displayId } = options; + const { displayId, requestId, streamId } = options; if (typeof callback === 'function') { - if (!stream || !stream.active || stream.getVideoTracks().length !== 1) { - callback({ type: 'error', reason: 'bad stream' }); - return; - } - if (displayId && typeof (displayId) !== 'string') { - callback({ type: 'error', reason: 'bad displayId' }); - return; - } - local.screenSharingIndicatorCallback = callback; - const id = ++nextId; ipcRenderer.send(apiName.symphonyApi, { cmd: apiCmds.openScreenSharingIndicator, - displayId: options.displayId, - id, + displayId, + id: requestId, + streamId, }); } } + /** + * Closes the screen sharing indicator + */ + public closeScreenSharingIndicator(winKey: string): void { + throttledCloseScreenShareIndicator(winKey); + } + } /** * Ipc events */ -// Creates a data url +/** + * An event triggered by the main process + * to construct a canvas for the Windows badge count image + * + * @param {IBadgeCount} arg { + * count: number + * } + */ local.ipcRenderer.on('create-badge-data-url', (_event: Event, arg: IBadgeCount) => { const count = arg && arg.count || 0; @@ -228,19 +347,47 @@ local.ipcRenderer.on('create-badge-data-url', (_event: Event, arg: IBadgeCount) } }); +/** + * An event triggered by the main process + * when the snippet is complete + * + * @param {IScreenSnippet} arg { + * message: string, + * data: base64, + * type: 'ERROR' | 'image/jpg;base64', + * } + */ local.ipcRenderer.on('screen-snippet-data', (_event: Event, arg: IScreenSnippet) => { if (typeof arg === 'object' && typeof local.screenSnippetCallback === 'function') { local.screenSnippetCallback(arg); } }); -local.ipcRenderer.on('activity', (_event: Event, arg: IActivityDetection) => { - if (typeof arg === 'object' && typeof local.activityDetectionCallback === 'function') { - local.activityDetectionCallback(arg); +/** + * An event triggered by the main process + * for ever few minutes if the user is active + * + * @param {number} idleTime - current system idle tick + */ +local.ipcRenderer.on('activity', (_event: Event, idleTime: number) => { + if (typeof idleTime === 'number' && typeof local.activityDetectionCallback === 'function') { + local.activityDetectionCallback(idleTime); } }); -// listen for notifications that some window size/position has changed +/** + * An event triggered by the main process + * Whenever some Window position or dimension changes + * + * @param {IBoundsChange} arg { + * x: number, + * y: number, + * height: number, + * width: number, + * windowName: string + * } + * + */ local.ipcRenderer.on('boundsChange', (_event, arg: IBoundsChange): void => { const { x, y, height, width, windowName } = arg; if (x && y && height && width && windowName && typeof local.boundsChangeCallback === 'function') { @@ -254,14 +401,40 @@ local.ipcRenderer.on('boundsChange', (_event, arg: IBoundsChange): void => { } }); -local.ipcRenderer.on('screen-sharing-stopped', () => { +/** + * An event triggered by the main process + * when the screen sharing has been stopper + */ +local.ipcRenderer.on('screen-sharing-stopped', (_event, id) => { if (typeof local.screenSharingIndicatorCallback === 'function') { - local.screenSharingIndicatorCallback({ type: 'stopRequested' }); - // closes the screen sharing indicator - ipcRenderer.send(apiName.symphonyApi, { - cmd: apiCmds.closeWindow, - windowType: 'screen-sharing-indicator', - }); + local.screenSharingIndicatorCallback({ type: 'stopRequested', requestId: id }); + } +}); + +/** + * An event triggered by the main process + * for send logs on to web app + * + * @param {object} arg { + * msgs: ILogMsg[], + * logLevel: LogLevel, + * showInConsole: boolean + * } + * + */ +local.ipcRenderer.on('log', (_event, arg) => { + if (arg && local.logger) { + local.logger(arg.msgs || [], arg.logLevel, arg.showInConsole); + } +}); + +/** + * An event triggered by the main process for processing protocol urls + * @param {String} arg - the protocol url + */ +local.ipcRenderer.on('protocol-action', (_event, arg: string) => { + if (typeof local.protocolActionCallback === 'function' && typeof arg === 'string') { + local.protocolActionCallback(arg); } }); @@ -284,7 +457,7 @@ const updateOnlineStatus = (): void => { }; // Handle key down events -const throttledKeyDown = throttle( (event) => { +const throttledKeyDown = throttle((event) => { isAltKey = event.keyCode === KeyCodes.Alt; if (event.keyCode === KeyCodes.Esc) { local.ipcRenderer.send(apiName.symphonyApi, { @@ -295,7 +468,7 @@ const throttledKeyDown = throttle( (event) => { }, 500); // Handle key up events -const throttledKeyUp = throttle( (event) => { +const throttledKeyUp = throttle((event) => { if (isAltKey && (event.keyCode === KeyCodes.Alt || KeyCodes.Esc)) { isMenuOpen = !isMenuOpen; } @@ -323,4 +496,4 @@ window.addEventListener('offline', updateOnlineStatus, false); window.addEventListener('online', updateOnlineStatus, false); window.addEventListener('keyup', throttledKeyUp, true); window.addEventListener('keydown', throttledKeyDown, true); -window.addEventListener('mousedown', throttleMouseDown, { capture: true }); \ No newline at end of file +window.addEventListener('mousedown', throttleMouseDown, { capture: true }); diff --git a/src/renderer/styles/notification-comp.less b/src/renderer/styles/notification-comp.less new file mode 100644 index 000000000..ac7041279 --- /dev/null +++ b/src/renderer/styles/notification-comp.less @@ -0,0 +1,109 @@ +@font-family: "Segoe UI", "Helvetica Neue", "Verdana", "Arial", sans-serif; + +.light { + --text-color: #4a4a4a; + --logo-bg: url('../assets/symphony-logo-black.png'); +} +.dark { + --text-color: #ffffff; + --logo-bg: url('../assets/symphony-logo-white.png'); +} + +body { + margin: 0; + overflow: hidden; + -webkit-user-select: none; + font-family: @font-family; +} + +.container { + width: 380px; + height: 100px; + display: flex; + justify-content: center; + background-color: #ffffff; + overflow: hidden; + position: relative; + line-height: 15px; + box-sizing: border-box; + border-radius: 5px; +} + +.header { + width: 245px; + min-width: 230px; + margin: auto; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.user-profile-pic-container { + align-items: center; + display: flex; +} + +.user-profile-pic { + height: 43px; + border-radius: 4px; + width: 43px; +} + +.close { + width: 16px; + height: 80px; + display: flex; + margin: auto; + opacity: 0.54; + font-size: 12px; + color: #CCC; + cursor: pointer; +} + +.title { + font-family: @font-family; + font-size: 14px; + font-weight: 700; + color: var(--text-color); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} + +.company { + font-family: @font-family; + font-size: 11px; + overflow: hidden; + filter: brightness(70%); + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} + +.message { + font-family: @font-family; + width: 100%; + overflow-wrap: break-word; + font-size: 12px; + margin-top: 5px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + cursor: default; + text-overflow: ellipsis; + color: var(--text-color); +} + +.logo-container { + display: flex; + align-items: center; +} + +.logo { + margin-left: 5px; + opacity: 0.6; + width: 43px; + content: var(--logo-bg); +} diff --git a/src/renderer/styles/screen-sharing-indicator.less b/src/renderer/styles/screen-sharing-indicator.less index d3fbb4b0d..a132f97a0 100644 --- a/src/renderer/styles/screen-sharing-indicator.less +++ b/src/renderer/styles/screen-sharing-indicator.less @@ -83,4 +83,3 @@ body { } } - diff --git a/tsconfig.json b/tsconfig.json index cf81168b6..6981c7405 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ "exclude": [ "node_modules", "lib", - "tests" + "tests", + "spec" ] -} \ No newline at end of file +} diff --git a/tslint.json b/tslint.json index 737df0b38..029d45f9f 100644 --- a/tslint.json +++ b/tslint.json @@ -8,8 +8,8 @@ ] }, "rules": { - "curly": false, - "eofline": false, + "curly": true, + "eofline": true, "align": [ true, "parameters" @@ -73,4 +73,4 @@ ], "completed-docs": [true, "functions", "methods"] } -} \ No newline at end of file +}