Merge branch 'master' into SDA-1927

This commit is contained in:
mattias-symphony 2020-04-01 09:31:50 +02:00 committed by GitHub
commit 1f87f6e565
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 5774 additions and 3826 deletions

View File

@ -10,6 +10,8 @@ reviewers:
- KiranNiranjan
- johankwarnmarksymphony
- mattias-symphony
- NavyaGopalakrishna
- psjostrom
# A list of keywords to be skipped the process that add reviewers if pull requests include it
skipKeywords:

View File

@ -18,7 +18,9 @@ gulp.task('clean', function() {
*/
gulp.task('compile', function() {
return tsProject.src()
.pipe(sourcemaps.init())
.pipe(tsProject())
.pipe(sourcemaps.write('.'))
.on('error', (err) => console.log(err))
.pipe(gulp.dest('lib/'))
});

View File

@ -230,8 +230,7 @@
<ROW File="icudtl.dat" Component_="blink_image_resources_200_percent.pak" FileName="icudtl.dat" Attributes="0" SourcePath="..\..\dist\win-unpacked\icudtl.dat" SelfReg="false" NextFile="libEGL.dll"/>
<ROW File="id.pak" Component_="am.pak" FileName="id.pak" Attributes="0" SourcePath="..\..\dist\win-unpacked\locales\id.pak" SelfReg="false" NextFile="it.pak"/>
<ROW File="index.js" Component_="index.js" FileName="index.js" Attributes="0" SourcePath="..\..\dist\win-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\lib\index.js" SelfReg="false" NextFile="index.js_1"/>
<ROW File="index.js.map" Component_="index.js_1" FileName="INDEXJ~1.MAP|index.js.map" Attributes="0" SourcePath="..\..\dist\win-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\lib\src\index.js.map" SelfReg="false" NextFile="package.json_2"/>
<ROW File="index.js_1" Component_="index.js_1" FileName="index.js" Attributes="0" SourcePath="..\..\dist\win-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\lib\src\index.js" SelfReg="false" NextFile="index.js.map"/>
<ROW File="index.js_1" Component_="index.js_1" FileName="index.js" Attributes="0" SourcePath="..\..\dist\win-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\lib\src\index.js" SelfReg="false" NextFile="package.json_2"/>
<ROW File="index.ts" Component_="index.ts" FileName="index.ts" Attributes="0" SourcePath="..\..\dist\win-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\src\index.ts" SelfReg="false" NextFile="tsconfig.json"/>
<ROW File="indexvalidatorx64.exe" Component_="indexvalidatorx64.exe" FileName="INDEXV~1.EXE|indexvalidator-x64.exe" Attributes="0" SourcePath="..\..\library\indexvalidator-x64.exe" SelfReg="false" NextFile="libsymphonysearchx64.dll" DigSign="true"/>
<ROW File="it.pak" Component_="am.pak" FileName="it.pak" Attributes="0" SourcePath="..\..\dist\win-unpacked\locales\it.pak" SelfReg="false" NextFile="ja.pak"/>

View File

@ -229,8 +229,7 @@
<ROW File="icudtl.dat" Component_="blink_image_resources_200_percent.pak" FileName="icudtl.dat" Attributes="0" SourcePath="..\..\dist\win-ia32-unpacked\icudtl.dat" SelfReg="false" NextFile="libEGL.dll"/>
<ROW File="id.pak" Component_="am.pak" FileName="id.pak" Attributes="0" SourcePath="..\..\dist\win-ia32-unpacked\locales\id.pak" SelfReg="false" NextFile="it.pak"/>
<ROW File="index.js" Component_="index.js" FileName="index.js" Attributes="0" SourcePath="..\..\dist\win-ia32-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\lib\index.js" SelfReg="false" NextFile="index.js_1"/>
<ROW File="index.js.map" Component_="index.js_1" FileName="INDEXJ~1.MAP|index.js.map" Attributes="0" SourcePath="..\..\dist\win-ia32-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\lib\src\index.js.map" SelfReg="false" NextFile="package.json_2"/>
<ROW File="index.js_1" Component_="index.js_1" FileName="index.js" Attributes="0" SourcePath="..\..\dist\win-ia32-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\lib\src\index.js" SelfReg="false" NextFile="index.js.map"/>
<ROW File="index.js_1" Component_="index.js_1" FileName="index.js" Attributes="0" SourcePath="..\..\dist\win-ia32-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\lib\src\index.js" SelfReg="false" NextFile="package.json_2"/>
<ROW File="index.ts" Component_="index.ts" FileName="index.ts" Attributes="0" SourcePath="..\..\dist\win-ia32-unpacked\resources\app.asar.unpacked\node_modules\spawn-rx\src\index.ts" SelfReg="false" NextFile="tsconfig.json"/>
<ROW File="indexvalidatorx86.exe" Component_="indexvalidatorx86.exe" FileName="INDEXV~2.EXE|indexvalidator-x86.exe" Attributes="0" SourcePath="..\..\library\indexvalidator-x86.exe" SelfReg="false" NextFile="libsymphonysearchx86.dll" DigSign="true"/>
<ROW File="it.pak" Component_="am.pak" FileName="it.pak" Attributes="0" SourcePath="..\..\dist\win-ia32-unpacked\locales\it.pak" SelfReg="false" NextFile="ja.pak"/>

9348
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -50,7 +50,8 @@
"!tests/*",
"!node_modules/@nornagon/cld/deps/cld${/*}",
"!node_modules/@nornagon/cld/build/deps${/*}",
"!node_modules/@nornagon/spellchecker/vendor${/*}"
"!node_modules/@nornagon/spellchecker/vendor${/*}",
"!**/*.map"
],
"extraFiles": [
"config/Symphony.config",
@ -118,14 +119,14 @@
"@types/react-dom": "16.0.9",
"@types/ref-napi": "1.4.0",
"ava": "2.4.0",
"browserify": "16.2.3",
"browserify": "16.5.1",
"cross-env": "5.2.0",
"del": "3.0.0",
"electron": "8.0.3",
"electron-builder": "21.2.0",
"electron-builder-squirrel-windows": "20.38.3",
"electron-icon-maker": "0.0.4",
"electron-rebuild": "1.8.2",
"electron-rebuild": "1.10.1",
"enzyme": "3.9.0",
"enzyme-adapter-react-16": "1.10.0",
"enzyme-to-json": "3.3.5",
@ -133,28 +134,27 @@
"gulp-less": "4.0.1",
"gulp-sourcemaps": "2.6.4",
"gulp-typescript": "5.0.1",
"jest": "23.6.0",
"jest-html-reporter": "2.4.2",
"jest": "25.2.4",
"jest-html-reporter": "3.0.0",
"less": "3.8.1",
"ncp": "2.0.0",
"node-abi": "^2.15.0",
"npm-run-all": "^4.1.5",
"npm-run-all": "4.1.5",
"robotjs": "0.6.0",
"run-script-os": "1.0.7",
"spectron": "10.0.1",
"ts-jest": "23.10.5",
"ts-jest": "25.3.0",
"tslint": "5.11.0",
"typescript": "3.1.1"
},
"dependencies": {
"archiver": "3.1.1",
"async.map": "0.5.2",
"auto-launch": "5.0.5",
"classnames": "2.2.6",
"electron-dl": "3.0.0",
"electron-fetch": "1.4.0",
"electron-log": "4.0.7",
"electron-spellchecker": "git+https://github.com/symphonyoss/electron-spellchecker.git#v2.3.0",
"electron-spellchecker": "git+https://github.com/symphonyoss/electron-spellchecker.git#v2.0.4",
"ffi-napi": "2.4.6",
"filesize": "6.1.0",
"react": "16.13.0",
@ -173,8 +173,8 @@
"files": [
"lib/spectron/**/*.spec.js"
],
"sources": [
"lib/src/**/*.js"
"ignoredByWatcher": [
"!lib/src/**/*.js"
]
}
}

View File

@ -173,6 +173,13 @@ const getCurrentWindow = jest.fn(() => {
};
});
const clipboard = {
write: jest.fn(),
readTest: jest.fn(() => {
return '';
}),
};
export const dialog = {
showMessageBox: jest.fn(),
showErrorBox: jest.fn(),
@ -198,4 +205,5 @@ export const session = {
export const remote = {
app,
getCurrentWindow,
clipboard,
};

View File

@ -39,6 +39,13 @@ exports[`about app should render correctly 1`] = `
<ul
className="AboutApp-symphony-section"
>
<li>
<b>
POD:
</b>
N/A
</li>
<li>
<b>
SBE:

View File

@ -1,14 +1,21 @@
import { shallow } from 'enzyme';
import * as React from 'react';
import AboutApp from '../src/renderer/components/about-app';
import { ipcRenderer } from './__mocks__/electron';
import { ipcRenderer, remote } from './__mocks__/electron';
describe('about app', () => {
const aboutAppDataLabel = 'about-app-data';
const aboutDataMock = {
buildNumber: '4.x.x',
userConfig: {},
globalConfig: {},
cloudConfig: {},
finalConfig: {},
appName: 'Symphony',
versionLocalised: 'Version',
clientVersion: '1',
version: 'x.x.x',
buildNumber: '4.x.x',
hostname: 'N/A',
sfeVersion: 'N/A',
sdaVersion: '3.8.0',
sdaBuildNumber: '0',
electronVersion: '3.1.11',
@ -21,7 +28,7 @@ describe('about app', () => {
aresVersion: '9.10',
httpParserVersion: '11.12',
swiftSearchVersion: '1.55.3-beta.1',
swiftSerchSupportedVersion: '1.55.3',
swiftSearchSupportedVersion: 'N/A',
};
const onLabelEvent = 'on';
const removeListenerLabelEvent = 'removeListener';
@ -52,4 +59,14 @@ describe('about app', () => {
ipcRenderer.send('about-app-data', aboutDataMock);
expect(spy).toBeCalledWith(aboutDataMock);
});
it('should copy the correct data on to clipboard', () => {
const spyMount = jest.spyOn(remote.clipboard, 'write');
const wrapper = shallow(React.createElement(AboutApp));
ipcRenderer.send('about-app-data', aboutDataMock);
const copyButtonSelector = `button.AboutApp-copy-button[title="Copy all the version information in this page"]`;
wrapper.find(copyButtonSelector).simulate('click');
const expectedData = { text: JSON.stringify(aboutDataMock, null, 4) };
expect(spyMount).toBeCalledWith(expectedData, 'clipboard');
});
});

View File

@ -16,6 +16,12 @@ export enum CloudConfigDataTypes {
DISABLED = 'DISABLED',
}
export enum ClientSwitchType {
CLIENT_1_5 = 'CLIENT_1_5',
CLIENT_2_0 = 'CLIENT_2_0',
CLIENT_2_0_DAILY = 'CLIENT_2_0_DAILY',
}
export interface IGlobalConfig {
url: string;
contextIsolation: boolean;
@ -42,6 +48,7 @@ export interface IConfig {
notificationSettings: INotificationSetting;
mainWinPos?: ICustomRectangle;
locale?: string;
clientSwitch: ClientSwitchType;
}
export interface ICloudConfig {
@ -108,10 +115,10 @@ export interface ICustomRectangle extends Partial<Electron.Rectangle> {
}
class Config {
private userConfig: IConfig | {};
private globalConfig: IConfig | {};
private cloudConfig: ICloudConfig | {};
private filteredCloudConfig: ICloudConfig | {};
public userConfig: IConfig | {};
public globalConfig: IConfig | {};
public cloudConfig: ICloudConfig | {};
public filteredCloudConfig: ICloudConfig | {};
private isFirstTime: boolean = true;
private readonly configFileName: string;
private readonly userConfigPath: string;

View File

@ -1,6 +1,14 @@
import { ChildProcess, ExecException, execFile } from 'child_process';
import * as electron from 'electron';
import { app, BrowserWindow, BrowserWindowConstructorOptions, crashReporter, DesktopCapturerSource, globalShortcut, ipcMain } from 'electron';
import {
app,
BrowserWindow,
BrowserWindowConstructorOptions,
crashReporter,
DesktopCapturerSource,
globalShortcut,
ipcMain,
} from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { format, parse } from 'url';
@ -13,7 +21,7 @@ import { getCommandLineArgs, getGuid } from '../common/utils';
import { notification } from '../renderer/notification';
import { AppMenu } from './app-menu';
import { handleChildWindow } from './child-window-handler';
import { CloudConfigDataTypes, config, IConfig, IGlobalConfig } from './config-handler';
import { ClientSwitchType, CloudConfigDataTypes, config, IConfig, IGlobalConfig } from './config-handler';
import { SpellChecker } from './spell-check-handler';
import { checkIfBuildExpired } from './ttl-handler';
import { versionHandler } from './version-handler';
@ -46,14 +54,6 @@ export interface ICustomBrowserWindow extends Electron.BrowserWindow {
const DEFAULT_WIDTH: number = 900;
const DEFAULT_HEIGHT: number = 900;
enum ClientVersionTypes {
CLIENT_1_5 = 'client_1_5',
CLIENT_MANA_STABLE = 'client_mana_stable',
CLIENT_MANA_DAILY = 'client_mana_daily',
}
let currentClient = ClientVersionTypes.CLIENT_1_5;
export class WindowHandler {
/**
@ -76,6 +76,8 @@ export class WindowHandler {
public isAutoReload: boolean;
public isOnline: boolean;
public url: string | undefined;
public startUrl!: string;
public currentClient: ClientSwitchType = ClientSwitchType.CLIENT_1_5;
public willQuitApp: boolean = false;
public spellchecker: SpellChecker | undefined;
public isCustomTitleBar: boolean;
@ -101,7 +103,7 @@ export class WindowHandler {
constructor(opts?: Electron.BrowserViewConstructorOptions) {
// Use these variables only on initial setup
this.config = config.getConfigFields([ 'isCustomTitleBar', 'mainWinPos', 'minimizeOnClose', 'notificationSettings', 'alwaysOnTop', 'locale', 'customFlags' ]);
this.config = config.getConfigFields([ 'isCustomTitleBar', 'mainWinPos', 'minimizeOnClose', 'notificationSettings', 'alwaysOnTop', 'locale', 'customFlags', 'clientSwitch' ]);
logger.info(`window-handler: main windows initialized with following config data`, this.config);
this.globalConfig = config.getGlobalConfigFields([ 'url', 'contextIsolation' ]);
const { disableThrottling } = config.getCloudConfigFields([ 'disableThrottling' ]) as any;
@ -156,7 +158,7 @@ export class WindowHandler {
/**
* Starting point of the app
*/
public createApplication() {
public async createApplication() {
this.updateVersionInfo();
this.spellchecker = new SpellChecker();
@ -226,6 +228,7 @@ export class WindowHandler {
}
}
this.startUrl = this.url;
// loads the main window with url from config/cmd line
this.mainWindow.loadURL(this.url);
// check for build expiry in case of test builds
@ -241,6 +244,13 @@ export class WindowHandler {
}
this.url = this.mainWindow.webContents.getURL();
logger.info(`window-handler: client switch from config is ${this.config.clientSwitch}`);
const parsedUrl = parse(this.url);
if (this.url.startsWith('https://corporate.symphony.com') && this.url.indexOf(`https://${parsedUrl.hostname}/client/index.html`) !== -1) {
this.switchClient(this.config.clientSwitch ? this.config.clientSwitch : ClientSwitchType.CLIENT_2_0);
}
// Injects custom title bar and snack bar css into the webContents
await injectStyles(this.mainWindow, this.isCustomTitleBar);
@ -527,7 +537,7 @@ export class WindowHandler {
const opts: BrowserWindowConstructorOptions = this.getWindowOpts({
width: 440,
height: 305,
height: 315,
modal: true,
alwaysOnTop: isMac,
resizable: false,
@ -550,7 +560,18 @@ export class WindowHandler {
this.aboutAppWindow.webContents.once('did-finish-load', async () => {
const ABOUT_SYMPHONY_NAMESPACE = 'AboutSymphony';
const versionLocalised = i18n.t('Version', ABOUT_SYMPHONY_NAMESPACE)();
const { hostname } = parse(this.url || this.globalConfig.url);
const userConfig = config.userConfig;
const globalConfig = config.globalConfig;
const cloudConfig = config.cloudConfig;
const filteredConfig = config.filteredCloudConfig;
const finalConfig = { ...globalConfig, ...userConfig, ...filteredConfig };
const aboutInfo = {
userConfig,
globalConfig,
cloudConfig,
finalConfig,
hostname,
buildNumber: versionHandler.versionInfo.buildNumber,
clientVersion: versionHandler.versionInfo.clientVersion,
sfeVersion: versionHandler.versionInfo.sfeVersion,
@ -982,10 +1003,10 @@ export class WindowHandler {
globalShortcut.register('CmdOrCtrl+R', this.onReload);
// Hack to switch between Client 1.5, Mana-stable and Mana-daily
if (this.globalConfig.url && this.globalConfig.url.startsWith('https://corporate.symphony.com')) {
globalShortcut.register(isMac ? 'Cmd+Alt+1' : 'Ctrl+Shift+1', this.onClient1_5);
globalShortcut.register(isMac ? 'Cmd+Alt+2' : 'Ctrl+Shift+2', this.onClientManaStable);
globalShortcut.register(isMac ? 'Cmd+Alt+3' : 'Ctrl+Shift+3', this.onClientManaDaily);
if (this.url && this.url.startsWith('https://corporate.symphony.com')) {
globalShortcut.register(isMac ? 'Cmd+Alt+1' : 'Ctrl+Shift+1', () => this.switchClient(ClientSwitchType.CLIENT_1_5));
globalShortcut.register(isMac ? 'Cmd+Alt+2' : 'Ctrl+Shift+2', () => this.switchClient(ClientSwitchType.CLIENT_2_0));
globalShortcut.register(isMac ? 'Cmd+Alt+3' : 'Ctrl+Shift+3', () => this.switchClient(ClientSwitchType.CLIENT_2_0_DAILY));
} else {
logger.info('Switch between clients not supported for this POD-url');
}
@ -1059,72 +1080,49 @@ export class WindowHandler {
}
/**
* HACK SWITCH to Client 1.5
* Switch between clients 1.5, 2.0 and 2.0 daily
* @param clientSwitch client switch you want to switch to.
*/
private async onClient1_5(): Promise <void> {
logger.info('window handler: go to Client 1.5');
logger.info('window handler: currentClient: ' + currentClient);
if (currentClient === ClientVersionTypes.CLIENT_1_5) {
return;
}
currentClient = ClientVersionTypes.CLIENT_1_5;
const focusedWindow = BrowserWindow.getFocusedWindow();
const dogfoodUrl = `https://corporate.symphony.com/`;
if (focusedWindow && windowExists(focusedWindow)) {
await focusedWindow.loadURL(dogfoodUrl);
} else {
logger.error('window handler: Could not go to client 1.5');
}
}
private async switchClient(clientSwitch: ClientSwitchType): Promise<void> {
/**
* HACK SWITCH to Client Mana-stable
*/
private async onClientManaStable(): Promise <void> {
logger.info('window handler: go to Client Mana-stable');
logger.info('window handler: currentClient: ' + currentClient);
if (currentClient === ClientVersionTypes.CLIENT_MANA_STABLE) {
if (this.currentClient && this.currentClient === clientSwitch) {
logger.info(`window handler: already in the same client ${clientSwitch}. Not switching!`);
return;
}
currentClient = ClientVersionTypes.CLIENT_MANA_STABLE;
logger.info(`window handler: switch to client ${clientSwitch}`);
logger.info(`window handler: currentClient: ${this.currentClient}`);
this.currentClient = clientSwitch;
const focusedWindow = BrowserWindow.getFocusedWindow();
let csrfToken;
if (focusedWindow && windowExists(focusedWindow)) {
try {
csrfToken = await focusedWindow.webContents.executeJavaScript(`localStorage.getItem('x-km-csrf-token')`);
} catch (e) {
logger.error(e);
if (!(focusedWindow && windowExists(focusedWindow))) {
return;
}
try {
if (!this.url) {
this.url = this.globalConfig.url;
}
const dogfoodUrl = `https://corporate.symphony.com/client-bff/index.html?x-km-csrf-token=${csrfToken}`;
await focusedWindow.loadURL(dogfoodUrl);
} else {
logger.error('window handler: Could not go to client Mana-stable');
}
}
/**
* HACK SWITCH to Client Mana-daily
*/
private async onClientManaDaily(): Promise <void> {
logger.info('window handler: go to Client Mana-daily');
logger.info('window handler: currentClient: ' + currentClient);
if (currentClient === ClientVersionTypes.CLIENT_MANA_DAILY) {
return;
}
currentClient = ClientVersionTypes.CLIENT_MANA_DAILY;
const focusedWindow = BrowserWindow.getFocusedWindow();
let csrfToken;
if (focusedWindow && windowExists(focusedWindow)) {
try {
csrfToken = await focusedWindow.webContents.executeJavaScript(`localStorage.getItem('x-km-csrf-token')`);
} catch (e) {
logger.error(e);
const parsedUrl = parse(this.url);
const manaPath = 'client-bff';
const manaChannel = 'daily';
const csrfToken = await focusedWindow.webContents.executeJavaScript(`localStorage.getItem('x-km-csrf-token')`);
switch (this.currentClient) {
case ClientSwitchType.CLIENT_1_5:
this.url = this.startUrl;
break;
case ClientSwitchType.CLIENT_2_0:
this.url = `https://${parsedUrl.hostname}/${manaPath}/index.html?x-km-csrf-token=${csrfToken}`;
break;
case ClientSwitchType.CLIENT_2_0_DAILY:
this.url = `https://${parsedUrl.hostname}/${manaPath}/${manaChannel}/index.html?x-km-csrf-token=${csrfToken}`;
break;
default:
this.url = this.globalConfig.url;
}
const dogfoodUrl = `https://corporate.symphony.com/client-bff/daily/index.html?x-km-csrf-token=${csrfToken}`;
await focusedWindow.loadURL(dogfoodUrl);
} else {
logger.error('window handler: Could not go to client Mana-stable');
await config.updateUserConfig({ clientSwitch });
this.config.clientSwitch = clientSwitch;
await focusedWindow.loadURL(this.url);
} catch (e) {
logger.error(`window-handler: failed to switch client because of error ${e}`);
}
}

View File

@ -3,10 +3,15 @@ import * as React from 'react';
import { i18n } from '../../common/i18n-preload';
interface IState {
userConfig: object;
globalConfig: object;
cloudConfig: object;
finalConfig: object;
appName: string;
copyWrite?: string;
clientVersion: string;
buildNumber: string;
hostname: string;
sfeVersion: string;
versionLocalised?: string;
sdaVersion?: string;
@ -38,10 +43,15 @@ export default class AboutApp extends React.Component<{}, IState> {
constructor(props) {
super(props);
this.state = {
userConfig: {},
globalConfig: {},
cloudConfig: {},
finalConfig: {},
appName: 'Symphony',
versionLocalised: 'Version',
clientVersion: 'N/A',
buildNumber: 'N/A',
hostname: 'N/A',
sfeVersion: 'N/A',
sdaVersion: 'N/A',
sdaBuildNumber: 'N/A',
@ -64,7 +74,7 @@ export default class AboutApp extends React.Component<{}, IState> {
* main render function
*/
public render(): JSX.Element {
const { clientVersion, buildNumber, sfeVersion,
const { clientVersion, buildNumber, hostname, sfeVersion,
sdaVersion, sdaBuildNumber,
} = this.state;
@ -90,6 +100,7 @@ export default class AboutApp extends React.Component<{}, IState> {
<div className='AboutApp-main-container'>
<section>
<ul className='AboutApp-symphony-section'>
<li><b>POD:</b> {hostname || 'N/A'}</li>
<li><b>SBE:</b> {podVersion}</li>
<li><b>SDA:</b> {sdaVersionBuild}</li>
<li><b>SFE:</b> {sfeVersion}</li>
@ -121,7 +132,7 @@ export default class AboutApp extends React.Component<{}, IState> {
public copy(): void {
const data = this.state;
if (data) {
remote.clipboard.write({ text: JSON.stringify(data) }, 'clipboard' );
remote.clipboard.write({ text: JSON.stringify(data, null, 4) }, 'clipboard' );
}
}