// SiYuan - Build Your Eternal Digital Garden // Copyright (c) 2020-present, b3log.org // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . const { app, BrowserWindow, shell, Menu, screen, nativeTheme, ipcMain, globalShortcut, Tray, } = require('electron') const path = require('path') const fs = require('fs') const fetch = require('electron-fetch').default process.noAsar = true const appDir = path.dirname(app.getAppPath()) const isDevEnv = process.env.NODE_ENV === 'development' const appVer = app.getVersion() const confDir = path.join(app.getPath('home'), '.config', 'siyuan') const windowStatePath = path.join(confDir, 'windowState.json') const portJSONPath = path.join(confDir, 'port.json') let tray // 托盘必须使用全局变量,以防止被垃圾回收 https://www.electronjs.org/docs/faq#my-apps-windowtray-disappeared-after-a-few-minutes let mainWindow // 从托盘处激活报错 https://github.com/siyuan-note/siyuan/issues/769 let firstOpenWindow, bootWindow let closeButtonBehavior = 0 let siyuanOpenURL let firstOpen = false let resetWindowStateOnRestart = false let kernelPort = "6806" const localhost = "127.0.0.1" require('@electron/remote/main').initialize() if (!app.requestSingleInstanceLock()) { app.quit() return } const getServer = () => { return "http://" + localhost + ":" + kernelPort } const showErrorWindow = (title, content) => { let errorHTMLPath = path.join(appDir, 'app', 'electron', 'error.html') if (isDevEnv) { errorHTMLPath = path.join(appDir, 'electron', 'error.html') } const errWindow = new BrowserWindow({ width: screen.getPrimaryDisplay().size.width / 2, height: screen.getPrimaryDisplay().workAreaSize.height / 2, frame: false, icon: path.join(appDir, 'stage', 'icon-large.png'), webPreferences: { nativeWindowOpen: true, nodeIntegration: true, webviewTag: true, webSecurity: false, contextIsolation: false, }, }) require('@electron/remote/main').enable(errWindow.webContents) errWindow.loadFile(errorHTMLPath, { query: { home: app.getPath('home'), v: appVer, title: title, content: content, icon: path.join(appDir, 'stage', 'icon-large.png'), }, }) errWindow.show() } try { firstOpen = !fs.existsSync(path.join(confDir, 'workspace.json')) if (!fs.existsSync(confDir)) { fs.mkdirSync(confDir, {mode: 0o755, recursive: true}) } } catch (e) { console.error(e) require('electron').dialog.showErrorBox('创建配置目录失败 Failed to create config directory', '思源需要在用户家目录下创建配置文件夹(~/.config/siyuan),请确保该路径具有写入权限。\n\nSiYuan needs to create a configuration folder (~/.config/siyuan) in the user\'s home directory. Please make sure that the path has write permissions.') app.exit() } const writeLog = (out) => { console.log(out) const logFile = path.join(confDir, 'app.log') let log = '' const maxLogLines = 1024 try { if (fs.existsSync(logFile)) { log = fs.readFileSync(logFile).toString() let lines = log.split('\n') if (maxLogLines < lines.length) { log = lines.slice(maxLogLines / 2, maxLogLines).join('\n') + '\n' } } out = out.toString() out = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '') + ' ' + out log += out + '\n' fs.writeFileSync(logFile, log) } catch (e) { console.error(e) } } const boot = () => { // 恢复主窗体状态 let oldWindowState = {} try { oldWindowState = JSON.parse(fs.readFileSync(windowStatePath, 'utf8')) } catch (e) { fs.writeFileSync(windowStatePath, '{}') } let defaultWidth let defaultHeight let workArea try { defaultWidth = screen.getPrimaryDisplay().size.width * 4 / 5 defaultHeight = screen.getPrimaryDisplay().workAreaSize.height * 4 / 5 workArea = screen.getPrimaryDisplay().workArea } catch (e) { console.error(e) } const windowState = Object.assign({}, { isMaximized: true, fullscreen: false, isDevToolsOpened: false, x: 0, y: 0, width: defaultWidth, height: defaultHeight, }, oldWindowState) let x = windowState.x let y = windowState.y if (workArea) { // 窗口大小等同于或大于 workArea 时,缩小会隐藏到左下角 if (windowState.width >= workArea.width || windowState.height >= workArea.height) { windowState.width = Math.min(defaultWidth, workArea.width) windowState.height = Math.min(defaultHeight, workArea.height) } if (x > workArea.width) { x = 0 } if (y > workArea.height) { y = 0 } } if (windowState.width < 400) { windowState.width = 400 } if (windowState.height < 300) { windowState.height = 300 } if (x < 0) { x = 0 } if (y < 0) { y = 0 } // 创建主窗体 mainWindow = new BrowserWindow({ show: false, backgroundColor: '#FFF', // 桌面端主窗体背景色设置为 `#FFF` Fix https://github.com/siyuan-note/siyuan/issues/4544 width: windowState.width, height: windowState.height, x, y, fullscreenable: true, fullscreen: windowState.fullscreen, trafficLightPosition: {x: 8, y: 8}, webPreferences: { nodeIntegration: true, nativeWindowOpen: true, webviewTag: true, webSecurity: false, contextIsolation: false, }, frame: 'darwin' === process.platform, titleBarStyle: 'hidden', icon: path.join(appDir, 'stage', 'icon-large.png'), }) require('@electron/remote/main').enable(mainWindow.webContents) mainWindow.webContents.userAgent = 'SiYuan/' + appVer + ' https://b3log.org/siyuan Electron' // 发起互联网服务请求时绕过安全策略 https://github.com/siyuan-note/siyuan/issues/5516 mainWindow.webContents.session.webRequest.onBeforeSendHeaders( (details, cb) => { if (-1 < details.url.indexOf('bili')) { // B 站不移除 Referer https://github.com/siyuan-note/siyuan/issues/94 cb({requestHeaders: details.requestHeaders}) return } for (let key in details.requestHeaders) { if ('referer' === key.toLowerCase()) { delete details.requestHeaders[key] } } cb({requestHeaders: details.requestHeaders}) }) mainWindow.webContents.session.webRequest.onHeadersReceived((details, cb) => { for (let key in details.responseHeaders) { if ('x-frame-options' === key.toLowerCase()) { delete details.responseHeaders[key] } else if ('content-security-policy' === key.toLowerCase()) { delete details.responseHeaders[key] } else if ('access-control-allow-origin' === key.toLowerCase()) { delete details.responseHeaders[key] } } cb({responseHeaders: details.responseHeaders}) }) mainWindow.webContents.on('did-finish-load', () => { if ('win32' === process.platform || 'linux' === process.platform) { siyuanOpenURL = process.argv.find((arg) => arg.startsWith('siyuan://')) } if (siyuanOpenURL) { if (mainWindow.isMinimized()) { mainWindow.restore() } if (!mainWindow.isVisible()) { mainWindow.show() } mainWindow.focus() setTimeout(() => { // 等待界面js执行完毕 writeLog(siyuanOpenURL) mainWindow.webContents.send('siyuan-openurl', siyuanOpenURL) siyuanOpenURL = null }, 2000) } }) if (windowState.isDevToolsOpened) { mainWindow.webContents.openDevTools({mode: 'bottom'}) } // 主界面事件监听 mainWindow.once('ready-to-show', () => { mainWindow.show() if (windowState.isMaximized) { mainWindow.maximize() } else { mainWindow.unmaximize() } if (bootWindow && !bootWindow.isDestroyed()) { bootWindow.destroy() } }) // 加载主界面 mainWindow.loadURL(getServer() + '/stage/build/app/index.html?v=' + new Date().getTime()) // 菜单 const productName = 'SiYuan' const template = [ { label: productName, submenu: [ { label: `About ${productName}`, role: 'about', }, {type: 'separator'}, {role: 'services'}, {type: 'separator'}, { label: `Hide ${productName}`, role: 'hide', }, {role: 'hideOthers'}, {role: 'unhide'}, {type: 'separator'}, { label: `Quit ${productName}`, role: 'quit', }, ], }, { role: 'editMenu', submenu: [ {role: 'cut'}, {role: 'copy'}, {role: 'paste'}, {role: 'pasteAndMatchStyle', accelerator: 'CmdOrCtrl+Shift+C'}, {role: 'selectAll'}, ], }, { role: 'viewMenu', submenu: [ {role: 'resetZoom'}, {role: 'zoomIn', accelerator: 'CommandOrControl+='}, {role: 'zoomOut'}, ], }, { role: 'windowMenu', submenu: [ {role: 'minimize'}, {role: 'zoom'}, {role: 'togglefullscreen'}, {type: 'separator'}, {role: 'toggledevtools'}, {type: 'separator'}, {role: 'front'}, ], }, ] const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) // 当前页面链接使用浏览器打开 mainWindow.webContents.on('will-navigate', (event, url) => { if (url.startsWith(getServer())) { return } event.preventDefault() shell.openExternal(url) }) mainWindow.on('close', (event) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('siyuan-save-close', false) } event.preventDefault() }) // 监听主题切换 ipcMain.on('siyuan-config-theme', (event, theme) => { nativeTheme.themeSource = theme }) ipcMain.on('siyuan-config-close', (event, close) => { closeButtonBehavior = close }) ipcMain.on('siyuan-config-tray', () => { mainWindow.hide() }) ipcMain.on('siyuan-config-closetray', () => { if ('win32' === process.platform) { tray.destroy() } }) ipcMain.on('siyuan-export-pdf', (event, data) => { mainWindow.webContents.send('siyuan-export-pdf', data) }) ipcMain.on('siyuan-export-close', (event, data) => { mainWindow.webContents.send('siyuan-export-close', data) }) ipcMain.on('siyuan-quit', () => { try { if (resetWindowStateOnRestart) { fs.writeFileSync(windowStatePath, '{}') } else { const bounds = mainWindow.getBounds() fs.writeFileSync(windowStatePath, JSON.stringify({ isMaximized: mainWindow.isMaximized(), fullscreen: mainWindow.isFullScreen(), isDevToolsOpened: mainWindow.webContents.isDevToolsOpened(), x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, })) } } catch (e) { writeLog(e) } app.exit() globalShortcut.unregisterAll() writeLog('exited ui') }) ipcMain.on('siyuan-init', async () => { await fetch(getServer() + '/api/system/uiproc?pid=' + process.pid, {method: 'POST'}) }) ipcMain.on('siyuan-hotkey', (event, hotkey) => { globalShortcut.unregisterAll() if (!hotkey) { return } globalShortcut.register(hotkey, () => { if (mainWindow.isMinimized()) { mainWindow.restore() if (!mainWindow.isVisible()) { mainWindow.show() } } else { if (mainWindow.isVisible()) { if (!mainWindow.isFocused()) { mainWindow.show() } else { mainWindow.hide() } } else { mainWindow.show() } } }) }) if ('win32' === process.platform || 'linux' === process.platform) { // 系统托盘 tray = new Tray(path.join(appDir, 'stage', 'icon-large.png')) tray.setToolTip('SiYuan v' + appVer) const trayMenuTemplate = [ { label: 'Show Window', click: () => { if (mainWindow.isMinimized()) { mainWindow.restore() } mainWindow.show() }, }, { label: 'Hide Window', click: () => { mainWindow.hide() }, }, { label: 'Official Website', click: () => { shell.openExternal('https://b3log.org/siyuan/') }, }, { label: 'Open Source', click: () => { shell.openExternal('https://github.com/siyuan-note/siyuan') }, }, { label: '中文反馈', click: () => { shell.openExternal('https://ld246.com/article/1649901726096') }, }, { label: 'Reset Window on restart', type: 'checkbox', click: v => { resetWindowStateOnRestart = v.checked }, }, { label: 'Quit', click: () => { mainWindow.webContents.send('siyuan-save-close', true) }, }] const contextMenu = Menu.buildFromTemplate(trayMenuTemplate) tray.setContextMenu(contextMenu) tray.on('click', () => { if (mainWindow.isMinimized()) { mainWindow.restore() if (!mainWindow.isVisible()) { mainWindow.show() } } else { if (mainWindow.isVisible()) { if (!mainWindow.isFocused()) { mainWindow.show() } else { mainWindow.hide() } } else { mainWindow.show() } } }) } } const initKernel = (initData) => { return new Promise(async (resolve) => { bootWindow = new BrowserWindow({ width: screen.getPrimaryDisplay().size.width / 2, height: screen.getPrimaryDisplay().workAreaSize.height / 2, frame: false, icon: path.join(appDir, 'stage', 'icon-large.png'), transparent: 'linux' !== process.platform, webPreferences: { nativeWindowOpen: true, }, }) const kernelName = 'win32' === process.platform ? 'SiYuan-Kernel.exe' : 'SiYuan-Kernel' const kernelPath = path.join(appDir, 'kernel', kernelName) if (!fs.existsSync(kernelPath)) { showErrorWindow('⚠️ 内核文件丢失 Kernel is missing', `
内核可执行文件丢失,请重新安装思源,并将思源加入杀毒软件信任列表。
The kernel binary is not found, please reinstall SiYuan and add SiYuan into the trust list of your antivirus software.
`) bootWindow.destroy() resolve(false) return } const cmds = ['--wd', appDir] if (isDevEnv) { cmds.push('--mode', 'dev') } if (initData) { const initDatas = initData.split('-') cmds.push('--workspace', initDatas[0]) cmds.push('--lang', initDatas[1]) } let cmd = `ui version [${appVer}], booting kernel [${kernelPath} ${cmds.join(' ')}]` writeLog(cmd) let kernelProcessPid = "" if (!isDevEnv) { const cp = require('child_process') const kernelProcess = cp.spawn(kernelPath, cmds, { detached: false, // 桌面端内核进程不再以游离模式拉起 https://github.com/siyuan-note/siyuan/issues/6336 stdio: 'ignore', }, ) kernelProcessPid = kernelProcess.pid writeLog('booted kernel process [pid=' + kernelProcessPid + ']') kernelProcess.on('close', (code) => { writeLog(`kernel exited with code [${code}]`) if (0 !== code) { switch (code) { case 20: showErrorWindow('⚠️ 数据库被锁定 The database is locked', `
数据库文件正在被其他进程占用,请检查是否同时存在多个内核进程(SiYuan Kernel)服务相同的工作空间。
The database file is being occupied by other processes, please check whether there are multiple kernel processes (SiYuan Kernel) serving the same workspace at the same time.
`) break case 21: showErrorWindow('⚠️ 监听端口 ' + kernelPort + ' 失败 Failed to listen to port ' + kernelPort, '
监听 ' + kernelPort + ' 端口失败,请确保程序拥有网络权限并不受防火墙和杀毒软件阻止。
Failed to listen to port ' + kernelPort + ', please make sure the program has network permissions and is not blocked by firewalls and antivirus software.
') break case 22: showErrorWindow( '⚠️ 创建配置目录失败 Failed to create config directory', `
思源需要在用户家目录下创建配置文件夹(~/.config/siyuan),请确保该路径具有写入权限。
SiYuan needs to create a configuration folder (~/.config/siyuan) in the user\'s home directory. Please make sure that the path has write permissions.
`) break case 23: showErrorWindow( '⚠️ 无法读写块树文件 Failed to access blocktree file', `
块树文件正在被其他程序锁定或者已经损坏,请删除 工作空间/temp/ 文件夹后重启
The block tree file is being locked by another program or is corrupted, please delete the workspace/temp/ folder and restart.
`) break case 0: case 1: // Fatal error break default: showErrorWindow( '⚠️ 内核因未知原因退出 The kernel exited for unknown reasons', `
思源内核因未知原因退出 [code=${code}],请尝试重启操作系统后再启动思源。如果该问题依然发生,请检查杀毒软件是否阻止思源内核启动。
SiYuan Kernel exited for unknown reasons [code=${code}], please try to reboot your operating system and then start SiYuan again. If occurs this problem still, please check your anti-virus software whether kill the SiYuan Kernel.
`) break } bootWindow.destroy() resolve(false) } }) } const getKernelPort = async () => { if (isDevEnv) { return kernelPort } await sleep(200) let gotPort = false let count = 0 while (!gotPort) { try { const portJSON = JSON.parse(fs.readFileSync(portJSONPath, 'utf8')) const ret = portJSON[kernelProcessPid] if (ret) { gotPort = true return ret } await sleep(100) } catch (e) { await sleep(100) } finally { count++ if (64 < count) { writeLog('get kernel port failed [pid=' + kernelProcessPid + '], try to use 6806') return kernelPort } } } } kernelPort = await getKernelPort() writeLog("got kernel port [" + kernelPort + "]") let gotVersion = false let apiData let count = 0 writeLog('checking kernel version') while (!gotVersion) { try { const apiResult = await fetch(getServer() + '/api/system/version') apiData = await apiResult.json() gotVersion = true bootWindow.setResizable(false) bootWindow.loadURL(getServer() + '/appearance/boot/index.html') bootWindow.show() } catch (e) { writeLog('get kernel version failed: ' + e.message) await sleep(100) } finally { count++ if (14 < count) { writeLog('get kernel ver failed') bootWindow.destroy() resolve(false) } } } if (0 === apiData.code) { writeLog('got kernel version [' + apiData.data + ']') if (!isDevEnv && apiData.data !== appVer) { writeLog( `kernel [${apiData.data}] is running, shutdown it now and then start kernel [${appVer}]`) fetch(getServer() + '/api/system/exit', {method: 'POST'}) bootWindow.destroy() resolve(false) } else { let progressing = false while (!progressing) { try { const progressResult = await fetch(getServer() + '/api/system/bootProgress') const progressData = await progressResult.json() if (progressData.data.progress >= 100) { resolve(true) progressing = true } else { await sleep(100) } } catch (e) { writeLog('get boot progress failed: ' + e.message) fetch(getServer() + '/api/system/exit', {method: 'POST'}) bootWindow.destroy() resolve(false) progressing = true } } } } else { writeLog(`get kernel version failed: ${apiData.code}, ${apiData.msg}`) resolve(false) } }) } app.setAsDefaultProtocolClient('siyuan') app.commandLine.appendSwitch('disable-web-security') app.commandLine.appendSwitch('auto-detect', 'false') app.commandLine.appendSwitch('no-proxy-server') app.commandLine.appendSwitch('enable-features', 'PlatformHEVCDecoderSupport') app.setPath('userData', app.getPath('userData') + '-Electron') // `~/.config` 下 Electron 相关文件夹名称改为 `SiYuan-Electron` https://github.com/siyuan-note/siyuan/issues/3349 app.whenReady().then(() => { ipcMain.on('siyuan-first-quit', () => { app.exit() }) if (firstOpen) { firstOpenWindow = new BrowserWindow({ width: screen.getPrimaryDisplay().size.width / 2, height: screen.getPrimaryDisplay().workAreaSize.height / 2, frame: false, icon: path.join(appDir, 'stage', 'icon-large.png'), transparent: 'linux' !== process.platform, webPreferences: { nativeWindowOpen: true, nodeIntegration: true, webviewTag: true, webSecurity: false, contextIsolation: false, }, }) require('@electron/remote/main').enable(firstOpenWindow.webContents) let initHTMLPath = path.join(appDir, 'app', 'electron', 'init.html') if (isDevEnv) { initHTMLPath = path.join(appDir, 'electron', 'init.html') } // 改进桌面端初始化时使用的外观语言 https://github.com/siyuan-note/siyuan/issues/6803 let languages = app.getPreferredSystemLanguages(); let language = languages && 0 < languages.length && "zh-Hans-CN" === languages[0] ? "zh_CN": "en_US"; firstOpenWindow.loadFile( initHTMLPath, { query: { lang: language, home: app.getPath('home'), v: appVer, icon: path.join(appDir, 'stage', 'icon-large.png'), }, }) firstOpenWindow.show() // 初始化启动 ipcMain.on('siyuan-first-init', (event, initData) => { initKernel(initData).then((isSucc) => { if (isSucc) { boot() } }) firstOpenWindow.destroy() }) } else { initKernel().then((isSucc) => { if (isSucc) { boot() } }) } }) app.on('open-url', (event, url) => { // for macOS if (url.startsWith('siyuan://')) { siyuanOpenURL = url if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) { mainWindow.restore() } if (!mainWindow.isVisible()) { mainWindow.show() } mainWindow.focus() mainWindow.webContents.send('siyuan-openurl', url) } } }) app.on('second-instance', (event, commandLine) => { if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) { mainWindow.restore() } if (!mainWindow.isVisible()) { mainWindow.show() } mainWindow.focus() mainWindow.webContents.send('siyuan-openurl', commandLine.find((arg) => arg.startsWith('siyuan://'))) } }) app.on('activate', () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show() } if (BrowserWindow.getAllWindows().length === 0) { boot() } }) // 在编辑器内打开链接的处理,比如 iframe 上的打开链接。 app.on('web-contents-created', (webContentsCreatedEvent, contents) => { contents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return {action: 'deny'} }) }) app.on('before-quit', (event) => { if (mainWindow && !mainWindow.isDestroyed()) { event.preventDefault() mainWindow.webContents.send('siyuan-save-close', true) } }) const {powerMonitor} = require('electron') powerMonitor.on('suspend', () => { writeLog('system suspend') }) powerMonitor.on('resume', async () => { writeLog('system resume') let online = false for (let i = 0; i < 7; i++) { if (await isOnline()) { online = true break; } writeLog("network is offline") await sleep(1000) } if (!online) { writeLog("network is offline, do not sync after system resume") return; } writeLog("sync after system resume") // 桌面端系统休眠唤醒后同步延时 7s 后再执行 https://github.com/siyuan-note/siyuan/issues/6687 fetch(getServer() + '/api/sync/performSync', {method: 'POST'}) }) powerMonitor.on('shutdown', () => { writeLog('system shutdown') fetch(getServer() + '/api/system/exit', {method: 'POST'}) }) const sleep = (ms) => { return new Promise(resolve => setTimeout(resolve, ms)) } const isOnline = async () => { try { const result = await fetch("https://icanhazip.com", {timeout: 1000}) return 200 === result.status } catch (e) { try { const result = await fetch("https://www.baidu.com", {timeout: 1000}) return 200 === result.status } catch (e) { return false; } } }