diff --git a/.eslintignore b/.eslintignore index 114143630..9219b416b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -415,6 +415,8 @@ packages/app-desktop/utils/checkForUpdatesUtils.test.js packages/app-desktop/utils/checkForUpdatesUtils.js packages/app-desktop/utils/checkForUpdatesUtilsTestData.js packages/app-desktop/utils/markupLanguageUtils.js +packages/app-desktop/utils/restartInSafeModeFromMain.test.js +packages/app-desktop/utils/restartInSafeModeFromMain.js packages/app-mobile/PluginAssetsLoader.js packages/app-mobile/components/ActionButton.js packages/app-mobile/components/BackButtonDialogBox.js @@ -902,6 +904,7 @@ packages/lib/themes/type.js packages/lib/time.js packages/lib/utils/credentialFiles.js packages/lib/utils/joplinCloud.js +packages/lib/utils/processStartFlags.js packages/lib/utils/userFetcher.js packages/lib/utils/webDAVUtils.test.js packages/lib/utils/webDAVUtils.js diff --git a/.gitignore b/.gitignore index 171e848e7..a3e7dcd08 100644 --- a/.gitignore +++ b/.gitignore @@ -397,6 +397,8 @@ packages/app-desktop/utils/checkForUpdatesUtils.test.js packages/app-desktop/utils/checkForUpdatesUtils.js packages/app-desktop/utils/checkForUpdatesUtilsTestData.js packages/app-desktop/utils/markupLanguageUtils.js +packages/app-desktop/utils/restartInSafeModeFromMain.test.js +packages/app-desktop/utils/restartInSafeModeFromMain.js packages/app-mobile/PluginAssetsLoader.js packages/app-mobile/components/ActionButton.js packages/app-mobile/components/BackButtonDialogBox.js @@ -884,6 +886,7 @@ packages/lib/themes/type.js packages/lib/time.js packages/lib/utils/credentialFiles.js packages/lib/utils/joplinCloud.js +packages/lib/utils/processStartFlags.js packages/lib/utils/userFetcher.js packages/lib/utils/webDAVUtils.test.js packages/lib/utils/webDAVUtils.js diff --git a/packages/app-desktop/ElectronAppWrapper.ts b/packages/app-desktop/ElectronAppWrapper.ts index 526784eb7..3a5cbee53 100644 --- a/packages/app-desktop/ElectronAppWrapper.ts +++ b/packages/app-desktop/ElectronAppWrapper.ts @@ -9,7 +9,10 @@ const url = require('url'); const path = require('path'); const { dirname } = require('@joplin/lib/path-utils'); const fs = require('fs-extra'); -const { ipcMain } = require('electron'); + +import { dialog, ipcMain } from 'electron'; +import { _ } from '@joplin/lib/locale'; +import restartInSafeModeFromMain from './utils/restartInSafeModeFromMain'; interface RendererProcessQuitReply { canClose: boolean; @@ -34,7 +37,7 @@ export default class ElectronAppWrapper { private pluginWindows_: PluginWindows = {}; private initialCallbackUrl_: string = null; - public constructor(electronApp: any, env: string, profilePath: string, isDebugMode: boolean, initialCallbackUrl: string) { + public constructor(electronApp: any, env: string, profilePath: string|null, isDebugMode: boolean, initialCallbackUrl: string) { this.electronApp_ = electronApp; this.env_ = env; this.isDebugMode_ = isDebugMode; @@ -66,6 +69,42 @@ export default class ElectronAppWrapper { return this.initialCallbackUrl_; } + // Call when the app fails in a significant way. + // + // Assumes that the renderer process may be in an invalid state and so cannot + // be accessed. + public async handleAppFailure(errorMessage: string, canIgnore: boolean, isTesting?: boolean) { + const buttons = []; + buttons.push(_('Quit')); + const exitIndex = 0; + + if (canIgnore) { + buttons.push(_('Ignore')); + } + const restartIndex = buttons.length; + buttons.push(_('Restart in safe mode')); + + const { response } = await dialog.showMessageBox({ + message: _('An error occurred: %s', errorMessage), + buttons, + }); + + if (response === restartIndex) { + await restartInSafeModeFromMain(); + + // A hung renderer seems to prevent the process from exiting completely. + // In this case, crashing the renderer allows the window to close. + // + // Also only run this if not testing (crashing the renderer breaks automated + // tests). + if (this.win_ && !this.win_.webContents.isCrashed() && !isTesting) { + this.win_.webContents.forcefullyCrashRenderer(); + } + } else if (response === exitIndex) { + process.exit(1); + } + } + public createWindow() { // Set to true to view errors if the application does not start const debugEarlyBugs = this.env_ === 'dev' || this.isDebugMode_; @@ -121,6 +160,20 @@ export default class ElectronAppWrapper { this.win_.setPosition(primaryDisplayWidth / 2 - windowWidth, primaryDisplayHeight / 2 - windowHeight); } + this.win_.webContents.on('unresponsive', async () => { + await this.handleAppFailure(_('Window unresponsive.'), true); + }); + + this.win_.webContents.on('render-process-gone', async _event => { + await this.handleAppFailure('Renderer process gone.', false); + }); + + this.win_.webContents.on('did-fail-load', async event => { + if ((event as any).isMainFrame) { + await this.handleAppFailure('Renderer process failed to load', false); + } + }); + void this.win_.loadURL(url.format({ pathname: path.join(__dirname, 'index.html'), protocol: 'file:', diff --git a/packages/app-desktop/integration-tests/main.spec.ts b/packages/app-desktop/integration-tests/main.spec.ts index 8782b054e..e76ed903f 100644 --- a/packages/app-desktop/integration-tests/main.spec.ts +++ b/packages/app-desktop/integration-tests/main.spec.ts @@ -2,6 +2,9 @@ import { test, expect } from './util/test'; import MainScreen from './models/MainScreen'; import activateMainMenuItem from './util/activateMainMenuItem'; import SettingsScreen from './models/SettingsScreen'; +import { _electron as electron } from '@playwright/test'; +import { writeFile } from 'fs-extra'; +import { join } from 'path'; test.describe('main', () => { @@ -121,4 +124,23 @@ test.describe('main', () => { expect(await nextExternalUrlPromise).toBe(linkHref); }); + + test('should start in safe mode if profile-dir/force-safe-mode-on-next-start exists', async ({ profileDirectory }) => { + await writeFile(join(profileDirectory, 'force-safe-mode-on-next-start'), 'true', 'utf8'); + + // We need to write to the force-safe-mode file before opening the Electron app. + // Open the app ourselves: + const startupArgs = [ + 'main.js', '--env', 'dev', '--profile', profileDirectory, + ]; + const electronApp = await electron.launch({ args: startupArgs }); + const mainWindow = await electronApp.firstWindow(); + + const safeModeDisableLink = mainWindow.getByText('Disable safe mode and restart'); + await safeModeDisableLink.waitFor(); + await expect(safeModeDisableLink).toBeInViewport(); + + await electronApp.close(); + }); }); + diff --git a/packages/app-desktop/integration-tests/util/test.ts b/packages/app-desktop/integration-tests/util/test.ts index 727aee539..bd0c136d6 100644 --- a/packages/app-desktop/integration-tests/util/test.ts +++ b/packages/app-desktop/integration-tests/util/test.ts @@ -6,6 +6,7 @@ import uuid from '@joplin/lib/uuid'; type JoplinFixtures = { + profileDirectory: string; electronApp: ElectronApplication; mainWindow: Page; }; @@ -20,19 +21,26 @@ export const test = base.extend({ // See https://github.com/microsoft/playwright/issues/8798 // // eslint-disable-next-line no-empty-pattern - electronApp: async ({ }, use) => { + profileDirectory: async ({ }, use) => { const profilePath = resolve(join(dirname(__dirname), 'test-profile')); const profileSubdir = join(profilePath, uuid.createNano()); await mkdirp(profileSubdir); - const startupArgs = ['main.js', '--env', 'dev', '--profile', profileSubdir]; + await use(profileSubdir); + + await remove(profileSubdir); + }, + + electronApp: async ({ profileDirectory }, use) => { + const startupArgs = [ + 'main.js', '--env', 'dev', '--profile', profileDirectory, + ]; const electronApp = await electron.launch({ args: startupArgs }); await use(electronApp); await electronApp.firstWindow(); await electronApp.close(); - await remove(profileSubdir); }, mainWindow: async ({ electronApp }, use) => { diff --git a/packages/app-desktop/main-html.js b/packages/app-desktop/main-html.js index 81b4f3cb6..854e88726 100644 --- a/packages/app-desktop/main-html.js +++ b/packages/app-desktop/main-html.js @@ -31,117 +31,122 @@ const React = require('react'); const nodeSqlite = require('sqlite3'); const initLib = require('@joplin/lib/initLib').default; - -if (bridge().env() === 'dev') { - const newConsole = function(oldConsole) { - const output = {}; - const fnNames = ['assert', 'clear', 'context', 'count', 'countReset', 'debug', 'dir', 'dirxml', 'error', 'group', 'groupCollapsed', 'groupEnd', 'info', 'log', 'memory', 'profile', 'profileEnd', 'table', 'time', 'timeEnd', 'timeLog', 'timeStamp', 'trace', 'warn']; - for (const fnName of fnNames) { - if (fnName === 'warn') { - output.warn = function(...text) { - const s = [...text].join(''); - // React spams the console with walls of warnings even outside of strict mode, and even after having renamed - // unsafe methods to UNSAFE_xxxx, so we need to hack the console to remove them... - if (s.indexOf('Warning: componentWillReceiveProps has been renamed, and is not recommended for use') === 0) return; - if (s.indexOf('Warning: componentWillUpdate has been renamed, and is not recommended for use.') === 0) return; - oldConsole.warn(...text); - }; - } else { - output[fnName] = function(...text) { - return oldConsole[fnName](...text); - }; +const main = async () => { + if (bridge().env() === 'dev') { + const newConsole = function(oldConsole) { + const output = {}; + const fnNames = ['assert', 'clear', 'context', 'count', 'countReset', 'debug', 'dir', 'dirxml', 'error', 'group', 'groupCollapsed', 'groupEnd', 'info', 'log', 'memory', 'profile', 'profileEnd', 'table', 'time', 'timeEnd', 'timeLog', 'timeStamp', 'trace', 'warn']; + for (const fnName of fnNames) { + if (fnName === 'warn') { + output.warn = function(...text) { + const s = [...text].join(''); + // React spams the console with walls of warnings even outside of strict mode, and even after having renamed + // unsafe methods to UNSAFE_xxxx, so we need to hack the console to remove them... + if (s.indexOf('Warning: componentWillReceiveProps has been renamed, and is not recommended for use') === 0) return; + if (s.indexOf('Warning: componentWillUpdate has been renamed, and is not recommended for use.') === 0) return; + oldConsole.warn(...text); + }; + } else { + output[fnName] = function(...text) { + return oldConsole[fnName](...text); + }; + } } - } - return output; - }(window.console); + return output; + }(window.console); - window.console = newConsole; -} + window.console = newConsole; + } -// eslint-disable-next-line no-console -console.info(`Environment: ${bridge().env()}`); + // eslint-disable-next-line no-console + console.info(`Environment: ${bridge().env()}`); -const fsDriver = new FsDriverNode(); -Logger.fsDriver_ = fsDriver; -Resource.fsDriver_ = fsDriver; -EncryptionService.fsDriver_ = fsDriver; -FileApiDriverLocal.fsDriver_ = fsDriver; + const fsDriver = new FsDriverNode(); + Logger.fsDriver_ = fsDriver; + Resource.fsDriver_ = fsDriver; + EncryptionService.fsDriver_ = fsDriver; + FileApiDriverLocal.fsDriver_ = fsDriver; -// That's not good, but it's to avoid circular dependency issues -// in the BaseItem class. -BaseItem.loadClass('Note', Note); -BaseItem.loadClass('Folder', Folder); -BaseItem.loadClass('Resource', Resource); -BaseItem.loadClass('Tag', Tag); -BaseItem.loadClass('NoteTag', NoteTag); -BaseItem.loadClass('MasterKey', MasterKey); -BaseItem.loadClass('Revision', Revision); + // That's not good, but it's to avoid circular dependency issues + // in the BaseItem class. + BaseItem.loadClass('Note', Note); + BaseItem.loadClass('Folder', Folder); + BaseItem.loadClass('Resource', Resource); + BaseItem.loadClass('Tag', Tag); + BaseItem.loadClass('NoteTag', NoteTag); + BaseItem.loadClass('MasterKey', MasterKey); + BaseItem.loadClass('Revision', Revision); -Setting.setConstant('appId', `net.cozic.joplin${bridge().env() === 'dev' ? 'dev' : ''}-desktop`); -Setting.setConstant('appType', 'desktop'); + Setting.setConstant('appId', `net.cozic.joplin${bridge().env() === 'dev' ? 'dev' : ''}-desktop`); + Setting.setConstant('appType', 'desktop'); -// eslint-disable-next-line no-console -console.info(`appId: ${Setting.value('appId')}`); -// eslint-disable-next-line no-console -console.info(`appType: ${Setting.value('appType')}`); + // eslint-disable-next-line no-console + console.info(`appId: ${Setting.value('appId')}`); + // eslint-disable-next-line no-console + console.info(`appType: ${Setting.value('appType')}`); -let keytar; -try { - keytar = shim.platformSupportsKeyChain() ? require('keytar') : null; -} catch (error) { - console.error('Cannot load keytar - keychain support will be disabled', error); - keytar = null; -} + let keytar; + try { + keytar = shim.platformSupportsKeyChain() ? require('keytar') : null; + } catch (error) { + console.error('Cannot load keytar - keychain support will be disabled', error); + keytar = null; + } -function appVersion() { - const p = require('./packageInfo.js'); - return p.version; -} + function appVersion() { + const p = require('./packageInfo.js'); + return p.version; + } -shimInit({ - keytar, - React, - appVersion, - electronBridge: bridge(), - nodeSqlite, -}); + shimInit({ + keytar, + React, + appVersion, + electronBridge: bridge(), + nodeSqlite, + }); -// Disable drag and drop of links inside application (which would -// open it as if the whole app was a browser) -document.addEventListener('dragover', event => event.preventDefault()); -document.addEventListener('drop', event => event.preventDefault()); + // Disable drag and drop of links inside application (which would + // open it as if the whole app was a browser) + document.addEventListener('dragover', event => event.preventDefault()); + document.addEventListener('drop', event => event.preventDefault()); -// Disable middle-click (which would open a new browser window, but we don't want this) -document.addEventListener('auxclick', event => event.preventDefault()); + // Disable middle-click (which would open a new browser window, but we don't want this) + document.addEventListener('auxclick', event => event.preventDefault()); -// Each link (rendered as a button or list item) has its own custom click event -// so disable the default. In particular this will disable Ctrl+Clicking a link -// which would open a new browser window. -document.addEventListener('click', (event) => { - // We don't apply this to labels and inputs because it would break - // checkboxes. Such a global event handler is probably not a good idea - // anyway but keeping it for now, as it doesn't seem to break anything else. - // https://github.com/facebook/react/issues/13477#issuecomment-489274045 - if (['LABEL', 'INPUT'].includes(event.target.nodeName)) return; + // Each link (rendered as a button or list item) has its own custom click event + // so disable the default. In particular this will disable Ctrl+Clicking a link + // which would open a new browser window. + document.addEventListener('click', (event) => { + // We don't apply this to labels and inputs because it would break + // checkboxes. Such a global event handler is probably not a good idea + // anyway but keeping it for now, as it doesn't seem to break anything else. + // https://github.com/facebook/react/issues/13477#issuecomment-489274045 + if (['LABEL', 'INPUT'].includes(event.target.nodeName)) return; - event.preventDefault(); -}); + event.preventDefault(); + }); -const logger = new Logger(); -Logger.initializeGlobalLogger(logger); -initLib(logger); + const logger = new Logger(); + Logger.initializeGlobalLogger(logger); + initLib(logger); -app().start(bridge().processArgv()).then((result) => { - if (!result || !result.action) { + const startResult = await app().start(bridge().processArgv()); + + if (!startResult || !startResult.action) { require('./gui/Root'); - } else if (result.action === 'upgradeSyncTarget') { + } else if (startResult.action === 'upgradeSyncTarget') { require('./gui/Root_UpgradeSyncTarget'); } -}).catch((error) => { - const env = bridge().env(); +}; +main().catch((error) => { + const env = bridge().env(); + console.error(error); + + let errorMessage; if (error.code === 'flagError') { - bridge().showErrorMessageBox(error.message); + errorMessage = error.message; } else { // If something goes wrong at this stage we don't have a console or a log file // so display the error in a message box. @@ -150,13 +155,12 @@ app().start(bridge().processArgv()).then((result) => { if (error.lineNumber) msg.push(error.lineNumber); if (error.stack) msg.push(error.stack); - if (env === 'dev') { - console.error(error); - } else { - bridge().showErrorMessageBox(msg.join('\n\n')); - } + errorMessage = msg.join('\n\n'); } - // In dev, we leave the app open as debug statements in the console can be useful - if (env !== 'dev') bridge().electronApp().exit(1); + // In dev, we give the option to leave the app open as debug statements in the + // console can be useful + const canIgnore = env === 'dev'; + bridge().electronApp().handleAppFailure(errorMessage, canIgnore); }); + diff --git a/packages/app-desktop/utils/restartInSafeModeFromMain.test.ts b/packages/app-desktop/utils/restartInSafeModeFromMain.test.ts new file mode 100644 index 000000000..196e3cbdb --- /dev/null +++ b/packages/app-desktop/utils/restartInSafeModeFromMain.test.ts @@ -0,0 +1,45 @@ + +let currentProfileDirectory: string; + +jest.doMock('../bridge', () => ({ + // Mock the bridge functions used by restartInSafeModeFromMain + // to remove the dependency on Electron. + default: () => ({ + restart: jest.fn(), + processArgv: () => [ + // The argument parser expects the first two arguments to + // be the path to NodeJS and the second to be the main filename. + process.argv[0], __filename, + + // Only the following arguments are used. + '--profile', currentProfileDirectory, + ], + env: () => 'dev', + }), +})); + +import { mkdtemp, readFile, remove } from 'fs-extra'; +import restartInSafeModeFromMain from './restartInSafeModeFromMain'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { safeModeFlagFilename } from '@joplin/lib/BaseApplication'; + + +describe('restartInSafeModeFromMain', () => { + beforeEach(async () => { + currentProfileDirectory = await mkdtemp(join(tmpdir(), 'safemode-restart-test')); + }); + + afterEach(async () => { + await remove(currentProfileDirectory); + }); + + test('should create a safe mode flag file', async () => { + await restartInSafeModeFromMain(); + const safeModeFlagFilepath = join( + currentProfileDirectory, safeModeFlagFilename, + ); + expect(await readFile(safeModeFlagFilepath, 'utf8')).toBe('true'); + }); +}); + diff --git a/packages/app-desktop/utils/restartInSafeModeFromMain.ts b/packages/app-desktop/utils/restartInSafeModeFromMain.ts new file mode 100644 index 000000000..a34f73ff5 --- /dev/null +++ b/packages/app-desktop/utils/restartInSafeModeFromMain.ts @@ -0,0 +1,33 @@ +import Setting from '@joplin/lib/models/Setting'; +import bridge from '../bridge'; +import processStartFlags from '@joplin/lib/utils/processStartFlags'; +import BaseApplication, { safeModeFlagFilename } from '@joplin/lib/BaseApplication'; +import initProfile from '@joplin/lib/services/profileConfig/initProfile'; +import { writeFile } from 'fs-extra'; +import { join } from 'path'; + + +const restartInSafeModeFromMain = async () => { + // Only set constants here -- the main process doesn't have easy access (without loading + // a large amount of other code) to the database. + const appName = `joplin${bridge().env() === 'dev' ? 'dev' : ''}-desktop`; + Setting.setConstant('appId', `net.cozic.${appName}`); + Setting.setConstant('appType', 'desktop'); + Setting.setConstant('appName', appName); + + // Load just enough for us to write a file in the profile directory + const { shimInit } = require('@joplin/lib/shim-init-node.js'); + shimInit({}); + + const startFlags = await processStartFlags(bridge().processArgv()); + const rootProfileDir = BaseApplication.determineProfileDir(startFlags.matched); + const { profileDir } = await initProfile(rootProfileDir); + + // We can't access the database, so write to a file instead. + const safeModeFlagFile = join(profileDir, safeModeFlagFilename); + await writeFile(safeModeFlagFile, 'true', 'utf8'); + + bridge().restart(); +}; + +export default restartInSafeModeFromMain; diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index 581a98a45..007f64670 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -6,7 +6,7 @@ import BaseService from './services/BaseService'; import reducer, { getNotesParent, serializeNotesParent, setStore, State } from './reducer'; import KeychainServiceDriver from './services/keychain/KeychainServiceDriver.node'; import KeychainServiceDriverDummy from './services/keychain/KeychainServiceDriver.dummy'; -import { _, setLocale } from './locale'; +import { setLocale } from './locale'; import KvStore from './services/KvStore'; import SyncTargetJoplinServer from './SyncTargetJoplinServer'; import SyncTargetOneDrive from './SyncTargetOneDrive'; @@ -26,8 +26,7 @@ import time from './time'; import BaseSyncTarget from './BaseSyncTarget'; const reduxSharedMiddleware = require('./components/shared/reduxSharedMiddleware'); const os = require('os'); -const fs = require('fs-extra'); -import JoplinError from './JoplinError'; +import fs = require('fs-extra'); const EventEmitter = require('events'); const syswidecas = require('./vendor/syswide-cas'); import SyncTargetRegistry from './SyncTargetRegistry'; @@ -60,6 +59,8 @@ import { ProfileConfig } from './services/profileConfig/types'; import initProfile from './services/profileConfig/initProfile'; import { parseShareCache } from './services/share/reducer'; import RotatingLogs from './RotatingLogs'; +import { join } from 'path'; +import processStartFlags, { MatchedStartFlags } from './utils/processStartFlags'; const appLogger: LoggerWrapper = Logger.create('App'); @@ -70,6 +71,7 @@ interface StartOptions { keychainEnabled?: boolean; setupGlobalLogger?: boolean; } +export const safeModeFlagFilename = 'force-safe-mode-on-next-start'; export default class BaseApplication { @@ -163,154 +165,15 @@ export default class BaseApplication { // Handles the initial flags passed to main script and // returns the remaining args. private async handleStartFlags_(argv: string[], setDefaults = true) { - const matched: any = {}; - argv = argv.slice(0); - argv.splice(0, 2); // First arguments are the node executable, and the node JS file + const flags = await processStartFlags(argv, setDefaults); - while (argv.length) { - const arg = argv[0]; - const nextArg = argv.length >= 2 ? argv[1] : null; - - if (arg === '--profile') { - if (!nextArg) throw new JoplinError(_('Usage: %s', '--profile '), 'flagError'); - matched.profileDir = nextArg; - argv.splice(0, 2); - continue; - } - - if (arg === '--no-welcome') { - matched.welcomeDisabled = true; - argv.splice(0, 1); - continue; - } - - if (arg === '--env') { - if (!nextArg) throw new JoplinError(_('Usage: %s', '--env '), 'flagError'); - matched.env = nextArg; - argv.splice(0, 2); - continue; - } - - if (arg === '--is-demo') { - Setting.setConstant('isDemo', true); - argv.splice(0, 1); - continue; - } - - if (arg === '--safe-mode') { - matched.isSafeMode = true; - argv.splice(0, 1); - continue; - } - - if (arg === '--open-dev-tools') { - Setting.setConstant('flagOpenDevTools', true); - argv.splice(0, 1); - continue; - } - - if (arg === '--debug') { - // Currently only handled by ElectronAppWrapper (isDebugMode property) - argv.splice(0, 1); - continue; - } - - if (arg === '--update-geolocation-disabled') { - Note.updateGeolocationEnabled_ = false; - argv.splice(0, 1); - continue; - } - - if (arg === '--stack-trace-enabled') { - this.showStackTraces_ = true; - argv.splice(0, 1); - continue; - } - - if (arg === '--log-level') { - if (!nextArg) throw new JoplinError(_('Usage: %s', '--log-level '), 'flagError'); - matched.logLevel = Logger.levelStringToId(nextArg); - argv.splice(0, 2); - continue; - } - - if (arg.indexOf('-psn') === 0) { - // Some weird flag passed by macOS - can be ignored. - // https://github.com/laurent22/joplin/issues/480 - // https://stackoverflow.com/questions/10242115 - argv.splice(0, 1); - continue; - } - - if (arg === '--enable-logging') { - // Electron-specific flag used for debugging - ignore it - argv.splice(0, 1); - continue; - } - - if (arg === '--dev-plugins') { - Setting.setConstant('startupDevPlugins', nextArg.split(',').map(p => p.trim())); - argv.splice(0, 2); - continue; - } - - if (arg.indexOf('--remote-debugging-port=') === 0) { - // Electron-specific flag used for debugging - ignore it. Electron expects this flag in '--x=y' form, a single string. - argv.splice(0, 1); - continue; - } - - if (arg === '--no-sandbox') { - // Electron-specific flag for running the app without chrome-sandbox - // Allows users to use it as a workaround for the electron+AppImage issue - // https://github.com/laurent22/joplin/issues/2246 - argv.splice(0, 1); - continue; - } - - if (arg.indexOf('--user-data-dir=') === 0) { - // Electron-specific flag. Allows users to run the app with chromedriver. - argv.splice(0, 1); - continue; - } - - if (arg.indexOf('--enable-features=') === 0) { - // Electron-specific flag - ignore it - // Allows users to run the app on native wayland - argv.splice(0, 1); - continue; - } - - if (arg.indexOf('--ozone-platform=') === 0) { - // Electron-specific flag - ignore it - // Allows users to run the app on native wayland - argv.splice(0, 1); - continue; - } - - if (arg === '--disable-smooth-scrolling') { - // Electron-specific flag - ignore it - // Allows users to disable smooth scrolling - argv.splice(0, 1); - continue; - } - - if (arg.length && arg[0] === '-') { - throw new JoplinError(_('Unknown flag: %s', arg), 'flagError'); - } else { - break; - } - } - - if (setDefaults) { - if (!matched.logLevel) matched.logLevel = Logger.LEVEL_INFO; - if (!matched.env) matched.env = 'prod'; - if (!matched.devPlugins) matched.devPlugins = []; + if (flags.matched.showStackTraces) { + this.showStackTraces_ = true; } return { - matched: matched, - argv: argv, + matched: flags.matched, + argv: flags.argv, }; } @@ -725,7 +588,7 @@ export default class BaseApplication { return flags.matched; } - public determineProfileDir(initArgs: any) { + public static determineProfileDir(initArgs: MatchedStartFlags) { let output = ''; if (initArgs.profileDir) { @@ -773,7 +636,7 @@ export default class BaseApplication { // https://immerjs.github.io/immer/docs/freezing setAutoFreeze(initArgs.env === 'dev'); - const rootProfileDir = this.determineProfileDir(initArgs); + const rootProfileDir = BaseApplication.determineProfileDir(initArgs); const { profileDir, profileConfig, isSubProfile } = await initProfile(rootProfileDir); this.profileConfig_ = profileConfig; @@ -863,6 +726,13 @@ export default class BaseApplication { Setting.setValue('isSafeMode', true); } + const safeModeFlagFile = join(profileDir, safeModeFlagFilename); + if (await fs.pathExists(safeModeFlagFile) && fs.readFileSync(safeModeFlagFile, 'utf8') === 'true') { + appLogger.info(`Safe mode enabled because of file: ${safeModeFlagFile}`); + Setting.setValue('isSafeMode', true); + fs.removeSync(safeModeFlagFile); + } + if (Setting.value('firstStart')) { const locale = shim.detectAndSetLocale(Setting); reg.logger().info(`First start: detected locale as ${locale}`); diff --git a/packages/lib/utils/processStartFlags.ts b/packages/lib/utils/processStartFlags.ts new file mode 100644 index 000000000..2bf38f1e9 --- /dev/null +++ b/packages/lib/utils/processStartFlags.ts @@ -0,0 +1,174 @@ + + +import Logger, { LogLevel } from '@joplin/utils/Logger'; +import JoplinError from '../JoplinError'; +import { _ } from '../locale'; +import Setting from '../models/Setting'; +import Note from '../models/Note'; + +export interface MatchedStartFlags { + profileDir?: string; + welcomeDisabled?: boolean; + env?: string; + isSafeMode?: boolean; + showStackTraces?: boolean; + logLevel?: LogLevel; + devPlugins?: string[]; +} + +// Handles the initial flags passed to main script and +// returns the remaining args. +const processStartFlags = async (argv: string[], setDefaults = true) => { + const matched: MatchedStartFlags = {}; + argv = argv.slice(0); + argv.splice(0, 2); // First arguments are the node executable, and the node JS file + + while (argv.length) { + const arg = argv[0]; + const nextArg = argv.length >= 2 ? argv[1] : null; + + if (arg === '--profile') { + if (!nextArg) throw new JoplinError(_('Usage: %s', '--profile '), 'flagError'); + matched.profileDir = nextArg; + argv.splice(0, 2); + continue; + } + + if (arg === '--no-welcome') { + matched.welcomeDisabled = true; + argv.splice(0, 1); + continue; + } + + if (arg === '--env') { + if (!nextArg) throw new JoplinError(_('Usage: %s', '--env '), 'flagError'); + matched.env = nextArg; + argv.splice(0, 2); + continue; + } + + if (arg === '--is-demo') { + Setting.setConstant('isDemo', true); + argv.splice(0, 1); + continue; + } + + if (arg === '--safe-mode') { + matched.isSafeMode = true; + argv.splice(0, 1); + continue; + } + + if (arg === '--open-dev-tools') { + Setting.setConstant('flagOpenDevTools', true); + argv.splice(0, 1); + continue; + } + + if (arg === '--debug') { + // Currently only handled by ElectronAppWrapper (isDebugMode property) + argv.splice(0, 1); + continue; + } + + if (arg === '--update-geolocation-disabled') { + Note.updateGeolocationEnabled_ = false; + argv.splice(0, 1); + continue; + } + + if (arg === '--stack-trace-enabled') { + matched.showStackTraces = true; + argv.splice(0, 1); + continue; + } + + if (arg === '--log-level') { + if (!nextArg) throw new JoplinError(_('Usage: %s', '--log-level '), 'flagError'); + matched.logLevel = Logger.levelStringToId(nextArg); + argv.splice(0, 2); + continue; + } + + if (arg.indexOf('-psn') === 0) { + // Some weird flag passed by macOS - can be ignored. + // https://github.com/laurent22/joplin/issues/480 + // https://stackoverflow.com/questions/10242115 + argv.splice(0, 1); + continue; + } + + if (arg === '--enable-logging') { + // Electron-specific flag used for debugging - ignore it + argv.splice(0, 1); + continue; + } + + if (arg === '--dev-plugins') { + matched.devPlugins = nextArg.split(',').map(p => p.trim()); + Setting.setConstant('startupDevPlugins', matched.devPlugins); + argv.splice(0, 2); + continue; + } + + if (arg.indexOf('--remote-debugging-port=') === 0) { + // Electron-specific flag used for debugging - ignore it. Electron expects this flag in '--x=y' form, a single string. + argv.splice(0, 1); + continue; + } + + if (arg === '--no-sandbox') { + // Electron-specific flag for running the app without chrome-sandbox + // Allows users to use it as a workaround for the electron+AppImage issue + // https://github.com/laurent22/joplin/issues/2246 + argv.splice(0, 1); + continue; + } + + if (arg.indexOf('--user-data-dir=') === 0) { + // Electron-specific flag. Allows users to run the app with chromedriver. + argv.splice(0, 1); + continue; + } + + if (arg.indexOf('--enable-features=') === 0) { + // Electron-specific flag - ignore it + // Allows users to run the app on native wayland + argv.splice(0, 1); + continue; + } + + if (arg.indexOf('--ozone-platform=') === 0) { + // Electron-specific flag - ignore it + // Allows users to run the app on native wayland + argv.splice(0, 1); + continue; + } + + if (arg === '--disable-smooth-scrolling') { + // Electron-specific flag - ignore it + // Allows users to disable smooth scrolling + argv.splice(0, 1); + continue; + } + + if (arg.length && arg[0] === '-') { + throw new JoplinError(_('Unknown flag: %s', arg), 'flagError'); + } else { + break; + } + } + + if (setDefaults) { + if (!matched.logLevel) matched.logLevel = Logger.LEVEL_INFO; + if (!matched.env) matched.env = 'prod'; + if (!matched.devPlugins) matched.devPlugins = []; + } + + return { + matched: matched, + argv: argv, + }; +}; + +export default processStartFlags;