From 8f518e3936c8c1566df752bb1dfdc6e20277c2bb Mon Sep 17 00:00:00 2001 From: Vishwas Shashidhar Date: Fri, 1 May 2020 13:20:07 +0530 Subject: [PATCH] feat: SDA-1995: implement download manager for Mana (#982) * SDA-1995: add download handler functionality for Mana Signed-off-by: Vishwas Shashidhar * SDA-1995: add unit tests Signed-off-by: Vishwas Shashidhar * SDA-1995: fix unit tests on Windows Signed-off-by: Vishwas Shashidhar * SDA-1955: address PR comments Signed-off-by: Vishwas Shashidhar --- spec/downloadHandler.spec.ts | 54 +++++++ spec/mainApiHandler.spec.ts | 73 +++++++++ src/app/download-handler.ts | 168 ++++++++++++++++++++ src/app/main-api-handler.ts | 17 ++ src/app/window-utils.ts | 20 ++- src/common/api-interface.ts | 14 ++ src/demo/index.html | 62 +++++++- src/renderer/app-bridge.ts | 25 +++ src/renderer/components/download-manager.ts | 2 +- src/renderer/preload-main.ts | 1 + src/renderer/ssf-api.ts | 72 ++++++++- 11 files changed, 504 insertions(+), 4 deletions(-) create mode 100644 spec/downloadHandler.spec.ts create mode 100644 src/app/download-handler.ts diff --git a/spec/downloadHandler.spec.ts b/spec/downloadHandler.spec.ts new file mode 100644 index 00000000..e905414c --- /dev/null +++ b/spec/downloadHandler.spec.ts @@ -0,0 +1,54 @@ +jest.mock('electron-log'); + +jest.mock('../src/app/window-handler', () => { + return { + windowHandler: { + setIsAutoReload: jest.fn(() => true), + }, + }; +}); + +jest.mock('../src/app/window-utils', () => { + return { + windowExists: jest.fn(() => true), + }; +}); + +describe('download handler', () => { + let downloadHandlerInstance; + + beforeEach(() => { + jest.resetModules(); + // I did it for reset module imported between tests + const { downloadHandler } = require('../src/app/download-handler'); + downloadHandlerInstance = downloadHandler; + }); + + afterAll((done) => { + done(); + }); + + it('should call `sendDownloadCompleted` when download succeeds', () => { + const spy: jest.SpyInstance = jest.spyOn(downloadHandlerInstance, 'sendDownloadCompleted') + .mockImplementation(() => jest.fn()); + + const data: any = { + _id: '121312-123912321-1231231', + savedPath: '/abc/def/123.txt', + total: '1234556', + fileName: 'Test.txt', + }; + + downloadHandlerInstance.onDownloadSuccess(data); + expect(spy).toBeCalled(); + }); + + it('should call `sendDownloadFailed` when download fails', () => { + const spy: jest.SpyInstance = jest.spyOn(downloadHandlerInstance, 'sendDownloadFailed') + .mockImplementation(() => jest.fn()); + + downloadHandlerInstance.onDownloadFailed(); + expect(spy).toBeCalled(); + }); + +}); diff --git a/spec/mainApiHandler.spec.ts b/spec/mainApiHandler.spec.ts index c119a651..45e96f5c 100644 --- a/spec/mainApiHandler.spec.ts +++ b/spec/mainApiHandler.spec.ts @@ -1,4 +1,5 @@ import { activityDetection } from '../src/app/activity-detection'; +import { downloadHandler } from '../src/app/download-handler'; import '../src/app/main-api-handler'; import { protocolHandler } from '../src/app/protocol-handler'; import { screenSnippet } from '../src/app/screen-snippet-handler'; @@ -94,6 +95,17 @@ jest.mock('../src/app/activity-detection', () => { }; }); +jest.mock('../src/app/download-handler', () => { + return { + downloadHandler: { + setWindow: jest.fn(), + openFile: jest.fn(), + showInFinder: jest.fn(), + clearDownloadItems: jest.fn(), + }, + }; +}); + jest.mock('../src/common/i18n'); describe('main api handler', () => { @@ -201,6 +213,67 @@ describe('main api handler', () => { expect(spy).toBeCalledWith(...expectedValue); }); + it('should call `registerDownloadHandler` correctly', () => { + const spy = jest.spyOn(downloadHandler, 'setWindow'); + const value = { + cmd: apiCmds.registerDownloadHandler, + }; + const expectedValue = [ { send: expect.any(Function) } ]; + ipcMain.send(apiName.symphonyApi, value); + expect(spy).toBeCalledWith(...expectedValue); + }); + + it('should call `openFile` correctly', () => { + const spy = jest.spyOn(downloadHandler, 'openFile'); + const value = { + cmd: apiCmds.openDownloadItem, + id: '12345678', + }; + const expectedValue = '12345678'; + ipcMain.send(apiName.symphonyApi, value); + expect(spy).toBeCalledWith(expectedValue); + }); + + it('should not call `openFile` if id is not a string', () => { + const spy = jest.spyOn(downloadHandler, 'openFile'); + const value = { + cmd: apiCmds.openDownloadItem, + id: 10, + }; + ipcMain.send(apiName.symphonyApi, value); + expect(spy).not.toBeCalled(); + }); + + it('should call `showFile` correctly', () => { + const spy = jest.spyOn(downloadHandler, 'showInFinder'); + const value = { + cmd: apiCmds.showDownloadItem, + id: `12345678`, + }; + const expectedValue = '12345678'; + ipcMain.send(apiName.symphonyApi, value); + expect(spy).toBeCalledWith(expectedValue); + }); + + it('should not call `showFile` if id is not a string', () => { + const spy = jest.spyOn(downloadHandler, 'showInFinder'); + const value = { + cmd: apiCmds.showDownloadItem, + id: 10, + }; + ipcMain.send(apiName.symphonyApi, value); + expect(spy).not.toBeCalled(); + }); + + it('should call `clearItems` correctly', () => { + const spy = jest.spyOn(downloadHandler, 'clearDownloadItems'); + const value = { + cmd: apiCmds.clearDownloadItems, + }; + ipcMain.send(apiName.symphonyApi, value); + expect(spy).toBeCalled(); + }); + it('should call `showNotificationSettings` correctly', () => { const spy = jest.spyOn(windowHandler, 'createNotificationSettingsWindow'); const value = { diff --git a/src/app/download-handler.ts b/src/app/download-handler.ts new file mode 100644 index 00000000..5df8f257 --- /dev/null +++ b/src/app/download-handler.ts @@ -0,0 +1,168 @@ +import { BrowserWindow, dialog, shell } from 'electron'; +import * as fs from 'fs'; +import { i18n } from '../common/i18n'; +import { logger } from '../common/logger'; +import { windowExists } from './window-utils'; + +const DOWNLOAD_MANAGER_NAMESPACE = 'DownloadManager'; + +export interface IDownloadManager { + _id: string; + fileName: string; + fileDisplayName?: string; + savedPath: string; + total: string; + flashing?: boolean; + count?: number; +} + +class DownloadHandler { + + /** + * Checks and constructs file name + * + * @param fileName {String} Filename + * @param item {IDownloadManager} Download Item + */ + private static getFileDisplayName(fileName: string, item: IDownloadManager): string { + /* If it exists, add a count to the name like how Chrome does */ + if (item.count && item.count > 0) { + const extLastIndex = fileName.lastIndexOf('.'); + const fileCount = ' (' + item.count + ')'; + + fileName = fileName.slice(0, extLastIndex) + fileCount + fileName.slice(extLastIndex); + } + return fileName; + } + + /** + * Show dialog for failed cases + */ + private static async showDialog(): Promise { + const focusedWindow = BrowserWindow.getFocusedWindow(); + const message = i18n.t('The file you are trying to open cannot be found in the specified path.', DOWNLOAD_MANAGER_NAMESPACE)(); + const title = i18n.t('File not Found', DOWNLOAD_MANAGER_NAMESPACE)(); + + if (!focusedWindow || !windowExists(focusedWindow)) { + return; + } + await dialog.showMessageBox(focusedWindow, { + message, + title, + type: 'error', + }); + } + + private window!: Electron.WebContents | null; + private items: IDownloadManager[] = []; + + /** + * Sets the window for the download handler + * @param window Window object + */ + public setWindow(window: Electron.WebContents): void { + this.window = window; + logger.info(`download-handler: Initialized download handler`); + } + + /** + * Opens the downloaded file + * + * @param id {string} File ID + */ + public openFile(id: string): void { + const filePath = this.getFilePath(id); + + const openResponse = fs.existsSync(`${filePath}`) && shell.openItem(`${filePath}`); + if (openResponse) { + return; + } + + DownloadHandler.showDialog(); + } + + /** + * Opens the downloaded file in finder/explorer + * + * @param id {string} File ID + */ + public showInFinder(id: string): void { + const filePath = this.getFilePath(id); + + if (fs.existsSync(filePath)) { + shell.showItemInFolder(filePath); + return; + } + + DownloadHandler.showDialog(); + } + + /** + * Clears download items + */ + public clearDownloadItems(): void { + this.items = []; + } + + /** + * Handle a successful download + * @param item Download item + */ + public onDownloadSuccess(item: IDownloadManager): void { + let itemCount = 0; + for (const existingItem of this.items) { + if (item.fileName === existingItem.fileName) { + itemCount++; + } + } + item.count = itemCount; + item.fileDisplayName = DownloadHandler.getFileDisplayName(item.fileName, item); + this.items.push(item); + this.sendDownloadCompleted(); + } + + /** + * Handle a failed download + */ + public onDownloadFailed(): void { + this.sendDownloadFailed(); + } + + /** + * Send download completed event to the renderer process + */ + private sendDownloadCompleted(): void { + if (this.window && !this.window.isDestroyed()) { + logger.info(`download-handler: Download completed! Informing the client!`); + this.window.send('download-completed', this.items.map((item) => { + return {id: item._id, fileDisplayName: item.fileDisplayName, fileSize: item.total}; + })); + } + } + + /** + * Send download failed event to the renderer process + */ + private sendDownloadFailed(): void { + if (this.window && !this.window.isDestroyed()) { + logger.info(`download-handler: Download failed! Informing the client!`); + this.window.send('download-failed'); + } + } + + /** + * Get file path for the given item + * @param id ID of the item + */ + private getFilePath(id: string): string { + const fileIndex = this.items.findIndex((item) => { + return item._id === id; + }); + + return this.items[fileIndex].savedPath; + } + +} + +const downloadHandler = new DownloadHandler(); +export { downloadHandler }; diff --git a/src/app/main-api-handler.ts b/src/app/main-api-handler.ts index 68e17595..2f8b378c 100644 --- a/src/app/main-api-handler.ts +++ b/src/app/main-api-handler.ts @@ -6,6 +6,7 @@ import { logger } from '../common/logger'; import { activityDetection } from './activity-detection'; import { analytics } from './analytics-handler'; import { CloudConfigDataTypes, config, ICloudConfig } from './config-handler'; +import { downloadHandler } from './download-handler'; import { memoryMonitor } from './memory-monitor'; import { protocolHandler } from './protocol-handler'; import { finalizeLogExports, registerLogRetriever } from './reports-handler'; @@ -85,6 +86,9 @@ ipcMain.on(apiName.symphonyApi, async (event: Electron.IpcMainEvent, arg: IApiAr activityDetection.setWindowAndThreshold(event.sender, arg.period); } break; + case apiCmds.registerDownloadHandler: + downloadHandler.setWindow(event.sender); + break; case apiCmds.showNotificationSettings: if (typeof arg.windowName === 'string') { windowHandler.createNotificationSettingsWindow(arg.windowName); @@ -147,6 +151,19 @@ ipcMain.on(apiName.symphonyApi, async (event: Electron.IpcMainEvent, arg: IApiAr downloadManagerAction(arg.type, arg.path); } break; + case apiCmds.openDownloadItem: + if (typeof arg.id === 'string') { + downloadHandler.openFile(arg.id); + } + break; + case apiCmds.showDownloadItem: + if (typeof arg.id === 'string') { + downloadHandler.showInFinder(arg.id); + } + break; + case apiCmds.clearDownloadItems: + downloadHandler.clearDownloadItems(); + break; case apiCmds.isMisspelled: if (typeof arg.word === 'string') { event.returnValue = windowHandler.spellchecker ? windowHandler.spellchecker.isMisspelled(arg.word) : false; diff --git a/src/app/window-utils.ts b/src/app/window-utils.ts index fd0195f3..2ea825f0 100644 --- a/src/app/window-utils.ts +++ b/src/app/window-utils.ts @@ -14,6 +14,7 @@ import { getGuid } from '../common/utils'; import { whitelistHandler } from '../common/whitelist-handler'; import { autoLaunchInstance } from './auto-launch-controller'; import { CloudConfigDataTypes, config, IConfig, ICustomRectangle } from './config-handler'; +import { downloadHandler, IDownloadManager } from './download-handler'; import { memoryMonitor } from './memory-monitor'; import { screenSnippet } from './screen-snippet-handler'; import { updateAlwaysOnTop } from './window-actions'; @@ -400,13 +401,30 @@ export const handleDownloadManager = (_event, item: Electron.DownloadItem, webCo // Send file path when download is complete item.once('done', (_e, state) => { if (state === 'completed') { - const data = { + const data: IDownloadManager = { _id: getGuid(), savedPath: item.getSavePath() || '', total: filesize(item.getTotalBytes() || 0), fileName: item.getFilename() || 'No name', }; + logger.info('window-utils: Download completed, informing download manager'); webContents.send('downloadCompleted', data); + downloadHandler.onDownloadSuccess(data); + } else { + logger.info('window-utils: Download failed, informing download manager'); + downloadHandler.onDownloadFailed(); + } + }); + + item.on('updated', (_e, state) => { + if (state === 'interrupted') { + logger.info('window-utils: Download is interrupted but can be resumed'); + } else if (state === 'progressing') { + if (item.isPaused()) { + logger.info('window-utils: Download is paused'); + } else { + logger.info(`window-utils: Received bytes: ${item.getReceivedBytes()}`); + } } }); }; diff --git a/src/common/api-interface.ts b/src/common/api-interface.ts index 69dffa52..bc2ef96a 100644 --- a/src/common/api-interface.ts +++ b/src/common/api-interface.ts @@ -38,6 +38,10 @@ export enum apiCmds { setCloudConfig = 'set-cloud-config', getCPUUsage = 'get-cpu-usage', checkMediaPermission = 'check-media-permission', + registerDownloadHandler = 'register-download-handler', + openDownloadItem = 'open-download-item', + showDownloadItem = 'show-download-item', + clearDownloadItems = 'clear-download-items', } export enum apiName { @@ -151,6 +155,16 @@ export interface ICPUUsage { idleWakeupsPerSecond: number; } +export interface IDownloadManager { + _id: string; + fileName: string; + fileDisplayName: string; + savedPath: string; + total: number; + flashing?: boolean; + count?: number; +} + export interface IMediaPermission { camera: string; microphone: string; diff --git a/src/demo/index.html b/src/demo/index.html index 1679800d..6f2aae8d 100644 --- a/src/demo/index.html +++ b/src/demo/index.html @@ -95,6 +95,9 @@ + + +

@@ -211,6 +214,10 @@ sendLogs: 'send-logs', registerAnalyticHandler: 'register-analytic-handler', registerActivityDetection: 'register-activity-detection', + registerDownloadHandler: 'register-download-handler', + openDownloadItem: 'open-download-item', + showDownloadItem: 'show-download-item', + clearDownloadItems: 'clear-download-items', showNotificationSettings: 'show-notification-settings', sanitize: 'sanitize', bringToFront: 'bring-to-front', @@ -539,6 +546,10 @@ handleResponse(data); console.log(event.data); break; + case 'download-handler-callback': + onDownload(data); + console.log(event.data); + break; default: console.log(event.data); } @@ -580,6 +591,21 @@ console.log('bounds changed for=', arg) } + if (window.ssf) { + ssf.registerDownloadHandler(onDownload); + } else { + postMessage(apiCmds.registerDownloadHandler); + } + + function onDownload(data) { + if (data && data.status === 'download-completed') { + items = data.items; + console.log('Download completed!', data.items); + } else { + console.log('Download failed!'); + } + } + /** * Protocol handler */ @@ -804,6 +830,7 @@ } }); + let items = []; /** * Download Manager api handler */ @@ -831,7 +858,40 @@ const filename = "bye.txt"; const text = document.getElementById("text-val").value; download(filename, text); - }, false); + }, false) + + document.getElementById('open-download-item').addEventListener('click', () => { + if (!items || items.length < 1) { + alert('No files downloaded! Try again!'); + } + const id = items[items.length - 1].id; + if (window.ssf) { + window.ssf.openDownloadItem(id); + } else { + postMessage(apiCmds.openDownloadItem, id); + } + }); + + document.getElementById('show-download-item').addEventListener('click', () => { + if (!items || items.length < 1) { + alert('No files downloaded! Try again!'); + } + const id = items[items.length - 1].id; + if (window.ssf) { + window.ssf.showDownloadItem(id); + } else { + postMessage(apiCmds.showDownloadItem, id); + } + }); + + document.getElementById('close-download-manager').addEventListener('click', () => { + items = []; + if (window.ssf) { + window.ssf.clearDownloadItems(); + } else { + postMessage(apiCmds.clearDownloadItems); + } + }); diff --git a/src/renderer/app-bridge.ts b/src/renderer/app-bridge.ts index b6309f36..95dd9aef 100644 --- a/src/renderer/app-bridge.ts +++ b/src/renderer/app-bridge.ts @@ -61,6 +61,7 @@ export class AppBridge { onNotificationCallback: (event, data) => this.notificationCallback(event, data), onAnalyticsEventCallback: (data) => this.analyticsEventCallback(data), restartFloater: (data) => this.restartFloater(data), + onDownloadItemCallback: (data) => this.onDownloadItemCallback(data), }; constructor() { @@ -108,6 +109,19 @@ export class AppBridge { ssf.setBadgeCount(data as number); } break; + case apiCmds.openDownloadItem: + if (typeof data === 'string') { + ssf.openDownloadItem(data as string); + } + break; + case apiCmds.showDownloadItem: + if (typeof data === 'string') { + ssf.showDownloadItem(data as string); + } + break; + case apiCmds.clearDownloadItems: + ssf.clearDownloadItems(); + break; case apiCmds.setLocale: if (typeof data === 'string') { ssf.setLocale(data as string); @@ -116,6 +130,9 @@ export class AppBridge { case apiCmds.registerActivityDetection: ssf.registerActivityDetection(data as number, this.callbackHandlers.onActivityCallback); break; + case apiCmds.registerDownloadHandler: + ssf.registerDownloadHandler(this.callbackHandlers.onDownloadItemCallback); + break; case apiCmds.openScreenSnippet: ssf.openScreenSnippet(this.callbackHandlers.onScreenSnippetCallback); break; @@ -243,6 +260,14 @@ export class AppBridge { this.broadcastMessage('analytics-event-callback', arg); } + /** + * Broadcast download item event + * @param arg {object} + */ + private onDownloadItemCallback(arg: object): void { + this.broadcastMessage('download-handler-callback', arg); + } + /** * Broadcast to restart floater event with data * @param arg {IAnalyticsData} diff --git a/src/renderer/components/download-manager.ts b/src/renderer/components/download-manager.ts index 4b73a1c8..30008cdb 100644 --- a/src/renderer/components/download-manager.ts +++ b/src/renderer/components/download-manager.ts @@ -8,7 +8,7 @@ interface IDownloadManager { _id: string; fileName: string; savedPath: string; - total: number; + total: string; flashing: boolean; count: number; } diff --git a/src/renderer/preload-main.ts b/src/renderer/preload-main.ts index 0c6ae243..90b6dd81 100644 --- a/src/renderer/preload-main.ts +++ b/src/renderer/preload-main.ts @@ -58,6 +58,7 @@ if (ssfWindow.ssf) { bringToFront: ssfWindow.ssf.bringToFront, getVersionInfo: ssfWindow.ssf.getVersionInfo, registerActivityDetection: ssfWindow.ssf.registerActivityDetection, + registerDownloadHandler: ssfWindow.ssf.registerDownloadHandler, registerBoundsChange: ssfWindow.ssf.registerBoundsChange, registerLogger: ssfWindow.ssf.registerLogger, registerProtocolHandler: ssfWindow.ssf.registerProtocolHandler, diff --git a/src/renderer/ssf-api.ts b/src/renderer/ssf-api.ts index d9306bf8..80468cd8 100644 --- a/src/renderer/ssf-api.ts +++ b/src/renderer/ssf-api.ts @@ -7,7 +7,7 @@ import { apiName, IBadgeCount, IBoundsChange, - ICPUUsage, + ICPUUsage, IDownloadManager, ILogMsg, IMediaPermission, IRestartFloaterData, @@ -36,6 +36,7 @@ export interface ILocalObject { ipcRenderer; logger?: (msg: ILogMsg, logLevel: LogLevel, showInConsole: boolean) => void; activityDetectionCallback?: (arg: number) => void; + downloadManagerCallback?: (arg?: any) => void; screenSnippetCallback?: (arg: IScreenSnippet) => void; boundsChangeCallback?: (arg: IBoundsChange) => void; screenSharingIndicatorCallback?: (arg: IScreenSharingIndicator) => void; @@ -101,6 +102,26 @@ const throttledSetCloudConfig = throttle((data) => { }); }, 1000); +const throttledOpenDownloadItem = throttle((id: string) => { + ipcRenderer.send(apiName.symphonyApi, { + cmd: apiCmds.openDownloadItem, + id, + }); +}, 1000); + +const throttledShowDownloadItem = throttle((id: string) => { + ipcRenderer.send(apiName.symphonyApi, { + cmd: apiCmds.showDownloadItem, + id, + }); +}, 1000); + +const throttledClearDownloadItems = throttle(() => { + ipcRenderer.send(apiName.symphonyApi, { + cmd: apiCmds.clearDownloadItems, + }); +}, 1000); + let cryptoLib: ICryptoLib | null; try { cryptoLib = remote.require('../app/crypto-handler.js').cryptoLibrary; @@ -215,6 +236,20 @@ export class SSFApi { } } + /** + * Registers the download handler + * @param downloadManagerCallback Callback to be triggered by the download handler + */ + public registerDownloadHandler(downloadManagerCallback: (arg: any) => void): void { + if (typeof downloadManagerCallback === 'function') { + local.downloadManagerCallback = downloadManagerCallback; + } + + local.ipcRenderer.send(apiName.symphonyApi, { + cmd: apiCmds.registerDownloadHandler, + }); + } + /** * Allows JS to register a callback to be invoked when size/positions * changes for any pop-out window (i.e., window.open). The main @@ -487,6 +522,29 @@ export class SSFApi { throttledSetCloudConfig(data); } + /** + * Open Downloaded item + * @param id ID of the item + */ + public openDownloadItem(id: string): void { + throttledOpenDownloadItem(id); + } + + /** + * Show downloaded item in finder / explorer + * @param id ID of the item + */ + public showDownloadItem(id: string): void { + throttledShowDownloadItem(id); + } + + /** + * Clears downloaded items + */ + public clearDownloadItems(): void { + throttledClearDownloadItems(); + } + /** * get CPU usage */ @@ -596,6 +654,18 @@ local.ipcRenderer.on('activity', (_event: Event, idleTime: number) => { } }); +local.ipcRenderer.on('download-completed', (_event: Event, downloadItems: IDownloadManager[]) => { + if (typeof downloadItems === 'object' && typeof local.downloadManagerCallback === 'function') { + local.downloadManagerCallback({status: 'download-completed', items: downloadItems}); + } +}); + +local.ipcRenderer.on('download-failed', (_event: Event) => { + if (typeof local.downloadManagerCallback === 'function') { + local.downloadManagerCallback({status: 'download-failed'}); + } +}); + /** * An event triggered by the main process * Whenever some Window position or dimension changes