diff --git a/client/desktop/package.json b/client/desktop/package.json index 8b73b324071..013e7124f61 100644 --- a/client/desktop/package.json +++ b/client/desktop/package.json @@ -222,7 +222,7 @@ "electronmon": { "patterns": [ "!**/**", - "src/main/*" + "src/main/**/*" ], "logLevel": "quiet" } diff --git a/client/desktop/src/main/lib/webview-manager.ts b/client/desktop/src/main/lib/webview-manager.ts new file mode 100644 index 00000000000..c3478de3e69 --- /dev/null +++ b/client/desktop/src/main/lib/webview-manager.ts @@ -0,0 +1,186 @@ +/** + * Fork from https://github.com/msgbyte/webbox/blob/main/src/main/webviewManager.ts + */ + +import { BrowserView, BrowserWindow, ipcMain, Rectangle } from 'electron'; +import os from 'os'; +import log from 'electron-log'; + +interface WebviewInfo { + view: BrowserView; + url: string; + hidden: boolean; +} + +const webviewMap = new Map(); + +/** + * fix rect into correct size + */ +function fixRect(rect: Rectangle, isFullScreen: boolean): Rectangle { + const xOffset = 1; + const yOffset = !isFullScreen && os.platform() === 'darwin' ? 28 : 0; // add y axis offset in mac os if is not fullScreen + + return { + x: Math.round(rect.x) + xOffset, + y: Math.round(rect.y) + yOffset, + width: Math.round(rect.width) - xOffset, + height: Math.round(rect.height), + }; +} + +export function initWebviewManager(win: BrowserWindow) { + ipcMain.on('$mount-webview', (e, info) => { + if (!win) { + log.info('[mount-webview]', 'cannot get mainWindow'); + return; + } + + log.info('[mount-webview] info:', info); + + const { key, url } = info; + if (!url) { + return; + } + + if (webviewMap.has(key)) { + const webview = webviewMap.get(key)!; + win.setTopBrowserView(webview.view); + webview.view.setBounds(fixRect(info.rect, win.isFullScreen())); + if (webview.url !== url) { + // url has been change. + webview.view.webContents.loadURL(url); + } + return; + } + + // hideAllWebview(); + const view = new BrowserView({ + webPreferences: { + nodeIntegration: false, + }, + }); + view.setBackgroundColor('#fff'); + view.setBounds(fixRect(info.rect, win.isFullScreen())); + view.webContents.loadURL(url); + win.addBrowserView(view); + webviewMap.set(key, { view, url, hidden: false }); + }); + + ipcMain.on('$unmount-webview', (e, info) => { + if (!win) { + log.info('[unmount-webview]', 'cannot get mainWindow'); + return; + } + + log.info('[unmount-webview] info:', info); + + const { key } = info; + const webview = webviewMap.get(key); + if (webview) { + win.removeBrowserView(webview.view); + webviewMap.delete(key); + } + }); + + ipcMain.on('$update-webview-rect', (e, info) => { + if (!win) { + log.info('[update-webview-rect]', 'cannot get mainWindow'); + return; + } + + log.info('[update-webview-rect] info:', info); + + // Change All View to avoid under view display on resize. + // webviewMap.forEach((webview) => { + // webview.hidden = false; + // webview.view.setBounds(fixRect(info.rect, win.isFullScreen())); + // }); + + // Change Single View + const webview = webviewMap.get(info.key); + if (webview) { + webview.hidden = false; + webview.view.setBounds(fixRect(info.rect, win.isFullScreen())); + } + }); + + ipcMain.on('$show-webview', (e, info) => { + log.info('[show-webview] info:', info); + + const webview = webviewMap.get(info.key); + if (webview) { + showWebView(webview); + } + }); + + ipcMain.on('$hide-webview', (e, info) => { + log.info('[hide-webview] info:', info); + + const webview = webviewMap.get(info.key); + if (webview) { + hideWebView(webview); + } + }); + + ipcMain.on('$hide-all-webview', () => { + log.info('[hide-all-webview]'); + + hideAllWebview(); + }); + + ipcMain.on('$clear-all-webview', () => { + if (!win) { + log.info('[clear-all-webview]', 'cannot get mainWindow'); + return; + } + + log.info('[clear-all-webview]'); + + win.getBrowserViews().forEach((view) => { + win.removeBrowserView(view); + }); + + webviewMap.clear(); + }); +} + +const HIDDEN_OFFSET = 3000; + +/** + * Show webview with remove offset in y + */ +function showWebView(webview: WebviewInfo) { + if (webview.hidden === false) { + return; + } + + webview.hidden = false; + const oldBounds = webview.view.getBounds(); + webview.view.setBounds({ + ...oldBounds, + y: oldBounds.y - HIDDEN_OFFSET, + }); +} + +/** + * Hide webview with append offset in y + */ +function hideWebView(webview: WebviewInfo) { + if (webview.hidden === true) { + return; + } + + webview.hidden = true; + const oldBounds = webview.view.getBounds(); + webview.view.setBounds({ + ...oldBounds, + y: oldBounds.y + HIDDEN_OFFSET, + }); +} + +function hideAllWebview() { + Array.from(webviewMap.values()).forEach((webview) => { + hideWebView(webview); + }); +} diff --git a/client/desktop/src/main/main.ts b/client/desktop/src/main/main.ts index 8a7f3d0cfdb..db64c50edf0 100644 --- a/client/desktop/src/main/main.ts +++ b/client/desktop/src/main/main.ts @@ -26,6 +26,7 @@ import is from 'electron-is'; import { initScreenshots } from './screenshots'; import { generateInjectedScript } from './inject'; import { handleTailchatMessage } from './inject/message-handler'; +import { initWebviewManager } from './lib/webview-manager'; log.info('Start...'); @@ -277,6 +278,8 @@ const createMainWindow = async (url: string) => { mainWindowState.manage(mainWindow); + initWebviewManager(mainWindow); + // Remove this if your app does not use auto updates new AppUpdater(); } catch (err) { diff --git a/client/web/plugins/com.msgbyte.env.electron/src/ElectronWebview.tsx b/client/web/plugins/com.msgbyte.env.electron/src/ElectronWebview.tsx new file mode 100644 index 00000000000..ed371e6e78f --- /dev/null +++ b/client/web/plugins/com.msgbyte.env.electron/src/ElectronWebview.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useRef } from 'react'; + +interface ElectronWebviewProps { + className?: string; + src: string; +} +export const ElectronWebview: React.FC = React.memo( + (props) => { + const containerRef = useRef(null); + const key = props.src; + const url = props.src; + + useEffect(() => { + if (!containerRef.current) { + return; + } + + const rect = containerRef.current.getBoundingClientRect(); + + (window as any).electron.ipcRenderer.sendMessage('$mount-webview', { + key, + url, + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + }); + + return () => { + (window as any).electron.ipcRenderer.sendMessage('$unmount-webview', { + key, + }); + }; + }, [key, url]); + + useEffect(() => { + if (!containerRef.current) { + return; + } + + const intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry: any) => { + if (entry.isVisible === true) { + // 完全可见,显示 + (window as any).electron.ipcRenderer.sendMessage( + '$show-webview', + { + key: key, + } + ); + } else { + (window as any).electron.ipcRenderer.sendMessage( + '$hide-webview', + { + key: key, + } + ); + } + }); + }, + { + trackVisibility: true, + delay: 200, + } as any + ); + + const resizeObserver = new ResizeObserver((entries) => { + entries.forEach((entry) => { + const { target } = entry; + if (!target.parentElement) { + return; + } + + const rect = target.getBoundingClientRect(); + + (window as any).electron.ipcRenderer.sendMessage( + '$update-webview-rect', + { + key: key, + rect: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + } + ); + }); + }); + + intersectionObserver.observe(containerRef.current); + resizeObserver.observe(containerRef.current); + + return () => { + if (containerRef.current) { + intersectionObserver.unobserve(containerRef.current); + resizeObserver.unobserve(containerRef.current); + } + }; + }, [key]); + + return ( +
+ ); + } +); +ElectronWebview.displayName = 'ElectronWebview'; diff --git a/client/web/plugins/com.msgbyte.env.electron/src/index.tsx b/client/web/plugins/com.msgbyte.env.electron/src/index.tsx index 287e9f3377b..ba74b186f3c 100644 --- a/client/web/plugins/com.msgbyte.env.electron/src/index.tsx +++ b/client/web/plugins/com.msgbyte.env.electron/src/index.tsx @@ -2,6 +2,9 @@ import { regCustomPanel, regChatInputButton, postMessageEvent, + sharedEvent, + regPluginSettings, + getCachedUserSettings, } from '@capital/common'; import { Icon } from '@capital/component'; import React from 'react'; @@ -9,8 +12,12 @@ import { DeviceInfoPanel } from './DeviceInfoPanel'; import { Translate } from './translate'; import { forwardSharedEvent } from './utils'; import { checkUpdate } from './checkUpdate'; +import { setWebviewKernel, resetWebviewKernel } from '@capital/common'; +import { ElectronWebview } from './ElectronWebview'; +import './overwrite.css'; const PLUGIN_NAME = 'Electron Support'; +const WEBVIEW_CONFIG = 'electron:nativeWebviewRender'; console.log(`Plugin ${PLUGIN_NAME} is loaded`); @@ -37,12 +44,42 @@ regChatInputButton({ }, }); +regPluginSettings({ + position: 'system', + type: 'boolean', + name: WEBVIEW_CONFIG, + label: Translate.nativeWebviewRender, + desc: Translate.nativeWebviewRenderDesc, +}); + forwardSharedEvent('receiveUnmutedMessage'); setTimeout(() => { checkUpdate(); }, 1000); +let changedWithElectron = false; + +const checkSettingConfig = (settings: Record) => { + if (settings[WEBVIEW_CONFIG] === true) { + setWebviewKernel(() => ElectronWebview); + changedWithElectron = true; + } else if (changedWithElectron === true) { + // 如果关闭了配置且仅当之前用electron设置了webview,则重置 + resetWebviewKernel(); + } +}; + +sharedEvent.on('loginSuccess', () => { + getCachedUserSettings().then((settings) => { + checkSettingConfig(settings); + }); +}); + +sharedEvent.on('userSettingsUpdate', (settings) => { + checkSettingConfig(settings); +}); + navigator.mediaDevices.getDisplayMedia = async ( options: DisplayMediaStreamOptions ) => { diff --git a/client/web/plugins/com.msgbyte.env.electron/src/overwrite.css b/client/web/plugins/com.msgbyte.env.electron/src/overwrite.css new file mode 100644 index 00000000000..de632ec7af0 --- /dev/null +++ b/client/web/plugins/com.msgbyte.env.electron/src/overwrite.css @@ -0,0 +1,3 @@ +.ant-dropdown-menu { + box-shadow: none; /* avoid group detail dropdown's shadow will make dom invisiable */ +} diff --git a/client/web/plugins/com.msgbyte.env.electron/src/translate.ts b/client/web/plugins/com.msgbyte.env.electron/src/translate.ts index 9325004efec..1bd1f901550 100644 --- a/client/web/plugins/com.msgbyte.env.electron/src/translate.ts +++ b/client/web/plugins/com.msgbyte.env.electron/src/translate.ts @@ -37,4 +37,13 @@ export const Translate = { 'zh-CN': '已经是最新版', 'en-US': 'Already the latest version', }), + nativeWebviewRender: localTrans({ + 'zh-CN': '启用原生浏览器内核渲染', + 'en-US': 'Use Native Webview Render', + }), + nativeWebviewRenderDesc: localTrans({ + 'zh-CN': '解除默认网页访问限制,允许在Tailchat嵌入任意网站内容', + 'en-US': + 'Lift default web page access restrictions and allow any website content to be embedded in Tailchat', + }), }; diff --git a/client/web/src/components/Webview.tsx b/client/web/src/components/Webview.tsx index 38454337a2e..f497257292f 100644 --- a/client/web/src/components/Webview.tsx +++ b/client/web/src/components/Webview.tsx @@ -40,11 +40,11 @@ const DefaultWebviewKernel: React.FC = React.memo( ); DefaultWebviewKernel.displayName = 'DefaultWebviewKernel'; -const [getWebviewKernel, setWebviewKernel] = buildRegFn< +const [getWebviewKernel, setWebviewKernel, resetWebviewKernel] = buildRegFn< () => React.ComponentType >('webviewKernelComponent', () => DefaultWebviewKernel); -export { setWebviewKernel }; +export { setWebviewKernel, resetWebviewKernel }; interface WebviewProps { className?: string; diff --git a/client/web/src/plugin/common/index.ts b/client/web/src/plugin/common/index.ts index a95c5da226e..5617e61b62f 100644 --- a/client/web/src/plugin/common/index.ts +++ b/client/web/src/plugin/common/index.ts @@ -74,6 +74,7 @@ export { useUpdateRef, isDevelopment, } from 'tailchat-shared'; +export { setWebviewKernel, resetWebviewKernel } from '@/components/Webview'; export { navigate } from '@/components/AppRouterApi'; export { useLocation, useNavigate } from 'react-router'; diff --git a/client/web/src/plugin/common/reg.ts b/client/web/src/plugin/common/reg.ts index c4b1f20d542..3302010bdbd 100644 --- a/client/web/src/plugin/common/reg.ts +++ b/client/web/src/plugin/common/reg.ts @@ -299,7 +299,7 @@ interface PluginUserExtraInfo { export const [pluginUserExtraInfo, regUserExtraInfo] = buildRegList(); -type PluginSettings = FullModalFactoryConfig & { +export type PluginSettings = FullModalFactoryConfig & { position: 'system'; // 后面可能还会有个人设置/群组设置 };