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..86ba0cd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,6 +55,7 @@ debug = true [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", + "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_LibraryLoader", @@ -62,6 +63,7 @@ windows = { version = "0.58", features = [ "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Controls", + "Win32_UI_HiDpi", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } 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..fb6211c 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -331,3 +331,216 @@ 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 mut builder = 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) + .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))?; + + // 使用物理像素坐标设置位置(避免 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) + } +} + +/// 手动设置实例的跟随窗口句柄(用于 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) +/// 优先使用 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..8994f65 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,13 @@ 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::set_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 +191,39 @@ pub fn run() { match event { // 窗口关闭请求:检查是否最小化到托盘 tauri::WindowEvent::CloseRequested { api, .. } => { + // 悬浮窗关闭时:获取当前尺寸,通知前端同步状态 + if window.label() == "log-overlay" { + let scale = window.scale_factor().unwrap_or(1.0); + let size = window.inner_size().ok(); + let pos = window.outer_position().ok(); + // 转换为逻辑像素保存(inner_size 返回物理像素,创建时用逻辑像素) + let payload = serde_json::json!({ + "width": size.as_ref().map(|s| (s.width as f64 / scale).round() as u32).unwrap_or(360), + "height": size.as_ref().map(|s| (s.height as f64 / scale).round() as u32).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); + } + // 主窗口关闭/最小化到托盘时,同步关闭悬浮窗 + // 使用 close() 而非 destroy(),让 CloseRequested 事件先触发以保存几何信息 + if window.label() == "main" { + if let Some(overlay) = window.app_handle().get_webview_window("log-overlay") { + let _ = overlay.close(); + } + } 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..a8efab3 --- /dev/null +++ b/src/components/LogOverlay.tsx @@ -0,0 +1,197 @@ +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'; +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 { t } = useTranslation(); + 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 ? ( +
+ {t('settings.logOverlayWaitingLogs')} +
+ ) : ( + <> + {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/LogOverlayPopover.tsx b/src/components/LogOverlayPopover.tsx new file mode 100644 index 0000000..6bc6f8f --- /dev/null +++ b/src/components/LogOverlayPopover.tsx @@ -0,0 +1,378 @@ +import { useEffect, useRef, useState, useCallback, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { RefreshCw } from 'lucide-react'; +import { useAppStore } from '@/stores/appStore'; +import { SwitchButton } from '@/components/FormControls'; +import { isTauri } from '@/utils/paths'; +import type { Win32Window } from '@/types/maa'; + +const POPOVER_WIDTH = 288; // w-72 + +interface LogOverlayPopoverProps { + open: boolean; + onClose: () => void; + anchorRef: React.RefObject; +} + +export function LogOverlayPopover({ open, onClose, anchorRef }: LogOverlayPopoverProps) { + const { t } = useTranslation(); + const panelRef = useRef(null); + + /* ---------- 定位状态 ---------- */ + const [pos, setPos] = useState({ top: 0, left: 0 }); + const [ready, setReady] = useState(false); + + /* ---------- store ---------- */ + const { + logOverlayEnabled, + setLogOverlayEnabled, + logOverlayMode, + setLogOverlayMode, + logOverlayAnchor, + setLogOverlayAnchor, + logOverlayZOrder, + setLogOverlayZOrder, + activeInstanceId, + 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 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(), + ); + onClose(); + } catch { + // ignore + } + }, + [activeInstanceId, onClose], + ); + + /* ---------- 打开时加载当前 handle ---------- */ + useEffect(() => { + if (!open || !activeInstanceId) return; + let cancelled = false; + const loadCurrentHandle = async () => { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const handle = await invoke('get_connected_window_handle', { + instanceId: activeInstanceId, + }); + if (!cancelled) setFollowWindowHandle(handle ?? null); + } catch { + if (!cancelled) setFollowWindowHandle(null); + } + }; + loadCurrentHandle(); + return () => { + cancelled = true; + }; + }, [open, activeInstanceId]); + + /* ---------- 自动刷新 ADB 窗口列表 ---------- */ + useEffect(() => { + if (open && isAdbController && logOverlayMode === 'follow' && followWindows.length === 0) { + refreshFollowWindows(); + } + }, [open, isAdbController, logOverlayMode, followWindows.length, refreshFollowWindows]); + + /* ---------- 定位逻辑 ---------- */ + // 关闭时重置 + useEffect(() => { + if (!open) { + setReady(false); + } + }, [open]); + + // 打开 / 内容变化 → 计算位置 + // 所有会影响面板高度的状态都放入 deps,确保重算 + useLayoutEffect(() => { + if (!open) return; + + const panel = panelRef.current; + const anchor = anchorRef.current; + if (!panel || !anchor) return; + + const anchorRect = anchor.getBoundingClientRect(); + const panelHeight = panel.offsetHeight; + const panelWidth = panel.offsetWidth || POPOVER_WIDTH; + + const spaceBelow = window.innerHeight - anchorRect.bottom - 8; + const spaceAbove = anchorRect.top - 8; + + let top: number; + if (spaceBelow >= panelHeight) { + top = anchorRect.bottom + 4; + } else if (spaceAbove >= panelHeight) { + top = anchorRect.top - panelHeight - 4; + } else { + top = spaceBelow >= spaceAbove + ? anchorRect.bottom + 4 + : Math.max(8, anchorRect.top - panelHeight - 4); + } + + let left = anchorRect.right - panelWidth; + if (left < 8) left = 8; + if (left + panelWidth > window.innerWidth - 8) left = window.innerWidth - panelWidth - 8; + + setPos({ top, left }); + setReady(true); + }, [open, anchorRef, logOverlayEnabled, logOverlayMode, isAdbController, followWindows.length]); + + /* ---------- 点击外部 / Esc 关闭 ---------- */ + useEffect(() => { + if (!open) return; + const onDocMouseDown = (e: MouseEvent) => { + const el = panelRef.current; + const anchor = anchorRef.current; + // 点击面板内部 → 不关闭 + if (el?.contains(e.target as Node)) return; + // 点击触发按钮 → 不关闭(让按钮自身的 onClick toggle 处理) + if (anchor?.contains(e.target as Node)) return; + + onClose(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', onDocMouseDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('mousedown', onDocMouseDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open, onClose, anchorRef]); + + /* ---------- 操作回调 ---------- */ + const handleToggleEnabled = (v: boolean) => { + setLogOverlayEnabled(v); + if (v) { + import('@/services/logOverlayService').then(({ showLogOverlay }) => showLogOverlay()); + } else { + import('@/services/logOverlayService').then(({ hideLogOverlay }) => hideLogOverlay()); + } + }; + + const handleModeChange = (mode: 'fixed' | 'follow') => { + setLogOverlayMode(mode); + import('@/services/logOverlayService').then(({ onOverlaySettingsChanged }) => + onOverlaySettingsChanged(), + ); + }; + + const handleAnchorChange = ( + anchor: 'left-center' | 'right-top-third' | 'right-bottom-third' | 'top-center', + ) => { + setLogOverlayAnchor(anchor); + import('@/services/logOverlayService').then(({ onOverlaySettingsChanged }) => + onOverlaySettingsChanged(), + ); + }; + + const handleZOrderChange = (z: 'always_on_top' | 'above_target') => { + setLogOverlayZOrder(z); + import('@/services/logOverlayService').then(({ onOverlaySettingsChanged }) => + onOverlaySettingsChanged(), + ); + }; + + /* ---------- 渲染 ---------- */ + // 不 open 时完全不渲染 + if (!open) return null; + + return createPortal( + // onClick stopPropagation: 阻止 React 合成事件沿组件树冒泡到 + // LogsPanel 标题栏的 onClick={toggleSidePanelExpanded} + // (React Portal 的事件按组件树而非 DOM 树冒泡) +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + className="w-72 rounded-xl border border-border bg-bg-secondary shadow-2xl p-3" + style={{ + position: 'fixed', + top: pos.top, + left: pos.left, + zIndex: 9999, + // 首次渲染测量尺寸时隐藏,定位完成后显示 + visibility: ready ? 'visible' : 'hidden', + pointerEvents: ready ? 'auto' : 'none', + }} + > +
{t('settings.logOverlay')}
+

{t('settings.logOverlayHint')}

+ +
+ {/* 开关 */} +
+ {t('settings.logOverlayEnable')} + +
+ + {logOverlayEnabled && ( + <> + {/* 模式 */} +
+ {t('settings.logOverlayMode')} + +
+ + {logOverlayMode === 'follow' && ( + <> + {/* 锚点 */} +
+ + {t('settings.logOverlayAnchor')} + + +
+ + {/* 跟随窗口 */} +
+ + {t('settings.logOverlayFollowWindow')} + + {isAdbController ? ( + <> +
+ + + {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 2d5bb4b..a7bd154 100644 --- a/src/components/LogsPanel.tsx +++ b/src/components/LogsPanel.tsx @@ -1,18 +1,26 @@ -import { useRef, useEffect, useCallback } from 'react'; +import { useRef, useEffect, useCallback, useState } 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'; import { isTauri } from '@/utils/paths'; import { useExportLogs } from '@/utils/useExportLogs'; import { ExportLogsModal } from './settings/ExportLogsModal'; +import { LogOverlayPopover } from './LogOverlayPopover'; export function LogsPanel() { const { t } = useTranslation(); const logsEndRef = useRef(null); - const { sidePanelExpanded, toggleSidePanelExpanded, activeInstanceId, instanceLogs, clearLogs } = - useAppStore(); + const [overlayPopoverOpen, setOverlayPopoverOpen] = useState(false); + const overlayTriggerRef = useRef(null); + // popover 关闭瞬间设为 true,防止同一次点击触发标题栏的 toggleSidePanelExpanded + const popoverJustClosedRef = useRef(false); + const { + sidePanelExpanded, toggleSidePanelExpanded, activeInstanceId, instanceLogs, clearLogs, + logOverlayEnabled, + } = useAppStore(); + const { state: menuState, show: showMenu, hide: hideMenu } = useContextMenu(); const { exportModal, handleExportLogs, closeExportModal, openExportedFile } = useExportLogs(); @@ -108,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(); @@ -137,6 +140,42 @@ export function LogsPanel() { > {t('logs.title')}
+ {/* 日志悬浮窗:点击打开统一设置弹层 */} + {isTauri() && ( + <> + + { + popoverJustClosedRef.current = true; + setOverlayPopoverOpen(false); + // 下一帧重置,确保后续点击正常触发 toggle + requestAnimationFrame(() => { + popoverJustClosedRef.current = false; + }); + }} + anchorRef={overlayTriggerRef} + /> + + )} {/* 导出日志 */}
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index f22574a..d98b34a 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -78,6 +78,8 @@ export function Toolbar({ showAddPanel, onToggleAddPanel }: ToolbarProps) { addLog, // 添加任务面板 setShowAddTaskPanel, + // 日志悬浮窗 (保存句柄用) + setConnectedHandle, // 国际化 interfaceTranslations, language, @@ -647,8 +649,18 @@ export function Toolbar({ showAddPanel, onToggleAddPanel }: ToolbarProps) { if (!connectResult) { log.warn(`实例 ${targetInstance.name}: 连接设备失败`); + setConnectedHandle(targetId, null); return false; } + + // 保存已连接的窗口句柄(Win32/Gamepad),供悬浮窗跟随使用 + if (config && (config.type === 'Win32' || config.type === 'Gamepad') && 'handle' in config) { + log.info(`实例 ${targetInstance.name}: 保存窗口句柄 handle=${config.handle}`); + setConnectedHandle(targetId, config.handle); + } else { + log.info(`实例 ${targetInstance.name}: 非窗口控制器,无 handle`); + setConnectedHandle(targetId, null); + } } // 如果资源未加载,尝试自动加载 @@ -1245,6 +1257,7 @@ export function Toolbar({ showAddPanel, onToggleAddPanel }: ToolbarProps) { {t('taskList.addTask')} +
{/* 右侧执行按钮组 */} diff --git a/src/components/settings/GeneralSection.tsx b/src/components/settings/GeneralSection.tsx index 92239e6..5581b13 100644 --- a/src/components/settings/GeneralSection.tsx +++ b/src/components/settings/GeneralSection.tsx @@ -287,7 +287,7 @@ export function GeneralSection() {
- {/* ⑧ 重置窗口布局 */} + {/* ⑨ 重置窗口布局 */} {isTauri() && (
diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index a6c310c..c70a3bb 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -104,6 +104,25 @@ 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', + logOverlayEnable: 'Enable', + logOverlayFollowWindow: 'Follow Window', + logOverlayFollowWindowNone: 'Not selected', + logOverlayFollowWindowAuto: 'Connected window', + logOverlayRefreshWindows: 'Refresh window list', + logOverlayWaitingLogs: 'Waiting for logs...', + logOverlayZOrder: 'Window Layer', + logOverlayZOrderTop: 'On Top', + logOverlayZOrderAboveTarget: 'Above Connected Window', }, // Special tasks diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 9826fad..ca9e98a 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -99,6 +99,25 @@ export default { '手動でアプリを開く際も、上で選択した設定を自動実行します(無効な場合はシステム起動時のみ実行)', confirmBeforeDelete: '削除操作の前に確認する', confirmBeforeDeleteHint: '削除/一覧クリア/上書きインポート等の前に確認ダイアログを表示します', + logOverlay: 'ログオーバーレイ', + logOverlayHint: '接続したウィンドウ付近に半透明のログを表示。未接続時は非表示', + logOverlayMode: 'オーバーレイモード', + logOverlayModeFixed: '固定位置', + logOverlayModeFollow: '接続ウィンドウに追従', + logOverlayZOrder: 'ウィンドウの重ね順', + logOverlayZOrderTop: '最前面に表示', + logOverlayZOrderAboveTarget: '接続ウィンドウの上', + logOverlayAnchor: 'アンカー位置', + logOverlayAnchorLeftCenter: '左中央', + logOverlayAnchorRightTop: '右上(1/3)', + logOverlayAnchorRightBottom: '右下(2/3)', + logOverlayAnchorTopCenter: '上中央', + logOverlayEnable: '有効', + logOverlayFollowWindow: '追従ウィンドウ', + 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 07615b3..1a530a2 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -99,6 +99,25 @@ export default { confirmBeforeDelete: '삭제 작업 확인', confirmBeforeDeleteHint: '삭제/목록 비우기/가져오기 덮어쓰기 등 전에 확인 대화 상자를 표시합니다', + logOverlay: '로그 오버레이', + logOverlayHint: '연결된 창 근처에 반투명 로그를 표시합니다. 연결 전에는 숨김', + logOverlayMode: '오버레이 모드', + logOverlayModeFixed: '고정 위치', + logOverlayModeFollow: '연결 창 따라가기', + logOverlayZOrder: '창 계층', + logOverlayZOrderTop: '맨 위에 표시', + logOverlayZOrderAboveTarget: '연결 창 위에', + logOverlayAnchor: '앵커 위치', + logOverlayAnchorLeftCenter: '왼쪽 가운데', + logOverlayAnchorRightTop: '오른쪽 위 (1/3)', + logOverlayAnchorRightBottom: '오른쪽 아래 (2/3)', + logOverlayAnchorTopCenter: '위쪽 가운데', + logOverlayEnable: '사용', + logOverlayFollowWindow: '따라갈 창', + 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 94c69a1..2ba65e3 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -98,6 +98,25 @@ export default { '每次手动打开程序时,也自动执行上方选定的配置(关闭则仅在开机自启动时触发)', confirmBeforeDelete: '删除操作需要二次确认', confirmBeforeDeleteHint: '删除任务、清空列表、导入覆盖等操作会先弹出确认对话框', + logOverlay: '日志悬浮窗', + logOverlayHint: '在控制器连接的窗口附近显示半透明悬浮日志,未连接时不显示', + logOverlayMode: '悬浮模式', + logOverlayModeFixed: '定点悬浮', + logOverlayModeFollow: '跟随连接窗口', + logOverlayZOrder: '窗口层级', + logOverlayZOrderTop: '置顶', + logOverlayZOrderAboveTarget: '连接窗口上一层', + logOverlayAnchor: '锚定位置', + logOverlayAnchorLeftCenter: '左中', + logOverlayAnchorRightTop: '右上(1/3处)', + logOverlayAnchorRightBottom: '右下(2/3处)', + logOverlayAnchorTopCenter: '上中', + logOverlayEnable: '开启', + logOverlayFollowWindow: '跟随窗口', + 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 4dbaae4..10d866d 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -97,6 +97,25 @@ export default { '每次手動開啟程式時,也自動執行上方選定的配置(關閉則僅在開機自啟動時觸發)', confirmBeforeDelete: '刪除操作需要二次確認', confirmBeforeDeleteHint: '刪除任務、清空列表、匯入覆蓋等操作會先彈出確認對話框', + logOverlay: '日誌懸浮窗', + logOverlayHint: '在控制器連接的視窗附近顯示半透明懸浮日誌,未連接時不顯示', + logOverlayMode: '懸浮模式', + logOverlayModeFixed: '定點懸浮', + logOverlayModeFollow: '跟隨連接視窗', + logOverlayZOrder: '視窗層級', + logOverlayZOrderTop: '置頂', + logOverlayZOrderAboveTarget: '連接視窗上一層', + logOverlayAnchor: '錨定位置', + logOverlayAnchorLeftCenter: '左中', + logOverlayAnchorRightTop: '右上(1/3 處)', + logOverlayAnchorRightBottom: '右下(2/3 處)', + logOverlayAnchorTopCenter: '上中', + logOverlayEnable: '開啟', + logOverlayFollowWindow: '跟隨視窗', + logOverlayFollowWindowNone: '未選擇', + logOverlayFollowWindowAuto: '已連接視窗', + logOverlayRefreshWindows: '重新整理視窗列表', + logOverlayWaitingLogs: '等待日誌...', maxLogsPerInstance: '每個實例保留的日誌上限', maxLogsPerInstanceHint: '超出上限會自動丟棄最舊的日誌(建議 500~2000)', resetWindowLayout: '重設視窗佈局', diff --git a/src/logOverlay.tsx b/src/logOverlay.tsx new file mode 100644 index 0000000..2f80d19 --- /dev/null +++ b/src/logOverlay.tsx @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..5d11339 --- /dev/null +++ b/src/services/logOverlayService.ts @@ -0,0 +1,454 @@ +/** + * 日志悬浮窗服务 + * + * - 只有控制器连接后才显示 + * - 定点模式:固定在屏幕位置,可拖拽,不跟随 + * - 跟随模式:锚定在控制器连接的窗口(游戏窗口)附近,跟随移动 + * - 层级选项:最置顶 / 只在连接窗口上一层(两种模式都生效) + * - 关闭时保存悬浮窗尺寸,下次打开时恢复 + * - 定点模式下确保悬浮窗在屏幕可见范围内 + * + * 所有坐标和尺寸统一使用物理像素(与 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 closeListenerPending = false; +/** 程序主动关闭悬浮窗时为 true,close 事件中不修改 enabled 状态 */ +let programmaticClose = false; +let saveTickCounter = 0; +const SAVE_INTERVAL_TICKS = 15; // 每 15 次 poll (~4.5s) 保存一次几何信息 + +// ========== Handle helpers ========== + +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 }); + 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 || closeListenerPending) return; + closeListenerPending = true; + + listen<{ width: number; height: number; x: number; y: number }>( + 'log-overlay-closed', + (event) => { + const { width, height, x, y } = event.payload; + log.info(`Log overlay closed, saving size: ${width}x${height}, pos: (${x}, ${y})`); + + // 保存逻辑尺寸(Rust 端已将物理像素转为逻辑像素) + if (width > 0 && height > 0) { + useAppStore.getState().setLogOverlaySize(width, height); + } + + // 保存位置(物理像素,与创建时一致) + 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; + }); +} + +// ========== Screen bounds check ========== + +/** + * 确保位置在屏幕可见范围内(定点模式用) + * + * 坐标空间说明: + * - 输入 x, y 为物理像素(来自 outerPosition / 保存的 logOverlayX/Y) + * - Tauri 2.0 的 availableMonitors() 返回的 Monitor.position (PhysicalPosition) + * 和 Monitor.size (PhysicalSize) 同样是物理像素 + * - 因此两者在同一坐标空间,无需额外转换 + */ +async function clampToScreen( + x: number, + y: number, +): Promise<{ x: number; y: number }> { + try { + const monitors = await availableMonitors(); + if (monitors.length === 0) return { x, y }; + + // 检查位置是否在任一显示器范围内(至少有一部分可见) + // position/size 均为物理像素 + const isVisible = monitors.some((m) => { + 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]; + 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(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); + } +} + +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 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(); + 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 + } +} + +// ========== 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 + } + } + } + + // 定点模式:优先使用上次保存的位置 + 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 { + await invoke('set_overlay_always_on_top', { + alwaysOnTop: state.logOverlayZOrder === 'always_on_top', + }); + } catch { + // overlay may not exist yet + } + + if (pollIntervalId) { + stopPolling(); + } + startPolling(); +} + +export function subscribeConnectionStatus(): () => void { + // 设置关闭事件监听 + setupCloseListener(); + + const unsub = useAppStore.subscribe( + (state) => ({ + hasConnection: Object.values(state.instanceConnectionStatus).some((s) => s === 'Connected'), + enabled: state.logOverlayEnabled, + }), + (curr, prev) => { + if (!curr.enabled) return; + + if (curr.hasConnection && !prev.hasConnection) { + log.info('Log overlay: connection detected, showing overlay'); + setTimeout(() => showLogOverlay().catch(() => {}), 500); + } else if (!curr.hasConnection && prev.hasConnection) { + log.info('Log overlay: all disconnected, hiding overlay (keeping enabled)'); + hideLogOverlay(true).catch(() => {}); + } + }, + { + equalityFn: (a, b) => a.hasConnection === b.hasConnection && a.enabled === b.enabled, + }, + ); + + return () => { + unsub(); + if (closeListenerCleanup) { + closeListenerCleanup(); + closeListenerCleanup = null; + } + }; +} diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index b31714a..c6f47c5 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -977,6 +977,14 @@ 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, + logOverlayX: config.settings.logOverlay?.x ?? null, + logOverlayY: config.settings.logOverlay?.y ?? null, recentlyClosed: config.recentlyClosed || [], // 记录新增任务,并在有新增时自动展开添加任务面板 newTaskNames: detectedNewTaskNames, @@ -1280,6 +1288,32 @@ 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 }), + logOverlayX: null as number | null, + logOverlayY: null as number | null, + setLogOverlayPosition: (x, y) => set({ logOverlayX: x, logOverlayY: y }), + + // 控制器连接的窗口句柄 + instanceConnectedHandle: {}, + setConnectedHandle: (instanceId, handle) => + set((state) => ({ + instanceConnectedHandle: { + ...state.instanceConnectedHandle, + [instanceId]: handle, + }, + })), + // 新用户引导 onboardingCompleted: false, setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }), @@ -1559,6 +1593,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 +1620,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 +1717,16 @@ 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, + x: state.logOverlayX ?? undefined, + y: state.logOverlayY ?? undefined, + }, }, recentlyClosed: state.recentlyClosed, // 保存当前 interface.json 的任务名列表快照,用于下次加载时检测新增任务 @@ -1718,6 +1785,14 @@ 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, + 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 896caa4..460a01e 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -322,6 +322,32 @@ 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; + /** 悬浮窗上次位置 X(物理像素,与 Win32 API / PhysicalPosition 一致) */ + logOverlayX: number | null; + /** 悬浮窗上次位置 Y(物理像素,与 Win32 API / PhysicalPosition 一致) */ + logOverlayY: number | null; + setLogOverlayPosition: (x: number, y: 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..8737ae6 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -103,6 +103,22 @@ 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; + /** 悬浮窗上次位置 X(物理像素) */ + x?: number; + /** 悬浮窗上次位置 Y(物理像素) */ + y?: number; +} + // 应用设置 export interface AppSettings { theme: 'light' | 'dark' | 'system'; @@ -132,6 +148,7 @@ export interface AppSettings { autoStartInstanceId?: string; // 启动后自动执行的实例 ID(为空或 undefined 表示不自动执行) autoRunOnLaunch?: boolean; // 非开机自启动的手动启动场景下,是否也自动执行选定的实例(默认 false) autoStartRemovedInstanceName?: string; // 被删除的自动执行配置名称(用于提示用户) + logOverlay?: LogOverlaySettings; // 日志悬浮窗配置 } // MXU 配置文件完整结构 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, "/");