diff --git a/package.json b/package.json index 3aa94926..f1f573eb 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "@types/react": "16.8.3", "@types/react-dom": "16.0.9", "@types/ref-napi": "1.4.0", + "@types/rimraf": "^3.0.0", "ava": "2.4.0", "browserify": "16.5.1", "cross-env": "5.2.0", @@ -161,6 +162,7 @@ "react": "16.13.0", "react-dom": "16.13.0", "ref-napi": "1.4.3", + "rimraf": "^3.0.2", "shell-path": "2.1.0" }, "optionalDependencies": { diff --git a/spec/appCacheHandler.spec.ts b/spec/appCacheHandler.spec.ts index ee3f2045..d4932624 100644 --- a/spec/appCacheHandler.spec.ts +++ b/spec/appCacheHandler.spec.ts @@ -1,12 +1,27 @@ import * as fs from 'fs'; import * as path from 'path'; -import { cleanUpAppCache, createAppCacheFile } from '../src/app/app-cache-handler'; +import * as rimraf from 'rimraf'; +import { cleanAppCacheOnInstall, cleanUpAppCache, createAppCacheFile } from '../src/app/app-cache-handler'; import { app, session } from './__mocks__/electron'; jest.mock('fs', () => ({ - writeFileSync: jest.fn(), - existsSync: jest.fn(() => true), - unlinkSync: jest.fn(), + writeFileSync: jest.fn(), + existsSync: jest.fn(() => true), + unlinkSync: jest.fn(), + readdirSync: jest.fn(() => ['fake1', 'fake2', 'Symphony.config', 'cloudConfig.config']), + lstatSync: jest.fn(() => { + return { + isDirectory: jest.fn(() => true), + }; + }), +})); + +jest.mock('path', () => ({ + join: jest.fn(), +})); + +jest.mock('rimraf', () => ({ + sync: jest.fn(), })); jest.mock('../src/common/logger', () => { @@ -19,27 +34,53 @@ jest.mock('../src/common/logger', () => { }); describe('app cache handler', () => { - const cachePathExpected = path.join(app.getPath('userData'), 'CacheCheck'); + describe('check app cache file', () => { + const cachePathExpected = path.join(app.getPath('userData'), 'CacheCheck'); - it('should call `cleanUpAppCache` correctly', () => { - const spyFn = 'unlinkSync'; - const spy = jest.spyOn(fs, spyFn); - cleanUpAppCache(); - expect(spy).toBeCalledWith(cachePathExpected); + it('should call `cleanUpAppCache` correctly', () => { + const spyFn = 'unlinkSync'; + const spy = jest.spyOn(fs, spyFn); + cleanUpAppCache(); + expect(spy).toBeCalledWith(cachePathExpected); + }); + + it('should call `clearCache` when `session.defaultSession` is not null', () => { + jest.spyOn(fs, 'existsSync').mockImplementation(() => false); + const spyFn = 'clearCache'; + const spy = jest.spyOn(session.defaultSession, spyFn); + cleanUpAppCache(); + expect(spy).toBeCalled(); + }); + + it('should call `createAppCacheFile` correctly', () => { + const spyFn = 'writeFileSync'; + const spy = jest.spyOn(fs, spyFn); + createAppCacheFile(); + expect(spy).lastCalledWith(cachePathExpected, ''); + }); }); - it('should call `clearCache` when `session.defaultSession` is not null', () => { - jest.spyOn(fs, 'existsSync').mockImplementation(() => false); - const spyFn = 'clearCache'; - const spy = jest.spyOn(session.defaultSession, spyFn); - cleanUpAppCache(); - expect(spy).toBeCalled(); + describe('clean app cache on install', () => { + it('should clean app cache and cookies on install', () => { + + const pathSpy = jest.spyOn(path, 'join'); + + const fsReadDirSpy = jest.spyOn(fs, 'readdirSync'); + const fsStatSpy = jest.spyOn(fs, 'lstatSync'); + const fsUnlinkSpy = jest.spyOn(fs, 'unlinkSync'); + + const rimrafSpy = jest.spyOn(rimraf, 'sync'); + + cleanAppCacheOnInstall(); + + expect(pathSpy).toBeCalled(); + + expect(fsReadDirSpy).toBeCalled(); + expect(fsStatSpy).toBeCalled(); + expect(fsUnlinkSpy).toBeCalled(); + + expect(rimrafSpy).toBeCalled(); + }); }); - it('should call `createAppCacheFile` correctly', () => { - const spyFn = 'writeFileSync'; - const spy = jest.spyOn(fs, spyFn); - createAppCacheFile(); - expect(spy).lastCalledWith(cachePathExpected, ''); - }); }); diff --git a/src/app/app-cache-handler.ts b/src/app/app-cache-handler.ts index 2ee72ef4..4e791b1e 100644 --- a/src/app/app-cache-handler.ts +++ b/src/app/app-cache-handler.ts @@ -1,11 +1,37 @@ -import { app, session } from 'electron'; +import { app, BrowserWindow, dialog, session } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; +import * as rimraf from 'rimraf'; +import { i18n } from '../common/i18n'; import { logger } from '../common/logger'; // Cache check file path -const cacheCheckFilePath: string = path.join(app.getPath('userData'), 'CacheCheck'); +const userDataPath: string = app.getPath('userData'); +const cacheCheckFilePath: string = path.join(userDataPath, 'CacheCheck'); + +/** + * Cleans old cache + */ +const cleanOldCache = (): void => { + const configFilename = 'Symphony.config'; + const cloudConfigFilename = 'cloudConfig.config'; + + const files = fs.readdirSync(userDataPath); + + files.forEach((file) => { + const filePath = path.join(userDataPath, file); + if (file === configFilename || file === cloudConfigFilename) { + return; + } + + if (fs.lstatSync(filePath).isDirectory()) { + rimraf.sync(filePath); + return; + } + fs.unlinkSync(filePath); + }); +}; /** * Deletes app cache file if exists or clears @@ -30,3 +56,45 @@ export const createAppCacheFile = (): void => { logger.info(`app-cache-handler: this is a clean exit, creating app cache file`); fs.writeFileSync(cacheCheckFilePath, ''); }; + +/** + * Cleans the app cache on new install + */ +export const cleanAppCacheOnInstall = (): void => { + logger.info(`app-cache-handler: cleaning app cache and cookies on new install`); + cleanOldCache(); +}; + +/** + * Cleans app cache and restarts the app on crash or unresponsive events + * @param window Browser window to listen to for crash events + */ +export const cleanAppCacheOnCrash = (window: BrowserWindow): void => { + logger.info(`app-cache-handler: listening to crash events & cleaning app cache`); + const events = ['unresponsive', 'crashed', 'plugin-crashed']; + + events.forEach((windowEvent: any) => { + window.webContents.on(windowEvent, async () => { + logger.info(`app-cache-handler: Window Event '${windowEvent}' occurred. Clearing cache & restarting app`); + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (!focusedWindow || (typeof focusedWindow.isDestroyed === 'function' && focusedWindow.isDestroyed())) { + return; + } + const options = { + type: 'question', + title: i18n.t('Relaunch Application')(), + message: i18n.t('Oops! Something went wrong. Would you like to restart the app?')(), + buttons: [i18n.t('Restart')(), i18n.t('Cancel')()], + cancelId: 1, + }; + + const { response } = await dialog.showMessageBox(focusedWindow, options); + + if (response === 0) { + cleanOldCache(); + app.relaunch(); + app.exit(); + } + }); + }); +}; diff --git a/src/app/dialog-handler.ts b/src/app/dialog-handler.ts index 2a7988ba..2afc15d0 100644 --- a/src/app/dialog-handler.ts +++ b/src/app/dialog-handler.ts @@ -68,7 +68,7 @@ electron.app.on('certificate-error', async (event, webContents, url, error, _cer const browserWin = electron.BrowserWindow.fromWebContents(webContents); if (browserWin && windowExists(browserWin)) { - const {response} = await electron.dialog.showMessageBox(browserWin, { + const { response } = await electron.dialog.showMessageBox(browserWin, { type: 'warning', buttons: [ i18n.t('Allow')(), @@ -111,7 +111,7 @@ export const showLoadFailure = async (browserWindow: Electron.BrowserWindow, url if (showDialog) { const { response } = await electron.dialog.showMessageBox(browserWindow, { type: 'error', - buttons: [ i18n.t('Reload')(), i18n.t('Ignore')() ], + buttons: [i18n.t('Reload')(), i18n.t('Ignore')()], defaultId: 0, cancelId: 1, noLink: true, @@ -157,13 +157,13 @@ export const titleBarChangeDialog = async (isNativeStyle: CloudConfigDataTypes) title: i18n.t('Relaunch Application')(), message: i18n.t('Updating Title bar style requires Symphony to relaunch.')(), detail: i18n.t('Note: When Hamburger menu is disabled, you can trigger the main menu by pressing the Alt key.')(), - buttons: [ i18n.t('Relaunch')(), i18n.t('Cancel')() ], + buttons: [i18n.t('Relaunch')(), i18n.t('Cancel')()], cancelId: 1, }; const { response } = await electron.dialog.showMessageBox(focusedWindow, options); if (response === 0) { logger.error(`test`, isNativeStyle); - await config.updateUserConfig({ isCustomTitleBar: isNativeStyle }); + await config.updateUserConfig({ isCustomTitleBar: isNativeStyle }); app.relaunch(); app.exit(); } @@ -182,7 +182,7 @@ export const gpuRestartDialog = async (disableGpu: boolean) => { type: 'question', title: i18n.t('Relaunch Application')(), message: i18n.t('Would you like to restart and apply these new settings now?')(), - buttons: [ i18n.t('Restart')(), i18n.t('Later')() ], + buttons: [i18n.t('Restart')(), i18n.t('Later')()], cancelId: 1, }; const { response } = await electron.dialog.showMessageBox(focusedWindow, options); diff --git a/src/app/main.ts b/src/app/main.ts index b38df304..520a975e 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -5,7 +5,7 @@ import * as shellPath from 'shell-path'; import { isDevEnv, isElectronQA, isLinux, isMac } from '../common/env'; import { logger } from '../common/logger'; import { getCommandLineArgs } from '../common/utils'; -import { cleanUpAppCache, createAppCacheFile } from './app-cache-handler'; +import { cleanAppCacheOnInstall, cleanUpAppCache, createAppCacheFile } from './app-cache-handler'; import { autoLaunchInstance } from './auto-launch-controller'; import { setChromeFlags, setSessionProperties } from './chrome-flags'; import { config } from './config-handler'; @@ -81,6 +81,7 @@ const startApplication = async () => { createAppCacheFile(); if (config.isFirstTimeLaunch()) { logger.info(`main: This is a first time launch! will update config and handle auto launch`); + cleanAppCacheOnInstall(); await config.setUpFirstTimeLaunch(); if (!isLinux) { await autoLaunchInstance.handleAutoLaunch(); @@ -142,7 +143,7 @@ app.on('window-all-closed', () => { /** * Creates a new empty cache file when the app is quit */ -app.on('quit', () => { +app.on('quit', () => { logger.info(`main: quitting the app!`); cleanUpAppCache(); }); diff --git a/src/app/window-handler.ts b/src/app/window-handler.ts index 31c2ced0..c36de94a 100644 --- a/src/app/window-handler.ts +++ b/src/app/window-handler.ts @@ -19,6 +19,7 @@ import { i18n, LocaleType } from '../common/i18n'; import { logger } from '../common/logger'; import { getCommandLineArgs, getGuid } from '../common/utils'; import { notification } from '../renderer/notification'; +import { cleanAppCacheOnCrash } from './app-cache-handler'; import { AppMenu } from './app-menu'; import { handleChildWindow } from './child-window-handler'; import { CloudConfigDataTypes, config, IConfig, IGlobalConfig } from './config-handler'; @@ -114,14 +115,14 @@ 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', 'clientSwitch', 'enableRendererLogs' ]); + this.config = config.getConfigFields(['isCustomTitleBar', 'mainWinPos', 'minimizeOnClose', 'notificationSettings', 'alwaysOnTop', 'locale', 'customFlags', 'clientSwitch', 'enableRendererLogs']); logger.info(`window-handler: main windows initialized with following config data`, this.config); - this.globalConfig = config.getGlobalConfigFields([ 'url', 'contextIsolation', 'contextOriginUrl' ]); - this.userConfig = config.getUserConfigFields([ 'url' ]); + this.globalConfig = config.getGlobalConfigFields(['url', 'contextIsolation', 'contextOriginUrl']); + this.userConfig = config.getUserConfigFields(['url']); const { customFlags } = this.config; - const { disableThrottling } = config.getCloudConfigFields([ 'disableThrottling' ]) as any; + const { disableThrottling } = config.getCloudConfigFields(['disableThrottling']) as any; this.windows = {}; this.contextIsolation = this.globalConfig.contextIsolation || false; @@ -159,9 +160,9 @@ export class WindowHandler { i18n.setLocale(locale); try { - const extra = {podUrl: this.userConfig.url ? this.userConfig.url : this.globalConfig.url, process: 'main'}; - const defaultOpts = {uploadToServer: false, companyName: 'Symphony', submitURL: ''}; - crashReporter.start({...defaultOpts, extra}); + const extra = { podUrl: this.userConfig.url ? this.userConfig.url : this.globalConfig.url, process: 'main' }; + const defaultOpts = { uploadToServer: false, companyName: 'Symphony', submitURL: '' }; + crashReporter.start({ ...defaultOpts, extra }); } catch (e) { throw new Error('failed to init crash report'); } @@ -178,7 +179,7 @@ export class WindowHandler { logger.info('window-handler: createApplication mainWinPos: ' + JSON.stringify(this.config.mainWinPos)); - let {isFullScreen, isMaximized} = this.config.mainWinPos ? this.config.mainWinPos : {isFullScreen: false, isMaximized: false}; + let { isFullScreen, isMaximized } = this.config.mainWinPos ? this.config.mainWinPos : { isFullScreen: false, isMaximized: false }; this.url = WindowHandler.getValidUrl(this.userConfig.url ? this.userConfig.url : this.globalConfig.url); logger.info(`window-handler: setting url ${this.url} from config file!`); @@ -219,7 +220,7 @@ export class WindowHandler { logger.info('window-handler: windowSize: sizes: ' + JSON.stringify(sizes)); DEFAULT_WIDTH = Number(sizes[0]); DEFAULT_HEIGHT = Number(sizes[1]); - if (this.config.mainWinPos ) { + if (this.config.mainWinPos) { this.config.mainWinPos.width = DEFAULT_WIDTH; this.config.mainWinPos.height = DEFAULT_HEIGHT; } @@ -245,7 +246,7 @@ export class WindowHandler { if (urlFromCmd) { const commandLineUrl = urlFromCmd.substr(6); logger.info(`window-handler: trying to set url ${commandLineUrl} from command line.`); - const { podWhitelist } = config.getConfigFields([ 'podWhitelist' ]); + const { podWhitelist } = config.getConfigFields(['podWhitelist']); logger.info(`window-handler: checking pod whitelist.`); if (podWhitelist.length > 0) { logger.info(`window-handler: pod whitelist is not empty ${podWhitelist}`); @@ -289,6 +290,7 @@ export class WindowHandler { this.handleWelcomeScreen(); } + cleanAppCacheOnCrash(this.mainWindow); // loads the main window with url from config/cmd line this.mainWindow.loadURL(this.url); // check for build expiry in case of test builds @@ -337,7 +339,7 @@ export class WindowHandler { isMainWindow: true, }); this.appMenu = new AppMenu(); - const { permissions } = config.getConfigFields([ 'permissions' ]); + const { permissions } = config.getConfigFields(['permissions']); this.mainWindow.webContents.send('is-screen-share-enabled', permissions.media); }); @@ -357,7 +359,7 @@ export class WindowHandler { if (href === 'data:text/html,chromewebdata' || href === 'chrome-error://chromewebdata/') { if (this.mainWindow && windowExists(this.mainWindow)) { this.mainWindow.webContents.insertCSS(fs.readFileSync(path.join(__dirname, '..', '/renderer/styles/network-error.css'), 'utf8').toString()); - this.mainWindow.webContents.send('network-error', {error: this.loadFailError}); + this.mainWindow.webContents.send('network-error', { error: this.loadFailError }); isSymphonyReachable(this.mainWindow, this.url || this.userConfig.url || this.globalConfig.url); } } @@ -399,7 +401,7 @@ export class WindowHandler { return this.destroyAllWindows(); } - const { minimizeOnClose } = config.getConfigFields([ 'minimizeOnClose' ]); + const { minimizeOnClose } = config.getConfigFields(['minimizeOnClose']); if (minimizeOnClose === CloudConfigDataTypes.ENABLED) { event.preventDefault(); this.mainWindow.minimize(); @@ -496,7 +498,7 @@ export class WindowHandler { resource: i18n.loadedResources, }); const userConfigUrl = this.userConfig.url - && this.userConfig.url.indexOf('/login/sso/initsso') > -1 ? + && this.userConfig.url.indexOf('/login/sso/initsso') > -1 ? this.userConfig.url.slice(0, this.userConfig.url.indexOf('/login/sso/initsso')) : this.userConfig.url; @@ -510,7 +512,7 @@ export class WindowHandler { }); ipcMain.on('set-pod-url', async (_event, newPodUrl: string) => { - await config.updateUserConfig({url: newPodUrl}); + await config.updateUserConfig({ url: newPodUrl }); app.relaunch(); app.exit(); }); @@ -777,14 +779,14 @@ export class WindowHandler { this.screenPickerWindow.webContents.setZoomFactor(1); this.screenPickerWindow.webContents.setVisualZoomLevelLimits(1, 1); - this.screenPickerWindow.webContents.send('screen-picker-data', {sources, id}); + this.screenPickerWindow.webContents.send('screen-picker-data', { sources, id }); this.addWindow(opts.winKey, this.screenPickerWindow); }); ipcMain.once('screen-source-selected', (_event, source) => { const displays = electron.screen.getAllDisplays(); logger.info('window-utils: displays.length: ' + displays.length); for (let i = 0, len = displays.length; i < len; i++) { - logger.info('window-utils: display[' + i + ']: ' + JSON.stringify(displays[ i ])); + logger.info('window-utils: display[' + i + ']: ' + JSON.stringify(displays[i])); } if (source != null) { @@ -793,22 +795,22 @@ export class WindowHandler { const type = source.id.split(':')[0]; if (type === 'window') { const hwnd = source.id.split(':')[1]; - this.execCmd(this.screenShareIndicatorFrameUtil, [ hwnd ]); + this.execCmd(this.screenShareIndicatorFrameUtil, [hwnd]); } else if (isMac && type === 'screen') { const dispId = source.id.split(':')[1]; - this.execCmd(this.screenShareIndicatorFrameUtil, [ dispId ]); + this.execCmd(this.screenShareIndicatorFrameUtil, [dispId]); } else if (isWindowsOS && type === 'screen') { logger.info('window-handler: source.display_id: ' + source.display_id); if (source.display_id !== '') { - this.execCmd(this.screenShareIndicatorFrameUtil, [ source.display_id ]); + this.execCmd(this.screenShareIndicatorFrameUtil, [source.display_id]); } else { const dispId = source.id.split(':')[1]; const clampedDispId = Math.min(dispId, displays.length - 1); const keyId = 'id'; logger.info('window-utils: dispId: ' + dispId); logger.info('window-utils: clampedDispId: ' + clampedDispId); - logger.info('window-utils: displays [' + clampedDispId + '] [id]: ' + displays [clampedDispId] [ keyId ]); - this.execCmd(this.screenShareIndicatorFrameUtil, [ displays [clampedDispId] [ keyId ].toString() ]); + logger.info('window-utils: displays [' + clampedDispId + '] [id]: ' + displays[clampedDispId][keyId]); + this.execCmd(this.screenShareIndicatorFrameUtil, [displays[clampedDispId][keyId].toString()]); } } } @@ -856,7 +858,7 @@ export class WindowHandler { if (!this.basicAuthWindow || !windowExists(this.basicAuthWindow)) { return; } - this.basicAuthWindow.webContents.send('basic-auth-data', {hostname, isValidCredentials: isMultipleTries}); + this.basicAuthWindow.webContents.send('basic-auth-data', { hostname, isValidCredentials: isMultipleTries }); }); const closeBasicAuth = (_event, shouldClearSettings = true) => { if (shouldClearSettings) { @@ -869,7 +871,7 @@ export class WindowHandler { }; const login = (_event, arg) => { - const {username, password} = arg; + const { username, password } = arg; callback(username, password); closeBasicAuth(null, false); }; @@ -921,17 +923,17 @@ export class WindowHandler { if (app.isReady()) { screens = electron.screen.getAllDisplays(); } - const { position, display } = config.getConfigFields([ 'notificationSettings' ]).notificationSettings; - this.notificationSettingsWindow.webContents.send('notification-settings-data', {screens, position, display}); + const { position, display } = config.getConfigFields(['notificationSettings']).notificationSettings; + this.notificationSettingsWindow.webContents.send('notification-settings-data', { screens, position, display }); } }); this.addWindow(opts.winKey, this.notificationSettingsWindow); ipcMain.once('notification-settings-update', async (_event, args) => { - const {display, position} = args; + const { display, position } = args; try { - await config.updateUserConfig({notificationSettings: {display, position}}); + await config.updateUserConfig({ notificationSettings: { display, position } }); } catch (e) { logger.error(`NotificationSettings: Could not update user config file error`, e); } @@ -991,7 +993,7 @@ export class WindowHandler { closable: false, }, { devTools: false, - }), ...{winKey: streamId}, + }), ...{ winKey: streamId }, }; if (opts.width && opts.height) { opts = Object.assign({}, opts, { @@ -1008,7 +1010,7 @@ export class WindowHandler { logger.info('window-handler: element.id.toString(): ' + element.id.toString()); if (displayId === element.id.toString()) { logger.info(`window-handler: element:`, element); - this.createScreenSharingFrameWindow('screen-sharing-frame', + this.createScreenSharingFrameWindow('screen-sharing-frame', element.workArea.width, element.workArea.height, element.workArea.x, @@ -1027,7 +1029,7 @@ export class WindowHandler { if (!this.screenSharingIndicatorWindow || !windowExists(this.screenSharingIndicatorWindow)) { return; } - this.screenSharingIndicatorWindow.webContents.send('screen-sharing-indicator-data', {id, streamId}); + this.screenSharingIndicatorWindow.webContents.send('screen-sharing-indicator-data', { id, streamId }); }); const stopScreenSharing = (_event, indicatorId) => { if (id === indicatorId) { @@ -1229,7 +1231,7 @@ export class WindowHandler { if (!focusedWindow || !windowExists(focusedWindow)) { return; } - const { devToolsEnabled } = config.getConfigFields([ 'devToolsEnabled' ]); + const { devToolsEnabled } = config.getConfigFields(['devToolsEnabled']); if (devToolsEnabled) { focusedWindow.webContents.toggleDevTools(); return; @@ -1333,7 +1335,7 @@ export class WindowHandler { type: 'error', title: i18n.t('Build expired')(), message: i18n.t('Sorry, this is a test build and it has expired. Please contact your administrator to get a production build.')(), - buttons: [ i18n.t('Quit')() ], + buttons: [i18n.t('Quit')()], cancelId: 0, }; @@ -1364,7 +1366,7 @@ export class WindowHandler { winKey: getGuid(), }; - return {...defaultWindowOpts, ...windowOpts}; + return { ...defaultWindowOpts, ...windowOpts }; } } diff --git a/src/locale/en-US.json b/src/locale/en-US.json index c06a8b7e..c0ae724d 100644 --- a/src/locale/en-US.json +++ b/src/locale/en-US.json @@ -111,6 +111,7 @@ }, "Oops! Looks like we have had a crash.": "Oops! Looks like we have had a crash.", "Oops! Looks like we have had a crash. Please reload or close this window.": "Oops! Looks like we have had a crash. Please reload or close this window.", + "Oops! Something went wrong. Would you like to restart the app?": "Oops! Something went wrong. Would you like to restart the app?", "Paste": "Paste", "Paste and Match Style": "Paste and Match Style", "Permission Denied": "Permission Denied", diff --git a/src/locale/en.json b/src/locale/en.json index c06a8b7e..c0ae724d 100644 --- a/src/locale/en.json +++ b/src/locale/en.json @@ -111,6 +111,7 @@ }, "Oops! Looks like we have had a crash.": "Oops! Looks like we have had a crash.", "Oops! Looks like we have had a crash. Please reload or close this window.": "Oops! Looks like we have had a crash. Please reload or close this window.", + "Oops! Something went wrong. Would you like to restart the app?": "Oops! Something went wrong. Would you like to restart the app?", "Paste": "Paste", "Paste and Match Style": "Paste and Match Style", "Permission Denied": "Permission Denied", diff --git a/src/locale/fr-FR.json b/src/locale/fr-FR.json index 21440099..4f160c83 100644 --- a/src/locale/fr-FR.json +++ b/src/locale/fr-FR.json @@ -112,6 +112,7 @@ }, "Oops! Looks like we have had a crash.": "Oops! On dirait que nous avons eu un crash.", "Oops! Looks like we have had a crash. Please reload or close this window.": "Oops! On dirait que nous avons eu un crash. Veuillez recharger ou fermer cette fenêtre.", + "Oops! Something went wrong. Would you like to restart the app?": "Oups ! Quelque chose s'est mal passé. Voulez-vous redémarrer l'application ?", "Paste": "Coller", "Paste and Match Style": "Coller et appliquer le style", "Permission Denied": "Permission refusée", diff --git a/src/locale/fr.json b/src/locale/fr.json index 99f64b4f..d269de4d 100644 --- a/src/locale/fr.json +++ b/src/locale/fr.json @@ -111,6 +111,7 @@ }, "Oops! Looks like we have had a crash.": "Oops! On dirait que nous avons eu un crash.", "Oops! Looks like we have had a crash. Please reload or close this window.": "Oops! On dirait que nous avons eu un crash. Veuillez recharger ou fermer cette fenêtre.", + "Oops! Something went wrong. Would you like to restart the app?": "Oups ! Quelque chose s'est mal passé. Voulez-vous redémarrer l'application ?", "Paste": "Coller", "Paste and Match Style": "Coller et appliquer le style", "Permission Denied": "Permission refusée", diff --git a/src/locale/ja-JP.json b/src/locale/ja-JP.json index a16529b4..bd439e1a 100644 --- a/src/locale/ja-JP.json +++ b/src/locale/ja-JP.json @@ -111,6 +111,7 @@ }, "Oops! Looks like we have had a crash.": "おっと!クラッシュしたようです。", "Oops! Looks like we have had a crash. Please reload or close this window.": "おっと!クラッシュしたようです。このウィンドウを再度読み込むか閉じてください。", + "Oops! Something went wrong. Would you like to restart the app?": "おっと!何か問題が起こった。アプリを再開しますか?", "Paste": "貼り付け", "Paste and Match Style": "貼り付けでスタイルを合わせる", "Permission Denied": "アクセス許可が拒否されています", diff --git a/src/locale/ja.json b/src/locale/ja.json index a16529b4..bd439e1a 100644 --- a/src/locale/ja.json +++ b/src/locale/ja.json @@ -111,6 +111,7 @@ }, "Oops! Looks like we have had a crash.": "おっと!クラッシュしたようです。", "Oops! Looks like we have had a crash. Please reload or close this window.": "おっと!クラッシュしたようです。このウィンドウを再度読み込むか閉じてください。", + "Oops! Something went wrong. Would you like to restart the app?": "おっと!何か問題が起こった。アプリを再開しますか?", "Paste": "貼り付け", "Paste and Match Style": "貼り付けでスタイルを合わせる", "Permission Denied": "アクセス許可が拒否されています",