Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Draft
wants to merge 9 commits into
base: dev
Choose a base branch
from
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,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
Expand Down
20 changes: 18 additions & 2 deletions packages/app-mobile/components/plugins/PluginRunnerWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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,
Expand All @@ -56,7 +70,7 @@ const usePlugins = (
if (!event.cancelled) {
reloadAllRef.current = false;
}
}, [pluginRunner, store, webviewLoaded, pluginSettings]);
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter]);
};

const useUnloadPluginsOnGlobalDisable = (
Expand All @@ -79,6 +93,7 @@ interface Props {
serializedPluginSettings: SerializedPluginSettings;
pluginSupportEnabled: boolean;
pluginStates: PluginStates;
devPluginPath: string;
pluginHtmlContents: PluginHtmlContents;
themeId: number;
}
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -23,63 +28,107 @@ 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');
}
} catch (e) {
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;
Loading
Loading