feat: SDA-1995: implement download manager for Mana (#982)

* SDA-1995: add download handler functionality for Mana

Signed-off-by: Vishwas Shashidhar <vishwas.shashidhar@symphony.com>

* SDA-1995: add unit tests

Signed-off-by: Vishwas Shashidhar <vishwas.shashidhar@symphony.com>

* SDA-1995: fix unit tests on Windows

Signed-off-by: Vishwas Shashidhar <vishwas.shashidhar@symphony.com>

* SDA-1955: address PR comments

Signed-off-by: Vishwas Shashidhar <vishwas.shashidhar@symphony.com>
This commit is contained in:
Vishwas Shashidhar
2020-05-01 13:20:07 +05:30
committed by GitHub
parent 0921cca4b1
commit 8f518e3936
11 changed files with 504 additions and 4 deletions

View File

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

View File

@@ -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 = {

168
src/app/download-handler.ts Normal file
View File

@@ -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<void> {
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 };

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -95,6 +95,9 @@
<div id="footer" class="hidden">
<div id="download-manager-footer" class="download-bar"></div>
</div>
<input type="button" id="open-download-item" value="Open Latest Item">
<input type="button" id="show-download-item" value="Show Latest Item in Finder">
<input type="button" id="close-download-manager" value="Close Download Manager">
<br>
<hr>
<p>
@@ -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);
}
});
</script>
</html>

View File

@@ -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}

View File

@@ -8,7 +8,7 @@ interface IDownloadManager {
_id: string;
fileName: string;
savedPath: string;
total: number;
total: string;
flashing: boolean;
count: number;
}

View File

@@ -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,

View File

@@ -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