From cce0d954a3e27153567d2a6f4dc722fd779fc9cf Mon Sep 17 00:00:00 2001 From: axb Date: Tue, 11 Aug 2026 14:01:33 +0800 Subject: [PATCH 1/2] fix(wework): let app shell follow viewport --- .../developer-guide/wework-macos-release.md | 9 +++ .../developer-guide/wework-macos-release.md | 6 ++ wework/src/App.plugins.test.tsx | 3 + wework/src/App.tsx | 11 --- wework/src/App.viewport.test.tsx | 73 ------------------ wework/src/hooks/useTauriViewportSize.ts | 77 ------------------- 6 files changed, 18 insertions(+), 161 deletions(-) delete mode 100644 wework/src/App.viewport.test.tsx delete mode 100644 wework/src/hooks/useTauriViewportSize.ts diff --git a/docs/en/wework/developer-guide/wework-macos-release.md b/docs/en/wework/developer-guide/wework-macos-release.md index c9650ac3d6..a8c7537a3c 100644 --- a/docs/en/wework/developer-guide/wework-macos-release.md +++ b/docs/en/wework/developer-guide/wework-macos-release.md @@ -104,6 +104,15 @@ Transparent window edges are composed differently across macOS versions and graphics environments, which can expose the desktop or appear as translucent or gray borders. +The root shell in the main React WebView must fill the current WebView through +CSS viewport constraints such as `fixed inset-0`. Do not listen for native +Tauri resize events and write asynchronously read and converted `innerSize` and +`scaleFactor` values back as inline root-shell dimensions. Native events, scale +factors, and WebView layout commits can arrive out of order, allowing a stale +inline size to override the current viewport and leave an unfilled region after +a resize. Native window dimensions are for window creation, restoration, and +system-level window management, not as a source for the React root layout. + The system drag panel and Popout Window are separate lightweight overlays and are not covered by this rule. Changes to ordinary window backgrounds, title bars, or creation options must verify both the main window and detached diff --git a/docs/zh/wework/developer-guide/wework-macos-release.md b/docs/zh/wework/developer-guide/wework-macos-release.md index 1a66bc31ff..defeb7845d 100644 --- a/docs/zh/wework/developer-guide/wework-macos-release.md +++ b/docs/zh/wework/developer-guide/wework-macos-release.md @@ -100,6 +100,12 @@ Wework 主窗口和独立工作区窗口必须使用不透明的 Tauri 窗口, 或原生 vibrancy 材质;不同 macOS 版本和图形环境对透明窗口边缘的合成结果不一致, 可能显示为透出桌面、半透明描边或灰色边框。 +主 React WebView 的根壳必须直接使用 CSS viewport 约束(例如 `fixed inset-0`) +填满当前 WebView。不要监听 Tauri 原生 resize 事件,再把异步读取和换算后的 +`innerSize`、`scaleFactor` 写成根壳的内联宽高;原生事件、缩放因子和 WebView +布局提交可能乱序,旧的内联尺寸会覆盖当前 viewport,并在窗口调整后留下未填充区域。 +原生窗口尺寸只用于窗口创建、恢复和系统级窗口管理,不作为 React 根布局的尺寸来源。 + 系统拖拽面板和 Popout Window 是独立的轻量浮层,不受该约束。修改普通窗口的背景、 标题栏或创建参数时,需要同时验证主窗口和独立工作区窗口,并保留自动化断言,确保 两者不会重新启用原生透明效果。 diff --git a/wework/src/App.plugins.test.tsx b/wework/src/App.plugins.test.tsx index f621d2578f..20d660182f 100644 --- a/wework/src/App.plugins.test.tsx +++ b/wework/src/App.plugins.test.tsx @@ -1737,6 +1737,9 @@ describe('App plugins route', () => { expect(screen.getByTestId('macos-traffic-light-spacer')).toBeInTheDocument() expect(screen.getByTestId('plugins-page-content')).toHaveClass('md:pl-6') expect(screen.getByTestId('plugins-page-content').style.paddingLeft).toBe('') + expect(screen.getByTestId('app-shell')).toHaveClass('fixed', 'inset-0') + expect(screen.getByTestId('app-shell').style.width).toBe('') + expect(screen.getByTestId('app-shell').style.height).toBe('') }) test('uses the mobile shell for plugins route at the shared mobile breakpoint', async () => { diff --git a/wework/src/App.tsx b/wework/src/App.tsx index 83c45dd849..62898a104b 100644 --- a/wework/src/App.tsx +++ b/wework/src/App.tsx @@ -89,7 +89,6 @@ import { TelemetryBridge } from '@/telemetry/TelemetryBridge' import { track, useTelemetryEnabled } from '@/telemetry/client' import { WorkspaceTabPortalOwner } from '@/components/topnav/TitlebarActionsPortal' import { setActiveWorkspaceTabPortalOwner } from '@/components/topnav/workspaceTabPortalOwnership' -import { useTauriViewportSize } from '@/hooks/useTauriViewportSize' const WORKBENCH_STARTUP_REVEAL_TIMEOUT_MS = 6000 const POPOUT_WINDOW_LABEL = 'popout-window' @@ -450,7 +449,6 @@ function AppShell() { } const { activeAppKey, navigateToApp } = useChromeTabs(path) const isTauri = isTauriRuntime() - const tauriViewportSize = useTauriViewportSize(isTauri) const isPopoutWindow = isPopoutWindowRuntime() const isWorkspaceWindow = isTauri && getCurrentWindow().label?.startsWith('workspace-') === true const titlebarOverlaysContent = false @@ -666,7 +664,6 @@ function AppShell() { >
{showChromeTitlebar && ( ({ - width: width / scaleFactor, - height: height / scaleFactor, - }), - } -} - -const windowMocks = vi.hoisted(() => ({ - innerSize: vi.fn(), - scaleFactor: vi.fn(), - onResized: vi.fn(), - onScaleChanged: vi.fn(), - resizeHandler: null as ((event: { payload: ReturnType }) => void) | null, -})) - -vi.mock('@tauri-apps/api/window', () => ({ - getCurrentWindow: () => ({ - innerSize: windowMocks.innerSize, - scaleFactor: windowMocks.scaleFactor, - onResized: windowMocks.onResized, - onScaleChanged: windowMocks.onScaleChanged, - }), -})) - -beforeEach(() => { - windowMocks.resizeHandler = null - windowMocks.innerSize.mockReset().mockResolvedValue(physicalSize(1280, 720)) - windowMocks.scaleFactor.mockReset().mockResolvedValue(1) - windowMocks.onResized.mockReset().mockImplementation(async handler => { - windowMocks.resizeHandler = handler - return vi.fn() - }) - windowMocks.onScaleChanged.mockReset().mockResolvedValue(vi.fn()) -}) - -test('keeps the latest native viewport size when resize conversions finish out of order', async () => { - const { result } = renderHook(() => useTauriViewportSize(true)) - - await waitFor(() => { - expect(windowMocks.resizeHandler).not.toBeNull() - expect(result.current).toEqual({ width: 1280, height: 720 }) - }) - - let resolveStaleScaleFactor: ((scaleFactor: number) => void) | undefined - const staleScaleFactor = new Promise(resolve => { - resolveStaleScaleFactor = resolve - }) - windowMocks.scaleFactor.mockReturnValueOnce(staleScaleFactor).mockResolvedValueOnce(1) - - act(() => { - windowMocks.resizeHandler?.({ payload: physicalSize(1504, 929) }) - windowMocks.resizeHandler?.({ payload: physicalSize(1728, 1084) }) - }) - - await waitFor(() => { - expect(result.current).toEqual({ width: 1728, height: 1084 }) - }) - - await act(async () => { - resolveStaleScaleFactor?.(1) - await staleScaleFactor - }) - - expect(result.current).toEqual({ width: 1728, height: 1084 }) -}) diff --git a/wework/src/hooks/useTauriViewportSize.ts b/wework/src/hooks/useTauriViewportSize.ts deleted file mode 100644 index 2bb003fd38..0000000000 --- a/wework/src/hooks/useTauriViewportSize.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { getCurrentWindow } from '@tauri-apps/api/window' -import { useEffect, useState } from 'react' - -interface ViewportSize { - width: number - height: number -} - -export function useTauriViewportSize(isTauri: boolean): ViewportSize | null { - const [viewportSize, setViewportSize] = useState(() => { - if (!isTauri || window.innerWidth <= 0 || window.innerHeight <= 0) return null - return { width: window.innerWidth, height: window.innerHeight } - }) - - useEffect(() => { - if (!isTauri) return undefined - - const appWindow = getCurrentWindow() - let disposed = false - let latestUpdate = 0 - let unlistenResize: (() => void) | undefined - let unlistenScale: (() => void) | undefined - - const applyPhysicalSize = async ( - physicalSize: - | Awaited> - | ReturnType, - scaleFactor: number | ReturnType - ) => { - const update = ++latestUpdate - const [resolvedSize, resolvedScaleFactor] = await Promise.all([physicalSize, scaleFactor]) - if (disposed || update !== latestUpdate) return - - const logicalSize = resolvedSize.toLogical(resolvedScaleFactor) - if (logicalSize.width <= 0 || logicalSize.height <= 0) return - setViewportSize(current => - current?.width === logicalSize.width && current.height === logicalSize.height - ? current - : { width: logicalSize.width, height: logicalSize.height } - ) - } - - void Promise.all([ - appWindow.onResized(({ payload }) => { - void applyPhysicalSize(payload, appWindow.scaleFactor()).catch(error => { - console.error('[Wework] Failed to update the Tauri viewport size:', error) - }) - }), - appWindow.onScaleChanged(({ payload }) => { - void applyPhysicalSize(payload.size, payload.scaleFactor) - }), - ]) - .then(([unlistenResizeFn, unlistenScaleFn]) => { - if (disposed) { - unlistenResizeFn() - unlistenScaleFn() - return - } - unlistenResize = unlistenResizeFn - unlistenScale = unlistenScaleFn - void applyPhysicalSize(appWindow.innerSize(), appWindow.scaleFactor()).catch(error => { - console.error('[Wework] Failed to read the Tauri viewport size:', error) - }) - }) - .catch(error => { - console.error('[Wework] Failed to listen for Tauri viewport changes:', error) - }) - - return () => { - disposed = true - unlistenResize?.() - unlistenScale?.() - } - }, [isTauri]) - - return viewportSize -} From ea95899a10ebf587fbee1fe411a5b8437fc896fb Mon Sep 17 00:00:00 2001 From: axb Date: Tue, 11 Aug 2026 14:44:42 +0800 Subject: [PATCH 2/2] fix(wework): isolate Linux browser overlays --- .../developer-guide/wework-macos-release.md | 3 + .../developer-guide/wework-macos-release.md | 2 + wework/src-tauri/src/embedded_browser.rs | 36 +++++- .../src/embedded_browser/linux_host.rs | 117 ++++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 wework/src-tauri/src/embedded_browser/linux_host.rs diff --git a/docs/en/wework/developer-guide/wework-macos-release.md b/docs/en/wework/developer-guide/wework-macos-release.md index a8c7537a3c..0d0f15d98e 100644 --- a/docs/en/wework/developer-guide/wework-macos-release.md +++ b/docs/en/wework/developer-guide/wework-macos-release.md @@ -112,6 +112,9 @@ factors, and WebView layout commits can arrive out of order, allowing a stale inline size to override the current viewport and leave an unfilled region after a resize. Native window dimensions are for window creation, restoration, and system-level window management, not as a source for the React root layout. +On Linux, embedded browser child WebViews must live in an absolute host built +from `GtkOverlay` and `GtkFixed`; adding them directly to the main window's +`GtkBox` lets them participate in layout and compress the main React WebView. The system drag panel and Popout Window are separate lightweight overlays and are not covered by this rule. Changes to ordinary window backgrounds, title diff --git a/docs/zh/wework/developer-guide/wework-macos-release.md b/docs/zh/wework/developer-guide/wework-macos-release.md index defeb7845d..5740d50dad 100644 --- a/docs/zh/wework/developer-guide/wework-macos-release.md +++ b/docs/zh/wework/developer-guide/wework-macos-release.md @@ -105,6 +105,8 @@ Wework 主窗口和独立工作区窗口必须使用不透明的 Tauri 窗口, `innerSize`、`scaleFactor` 写成根壳的内联宽高;原生事件、缩放因子和 WebView 布局提交可能乱序,旧的内联尺寸会覆盖当前 viewport,并在窗口调整后留下未填充区域。 原生窗口尺寸只用于窗口创建、恢复和系统级窗口管理,不作为 React 根布局的尺寸来源。 +Linux 上的内置浏览器子 WebView 必须放在 `GtkOverlay` 和 `GtkFixed` 组成的绝对定位 +宿主中,不能直接加入主窗口的 `GtkBox` 参与布局,否则会挤压主 React WebView。 系统拖拽面板和 Popout Window 是独立的轻量浮层,不受该约束。修改普通窗口的背景、 标题栏或创建参数时,需要同时验证主窗口和独立工作区窗口,并保留自动化断言,确保 diff --git a/wework/src-tauri/src/embedded_browser.rs b/wework/src-tauri/src/embedded_browser.rs index 6ccc4c0703..f9703a79fe 100644 --- a/wework/src-tauri/src/embedded_browser.rs +++ b/wework/src-tauri/src/embedded_browser.rs @@ -14,17 +14,21 @@ use serde_json::{json, Value}; use tauri::{ async_runtime::Mutex as AsyncMutex, webview::{DownloadEvent, NewWindowResponse, PageLoadEvent}, - Emitter, LogicalPosition, LogicalSize, Manager, Position, Rect, Size, Webview, WebviewUrl, Wry, + Emitter, LogicalPosition, LogicalSize, Manager, Webview, WebviewUrl, Wry, }; #[cfg(target_os = "macos")] use objc2_web_kit::WKWebView; +#[cfg(not(target_os = "linux"))] +use tauri::{Position, Rect, Size}; mod agent_control; mod bridge_security; mod bridge_server; mod browser_runtime; mod data_clearing; +#[cfg(target_os = "linux")] +mod linux_host; mod local_file_preview; mod popup; mod screenshot; @@ -328,6 +332,7 @@ struct NormalizedBounds { } impl NormalizedBounds { + #[cfg(not(target_os = "linux"))] fn rect(&self) -> Rect { Rect { position: Position::Logical(self.position), @@ -343,6 +348,12 @@ fn normalize_bounds(bounds: EmbeddedBrowserBounds) -> NormalizedBounds { } } +#[cfg(target_os = "linux")] +fn apply_webview_bounds(webview: &Webview, bounds: NormalizedBounds) -> Result<(), String> { + linux_host::apply_bounds(webview, bounds.position, bounds.size) +} + +#[cfg(not(target_os = "linux"))] fn apply_webview_bounds(webview: &Webview, bounds: NormalizedBounds) -> Result<(), String> { webview .set_bounds(bounds.rect()) @@ -1941,6 +1952,29 @@ pub async fn embedded_browser_open( } }; + #[cfg(target_os = "linux")] + if let Err(error) = apply_webview_bounds(&webview, normalized_bounds) { + log_embedded_browser_diagnostic( + &state, + &label, + "open_host_failed", + json!({ + "nativeLabel": &native_label, + "error": &error, + }), + ); + if let Ok(mut webviews) = state.webviews.lock() { + remove_logical_entry_if_native_matches( + &mut webviews, + &label, + &native_label, + |current| current.native_label.as_str(), + ); + } + let _ = webview.close(); + return Err(error); + } + log_embedded_browser_diagnostic( &state, &label, diff --git a/wework/src-tauri/src/embedded_browser/linux_host.rs b/wework/src-tauri/src/embedded_browser/linux_host.rs new file mode 100644 index 0000000000..5bdbe015de --- /dev/null +++ b/wework/src-tauri/src/embedded_browser/linux_host.rs @@ -0,0 +1,117 @@ +use std::time::Duration; + +use gtk::prelude::*; +use tauri::{LogicalPosition, LogicalSize, Webview, Wry}; + +const HOST_NAME: &str = "wework-embedded-browser-host"; + +fn find_host(container: >k::Box) -> Option { + container.children().into_iter().find_map(|child| { + let overlay = child.downcast::().ok()?; + overlay.children().into_iter().find_map(|overlay_child| { + if overlay_child.widget_name() != HOST_NAME { + return None; + } + overlay_child.downcast::().ok() + }) + }) +} + +fn create_host( + container: >k::Box, + embedded_webview: &webkit2gtk::WebView, +) -> Result { + let embedded_widget = embedded_webview.clone().upcast::(); + let (main_webview_index, main_webview) = container + .children() + .into_iter() + .enumerate() + .find_map(|(index, child)| { + if child == embedded_widget { + return None; + } + child + .downcast::() + .ok() + .map(|webview| (index, webview)) + }) + .ok_or_else(|| "Main webview was not found in the GTK window container".to_string())?; + + container.remove(&main_webview); + + let overlay = gtk::Overlay::new(); + overlay.set_hexpand(true); + overlay.set_vexpand(true); + overlay.add(&main_webview); + + let host = gtk::Fixed::new(); + host.set_widget_name(HOST_NAME); + host.set_hexpand(true); + host.set_vexpand(true); + host.set_halign(gtk::Align::Fill); + host.set_valign(gtk::Align::Fill); + overlay.add_overlay(&host); + + container.pack_start(&overlay, true, true, 0); + container.reorder_child(&overlay, main_webview_index as i32); + main_webview.show(); + host.show(); + overlay.show(); + + Ok(host) +} + +fn place_webview( + embedded_webview: webkit2gtk::WebView, + position: LogicalPosition, + size: LogicalSize, +) -> Result<(), String> { + let host = match embedded_webview + .parent() + .and_then(|parent| parent.downcast::().ok()) + { + Some(host) => host, + None => { + let container = embedded_webview + .parent() + .and_then(|parent| parent.downcast::().ok()) + .ok_or_else(|| { + "Embedded browser webview is not attached to the GTK window container" + .to_string() + })?; + let host = find_host(&container) + .map(Ok) + .unwrap_or_else(|| create_host(&container, &embedded_webview))?; + container.remove(&embedded_webview); + host.put(&embedded_webview, 0, 0); + host + } + }; + + embedded_webview.set_size_request(size.width.round() as i32, size.height.round() as i32); + host.move_( + &embedded_webview, + position.x.round() as i32, + position.y.round() as i32, + ); + Ok(()) +} + +pub fn apply_bounds( + webview: &Webview, + position: LogicalPosition, + size: LogicalSize, +) -> Result<(), String> { + // Tauri packs Linux child webviews into the main GtkBox, so their requested + // overlay bounds are ignored and they can collapse the app webview. Move + // them into an absolute overlay host before exposing them to the frontend. + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + webview + .with_webview(move |platform_webview| { + let _ = sender.send(place_webview(platform_webview.inner(), position, size)); + }) + .map_err(|error| format!("Failed to access embedded browser webview: {error}"))?; + receiver + .recv_timeout(Duration::from_secs(5)) + .map_err(|error| format!("Timed out positioning embedded browser webview: {error}"))? +}