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; 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( '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 & { 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()); const contentScripts = useContentScripts(props.pluginStates); useEffect(() => { const startTime = Date.now(); const totalBytes = contentScripts.reduce((sum, s) => sum + s.js.length, 0); logger.info(`[perf] setExtraContentScriptsAndRerender: sending ${contentScripts.length} script(s), ${Math.round(totalBytes / 1024)} KiB`); void (async () => { try { const result = await messenger.remoteApi.renderer.setExtraContentScriptsAndRerender(contentScripts); const totalMs = Date.now() - startTime; const evalMs = result?.evalMs ?? -1; const renderMs = result?.renderMs ?? -1; const bridge = totalMs - evalMs - renderMs; logger.info(`[perf] setExtraContentScriptsAndRerender: round-trip ${totalMs} ms (eval ${evalMs} ms, rerender ${renderMs} ms, bridge ~${bridge} ms)`); } catch (error) { logger.error(`[perf] setExtraContentScriptsAndRerender FAILED after ${Date.now() - startTime} ms:`, error); } })(); }, [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 = 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 => { 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;