From b729be3d33971c926aab3e2bd48ef786c23c9280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E6=9D=89=E3=81=88=E3=82=8A=E3=81=84?= <47024820+niechy@users.noreply.github.com> Date: Fri, 13 Feb 2026 02:01:56 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=82=AC=E6=B5=AE=E7=AA=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持定点悬浮和跟随连接窗口两种模式 - 跟随模式支持左中/右上/右下/上中四种锚定位置 - 支持最置顶和连接窗口上一层两种层级模式 - 使用 DWM API 获取精确窗口边界,PhysicalPosition 处理 DPI 缩放 - 悬浮窗可拖拽、可缩放,关闭时保存尺寸 - 定点模式检测屏幕范围,防止悬浮窗在屏幕外 - 控制器未连接时不显示悬浮窗 - 主窗口关闭/最小化到托盘时自动关闭悬浮窗 - 支持中英文 i18n Co-authored-by: Cursor --- log-overlay.html | 22 ++ src-tauri/Cargo.toml | 8 + src-tauri/capabilities/default.json | 3 + src-tauri/capabilities/log-overlay.json | 21 ++ src-tauri/src/commands/maa_core.rs | 10 + src-tauri/src/commands/system.rs | 186 ++++++++++ src-tauri/src/commands/types.rs | 3 + src-tauri/src/lib.rs | 34 +- src/App.tsx | 9 + src/components/LogOverlay.tsx | 195 ++++++++++ src/components/LogsPanel.tsx | 34 +- src/components/Toolbar.tsx | 13 + src/components/settings/GeneralSection.tsx | 110 +++++- src/i18n/locales/en-US.ts | 13 + src/i18n/locales/zh-CN.ts | 13 + src/logOverlay.tsx | 10 + src/services/logOverlayService.ts | 395 +++++++++++++++++++++ src/stores/appStore.ts | 72 +++- src/stores/types.ts | 21 ++ src/types/config.ts | 13 + 20 files changed, 1176 insertions(+), 9 deletions(-) create mode 100644 log-overlay.html create mode 100644 src-tauri/capabilities/log-overlay.json create mode 100644 src/components/LogOverlay.tsx create mode 100644 src/logOverlay.tsx create mode 100644 src/services/logOverlayService.ts diff --git a/log-overlay.html b/log-overlay.html new file mode 100644 index 0000000..e453709 --- /dev/null +++ b/log-overlay.html @@ -0,0 +1,22 @@ + + + + + + 日志悬浮窗 + + + +
+ + + diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f82953e..3984af4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,6 +51,12 @@ shell-words = "1.1.1" [profile.release] # 保留调试符号以生成 PDB 文件,便于崩溃分析 debug = true +# 编译单元数量 - 增加到 4 可以减少内存占用(特别是在链接阶段) +# 设置为 1 会最大化优化但增加内存占用,设置为 4 是内存和性能的平衡点 +codegen-units = 4 +# 使用薄 LTO 而不是完整 LTO,在保持优化效果的同时加快编译速度并减少内存占用 +# 如果内存仍然不足,可以注释掉这一行(但会略微降低运行时性能) +lto = "thin" [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ @@ -64,5 +70,7 @@ windows = { version = "0.58", features = [ "Win32_UI_Controls", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", + "Win32_UI_HiDpi", + "Win32_Graphics_Dwm", ] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 46856dd..9055472 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -24,6 +24,9 @@ "core:window:allow-start-resize-dragging", "core:event:allow-listen", "core:event:allow-unlisten", + "core:event:allow-emit", + "core:event:allow-emit-to", + "core:window:allow-get-all-windows", "opener:default", { "identifier": "opener:allow-open-path", diff --git a/src-tauri/capabilities/log-overlay.json b/src-tauri/capabilities/log-overlay.json new file mode 100644 index 0000000..cea8ced --- /dev/null +++ b/src-tauri/capabilities/log-overlay.json @@ -0,0 +1,21 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "log-overlay", + "description": "Capability for the log overlay window", + "windows": ["log-overlay"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:window:allow-start-dragging", + "core:window:allow-start-resize-dragging", + "core:window:allow-outer-position", + "core:window:allow-set-position", + "core:window:allow-inner-size", + "core:window:allow-outer-size", + "core:event:allow-listen", + "core:event:allow-unlisten" + ] +} diff --git a/src-tauri/src/commands/maa_core.rs b/src-tauri/src/commands/maa_core.rs index ef90acd..b4bdd53 100644 --- a/src-tauri/src/commands/maa_core.rs +++ b/src-tauri/src/commands/maa_core.rs @@ -587,6 +587,16 @@ pub fn maa_connect_controller( } instance.controller = Some(controller); + + // 保存 Win32/Gamepad 窗口句柄 + instance.connected_window_handle = match &config { + ControllerConfig::Win32 { handle, .. } => Some(*handle), + ControllerConfig::Gamepad { handle, .. } => Some(*handle), + _ => None, + }; + if let Some(h) = instance.connected_window_handle { + info!("Stored connected window handle: {} for instance {}", h, instance_id); + } } Ok(conn_id) diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index d019487..9b7e8a6 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -331,3 +331,189 @@ pub fn get_system_info() -> SystemInfo { tauri_version, } } + +/// 创建日志悬浮窗 +/// x, y 为物理像素坐标(与 GetWindowRect 一致) +#[tauri::command] +pub async fn create_log_overlay_window( + app_handle: tauri::AppHandle, + x: i32, + y: i32, + width: f64, + height: f64, + always_on_top: bool, +) -> Result<(), String> { + use tauri::Manager; + + let label = "log-overlay"; + + // 检查窗口是否已存在 + if let Some(window) = app_handle.get_webview_window(label) { + info!("Log overlay window already exists"); + let _ = window.show(); + let _ = window.set_focus(); + return Ok(()); + } + + let window = tauri::WebviewWindowBuilder::new( + &app_handle, + label, + tauri::WebviewUrl::App("log-overlay.html".into()), + ) + .title("日志悬浮窗") + .inner_size(width, height) + .decorations(false) + .resizable(true) + .always_on_top(always_on_top) + .skip_taskbar(true) + .transparent(true) + .visible(false) + .build() + .map_err(|e| format!("Failed to create log overlay window: {}", e))?; + + // 使用物理像素坐标设置位置(避免 DPI 缩放问题) + use tauri::PhysicalPosition; + window + .set_position(tauri::Position::Physical(PhysicalPosition::new(x, y))) + .map_err(|e| format!("Failed to set position: {}", e))?; + + window.show().map_err(|e| format!("Failed to show window: {}", e))?; + + info!("Log overlay window created (always_on_top={}, pos=({},{}))", always_on_top, x, y); + + Ok(()) +} + +/// 获取实例连接的窗口句柄(由 Rust 后端存储,前端直接查询) +#[tauri::command] +pub fn get_connected_window_handle( + state: tauri::State>, + instance_id: String, +) -> Result, String> { + let instances = state.instances.lock().map_err(|e| e.to_string())?; + if let Some(instance) = instances.get(&instance_id) { + Ok(instance.connected_window_handle.map(|h| h as i64)) + } else { + Ok(None) + } +} + +/// 获取指定窗口的可见区域位置和大小 (物理像素, Windows only) +/// +/// 返回 (x, y, width, height, scale_factor) +/// 优先使用 DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS) 获取真实可见边界, +/// 排除 Windows 10/11 不可见的扩展边框。回退到 GetWindowRect。 +/// scale_factor 为该窗口所在监视器的 DPI 缩放比 (如 1.0, 1.25, 1.5)。 +#[tauri::command] +pub fn get_window_rect_by_handle(handle: i64) -> Result<(i32, i32, i32, i32, f64), String> { + #[cfg(windows)] + { + use windows::Win32::Foundation::{HWND, RECT}; + use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS}; + use windows::Win32::UI::HiDpi::GetDpiForWindow; + use windows::Win32::UI::WindowsAndMessaging::GetWindowRect; + + let hwnd = HWND(handle as *mut _); + let mut rect = RECT::default(); + + // 优先用 DWM 获取真实可见边界 + let ok = unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_EXTENDED_FRAME_BOUNDS, + &mut rect as *mut RECT as *mut _, + std::mem::size_of::() as u32, + ) + }; + + if ok.is_err() { + unsafe { + GetWindowRect(hwnd, &mut rect) + .map_err(|e| format!("GetWindowRect failed: {}", e))?; + } + } + + let dpi = unsafe { GetDpiForWindow(hwnd) }; + let scale = if dpi > 0 { dpi as f64 / 96.0 } else { 1.0 }; + + Ok((rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, scale)) + } + #[cfg(not(windows))] + { + let _ = handle; + Err("Not supported on this platform".to_string()) + } +} + +/// 将悬浮窗放置在目标窗口的上一层(z-order) +#[tauri::command] +pub async fn set_overlay_above_target( + app_handle: tauri::AppHandle, + target_handle: i64, +) -> Result<(), String> { + #[cfg(windows)] + { + use tauri::Manager; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + GetWindow, SetWindowPos, GW_HWNDPREV, SWP_NOMOVE, SWP_NOSIZE, SWP_NOACTIVATE, + HWND_TOP, + }; + + let overlay = app_handle + .get_webview_window("log-overlay") + .ok_or("Overlay window not found")?; + let overlay_hwnd = overlay.hwnd().map_err(|e| format!("Failed to get overlay hwnd: {}", e))?; + + let target_hwnd = HWND(target_handle as *mut _); + let overlay_win_hwnd = HWND(overlay_hwnd.0 as *mut _); + let flags = SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE; + + unsafe { + // 找到 target 上方的窗口,把 overlay 插到那下面(即 target 上方) + let insert_after = GetWindow(target_hwnd, GW_HWNDPREV) + .ok() + .filter(|h| !h.is_invalid()) + .unwrap_or(HWND_TOP); + + let _ = SetWindowPos(overlay_win_hwnd, insert_after, 0, 0, 0, 0, flags); + } + + Ok(()) + } + #[cfg(not(windows))] + { + let _ = (app_handle, target_handle); + Ok(()) + } +} + +/// 设置悬浮窗是否始终置顶 +#[tauri::command] +pub async fn set_overlay_always_on_top( + app_handle: tauri::AppHandle, + always_on_top: bool, +) -> Result<(), String> { + use tauri::Manager; + + let overlay = app_handle + .get_webview_window("log-overlay") + .ok_or("Overlay window not found")?; + + overlay + .set_always_on_top(always_on_top) + .map_err(|e| format!("Failed to set always_on_top: {}", e))?; + + info!("Log overlay always_on_top set to {}", always_on_top); + Ok(()) +} + +/// 关闭日志悬浮窗 +#[tauri::command] +pub async fn close_log_overlay(app_handle: tauri::AppHandle) -> Result<(), String> { + use tauri::Manager; + if let Some(overlay) = app_handle.get_webview_window("log-overlay") { + overlay.close().map_err(|e| format!("Failed to close overlay: {}", e))?; + } + Ok(()) +} diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs index 0876f31..f61f351 100644 --- a/src-tauri/src/commands/types.rs +++ b/src-tauri/src/commands/types.rs @@ -142,6 +142,8 @@ pub struct InstanceRuntime { pub stop_in_progress: bool, /// stop 请求的起始时间(用于节流/重试) pub stop_started_at: Option, + /// Win32/Gamepad 控制器连接的窗口句柄(供悬浮窗跟随使用) + pub connected_window_handle: Option, } // 为原始指针实现 Send 和 Sync @@ -157,6 +159,7 @@ impl Default for InstanceRuntime { tasker: None, agent_client: None, agent_child: None, + connected_window_handle: None, task_ids: Vec::new(), stop_in_progress: false, stop_started_at: None, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b5dd0cc..8c1a138 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,7 @@ mod tray; use commands::MaaState; use maa_ffi::MaaLibraryError; use std::sync::Arc; -use tauri::Manager; +use tauri::{Emitter, Manager}; use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -174,6 +174,12 @@ pub fn run() { commands::system::check_vcredist_missing, commands::system::get_arch, commands::system::get_system_info, + commands::system::create_log_overlay_window, + commands::system::get_connected_window_handle, + commands::system::get_window_rect_by_handle, + commands::system::set_overlay_above_target, + commands::system::set_overlay_always_on_top, + commands::system::close_log_overlay, // 托盘相关命令 commands::tray::set_minimize_to_tray, commands::tray::get_minimize_to_tray, @@ -184,12 +190,36 @@ pub fn run() { match event { // 窗口关闭请求:检查是否最小化到托盘 tauri::WindowEvent::CloseRequested { api, .. } => { + // 悬浮窗关闭时:获取当前尺寸,通知前端同步状态 + if window.label() == "log-overlay" { + let size = window.inner_size().ok(); + let pos = window.outer_position().ok(); + let payload = serde_json::json!({ + "width": size.as_ref().map(|s| s.width).unwrap_or(360), + "height": size.as_ref().map(|s| s.height).unwrap_or(260), + "x": pos.as_ref().map(|p| p.x).unwrap_or(100), + "y": pos.as_ref().map(|p| p.y).unwrap_or(100), + }); + let _ = window.app_handle().emit("log-overlay-closed", payload); + } + // 主窗口关闭/最小化到托盘时,同步关闭悬浮窗 + if window.label() == "main" { + if let Some(overlay) = window.app_handle().get_webview_window("log-overlay") { + let _ = overlay.destroy(); + } + } if tray::handle_close_requested(window.app_handle()) { api.prevent_close(); } } - // 窗口销毁时清理所有 agent 子进程 + // 主窗口销毁时清理所有 agent 子进程和悬浮窗 tauri::WindowEvent::Destroyed => { + if window.label() == "main" { + // 关闭悬浮窗 + if let Some(overlay) = window.app_handle().get_webview_window("log-overlay") { + let _ = overlay.destroy(); + } + } if let Some(state) = window.try_state::>() { state.cleanup_all_agent_children(); } diff --git a/src/App.tsx b/src/App.tsx index 933459f..605f32a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -447,6 +447,15 @@ function App() { } }, 0); + // 订阅连接状态变化,自动显示/隐藏日志悬浮窗 + if (isTauri()) { + import('@/services/logOverlayService') + .then(({ subscribeConnectionStatus }) => { + subscribeConnectionStatus(); + }) + .catch((err) => log.warn('订阅日志悬浮窗状态失败:', err)); + } + // 检查是否为开机自启动,若配置了自动执行的实例则激活并启动任务 // 或者手动启动时,如果勾选了"手动启动时也自动执行",也自动执行 if (isTauri()) { diff --git a/src/components/LogOverlay.tsx b/src/components/LogOverlay.tsx new file mode 100644 index 0000000..9f373fb --- /dev/null +++ b/src/components/LogOverlay.tsx @@ -0,0 +1,195 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { X, GripHorizontal } from 'lucide-react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { listen } from '@tauri-apps/api/event'; +import clsx from 'clsx'; + +type LogType = 'info' | 'success' | 'warning' | 'error' | 'agent' | 'focus'; + +interface OverlayLogEntry { + id: string; + timestamp: number; + type: LogType; + message: string; + html?: string; +} + +const MAX_OVERLAY_LOGS = 200; + +export function LogOverlayApp() { + const [logs, setLogs] = useState([]); + const [isHovered, setIsHovered] = useState(false); + const logsEndRef = useRef(null); + + // Listen for ALL log events (no instance filtering) + useEffect(() => { + const unlisteners: Array<() => void> = []; + + listen<{ instanceId: string; log: OverlayLogEntry }>('log-overlay-new-log', (event) => { + setLogs((prev) => { + const next = [...prev, event.payload.log]; + return next.length > MAX_OVERLAY_LOGS ? next.slice(-MAX_OVERLAY_LOGS) : next; + }); + }).then((unlisten) => unlisteners.push(unlisten)); + + listen<{ instanceId: string }>('log-overlay-clear', () => { + setLogs([]); + }).then((unlisten) => unlisteners.push(unlisten)); + + return () => { + unlisteners.forEach((fn) => fn()); + }; + }, []); + + // Auto-scroll + useEffect(() => { + logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs]); + + // Drag via Tauri native startDragging + const handleDragStart = useCallback(async (e: React.MouseEvent) => { + e.preventDefault(); + try { + await getCurrentWindow().startDragging(); + } catch { + // ignore + } + }, []); + + // Close + const handleClose = useCallback(async () => { + try { + await getCurrentWindow().close(); + } catch { + // ignore + } + }, []); + + const getLogColor = (type: LogType) => { + switch (type) { + case 'success': + return 'text-emerald-400'; + case 'warning': + return 'text-amber-400'; + case 'error': + return 'text-red-400'; + case 'agent': + return 'text-slate-400'; + case 'focus': + return 'text-blue-400'; + case 'info': + return 'text-sky-400'; + default: + return 'text-slate-300'; + } + }; + + const formatTime = (ts: number) => { + const d = new Date(ts); + const h = d.getHours().toString().padStart(2, '0'); + const m = d.getMinutes().toString().padStart(2, '0'); + const s = d.getSeconds().toString().padStart(2, '0'); + return `${h}:${m}:${s}`; + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Title bar - draggable */} +
+ + Log + + +
+ + {/* Logs content */} +
+ {logs.length === 0 ? ( +
+ 等待日志... +
+ ) : ( + <> + {logs.map((entry) => ( +
+ + {formatTime(entry.timestamp)} + + {entry.html ? ( + + ) : ( + + {entry.message} + + )} +
+ ))} +
+ + )} + + {/* Resize grip */} +
{ + e.preventDefault(); + e.stopPropagation(); + try { + await getCurrentWindow().startResizeDragging('SouthEast'); + } catch { + // ignore + } + }} + className={clsx( + 'absolute bottom-0 right-0 w-4 h-4 cursor-se-resize flex items-center justify-center transition-opacity duration-200', + isHovered ? 'opacity-40' : 'opacity-0', + )} + > + +
+
+
+ ); +} diff --git a/src/components/LogsPanel.tsx b/src/components/LogsPanel.tsx index 2d5bb4b..0f66d07 100644 --- a/src/components/LogsPanel.tsx +++ b/src/components/LogsPanel.tsx @@ -1,6 +1,6 @@ import { useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Trash2, Copy, ChevronUp, ChevronDown, Archive } from 'lucide-react'; +import { Trash2, Copy, ChevronUp, ChevronDown, Archive, MonitorUp } from 'lucide-react'; import clsx from 'clsx'; import { useAppStore, type LogType } from '@/stores/appStore'; import { ContextMenu, useContextMenu, type MenuItem } from './ContextMenu'; @@ -11,8 +11,10 @@ import { ExportLogsModal } from './settings/ExportLogsModal'; export function LogsPanel() { const { t } = useTranslation(); const logsEndRef = useRef(null); - const { sidePanelExpanded, toggleSidePanelExpanded, activeInstanceId, instanceLogs, clearLogs } = - useAppStore(); + const { + sidePanelExpanded, toggleSidePanelExpanded, activeInstanceId, instanceLogs, clearLogs, + logOverlayEnabled, setLogOverlayEnabled, + } = useAppStore(); const { state: menuState, show: showMenu, hide: hideMenu } = useContextMenu(); const { exportModal, handleExportLogs, closeExportModal, openExportedFile } = useExportLogs(); @@ -137,6 +139,32 @@ export function LogsPanel() { > {t('logs.title')}
+ {/* 日志悬浮窗开关 */} + {isTauri() && ( + + )} {/* 导出日志 */} +
{/* 右侧执行按钮组 */} diff --git a/src/components/settings/GeneralSection.tsx b/src/components/settings/GeneralSection.tsx index 92239e6..bf9c22a 100644 --- a/src/components/settings/GeneralSection.tsx +++ b/src/components/settings/GeneralSection.tsx @@ -36,6 +36,14 @@ export function GeneralSection() { autoRunOnLaunch, setAutoRunOnLaunch, autoStartRemovedInstanceName, + logOverlayEnabled, + setLogOverlayEnabled, + logOverlayMode, + setLogOverlayMode, + logOverlayAnchor, + setLogOverlayAnchor, + logOverlayZOrder, + setLogOverlayZOrder, } = useAppStore(); // 开机自启动状态(直接从 Tauri 插件查询,不走 store) @@ -287,7 +295,107 @@ export function GeneralSection() {
- {/* ⑧ 重置窗口布局 */} + {/* ⑧ 日志悬浮窗 */} + {isTauri() && ( +
+
+
+ +
+ + {t('settings.logOverlay')} + +

+ {t('settings.logOverlayHint')} +

+
+
+ { + setLogOverlayEnabled(v); + if (v) { + import('@/services/logOverlayService').then(({ showLogOverlay }) => showLogOverlay()); + } else { + import('@/services/logOverlayService').then(({ hideLogOverlay }) => hideLogOverlay()); + } + }} + /> +
+ {logOverlayEnabled && ( +
+ {/* 模式选择 */} +
+ + {t('settings.logOverlayMode')} + + +
+ + {/* 跟随模式下的锚定位置 */} + {logOverlayMode === 'follow' && ( +
+ + {t('settings.logOverlayAnchor')} + + +
+ )} + + {/* 窗口层级 */} +
+ + {t('settings.logOverlayZOrder')} + + +
+
+ )} +
+ )} + + {/* ⑨ 重置窗口布局 */} {isTauri() && (
diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index a6c310c..3ab8fb3 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -104,6 +104,19 @@ export default { 'Oldest logs will be discarded when exceeding the limit (recommended 500–2000)', resetWindowLayout: 'Reset Window Layout', resetWindowLayoutHint: 'Restore window size to default and center the window', + logOverlay: 'Log Overlay', + logOverlayHint: 'Show a transparent floating log near the connected window. Hidden until connected', + logOverlayMode: 'Overlay Mode', + logOverlayModeFixed: 'Fixed Position', + logOverlayModeFollow: 'Follow Connected Window', + logOverlayAnchor: 'Anchor Position', + logOverlayAnchorLeftCenter: 'Left Center', + logOverlayAnchorRightTop: 'Right Top (1/3)', + logOverlayAnchorRightBottom: 'Right Bottom (2/3)', + logOverlayAnchorTopCenter: 'Top Center', + logOverlayZOrder: 'Window Layer', + logOverlayZOrderTop: 'Always on Top', + logOverlayZOrderAboveTarget: 'Above Connected Window', }, // Special tasks diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 94c69a1..ab2a3d2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -98,6 +98,19 @@ export default { '每次手动打开程序时,也自动执行上方选定的配置(关闭则仅在开机自启动时触发)', confirmBeforeDelete: '删除操作需要二次确认', confirmBeforeDeleteHint: '删除任务、清空列表、导入覆盖等操作会先弹出确认对话框', + logOverlay: '日志悬浮窗', + logOverlayHint: '在控制器连接的窗口附近显示半透明悬浮日志,未连接时不显示', + logOverlayMode: '悬浮模式', + logOverlayModeFixed: '定点悬浮', + logOverlayModeFollow: '跟随连接窗口', + logOverlayZOrder: '窗口层级', + logOverlayZOrderTop: '最置顶', + logOverlayZOrderAboveTarget: '连接窗口上一层', + logOverlayAnchor: '锚定位置', + logOverlayAnchorLeftCenter: '左中', + logOverlayAnchorRightTop: '右上(1/3处)', + logOverlayAnchorRightBottom: '右下(2/3处)', + logOverlayAnchorTopCenter: '上中', maxLogsPerInstance: '每个实例保留的日志上限', maxLogsPerInstanceHint: '超过上限会自动丢弃最旧的日志(建议 500~2000)', resetWindowLayout: '重置窗口布局', diff --git a/src/logOverlay.tsx b/src/logOverlay.tsx new file mode 100644 index 0000000..0867cc6 --- /dev/null +++ b/src/logOverlay.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { LogOverlayApp } from './components/LogOverlay'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/services/logOverlayService.ts b/src/services/logOverlayService.ts new file mode 100644 index 0000000..3b4ca40 --- /dev/null +++ b/src/services/logOverlayService.ts @@ -0,0 +1,395 @@ +/** + * 日志悬浮窗服务 + * + * - 只有控制器连接后才显示 + * - 定点模式:固定在屏幕位置,可拖拽,不跟随 + * - 跟随模式:锚定在控制器连接的窗口(游戏窗口)附近,跟随移动 + * - 层级选项:最置顶 / 只在连接窗口上一层(两种模式都生效) + * - 关闭时保存悬浮窗尺寸,下次打开时恢复 + * - 定点模式下确保悬浮窗在屏幕可见范围内 + * + * 所有坐标和尺寸统一使用物理像素(与 Win32 API 一致),避免 DPI 缩放问题。 + */ + +import { invoke } from '@tauri-apps/api/core'; +import { getAllWindows, availableMonitors } from '@tauri-apps/api/window'; +import { PhysicalPosition } from '@tauri-apps/api/dpi'; +import { listen } from '@tauri-apps/api/event'; +import { isTauri } from '@/utils/paths'; +import { loggers } from '@/utils/logger'; +import { useAppStore } from '@/stores/appStore'; + +const log = loggers.app; + +const OVERLAY_LABEL = 'log-overlay'; +const DEFAULT_WIDTH = 360; +const DEFAULT_HEIGHT = 260; +const DEFAULT_X = 100; +const DEFAULT_Y = 100; + +let pollIntervalId: ReturnType | null = null; +let closeListenerCleanup: (() => void) | null = null; +let saveTickCounter = 0; +const SAVE_INTERVAL_TICKS = 15; // 每 15 次 poll (~4.5s) 保存一次几何信息 + +// ========== Handle helpers ========== + +async function queryConnectedHandle(): Promise { + const state = useAppStore.getState(); + for (const [instanceId, status] of Object.entries(state.instanceConnectionStatus)) { + if (status !== 'Connected') continue; + try { + const handle = await invoke('get_connected_window_handle', { instanceId }); + if (handle) return handle; + } catch { + // ignore + } + } + return null; +} + +function hasAnyConnection(): boolean { + const state = useAppStore.getState(); + return Object.values(state.instanceConnectionStatus).some((s) => s === 'Connected'); +} + +async function queryTargetRect(handle: number) { + const [x, y, w, h, scale] = await invoke<[number, number, number, number, number]>( + 'get_window_rect_by_handle', + { handle }, + ); + return { x, y, w, h, scale }; +} + +// ========== Close event listener ========== + +/** + * 监听悬浮窗关闭事件(由 Rust on_window_event 发出) + * 保存尺寸并同步 toggle 状态 + */ +function setupCloseListener() { + if (closeListenerCleanup) return; + + listen<{ width: number; height: number; x: number; y: number }>( + 'log-overlay-closed', + (event) => { + const { width, height } = event.payload; + log.info(`Log overlay closed, saving size: ${width}x${height}`); + + // 保存逻辑尺寸(inner_size 返回的是逻辑像素) + if (width > 0 && height > 0) { + useAppStore.getState().setLogOverlaySize(width, height); + } + + // 同步 toggle 状态 + useAppStore.getState().setLogOverlayEnabled(false); + stopPolling(); + }, + ).then((unlisten) => { + closeListenerCleanup = unlisten; + }); +} + +// ========== Screen bounds check ========== + +/** + * 确保位置在屏幕可见范围内(定点模式用) + * 使用 Tauri 的 availableMonitors API 获取所有显示器信息 + */ +async function clampToScreen( + x: number, + y: number, +): Promise<{ x: number; y: number }> { + try { + const monitors = await availableMonitors(); + if (monitors.length === 0) return { x, y }; + + // 检查位置是否在任一显示器范围内(至少有一部分可见) + const isVisible = monitors.some((m: { position: { x: number; y: number }; size: { width: number; height: number } }) => { + const mx = m.position.x; + const my = m.position.y; + const mw = m.size.width; + const mh = m.size.height; + return x + 50 > mx && x < mx + mw && y + 30 > my && y < my + mh; + }); + + if (isVisible) return { x, y }; + + // 不可见,移到主显示器左上角 + log.info('Log overlay: position out of screen, resetting'); + const primary = monitors[0] as { position: { x: number; y: number } }; + return { + x: primary.position.x + 50, + y: primary.position.y + 50, + }; + } catch { + return { x, y }; + } +} + +// ========== Window management ========== + +export async function showLogOverlay(): Promise { + if (!isTauri()) return; + + const state = useAppStore.getState(); + if (!state.logOverlayEnabled) return; + if (!hasAnyConnection()) return; + + // 确保关闭事件监听器已设置 + setupCloseListener(); + + try { + const windows = await getAllWindows(); + const existing = windows.find((w) => w.label === OVERLAY_LABEL); + + if (existing) { + await existing.show(); + startPolling(); + return; + } + + // 使用保存的尺寸或默认值 + const overlayW = state.logOverlayWidth || DEFAULT_WIDTH; + const overlayH = state.logOverlayHeight || DEFAULT_HEIGHT; + + let pos = await getInitialPosition(overlayW, overlayH); + + // 定点模式下检查屏幕范围 + if (state.logOverlayMode === 'fixed') { + pos = await clampToScreen(pos.x, pos.y); + } + + const alwaysOnTop = state.logOverlayZOrder === 'always_on_top'; + + log.info( + `Creating log overlay: pos=(${pos.x}, ${pos.y}), size=${overlayW}x${overlayH}, alwaysOnTop=${alwaysOnTop}, mode=${state.logOverlayMode}`, + ); + + await invoke('create_log_overlay_window', { + x: pos.x, + y: pos.y, + width: overlayW, + height: overlayH, + alwaysOnTop, + }); + + log.info('Log overlay window created'); + startPolling(); + } catch (err) { + log.error('Failed to create log overlay window:', err); + } +} + +export async function hideLogOverlay(): Promise { + if (!isTauri()) return; + stopPolling(); + try { + // 关闭前保存尺寸 + await saveOverlayGeometry(); + await invoke('close_log_overlay'); + log.info('Log overlay closed'); + } catch (err) { + log.error('Failed to close log overlay window:', err); + } +} + +export async function toggleLogOverlay(): Promise { + if (!isTauri()) return; + try { + const windows = await getAllWindows(); + const existing = windows.find((w) => w.label === OVERLAY_LABEL); + if (existing) { + await hideLogOverlay(); + } else { + await showLogOverlay(); + } + } catch { + // ignore + } +} + +// ========== Unified polling ========== + +function startPolling() { + if (pollIntervalId) return; + log.debug('Log overlay: starting poll'); + pollIntervalId = setInterval(pollTick, 300); + pollTick(); +} + +function stopPolling() { + if (pollIntervalId) { + clearInterval(pollIntervalId); + pollIntervalId = null; + } +} + +async function pollTick() { + const state = useAppStore.getState(); + const needFollow = state.logOverlayMode === 'follow'; + const needZOrder = state.logOverlayZOrder === 'above_target'; + + // 定期保存悬浮窗尺寸(无论什么模式都需要) + saveTickCounter++; + if (saveTickCounter >= SAVE_INTERVAL_TICKS) { + saveTickCounter = 0; + saveOverlayGeometry(); + } + + if (!needFollow && !needZOrder) return; + + const handle = await queryConnectedHandle(); + if (!handle) return; + + try { + if (needFollow) { + const windows = await getAllWindows(); + const overlayWin = windows.find((w) => w.label === OVERLAY_LABEL); + if (!overlayWin) return; + + const target = await queryTargetRect(handle); + + const physSize = await overlayWin.outerSize(); + const ow = physSize.width; + const oh = physSize.height; + + const pos = calcAnchorPosition( + target.x, target.y, target.w, target.h, + ow, oh, + state.logOverlayAnchor, + ); + await overlayWin.setPosition(new PhysicalPosition(pos.x, pos.y)); + } + + if (needZOrder) { + await invoke('set_overlay_above_target', { targetHandle: handle }); + } + } catch { + // window may have been closed or handle invalid + } +} + +/** + * 保存悬浮窗当前尺寸到 store(自动持久化到配置文件) + */ +async function saveOverlayGeometry() { + try { + const windows = await getAllWindows(); + const overlayWin = windows.find((w) => w.label === OVERLAY_LABEL); + if (!overlayWin) return; + + const size = await overlayWin.innerSize(); + if (size.width > 0 && size.height > 0) { + const { logOverlayWidth, logOverlayHeight } = useAppStore.getState(); + // 只在尺寸变化时才更新 store(避免无谓的配置写入) + if (size.width !== logOverlayWidth || size.height !== logOverlayHeight) { + useAppStore.getState().setLogOverlaySize(size.width, size.height); + } + } + } catch { + // overlay may have been closed + } +} + +// ========== Position calculation ========== + +function calcAnchorPosition( + wx: number, wy: number, ww: number, wh: number, + ow: number, oh: number, + anchor: string, +): { x: number; y: number } { + switch (anchor) { + case 'left-center': + return { x: wx, y: wy + Math.round((wh - oh) / 2) }; + case 'right-top-third': + return { x: wx + ww - ow, y: wy + Math.round(wh / 3) - Math.round(oh / 2) }; + case 'right-bottom-third': + return { x: wx + ww - ow, y: wy + Math.round((2 * wh) / 3) - Math.round(oh / 2) }; + case 'top-center': + return { x: wx + Math.round((ww - ow) / 2), y: wy }; + default: + return { x: wx + ww - ow, y: wy + Math.round(wh / 3) - Math.round(oh / 2) }; + } +} + +async function getInitialPosition( + overlayW: number, + overlayH: number, +): Promise<{ x: number; y: number }> { + const state = useAppStore.getState(); + + if (state.logOverlayMode === 'follow') { + const handle = await queryConnectedHandle(); + if (handle) { + try { + const target = await queryTargetRect(handle); + const ow = Math.round(overlayW * target.scale); + const oh = Math.round(overlayH * target.scale); + return calcAnchorPosition( + target.x, target.y, target.w, target.h, + ow, oh, + state.logOverlayAnchor, + ); + } catch { + // fallback + } + } + } + + return { x: DEFAULT_X, y: DEFAULT_Y }; +} + +export async function onOverlaySettingsChanged(): Promise { + const state = useAppStore.getState(); + + try { + if (state.logOverlayZOrder === 'always_on_top') { + await invoke('set_overlay_always_on_top', { alwaysOnTop: true }); + } else { + await invoke('set_overlay_always_on_top', { alwaysOnTop: false }); + } + } catch { + // overlay may not exist yet + } + + if (pollIntervalId) { + stopPolling(); + } + startPolling(); +} + +export function subscribeConnectionStatus(): () => void { + // 设置关闭事件监听 + setupCloseListener(); + + const unsub = useAppStore.subscribe( + (state) => ({ + connectionStatus: state.instanceConnectionStatus, + enabled: state.logOverlayEnabled, + }), + (curr, prev) => { + if (!curr.enabled) return; + + const nowConnected = Object.values(curr.connectionStatus).some((s) => s === 'Connected'); + const wasConnected = Object.values(prev.connectionStatus).some((s) => s === 'Connected'); + + if (nowConnected && !wasConnected) { + log.info('Log overlay: connection detected, showing overlay'); + setTimeout(() => showLogOverlay().catch(() => {}), 500); + } else if (!nowConnected && wasConnected) { + log.info('Log overlay: all disconnected, hiding overlay'); + hideLogOverlay().catch(() => {}); + } + }, + { equalityFn: (a, b) => JSON.stringify(a) === JSON.stringify(b) }, + ); + + return () => { + unsub(); + if (closeListenerCleanup) { + closeListenerCleanup(); + closeListenerCleanup = null; + } + }; +} diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index b31714a..2a3a825 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -977,6 +977,12 @@ export const useAppStore = create()( stopTasks: 'F11', globalEnabled: false, }, + logOverlayEnabled: config.settings.logOverlay?.enabled ?? false, + logOverlayMode: config.settings.logOverlay?.mode ?? 'fixed', + logOverlayAnchor: config.settings.logOverlay?.anchor ?? 'right-top-third', + logOverlayZOrder: config.settings.logOverlay?.zOrder ?? 'always_on_top', + logOverlayWidth: config.settings.logOverlay?.width ?? 360, + logOverlayHeight: config.settings.logOverlay?.height ?? 260, recentlyClosed: config.recentlyClosed || [], // 记录新增任务,并在有新增时自动展开添加任务面板 newTaskNames: detectedNewTaskNames, @@ -1280,6 +1286,29 @@ export const useAppStore = create()( } }, + // 日志悬浮窗设置 + logOverlayEnabled: false, + setLogOverlayEnabled: (enabled) => set({ logOverlayEnabled: enabled }), + logOverlayMode: 'fixed' as 'fixed' | 'follow', + setLogOverlayMode: (mode) => set({ logOverlayMode: mode }), + logOverlayAnchor: 'right-top-third' as 'left-center' | 'right-top-third' | 'right-bottom-third' | 'top-center', + setLogOverlayAnchor: (anchor) => set({ logOverlayAnchor: anchor }), + logOverlayZOrder: 'always_on_top' as 'always_on_top' | 'above_target', + setLogOverlayZOrder: (z) => set({ logOverlayZOrder: z }), + logOverlayWidth: 360, + logOverlayHeight: 260, + setLogOverlaySize: (width, height) => set({ logOverlayWidth: width, logOverlayHeight: height }), + + // 控制器连接的窗口句柄 + instanceConnectedHandle: {}, + setConnectedHandle: (instanceId, handle) => + set((state) => ({ + instanceConnectedHandle: { + ...state.instanceConnectedHandle, + [instanceId]: handle, + }, + })), + // 新用户引导 onboardingCompleted: false, setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }), @@ -1559,6 +1588,25 @@ export const useAppStore = create()( : DEFAULT_MAX_LOGS_PER_INSTANCE; const limit = Math.min(10000, Math.max(100, Math.floor(rawLimit))); const updatedLogs = [...logs, newLog].slice(-limit); + + // 广播日志到悬浮窗(fire-and-forget,不阻塞 store 更新) + import('@tauri-apps/api/event') + .then(({ emit }) => + emit('log-overlay-new-log', { + instanceId, + log: { + id: newLog.id, + timestamp: newLog.timestamp.getTime(), + type: newLog.type, + message: newLog.message, + html: newLog.html, + }, + }), + ) + .catch(() => { + /* ignore in non-tauri env */ + }); + return { instanceLogs: { ...state.instanceLogs, @@ -1567,13 +1615,17 @@ export const useAppStore = create()( }; }), - clearLogs: (instanceId) => - set((state) => ({ + clearLogs: (instanceId) => { + import('@tauri-apps/api/event') + .then(({ emit }) => emit('log-overlay-clear', { instanceId })) + .catch(() => {}); + return set((state) => ({ instanceLogs: { ...state.instanceLogs, [instanceId]: [], }, - })), + })); + }, // 回调 ID 与名称的映射 ctrlIdToName: {}, @@ -1660,6 +1712,14 @@ function generateConfig(): MxuConfig { minimizeToTray: state.minimizeToTray, onboardingCompleted: state.onboardingCompleted, hotkeys: state.hotkeys, + logOverlay: { + enabled: state.logOverlayEnabled, + mode: state.logOverlayMode, + anchor: state.logOverlayAnchor, + zOrder: state.logOverlayZOrder, + width: state.logOverlayWidth, + height: state.logOverlayHeight, + }, }, recentlyClosed: state.recentlyClosed, // 保存当前 interface.json 的任务名列表快照,用于下次加载时检测新增任务 @@ -1718,6 +1778,12 @@ useAppStore.subscribe( minimizeToTray: state.minimizeToTray, onboardingCompleted: state.onboardingCompleted, hotkeys: state.hotkeys, + logOverlayEnabled: state.logOverlayEnabled, + logOverlayMode: state.logOverlayMode, + logOverlayAnchor: state.logOverlayAnchor, + logOverlayZOrder: state.logOverlayZOrder, + logOverlayWidth: state.logOverlayWidth, + logOverlayHeight: state.logOverlayHeight, recentlyClosed: state.recentlyClosed, newTaskNames: state.newTaskNames, customAccents: state.customAccents, diff --git a/src/stores/types.ts b/src/stores/types.ts index 896caa4..4d70425 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -322,6 +322,27 @@ export interface AppState { minimizeToTray: boolean; setMinimizeToTray: (enabled: boolean) => void; + // 日志悬浮窗设置 + logOverlayEnabled: boolean; + setLogOverlayEnabled: (enabled: boolean) => void; + logOverlayMode: 'fixed' | 'follow'; + setLogOverlayMode: (mode: 'fixed' | 'follow') => void; + /** 跟随模式下的锚定位置 */ + logOverlayAnchor: 'left-center' | 'right-top-third' | 'right-bottom-third' | 'top-center'; + setLogOverlayAnchor: (anchor: 'left-center' | 'right-top-third' | 'right-bottom-third' | 'top-center') => void; + /** 悬浮窗层级:always_on_top 最置顶,above_target 只在连接窗口上一层 */ + logOverlayZOrder: 'always_on_top' | 'above_target'; + setLogOverlayZOrder: (z: 'always_on_top' | 'above_target') => void; + /** 悬浮窗逻辑宽度 */ + logOverlayWidth: number; + /** 悬浮窗逻辑高度 */ + logOverlayHeight: number; + setLogOverlaySize: (width: number, height: number) => void; + + // 控制器连接的窗口句柄(Win32/Gamepad) + instanceConnectedHandle: Record; + setConnectedHandle: (instanceId: string, handle: number | null) => void; + // 启动后自动执行的实例 ID autoStartInstanceId: string | undefined; setAutoStartInstanceId: (id: string | undefined) => void; diff --git a/src/types/config.ts b/src/types/config.ts index 3987dc3..199f37a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -103,6 +103,18 @@ export interface HotkeySettings { globalEnabled?: boolean; } +// 日志悬浮窗配置 +export interface LogOverlaySettings { + enabled: boolean; + mode: 'fixed' | 'follow'; + anchor?: 'left-center' | 'right-top-third' | 'right-bottom-third' | 'top-center'; + zOrder?: 'always_on_top' | 'above_target'; + /** 悬浮窗逻辑宽度 */ + width?: number; + /** 悬浮窗逻辑高度 */ + height?: number; +} + // 应用设置 export interface AppSettings { theme: 'light' | 'dark' | 'system'; @@ -132,6 +144,7 @@ export interface AppSettings { autoStartInstanceId?: string; // 启动后自动执行的实例 ID(为空或 undefined 表示不自动执行) autoRunOnLaunch?: boolean; // 非开机自启动的手动启动场景下,是否也自动执行选定的实例(默认 false) autoStartRemovedInstanceName?: string; // 被删除的自动执行配置名称(用于提示用户) + logOverlay?: LogOverlaySettings; // 日志悬浮窗配置 } // MXU 配置文件完整结构 From b2643ceb3dffb390d9b299f81e539c135bd93203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E6=9D=89=E3=81=88=E3=82=8A=E3=81=84?= <47024820+niechy@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:59:12 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20macOS=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=A4=B1=E8=B4=A5=EF=BC=8C=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=20i18n=EF=BC=8C=E6=94=AF=E6=8C=81=20ADB=20=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E8=B7=9F=E9=9A=8F=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 macOS 构建:transparent() 仅在 Windows 上使用 - Cargo.toml 补全 Win32_Graphics_Dwm 和 Win32_UI_HiDpi features - 新增 set_connected_window_handle 命令,支持 ADB 手动指定跟随窗口 - LogsPanel 添加窗口选择器(ADB + 跟随模式时显示) - 「最置顶」改为「置顶」 - 补全 zh-TW、ja-JP、ko-KR 的日志悬浮窗 i18n Co-authored-by: Cursor --- src-tauri/Cargo.toml | 10 +--- src-tauri/src/commands/system.rs | 33 ++++++++++-- src-tauri/src/lib.rs | 1 + src/components/LogsPanel.tsx | 92 +++++++++++++++++++++++++++++++- src/i18n/locales/en-US.ts | 5 +- src/i18n/locales/ja-JP.ts | 16 ++++++ src/i18n/locales/ko-KR.ts | 16 ++++++ src/i18n/locales/zh-CN.ts | 5 +- src/i18n/locales/zh-TW.ts | 16 ++++++ 9 files changed, 179 insertions(+), 15 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3984af4..86ba0cd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,16 +51,11 @@ shell-words = "1.1.1" [profile.release] # 保留调试符号以生成 PDB 文件,便于崩溃分析 debug = true -# 编译单元数量 - 增加到 4 可以减少内存占用(特别是在链接阶段) -# 设置为 1 会最大化优化但增加内存占用,设置为 4 是内存和性能的平衡点 -codegen-units = 4 -# 使用薄 LTO 而不是完整 LTO,在保持优化效果的同时加快编译速度并减少内存占用 -# 如果内存仍然不足,可以注释掉这一行(但会略微降低运行时性能) -lto = "thin" [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", + "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_LibraryLoader", @@ -68,9 +63,8 @@ windows = { version = "0.58", features = [ "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Controls", + "Win32_UI_HiDpi", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", - "Win32_UI_HiDpi", - "Win32_Graphics_Dwm", ] } diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 9b7e8a6..fb6211c 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -355,7 +355,7 @@ pub async fn create_log_overlay_window( return Ok(()); } - let window = tauri::WebviewWindowBuilder::new( + let mut builder = tauri::WebviewWindowBuilder::new( &app_handle, label, tauri::WebviewUrl::App("log-overlay.html".into()), @@ -366,8 +366,15 @@ pub async fn create_log_overlay_window( .resizable(true) .always_on_top(always_on_top) .skip_taskbar(true) - .transparent(true) - .visible(false) + .visible(false); + + // transparent() 在 macOS 上需要 macos-private-api feature,仅 Windows 启用 + #[cfg(target_os = "windows")] + { + builder = builder.transparent(true); + } + + let window = builder .build() .map_err(|e| format!("Failed to create log overlay window: {}", e))?; @@ -398,6 +405,26 @@ pub fn get_connected_window_handle( } } +/// 手动设置实例的跟随窗口句柄(用于 ADB 控制器手动选择模拟器窗口) +#[tauri::command] +pub fn set_connected_window_handle( + state: tauri::State>, + instance_id: String, + handle: Option, +) -> Result<(), String> { + let mut instances = state.instances.lock().map_err(|e| e.to_string())?; + if let Some(instance) = instances.get_mut(&instance_id) { + instance.connected_window_handle = handle.map(|h| h as u64); + info!( + "Manually set connected window handle for instance {}: {:?}", + instance_id, handle + ); + Ok(()) + } else { + Err("Instance not found".to_string()) + } +} + /// 获取指定窗口的可见区域位置和大小 (物理像素, Windows only) /// /// 返回 (x, y, width, height, scale_factor) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c1a138..dbdd445 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -176,6 +176,7 @@ pub fn run() { commands::system::get_system_info, commands::system::create_log_overlay_window, commands::system::get_connected_window_handle, + commands::system::set_connected_window_handle, commands::system::get_window_rect_by_handle, commands::system::set_overlay_above_target, commands::system::set_overlay_always_on_top, diff --git a/src/components/LogsPanel.tsx b/src/components/LogsPanel.tsx index 0f66d07..fb98eac 100644 --- a/src/components/LogsPanel.tsx +++ b/src/components/LogsPanel.tsx @@ -1,12 +1,13 @@ -import { useRef, useEffect, useCallback } from 'react'; +import { useRef, useEffect, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Trash2, Copy, ChevronUp, ChevronDown, Archive, MonitorUp } from 'lucide-react'; +import { Trash2, Copy, ChevronUp, ChevronDown, Archive, MonitorUp, RefreshCw } from 'lucide-react'; import clsx from 'clsx'; import { useAppStore, type LogType } from '@/stores/appStore'; import { ContextMenu, useContextMenu, type MenuItem } from './ContextMenu'; import { isTauri } from '@/utils/paths'; import { useExportLogs } from '@/utils/useExportLogs'; import { ExportLogsModal } from './settings/ExportLogsModal'; +import type { Win32Window } from '@/types/maa'; export function LogsPanel() { const { t } = useTranslation(); @@ -14,7 +15,57 @@ export function LogsPanel() { const { sidePanelExpanded, toggleSidePanelExpanded, activeInstanceId, instanceLogs, clearLogs, logOverlayEnabled, setLogOverlayEnabled, + logOverlayMode, selectedController: selectedControllerMap, projectInterface, } = useAppStore(); + + // ADB 跟随窗口选择器 + const [followWindows, setFollowWindows] = useState([]); + const [followWindowHandle, setFollowWindowHandle] = useState(null); + const [followWindowLoading, setFollowWindowLoading] = useState(false); + + const activeControllerName = activeInstanceId ? selectedControllerMap[activeInstanceId] : undefined; + const activeControllerDef = projectInterface?.controller.find((c) => c.name === activeControllerName); + const isAdbController = activeControllerDef?.type === 'Adb' || activeControllerDef?.type === 'PlayCover'; + const showWindowPicker = logOverlayEnabled && logOverlayMode === 'follow' && isAdbController; + + // 切换实例时重置选中的窗口句柄 + useEffect(() => { + setFollowWindowHandle(null); + }, [activeInstanceId]); + + const refreshFollowWindows = useCallback(async () => { + if (!isTauri()) return; + setFollowWindowLoading(true); + try { + const { invoke } = await import('@tauri-apps/api/core'); + const windows = await invoke('maa_find_win32_windows', { + classRegex: '', + windowRegex: '', + }); + setFollowWindows(windows.filter((w) => w.window_name.trim().length > 0)); + } catch { + // ignore + } finally { + setFollowWindowLoading(false); + } + }, []); + + const selectFollowWindow = useCallback(async (handle: number | null) => { + if (!isTauri() || !activeInstanceId) return; + setFollowWindowHandle(handle); + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('set_connected_window_handle', { + instanceId: activeInstanceId, + handle, + }); + import('@/services/logOverlayService').then(({ onOverlaySettingsChanged }) => + onOverlaySettingsChanged(), + ); + } catch { + // ignore + } + }, [activeInstanceId]); const { state: menuState, show: showMenu, hide: hideMenu } = useContextMenu(); const { exportModal, handleExportLogs, closeExportModal, openExportedFile } = useExportLogs(); @@ -165,6 +216,43 @@ export function LogsPanel() { )} + {/* ADB 模式下选择跟随窗口 */} + {showWindowPicker && ( + <> + + + + )} {/* 导出日志 */} + + {t('settings.logOverlayRefreshWindows')} + +
+
    +
  • + +
  • + {followWindows.map((w) => ( +
  • + +
  • + ))} +
+ + ) : ( + + {t('settings.logOverlayFollowWindowAuto')} + + )} +
+ + )} + + {/* 窗口层级 */} +
+ + {t('settings.logOverlayZOrder')} + + +
+ + )} + + , + document.body, + ); +} diff --git a/src/components/LogsPanel.tsx b/src/components/LogsPanel.tsx index fb98eac..a7bd154 100644 --- a/src/components/LogsPanel.tsx +++ b/src/components/LogsPanel.tsx @@ -1,71 +1,26 @@ import { useRef, useEffect, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Trash2, Copy, ChevronUp, ChevronDown, Archive, MonitorUp, RefreshCw } from 'lucide-react'; +import { Trash2, Copy, ChevronUp, ChevronDown, Archive, MonitorUp } from 'lucide-react'; import clsx from 'clsx'; import { useAppStore, type LogType } from '@/stores/appStore'; import { ContextMenu, useContextMenu, type MenuItem } from './ContextMenu'; import { isTauri } from '@/utils/paths'; import { useExportLogs } from '@/utils/useExportLogs'; import { ExportLogsModal } from './settings/ExportLogsModal'; -import type { Win32Window } from '@/types/maa'; +import { LogOverlayPopover } from './LogOverlayPopover'; export function LogsPanel() { const { t } = useTranslation(); const logsEndRef = useRef(null); + const [overlayPopoverOpen, setOverlayPopoverOpen] = useState(false); + const overlayTriggerRef = useRef(null); + // popover 关闭瞬间设为 true,防止同一次点击触发标题栏的 toggleSidePanelExpanded + const popoverJustClosedRef = useRef(false); const { sidePanelExpanded, toggleSidePanelExpanded, activeInstanceId, instanceLogs, clearLogs, - logOverlayEnabled, setLogOverlayEnabled, - logOverlayMode, selectedController: selectedControllerMap, projectInterface, + logOverlayEnabled, } = useAppStore(); - // ADB 跟随窗口选择器 - const [followWindows, setFollowWindows] = useState([]); - const [followWindowHandle, setFollowWindowHandle] = useState(null); - const [followWindowLoading, setFollowWindowLoading] = useState(false); - - const activeControllerName = activeInstanceId ? selectedControllerMap[activeInstanceId] : undefined; - const activeControllerDef = projectInterface?.controller.find((c) => c.name === activeControllerName); - const isAdbController = activeControllerDef?.type === 'Adb' || activeControllerDef?.type === 'PlayCover'; - const showWindowPicker = logOverlayEnabled && logOverlayMode === 'follow' && isAdbController; - - // 切换实例时重置选中的窗口句柄 - useEffect(() => { - setFollowWindowHandle(null); - }, [activeInstanceId]); - - const refreshFollowWindows = useCallback(async () => { - if (!isTauri()) return; - setFollowWindowLoading(true); - try { - const { invoke } = await import('@tauri-apps/api/core'); - const windows = await invoke('maa_find_win32_windows', { - classRegex: '', - windowRegex: '', - }); - setFollowWindows(windows.filter((w) => w.window_name.trim().length > 0)); - } catch { - // ignore - } finally { - setFollowWindowLoading(false); - } - }, []); - - const selectFollowWindow = useCallback(async (handle: number | null) => { - if (!isTauri() || !activeInstanceId) return; - setFollowWindowHandle(handle); - try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('set_connected_window_handle', { - instanceId: activeInstanceId, - handle, - }); - import('@/services/logOverlayService').then(({ onOverlaySettingsChanged }) => - onOverlaySettingsChanged(), - ); - } catch { - // ignore - } - }, [activeInstanceId]); const { state: menuState, show: showMenu, hide: hideMenu } = useContextMenu(); const { exportModal, handleExportLogs, closeExportModal, openExportedFile } = useExportLogs(); @@ -161,25 +116,20 @@ export function LogsPanel() { ], ); - // 根据日志类型获取前缀标签 - const getLogPrefix = (type: LogType) => { - switch (type) { - case 'agent': - return ''; - case 'focus': - return ''; - default: - return ''; - } - }; - return (
{/* 标题栏(可点击展开/折叠上方面板) */}
{ + // popover 刚关闭时跳过,避免同一次点击既关闭 popover 又切换面板 + if (popoverJustClosedRef.current) { + popoverJustClosedRef.current = false; + return; + } + toggleSidePanelExpanded(); + }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); @@ -190,67 +140,40 @@ export function LogsPanel() { > {t('logs.title')}
- {/* 日志悬浮窗开关 */} + {/* 日志悬浮窗:点击打开统一设置弹层 */} {isTauri() && ( - - )} - {/* ADB 模式下选择跟随窗口 */} - {showWindowPicker && ( <> - + { + popoverJustClosedRef.current = true; + setOverlayPopoverOpen(false); + // 下一帧重置,确保后续点击正常触发 toggle + requestAnimationFrame(() => { + popoverJustClosedRef.current = false; + }); + }} + anchorRef={overlayTriggerRef} + /> )} {/* 导出日志 */} @@ -333,7 +256,6 @@ export function LogsPanel() { [{log.timestamp.toLocaleTimeString()}] - {getLogPrefix(log.type)} {log.message}
diff --git a/src/components/settings/GeneralSection.tsx b/src/components/settings/GeneralSection.tsx index bf9c22a..5581b13 100644 --- a/src/components/settings/GeneralSection.tsx +++ b/src/components/settings/GeneralSection.tsx @@ -36,14 +36,6 @@ export function GeneralSection() { autoRunOnLaunch, setAutoRunOnLaunch, autoStartRemovedInstanceName, - logOverlayEnabled, - setLogOverlayEnabled, - logOverlayMode, - setLogOverlayMode, - logOverlayAnchor, - setLogOverlayAnchor, - logOverlayZOrder, - setLogOverlayZOrder, } = useAppStore(); // 开机自启动状态(直接从 Tauri 插件查询,不走 store) @@ -295,106 +287,6 @@ export function GeneralSection() {
- {/* ⑧ 日志悬浮窗 */} - {isTauri() && ( -
-
-
- -
- - {t('settings.logOverlay')} - -

- {t('settings.logOverlayHint')} -

-
-
- { - setLogOverlayEnabled(v); - if (v) { - import('@/services/logOverlayService').then(({ showLogOverlay }) => showLogOverlay()); - } else { - import('@/services/logOverlayService').then(({ hideLogOverlay }) => hideLogOverlay()); - } - }} - /> -
- {logOverlayEnabled && ( -
- {/* 模式选择 */} -
- - {t('settings.logOverlayMode')} - - -
- - {/* 跟随模式下的锚定位置 */} - {logOverlayMode === 'follow' && ( -
- - {t('settings.logOverlayAnchor')} - - -
- )} - - {/* 窗口层级 */} -
- - {t('settings.logOverlayZOrder')} - - -
-
- )} -
- )} - {/* ⑨ 重置窗口布局 */} {isTauri() && (
diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index e42e893..0d913d4 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -114,8 +114,10 @@ export default { logOverlayAnchorRightTop: 'Right Top (1/3)', logOverlayAnchorRightBottom: 'Right Bottom (2/3)', logOverlayAnchorTopCenter: 'Top Center', + logOverlayEnable: 'Enable', logOverlayFollowWindow: 'Follow Window', logOverlayFollowWindowNone: 'Not selected (auto)', + logOverlayFollowWindowAuto: 'Connected window', logOverlayRefreshWindows: 'Refresh window list', logOverlayZOrder: 'Window Layer', logOverlayZOrderTop: 'On Top', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 8b487c5..d503f1d 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -112,8 +112,10 @@ export default { logOverlayAnchorRightTop: '右上(1/3)', logOverlayAnchorRightBottom: '右下(2/3)', logOverlayAnchorTopCenter: '上中央', + logOverlayEnable: '有効', logOverlayFollowWindow: '追従ウィンドウ', logOverlayFollowWindowNone: '未選択(自動)', + logOverlayFollowWindowAuto: '接続中のウィンドウ', logOverlayRefreshWindows: 'ウィンドウ一覧を更新', maxLogsPerInstance: 'インスタンスあたりのログ上限', maxLogsPerInstanceHint: '上限を超えると古いログから自動的に破棄します(推奨 500~2000)', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index c3f80d4..ded4c97 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -112,8 +112,10 @@ export default { logOverlayAnchorRightTop: '오른쪽 위 (1/3)', logOverlayAnchorRightBottom: '오른쪽 아래 (2/3)', logOverlayAnchorTopCenter: '위쪽 가운데', + logOverlayEnable: '사용', logOverlayFollowWindow: '따라갈 창', logOverlayFollowWindowNone: '선택 안 함 (자동)', + logOverlayFollowWindowAuto: '연결된 창', logOverlayRefreshWindows: '창 목록 새로고침', maxLogsPerInstance: '인스턴스당 로그 최대 개수', maxLogsPerInstanceHint: '한도를 초과하면 가장 오래된 로그가 자동으로 삭제됩니다(권장 500~2000)', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 6eb56fc..06a9177 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -111,8 +111,10 @@ export default { logOverlayAnchorRightTop: '右上(1/3处)', logOverlayAnchorRightBottom: '右下(2/3处)', logOverlayAnchorTopCenter: '上中', + logOverlayEnable: '开启', logOverlayFollowWindow: '跟随窗口', logOverlayFollowWindowNone: '未选择(自动)', + logOverlayFollowWindowAuto: '已连接窗口', logOverlayRefreshWindows: '刷新窗口列表', maxLogsPerInstance: '每个实例保留的日志上限', maxLogsPerInstanceHint: '超过上限会自动丢弃最旧的日志(建议 500~2000)', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 8800938..294dba7 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -110,8 +110,10 @@ export default { logOverlayAnchorRightTop: '右上(1/3 處)', logOverlayAnchorRightBottom: '右下(2/3 處)', logOverlayAnchorTopCenter: '上中', + logOverlayEnable: '開啟', logOverlayFollowWindow: '跟隨視窗', logOverlayFollowWindowNone: '未選擇(自動)', + logOverlayFollowWindowAuto: '已連接視窗', logOverlayRefreshWindows: '重新整理視窗列表', maxLogsPerInstance: '每個實例保留的日誌上限', maxLogsPerInstanceHint: '超出上限會自動丟棄最舊的日誌(建議 500~2000)', diff --git a/src/services/logOverlayService.ts b/src/services/logOverlayService.ts index 3b4ca40..32ba292 100644 --- a/src/services/logOverlayService.ts +++ b/src/services/logOverlayService.ts @@ -29,6 +29,9 @@ const DEFAULT_Y = 100; let pollIntervalId: ReturnType | null = null; let closeListenerCleanup: (() => void) | null = null; +let closeListenerPending = false; +/** 程序主动关闭悬浮窗时为 true,close 事件中不修改 enabled 状态 */ +let programmaticClose = false; let saveTickCounter = 0; const SAVE_INTERVAL_TICKS = 15; // 每 15 次 poll (~4.5s) 保存一次几何信息 @@ -36,7 +39,25 @@ const SAVE_INTERVAL_TICKS = 15; // 每 15 次 poll (~4.5s) 保存一次几何信 async function queryConnectedHandle(): Promise { const state = useAppStore.getState(); + + // 优先查询当前激活实例的窗口句柄 + if (state.activeInstanceId) { + const activeStatus = state.instanceConnectionStatus[state.activeInstanceId]; + if (activeStatus === 'Connected') { + try { + const handle = await invoke('get_connected_window_handle', { + instanceId: state.activeInstanceId, + }); + if (handle) return handle; + } catch { + // ignore + } + } + } + + // 回退:查找任意已连接实例 for (const [instanceId, status] of Object.entries(state.instanceConnectionStatus)) { + if (instanceId === state.activeInstanceId) continue; // 已经查过了 if (status !== 'Connected') continue; try { const handle = await invoke('get_connected_window_handle', { instanceId }); @@ -68,25 +89,36 @@ async function queryTargetRect(handle: number) { * 保存尺寸并同步 toggle 状态 */ function setupCloseListener() { - if (closeListenerCleanup) return; + if (closeListenerCleanup || closeListenerPending) return; + closeListenerPending = true; listen<{ width: number; height: number; x: number; y: number }>( 'log-overlay-closed', (event) => { - const { width, height } = event.payload; - log.info(`Log overlay closed, saving size: ${width}x${height}`); + const { width, height, x, y } = event.payload; + log.info(`Log overlay closed, saving size: ${width}x${height}, pos: (${x}, ${y})`); - // 保存逻辑尺寸(inner_size 返回的是逻辑像素) + // 保存逻辑尺寸(Rust 端已将物理像素转为逻辑像素) if (width > 0 && height > 0) { useAppStore.getState().setLogOverlaySize(width, height); } - // 同步 toggle 状态 - useAppStore.getState().setLogOverlayEnabled(false); + // 保存位置(物理像素,与创建时一致) + if (x != null && y != null) { + useAppStore.getState().setLogOverlayPosition(x, y); + } + + // 仅用户手动关闭时同步 enabled 状态; + // 程序主动关闭(断开连接等)不修改 enabled,保留用户偏好 + if (!programmaticClose) { + useAppStore.getState().setLogOverlayEnabled(false); + } + programmaticClose = false; stopPolling(); }, ).then((unlisten) => { closeListenerCleanup = unlisten; + closeListenerPending = false; }); } @@ -105,7 +137,7 @@ async function clampToScreen( if (monitors.length === 0) return { x, y }; // 检查位置是否在任一显示器范围内(至少有一部分可见) - const isVisible = monitors.some((m: { position: { x: number; y: number }; size: { width: number; height: number } }) => { + const isVisible = monitors.some((m) => { const mx = m.position.x; const my = m.position.y; const mw = m.size.width; @@ -117,7 +149,7 @@ async function clampToScreen( // 不可见,移到主显示器左上角 log.info('Log overlay: position out of screen, resetting'); - const primary = monitors[0] as { position: { x: number; y: number } }; + const primary = monitors[0]; return { x: primary.position.x + 50, y: primary.position.y + 50, @@ -181,15 +213,20 @@ export async function showLogOverlay(): Promise { } } -export async function hideLogOverlay(): Promise { +export async function hideLogOverlay(keepEnabled = false): Promise { if (!isTauri()) return; stopPolling(); try { // 关闭前保存尺寸 await saveOverlayGeometry(); + // 标记为程序主动关闭,close 事件中不修改 enabled 状态 + if (keepEnabled) { + programmaticClose = true; + } await invoke('close_log_overlay'); log.info('Log overlay closed'); } catch (err) { + programmaticClose = false; log.error('Failed to close log overlay window:', err); } } @@ -271,7 +308,7 @@ async function pollTick() { } /** - * 保存悬浮窗当前尺寸到 store(自动持久化到配置文件) + * 保存悬浮窗当前尺寸和位置到 store(自动持久化到配置文件) */ async function saveOverlayGeometry() { try { @@ -279,14 +316,24 @@ async function saveOverlayGeometry() { const overlayWin = windows.find((w) => w.label === OVERLAY_LABEL); if (!overlayWin) return; - const size = await overlayWin.innerSize(); - if (size.width > 0 && size.height > 0) { + const physSize = await overlayWin.innerSize(); + const scaleFactor = await overlayWin.scaleFactor(); + // inner_size 返回物理像素,创建窗口时用逻辑像素,需要转换 + const logicalW = Math.round(physSize.width / scaleFactor); + const logicalH = Math.round(physSize.height / scaleFactor); + if (logicalW > 0 && logicalH > 0) { const { logOverlayWidth, logOverlayHeight } = useAppStore.getState(); - // 只在尺寸变化时才更新 store(避免无谓的配置写入) - if (size.width !== logOverlayWidth || size.height !== logOverlayHeight) { - useAppStore.getState().setLogOverlaySize(size.width, size.height); + if (logicalW !== logOverlayWidth || logicalH !== logOverlayHeight) { + useAppStore.getState().setLogOverlaySize(logicalW, logicalH); } } + + // 保存位置(outer_position 返回物理像素,与创建时 PhysicalPosition 一致) + const physPos = await overlayWin.outerPosition(); + const { logOverlayX, logOverlayY } = useAppStore.getState(); + if (physPos.x !== logOverlayX || physPos.y !== logOverlayY) { + useAppStore.getState().setLogOverlayPosition(physPos.x, physPos.y); + } } catch { // overlay may have been closed } @@ -337,18 +384,25 @@ async function getInitialPosition( } } + // 定点模式:优先使用上次保存的位置 + if (state.logOverlayX != null && state.logOverlayY != null) { + return { x: state.logOverlayX, y: state.logOverlayY }; + } + return { x: DEFAULT_X, y: DEFAULT_Y }; } export async function onOverlaySettingsChanged(): Promise { const state = useAppStore.getState(); + // 检查悬浮窗是否存在,不存在则无需操作 + const windows = await getAllWindows(); + if (!windows.some((w) => w.label === OVERLAY_LABEL)) return; + try { - if (state.logOverlayZOrder === 'always_on_top') { - await invoke('set_overlay_always_on_top', { alwaysOnTop: true }); - } else { - await invoke('set_overlay_always_on_top', { alwaysOnTop: false }); - } + await invoke('set_overlay_always_on_top', { + alwaysOnTop: state.logOverlayZOrder === 'always_on_top', + }); } catch { // overlay may not exist yet } @@ -378,8 +432,8 @@ export function subscribeConnectionStatus(): () => void { log.info('Log overlay: connection detected, showing overlay'); setTimeout(() => showLogOverlay().catch(() => {}), 500); } else if (!nowConnected && wasConnected) { - log.info('Log overlay: all disconnected, hiding overlay'); - hideLogOverlay().catch(() => {}); + log.info('Log overlay: all disconnected, hiding overlay (keeping enabled)'); + hideLogOverlay(true).catch(() => {}); } }, { equalityFn: (a, b) => JSON.stringify(a) === JSON.stringify(b) }, diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 2a3a825..c6f47c5 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -983,6 +983,8 @@ export const useAppStore = create()( logOverlayZOrder: config.settings.logOverlay?.zOrder ?? 'always_on_top', logOverlayWidth: config.settings.logOverlay?.width ?? 360, logOverlayHeight: config.settings.logOverlay?.height ?? 260, + logOverlayX: config.settings.logOverlay?.x ?? null, + logOverlayY: config.settings.logOverlay?.y ?? null, recentlyClosed: config.recentlyClosed || [], // 记录新增任务,并在有新增时自动展开添加任务面板 newTaskNames: detectedNewTaskNames, @@ -1298,6 +1300,9 @@ export const useAppStore = create()( logOverlayWidth: 360, logOverlayHeight: 260, setLogOverlaySize: (width, height) => set({ logOverlayWidth: width, logOverlayHeight: height }), + logOverlayX: null as number | null, + logOverlayY: null as number | null, + setLogOverlayPosition: (x, y) => set({ logOverlayX: x, logOverlayY: y }), // 控制器连接的窗口句柄 instanceConnectedHandle: {}, @@ -1719,6 +1724,8 @@ function generateConfig(): MxuConfig { zOrder: state.logOverlayZOrder, width: state.logOverlayWidth, height: state.logOverlayHeight, + x: state.logOverlayX ?? undefined, + y: state.logOverlayY ?? undefined, }, }, recentlyClosed: state.recentlyClosed, @@ -1784,6 +1791,8 @@ useAppStore.subscribe( logOverlayZOrder: state.logOverlayZOrder, logOverlayWidth: state.logOverlayWidth, logOverlayHeight: state.logOverlayHeight, + logOverlayX: state.logOverlayX, + logOverlayY: state.logOverlayY, recentlyClosed: state.recentlyClosed, newTaskNames: state.newTaskNames, customAccents: state.customAccents, diff --git a/src/stores/types.ts b/src/stores/types.ts index 4d70425..460a01e 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -338,6 +338,11 @@ export interface AppState { /** 悬浮窗逻辑高度 */ logOverlayHeight: number; setLogOverlaySize: (width: number, height: number) => void; + /** 悬浮窗上次位置 X(物理像素,与 Win32 API / PhysicalPosition 一致) */ + logOverlayX: number | null; + /** 悬浮窗上次位置 Y(物理像素,与 Win32 API / PhysicalPosition 一致) */ + logOverlayY: number | null; + setLogOverlayPosition: (x: number, y: number) => void; // 控制器连接的窗口句柄(Win32/Gamepad) instanceConnectedHandle: Record; diff --git a/src/types/config.ts b/src/types/config.ts index 199f37a..8737ae6 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -113,6 +113,10 @@ export interface LogOverlaySettings { width?: number; /** 悬浮窗逻辑高度 */ height?: number; + /** 悬浮窗上次位置 X(物理像素) */ + x?: number; + /** 悬浮窗上次位置 Y(物理像素) */ + y?: number; } // 应用设置 diff --git a/vite.config.ts b/vite.config.ts index f3df063..b5eebee 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; -import path from "path"; +import path, { resolve } from "path"; import { readFileSync } from "node:fs"; // @ts-expect-error process is a nodejs global @@ -24,7 +24,17 @@ export default defineConfig(async () => ({ __MXU_VERSION__: JSON.stringify(mxuVersion), }, build: { + // 启用压缩以减小输出大小 + minify: "esbuild", + // 禁用 source map 以加快构建速度(生产环境通常不需要) + sourcemap: false, + // 优化 chunk 大小 + chunkSizeWarningLimit: 1000, rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + "log-overlay": resolve(__dirname, "log-overlay.html"), + }, output: { manualChunks(id) { const normalizedId = id.replace(/\\/g, "/"); From 010c768f32952689274be8cd0b2d2dd194579880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E6=9D=89=E3=81=88=E3=82=8A=E3=81=84?= <47024820+niechy@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:46:05 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=9C=AA=E9=80=89=E6=8B=A9=E9=80=89=E9=A1=B9?= =?UTF-8?q?=E4=B8=AD=E5=A4=9A=E4=BD=99=E7=9A=84"=EF=BC=88=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=EF=BC=89"=E5=90=8E=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/i18n/locales/en-US.ts | 2 +- src/i18n/locales/ja-JP.ts | 2 +- src/i18n/locales/ko-KR.ts | 2 +- src/i18n/locales/zh-CN.ts | 2 +- src/i18n/locales/zh-TW.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 0d913d4..625bd5c 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -116,7 +116,7 @@ export default { logOverlayAnchorTopCenter: 'Top Center', logOverlayEnable: 'Enable', logOverlayFollowWindow: 'Follow Window', - logOverlayFollowWindowNone: 'Not selected (auto)', + logOverlayFollowWindowNone: 'Not selected', logOverlayFollowWindowAuto: 'Connected window', logOverlayRefreshWindows: 'Refresh window list', logOverlayZOrder: 'Window Layer', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index d503f1d..24a3214 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -114,7 +114,7 @@ export default { logOverlayAnchorTopCenter: '上中央', logOverlayEnable: '有効', logOverlayFollowWindow: '追従ウィンドウ', - logOverlayFollowWindowNone: '未選択(自動)', + logOverlayFollowWindowNone: '未選択', logOverlayFollowWindowAuto: '接続中のウィンドウ', logOverlayRefreshWindows: 'ウィンドウ一覧を更新', maxLogsPerInstance: 'インスタンスあたりのログ上限', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index ded4c97..19e36ec 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -114,7 +114,7 @@ export default { logOverlayAnchorTopCenter: '위쪽 가운데', logOverlayEnable: '사용', logOverlayFollowWindow: '따라갈 창', - logOverlayFollowWindowNone: '선택 안 함 (자동)', + logOverlayFollowWindowNone: '선택 안 함', logOverlayFollowWindowAuto: '연결된 창', logOverlayRefreshWindows: '창 목록 새로고침', maxLogsPerInstance: '인스턴스당 로그 최대 개수', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 06a9177..ba78add 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -113,7 +113,7 @@ export default { logOverlayAnchorTopCenter: '上中', logOverlayEnable: '开启', logOverlayFollowWindow: '跟随窗口', - logOverlayFollowWindowNone: '未选择(自动)', + logOverlayFollowWindowNone: '未选择', logOverlayFollowWindowAuto: '已连接窗口', logOverlayRefreshWindows: '刷新窗口列表', maxLogsPerInstance: '每个实例保留的日志上限', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 294dba7..529eace 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -112,7 +112,7 @@ export default { logOverlayAnchorTopCenter: '上中', logOverlayEnable: '開啟', logOverlayFollowWindow: '跟隨視窗', - logOverlayFollowWindowNone: '未選擇(自動)', + logOverlayFollowWindowNone: '未選擇', logOverlayFollowWindowAuto: '已連接視窗', logOverlayRefreshWindows: '重新整理視窗列表', maxLogsPerInstance: '每個實例保留的日誌上限', From f98652390ec6baa9e0d362d4d68845af7762f494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8A=E6=9D=89=E3=81=88=E3=82=8A=E3=81=84?= <47024820+niechy@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:06:52 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20code=20review?= =?UTF-8?q?=20=E6=84=8F=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 clampToScreen 添加坐标空间注释,明确物理像素一致性 - subscribeConnectionStatus 用轻量布尔比较替代 JSON.stringify - 悬浮窗空状态文案改用 i18n,入口初始化 i18n Co-authored-by: Cursor --- src/components/LogOverlay.tsx | 4 +++- src/i18n/locales/en-US.ts | 1 + src/i18n/locales/ja-JP.ts | 1 + src/i18n/locales/ko-KR.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + src/i18n/locales/zh-TW.ts | 1 + src/logOverlay.tsx | 1 + src/services/logOverlayService.ts | 21 +++++++++++++-------- 8 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/components/LogOverlay.tsx b/src/components/LogOverlay.tsx index 9f373fb..a8efab3 100644 --- a/src/components/LogOverlay.tsx +++ b/src/components/LogOverlay.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { X, GripHorizontal } from 'lucide-react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { listen } from '@tauri-apps/api/event'; @@ -17,6 +18,7 @@ interface OverlayLogEntry { const MAX_OVERLAY_LOGS = 200; export function LogOverlayApp() { + const { t } = useTranslation(); const [logs, setLogs] = useState([]); const [isHovered, setIsHovered] = useState(false); const logsEndRef = useRef(null); @@ -146,7 +148,7 @@ export function LogOverlayApp() { > {logs.length === 0 ? (
- 等待日志... + {t('settings.logOverlayWaitingLogs')}
) : ( <> diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 625bd5c..c70a3bb 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -119,6 +119,7 @@ export default { logOverlayFollowWindowNone: 'Not selected', logOverlayFollowWindowAuto: 'Connected window', logOverlayRefreshWindows: 'Refresh window list', + logOverlayWaitingLogs: 'Waiting for logs...', logOverlayZOrder: 'Window Layer', logOverlayZOrderTop: 'On Top', logOverlayZOrderAboveTarget: 'Above Connected Window', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 24a3214..ca9e98a 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -117,6 +117,7 @@ export default { logOverlayFollowWindowNone: '未選択', logOverlayFollowWindowAuto: '接続中のウィンドウ', logOverlayRefreshWindows: 'ウィンドウ一覧を更新', + logOverlayWaitingLogs: 'ログを待機中...', maxLogsPerInstance: 'インスタンスあたりのログ上限', maxLogsPerInstanceHint: '上限を超えると古いログから自動的に破棄します(推奨 500~2000)', resetWindowLayout: 'ウィンドウレイアウトをリセット', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index 19e36ec..1a530a2 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -117,6 +117,7 @@ export default { logOverlayFollowWindowNone: '선택 안 함', logOverlayFollowWindowAuto: '연결된 창', logOverlayRefreshWindows: '창 목록 새로고침', + logOverlayWaitingLogs: '로그 대기 중...', maxLogsPerInstance: '인스턴스당 로그 최대 개수', maxLogsPerInstanceHint: '한도를 초과하면 가장 오래된 로그가 자동으로 삭제됩니다(권장 500~2000)', resetWindowLayout: '창 레이아웃 초기화', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index ba78add..2ba65e3 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -116,6 +116,7 @@ export default { logOverlayFollowWindowNone: '未选择', logOverlayFollowWindowAuto: '已连接窗口', logOverlayRefreshWindows: '刷新窗口列表', + logOverlayWaitingLogs: '等待日志...', maxLogsPerInstance: '每个实例保留的日志上限', maxLogsPerInstanceHint: '超过上限会自动丢弃最旧的日志(建议 500~2000)', resetWindowLayout: '重置窗口布局', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 529eace..10d866d 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -115,6 +115,7 @@ export default { logOverlayFollowWindowNone: '未選擇', logOverlayFollowWindowAuto: '已連接視窗', logOverlayRefreshWindows: '重新整理視窗列表', + logOverlayWaitingLogs: '等待日誌...', maxLogsPerInstance: '每個實例保留的日誌上限', maxLogsPerInstanceHint: '超出上限會自動丟棄最舊的日誌(建議 500~2000)', resetWindowLayout: '重設視窗佈局', diff --git a/src/logOverlay.tsx b/src/logOverlay.tsx index 0867cc6..2f80d19 100644 --- a/src/logOverlay.tsx +++ b/src/logOverlay.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { LogOverlayApp } from './components/LogOverlay'; +import './i18n'; import './index.css'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/services/logOverlayService.ts b/src/services/logOverlayService.ts index 32ba292..5d11339 100644 --- a/src/services/logOverlayService.ts +++ b/src/services/logOverlayService.ts @@ -126,7 +126,12 @@ function setupCloseListener() { /** * 确保位置在屏幕可见范围内(定点模式用) - * 使用 Tauri 的 availableMonitors API 获取所有显示器信息 + * + * 坐标空间说明: + * - 输入 x, y 为物理像素(来自 outerPosition / 保存的 logOverlayX/Y) + * - Tauri 2.0 的 availableMonitors() 返回的 Monitor.position (PhysicalPosition) + * 和 Monitor.size (PhysicalSize) 同样是物理像素 + * - 因此两者在同一坐标空间,无需额外转换 */ async function clampToScreen( x: number, @@ -137,6 +142,7 @@ async function clampToScreen( if (monitors.length === 0) return { x, y }; // 检查位置是否在任一显示器范围内(至少有一部分可见) + // position/size 均为物理像素 const isVisible = monitors.some((m) => { const mx = m.position.x; const my = m.position.y; @@ -419,24 +425,23 @@ export function subscribeConnectionStatus(): () => void { const unsub = useAppStore.subscribe( (state) => ({ - connectionStatus: state.instanceConnectionStatus, + hasConnection: Object.values(state.instanceConnectionStatus).some((s) => s === 'Connected'), enabled: state.logOverlayEnabled, }), (curr, prev) => { if (!curr.enabled) return; - const nowConnected = Object.values(curr.connectionStatus).some((s) => s === 'Connected'); - const wasConnected = Object.values(prev.connectionStatus).some((s) => s === 'Connected'); - - if (nowConnected && !wasConnected) { + if (curr.hasConnection && !prev.hasConnection) { log.info('Log overlay: connection detected, showing overlay'); setTimeout(() => showLogOverlay().catch(() => {}), 500); - } else if (!nowConnected && wasConnected) { + } else if (!curr.hasConnection && prev.hasConnection) { log.info('Log overlay: all disconnected, hiding overlay (keeping enabled)'); hideLogOverlay(true).catch(() => {}); } }, - { equalityFn: (a, b) => JSON.stringify(a) === JSON.stringify(b) }, + { + equalityFn: (a, b) => a.hasConnection === b.hasConnection && a.enabled === b.enabled, + }, ); return () => {