Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions log-overlay.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>日志悬浮窗</title>
<style>
html, body, #root {
margin: 0;
padding: 0;
background: transparent;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/logOverlay.tsx"></script>
</body>
</html>
2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@ debug = true
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_Security",
"Win32_System_LibraryLoader",
"Win32_System_Registry",
"Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_UI_Controls",
"Win32_UI_HiDpi",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src-tauri/capabilities/log-overlay.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
10 changes: 10 additions & 0 deletions src-tauri/src/commands/maa_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
213 changes: 213 additions & 0 deletions src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::sync::Arc<super::types::MaaState>>,
instance_id: String,
) -> Result<Option<i64>, 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<std::sync::Arc<super::types::MaaState>>,
instance_id: String,
handle: Option<i64>,
) -> 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::<RECT>() 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(())
}
3 changes: 3 additions & 0 deletions src-tauri/src/commands/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ pub struct InstanceRuntime {
pub stop_in_progress: bool,
/// stop 请求的起始时间(用于节流/重试)
pub stop_started_at: Option<Instant>,
/// Win32/Gamepad 控制器连接的窗口句柄(供悬浮窗跟随使用)
pub connected_window_handle: Option<u64>,
}

// 为原始指针实现 Send 和 Sync
Expand All @@ -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,
Expand Down
38 changes: 36 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
Expand All @@ -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::<Arc<MaaState>>() {
state.cleanup_all_agent_children();
}
Expand Down
Loading
Loading