Web: Add support for auto-reloading dev plugins on change (#11545)

pull/11602/head^2
Henry Heino 2025-01-09 07:25:06 -08:00 committed by GitHub
parent a81af0711c
commit 98fce34fe9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 249 additions and 64 deletions

View File

@ -704,6 +704,7 @@ packages/app-mobile/components/plugins/dialogs/hooks/useViewInfos.js
packages/app-mobile/components/plugins/dialogs/hooks/useWebViewSetup.js
packages/app-mobile/components/plugins/types.js
packages/app-mobile/components/plugins/utils/createOnLogHandler.js
packages/app-mobile/components/plugins/utils/useOnDevPluginsUpdated.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen/FileSystemPathSelector.js
packages/app-mobile/components/screens/ConfigScreen/JoplinCloudConfig.js

1
.gitignore vendored
View File

@ -679,6 +679,7 @@ packages/app-mobile/components/plugins/dialogs/hooks/useViewInfos.js
packages/app-mobile/components/plugins/dialogs/hooks/useWebViewSetup.js
packages/app-mobile/components/plugins/types.js
packages/app-mobile/components/plugins/utils/createOnLogHandler.js
packages/app-mobile/components/plugins/utils/useOnDevPluginsUpdated.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen/FileSystemPathSelector.js
packages/app-mobile/components/screens/ConfigScreen/JoplinCloudConfig.js

View File

@ -15,6 +15,7 @@ import { AppState } from '../../utils/types';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import PlatformImplementation from '../../services/plugins/PlatformImplementation';
import AccessibleView from '../accessibility/AccessibleView';
import useOnDevPluginsUpdated from './utils/useOnDevPluginsUpdated';
const logger = Logger.create('PluginRunnerWebView');
@ -29,20 +30,33 @@ const usePlugins = (
pluginRunner: PluginRunner,
webviewLoaded: boolean,
pluginSettings: PluginSettings,
pluginSupportEnabled: boolean,
devPluginPath: string,
) => {
const store = useStore<AppState>();
const lastPluginRunner = usePrevious(pluginRunner);
const [reloadCounter, setReloadCounter] = useState(0);
// Only set reloadAll to true here -- this ensures that all plugins are reloaded,
// even if loadPlugins is cancelled and re-run.
const reloadAllRef = useRef(false);
reloadAllRef.current ||= pluginRunner !== lastPluginRunner;
useOnDevPluginsUpdated(async (pluginId: string) => {
logger.info(`Dev plugin ${pluginId} updated. Reloading...`);
await PluginService.instance().unloadPlugin(pluginId);
setReloadCounter(counter => counter + 1);
}, devPluginPath, pluginSupportEnabled);
useAsyncEffect(async (event) => {
if (!webviewLoaded) {
return;
}
if (reloadCounter > 0) {
logger.debug('Reloading with counter set to', reloadCounter);
}
await loadPlugins({
pluginRunner,
pluginSettings,
@ -56,7 +70,7 @@ const usePlugins = (
if (!event.cancelled) {
reloadAllRef.current = false;
}
}, [pluginRunner, store, webviewLoaded, pluginSettings]);
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter]);
};
const useUnloadPluginsOnGlobalDisable = (
@ -79,6 +93,7 @@ interface Props {
serializedPluginSettings: SerializedPluginSettings;
pluginSupportEnabled: boolean;
pluginStates: PluginStates;
devPluginPath: string;
pluginHtmlContents: PluginHtmlContents;
themeId: number;
}
@ -98,7 +113,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
}, [webviewReloadCounter]);
const pluginSettings = usePluginSettings(props.serializedPluginSettings);
usePlugins(pluginRunner, webviewLoaded, pluginSettings);
usePlugins(pluginRunner, webviewLoaded, pluginSettings, props.pluginSupportEnabled, props.devPluginPath);
useUnloadPluginsOnGlobalDisable(props.pluginStates, props.pluginSupportEnabled);
const onLoadStart = useCallback(() => {
@ -183,6 +198,7 @@ export default connect((state: AppState) => {
const result: Props = {
serializedPluginSettings: state.settings['plugins.states'],
pluginSupportEnabled: state.settings['plugins.pluginSupportEnabled'],
devPluginPath: state.settings['plugins.devPluginPaths'],
pluginStates: state.pluginService.plugins,
pluginHtmlContents: state.pluginService.pluginHtmlContents,
themeId: state.settings.theme,

View File

@ -0,0 +1,60 @@
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import shim from '@joplin/lib/shim';
import time from '@joplin/lib/time';
import { basename, join } from 'path';
import { useRef } from 'react';
type OnDevPluginChange = (id: string)=> void;
const useOnDevPluginsUpdated = (onDevPluginChange: OnDevPluginChange, devPluginPath: string, pluginSupportEnabled: boolean) => {
const onDevPluginChangeRef = useRef(onDevPluginChange);
onDevPluginChangeRef.current = onDevPluginChange;
const isFirstUpdateRef = useRef(true);
useAsyncEffect(async (event) => {
if (!devPluginPath || !pluginSupportEnabled) return;
const itemToLastModTime = new Map<string, number>();
// publishPath should point to the publish/ subfolder of a plugin's development
// directory.
const checkPluginChange = async (pluginPublishPath: string) => {
const dirStats = await shim.fsDriver().readDirStats(pluginPublishPath);
let hasChange = false;
let changedPluginId = '';
for (const item of dirStats) {
if (item.path.endsWith('.jpl')) {
const lastModTime = itemToLastModTime.get(item.path);
const modTime = item.mtime.getTime();
if (lastModTime === undefined || lastModTime < modTime) {
itemToLastModTime.set(item.path, modTime);
hasChange = true;
changedPluginId = basename(item.path, '.jpl');
break;
}
}
}
if (hasChange) {
if (isFirstUpdateRef.current) {
// Avoid sending an event the first time the hook is called. The first iteration
// collects initial timestamp information. In that case, hasChange
// will always be true, even with no plugin reload.
isFirstUpdateRef.current = false;
} else {
onDevPluginChangeRef.current(changedPluginId);
}
}
};
while (!event.cancelled) {
const publishFolder = join(devPluginPath, 'publish');
await checkPluginChange(publishFolder);
const pollingIntervalSeconds = 5;
await time.sleep(pollingIntervalSeconds);
}
}, [devPluginPath, pluginSupportEnabled]);
};
export default useOnDevPluginsUpdated;

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { Platform, Linking, View, Switch, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo } from 'react-native';
import { Platform, Linking, View, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo } from 'react-native';
import Setting, { AppType, SettingMetadataSection } from '@joplin/lib/models/Setting';
import NavService from '@joplin/lib/services/NavService';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
@ -12,7 +12,6 @@ import { connect } from 'react-redux';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import BaseScreenComponent from '../../base-screen';
import { themeStyle } from '../../global-style';
import * as shared from '@joplin/lib/components/shared/config/config-shared';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
@ -36,6 +35,8 @@ import EnablePluginSupportPage from './plugins/EnablePluginSupportPage';
import getVersionInfoText from '../../../utils/getVersionInfoText';
import JoplinCloudConfig, { emailToNoteDescription, emailToNoteLabel } from './JoplinCloudConfig';
import shim from '@joplin/lib/shim';
import SettingsToggle from './SettingsToggle';
import { UpdateSettingValueCallback } from './types';
interface ConfigScreenState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@ -673,22 +674,16 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
);
}
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
private renderToggle(key: string, label: string, value: any, updateSettingValue: Function, descriptionComp: any = null) {
const theme = themeStyle(this.props.themeId);
return (
<View key={key}>
<View style={this.styles().getContainerStyle(false)}>
<Text key="label" style={this.styles().styleSheet.switchSettingText}>
{label}
</Text>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied */}
<Switch key="control" style={this.styles().styleSheet.switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={(value: any) => void updateSettingValue(key, value)} />
</View>
{descriptionComp}
</View>
);
private renderToggle(key: string, label: string, value: unknown, updateSettingValue: UpdateSettingValueCallback) {
return <SettingsToggle
key={key}
settingId={key}
value={value}
label={label}
updateSettingValue={updateSettingValue}
styles={this.styles()}
themeId={this.props.themeId}
/>;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@ -3,18 +3,23 @@ import * as React from 'react';
import shim from '@joplin/lib/shim';
import { FunctionComponent, useCallback, useEffect, useState } from 'react';
import { ConfigScreenStyles } from './configScreenStyles';
import { View, Text } from 'react-native';
import { View, Text, StyleSheet } from 'react-native';
import Setting, { SettingItem } from '@joplin/lib/models/Setting';
import { openDocumentTree } from '@joplin/react-native-saf-x';
import { UpdateSettingValueCallback } from './types';
import { reg } from '@joplin/lib/registry';
import type FsDriverWeb from '../../../utils/fs-driver/fs-driver-rn.web';
import { TouchableRipple } from 'react-native-paper';
import { IconButton, TouchableRipple } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
type Mode = 'read'|'readwrite';
interface Props {
themeId: number;
styles: ConfigScreenStyles;
settingMetadata: SettingItem;
mode: 'read'|'readwrite';
mode: Mode;
description: React.ReactNode|null;
updateSettingValue: UpdateSettingValueCallback;
}
@ -23,30 +28,28 @@ type ExtendedSelf = (typeof window.self) & {
};
declare const self: ExtendedSelf;
const FileSystemPathSelector: FunctionComponent<Props> = props => {
const useFileSystemPath = (settingId: string, updateSettingValue: UpdateSettingValueCallback, accessMode: Mode) => {
const [fileSystemPath, setFileSystemPath] = useState<string>('');
const settingId = props.settingMetadata.key;
useEffect(() => {
setFileSystemPath(Setting.value(settingId));
}, [settingId]);
const selectDirectoryButtonPress = useCallback(async () => {
const showDirectoryPicker = useCallback(async () => {
if (shim.mobilePlatform() === 'web') {
// Directory picker IDs can't include certain characters.
const pickerId = `setting-${settingId}`.replace(/[^a-zA-Z]/g, '_');
const handle = await self.showDirectoryPicker({ id: pickerId, mode: props.mode });
const handle = await self.showDirectoryPicker({ id: pickerId, mode: accessMode });
const fsDriver = shim.fsDriver() as FsDriverWeb;
const uri = await fsDriver.mountExternalDirectory(handle, pickerId, props.mode);
await props.updateSettingValue(settingId, uri);
const uri = await fsDriver.mountExternalDirectory(handle, pickerId, accessMode);
await updateSettingValue(settingId, uri);
setFileSystemPath(uri);
} else {
try {
const doc = await openDocumentTree(true);
if (doc?.uri) {
setFileSystemPath(doc.uri);
await props.updateSettingValue(settingId, doc.uri);
await updateSettingValue(settingId, doc.uri);
} else {
throw new Error('User cancelled operation');
}
@ -54,32 +57,78 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => {
reg.logger().info('Didn\'t pick sync dir: ', e);
}
}
}, [props.updateSettingValue, settingId, props.mode]);
}, [updateSettingValue, settingId, accessMode]);
const clearPath = useCallback(() => {
setFileSystemPath('');
void updateSettingValue(settingId, '');
}, [updateSettingValue, settingId]);
// Supported on Android and some versions of Chrome
const supported = shim.fsDriver().isUsingAndroidSAF() || (shim.mobilePlatform() === 'web' && 'showDirectoryPicker' in self);
if (!supported) {
return null;
}
return { clearPath, showDirectoryPicker, fileSystemPath, supported };
};
const pathSelectorStyles = StyleSheet.create({
innerContainer: {
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
},
mainButton: {
flexGrow: 1,
flexShrink: 1,
paddingHorizontal: 16,
paddingVertical: 22,
margin: 0,
},
buttonContent: {
flexDirection: 'row',
},
});
const FileSystemPathSelector: FunctionComponent<Props> = props => {
const settingId = props.settingMetadata.key;
const { clearPath, showDirectoryPicker, fileSystemPath, supported } = useFileSystemPath(settingId, props.updateSettingValue, props.mode);
const styleSheet = props.styles.styleSheet;
return (
const clearButton = (
<IconButton
icon='delete'
accessibilityLabel={_('Clear')}
onPress={clearPath}
/>
);
const containerStyles = props.styles.getContainerStyle(!!props.description);
const control = <View style={[containerStyles.innerContainer, pathSelectorStyles.innerContainer]}>
<TouchableRipple
onPress={selectDirectoryButtonPress}
style={styleSheet.settingContainer}
onPress={showDirectoryPicker}
style={pathSelectorStyles.mainButton}
role='button'
>
<View style={styleSheet.settingContainer}>
<View style={pathSelectorStyles.buttonContent}>
<Text key="label" style={styleSheet.settingText}>
{props.settingMetadata.label()}
</Text>
<Text style={styleSheet.settingControl}>
<Text style={styleSheet.settingControl} numberOfLines={1}>
{fileSystemPath}
</Text>
</View>
</TouchableRipple>
);
{fileSystemPath ? clearButton : null}
</View>;
if (!supported) return null;
return <View style={containerStyles.outerContainer}>
{control}
{props.description}
</View>;
};
export default FileSystemPathSelector;

View File

@ -38,7 +38,7 @@ const SettingComponent: React.FunctionComponent<Props> = props => {
const styleSheet = props.styles.styleSheet;
const descriptionComp = !settingDescription ? null : <Text style={styleSheet.settingDescriptionText}>{settingDescription}</Text>;
const containerStyle = props.styles.getContainerStyle(!!settingDescription);
const containerStyles = props.styles.getContainerStyle(!!settingDescription);
const labelId = useId();
@ -49,8 +49,8 @@ const SettingComponent: React.FunctionComponent<Props> = props => {
const label = md.label();
return (
<View key={props.settingId} style={{ flexDirection: 'column', borderBottomWidth: 1, borderBottomColor: theme.dividerColor }}>
<View style={containerStyle}>
<View key={props.settingId} style={containerStyles.outerContainer}>
<View style={containerStyles.innerContainer}>
<Text key="label" style={styleSheet.settingText}>
{label}
</Text>
@ -125,17 +125,19 @@ const SettingComponent: React.FunctionComponent<Props> = props => {
if (['sync.2.path', 'plugins.devPluginPaths'].includes(md.key) && (shim.fsDriver().isUsingAndroidSAF() || shim.mobilePlatform() === 'web')) {
return (
<FileSystemPathSelector
themeId={props.themeId}
mode={md.key === 'sync.2.path' ? 'readwrite' : 'read'}
styles={props.styles}
settingMetadata={md}
updateSettingValue={props.updateSettingValue}
description={descriptionComp}
/>
);
}
return (
<View key={props.settingId} style={{ flexDirection: 'column', borderBottomWidth: 1, borderBottomColor: theme.dividerColor }}>
<View key={props.settingId} style={containerStyle}>
<View key={props.settingId} style={containerStyles.outerContainer}>
<View key={props.settingId} style={containerStyles.innerContainer}>
<Text key="label" style={styleSheet.settingText} nativeID={labelId}>
{md.label()}
</Text>

View File

@ -24,9 +24,11 @@ const SettingsToggle: FunctionComponent<Props> = props => {
const theme = themeStyle(props.themeId);
const styleSheet = props.styles.styleSheet;
const containerStyles = props.styles.getContainerStyle(!!props.description);
return (
<View>
<View style={props.styles.getContainerStyle(false)}>
<View style={containerStyles.outerContainer}>
<View style={containerStyles.innerContainer}>
<Text key="label" style={styleSheet.switchSettingText}>
{props.label}
</Text>

View File

@ -6,8 +6,11 @@ type SidebarButtonStyle = ViewStyle & { height: number };
export interface ConfigScreenStyleSheet {
body: ViewStyle;
settingOuterContainer: ViewStyle;
settingOuterContainerNoBorder: ViewStyle;
settingContainer: ViewStyle;
settingContainerNoBottomBorder: ViewStyle;
headerWrapperStyle: ViewStyle;
headerTextStyle: TextStyle;
@ -39,12 +42,17 @@ export interface ConfigScreenStyleSheet {
settingControl: TextStyle;
}
interface ContainerStyles {
outerContainer: ViewStyle;
innerContainer: ViewStyle;
}
export interface ConfigScreenStyles {
styleSheet: ConfigScreenStyleSheet;
selectedSectionButtonColor: string;
keyboardAppearance: 'default'|'light'|'dark';
getContainerStyle(hasDescription: boolean): ViewStyle;
getContainerStyle(hasDescription: boolean): ContainerStyles;
}
const configScreenStyles = (themeId: number): ConfigScreenStyles => {
@ -107,6 +115,14 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
justifyContent: 'flex-start',
flexDirection: 'column',
},
settingOuterContainer: {
flexDirection: 'column',
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
},
settingOuterContainerNoBorder: {
flexDirection: 'column',
},
settingContainer: settingContainerStyle,
settingContainerNoBottomBorder: {
...settingContainerStyle,
@ -229,7 +245,9 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
selectedSectionButtonColor: theme.selectedColor,
keyboardAppearance: theme.keyboardAppearance,
getContainerStyle: (hasDescription) => {
return !hasDescription ? styleSheet.settingContainer : styleSheet.settingContainerNoBottomBorder;
const outerContainer = hasDescription ? styleSheet.settingOuterContainer : styleSheet.settingOuterContainerNoBorder;
const innerContainer = hasDescription ? styleSheet.settingContainerNoBottomBorder : styleSheet.settingContainer;
return { outerContainer, innerContainer };
},
};
};

View File

@ -92,12 +92,20 @@ const PluginChips: React.FC<Props> = props => {
return <PluginChip faded={true}>{_('Installed')}</PluginChip>;
};
const renderDevChip = () => {
if (!item.devMode) {
return null;
}
return <PluginChip faded={true}>{_('Dev')}</PluginChip>;
};
return <View style={containerStyle}>
{renderIncompatibleChip()}
{renderInstalledChip()}
{renderErrorsChip()}
{renderBuiltInChip()}
{renderUpdatableChip()}
{renderDevChip()}
{renderDisabledChip()}
</View>;
};

View File

@ -203,7 +203,7 @@ const PluginInfoModalContent: React.FC<Props> = props => {
item={item}
type={ButtonType.Delete}
onPress={props.pluginCallbacks.onDelete}
disabled={item.builtIn || (item?.deleted ?? true)}
disabled={item.builtIn || item.devMode || (item?.deleted ?? true)}
title={item?.deleted ? _('Deleted') : _('Delete')}
/>
);

View File

@ -91,7 +91,7 @@ const PluginUploadButton: React.FC<Props> = props => {
}, [props.pluginSettings, props.updatePluginStates]);
return (
<View style={props.styles.getContainerStyle(false)}>
<View style={props.styles.getContainerStyle(false).innerContainer}>
<TextButton
type={ButtonType.Primary}
onPress={onInstallFromFile}

View File

@ -8,7 +8,7 @@ export interface CustomSettingSection {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export type UpdateSettingValueCallback = (key: string, value: any)=> Promise<void>;
export type UpdateSettingValueCallback = (key: string, value: any)=> void|Promise<void>;
export interface PluginStatusRecord {
[pluginId: string]: boolean;

View File

@ -929,9 +929,23 @@ const builtInMetadata = (Setting: typeof SettingType) => {
section: 'plugins',
public: true,
advanced: true,
appTypes: [AppType.Desktop],
appTypes: [AppType.Desktop, AppType.Mobile],
// For now, development plugins are only enabled on desktop & web.
show: (settings) => {
if (shim.isElectron()) return true;
if (shim.mobilePlatform() !== 'web') return false;
const pluginSupportEnabled = settings['plugins.pluginSupportEnabled'];
return !!pluginSupportEnabled;
},
label: () => 'Development plugins',
description: () => 'You may add multiple plugin paths, each separated by a comma. You will need to restart the application for the changes to take effect.',
description: () => {
if (shim.mobilePlatform()) {
return 'The path to a plugin\'s development directory. When the plugin is rebuilt, Joplin reloads the plugin automatically.';
} else {
return 'You may add multiple plugin paths, each separated by a comma. You will need to restart the application for the changes to take effect.';
}
},
storage: SettingStorage.File,
},

View File

@ -209,6 +209,10 @@ export default class CommandService extends BaseService {
};
}
public unregisterDeclaration(name: string) {
delete this.commands_[name];
}
public registerRuntime(commandName: string, runtime: CommandRuntime, allowMultiple = false): RegisteredRuntime {
if (typeof commandName !== 'string') throw new Error(`Command name must be a string. Got: ${JSON.stringify(commandName)}`);

View File

@ -48,22 +48,24 @@ export default class ToolbarButtonUtils {
private commandToToolbarButton(commandName: string, whenClauseContext: WhenClauseContext): ToolbarButtonInfo {
const newEnabled = this.service.isEnabled(commandName, whenClauseContext);
const newTitle = this.service.title(commandName);
const newIcon = this.service.iconName(commandName);
const newLabel = this.service.label(commandName);
if (
this.toolbarButtonCache_[commandName] &&
this.toolbarButtonCache_[commandName].info.enabled === newEnabled &&
this.toolbarButtonCache_[commandName].info.title === newTitle
this.toolbarButtonCache_[commandName].info.title === newTitle &&
this.toolbarButtonCache_[commandName].info.iconName === newIcon &&
this.toolbarButtonCache_[commandName].info.tooltip === newLabel
) {
return this.toolbarButtonCache_[commandName].info;
}
const command = this.service.commandByName(commandName, { runtimeMustBeRegistered: true });
const output: ToolbarButtonInfo = {
type: 'button',
name: commandName,
tooltip: this.service.label(commandName),
iconName: command.declaration.iconName,
tooltip: newLabel,
iconName: newIcon,
enabled: newEnabled,
onClick: async () => {
await this.service.execute(commandName);

View File

@ -122,6 +122,7 @@ export default class JoplinCommands {
CommandService.instance().registerRuntime(declaration.name, runtime);
this.plugin_.addOnUnloadListener(() => {
CommandService.instance().unregisterRuntime(declaration.name);
CommandService.instance().unregisterDeclaration(declaration.name);
});
}

View File

@ -51,10 +51,7 @@ const loadPlugins = async ({
}
}
if (Setting.value('env') === 'dev') {
logger.info('Running dev plugins (if any)...');
await pluginService.loadAndRunDevPlugins(pluginSettings);
}
await pluginService.loadAndRunDevPlugins(pluginSettings);
if (cancelEvent.cancelled) {
logger.info('Cancelled.');

View File

@ -202,6 +202,7 @@ const reducer = (draftRoot: Draft<any>, action: any) => {
case 'PLUGIN_UNLOAD':
delete draft.plugins[action.pluginId];
delete draft.pluginHtmlContents[action.pluginId];
break;
}

View File

@ -32,6 +32,20 @@ After loading, plugins are run in an `<iframe>` with an `about:srcdoc` URL. To v
- The JavaScript context will be named `about:srcdoc`.
- If using Chrome's DevTools, the [`debug`](https://developer.chrome.com/docs/devtools/console/utilities#debug-function) and other console utility function may be helpful.
## Web: Automatic reloading
In some browsers, the web version of the mobile app supports development plugins. Development plugins automatically reload when changed on disk. Non-development plugins must be re-installed when changed.
To add a development plugin:
1. Open the web version of Joplin mobile in a Chromium-based browser.
- Development plugins are loaded with `showOpenFilePicker`. As of early 2025, [only Chrome and several Chromium-based browsers support this API](https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#browser_compatibility).
2. Open Configuration > Plugins > Advanced.
3. Click "Development plugins".
4. Select a plugin folder.
- This folder should contain the `publish`, `dist`, and `src` folders for the plugin.
5. Click "save".
**Note**: Markdown editor plugins may not fully reload unless either the page is reloaded or the Markdown editor is closed and re-opened.
## Android: Inspecting a WebView