From 7cb29ebb5d6c1a7a1edb196fd4feebc38e87e820 Mon Sep 17 00:00:00 2001 From: SWangHash <88996709+SWangHash@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:26:34 +0800 Subject: [PATCH 1/4] feat(ohos): bridge toolbar mode to native window APIs --- src/apps/desktop/src/api/ohos/window.rs | 285 ++++++++++++++++++ src/apps/desktop/src/lib.rs | 25 +- .../main/ets/entryability/EntryAbility.ets | 168 ++++++++++- src/apps/ohos/entry/src/main/module.json5 | 10 + .../main/resources/base/element/string.json | 4 + .../main/resources/zh_CN/element/string.json | 4 + .../components/toolbar-mode/ToolbarMode.tsx | 12 +- .../toolbar-mode/ToolbarModeProvider.tsx | 130 ++++---- .../toolbar-mode/toolbarModeWindowOps.ts | 210 +++++++++++++ .../api/service-api/WorkspaceAPI.ts | 124 ++++++++ 10 files changed, 895 insertions(+), 77 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/toolbar-mode/toolbarModeWindowOps.ts diff --git a/src/apps/desktop/src/api/ohos/window.rs b/src/apps/desktop/src/api/ohos/window.rs index 8cc7e2c7a3..5b38d1a991 100644 --- a/src/apps/desktop/src/api/ohos/window.rs +++ b/src/apps/desktop/src/api/ohos/window.rs @@ -109,4 +109,289 @@ pub async fn window_is_maximized() -> Result { } Err(err) => Err(err.to_string()), } +} + +#[tauri::command] +pub async fn set_always_on_top_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_always_on_top_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_decorations_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_decorations_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_skip_taskbar_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_skip_taskbar_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_window_size_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_window_size_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_window_position_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_window_position_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn outer_position_ohos() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("outer_position_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(result) => Ok(result), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn outer_size_ohos() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("outer_size_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(result) => Ok(result), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn inner_size_ohos() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("inner_size_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(result) => Ok(result), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn current_monitor_ohos() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("current_monitor_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(result) => Ok(result), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn unmaximize_ohos() -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("unmaximize_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_min_size_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_min_size_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_focus_ohos() -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_focus_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_resizable_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_resizable_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn maximize_ohos() -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("maximize_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn center_ohos() -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("center_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } } \ No newline at end of file diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 9405ef9614..7f8f9a5504 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -59,8 +59,11 @@ pub use api::*; use crate::ohos::ohos_file_system::{open_oh_file_dialog, set_theme_mode}; use crate::ohos::window::{ - close_window, handle_max_window, handle_min_window, handle_restore_window, window_is_maximized, - window_is_minimized, window_start_dragging, + center_ohos, close_window, current_monitor_ohos, handle_max_window, handle_min_window, + handle_restore_window, inner_size_ohos, maximize_ohos, outer_position_ohos, outer_size_ohos, + set_always_on_top_ohos, set_decorations_ohos, set_focus_ohos, set_min_size_ohos, + set_resizable_ohos, set_skip_taskbar_ohos, set_window_position_ohos, set_window_size_ohos, + unmaximize_ohos, window_is_maximized, window_is_minimized, window_start_dragging, }; use api::acp_client_api::*; use api::clipboard_file_api::*; @@ -342,7 +345,8 @@ fn persist_main_window_state(app: &tauri::AppHandle) -> Result<(), String> { #[cfg(target_env = "ohos")] { - Err("Unable to save main window state".to_string()) + let _ = app; + Ok(()) } } @@ -1974,6 +1978,21 @@ pub async fn _run() { close_window, set_theme_mode, open_external_ohos, + set_always_on_top_ohos, + set_decorations_ohos, + set_skip_taskbar_ohos, + set_window_size_ohos, + set_window_position_ohos, + outer_position_ohos, + outer_size_ohos, + inner_size_ohos, + current_monitor_ohos, + unmaximize_ohos, + set_min_size_ohos, + set_focus_ohos, + set_resizable_ohos, + maximize_ohos, + center_ohos, // Debug API (no-op stubs in release builds) api::debug_api::debug_devtools_available, diff --git a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets index d2de519a23..710b58e949 100644 --- a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -6,7 +6,7 @@ import { Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; -import { window } from '@kit.ArkUI'; +import { window, display } from '@kit.ArkUI'; import { RustAbility } from '@ohos-rs/ability'; import { CommonEventListener } from '../utils/CommonEventListener'; import { harmonyShare, systemShare } from '@kit.ShareKit'; @@ -32,6 +32,44 @@ import { const DOMAIN = 0x0000; +interface SetAlwaysOnTopArg { + isTopmost: boolean; +} +interface SetDecorationsArg { + visible: boolean; +} +interface SetSkipTaskbarArg { + skip: boolean; +} +interface SetWindowSizeArg { + width: number; + height: number; +} +interface SetWindowPositionArg { + x: number; + y: number; +} +interface SetMinSizeArg { + width: number; + height: number; +} +interface SetResizableArg { + resizable: boolean; +} +interface PointResult { + x: number; + y: number; +} +interface SizeResult { + width: number; + height: number; +} +interface MonitorResult { + width: number; + height: number; + scaleFactor: number; +} + export default class EntryAbility extends RustAbility { public moduleName: string = "bitfun_desktop_lib"; public defaultPage: boolean = true; @@ -183,6 +221,134 @@ export default class EntryAbility extends RustAbility { windowStage.getMainWindowSync().startMoving(); return ''; }); + RustModule.registerArktsFunction('set_always_on_top_ohos', async (err: Error, arg: string): Promise => { + const req: SetAlwaysOnTopArg = JSON.parse(arg); + const mainWin = windowStage.getMainWindowSync(); + try { + await mainWin.setWindowTopmost(req.isTopmost); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `setWindowTopmost failed (code=${be.code}), falling back to showWindow: ${be.message}`); + try { + await mainWin.showWindow(); + } catch (e2) { + const be2 = e2 as BusinessError; + hilog.warn(DOMAIN, TAG, `showWindow fallback failed: ${be2.message}`); + } + } + return ''; + }); + RustModule.registerArktsFunction('set_decorations_ohos', async (err: Error, arg: string): Promise => { + const req: SetDecorationsArg = JSON.parse(arg); + try { + await windowStage.getMainWindowSync().setWindowDecorVisible(req.visible); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `setWindowDecorVisible failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('set_skip_taskbar_ohos', async (err: Error, arg: string): Promise => { + return ''; + }); + RustModule.registerArktsFunction('set_window_size_ohos', async (err: Error, arg: string): Promise => { + const req: SetWindowSizeArg = JSON.parse(arg); + try { + await windowStage.getMainWindowSync().resize(req.width, req.height); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `resize failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('set_window_position_ohos', async (err: Error, arg: string): Promise => { + const req: SetWindowPositionArg = JSON.parse(arg); + try { + await windowStage.getMainWindowSync().moveWindowTo(req.x, req.y); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `moveWindowTo failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('outer_position_ohos', async (err: Error, arg: string): Promise => { + const rect = windowStage.getMainWindowSync().getWindowProperties().windowRect; + const result: PointResult = { x: rect.left, y: rect.top }; + return JSON.stringify(result); + }); + RustModule.registerArktsFunction('outer_size_ohos', async (err: Error, arg: string): Promise => { + const rect = windowStage.getMainWindowSync().getWindowProperties().windowRect; + const result: SizeResult = { width: rect.width, height: rect.height }; + return JSON.stringify(result); + }); + RustModule.registerArktsFunction('inner_size_ohos', async (err: Error, arg: string): Promise => { + const rect = windowStage.getMainWindowSync().getWindowProperties().windowRect; + const result: SizeResult = { width: rect.width, height: rect.height }; + return JSON.stringify(result); + }); + RustModule.registerArktsFunction('current_monitor_ohos', async (err: Error, arg: string): Promise => { + let dispWidth: number = 0; + let dispHeight: number = 0; + let scale: number = 1; + try { + const disp = display.getDefaultDisplaySync(); + dispWidth = disp.width; + dispHeight = disp.height; + scale = disp.densityPixels > 0 ? disp.densityPixels : 1; + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `getDefaultDisplaySync failed: ${be.message}`); + } + const result: MonitorResult = { width: dispWidth, height: dispHeight, scaleFactor: scale }; + return JSON.stringify(result); + }); + RustModule.registerArktsFunction('unmaximize_ohos', async (err: Error, arg: string): Promise => { + try { + await windowStage.getMainWindowSync().recover(); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `recover failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('set_min_size_ohos', async (err: Error, arg: string): Promise => { + return ''; + }); + RustModule.registerArktsFunction('set_focus_ohos', async (err: Error, arg: string): Promise => { + try { + await windowStage.getMainWindowSync().showWindow(); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `showWindow failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('set_resizable_ohos', async (err: Error, arg: string): Promise => { + return ''; + }); + RustModule.registerArktsFunction('maximize_ohos', async (err: Error, arg: string): Promise => { + try { + await windowStage.getMainWindowSync().maximize(); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `maximize failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('center_ohos', async (err: Error, arg: string): Promise => { + try { + const mainWin = windowStage.getMainWindowSync(); + const rect = mainWin.getWindowProperties().windowRect; + const disp = display.getDefaultDisplaySync(); + const x: number = Math.max(0, Math.floor((disp.width - rect.width) / 2)); + const y: number = Math.max(0, Math.floor((disp.height - rect.height) / 2)); + await mainWin.moveWindowTo(x, y); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `center failed: ${be.message}`); + } + return ''; + }); RustModule.registerArktsFunction('call_calendar', async (err: Error, arg: string): Promise => { return await createMeetingEvent(arg); }); diff --git a/src/apps/ohos/entry/src/main/module.json5 b/src/apps/ohos/entry/src/main/module.json5 index 819589a728..b78692bf95 100644 --- a/src/apps/ohos/entry/src/main/module.json5 +++ b/src/apps/ohos/entry/src/main/module.json5 @@ -131,6 +131,16 @@ ], "when": "always" } + }, + { + "name": "ohos.permission.WINDOW_TOPMOST", + "reason": "$string:permission_window_topmost_reason", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "always" + } } ], "extensionAbilities": [ diff --git a/src/apps/ohos/entry/src/main/resources/base/element/string.json b/src/apps/ohos/entry/src/main/resources/base/element/string.json index 3364b16c64..0bbb629f06 100644 --- a/src/apps/ohos/entry/src/main/resources/base/element/string.json +++ b/src/apps/ohos/entry/src/main/resources/base/element/string.json @@ -54,6 +54,10 @@ { "name": "permission_microphone_reason", "value": "Used to capture microphone audio for voice input transcription in the chat composer" + }, + { + "name": "permission_window_topmost_reason", + "value": "Used to keep the floating toolbar window above other application windows" } ] } \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json index b0a52c3c7f..047ab7eb92 100644 --- a/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json @@ -55,6 +55,10 @@ { "name": "permission_microphone_reason", "value": "用于采集麦克风音频以进行对话框语音输入转写" + }, + { + "name": "permission_window_topmost_reason", + "value": "用于将悬浮工具栏窗口保持在其他应用窗口之上" } ] } \ No newline at end of file diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx index 8b18eea811..d68f4b810e 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx @@ -13,7 +13,8 @@ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; -import { getCurrentWindow } from '@tauri-apps/api/window'; +import { isOpenHarmonyRuntime } from '@/infrastructure/runtime'; +import { workspaceAPI } from '@/infrastructure/api'; import { Square, Check, @@ -173,8 +174,13 @@ export const ToolbarMode: React.FC = () => { return; } try { - const win = getCurrentWindow(); - await win.startDragging(); + if (isOpenHarmonyRuntime()) { + await workspaceAPI.window_start_dragging(); + } else { + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + const win = getCurrentWindow(); + await win.startDragging(); + } } catch (error) { log.error('Failed to start dragging', error); } diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx index 42069e6f69..4ee53b7acd 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx @@ -1,6 +1,4 @@ import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { currentMonitor, getCurrentWindow } from '@tauri-apps/api/window'; -import { LogicalSize, PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi'; import { createLogger } from '@/shared/utils/logger'; import { MAIN_WINDOW_DEFAULT_SIZE, @@ -15,6 +13,10 @@ import { type ToolbarModeState, } from './ToolbarModeContext'; import { resolveToolbarWindowGeometry } from './toolbarWindowGeometry'; +import { + createWindowOps, + type WindowOps, +} from './toolbarModeWindowOps'; const log = createLogger('ToolbarModeContext'); @@ -22,7 +24,12 @@ interface ToolbarModeProviderProps { children: ReactNode; } -type MainWindow = ReturnType; +const isMacOSPlatform = (): boolean => + typeof window !== 'undefined' && + '__TAURI__' in window && + typeof navigator !== 'undefined' && + typeof navigator.platform === 'string' && + navigator.platform.toUpperCase().includes('MAC'); const setMainWindowTransientGeometry = async (transient: boolean): Promise => { const { systemAPI } = await import('@/infrastructure/api/service-api/SystemAPI'); @@ -30,58 +37,58 @@ const setMainWindowTransientGeometry = async (transient: boolean): Promise }; const restoreMainWindowFromToolbarMode = async ( - win: MainWindow, + ops: WindowOps, saved: SavedWindowState | null, isMacOS: boolean, ): Promise => { // Remove the toolbar constraint before restoring the normal bounds. The // standard client minimum is re-applied once restoration completes. - await win.setMinSize(null); + await ops.setMinSize(null); if (isMacOS) { try { - await win.setTitleBarStyle('overlay'); + await ops.setTitleBarOverlay(); } catch (error) { log.debug('Failed to restore macOS overlay title bar (early, ignored)', error); } } else { try { - await win.setDecorations(saved?.isDecorated ?? false); + await ops.setDecorations(saved?.isDecorated ?? false); } catch (error) { log.debug('Failed to restore window decorations (ignored)', error); } } await Promise.all([ - win.setResizable(true), - win.setSkipTaskbar(false), + ops.setResizable(true), + ops.setSkipTaskbar(false), ]); if (saved) { - await win.setSize(new PhysicalSize(saved.width, saved.height)); - await win.setPosition(new PhysicalPosition(saved.x, saved.y)); + await ops.setSize({ width: saved.width, height: saved.height }); + await ops.setPosition({ x: saved.x, y: saved.y }); if (saved.isMaximized) { - await win.maximize(); + await ops.maximize(); } } else { - await win.setSize(new LogicalSize( - MAIN_WINDOW_DEFAULT_SIZE.width, - MAIN_WINDOW_DEFAULT_SIZE.height, - )); - await win.center(); + await ops.setSizeLogical({ + width: MAIN_WINDOW_DEFAULT_SIZE.width, + height: MAIN_WINDOW_DEFAULT_SIZE.height, + }); + await ops.center(); } - await win.setMinSize(new LogicalSize( - MAIN_WINDOW_MIN_SIZE.width, - MAIN_WINDOW_MIN_SIZE.height, - )); + await ops.setMinSizeLogical({ + width: MAIN_WINDOW_MIN_SIZE.width, + height: MAIN_WINDOW_MIN_SIZE.height, + }); if (isMacOS) { try { - await win.setTitleBarStyle('overlay'); + await ops.setTitleBarOverlay(); await new Promise((resolve) => setTimeout(resolve, 60)); - await win.setTitleBarStyle('overlay'); + await ops.setTitleBarOverlay(); } catch (error) { log.debug('Failed to re-apply macOS overlay title bar (ignored)', error); } @@ -89,9 +96,9 @@ const restoreMainWindowFromToolbarMode = async ( // Keep the native side in transient mode until all normal geometry has been // restored. Turning this off persists only the final standard-client state. - await win.setAlwaysOnTop(false); + await ops.setAlwaysOnTop(false); await setMainWindowTransientGeometry(false); - await win.setFocus(); + await ops.setFocus(); }; export const ToolbarModeProvider: React.FC = ({ children }) => { @@ -113,32 +120,19 @@ export const ToolbarModeProvider: React.FC = ({ childr const savedWindowStateRef = useRef(null); const enableToolbarMode = useCallback(async () => { - const win = getCurrentWindow(); - const isMacOS = - typeof window !== 'undefined' && - '__TAURI__' in window && - typeof navigator !== 'undefined' && - typeof navigator.platform === 'string' && - navigator.platform.toUpperCase().includes('MAC'); + const ops = createWindowOps(); + const isMacOS = isMacOSPlatform(); try { window.dispatchEvent(new CustomEvent('toolbar-mode-activating')); const [position, size, isMaximized, isDecorated] = await Promise.all([ - win.outerPosition(), + ops.outerPosition(), // setSize restores the inner size, so capture the matching metric. // Using outerSize here grows decorated windows on every mode round-trip. - win.innerSize(), - win.isMaximized(), - (async () => { - try { - if (typeof (win as any).isDecorated === 'function') { - return await (win as any).isDecorated(); - } - } catch { - } - return undefined; - })(), + ops.innerSize(), + ops.isMaximized(), + ops.isDecorated(), ]); savedWindowStateRef.current = { @@ -158,30 +152,31 @@ export const ToolbarModeProvider: React.FC = ({ childr setIsExpanded(true); if (isMaximized) { - await win.unmaximize(); + await ops.unmaximize(); } - const monitor = await currentMonitor(); + const monitor = await ops.currentMonitor(); const geometry = resolveToolbarWindowGeometry({ monitor, targetSize: TOOLBAR_EXPANDED_SIZE, minSize: TOOLBAR_EXPANDED_MIN, }); - await win.setMinSize(new PhysicalSize(geometry.minWidth, geometry.minHeight)); - await win.setAlwaysOnTop(true); + await ops.setMinSize({ width: geometry.minWidth, height: geometry.minHeight }); + await ops.setAlwaysOnTop(true); const toolbarWindowOps: Array> = [ - win.setSize(new PhysicalSize(geometry.width, geometry.height)), - win.setPosition(new PhysicalPosition(geometry.x, geometry.y)), - win.setResizable(true), - win.setSkipTaskbar(true), + ops.setSize({ width: geometry.width, height: geometry.height }), + ops.setPosition({ x: geometry.x, y: geometry.y }), + ops.setResizable(true), + ops.setSkipTaskbar(true), ]; if (!isMacOS) { - toolbarWindowOps.push(win.setDecorations(false)); + toolbarWindowOps.push(ops.setDecorations(false)); } else { try { - await win.setTitleBarStyle('overlay'); + await ops.setTitleBarOverlay(); } catch { + // ignore macOS title bar style on platforms without it } } await Promise.all(toolbarWindowOps); @@ -190,7 +185,7 @@ export const ToolbarModeProvider: React.FC = ({ childr setIsToolbarMode(false); setIsExpanded(false); try { - await restoreMainWindowFromToolbarMode(win, savedWindowStateRef.current, isMacOS); + await restoreMainWindowFromToolbarMode(ops, savedWindowStateRef.current, isMacOS); savedWindowStateRef.current = null; } catch (restoreError) { // The native transient flag intentionally remains active if rollback @@ -201,18 +196,13 @@ export const ToolbarModeProvider: React.FC = ({ childr }, []); const disableToolbarMode = useCallback(async () => { - const win = getCurrentWindow(); - const isMacOS = - typeof window !== 'undefined' && - '__TAURI__' in window && - typeof navigator !== 'undefined' && - typeof navigator.platform === 'string' && - navigator.platform.toUpperCase().includes('MAC'); + const ops = createWindowOps(); + const isMacOS = isMacOSPlatform(); try { setIsToolbarMode(false); setIsExpanded(false); - await restoreMainWindowFromToolbarMode(win, savedWindowStateRef.current, isMacOS); + await restoreMainWindowFromToolbarMode(ops, savedWindowStateRef.current, isMacOS); savedWindowStateRef.current = null; } catch (error) { log.error('Failed to disable toolbar mode', error); @@ -233,12 +223,12 @@ export const ToolbarModeProvider: React.FC = ({ childr const newIsExpanded = !isExpanded; try { - const win = getCurrentWindow(); + const ops = createWindowOps(); const targetSize = newIsExpanded ? TOOLBAR_EXPANDED_SIZE : TOOLBAR_COMPACT_SIZE; const minSize = newIsExpanded ? TOOLBAR_EXPANDED_MIN : TOOLBAR_COMPACT_MIN; - const currentPosition = await win.outerPosition(); - const currentSize = await win.outerSize(); - const monitor = await currentMonitor(); + const currentPosition = await ops.outerPosition(); + const currentSize = await ops.outerSize(); + const monitor = await ops.currentMonitor(); const geometry = resolveToolbarWindowGeometry({ monitor, targetSize, @@ -256,9 +246,9 @@ export const ToolbarModeProvider: React.FC = ({ childr }); setIsExpanded(newIsExpanded); - await win.setMinSize(new PhysicalSize(geometry.minWidth, geometry.minHeight)); - await win.setSize(new PhysicalSize(geometry.width, geometry.height)); - await win.setPosition(new PhysicalPosition(geometry.x, geometry.y)); + await ops.setMinSize({ width: geometry.minWidth, height: geometry.minHeight }); + await ops.setSize({ width: geometry.width, height: geometry.height }); + await ops.setPosition({ x: geometry.x, y: geometry.y }); } catch (error) { log.error('Failed to toggle expanded state', { newIsExpanded, error }); } diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/toolbarModeWindowOps.ts b/src/web-ui/src/flow_chat/components/toolbar-mode/toolbarModeWindowOps.ts new file mode 100644 index 0000000000..6e10e60604 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/toolbarModeWindowOps.ts @@ -0,0 +1,210 @@ +import { currentMonitor, getCurrentWindow } from '@tauri-apps/api/window'; +import { LogicalSize, PhysicalPosition, PhysicalSize } from '@tauri-apps/api/dpi'; +import { isOpenHarmonyRuntime } from '@/infrastructure/runtime'; +import { workspaceAPI } from '@/infrastructure/api'; +import type { ToolbarMonitorGeometry } from './toolbarWindowGeometry'; + +export interface Point2D { + x: number; + y: number; +} + +export interface Size2D { + width: number; + height: number; +} + +export interface WindowOps { + outerPosition(): Promise; + innerSize(): Promise; + outerSize(): Promise; + isMaximized(): Promise; + isDecorated(): Promise; + unmaximize(): Promise; + setMinSize(size: Size2D | null): Promise; + setMinSizeLogical(size: Size2D): Promise; + setAlwaysOnTop(top: boolean): Promise; + setSize(size: Size2D): Promise; + setSizeLogical(size: Size2D): Promise; + setPosition(pos: Point2D): Promise; + setResizable(resizable: boolean): Promise; + setSkipTaskbar(skip: boolean): Promise; + setDecorations(visible: boolean): Promise; + setTitleBarOverlay(): Promise; + maximize(): Promise; + center(): Promise; + setFocus(): Promise; + currentMonitor(): Promise; +} + +const tauriOps = (): WindowOps => { + const win = getCurrentWindow(); + return { + async outerPosition(): Promise { + const p = await win.outerPosition(); + return { x: p.x, y: p.y }; + }, + async innerSize(): Promise { + const s = await win.innerSize(); + return { width: s.width, height: s.height }; + }, + async outerSize(): Promise { + const s = await win.outerSize(); + return { width: s.width, height: s.height }; + }, + async isMaximized(): Promise { + return await win.isMaximized(); + }, + async isDecorated(): Promise { + try { + const candidate = win as unknown as { isDecorated?: () => Promise }; + if (typeof candidate.isDecorated === 'function') { + return await candidate.isDecorated(); + } + } catch { + // ignore + } + return undefined; + }, + async unmaximize(): Promise { + await win.unmaximize(); + }, + async setMinSize(size: Size2D | null): Promise { + if (size === null) { + await win.setMinSize(null); + } else { + await win.setMinSize(new PhysicalSize(size.width, size.height)); + } + }, + async setMinSizeLogical(size: Size2D): Promise { + await win.setMinSize(new LogicalSize(size.width, size.height)); + }, + async setAlwaysOnTop(top: boolean): Promise { + await win.setAlwaysOnTop(top); + }, + async setSize(size: Size2D): Promise { + await win.setSize(new PhysicalSize(size.width, size.height)); + }, + async setSizeLogical(size: Size2D): Promise { + await win.setSize(new LogicalSize(size.width, size.height)); + }, + async setPosition(pos: Point2D): Promise { + await win.setPosition(new PhysicalPosition(pos.x, pos.y)); + }, + async setResizable(resizable: boolean): Promise { + await win.setResizable(resizable); + }, + async setSkipTaskbar(skip: boolean): Promise { + await win.setSkipTaskbar(skip); + }, + async setDecorations(visible: boolean): Promise { + await win.setDecorations(visible); + }, + async setTitleBarOverlay(): Promise { + await win.setTitleBarStyle('overlay'); + }, + async maximize(): Promise { + await win.maximize(); + }, + async center(): Promise { + await win.center(); + }, + async setFocus(): Promise { + await win.setFocus(); + }, + async currentMonitor(): Promise { + const m = await currentMonitor(); + if (!m) return null; + return { + position: { x: m.position.x, y: m.position.y }, + size: { width: m.size.width, height: m.size.height }, + scaleFactor: m.scaleFactor, + }; + }, + }; +}; + +const logicalToPhysical = async (size: Size2D): Promise => { + const monitor = await workspaceAPI.currentMonitorOhos(); + const scale = monitor.scaleFactor > 0 ? monitor.scaleFactor : 1; + return { + width: Math.max(1, Math.round(size.width * scale)), + height: Math.max(1, Math.round(size.height * scale)), + }; +}; + +const ohosOps = (): WindowOps => ({ + async outerPosition(): Promise { + return await workspaceAPI.outerPositionOhos(); + }, + async innerSize(): Promise { + return await workspaceAPI.innerSizeOhos(); + }, + async outerSize(): Promise { + return await workspaceAPI.outerSizeOhos(); + }, + async isMaximized(): Promise { + return await workspaceAPI.window_is_maximized(); + }, + async isDecorated(): Promise { + return false; + }, + async unmaximize(): Promise { + await workspaceAPI.unmaximizeOhos(); + }, + async setMinSize(size: Size2D | null): Promise { + if (size !== null) { + await workspaceAPI.setMinSizeOhos(size.width, size.height); + } + }, + async setMinSizeLogical(size: Size2D): Promise { + const physical = await logicalToPhysical(size); + await workspaceAPI.setMinSizeOhos(physical.width, physical.height); + }, + async setAlwaysOnTop(top: boolean): Promise { + await workspaceAPI.setAlwaysOnTopOhos(top); + }, + async setSize(size: Size2D): Promise { + await workspaceAPI.setWindowSizeOhos(size.width, size.height); + }, + async setSizeLogical(size: Size2D): Promise { + const physical = await logicalToPhysical(size); + await workspaceAPI.setWindowSizeOhos(physical.width, physical.height); + }, + async setPosition(pos: Point2D): Promise { + await workspaceAPI.setWindowPositionOhos(pos.x, pos.y); + }, + async setResizable(resizable: boolean): Promise { + await workspaceAPI.setResizableOhos(resizable); + }, + async setSkipTaskbar(skip: boolean): Promise { + await workspaceAPI.setSkipTaskbarOhos(skip); + }, + async setDecorations(visible: boolean): Promise { + await workspaceAPI.setDecorationsOhos(visible); + }, + async setTitleBarOverlay(): Promise { + // No-op on OpenHarmony; the native ArkUI host owns the window chrome. + }, + async maximize(): Promise { + await workspaceAPI.maximizeOhos(); + }, + async center(): Promise { + await workspaceAPI.centerOhos(); + }, + async setFocus(): Promise { + await workspaceAPI.setFocusOhos(); + }, + async currentMonitor(): Promise { + const m = await workspaceAPI.currentMonitorOhos(); + return { + position: { x: 0, y: 0 }, + size: { width: m.width, height: m.height }, + scaleFactor: m.scaleFactor, + }; + }, +}); + +export const createWindowOps = (): WindowOps => + isOpenHarmonyRuntime() ? ohosOps() : tauriOps(); + diff --git a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts index 9e65ae7638..c782955ff0 100644 --- a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts @@ -1132,6 +1132,130 @@ export class WorkspaceAPI { } } + async setAlwaysOnTopOhos(isTopmost: boolean): Promise { + try { + await api.invoke('set_always_on_top_ohos', { arg: JSON.stringify({ isTopmost }) }); + } catch (error) { + throw createTauriCommandError('set_always_on_top_ohos', error, { isTopmost }); + } + } + + async setDecorationsOhos(visible: boolean): Promise { + try { + await api.invoke('set_decorations_ohos', { arg: JSON.stringify({ visible }) }); + } catch (error) { + throw createTauriCommandError('set_decorations_ohos', error, { visible }); + } + } + + async setSkipTaskbarOhos(skip: boolean): Promise { + try { + await api.invoke('set_skip_taskbar_ohos', { arg: JSON.stringify({ skip }) }); + } catch (error) { + throw createTauriCommandError('set_skip_taskbar_ohos', error, { skip }); + } + } + + async setWindowSizeOhos(width: number, height: number): Promise { + try { + await api.invoke('set_window_size_ohos', { arg: JSON.stringify({ width, height }) }); + } catch (error) { + throw createTauriCommandError('set_window_size_ohos', error, { width, height }); + } + } + + async setWindowPositionOhos(x: number, y: number): Promise { + try { + await api.invoke('set_window_position_ohos', { arg: JSON.stringify({ x, y }) }); + } catch (error) { + throw createTauriCommandError('set_window_position_ohos', error, { x, y }); + } + } + + async outerPositionOhos(): Promise<{ x: number; y: number }> { + try { + const raw = await api.invoke('outer_position_ohos'); + return JSON.parse(raw) as { x: number; y: number }; + } catch (error) { + throw createTauriCommandError('outer_position_ohos', error); + } + } + + async outerSizeOhos(): Promise<{ width: number; height: number }> { + try { + const raw = await api.invoke('outer_size_ohos'); + return JSON.parse(raw) as { width: number; height: number }; + } catch (error) { + throw createTauriCommandError('outer_size_ohos', error); + } + } + + async innerSizeOhos(): Promise<{ width: number; height: number }> { + try { + const raw = await api.invoke('inner_size_ohos'); + return JSON.parse(raw) as { width: number; height: number }; + } catch (error) { + throw createTauriCommandError('inner_size_ohos', error); + } + } + + async currentMonitorOhos(): Promise<{ width: number; height: number; scaleFactor: number }> { + try { + const raw = await api.invoke('current_monitor_ohos'); + return JSON.parse(raw) as { width: number; height: number; scaleFactor: number }; + } catch (error) { + throw createTauriCommandError('current_monitor_ohos', error); + } + } + + async unmaximizeOhos(): Promise { + try { + await api.invoke('unmaximize_ohos'); + } catch (error) { + throw createTauriCommandError('unmaximize_ohos', error); + } + } + + async setMinSizeOhos(width: number, height: number): Promise { + try { + await api.invoke('set_min_size_ohos', { arg: JSON.stringify({ width, height }) }); + } catch (error) { + throw createTauriCommandError('set_min_size_ohos', error, { width, height }); + } + } + + async setFocusOhos(): Promise { + try { + await api.invoke('set_focus_ohos'); + } catch (error) { + throw createTauriCommandError('set_focus_ohos', error); + } + } + + async setResizableOhos(resizable: boolean): Promise { + try { + await api.invoke('set_resizable_ohos', { arg: JSON.stringify({ resizable }) }); + } catch (error) { + throw createTauriCommandError('set_resizable_ohos', error, { resizable }); + } + } + + async maximizeOhos(): Promise { + try { + await api.invoke('maximize_ohos'); + } catch (error) { + throw createTauriCommandError('maximize_ohos', error); + } + } + + async centerOhos(): Promise { + try { + await api.invoke('center_ohos'); + } catch (error) { + throw createTauriCommandError('center_ohos', error); + } + } + async revealInExplorer(path: string): Promise { try { await api.invoke('reveal_in_explorer', { From 56711d850ee889f5bf90ad2a6a09072a5da5cd67 Mon Sep 17 00:00:00 2001 From: SWangHash <88996709+SWangHash@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:33:40 +0800 Subject: [PATCH 2/4] fix: feedback dialog content stretched to full modal width --- .../src/app/components/FeedbackDialog/FeedbackDialog.scss | 8 ++++++++ .../src/app/components/FeedbackDialog/FeedbackDialog.tsx | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.scss b/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.scss index 21b9cff69a..8f324112f0 100644 --- a/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.scss +++ b/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.scss @@ -14,6 +14,14 @@ overflow: hidden; } +.bitfun-feedback__root { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; +} + .bitfun-feedback__center { display: flex; flex: 1; diff --git a/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.tsx b/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.tsx index 9b01bb6581..dd04642e4b 100644 --- a/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.tsx +++ b/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.tsx @@ -317,7 +317,7 @@ export const FeedbackDialog: React.FC = ({ isOpen, onClose closeOnOverlayClick={!submitting && !replyState.sending} testId="feedback-dialog" > -
+
{completed ? (