1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-04-18 19:42:23 +02:00

Compare commits

..

3 Commits

Author SHA1 Message Date
Laurent Cozic f1ee89c2b7 Merge branch 'dev' into disabled_plugin 2026-04-13 17:51:33 +01:00
Laurent Cozic 33e8f0ee1b update 2026-04-13 16:55:29 +01:00
Laurent Cozic 1cde57601b update 2026-04-13 15:59:32 +01:00
21 changed files with 85 additions and 287 deletions
-2
View File
@@ -201,8 +201,6 @@ packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
-2
View File
@@ -174,8 +174,6 @@ packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
-1
View File
@@ -11,7 +11,6 @@
- Focus on testing essential behaviour and edge cases — avoid adding tests for every minor detail.
- Avoid duplicating code in tests; when testing the same logic with different inputs, use `test.each` or shared helpers instead of repeating similar test blocks.
- Do not make white space changes - do not add unnecessary new lines, or spaces to existing code, or wrap existing code.
- If you add a new TypeScript file, run `yarn updateIgnored` from the root.
## Full Documentation
@@ -939,7 +939,6 @@ export default class ElectronAppWrapper {
this.electronApp_.on('before-quit', () => {
this.appLogger_.info('[appClose] before-quit event fired, setting willQuitApp_ = true');
this.willQuitApp_ = true;
bridge().unregisterGlobalHotkey();
});
this.electronApp_.on('window-all-closed', () => {
-4
View File
@@ -150,10 +150,6 @@ class Application extends BaseApplication {
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');
}
+1 -50
View File
@@ -1,7 +1,7 @@
import ElectronAppWrapper from './ElectronAppWrapper';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { _, setLocale } from '@joplin/lib/locale';
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions, globalShortcut } from 'electron';
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions } from 'electron';
import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
import { fileUriToPath } from '@joplin/utils/url';
import { urlDecode } from '@joplin/lib/string-utils';
@@ -46,7 +46,6 @@ export class Bridge {
private extraAllowedExtensions_: string[] = [];
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
private registeredGlobalHotkey_ = '';
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
this.electronWrapper_ = electronWrapper;
@@ -208,54 +207,6 @@ export class Bridge {
this.onAllowedExtensionsChangeListener_ = listener;
}
public updateGlobalHotkey(accelerator: string) {
// Skip if the accelerator hasn't changed
if (accelerator === this.registeredGlobalHotkey_) return;
// Unregister the previous shortcut (only Joplin's own)
this.unregisterGlobalHotkey();
if (!accelerator) return;
try {
const registered = globalShortcut.register(accelerator, () => {
const win = this.mainWindow();
if (!win) return;
if (win.isVisible() && win.isFocused()) {
win.hide();
} else {
if (win.isMinimized()) win.restore();
win.show();
// eslint-disable-next-line no-restricted-properties
win.focus();
}
});
if (registered) {
this.registeredGlobalHotkey_ = accelerator;
} else {
// eslint-disable-next-line no-console
console.warn(`Bridge: Failed to register global shortcut: ${accelerator}`);
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Bridge: Error registering global shortcut "${accelerator}":`, error);
}
}
public unregisterGlobalHotkey() {
if (this.registeredGlobalHotkey_) {
try {
globalShortcut.unregister(this.registeredGlobalHotkey_);
} catch (error) {
// eslint-disable-next-line no-console
console.warn('Bridge: Error removing global shortcut:', error);
}
this.registeredGlobalHotkey_ = '';
}
}
public async captureException(error: unknown) {
Sentry.captureException(error);
// We wait to give the "beforeSend" event handler time to process the crash dump and write
@@ -1,24 +0,0 @@
import * as React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import GlobalHotkeyInput from './GlobalHotkeyInput';
describe('GlobalHotkeyInput', () => {
test('should render ShortcutRecorder with Save and Restore buttons', () => {
const onChange = jest.fn();
render(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);
// ShortcutRecorder is always visible with its built-in buttons
expect(screen.getByText('Save')).toBeTruthy();
expect(screen.getByText('Restore')).toBeTruthy();
expect(screen.getByText('Cancel')).toBeTruthy();
});
test('should clear value when Restore is clicked', () => {
const onChange = jest.fn();
render(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);
fireEvent.click(screen.getByText('Restore'));
expect(onChange).toHaveBeenCalledWith({ value: '' });
});
});
@@ -1,53 +0,0 @@
import * as React from 'react';
import { useCallback } from 'react';
import { ShortcutRecorder } from '../../KeymapConfig/ShortcutRecorder';
interface OnChangeEvent {
value: string;
}
interface Props {
value: string;
themeId: number;
onChange: (event: OnChangeEvent)=> void;
}
// A thin wrapper around ShortcutRecorder for the global hotkey setting.
// Reuses ShortcutRecorder directly instead of maintaining a separate display mode.
export default function GlobalHotkeyInput(props: Props) {
const value = props.value || '';
const onSave = useCallback((event: { commandName: string; accelerator: string }) => {
// Normalize platform-specific modifiers to CommandOrControl for
// consistent cross-platform storage.
const accelerator = event.accelerator
.replace(/\bCmd\b/, 'CommandOrControl')
.replace(/\bCtrl\b/, 'CommandOrControl');
props.onChange({ value: accelerator });
}, [props.onChange]);
const onReset = useCallback(() => {
props.onChange({ value: '' });
}, [props.onChange]);
// No-op: global hotkeys don't have a separate editing mode to cancel out of.
const onCancel = useCallback(() => {}, []);
// No-op: ShortcutRecorder validates against the keymap (command
// conflicts), which doesn't apply to global hotkeys.
const onError = useCallback((_event: { recorderError: Error }) => {}, []);
return (
<ShortcutRecorder
onSave={onSave}
onReset={onReset}
onCancel={onCancel}
onError={onError}
initialAccelerator={value}
commandName="globalHotkey"
themeId={props.themeId}
skipKeymapValidation
autoFocus={false}
/>
);
}
@@ -3,7 +3,6 @@ import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react';
import { useCallback, useId } from 'react';
import control_PluginsStates from './plugins/PluginsStates';
import control_GlobalHotkeyInput from './GlobalHotkeyInput';
import bridge from '../../../services/bridge';
import { _ } from '@joplin/lib/locale';
import Button, { ButtonLevel, ButtonSize } from '../../Button/Button';
@@ -12,10 +11,8 @@ import * as pathUtils from '@joplin/lib/path-utils';
import SettingLabel from './SettingLabel';
import SettingDescription from './SettingDescription';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each control component has different prop types
const settingKeyToControl: Record<string, React.FC<any>> = {
const settingKeyToControl: Record<string, typeof control_PluginsStates> = {
'plugins.states': control_PluginsStates,
'globalHotkey': control_GlobalHotkeyInput,
};
export interface UpdateSettingValueEvent {
@@ -15,14 +15,9 @@ export interface ShortcutRecorderProps {
initialAccelerator: string;
commandName: string;
themeId: number;
// When true, skip keymap conflict validation (useful for global hotkeys
// that aren't part of the internal command keymap).
skipKeymapValidation?: boolean;
// Controls whether the input auto-focuses on mount. Defaults to true.
autoFocus?: boolean;
}
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId, skipKeymapValidation, autoFocus = true }: ShortcutRecorderProps) => {
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId }: ShortcutRecorderProps) => {
const styles = styles_(themeId);
const [accelerator, setAccelerator] = useState(initialAccelerator);
@@ -34,9 +29,7 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
// Otherwise performing a save means that it's going to be disabled
if (accelerator) {
keymapService.validateAccelerator(accelerator);
if (!skipKeymapValidation) {
keymapService.validateKeymap({ accelerator, command: commandName });
}
keymapService.validateKeymap({ accelerator, command: commandName });
}
// Discard previous errors
@@ -93,7 +86,7 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
onKeyDown={handleKeyDown}
readOnly
autoFocus={autoFocus}
autoFocus
/>
<button style={styles.inlineButton} disabled={!saveAllowed} onClick={() => onSave({ commandName, accelerator })}>
@@ -38,9 +38,6 @@ const useStyle = (themeId: number) => {
right: 0,
bottom: 0,
},
profileList: {
flex: 1,
},
profileListItem: {
paddingLeft: theme.margin,
paddingRight: theme.margin,
@@ -209,15 +206,15 @@ export default (props: Props) => {
return (
<View style={style.root}>
<ScreenHeader title={_('Profiles')} showSaveButton={false} showSideMenuButton={false} showSearchButton={false} />
<FlatList
style={style.profileList}
data={profiles}
renderItem={renderProfileItem}
keyExtractor={profile => profile.id}
// Needed so that the list rerenders when its dependencies change:
extraData={extraListItemData}
contentContainerStyle={{ paddingBottom: 80 }}
/>
<View>
<FlatList
data={profiles}
renderItem={renderProfileItem}
keyExtractor={profile => profile.id}
// Needed so that the list rerenders when its dependencies change:
extraData={extraListItemData}
/>
</View>
<FAB
icon="plus"
accessibilityLabel={_('New profile')}
@@ -46,18 +46,12 @@ export default class PluginRunner extends BasePluginRunner {
return false;
});
// On native mobile, pass a file path so the WebView can load the
// script directly from the filesystem (avoids transferring the full
// script text across the React Native bridge). On web, file:// URLs
// are blocked by CSP so we pass the script text directly.
const scriptFilePath = plugin.scriptText ? '' : `${plugin.baseDir}/index.js`;
this.webviewRef.current.injectJS(`
pluginBackgroundPage.runPlugin(
${JSON.stringify(shim.injectedJs('pluginBackgroundPage'))},
${JSON.stringify(scriptFilePath)},
${JSON.stringify(plugin.scriptText)},
${JSON.stringify(messageChannelId)},
${JSON.stringify(plugin.id)},
${JSON.stringify(plugin.scriptText)},
);
`);
@@ -186,7 +186,6 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
html={html}
injectedJavaScript={injectedJs}
hasPluginScripts={true}
allowFileAccessFromJs={true}
onMessage={pluginRunner.onWebviewMessage}
onLoadEnd={onLoadEnd}
onLoadStart={onLoadStart}
@@ -26,29 +26,14 @@ export const stopPlugin = async (pluginId: string) => {
delete loadedPlugins[pluginId];
};
export const runPlugin = async (
pluginBackgroundScript: string, scriptFilePath: string, messageChannelId: string, pluginId: string, scriptText = '',
export const runPlugin = (
pluginBackgroundScript: string, pluginScript: string, messageChannelId: string, pluginId: string,
) => {
if (loadedPlugins[pluginId]) {
console.warn(`Plugin already running ${pluginId}`);
return;
}
// When scriptText is provided (web), use it directly. Otherwise load
// the plugin script from the filesystem (native mobile). We use
// XMLHttpRequest because fetch() doesn't support file:// URLs on
// Android WebView.
let pluginScript = scriptText;
if (!pluginScript) {
pluginScript = await new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `file://${scriptFilePath}`, true);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error(`Failed to load plugin script: ${scriptFilePath}`));
xhr.send();
});
}
const bodyHtml = '';
const initialJavaScript = `
"use strict";
@@ -404,29 +404,6 @@ describe('screens/Note', () => {
unmount();
});
it('should set title, body, and parent_id correctly when a note is created via share', async () => {
const folder = await Folder.save({ title: 'Share target folder', parent_id: '' });
const note = await Note.save({ parent_id: folder.id }, { provisional: true });
store.dispatch({
type: 'NAV_GO',
routeName: 'Note',
noteId: note.id,
sharedData: { title: 'Shared title', text: 'https://example.com' },
});
store.dispatch({ type: 'NOTE_UPDATE_ONE', note: { ...note }, provisional: true });
const { unmount } = render(<WrappedNoteScreen />);
await waitForNoteToMatch(note.id, {
title: 'Shared title',
body: 'https://example.com',
parent_id: folder.id,
});
unmount();
});
it('should always start in edit mode for provisional notes regardless of noteVisiblePanes', async () => {
store.dispatch({
type: 'NOTE_VISIBLE_PANES_SET',
+6 -10
View File
@@ -586,18 +586,14 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
if (sharedData) {
reg.logger().info('Received shared data');
const activeFolder = await Folder.getValidActiveFolder();
if (activeFolder) {
// selectedFolderId can be null if no screens other than "All notes"
// have been opened.
const targetFolder = this.props.selectedFolderId ?? (await Folder.defaultFolder())?.id;
if (targetFolder) {
logger.info('Sharing: handleShareData: Processing...');
await handleShared(sharedData, activeFolder.id, this.props.dispatch);
await handleShared(sharedData, targetFolder, this.props.dispatch);
} else {
reg.logger().warn('Cannot handle share - no valid active folder found');
void this.dropdownAlert_({
type: 'error',
title: _('Cannot share'),
message: _('No valid notebook is available. Please create or select a notebook and try again.'),
});
ShareExtension.close();
reg.logger().info('Cannot handle share - default folder id is not set');
}
} else {
logger.info('Sharing: received empty share data.');
@@ -304,10 +304,7 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
const fromShare = !!comp.props.sharedData;
if (note) {
let folder = Folder.byId(comp.props.folders, note.parent_id);
if (!folder && note.parent_id) {
folder = await Folder.load(note.parent_id);
}
const folder = Folder.byId(comp.props.folders, note.parent_id);
comp.setState({
lastSavedNote: { ...note },
note: note,
@@ -340,24 +337,12 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
shared.initState = async function(comp: BaseNoteScreenComponent) {
const note = await shared.reloadNote(comp);
if (comp.props.sharedData && note) {
// Use the note returned by reloadNote directly to avoid a race condition where
// comp.state.note is still the initial empty note (Note.new() with parent_id='')
// because React hasn't flushed reloadNote's setState yet. Without this, the
// scheduled save would overwrite parent_id with an empty string in the DB.
const updatedNote = { ...note };
const fieldsToSave: NoteEntity = { id: note.id };
if (comp.props.sharedData) {
if (comp.props.sharedData.title) {
updatedNote.title = comp.props.sharedData.title;
fieldsToSave.title = comp.props.sharedData.title;
this.noteComponent_change(comp, 'title', comp.props.sharedData.title);
}
if (comp.props.sharedData.text) {
updatedNote.body = comp.props.sharedData.text;
fieldsToSave.body = comp.props.sharedData.text;
}
if (fieldsToSave.title !== undefined || fieldsToSave.body !== undefined) {
await Note.save(fieldsToSave);
comp.setState({ note: updatedNote, lastSavedNote: updatedNote });
this.noteComponent_change(comp, 'body', comp.props.sharedData.text);
}
if (comp.props.sharedData.resources) {
for (let i = 0; i < comp.props.sharedData.resources.length; i++) {
@@ -1177,25 +1177,6 @@ const builtInMetadata = (Setting: typeof SettingType) => {
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon'), show: settings => !!settings['showTrayIcon'] },
'globalHotkey': {
value: '',
type: SettingItemType.String,
section: 'application',
public: true,
appTypes: [AppType.Desktop],
label: () => _('Global shortcut to show/hide Joplin'),
description: () => _('A system-wide keyboard shortcut that toggles the Joplin window. Works even when Joplin is not focused. Example: CommandOrControl+Shift+J. Leave empty to disable.'),
storage: SettingStorage.File,
isGlobal: true,
autoSave: true,
// Electron's globalShortcut API does not yet work under Wayland,
// so we hide this option when running on a Wayland session.
show: () => {
if (platform !== 'linux') return true;
return process.env.XDG_SESSION_TYPE !== 'wayland' && !process.env.WAYLAND_DISPLAY;
},
},
collapsedFolderIds: { value: [] as string[], type: SettingItemType.Array, public: false },
'keychain.supported': { value: -1, type: SettingItemType.Int, public: false },
+1 -11
View File
@@ -162,17 +162,7 @@ export default class ExternalEditWatcher {
return;
}
let noteContent: string;
try {
noteContent = await shim.fsDriver().readFile(path, 'utf-8');
} catch (error) {
if (error.code === 'ENOENT') {
this.logger().warn(`ExternalEditWatcher: Watched file no longer exists: ${path}`);
void this.stopWatching(id);
return;
}
throw error;
}
let noteContent = await shim.fsDriver().readFile(path, 'utf-8');
// In some very rare cases, the "change" event is going to be emitted but the file will be empty.
// This is likely to be the editor that first clears the file, then writes the content to it, so if
+26 -16
View File
@@ -287,15 +287,25 @@ export default class PluginService extends BaseService {
return this.loadPlugin(baseDir, r.manifestText, r.scriptText, pluginIdIfNotSpecified);
}
public async loadPluginFromPackage(baseDir: string, path: string): Promise<Plugin> {
public async loadPluginFromPackage(baseDir: string, path: string, manifestOnly = false): Promise<Plugin> {
baseDir = rtrimSlashes(baseDir);
const fname = filename(path);
const hash = await shim.fsDriver().md5File(path);
const unpackDir = `${Setting.value('cacheDir')}/${fname}`;
const manifestFilePath = `${unpackDir}/manifest.json`;
if (manifestOnly) {
// When loading only the manifest (e.g. for disabled plugins), try
// to use an already-extracted manifest from cache to avoid the
// expensive MD5 hash and tar extraction.
const manifest = await this.loadManifestToObject(manifestFilePath);
if (manifest) {
return this.loadPlugin(unpackDir, JSON.stringify(manifest), '', makePluginId(fname));
}
}
const hash = await shim.fsDriver().md5File(path);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
let manifest: any = await this.loadManifestToObject(manifestFilePath);
@@ -318,7 +328,7 @@ export default class PluginService extends BaseService {
await shim.fsDriver().writeFile(manifestFilePath, JSON.stringify(manifest, null, '\t'), 'utf8');
}
return this.loadPluginFromPath(unpackDir);
return this.loadPluginFromPath(unpackDir, manifestOnly);
}
// Loads the manifest as a simple object with no validation. Used only
@@ -333,7 +343,7 @@ export default class PluginService extends BaseService {
}
}
public async loadPluginFromPath(path: string): Promise<Plugin> {
public async loadPluginFromPath(path: string, manifestOnly = false): Promise<Plugin> {
path = rtrimSlashes(path);
const fsDriver = shim.fsDriver();
@@ -341,7 +351,7 @@ export default class PluginService extends BaseService {
if (path.toLowerCase().endsWith('.js')) {
return this.loadPluginFromJsBundle(dirname(path), await fsDriver.readFile(path), filename(path));
} else if (path.toLowerCase().endsWith('.jpl')) {
return this.loadPluginFromPackage(dirname(path), path);
return this.loadPluginFromPackage(dirname(path), path, manifestOnly);
} else {
let distPath = path;
if (!(await fsDriver.exists(`${distPath}/manifest.json`))) {
@@ -351,15 +361,7 @@ export default class PluginService extends BaseService {
logger.info(`Loading plugin from ${path}`);
const manifestText = await fsDriver.readFile(`${distPath}/manifest.json`);
// On mobile, plugin scripts are loaded directly by the WebView
// from the filesystem, so we don't need to read them here.
const indexPath = `${distPath}/index.js`;
if (shim.mobilePlatform()) {
if (!(await fsDriver.exists(indexPath))) {
throw new Error(`Plugin bundle not found at: ${indexPath}`);
}
}
const scriptText = shim.mobilePlatform() ? '' : await fsDriver.readFile(indexPath);
const scriptText = manifestOnly ? '' : await fsDriver.readFile(`${distPath}/index.js`);
const pluginId = makePluginId(filename(path));
return this.loadPlugin(distPath, manifestText, scriptText, pluginId);
@@ -457,8 +459,16 @@ export default class PluginService extends BaseService {
}
try {
const plugin = await this.loadPluginFromPath(pluginPath);
// Load only the manifest first to check if the plugin is
// enabled before doing the expensive full load.
let plugin = await this.loadPluginFromPath(pluginPath, true);
const enabled = this.pluginEnabled(settings, plugin.id);
if (enabled) {
logger.info(`Loading full plugin: ${plugin.id}`);
plugin = await this.loadPluginFromPath(pluginPath, false);
} else {
logger.info(`Loading manifest only for disabled plugin: ${plugin.id}`);
}
const existingPlugin = this.plugins_[plugin.id];
if (existingPlugin) {
@@ -1,5 +1,5 @@
import Setting from '../../models/Setting';
import PluginService from '../../services/plugins/PluginService';
import PluginService, { defaultPluginSetting } from '../../services/plugins/PluginService';
import { setupDatabaseAndSynchronizer, switchClient, withWarningSilenced } from '../../testing/test-utils';
import loadPlugins, { Props as LoadPluginsProps } from './loadPlugins';
import MockPluginRunner from './testing/MockPluginRunner';
@@ -8,6 +8,9 @@ import { Action, createStore } from 'redux';
import MockPlatformImplementation from './testing/MockPlatformImplementation';
import createTestPlugin from '../../testing/plugins/createTestPlugin';
import Plugin from './Plugin';
import { PluginManifest } from './utils/types';
import { writeFile, mkdirp } from 'fs-extra';
import { join } from 'path';
const createMockReduxStore = () => {
return createStore((state: State = defaultState, action: Action<string>) => {
@@ -136,6 +139,33 @@ describe('loadPlugins', () => {
expect([...pluginRunner.runningPluginIds].sort()).toMatchObject(expectedRunningIds);
});
test('should not load the script for disabled plugins', async () => {
const createDirPlugin = async (manifest: PluginManifest, enabled: boolean) => {
const dir = join(Setting.value('pluginDir'), manifest.id);
await mkdirp(dir);
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest), 'utf-8');
await writeFile(join(dir, 'index.js'), 'joplin.plugins.register({ onStart: async function() {} });', 'utf-8');
const newStates = {
...Setting.value('plugins.states'),
[manifest.id]: { ...defaultPluginSetting(), enabled },
};
Setting.setValue('plugins.states', newStates);
};
const enabledId = 'joplin.test.plugin.enabled';
const disabledId = 'joplin.test.plugin.disabled';
await createDirPlugin({ ...defaultManifestProperties, id: enabledId, name: 'Enabled' }, true);
await createDirPlugin({ ...defaultManifestProperties, id: disabledId, name: 'Disabled' }, false);
const pluginRunner = new MockPluginRunner();
const store = createMockReduxStore();
PluginService.instance().initialize('2.3.4', platformImplementation, pluginRunner, store);
await PluginService.instance().loadAndRunPlugins(Setting.value('pluginDir'), Setting.value('plugins.states'));
expect(PluginService.instance().plugins[disabledId].scriptText).toBe('');
expect(PluginService.instance().plugins[enabledId].scriptText).not.toBe('');
});
test('should not block allPluginsStarted when a plugin fails to start', async () => {
// This tests the fix for https://github.com/laurent22/joplin/issues/12793
// When a plugin crashes before calling register(), it should not block