Update Electron to v15 (#2772)

* Prepare Electron >=14 upgrade

* Replace spectron with playwright

* Upgrade Electron to v15

* Fix unit test issue with @electron/remote

* Use per day cache directory for E2E tests

* Fix code style
This commit is contained in:
Felix Häusler 2021-12-18 14:52:24 +01:00 committed by GitHub
parent 0dd09cc684
commit bdaca98876
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 719 additions and 725 deletions

View File

@ -15,7 +15,7 @@
"build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js",
"build:dev": "node .electron-vue/build.js",
"dev": "cross-env node .electron-vue/dev-runner.js",
"e2e": "yarn run pack && cross-env MARKTEXT_EXIT_ON_ERROR=1 mocha --timeout 10000 test/e2e",
"e2e": "yarn run pack && cross-env MARKTEXT_EXIT_ON_ERROR=1 playwright test -c test/e2e/playwright.config.js test/e2e",
"lint": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter src test",
"lint:fix": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter --fix src test",
"pack": "yarn run pack:main && yarn run pack:renderer",
@ -33,6 +33,7 @@
"validate-licenses": "node tools/validateLicenses.js"
},
"dependencies": {
"@electron/remote": "^2.0.1",
"@hfelix/electron-localshortcut": "^3.1.1",
"@hfelix/electron-spellchecker": "^2.0.0",
"@octokit/rest": "^16.43.2",
@ -98,6 +99,7 @@
"@babel/register": "^7.16.0",
"@babel/runtime": "^7.16.3",
"@markedjs/html-differ": "^3.0.4",
"@playwright/test": "^1.17.1",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.2.3",
"babel-plugin-component": "^1.1.1",
@ -112,7 +114,7 @@
"del": "^5.1.0",
"devtron": "^1.4.0",
"dotenv": "^10.0.0",
"electron": "^13.6.1",
"electron": "^15.3.4",
"electron-builder": "^22.14.8",
"electron-devtools-installer": "^3.2.0",
"electron-rebuild": "^3.2.5",
@ -148,11 +150,11 @@
"multispinner": "^0.2.1",
"node-fetch": "^2.6.6",
"node-loader": "^1.0.3",
"playwright": "^1.17.1",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0",
"raw-loader": "^4.0.2",
"require-dir": "^1.2.0",
"spectron": "^15.0.0",
"style-loader": "^2.0.0",
"svg-sprite-loader": "^4.3.0",
"svgo": "^1.3.2",

View File

@ -6,7 +6,6 @@ export const editorWinOptions = Object.freeze({
minWidth: 550,
minHeight: 350,
webPreferences: {
enableRemoteModule: true,
contextIsolation: false,
spellcheck: false,
nodeIntegration: true,
@ -23,7 +22,6 @@ export const preferencesWinOptions = Object.freeze({
width: 950,
height: 650,
webPreferences: {
enableRemoteModule: true,
contextIsolation: false,
spellcheck: false,
nodeIntegration: true,

View File

@ -1,6 +1,7 @@
import './globalSetting'
import path from 'path'
import { app, dialog } from 'electron'
import { initialize as remoteInitializeServer } from '@electron/remote/main'
import cli from './cli'
import setupExceptionHandler, { initExceptionLogger } from './exceptionHandler'
import log from 'electron-log'
@ -12,10 +13,9 @@ import { getLogLevel } from './utils'
const initializeLogger = appEnvironment => {
log.transports.console.level = process.env.NODE_ENV === 'development' ? true : 'error'
log.transports.rendererConsole = null
log.transports.file.file = path.join(appEnvironment.paths.logPath, 'main.log')
log.transports.file.resolvePath = () => path.join(appEnvironment.paths.logPath, 'main.log')
log.transports.file.level = getLogLevel()
log.transports.file.sync = true
log.transports.file.init()
initExceptionLogger()
}
@ -80,5 +80,9 @@ log.transports.file.sync = false
// Be careful when changing code before this line!
// NOTE: Do not create classes or other code before this line!
// TODO: We should switch to another async API like https://nornagon.medium.com/electrons-remote-module-considered-harmful-70d69500f31.
// Enable remote module
remoteInitializeServer()
const marktext = new App(accessor, args)
marktext.init()

View File

@ -1,5 +1,6 @@
import path from 'path'
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { enable as remoteEnable } from '@electron/remote/main'
import log from 'electron-log'
import windowStateKeeper from 'electron-window-state'
import { isChildOfDirectory, isSamePathSync } from 'common/filesystem/paths'
@ -68,6 +69,7 @@ class EditorWindow extends BaseWindow {
winOptions.backgroundColor = this._getPreferredBackgroundColor(theme)
let win = this.browserWindow = new BrowserWindow(winOptions)
remoteEnable(win.webContents)
this.id = win.id
// Create a menu for the current window

View File

@ -1,5 +1,6 @@
import path from 'path'
import { BrowserWindow, ipcMain } from 'electron'
import { enable as remoteEnable } from '@electron/remote/main'
import electronLocalshortcut from '@hfelix/electron-localshortcut'
import BaseWindow, { WindowLifecycle, WindowType } from './base'
import { centerWindowOptions } from './utils'
@ -45,6 +46,7 @@ class SettingWindow extends BaseWindow {
winOptions.backgroundColor = this._getPreferredBackgroundColor(theme)
let win = this.browserWindow = new BrowserWindow(winOptions)
remoteEnable(win.webContents)
this.id = win.id
// Create a menu for the current window

View File

@ -9,10 +9,9 @@ const configureLogger = () => {
const { debug, paths, windowId } = global.marktext.env
log.transports.console.level = process.env.NODE_ENV === 'development' // mirror to window console
log.transports.mainConsole = null
log.transports.file.file = path.join(paths.logPath, `editor-${windowId}.log`)
log.transports.file.resolvePath = () => path.join(paths.logPath, `editor-${windowId}.log`)
log.transports.file.level = debug ? 'debug' : 'info'
log.transports.file.sync = false
log.transports.file.init()
exceptionLogger = log.error
}

View File

@ -1,5 +1,6 @@
// List of all static commands that are loaded into command center.
import { ipcRenderer, remote, shell } from 'electron'
import { ipcRenderer, shell } from 'electron'
import { getCurrentWindow } from '@electron/remote'
import bus from '../bus'
import { delay, isOsx } from '@/util'
import { isUpdatable } from './utils'
@ -509,7 +510,7 @@ const commands = [
id: 'window.minimize',
description: 'Window: Minimize',
execute: async () => {
remote.getCurrentWindow().minimize()
getCurrentWindow().minimize()
}
}, {
id: 'window.always-on-top',
@ -521,7 +522,7 @@ const commands = [
id: 'window.toggle-full-screen',
description: 'Window: Toggle Full Screen',
execute: async () => {
const win = remote.getCurrentWindow()
const win = getCurrentWindow()
win.setFullScreen(!win.isFullScreen())
}
},

View File

@ -98,7 +98,8 @@
</template>
<script>
import { ipcRenderer, remote } from 'electron'
import { ipcRenderer } from 'electron'
import { getCurrentWindow, Menu as RemoteMenu } from '@electron/remote'
import { mapState } from 'vuex'
import { minimizePath, restorePath, maximizePath, closePath } from '../../assets/window-controls.js'
import { PATH_SEPARATOR } from '../../config'
@ -130,8 +131,8 @@ export default {
this.windowIconMaximize = maximizePath
this.windowIconClose = closePath
return {
isFullScreen: remote.getCurrentWindow().isFullScreen(),
isMaximized: remote.getCurrentWindow().isMaximized(),
isFullScreen: getCurrentWindow().isFullScreen(),
isMaximized: getCurrentWindow().isMaximized(),
show: 'word'
}
},
@ -189,11 +190,11 @@ export default {
},
handleCloseClick () {
remote.getCurrentWindow().close()
getCurrentWindow().close()
},
handleMaximizeClick () {
const win = remote.getCurrentWindow()
const win = getCurrentWindow()
if (win.isFullScreen()) {
win.setFullScreen(false)
} else if (win.isMaximized()) {
@ -210,15 +211,12 @@ export default {
},
handleMinimizeClick () {
remote.getCurrentWindow().minimize()
getCurrentWindow().minimize()
},
handleMenuClick () {
const win = remote.getCurrentWindow()
remote
.Menu
.getApplicationMenu()
.popup({ window: win, x: 23, y: 20 })
const win = getCurrentWindow()
RemoteMenu.getApplicationMenu().popup({ window: win, x: 23, y: 20 })
},
rename () {

View File

@ -1,4 +1,4 @@
import { remote } from 'electron'
import { getCurrentWindow, Menu as RemoteMenu, MenuItem as RemoteMenuItem } from '@electron/remote'
import {
CUT,
COPY,
@ -12,7 +12,6 @@ import {
} from './menuItems'
import spellcheckMenuBuilder from './spellcheck'
const { Menu, MenuItem } = remote
const CONTEXT_ITEMS = [INSERT_BEFORE, INSERT_AFTER, SEPARATOR, CUT, COPY, PASTE, SEPARATOR, COPY_AS_MARKDOWN, COPY_AS_HTML, PASTE_AS_PLAIN_TEXT]
/**
@ -27,17 +26,17 @@ const CONTEXT_ITEMS = [INSERT_BEFORE, INSERT_AFTER, SEPARATOR, CUT, COPY, PASTE,
*/
export const showContextMenu = (event, selection, spellchecker, selectedWord, wordSuggestions, replaceCallback) => {
const { start, end } = selection
const menu = new Menu()
const win = remote.getCurrentWindow()
const menu = new RemoteMenu()
const win = getCurrentWindow()
const disableCutAndCopy = start.key === end.key && start.offset === end.offset
const spellingSubmenu = spellcheckMenuBuilder(spellchecker, selectedWord, wordSuggestions, replaceCallback)
if (spellingSubmenu) {
menu.append(new MenuItem({
menu.append(new RemoteMenuItem({
label: 'Spelling...',
submenu: spellingSubmenu
}))
menu.append(new MenuItem(SEPARATOR))
menu.append(new RemoteMenuItem(SEPARATOR))
}
[CUT, COPY, COPY_AS_HTML, COPY_AS_MARKDOWN].forEach(item => {
@ -45,7 +44,7 @@ export const showContextMenu = (event, selection, spellchecker, selectedWord, wo
})
CONTEXT_ITEMS.forEach(item => {
menu.append(new MenuItem(item))
menu.append(new RemoteMenuItem(item))
})
menu.popup([{ window: win, x: event.clientX, y: event.clientY }])
}

View File

@ -1,11 +1,9 @@
import { remote } from 'electron'
import { MenuItem as RemoteMenuItem } from '@electron/remote'
import log from 'electron-log'
import bus from '@/bus'
import { getLanguageName } from '@/spellchecker/languageMap'
import { SEPARATOR } from './menuItems'
const { MenuItem } = remote
/**
* Build the spell checker menu depending on input.
*
@ -24,7 +22,7 @@ export default (spellchecker, selectedWord, wordSuggestions, replaceCallback) =>
const availableDictionaries = spellchecker.getAvailableDictionaries()
const availableDictionariesSubmenu = []
for (const dict of availableDictionaries) {
availableDictionariesSubmenu.push(new MenuItem({
availableDictionariesSubmenu.push(new RemoteMenuItem({
label: getLanguageName(dict),
enabled: dict !== currentLanguage,
click () {
@ -33,7 +31,7 @@ export default (spellchecker, selectedWord, wordSuggestions, replaceCallback) =>
}))
}
spellingSubmenu.push(new MenuItem({
spellingSubmenu.push(new RemoteMenuItem({
label: 'Change Language...',
submenu: availableDictionariesSubmenu
}))

View File

@ -1,4 +1,4 @@
import { remote } from 'electron'
import { getCurrentWindow, Menu as RemoteMenu, MenuItem as RemoteMenuItem } from '@electron/remote'
import {
SEPARATOR,
NEW_FILE,
@ -11,11 +11,9 @@ import {
SHOW_IN_FOLDER
} from './menuItems'
const { Menu, MenuItem } = remote
export const showContextMenu = (event, hasPathCache) => {
const menu = new Menu()
const win = remote.getCurrentWindow()
const menu = new RemoteMenu()
const win = getCurrentWindow()
const CONTEXT_ITEMS = [
NEW_FILE,
NEW_DIRECTORY,
@ -33,7 +31,7 @@ export const showContextMenu = (event, hasPathCache) => {
PASTE.enabled = hasPathCache
CONTEXT_ITEMS.forEach(item => {
menu.append(new MenuItem(item))
menu.append(new RemoteMenuItem(item))
})
menu.popup([{ window: win, x: event.clientX, y: event.clientY }])
}

View File

@ -1,4 +1,4 @@
import { remote } from 'electron'
import { getCurrentWindow, Menu as RemoteMenu, MenuItem as RemoteMenuItem } from '@electron/remote'
import {
CLOSE_THIS,
CLOSE_OTHERS,
@ -10,11 +10,9 @@ import {
SHOW_IN_FOLDER
} from './menuItems'
const { Menu, MenuItem } = remote
export const showContextMenu = (event, tab) => {
const menu = new Menu()
const win = remote.getCurrentWindow()
const menu = new RemoteMenu()
const win = getCurrentWindow()
const { pathname } = tab
const CONTEXT_ITEMS = [CLOSE_THIS, CLOSE_OTHERS, CLOSE_SAVED, CLOSE_ALL, SEPARATOR, RENAME, COPY_PATH, SHOW_IN_FOLDER]
const FILE_CONTEXT_ITEMS = [RENAME, COPY_PATH, SHOW_IN_FOLDER]
@ -24,7 +22,7 @@ export const showContextMenu = (event, tab) => {
})
CONTEXT_ITEMS.forEach(item => {
const menuItem = new MenuItem(item)
const menuItem = new RemoteMenuItem(item)
menuItem._tabId = tab.id
menu.append(menuItem)
})

View File

@ -11,7 +11,7 @@
</template>
<script>
import { remote } from 'electron'
import { getCurrentWindow } from '@electron/remote'
import { closePath } from '../../assets/window-controls.js'
export default {
@ -21,7 +21,7 @@ export default {
},
methods: {
handleCloseClick () {
remote.getCurrentWindow().close()
getCurrentWindow().close()
}
}
}

View File

@ -2,7 +2,7 @@ import fs from 'fs-extra'
import path from 'path'
import { SpellChecker } from '@hfelix/electron-spellchecker'
import axios from '../axios'
import { dictionaryPath } from '../spellchecker'
import { getDictionaryPath } from '../spellchecker'
/**
* Try to download the given Hunspell dictionary.
@ -15,6 +15,7 @@ export const downloadHunspellDictionary = async lang => {
responseType: 'stream'
})
const dictionaryPath = getDictionaryPath()
await fs.ensureDir(dictionaryPath)
const dstFile = path.join(dictionaryPath, `${lang}.bdic`)
@ -48,5 +49,5 @@ export const downloadHunspellDictionary = async lang => {
* @param {string} lang The language to remove.
*/
export const deleteHunspellDictionary = async lang => {
return await fs.remove(path.join(dictionaryPath, `${lang}.bdic`))
return await fs.remove(path.join(getDictionaryPath(), `${lang}.bdic`))
}

View File

@ -1,13 +1,15 @@
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { remote } from 'electron'
import { SpellCheckHandler, fallbackLocales, normalizeLanguageCode } from '@hfelix/electron-spellchecker'
import { isDirectory, isFile } from 'common/filesystem'
import { cloneObj, isOsx, isLinux, isWindows } from '@/util'
// NOTE: Hardcoded in "@hfelix/electron-spellchecker/src/spell-check-handler.js"
export const dictionaryPath = path.join(remote.app.getPath('userData'), 'dictionaries')
export const getDictionaryPath = () => {
const { userDataPath } = global.marktext.paths
return path.join(userDataPath, 'dictionaries')
}
// Source: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/common/model/wordHelper.ts
// /(-?\d*\.\d\w*)|([^\`\~\!\@\#\$\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/
@ -73,6 +75,7 @@ export const validateLineCursor = selection => {
* @returns {string[]} List of available Hunspell dictionary language codes.
*/
export const getAvailableHunspellDictionaries = () => {
const dictionaryPath = getDictionaryPath()
const dict = []
// Search for dictionaries on filesystem.
if (isDirectory(dictionaryPath)) {
@ -140,7 +143,7 @@ export class SpellChecker {
throw new Error('Invalid state.')
}
this.provider = new SpellCheckHandler(dictionaryPath)
this.provider = new SpellCheckHandler(getDictionaryPath())
this.isHunspell = this.provider.isHunspell
// The spell checker is now initialized but not yet enabled. You need to call `init`.

View File

@ -1,14 +1,14 @@
import { isLinux, isOsx, isWindows } from './index'
import plist from 'plist'
import { remote } from 'electron'
import { clipboard as remoteClipboard } from '@electron/remote'
const hasClipboardFiles = () => {
return remote.clipboard.has('NSFilenamesPboardType')
return remoteClipboard.has('NSFilenamesPboardType')
}
const getClipboardFiles = () => {
if (!hasClipboardFiles()) { return [] }
return plist.parse(remote.clipboard.read('NSFilenamesPboardType'))
return plist.parse(remoteClipboard.read('NSFilenamesPboardType'))
}
export const guessClipboardFilePath = () => {
@ -17,7 +17,7 @@ export const guessClipboardFilePath = () => {
const result = getClipboardFiles()
return Array.isArray(result) && result.length ? result[0] : ''
} else if (isWindows) {
const rawFilePath = remote.clipboard.read('FileNameW')
const rawFilePath = remoteClipboard.read('FileNameW')
const filePath = rawFilePath.replace(new RegExp(String.fromCharCode(0), 'g'), '')
return filePath && typeof filePath === 'string' ? filePath : ''
} else {

37
test/e2e/helpers.js Normal file
View File

@ -0,0 +1,37 @@
const os = require('os')
const path = require('path')
const { _electron } = require('playwright')
const mainEntrypoint = 'dist/electron/main.js'
const getDateAsFilename = () => {
const date = new Date()
return '' + date.getFullYear() + (date.getMonth() + 1) + date.getDay()
}
const getTempPath = () => {
const name = 'marktext-e2etest-' + getDateAsFilename()
return path.join(os.tmpdir(), name)
}
const getElectronPath = () => {
const launcherName = process.platform === 'win32' ? 'electron.cmd' : 'electron'
return path.resolve(path.join('node_modules', '.bin', launcherName))
}
const launchElectron = async userArgs => {
userArgs = userArgs || []
const executablePath = getElectronPath()
const args = [mainEntrypoint, '--user-data-dir', getTempPath()].concat(userArgs)
const app = await _electron.launch({
executablePath,
args,
timeout: 30000
})
const page = await app.firstWindow()
await page.waitForLoadState('domcontentloaded')
await new Promise((resolve) => setTimeout(resolve, 500))
return { app, page }
}
module.exports = { getElectronPath, launchElectron}

View File

@ -1,18 +0,0 @@
'use strict'
// Set BABEL_ENV to use proper env config
process.env.BABEL_ENV = 'test'
// Enable use of ES6+ on required files
require('@babel/register')({
ignore: [/node_modules/]
})
// Attach Chai APIs to global scope
const { expect, should, assert } = require('chai')
global.expect = expect
global.should = should
global.assert = assert
// Require all JS files in `./specs` for Mocha to consume
require('require-dir')('./specs')

22
test/e2e/launch.spec.js Normal file
View File

@ -0,0 +1,22 @@
const { expect, test } = require('@playwright/test')
const { launchElectron } = require('./helpers')
test.describe('Check Launch Mark Text', async () => {
let app = null
let page = null
test.beforeAll(async () => {
const { app: electronApp, page: firstPage } = await launchElectron()
app = electronApp
page = firstPage
})
test.afterAll(async () => {
await app.close()
})
test('Empty Mark Text', async () => {
const title = await page.title()
expect(/^Mark Text|Untitled-1 - Mark Text$/.test(title)).toBeTruthy()
})
})

View File

@ -0,0 +1,9 @@
const config = {
workers: 1,
use: {
headless: false,
viewport: { width: 1280, height: 720 },
timeout: 30000
}
}
module.exports = config

View File

@ -1,17 +0,0 @@
import utils from '../utils'
describe('Launch', function () {
beforeEach(utils.beforeEach)
afterEach(utils.afterEach)
it('shows the proper application title', function () {
return this.app.client.getTitle()
.then(title => {
const result = /^Mark Text|Untitled-1 - Mark Text$/.test(title)
if (!result) {
console.error(`AssertionError: expected '${title}' to equal 'Mark Text' or 'Untitled-1'`)
expect(false).to.equal(true)
}
})
})
})

View File

@ -1,23 +0,0 @@
import utils from '../utils'
describe('Cross-site Scripting Test', function () {
beforeEach(utils.beforeXss)
afterEach(utils.afterEach)
it('Load malicious document', function () {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, 3000)
})
.then(() => {
return this.app.client.getRenderProcessLogs()
.then(function (logs) {
const xssErrorCount = logs.filter(log => {
return log.level === 'SEVERE' && /XSS/i.test(log.message) && log.source === 'javascript'
}).length
expect(xssErrorCount).to.equal(0)
})
})
})
})

View File

@ -1,32 +0,0 @@
import electron from 'electron'
import { Application } from 'spectron'
export default {
afterEach () {
this.timeout(20000)
if (this.app && this.app.isRunning()) {
return this.app.stop()
}
},
beforeEach () {
this.timeout(20000)
this.app = new Application({
path: electron,
args: ['dist/electron/main.js'],
startTimeout: 20000,
waitTimeout: 20000
})
return this.app.start()
},
beforeXss () {
this.timeout(20000)
this.app = new Application({
path: electron,
args: ['dist/electron/main.js', 'test/e2e/data/xss.md'],
startTimeout: 20000,
waitTimeout: 20000
})
return this.app.start()
}
}

33
test/e2e/xss.spec.js Normal file
View File

@ -0,0 +1,33 @@
const { expect, test } = require('@playwright/test')
const { launchElectron } = require('./helpers')
test.describe('Test XSS Vulnerabilities', async () => {
let app = null
let page = null
test.beforeAll(async () => {
const { app: electronApp, page: firstPage } = await launchElectron(['test/e2e/data/xss.md'])
app = electronApp
page = firstPage
// Wait to parse and render the document.
await new Promise((resolve) => setTimeout(resolve, 3000))
})
test.afterAll(async () => {
await app.close()
})
test('Load malicious document', async () => {
const { isVisible, isCrashed } = await app.evaluate(async process => {
const mainWindow = process.BrowserWindow.getAllWindows()[0]
return {
isVisible: mainWindow.isVisible(),
isCrashed: mainWindow.webContents.isCrashed()
}
})
expect(isVisible).toBeTruthy()
expect(isCrashed).toBeFalsy()
})
})

View File

@ -48,7 +48,6 @@ module.exports = config => {
base: 'Electron',
browserWindowOptions: {
webPreferences: {
enableRemoteModule: true,
contextIsolation: false,
spellcheck: false,
nodeIntegration: true,

1133
yarn.lock

File diff suppressed because it is too large Load Diff