feat: SDA-2559: clean app cache on install & crash (#1091)

* SDA-2559: clean app cache on install

- Clean app cache on a fresh install of SDA to avoid upgradation problems
- Clean app cache and restart SDA when it crashes or is unresponsive

* SDA-2559: fix failing unit test

* SDA-2559: add dialog before restarting the app

* SDA-2559: add translations

* SDA-2559: fix failing unit tests and refactor code
This commit is contained in:
Vishwas Shashidhar 2020-10-14 17:28:28 +05:30 committed by GitHub
parent c13a6a16ad
commit e3720c22f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 185 additions and 65 deletions

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "アクセス許可が拒否されています",

View File

@ -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": "アクセス許可が拒否されています",