1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-06-12 18:27:36 +02:00
Files

312 lines
10 KiB
TypeScript

import { RefObject, useEffect, useMemo, useRef } from 'react';
import shim from '@joplin/lib/shim';
import Setting, { AppType } from '@joplin/lib/models/Setting';
import { Platform } from 'react-native';
import { SetUpResult } from '../types';
import { themeStyle } from '../../components/global-style';
import Logger from '@joplin/utils/Logger';
import { WebViewControl } from '../../components/ExtendedWebView/types';
import { MainProcessApi, OnScrollCallback, RendererControl, RendererProcessApi, RendererWebViewOptions, RenderOptions } from './types';
import { PluginOptions } from '@joplin/renderer/MarkupToHtml';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import useEditPopup from './utils/useEditPopup';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { RenderSettings } from './contentScript/Renderer';
import Resource from '@joplin/lib/models/Resource';
import { ResourceInfos } from '@joplin/renderer/types';
import useContentScripts from './utils/useContentScripts';
import uuid from '@joplin/lib/uuid';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
const logger = Logger.create('renderer/useWebViewSetup');
interface Props {
webviewRef: RefObject<WebViewControl>;
onBodyScroll: OnScrollCallback|null;
onPostMessage: (message: string)=> void;
pluginStates: PluginStates;
themeId: number;
}
const useSource = (tempDirPath: string) => {
const injectedJs = useMemo(() => {
const subValues = Setting.subValues('markdown.plugin', Setting.toPlainObject());
const pluginOptions: PluginOptions = {};
for (const n in subValues) {
const appTypes = Setting.settingMetadata(`markdown.plugin.${n}`).appTypes;
const enabled = !appTypes.includes(AppType.Mobile) ? false : !!subValues[n];
pluginOptions[n] = { enabled };
}
const rendererWebViewStaticOptions: RendererWebViewOptions = {
settings: {
safeMode: Setting.value('isSafeMode'),
tempDir: tempDirPath,
resourceDir: Setting.value('resourceDir'),
resourceDownloadMode: Setting.value('sync.resourceDownloadMode'),
},
// Web needs files to be transferred manually, since image SRCs can't reference
// the Origin Private File System.
useTransferredFiles: Platform.OS === 'web',
pluginOptions,
};
return `
if (!window.rendererJsLoaded) {
window.rendererJsLoaded = true;
${shim.injectedJs('webviewLib')}
${shim.injectedJs('rendererBundle')}
rendererBundle.initialize(${JSON.stringify(rendererWebViewStaticOptions)});
}
`;
}, [tempDirPath]);
return { css: '', injectedJs };
};
const onPostPluginMessage = async (contentScriptId: string, message: unknown) => {
logger.debug(`Handling message from content script: ${contentScriptId}:`, message);
const pluginService = PluginService.instance();
const pluginId = pluginService.pluginIdByContentScriptId(contentScriptId);
if (!pluginId) {
throw new Error(`Plugin not found for content script with ID ${contentScriptId}`);
}
const plugin = pluginService.pluginById(pluginId);
return plugin.emitContentScriptMessage(contentScriptId, message);
};
type UseMessengerProps = Props & { tempDirPath: string };
const useMessenger = (props: UseMessengerProps) => {
const onScrollRef = useRef(props.onBodyScroll);
onScrollRef.current = props.onBodyScroll;
const onPostMessageRef = useRef(props.onPostMessage);
onPostMessageRef.current = props.onPostMessage;
const messenger = useMemo(() => {
const fsDriver = shim.fsDriver();
const localApi: MainProcessApi = {
onScroll: (event) => onScrollRef.current?.(event),
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
onPostPluginMessage,
fsDriver: {
writeFile: async (path: string, content: string, encoding?: string) => {
if (!await fsDriver.exists(props.tempDirPath)) {
await fsDriver.mkdir(props.tempDirPath);
}
// To avoid giving the WebView access to the entire main tempDir,
// we use props.tempDir (which should be different).
path = fsDriver.resolveRelativePathWithinDir(props.tempDirPath, path);
return await fsDriver.writeFile(path, content, encoding);
},
exists: fsDriver.exists,
cacheCssToFile: fsDriver.cacheCssToFile,
},
};
return new RNToWebViewMessenger<MainProcessApi, RendererProcessApi>(
'renderer', props.webviewRef, localApi,
);
}, [props.webviewRef, props.tempDirPath]);
return messenger;
};
const useTempDirPath = () => {
// The renderer can write to whichever temporary directory is chosen here. As such,
// use a subdirectory of the main temporary directory for security reasons.
const tempDirPath = useMemo(() => {
return `${Setting.value('tempDir')}/${uuid.createNano()}`;
}, []);
useEffect(() => {
return () => {
void (async () => {
if (await shim.fsDriver().exists(tempDirPath)) {
await shim.fsDriver().remove(tempDirPath);
}
})();
};
}, [tempDirPath]);
return tempDirPath;
};
type Result = SetUpResult<RendererControl> & {
hasPluginScripts: boolean;
};
const useWebViewSetup = (props: Props): Result => {
const tempDirPath = useTempDirPath();
const { css, injectedJs } = useSource(tempDirPath);
const { editPopupCss, createEditPopupSyntax, destroyEditPopupSyntax } = useEditPopup(props.themeId);
const messenger = useMessenger({ ...props, tempDirPath });
const pluginSettingKeysRef = useRef(new Set<string>());
const contentScripts = useContentScripts(props.pluginStates);
useEffect(() => {
void messenger.remoteApi.renderer.setExtraContentScriptsAndRerender(contentScripts);
}, [messenger, contentScripts]);
const onRerenderRequestRef = useRef(()=>{});
const rendererControl = useMemo((): RendererControl => {
const renderer = messenger.remoteApi.renderer;
const transferResources = async (resources: ResourceInfos) => {
// On web, resources are virtual files and thus need to be transferred to the WebView.
if (shim.mobilePlatform() === 'web') {
for (const [resourceId, resource] of Object.entries(resources)) {
try {
await renderer.setResourceFile(
resourceId,
await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)),
);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// This can happen if a resource hasn't been downloaded yet
logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id);
}
}
}
};
const prepareRenderer = async (options: RenderOptions) => {
const theme = themeStyle(options.themeId);
const loadPluginSettings = () => {
const output: Record<string, unknown> = Object.create(null);
for (const key of pluginSettingKeysRef.current) {
output[key] = Setting.value(`plugin-${key}`);
}
return output;
};
let settingsChanged = false;
const getSettings = (): RenderSettings => ({
...options,
codeTheme: theme.codeThemeCss,
// We .stringify the theme to avoid a JSON serialization error involving
// the color package.
theme: JSON.stringify({
...theme,
...options.themeOverrides,
}),
createEditPopupSyntax,
destroyEditPopupSyntax,
pluginSettings: loadPluginSettings(),
requestPluginSetting: (pluginId: string, settingKey: string) => {
const key = `${pluginId}.${settingKey}`;
if (!pluginSettingKeysRef.current.has(key)) {
pluginSettingKeysRef.current.add(key);
onRerenderRequestRef.current();
settingsChanged = true;
}
},
// Handles plugin asset loading on web (where the WebView can't load assets directly).
readAssetBlob: async (assetPath: string): Promise<Blob> => {
if (assetPath.startsWith('pluginAssets/')) { // Built-in plugin asset
assetPath = assetPath.replace(/^pluginAssets\//, '');
const fullPath = shim.fsDriver().resolveRelativePathWithinDir(
Setting.value('pluginAssetDir'), assetPath,
);
return shim.fsDriver().fileAtPath(fullPath);
} else { // Asset from an installed/development plugin
// User-installed plugins are stored in cacheDir
const allowedBasePaths = [Setting.value('cacheDir')];
// Development plugins are stored in other locations. Add them separately:
if (Setting.value('plugins.devPluginPaths')) {
allowedBasePaths.push(...Setting.value('plugins.devPluginPaths').split(','));
}
for (const basePath of allowedBasePaths) {
const resolved = resolvePathWithinDir(basePath, assetPath);
if (resolved) {
return shim.fsDriver().fileAtPath(resolved);
}
}
throw new Error(`Unable to resolve plugin asset: ${assetPath}`);
}
},
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
showNoteLinkIcon: options.showNoteLinkIcon,
globalSettings: {
'markdown.plugin.abc.options': Setting.value('markdown.plugin.abc.options'),
},
});
await transferResources(options.resources);
return {
getSettings,
getSettingsChanged() {
return settingsChanged;
},
};
};
return {
rerenderToBody: async (markup, options, cancelEvent) => {
const { getSettings } = await prepareRenderer(options);
if (cancelEvent?.cancelled) return null;
const render = async () => {
if (cancelEvent?.cancelled) return;
await renderer.rerenderToBody(markup, getSettings());
};
const queue = new AsyncActionQueue();
onRerenderRequestRef.current = async () => {
queue.push(render);
};
return await render();
},
render: async (markup, options) => {
const { getSettings, getSettingsChanged } = await prepareRenderer(options);
const output = await renderer.render(markup, getSettings());
if (getSettingsChanged()) {
return await renderer.render(markup, getSettings());
}
return output;
},
clearCache: async markupLanguage => {
await renderer.clearCache(markupLanguage);
},
};
}, [createEditPopupSyntax, destroyEditPopupSyntax, messenger]);
const hasPluginScripts = contentScripts.length > 0;
return useMemo(() => {
return {
api: rendererControl,
pageSetup: {
css: `${css} ${editPopupCss}`,
js: injectedJs,
},
webViewEventHandlers: {
onLoadEnd: messenger.onWebViewLoaded,
onMessage: messenger.onWebViewMessage,
},
hasPluginScripts,
};
}, [css, injectedJs, messenger, editPopupCss, rendererControl, hasPluginScripts]);
};
export default useWebViewSetup;