import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index'; import CommandService from '@joplin/lib/services/CommandService'; import KeymapService from '@joplin/lib/services/KeymapService'; import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; import resourceEditWatcherReducer, { defaultState as resourceEditWatcherDefaultState } from '@joplin/lib/services/ResourceEditWatcher/reducer'; import PluginRunner from './services/plugins/PluginRunner'; import PlatformImplementation from './services/plugins/PlatformImplementation'; import type ShimType from '@joplin/lib/shim'; const shim: typeof ShimType = require('@joplin/lib/shim').default; import AlarmService from '@joplin/lib/services/AlarmService'; import AlarmServiceDriverNode from '@joplin/lib/services/AlarmServiceDriverNode'; import Logger, { TargetType } from '@joplin/utils/Logger'; import Setting from '@joplin/lib/models/Setting'; import actionApi from '@joplin/lib/services/rest/actionApi.desktop'; import BaseApplication, { StartOptions } from '@joplin/lib/BaseApplication'; import DebugService from '@joplin/lib/debug/DebugService'; import { _, setLocale } from '@joplin/lib/locale'; import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService'; import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative'; import bridge from './services/bridge'; import menuCommandNames from './gui/menuCommandNames'; import ResourceService from '@joplin/lib/services/ResourceService'; import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher'; import appReducer, { createAppDefaultState } from './app.reducer'; import Folder from '@joplin/lib/models/Folder'; import Tag from '@joplin/lib/models/Tag'; import { reg } from '@joplin/lib/registry'; const packageInfo: PackageInfo = require('./packageInfo.js'); import DecryptionWorker from '@joplin/lib/services/DecryptionWorker'; import ClipperServer from '@joplin/lib/ClipperServer'; import { ipcRenderer } from 'electron'; const Menu = bridge().Menu; import PluginManager from '@joplin/lib/services/PluginManager'; import RevisionService from '@joplin/lib/services/RevisionService'; import MigrationService from '@joplin/lib/services/MigrationService'; import { loadCustomCss } from '@joplin/lib/CssUtils'; import { homedir } from 'os'; import getDefaultPluginsInfo from '@joplin/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo'; const electronContextMenu = require('./services/electron-context-menu'); // import populateDatabase from '@joplin/lib/services/debug/populateDatabase'; // Commands that are not tied to any particular component. // The runtime for these commands can be loaded when the app starts. import PerFolderSortOrderService from '@joplin/lib/services/sortOrder/PerFolderSortOrderService'; import ShareService from '@joplin/lib/services/share/ShareService'; import checkForUpdates from './checkForUpdates'; import { AppState } from './app.reducer'; import syncDebugLog from '@joplin/lib/services/synchronizer/syncDebugLog'; import { completePendingAuthentication } from '@joplin/lib/services/joplinCloudUtils'; import eventManager, { EventName } from '@joplin/lib/eventManager'; import path = require('path'); import { afterDefaultPluginsLoaded, loadAndRunDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils'; import userFetcher, { initializeUserFetcher } from '@joplin/lib/utils/userFetcher'; import { parseNotesParent } from '@joplin/lib/reducer'; import OcrService from '@joplin/lib/services/ocr/OcrService'; import OcrDriverTesseract from '@joplin/lib/services/ocr/drivers/OcrDriverTesseract'; import OcrDriverTranscribe from '@joplin/lib/services/ocr/drivers/OcrDriverTranscribe'; import SearchEngine from '@joplin/lib/services/search/SearchEngine'; import { PackageInfo } from '@joplin/lib/versionInfo'; import { CustomContentProtocolHandler } from './utils/customProtocols/handleCustomProtocols'; import { refreshFolders } from '@joplin/lib/folders-screen-utils'; import initializeCommandService from './utils/initializeCommandService'; import OcrDriverBase from '@joplin/lib/services/ocr/OcrDriverBase'; import PerformanceLogger from '@joplin/lib/PerformanceLogger'; import Note from '@joplin/lib/models/Note'; import Resource from '@joplin/lib/models/Resource'; import AiService from '@joplin/lib/services/ai/AiService'; import LocalEmbeddingProvider from '@joplin/lib/services/ai/LocalEmbeddingProvider'; import { installAiStatusBridge, AiStatusStore } from './services/aiStatusBridge'; import ItemChange from '@joplin/lib/models/ItemChange'; const perfLogger = PerformanceLogger.create(); const pluginClasses = [ require('./plugins/GotoAnything').default, ]; const appDefaultState = createAppDefaultState(resourceEditWatcherDefaultState); type StartupTask = { label: string; task: ()=> void|Promise }; class Application extends BaseApplication { private checkAllPluginStartedIID_: ReturnType = null; private initPluginServiceDone_ = false; private ocrService_: OcrService; private protocolHandler_: CustomContentProtocolHandler; public constructor() { super(); this.bridge_nativeThemeUpdated = this.bridge_nativeThemeUpdated.bind(this); } public hasGui() { return true; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Redux actions are heterogeneous; typing would require an action-type union and base class signature change public reducer(state: AppState = appDefaultState, action: any) { let newState = appReducer(state, action); newState = resourceEditWatcherReducer(newState, action); newState = super.reducer(newState, action) as AppState; return newState; } public toggleDevTools(visible: boolean) { if (visible) { bridge().openDevTools(); } else { bridge().closeDevTools(); } } private updateLanguage() { setLocale(Setting.value('locale')); // The bridge runs within the main process, with its own instance of locale.js // so it needs to be set too here. bridge().setLocale(Setting.value('locale')); const htmlContainer = document.querySelector('html'); // HTML expects the lang attribute to be in BCP47 format, with a dash rather than // an underscore: const htmlLang = Setting.value('locale').replace(/_/g, '-'); htmlContainer.setAttribute('lang', htmlLang); } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Redux middleware signature; matches the base class which takes heterogeneous action types protected async generalMiddleware(store: any, next: any, action: any) { if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'locale' || action.type === 'SETTING_UPDATE_ALL') { this.updateLanguage(); } if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'renderer.fileUrls' || action.type === 'SETTING_UPDATE_ALL') { bridge().electronApp().getContentProtocolHandler().setMediaAccessEnabled( Setting.value('renderer.fileUrls'), ); } if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'showTrayIcon' || action.type === 'SETTING_UPDATE_ALL') { this.updateTray(); } if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'ocr.enabled' || action.type === 'SETTING_UPDATE_ALL') { void this.setupOcrService(); } if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'windowContentZoomFactor' || action.type === 'SETTING_UPDATE_ALL') { bridge().setZoomFactor(Setting.value('windowContentZoomFactor') / 100); } if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'linking.extraAllowedExtensions' || action.type === 'SETTING_UPDATE_ALL') { bridge().extraAllowedOpenExtensions = Setting.value('linking.extraAllowedExtensions'); } if ((action.type === 'SETTING_UPDATE_ONE' && action.key === 'globalHotkey') || action.type === 'SETTING_UPDATE_ALL') { bridge().updateGlobalHotkey(Setting.value('globalHotkey')); } if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) { await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE'); } if (action.type === 'NOTE_DELETE' && store.getState().watchedNoteFiles.includes(action.id)) { await ExternalEditWatcher.instance().stopWatching(action.id); } // Like a lock made on this device (see enableNoteEncryption), a lock that arrives with a sync ends the note's // external editing, which the UI cannot stop for a locked note. The lock is already saved here, so pending // plaintext edits are dropped rather than saved over the ciphertext. if (action.type === 'NOTE_UPDATE_ONE' && action.note.is_locked && store.getState().watchedNoteFiles.includes(action.note.id)) { await ExternalEditWatcher.instance().stopWatching(action.note.id, false); } if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'featureFlag.autoUpdaterServiceEnabled' || action.type === 'SETTING_UPDATE_ALL') { if (Setting.value('featureFlag.autoUpdaterServiceEnabled')) this.setupAutoUpdaterService(); } const result = await super.generalMiddleware(store, next, action); const newState = store.getState(); if ( action.type === 'NOTE_UPDATE_ONE' && [ItemChange.SOURCE_SYNC, ItemChange.SOURCE_DECRYPTION].includes(action.changeSource) && !action.note.encryption_applied && !action.note.is_locked && action.changedFields.some((field: string) => ['title', 'body'].includes(field)) ) { const externalEditWatcher = ExternalEditWatcher.instance(); if (externalEditWatcher.noteIsWatched(action.note)) { await externalEditWatcher.updateNoteFile(action.note); } } if (['NOTE_VISIBLE_PANES_TOGGLE', 'NOTE_VISIBLE_PANES_SET'].indexOf(action.type) >= 0) { Setting.setValue('noteVisiblePanes', newState.noteVisiblePanes); } if (['NOTE_DEVTOOLS_TOGGLE', 'NOTE_DEVTOOLS_SET'].indexOf(action.type) >= 0) { this.toggleDevTools(newState.devToolsVisible); } if (action.type === 'FOLDER_AND_NOTE_SELECT') { await Folder.expandTree(newState.folders, action.folderId); } if (this.hasGui() && ((action.type === 'SETTING_UPDATE_ONE' && ['themeAutoDetect', 'theme', 'preferredLightTheme', 'preferredDarkTheme'].includes(action.key)) || action.type === 'SETTING_UPDATE_ALL')) { this.handleThemeAutoDetect(); } if (action.type === 'PLUGIN_ADD') { const plugin = PluginService.instance().pluginById(action.plugin.id); this.protocolHandler_.allowReadAccessToDirectory(plugin.baseDir); this.protocolHandler_.allowReadAccessToDirectory(plugin.dataDir); } return result; } public handleThemeAutoDetect() { if (!Setting.value('themeAutoDetect')) return; if (bridge().shouldUseDarkColors()) { Setting.setValue('theme', Setting.value('preferredDarkTheme')); } else { Setting.setValue('theme', Setting.value('preferredLightTheme')); } } private bridge_nativeThemeUpdated() { this.handleThemeAutoDetect(); } public updateTray() { const app = bridge().electronApp(); if (app.trayShown() === Setting.value('showTrayIcon')) return; if (!Setting.value('showTrayIcon')) { app.destroyTray(); } else { const contextMenu = Menu.buildFromTemplate([ { label: _('Open %s', app.electronApp().name), click: () => { app.mainWindow().show(); } }, { type: 'separator' }, { label: _('Quit'), click: () => { app.quitWithSyncCheck( (action: { type: string; [key: string]: unknown }) => this.store().dispatch(action), this.store().getState().syncPending, ); } }, ]); app.createTray(contextMenu); } } public setupContextMenu() { // bridge().setupContextMenu((misspelledWord: string, dictionarySuggestions: string[]) => { // let output = SpellCheckerService.instance().contextMenuItems(misspelledWord, dictionarySuggestions); // console.info(misspelledWord, dictionarySuggestions); // console.info(output); // output = output.map(o => { // delete o.click; // return o; // }); // return output; // }); const MenuItem = bridge().MenuItem; // The context menu must be setup in renderer process because that's where // the spell checker service lives. electronContextMenu({ shouldShowMenu: (_event: unknown, params: { isEditable: boolean; inputFieldType: string }) => { // params.inputFieldType === 'none' when right-clicking the text editor. This is a bit of a hack to detect it because in this // case we don't want to use the built-in context menu but a custom one. return params.isEditable && params.inputFieldType !== 'none'; }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- electron-context-menu's actions/props don't have public types in this version menu: (actions: any, props: any) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- contextMenuItems returns a heterogeneous menu shape that doesn't satisfy Electron's MenuItemConstructorOptions structurally const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(props.misspelledWord, props.dictionarySuggestions).map((item: any) => new MenuItem(item)); const output = [ actions.cut(), actions.copy(), actions.paste(), ...spellCheckerMenuItems, ]; return output; }, }); } private async initPluginService() { if (this.initPluginServiceDone_) return; this.initPluginServiceDone_ = true; const service = PluginService.instance(); const pluginRunner = new PluginRunner(); service.initialize(packageInfo.version, PlatformImplementation.instance(), pluginRunner, this.store()); service.isSafeMode = Setting.value('isSafeMode'); let pluginSettings = service.unserializePluginSettings(Setting.value('plugins.states')); { // Users can add and remove plugins from the config screen at any // time, however we only effectively uninstall the plugin the next // time the app is started. What plugin should be uninstalled is // stored in the settings. pluginSettings = service.clearUpdateState(await service.uninstallPlugins(pluginSettings)); Setting.setValue('plugins.states', pluginSettings); } // As of Joplin 3.5.7, the ABC rendering is part of the app so we automatically disable the plugin if (pluginSettings['org.joplinapp.plugins.AbcSheetMusic']) { pluginSettings = { ...pluginSettings, ['org.joplinapp.plugins.AbcSheetMusic']: { enabled: false, deleted: false, hasBeenUpdated: false, }, }; } try { if (await shim.fsDriver().exists(Setting.value('pluginDir'))) { await service.loadAndRunPlugins(Setting.value('pluginDir'), pluginSettings); } } catch (error) { this.logger().error(`There was an error loading plugins from ${Setting.value('pluginDir')}:`, error); } try { await service.loadAndRunDevPlugins(pluginSettings); } catch (error) { this.logger().error(`There was an error loading plugins from ${Setting.value('plugins.devPluginPaths')}:`, error); } // Load default plugins after loading other plugins -- this allows users // to override built-in plugins with development versions with the same // ID. const defaultPluginsDir = path.join(bridge().buildDir(), 'defaultPlugins'); try { pluginSettings = await loadAndRunDefaultPlugins(service, defaultPluginsDir, getDefaultPluginsInfo(), pluginSettings); } catch (error) { this.logger().error(`There was an error loading plugins from ${defaultPluginsDir}:`, error); } { // Users can potentially delete files from /plugins or even delete // the complete folder. When that happens, we still have the plugin // info in the state, which can cause various issues, so to sort it // out we remove from the state any plugin that has *not* been loaded // above (meaning the file was missing). // https://github.com/laurent22/joplin/issues/5253 const oldSettings = pluginSettings; const newSettings: PluginSettings = {}; for (const pluginId of Object.keys(oldSettings)) { if (!service.pluginIds.includes(pluginId)) { this.logger().warn('Found a plugin in the state that has not been loaded, which means the plugin might have been deleted outside Joplin - removing it from the state:', pluginId); continue; } newSettings[pluginId] = oldSettings[pluginId]; } Setting.setValue('plugins.states', newSettings); pluginSettings = newSettings; } this.checkAllPluginStartedIID_ = setInterval(() => { if (service.allPluginsStarted) { clearInterval(this.checkAllPluginStartedIID_); this.dispatch({ type: 'STARTUP_PLUGINS_LOADED', value: true, }); // Sends an event to the main process -- this is used by the Playwright // tests to wait for plugins to load. ipcRenderer.send('startup-plugins-loaded'); void afterDefaultPluginsLoaded(service.plugins, getDefaultPluginsInfo(), pluginSettings); } }, 500); } private async setupOcrService() { if (Setting.value('ocr.clearLanguageDataCache')) { Setting.setValue('ocr.clearLanguageDataCache', false); try { await OcrDriverTesseract.clearLanguageDataCache(); } catch (error) { this.logger().warn('OCR: Failed to clear language data cache.', error); } } if (Setting.value('ocr.enabled')) { if (!this.ocrService_) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tesseract.js is loaded via