1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-23 22:36:32 +02:00
Files
joplin/packages/app-mobile/contentScripts/rendererBundle/useWebViewSetup.ts

279 lines
9.2 KiB
TypeScript
Raw Normal View History

import { RefObject, useEffect, useMemo, useRef } from 'react';
import shim from '@joplin/lib/shim';
import Setting 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 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 resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
import Resource from '@joplin/lib/models/Resource';
import { ResourceInfos } from '@joplin/renderer/types';
import useContentScripts from './utils/useContentScripts';
import uuid from '@joplin/lib/uuid';
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());
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const pluginOptions: any = {};
for (const n in subValues) {
pluginOptions[n] = { enabled: subValues[n] };
}
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 = {
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
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;
};
const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
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 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 settings: 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);
settingsChanged = true;
}
},
readAssetBlob: (assetPath: string): Promise<Blob> => {
// Built-in assets are in resourceDir, external plugin assets are in cacheDir.
const assetsDirs = [Setting.value('resourceDir'), Setting.value('cacheDir')];
let resolvedPath = null;
for (const assetDir of assetsDirs) {
resolvedPath ??= resolvePathWithinDir(assetDir, assetPath);
if (resolvedPath) break;
}
if (!resolvedPath) {
throw new Error(`Failed to load asset at ${assetPath} -- not in any of the allowed asset directories: ${assetsDirs.join(',')}.`);
}
return shim.fsDriver().fileAtPath(resolvedPath);
},
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
};
await transferResources(options.resources);
return {
settings,
getSettingsChanged() {
return settingsChanged;
},
};
};
return {
rerenderToBody: async (markup, options, cancelEvent) => {
const { settings, getSettingsChanged } = await prepareRenderer(options);
if (cancelEvent?.cancelled) return null;
const output = await renderer.rerenderToBody(markup, settings);
if (cancelEvent?.cancelled) return null;
if (getSettingsChanged()) {
return await renderer.rerenderToBody(markup, settings);
}
return output;
},
render: async (markup, options) => {
const { settings, getSettingsChanged } = await prepareRenderer(options);
const output = await renderer.render(markup, settings);
if (getSettingsChanged()) {
return await renderer.render(markup, settings);
}
return output;
},
clearCache: async markupLanguage => {
await renderer.clearCache(markupLanguage);
},
};
}, [createEditPopupSyntax, destroyEditPopupSyntax, messenger]);
return useMemo(() => {
return {
api: rendererControl,
pageSetup: {
css: `${css} ${editPopupCss}`,
js: injectedJs,
},
webViewEventHandlers: {
onLoadEnd: messenger.onWebViewLoaded,
onMessage: messenger.onWebViewMessage,
},
};
}, [css, injectedJs, messenger, editPopupCss, rendererControl]);
};
export default useWebViewSetup;