From c8d9392c33ea24b67aff8d087253e61656db7ffd Mon Sep 17 00:00:00 2001 From: SENyiAi <123186357+SENyiAi@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:33:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20Windows=20?= =?UTF-8?q?=E4=BA=91=E7=BB=88=E6=9C=AB=E5=9C=B0=E7=BD=91=E9=A1=B5=E7=89=88?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 13 + .../src/commands/cloud_web_controller.rs | 532 ++++++++++++++++++ src-tauri/src/commands/cloud_web_transport.rs | 289 ++++++++++ src-tauri/src/commands/maa_agent.rs | 89 ++- src-tauri/src/commands/maa_core.rs | 70 ++- src-tauri/src/commands/system.rs | 3 + src-tauri/src/commands/task_completion.rs | 85 +++ src-tauri/src/commands/types.rs | 11 + src-tauri/src/commands/utils.rs | 36 +- src/components/DashboardView.tsx | 4 + src/components/Toolbar.tsx | 72 ++- src/types/maa.ts | 7 + src/utils/controller.ts | 8 + 14 files changed, 1146 insertions(+), 74 deletions(-) create mode 100644 src-tauri/src/commands/cloud_web_controller.rs create mode 100644 src-tauri/src/commands/cloud_web_transport.rs create mode 100644 src-tauri/src/commands/task_completion.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 326ffe35..dcefbce3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3136,6 +3136,7 @@ dependencies = [ "clap", "flate2", "futures-util", + "image", "libc", "log", "maa-framework", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a1c34f0c..395e26ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,10 @@ edition = "2021" name = "mxu_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[features] +# 生产构建启用内嵌前端资源: cargo build --release --features custom-protocol +custom-protocol = ["tauri/custom-protocol"] + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -73,6 +77,7 @@ sentry = { version = "0.49.1", default-features = false, features = ["logs", "te debug = true [target.'cfg(windows)'.dependencies] +image = { version = "0.25", default-features = false, features = ["png"] } winsafe = { version = "0.0.28", features = [ "advapi", "kernel", @@ -87,4 +92,12 @@ windows = { version = "0.61.3", features = [ "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Variant", + "Win32_UI_WindowsAndMessaging", + "Win32_System_Memory", + "Win32_Foundation", + "Win32_System_Pipes", + "Win32_System_IO", + "Win32_Storage_FileSystem", + "Win32_System_Threading", + "Win32_Security", ] } diff --git a/src-tauri/src/commands/cloud_web_controller.rs b/src-tauri/src/commands/cloud_web_controller.rs new file mode 100644 index 00000000..c6307559 --- /dev/null +++ b/src-tauri/src/commands/cloud_web_controller.rs @@ -0,0 +1,532 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::time::Duration; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use image::ImageEncoder; +use maa_framework::common::ControllerFeature; +use maa_framework::custom_controller::CustomControllerCallback; +use serde_json::{json, Value}; +use windows::Win32::Foundation::{HANDLE, HWND}; +use windows::Win32::System::Memory::{MapViewOfFile, UnmapViewOfFile, FILE_MAP_READ}; +use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; + +#[path = "cloud_web_transport.rs"] +mod transport; + +const WIDTH: usize = 1280; +const HEIGHT: usize = 720; +const FRAME_BYTES: usize = WIDTH * HEIGHT * 4; + +pub struct CloudWebController { + process_id: u32, + keys: Mutex>, + contacts: Mutex>, + frame_lock: Mutex<()>, + request_lock: Mutex<()>, + cancellation: Arc, +} + +impl CloudWebController { + pub fn new(process_id: u32) -> Self { + let cancellation = Arc::new(AtomicU64::new(0)); + cancellations() + .lock() + .unwrap() + .push((process_id, Arc::downgrade(&cancellation))); + Self { + process_id, + keys: Mutex::new(BTreeSet::new()), + contacts: Mutex::new(BTreeMap::new()), + frame_lock: Mutex::new(()), + request_lock: Mutex::new(()), + cancellation, + } + } + + fn request_with( + &self, + message: Value, + consume: impl FnOnce(Value) -> io::Result, + ) -> Option { + // Capture before locking so a queued request is cancelled with the + // in-flight request, without disabling later key/button releases. + let generation = self.cancellation.load(Ordering::Acquire); + let _guard = self.request_lock.lock().unwrap(); + match transport::request( + self.process_id, + &message, + &self.cancellation, + generation, + consume, + ) { + Ok(value) => Some(value), + Err(error) => { + log::warn!( + "Cloud controller {} RPC {} failed: {}", + self.process_id, + message["op"], + error + ); + None + } + } + } + + fn request(&self, message: Value) -> Option { + self.request_with(message, Ok) + } + + fn input(&self, op: &str, mut params: Value) -> bool { + params["modifiers"] = json!(modifiers(&self.keys.lock().unwrap())); + self.request(json!({"op": op, "params": params})) + .and_then(|v| v.get("ok").and_then(Value::as_bool)) + .unwrap_or(false) + } + + fn frame(&self) -> Option> { + let _guard = self.frame_lock.lock().unwrap(); + let (response, pixels) = self.request_with(json!({"op":"frame_shared"}), |response| { + let pixels = if response.get("ok").and_then(Value::as_bool) == Some(true) { + Some(copy_shared_frame(&response["value"])?) + } else { + None + }; + Ok((response, pixels)) + })?; + if !response.get("ok")?.as_bool()? { + if response.get("error")?.as_str()? == "video_unavailable" { + return self.landing_screenshot(); + } + return None; + } + let pixels = pixels?; + // These pixels stay on this machine. Filtering/compressing each frame + // costs both encoder time and another filter pass in the framework. + let mut png = Vec::with_capacity(FRAME_BYTES + HEIGHT + 1024); + let result = image::codecs::png::PngEncoder::new_with_quality( + &mut png, + image::codecs::png::CompressionType::Uncompressed, + image::codecs::png::FilterType::NoFilter, + ) + .write_image( + &pixels, + WIDTH as u32, + HEIGHT as u32, + image::ColorType::Rgba8.into(), + ); + result.ok()?; + Some(png) + } + + fn landing_screenshot(&self) -> Option> { + let status = self.request(json!({"op":"status"}))?; + let state = status.get("value")?; + if state.get("account")?.as_str()? != "signed_in" + || state.get("page")?.as_str()? != "landing" + || !state.get("start_available")?.as_bool()? + { + return None; + } + + let response = self.request(json!({"op":"screencap"}))?; + if !response.get("ok")?.as_bool()? { + return None; + } + let png = STANDARD + .decode(response.get("value")?.get("data")?.as_str()?) + .ok()?; + // WebView page screenshots use physical viewport pixels, while CDP + // input and decoded game frames use the fixed 1280 x 720 coordinates. + let screenshot = image::load_from_memory(&png).ok()?; + if screenshot.width() == WIDTH as u32 && screenshot.height() == HEIGHT as u32 { + return Some(png); + } + let pixels = screenshot + .resize_exact( + WIDTH as u32, + HEIGHT as u32, + image::imageops::FilterType::Triangle, + ) + .to_rgba8(); + let mut normalized = Vec::new(); + image::codecs::png::PngEncoder::new(&mut normalized) + .write_image( + pixels.as_raw(), + WIDTH as u32, + HEIGHT as u32, + image::ColorType::Rgba8.into(), + ) + .ok()?; + Some(normalized) + } +} + +impl CustomControllerCallback for CloudWebController { + fn request_uuid(&self) -> Option { + Some(format!("cloud-webview-{}", self.process_id)) + } + + fn connect(&self) -> bool { + self.request(json!({"op":"status"})) + .and_then(|v| v.get("ok").and_then(Value::as_bool)) + .unwrap_or(false) + } + + fn connected(&self) -> bool { + self.request(json!({"op":"status"})) + .and_then(|v| v.get("ok").and_then(Value::as_bool)) + .unwrap_or(false) + } + + fn get_features(&self) -> ControllerFeature { + // Preserve contact (including -1 for hover), duration and held buttons. + // The legacy click/swipe callbacks do not receive these parameters. + ControllerFeature::USE_MOUSE_DOWN_UP_INSTEAD_OF_CLICK + | ControllerFeature::USE_KEY_DOWN_UP_INSTEAD_OF_CLICK + } + + fn screencap(&self) -> Option> { + self.frame() + } + + fn click(&self, x: i32, y: i32) -> bool { + let down = self.touch_down(0, x, y, 0); + let up = self.touch_up(0); + down && up + } + + fn swipe(&self, x1: i32, y1: i32, x2: i32, y2: i32, duration: i32) -> bool { + if !self.touch_down(0, x1, y1, 0) { + return false; + } + let steps = (duration.max(1) / 16).max(1); + let started = std::time::Instant::now(); + for step in 1..=steps { + let due = Duration::from_millis((duration.max(0) as u64 * step as u64) / steps as u64); + std::thread::sleep(due.saturating_sub(started.elapsed())); + let x = x1 + ((x2 as i64 - x1 as i64) * step as i64 / steps as i64) as i32; + let y = y1 + ((y2 as i64 - y1 as i64) * step as i64 / steps as i64) as i32; + if !self.touch_move(0, x, y, 0) { + let _ = self.touch_up(0); + return false; + } + } + self.touch_up(0) + } + + fn click_key(&self, keycode: i32) -> bool { + let down = self.key_down(keycode); + let up = self.key_up(keycode); + down && up + } + + fn key_down(&self, keycode: i32) -> bool { + let key = key_identity(keycode); + let mut keys = self.keys.lock().unwrap(); + let repeated = !keys.insert(keycode); + let ok = self.request(json!({"op":"key","params":{"type":"keyDown","key":key.0,"code":key.1,"windowsVirtualKeyCode":keycode,"modifiers":modifiers(&keys),"autoRepeat":repeated}})) + .and_then(|v| v.get("ok").and_then(Value::as_bool)).unwrap_or(false); + if !ok && !repeated { + keys.remove(&keycode); + } + ok + } + + fn key_up(&self, keycode: i32) -> bool { + let key = key_identity(keycode); + let mut keys = self.keys.lock().unwrap(); + let was_down = keys.remove(&keycode); + let ok = self.request(json!({"op":"key","params":{"type":"keyUp","key":key.0,"code":key.1,"windowsVirtualKeyCode":keycode,"modifiers":modifiers(&keys)}})) + .and_then(|v| v.get("ok").and_then(Value::as_bool)).unwrap_or(false); + if !ok && was_down { + keys.insert(keycode); + } + ok + } + + fn touch_down(&self, contact: i32, x: i32, y: i32, _pressure: i32) -> bool { + if contact == -1 { + return true; + } + let Some(button) = mouse_button(contact) else { + return false; + }; + let mut contacts = self.contacts.lock().unwrap(); + if self.input( + "mouse", + json!({"type":"mousePressed","x":x,"y":y,"button":button,"buttons":mouse_buttons(&contacts) | (1 << contact),"clickCount":1}), + ) { + contacts.insert(contact, (x, y)); + true + } else { + false + } + } + + fn touch_move(&self, contact: i32, x: i32, y: i32, _pressure: i32) -> bool { + if contact != -1 && mouse_button(contact).is_none() { + return false; + } + let mut contacts = self.contacts.lock().unwrap(); + let buttons = mouse_buttons(&contacts); + if self.input( + "mouse", + json!({"type":"mouseMoved","x":x,"y":y,"button":"none","buttons":buttons}), + ) { + if contacts.contains_key(&contact) { + contacts.insert(contact, (x, y)); + } + true + } else { + false + } + } + + fn touch_up(&self, contact: i32) -> bool { + if contact == -1 { + return true; + } + let Some(button) = mouse_button(contact) else { + return false; + }; + let mut contacts = self.contacts.lock().unwrap(); + let (x, y) = contacts.get(&contact).copied().unwrap_or((640, 360)); + if self.input( + "mouse", + json!({"type":"mouseReleased","x":x,"y":y,"button":button,"buttons":mouse_buttons(&contacts) & !(1 << contact),"clickCount":1}), + ) { + contacts.remove(&contact); + true + } else { + false + } + } + + fn input_text(&self, text: &str) -> bool { + self.request(json!({"op":"text","text":text})) + .and_then(|v| v.get("ok").and_then(Value::as_bool)) + .unwrap_or(false) + } + + fn relative_move(&self, dx: i32, dy: i32) -> bool { + let contacts = self.contacts.lock().unwrap(); + let keys = self.keys.lock().unwrap(); + self.request(json!({"op":"relative","dx":dx,"dy":dy,"buttons":mouse_buttons(&contacts),"modifiers":modifiers(&keys)})) + .and_then(|v| v.get("ok").and_then(Value::as_bool)) + .unwrap_or(false) + } + + fn scroll(&self, dx: i32, dy: i32) -> bool { + let contacts = self.contacts.lock().unwrap(); + self.input( + "mouse", + json!({"type":"mouseWheel","x":640,"y":360,"deltaX":dx,"deltaY":dy,"buttons":mouse_buttons(&contacts)}), + ) + } + + fn inactive(&self) -> bool { + let keys: Vec<_> = self.keys.lock().unwrap().iter().copied().collect(); + let contacts: Vec<_> = self.contacts.lock().unwrap().keys().copied().collect(); + let mut ok = true; + for key in keys { + ok = self.key_up(key) && ok; + } + for contact in contacts { + ok = self.touch_up(contact) && ok; + } + ok + } +} + +fn copy_shared_frame(value: &Value) -> io::Result> { + let invalid = || io::Error::new(io::ErrorKind::InvalidData, "invalid shared frame"); + let handle = value + .get("mapping_handle") + .and_then(Value::as_i64) + .ok_or_else(invalid)? as usize; + if handle == 0 || handle == usize::MAX { + return Err(invalid()); + } + // Take ownership before validating any remaining field, including errors. + let mapping = unsafe { OwnedHandle::from_raw_handle(handle as *mut _) }; + if value["byte_size"].as_u64() != Some(FRAME_BYTES as u64) + || value["width"].as_u64() != Some(WIDTH as u64) + || value["height"].as_u64() != Some(HEIGHT as u64) + || value["stride"].as_u64() != Some((WIDTH * 4) as u64) + || value["format"].as_str() != Some("RGBA8") + || value["protocol"].as_u64() != Some(1) + { + return Err(invalid()); + } + let view = unsafe { + MapViewOfFile( + HANDLE(mapping.as_raw_handle()), + FILE_MAP_READ, + 0, + 0, + FRAME_BYTES, + ) + }; + if view.Value.is_null() { + return Err(io::Error::last_os_error()); + } + let pixels = + unsafe { std::slice::from_raw_parts(view.Value.cast::(), FRAME_BYTES).to_vec() }; + unsafe { UnmapViewOfFile(view) }?; + Ok(pixels) +} + +type Cancellations = Vec<(u32, Weak)>; + +fn cancellations() -> &'static Mutex { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(Vec::new())) +} + +pub fn cancel_pending(handle: u64) { + let process_id = process_id(handle); + cancellations().lock().unwrap().retain(|(pid, weak)| { + if let Some(generation) = weak.upgrade() { + if *pid == process_id { + generation.fetch_add(1, Ordering::AcqRel); + } + true + } else { + false + } + }); +} + +fn process_id(handle: u64) -> u32 { + let mut process_id = 0; + unsafe { GetWindowThreadProcessId(HWND(handle as *mut _), Some(&mut process_id)) }; + process_id +} + +// A lease belongs to a single task run, not a window title or installation. +// The host validates both client PID and token before releasing/closing it. +pub struct CloudRun { + process_id: u32, + token: String, + finished: bool, +} + +impl CloudRun { + pub fn acquire(handle: u64) -> Result { + static NEXT_RUN: AtomicU64 = AtomicU64::new(1); + let lease = Self { + process_id: process_id(handle), + finished: false, + token: format!( + "{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + NEXT_RUN.fetch_add(1, Ordering::Relaxed) + ), + }; + lease.command("run_acquire")?; + Ok(lease) + } + + fn command(&self, op: &str) -> Result<(), String> { + let cancellation = AtomicU64::new(0); + let response = transport::request( + self.process_id, + &json!({"op":op,"token":self.token}), + &cancellation, + 0, + Ok, + ) + .map_err(|error| error.to_string())?; + if response["ok"].as_bool() != Some(true) { + return Err(format!("Cloud run {} failed: {}", op, response["error"])); + } + Ok(()) + } + + pub fn finish(mut self) { + if let Err(error) = self.command("run_close") { + log::warn!("{}", error); + } else { + self.finished = true; + } + } +} + +impl Drop for CloudRun { + fn drop(&mut self) { + // Best effort on disconnect/post_task failure. A successful run_close + // may already have exited the host; that is not a release failure. + if !self.finished { + let _ = self.command("run_release"); + } + } +} + +fn mouse_buttons(contacts: &BTreeMap) -> i32 { + contacts + .keys() + .fold(0, |buttons, contact| buttons | (1 << contact)) +} + +fn modifiers(keys: &BTreeSet) -> i32 { + let held = |codes: &[i32]| codes.iter().any(|key| keys.contains(key)) as i32; + held(&[18, 164, 165]) + | (held(&[17, 162, 163]) << 1) + | (held(&[91, 92]) << 2) + | (held(&[16, 160, 161]) << 3) +} + +fn mouse_button(contact: i32) -> Option<&'static str> { + match contact { + 0 => Some("left"), + 1 => Some("right"), + 2 => Some("middle"), + _ => None, + } +} + +fn key_identity(keycode: i32) -> (String, String) { + match keycode { + 8 => ("Backspace".into(), "Backspace".into()), + 9 => ("Tab".into(), "Tab".into()), + 13 => ("Enter".into(), "Enter".into()), + 16 => ("Shift".into(), "ShiftLeft".into()), + 17 => ("Control".into(), "ControlLeft".into()), + 18 => ("Alt".into(), "AltLeft".into()), + 27 => ("Escape".into(), "Escape".into()), + 32 => (" ".into(), "Space".into()), + 37 => ("ArrowLeft".into(), "ArrowLeft".into()), + 38 => ("ArrowUp".into(), "ArrowUp".into()), + 39 => ("ArrowRight".into(), "ArrowRight".into()), + 40 => ("ArrowDown".into(), "ArrowDown".into()), + 91 => ("Meta".into(), "MetaLeft".into()), + 92 => ("Meta".into(), "MetaRight".into()), + 112..=135 => { + let key = format!("F{}", keycode - 111); + (key.clone(), key) + } + 160 => ("Shift".into(), "ShiftLeft".into()), + 161 => ("Shift".into(), "ShiftRight".into()), + 162 => ("Control".into(), "ControlLeft".into()), + 163 => ("Control".into(), "ControlRight".into()), + 164 => ("Alt".into(), "AltLeft".into()), + 165 => ("Alt".into(), "AltRight".into()), + 48..=57 => { + let ch = char::from_u32(keycode as u32).unwrap_or('0'); + (ch.to_string(), format!("Digit{ch}")) + } + 65..=90 => { + let ch = char::from_u32(keycode as u32).unwrap_or('A'); + (ch.to_ascii_lowercase().to_string(), format!("Key{ch}")) + } + _ => (format!("U+{keycode:04X}"), String::new()), + } +} diff --git a/src-tauri/src/commands/cloud_web_transport.rs b/src-tauri/src/commands/cloud_web_transport.rs new file mode 100644 index 00000000..5792e0d7 --- /dev/null +++ b/src-tauri/src/commands/cloud_web_transport.rs @@ -0,0 +1,289 @@ +use std::fs::{File, OpenOptions}; +use std::io; +use std::os::windows::fs::OpenOptionsExt; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use serde_json::Value; +use windows::core::PCWSTR; +use windows::Win32::Foundation::{ERROR_IO_PENDING, HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows::Win32::Storage::FileSystem::{ReadFile, WriteFile, FILE_FLAG_OVERLAPPED}; +use windows::Win32::System::Pipes::WaitNamedPipeW; +use windows::Win32::System::Threading::{CreateEventW, ResetEvent, WaitForSingleObject}; +use windows::Win32::System::IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024; + +pub(super) struct Transaction<'a> { + pipe: File, + deadline: Instant, + cancellation: &'a AtomicU64, + generation: u64, +} + +impl<'a> Transaction<'a> { + fn check(&self) -> io::Result<()> { + if self.cancellation.load(Ordering::Acquire) != self.generation { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "request cancelled", + )); + } + if Instant::now() >= self.deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "request deadline exceeded", + )); + } + Ok(()) + } + + fn transfer(&self, bytes: &mut [u8], write: bool) -> io::Result<()> { + let pipe = HANDLE(self.pipe.as_raw_handle()); + let event = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }?; + let event = unsafe { OwnedHandle::from_raw_handle(event.0) }; + let mut offset = 0; + while offset < bytes.len() { + self.check()?; + unsafe { ResetEvent(HANDLE(event.as_raw_handle())) }?; + let mut operation = OVERLAPPED { + hEvent: HANDLE(event.as_raw_handle()), + ..Default::default() + }; + let mut transferred = 0; + let started = unsafe { + if write { + WriteFile(pipe, Some(&bytes[offset..]), None, Some(&mut operation)) + } else { + ReadFile(pipe, Some(&mut bytes[offset..]), None, Some(&mut operation)) + } + }; + if let Err(error) = started { + if error.code() != ERROR_IO_PENDING.to_hresult() { + return Err(error.into()); + } + } + let completion = loop { + if let Err(error) = self.check() { + break Err(error); + } + let wait = unsafe { WaitForSingleObject(operation.hEvent, 50) }; + if wait == WAIT_OBJECT_0 { + break Ok(()); + } + if wait != WAIT_TIMEOUT { + break Err(io::Error::last_os_error()); + } + }; + if let Err(error) = completion { + // The OS must release the OVERLAPPED and buffer before they + // leave this scope, including timeout and cancellation paths. + unsafe { + let _ = CancelIoEx(pipe, Some(&operation)); + let _ = GetOverlappedResult(pipe, &operation, &mut transferred, true); + } + return Err(error); + } + unsafe { GetOverlappedResult(pipe, &operation, &mut transferred, false) }?; + if transferred == 0 { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "pipe closed")); + } + offset += transferred as usize; + } + Ok(()) + } +} + +// Consume shared pixels while the server still owns this transaction. Only +// acknowledge after the consumer has copied/closed every transferred handle. +pub(super) fn request( + process_id: u32, + message: &Value, + cancellation: &AtomicU64, + generation: u64, + consume: impl FnOnce(Value) -> io::Result, +) -> io::Result { + let deadline = Instant::now() + REQUEST_TIMEOUT; + let pipe_path = format!(r"\\.\pipe\MaaEnd.CloudWeb.{process_id}"); + let pipe_name: Vec = pipe_path.encode_utf16().chain(Some(0)).collect(); + let pipe = loop { + if cancellation.load(Ordering::Acquire) != generation { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "request cancelled", + )); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "pipe connection timed out", + )); + } + match OpenOptions::new() + .read(true) + .write(true) + .custom_flags(FILE_FLAG_OVERLAPPED.0) + .open(&pipe_path) + { + Ok(pipe) => break pipe, + Err(error) if error.raw_os_error() == Some(231) => unsafe { + // Waiting does not reserve a pipe instance. Retry only before + // sending anything, and poll cancellation even during contention. + let _ = WaitNamedPipeW(PCWSTR(pipe_name.as_ptr()), 50); + }, + Err(error) => return Err(error), + } + }; + let transaction = Transaction { + pipe, + deadline, + cancellation, + generation, + }; + let mut body = serde_json::to_vec(message)?; + if body.is_empty() || body.len() > MAX_MESSAGE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid request length", + )); + } + transaction.transfer(&mut (body.len() as u32).to_le_bytes(), true)?; + transaction.transfer(&mut body, true)?; + let mut size = [0; 4]; + transaction.transfer(&mut size, false)?; + let size = u32::from_le_bytes(size) as usize; + if size == 0 || size > MAX_MESSAGE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid reply length", + )); + } + let mut reply = vec![0; size]; + transaction.transfer(&mut reply, false)?; + let result = consume(serde_json::from_slice(&reply)?); + transaction.transfer(&mut [1], true)?; + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::sync::{mpsc, Arc}; + use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX; + use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_TYPE_BYTE, PIPE_WAIT, + }; + + fn server(serve: impl FnOnce(File) + Send + 'static) -> (u32, std::thread::JoinHandle<()>) { + static NEXT: AtomicU64 = AtomicU64::new(1); + // Isolated names cannot collide with an actual host PID. + let id = 0x8000_0000 + NEXT.fetch_add(1, Ordering::Relaxed) as u32; + let name: Vec = format!(r"\\.\pipe\MaaEnd.CloudWeb.{id}") + .encode_utf16() + .chain(Some(0)) + .collect(); + let (ready, started) = mpsc::channel(); + let thread = std::thread::spawn(move || { + let pipe = unsafe { + CreateNamedPipeW( + PCWSTR(name.as_ptr()), + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, + 65536, + 65536, + 0, + None, + ) + }; + assert!(!pipe.is_invalid()); + let mut pipe = unsafe { File::from_raw_handle(pipe.0) }; + ready.send(()).unwrap(); + if let Err(error) = unsafe { ConnectNamedPipe(HANDLE(pipe.as_raw_handle()), None) } { + assert_eq!( + error.code(), + windows::Win32::Foundation::ERROR_PIPE_CONNECTED.to_hresult() + ); + } + let mut size = [0; 4]; + pipe.read_exact(&mut size).unwrap(); + let mut body = vec![0; u32::from_le_bytes(size) as usize]; + pipe.read_exact(&mut body).unwrap(); + serve(pipe); + }); + started.recv().unwrap(); + (id, thread) + } + + #[test] + fn acknowledges_only_after_consumer_finishes_even_on_consumer_error() { + let consumed = Arc::new(AtomicU64::new(0)); + let observed = Arc::clone(&consumed); + let (id, thread) = server(move |mut pipe| { + pipe.write_all(&2u32.to_le_bytes()).unwrap(); + pipe.write_all(b"{}").unwrap(); + let mut ack = [0]; + pipe.read_exact(&mut ack).unwrap(); + assert_eq!(ack, [1]); + assert_eq!(observed.load(Ordering::Acquire), 1); + }); + let cancellation = AtomicU64::new(0); + let result: io::Result<()> = request( + id, + &serde_json::json!({"op":"frame_shared"}), + &cancellation, + 0, + |_| { + consumed.store(1, Ordering::Release); + Err(io::Error::other("consumer failed")) + }, + ); + assert_eq!(result.unwrap_err().to_string(), "consumer failed"); + thread.join().unwrap(); + } + + #[test] + fn partial_reply_has_a_total_deadline() { + let (id, thread) = server(|mut pipe| { + pipe.write_all(&[2, 0]).unwrap(); + let mut ack = [0]; + assert!(pipe.read_exact(&mut ack).is_err()); + }); + let started = Instant::now(); + let error = request(id, &serde_json::json!({}), &AtomicU64::new(0), 0, Ok).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!(started.elapsed() < Duration::from_secs(7)); + thread.join().unwrap(); + } + + #[test] + fn cancellation_interrupts_pending_io() { + let cancellation = Arc::new(AtomicU64::new(0)); + let cancel = Arc::clone(&cancellation); + let (id, thread) = server(move |mut pipe| { + cancel.fetch_add(1, Ordering::AcqRel); + let mut ack = [0]; + assert!(pipe.read_exact(&mut ack).is_err()); + }); + let started = Instant::now(); + let error = request(id, &serde_json::json!({}), &cancellation, 0, Ok).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Interrupted); + assert!(started.elapsed() < Duration::from_secs(1)); + thread.join().unwrap(); + } + + #[test] + fn rejects_oversized_replies_before_allocating() { + let (id, thread) = server(|mut pipe| { + pipe.write_all(&u32::MAX.to_le_bytes()).unwrap(); + let mut ack = [0]; + assert!(pipe.read_exact(&mut ack).is_err()); + }); + let error = request(id, &serde_json::json!({}), &AtomicU64::new(0), 0, Ok).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + thread.join().unwrap(); + } +} diff --git a/src-tauri/src/commands/maa_agent.rs b/src-tauri/src/commands/maa_agent.rs index 1f0d798b..2b8efcbd 100644 --- a/src-tauri/src/commands/maa_agent.rs +++ b/src-tauri/src/commands/maa_agent.rs @@ -550,6 +550,27 @@ async fn start_single_agent( }).await.map_err(|e| e.to_string())? } +// Keep newly started agents local until at least one task is accepted. +// Every earlier return rolls back only this batch, preserving existing agents. +#[derive(Default)] +struct PendingAgents { + clients: Vec, + children: Vec, +} + +impl Drop for PendingAgents { + fn drop(&mut self) { + for client in &self.clients { + let _ = client.disconnect(); + } + self.clients.clear(); + for child in &mut self.children { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + /// 启动任务的核心实现(Tauri invoke 和 HTTP handler 共享) pub async fn start_tasks_impl( app: tauri::AppHandle, @@ -564,6 +585,9 @@ pub async fn start_tasks_impl( controller_info: Option, ) -> Result, String> { info!("start_tasks_impl called"); + if tasks.is_empty() { + return Err("No tasks to submit".to_string()); + } info!("instance_id: {}", instance_id); info!("tasks: {:?}", tasks); @@ -578,6 +602,9 @@ pub async fn start_tasks_impl( .get_mut(&instance_id) .ok_or("Instance not found")?; debug!("[start_tasks] Instance found: {}", instance_id); + if reset_state && !instance.pending_tasks.is_empty() { + return Err("Cannot reset a running task queue".to_string()); + } let res = instance .resource @@ -663,6 +690,7 @@ pub async fn start_tasks_impl( // 启动所有 Agent(如果配置了) debug!("[start_tasks] Checking agent configs..."); + let mut new_agents = PendingAgents::default(); let pi_envs = Arc::new(pi_envs.unwrap_or_default()); if let Some(configs) = agent_configs { if configs.is_empty() { @@ -670,10 +698,6 @@ pub async fn start_tasks_impl( } else { info!("[start_tasks] Starting {} agent(s)...", configs.len()); - // 用于收集所有成功启动的 agent,失败时需要回滚清理 - let mut new_clients = Vec::new(); - let mut new_children = Vec::new(); - for (idx, config) in configs.iter().enumerate() { let res_clone = resource.clone(); let ctrl_clone = controller.clone(); @@ -698,8 +722,8 @@ pub async fn start_tasks_impl( .await { Ok((client, child)) => { - new_clients.push(client); - new_children.push(child); + new_agents.clients.push(client); + new_agents.children.push(child); } Err(e) => { error!( @@ -707,26 +731,11 @@ pub async fn start_tasks_impl( idx, e ); - // 回滚:清理已启动的 agent - for client in &new_clients { - let _ = client.disconnect(); - } - for mut child in new_children { - let _ = child.kill(); - let _ = child.wait(); - } return Err(format!("Agent start failed: {}", e)); } } } - // 保存所有 agent 状态到 instance - let mut instances = maa_state.instances.lock().map_err(|e| e.to_string())?; - if let Some(instance) = instances.get_mut(&instance_id) { - instance.agent_clients.extend(new_clients); - instance.agent_children.extend(new_children); - } - info!( "[start_tasks] All {} agent(s) started successfully", configs.len() @@ -764,6 +773,23 @@ pub async fn start_tasks_impl( let instance = instances .get_mut(&instance_id) .ok_or("Instance not found")?; + #[cfg(target_os = "windows")] + let new_cloud_run = if instance.cloud_run.is_none() { + if let Some(super::types::ControllerConfig::CloudWeb { handle, .. }) = + instance.controller_config.as_ref() + { + Some(super::maa_core::cloud_web_controller::CloudRun::acquire( + *handle, + )?) + } else { + None + } + } else { + None + }; + if reset_state && !instance.pending_tasks.is_empty() { + return Err("Cannot reset a running task queue".to_string()); + } if reset_state { // 首批:重置任务运行状态 instance.task_ids.clear(); @@ -785,6 +811,7 @@ pub async fn start_tasks_impl( Ok(job) => { info!("[start_tasks] post_task returned task_id: {}", job.id); task_id_pairs.push((job.id, task.selected_task_id.clone())); + instance.pending_tasks.add(job.id); // 立即登记,保证回调不会先于状态 let state = &mut instance.task_run_state; state.pending_task_ids.push(job.id); @@ -812,6 +839,15 @@ pub async fn start_tasks_impl( } } } + if task_id_pairs.is_empty() { + return Err("No tasks could be submitted".to_string()); + } + #[cfg(target_os = "windows")] + if let Some(lease) = new_cloud_run { + instance.cloud_run = Some(lease); + } + instance.agent_clients.append(&mut new_agents.clients); + instance.agent_children.append(&mut new_agents.children); let task_ids: Vec = task_id_pairs.iter().map(|(id, _)| *id).collect(); task_ids_ret = task_ids.clone(); debug!( @@ -884,9 +920,16 @@ pub fn stop_agent_impl(maa_state: &Arc, instance_id: &str) -> Result<( ) }; + stop_agents(clients, children); + Ok(()) +} + +// Callers detach a completed run's agents while holding the instance lock. +// Cleanup therefore cannot take agents belonging to a subsequent run. +pub(super) fn stop_agents(clients: Vec, children: Vec) { if clients.is_empty() && children.is_empty() { debug!("[stop_agent] No agents to stop"); - return Ok(()); + return; } info!( @@ -936,8 +979,6 @@ pub fn stop_agent_impl(maa_state: &Arc, instance_id: &str) -> Result<( } } }); - - Ok(()) } /// 停止所有 Agent 并断开连接 — Tauri invoke 入口,委托给 stop_agent_impl diff --git a/src-tauri/src/commands/maa_core.rs b/src-tauri/src/commands/maa_core.rs index c32d227a..373eb737 100644 --- a/src-tauri/src/commands/maa_core.rs +++ b/src-tauri/src/commands/maa_core.rs @@ -10,6 +10,7 @@ use std::time::{Duration, Instant}; use tauri::State; use maa_framework::controller::{AdbControllerBuilder, Controller}; +#[cfg(target_os = "windows")] use maa_framework::resource::Resource; use maa_framework::tasker::Tasker; #[cfg(target_os = "macos")] @@ -23,6 +24,10 @@ use super::types::{ }; use super::utils::{emit_callback_event, get_maafw_dir, handle_task_callback, normalize_path}; +#[cfg(target_os = "windows")] +#[path = "cloud_web_controller.rs"] +pub(super) mod cloud_web_controller; + /// MaaFramework 最小支持版本 const MIN_MAAFW_VERSION: &str = "5.12.1"; @@ -900,6 +905,26 @@ pub async fn connect_controller_impl( ) .map_err(|e| e.to_string())? } + #[cfg(target_os = "windows")] + ControllerConfig::CloudWeb { handle, .. } => { + let hwnd = windows::Win32::Foundation::HWND(*handle as *mut _); + let mut process_id = 0; + unsafe { + windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId( + hwnd, + Some(&mut process_id), + ); + } + if process_id == 0 { + return Err("Cloud WebView window process is unavailable".into()); + } + Controller::new_custom(cloud_web_controller::CloudWebController::new(process_id)) + .map_err(|e| e.to_string())? + } + #[cfg(not(target_os = "windows"))] + ControllerConfig::CloudWeb { .. } => { + return Err("Cloud WebView control is only supported on Windows".into()); + } ControllerConfig::MacOS { handle, screencap_method, @@ -1031,6 +1056,9 @@ pub async fn connect_controller_impl( | ControllerConfig::Win32 { display_short_side, .. } + | ControllerConfig::CloudWeb { + display_short_side, .. + } | ControllerConfig::MacOS { display_short_side, .. } @@ -1374,27 +1402,43 @@ pub fn run_task_impl( return Err("Tasker not initialized even after rebuild".to_string()); } + #[cfg(target_os = "windows")] + let new_cloud_run = if instance.cloud_run.is_none() { + if let Some(ControllerConfig::CloudWeb { handle, .. }) = instance.controller_config.as_ref() + { + Some(cloud_web_controller::CloudRun::acquire(*handle)?) + } else { + None + } + } else { + None + }; let job = tasker .post_task(entry, pipeline_override) .map_err(|e| e.to_string())?; + #[cfg(target_os = "windows")] + if let Some(lease) = new_cloud_run { + instance.cloud_run = Some(lease); + } let task_id = job.id; + instance.pending_tasks.add(task_id); if !instance.task_ids.contains(&task_id) { instance.task_ids.push(task_id); } + let task_run_state = &mut instance.task_run_state; + if !task_run_state.pending_task_ids.contains(&task_id) { + task_run_state.pending_task_ids.push(task_id); + } + task_run_state.overall_status = Some("Running".to_string()); if let Some(selected_task_id) = selected_task_id { - let task_run_state = &mut instance.task_run_state; - if !task_run_state.pending_task_ids.contains(&task_id) { - task_run_state.pending_task_ids.push(task_id); - } task_run_state .mappings .insert(task_id, selected_task_id.to_string()); task_run_state .statuses .insert(selected_task_id.to_string(), "pending".to_string()); - task_run_state.overall_status = Some("Running".to_string()); } Ok(task_id) @@ -1479,6 +1523,7 @@ pub fn stop_task_impl(state: &MaaState, instance_id: &str) -> Result<(), String> instance.stop_in_progress = true; instance.stop_started_at = Some(Instant::now()); instance.task_ids.clear(); + instance.pending_tasks.cancel(); // 将剩余 pending 任务标记为 failed,更新整体状态 { @@ -1494,7 +1539,22 @@ pub fn stop_task_impl(state: &MaaState, instance_id: &str) -> Result<(), String> // 遥测:用户取消,以 cancelled 结束当前运行的 Transaction(幂等,仅首次生效) super::telemetry::on_run_cancelled(instance_id); + #[cfg(target_os = "windows")] + if let Some(ControllerConfig::CloudWeb { handle, .. }) = instance.controller_config.as_ref() { + cloud_web_controller::cancel_pending(*handle); + } tasker.post_stop().map_err(|e| e.to_string())?; + #[cfg(target_os = "windows")] + if let Some(lease) = instance.cloud_run.take() { + let tasker = tasker.clone(); + std::thread::spawn(move || { + // Preserve ownership until cancellation and input release finish. + while tasker.running() { + std::thread::sleep(Duration::from_millis(50)); + } + lease.finish(); + }); + } Ok(()) } diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 48f404d1..408770b5 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -755,6 +755,9 @@ pub async fn run_pretask( "run_pretask wait cancelled by stop request: {}", instance_id ); + // A cancelled pre-task must not resume input when a cloud queue later + // completes. The separately launched browser host stays open. + let _ = child.kill(); std::thread::spawn(move || { let _ = child.wait(); }); diff --git a/src-tauri/src/commands/task_completion.rs b/src-tauri/src/commands/task_completion.rs new file mode 100644 index 00000000..23acc7ba --- /dev/null +++ b/src-tauri/src/commands/task_completion.rs @@ -0,0 +1,85 @@ +use std::collections::HashSet; + +// Keep callback membership separate from the task history returned to clients. +#[derive(Default)] +pub struct TaskCompletion { + pending: HashSet, + failed: bool, +} + +impl TaskCompletion { + pub fn add(&mut self, id: i64) { + if self.pending.is_empty() { + self.failed = false; + } + self.pending.insert(id); + } + + pub fn contains(&self, id: i64) -> bool { + self.pending.contains(&id) + } + + pub fn complete(&mut self, id: i64, succeeded: bool) -> bool { + if !self.pending.remove(&id) { + return false; + } + self.failed |= !succeeded; + self.pending.is_empty() + } + + pub fn failed(&self) -> bool { + self.failed + } + + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } + + pub fn cancel(&mut self) { + self.pending.clear(); + self.failed = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn old_and_duplicate_callbacks_cannot_complete_a_new_run() { + let mut run = TaskCompletion::default(); + run.add(1); + assert!(run.complete(1, true)); + run.add(2); + assert!(!run.complete(1, false)); + assert!(!run.failed()); + assert!(run.contains(2)); + assert!(run.complete(2, true)); + assert!(!run.complete(2, true)); + } + + #[test] + fn tracks_every_task_without_requiring_frontend_ids() { + let mut run = TaskCompletion::default(); + run.add(10); + run.add(11); + assert!(!run.complete(10, false)); + run.add(12); + assert!(!run.complete(11, true)); + assert!(run.complete(12, true)); + assert!(run.failed()); + } + + #[test] + fn cancel_invalidates_queued_callbacks_and_next_run_resets_failure() { + let mut run = TaskCompletion::default(); + run.add(1); + run.add(2); + run.cancel(); + assert!(!run.complete(1, true)); + run.add(3); + assert!(!run.complete(2, false)); + assert!(run.complete(3, true)); + assert!(!run.failed()); + } +} diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs index abe9de50..149a85e8 100644 --- a/src-tauri/src/commands/types.rs +++ b/src-tauri/src/commands/types.rs @@ -2,6 +2,9 @@ //! //! 包含 Tauri 命令使用的数据结构和枚举 +#[path = "task_completion.rs"] +mod task_completion; + use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::process::Child; @@ -89,6 +92,11 @@ pub enum ControllerConfig { #[serde(default)] display_short_side: Option, }, + CloudWeb { + handle: u64, + #[serde(default)] + display_short_side: Option, + }, MacOS { handle: u64, screencap_method: u64, @@ -230,6 +238,9 @@ pub struct AllInstanceStates { /// 实例运行时状态(持有 MaaFramework 对象句柄) #[derive(Default)] pub struct InstanceRuntime { + pub pending_tasks: task_completion::TaskCompletion, + #[cfg(target_os = "windows")] + pub cloud_run: Option, pub resource: Option, pub controller: Option, /// 当前控制器的配置(用于 ControllerPool 引用管理) diff --git a/src-tauri/src/commands/utils.rs b/src-tauri/src/commands/utils.rs index 84e0ab21..175d7758 100644 --- a/src-tauri/src/commands/utils.rs +++ b/src-tauri/src/commands/utils.rs @@ -79,6 +79,7 @@ pub fn handle_task_callback( None => return, }; + let mut completed_agents = None; let all_done = { let mut instances = match maa_state.instances.lock() { Ok(g) => g, @@ -88,6 +89,10 @@ pub fn handle_task_callback( Some(i) => i, None => return, }; + // Ignore callbacks from an older queue and repeated terminal events. + if !instance.pending_tasks.contains(task_id) { + return; + } let state = &mut instance.task_run_state; if is_started { @@ -107,20 +112,23 @@ pub fn handle_task_callback( } // 检查所有已入队任务是否均已完成 - let all_completed = state.pending_task_ids.iter().all(|id| { - state - .mappings - .get(id) - .and_then(|sel_id| state.statuses.get(sel_id)) - .map(|s| s == "succeeded" || s == "failed") - .unwrap_or(false) - }); + let all_completed = instance.pending_tasks.complete(task_id, is_succeeded); if all_completed { - let has_failed = state.statuses.values().any(|s| s == "failed"); + instance.task_ids.clear(); + completed_agents = Some(( + std::mem::take(&mut instance.agent_clients), + std::mem::take(&mut instance.agent_children), + )); + let has_failed = instance.pending_tasks.failed(); state.overall_status = Some(if has_failed { "Failed" } else { "Succeeded" }.to_string()); - instance.task_ids.clear(); + #[cfg(target_os = "windows")] + if let Some(lease) = instance.cloud_run.take() { + // Serialize run completion with the next run's acquisition. + // The host checks the lease token before closing. + lease.finish(); + } } all_completed @@ -142,12 +150,8 @@ pub fn handle_task_callback( if all_done { emit_state_changed(app, instance_id, "tasks-completed"); - // 实例内全部任务结束后清理旧 agent,避免 agent 进程跨轮累积 - if let Err(e) = super::maa_agent::stop_agent_impl(maa_state, instance_id) { - log::warn!( - "[handle_task_callback] Failed to stop agents after tasks completed: {}", - e - ); + if let Some((clients, children)) = completed_agents { + super::maa_agent::stop_agents(clients, children); } } } diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx index 29dac3e5..11c9416a 100644 --- a/src/components/DashboardView.tsx +++ b/src/components/DashboardView.tsx @@ -290,10 +290,14 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance try { const exitCode = await maaService.runPretask(instanceId, item.exec, args, basePath); if (exitCode !== 0) { + if (currentControllerName === 'Win32-CloudWeb') { + throw new Error(t('action.pretaskExitCode', { code: exitCode })); + } log.warn(`[${instanceName}] 预任务退出码非零: ${exitCode}`); } } catch (err) { log.error(`[${instanceName}] 预任务执行失败:`, err); + if (currentControllerName === 'Win32-CloudWeb') throw err; } } diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 505ea102..873834df 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -514,6 +514,9 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr throwIfPreActionStopped(targetId); if (exitCode !== 0) { + if (controllerName === 'Win32-CloudWeb') { + return failStart(t('action.pretaskExitCode', { code: exitCode })); + } log.warn(`实例 ${targetInstance.name}: 预任务退出码非零:`, exitCode); addLog(targetId, { type: 'warning', @@ -532,6 +535,9 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr throw err; } log.error(`实例 ${targetInstance.name}: 预任务执行失败:`, err); + if (controllerName === 'Win32-CloudWeb') { + return failStart(t('action.pretaskFailed', { error: String(err) })); + } addLog(targetId, { type: 'error', message: t('action.pretaskFailed', { error: String(err) }), @@ -970,12 +976,19 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr // 提前注册回调收集器,await 完成后再发起连接,避免竞态 const collectedCallbacks: Array<{ message: string; details: { ctrl_id?: number } }> = []; + let handleConnectionCallback: + | ((message: string, details: { ctrl_id?: number }) => void) + | undefined; const unsubscribe = await maaService.onCallback((message, details) => { if ( message === 'Controller.Action.Succeeded' || message === 'Controller.Action.Failed' ) { - collectedCallbacks.push({ message, details }); + if (handleConnectionCallback) { + handleConnectionCallback(message, details); + } else { + collectedCallbacks.push({ message, details }); + } } }); @@ -999,7 +1012,6 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr resolved = true; clearTimeout(timeout); unsubscribe(); - unlistenCb?.(); unlistenState?.(); setInstanceConnectionStatus(targetId, 'Connected'); resolve(true); @@ -1010,7 +1022,6 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr resolved = true; clearTimeout(timeout); unsubscribe(); - unlistenCb?.(); unlistenState?.(); resolve(false); }; @@ -1022,8 +1033,15 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr } }, 30000); - // 路径 1:继续监听新的 maa-callback(Controller.Action.Succeeded/Failed) - let unlistenCb: (() => void) | undefined; + // 复用连接前已注册的监听器,避免重新订阅时遗漏完成通知。 + handleConnectionCallback = (message, details) => { + if (resolved || details.ctrl_id !== ctrlId) return; + if (message === 'Controller.Action.Succeeded') { + handleSuccess(); + } else if (message === 'Controller.Action.Failed') { + handleFailure(); + } + }; // 路径 2:监听 state-changed(兜底:后端已广播 connected 但 Action.Succeeded 可能因竞态丢失) // Tauri 桌面端走 app.emit() → listen(),WebSocket 端走 wsService let unlistenState: (() => void) | undefined; @@ -1039,32 +1057,23 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr return; } - maaService - .onCallback((message, details) => { - if (resolved) return; - if (details.ctrl_id !== ctrlId) return; - if (message === 'Controller.Action.Succeeded') { - handleSuccess(); - } else if (message === 'Controller.Action.Failed') { - handleFailure(); - } - }) - .then((cb) => { - if (!resolved) { - unlistenCb = cb; - } else { - cb(); - } - }) - .catch((err) => { - log.error(`实例 ${targetInstance.name}: 注册 maa-callback 监听失败:`, err); - handleFailure(); - }); + const checkConnectionStatus = () => { + maaService + .getConnectionStatus(targetId) + .then((status) => { + if (!resolved && status === 'Connected') { + log.info(`实例 ${targetInstance.name}: 后端确认控制器已连接`); + handleSuccess(); + } + }) + .catch((err) => { + log.warn(`实例 ${targetInstance.name}: 查询连接状态失败:`, err); + }); + }; const handleStateChanged = (instanceId: string, kind: string) => { if (resolved) return; if (instanceId === targetId && kind === 'connected') { - log.info(`实例 ${targetInstance.name}: 通过 state-changed 兜底判定连接成功`); - handleSuccess(); + checkConnectionStatus(); } }; @@ -1077,13 +1086,18 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr ) .then((dispose) => { if (resolved) dispose(); - else unlistenState = dispose; + else { + unlistenState = dispose; + // 连接可能在监听器注册前完成,订阅后查询实际状态补齐这一窗口。 + checkConnectionStatus(); + } }) .catch((err) => { log.warn('注册 state-changed 监听失败:', err); }); } else { unlistenState = onStateChanged(handleStateChanged); + checkConnectionStatus(); } }); } diff --git a/src/types/maa.ts b/src/types/maa.ts index b5e23cd5..95a8b3cf 100644 --- a/src/types/maa.ts +++ b/src/types/maa.ts @@ -47,6 +47,12 @@ export interface Win32ControllerConfig { display_short_side?: number; } +export interface CloudWebControllerConfig { + type: 'CloudWeb'; + handle: number; + display_short_side?: number; +} + /** macOS 原生窗口控制器配置 */ export interface MacOSControllerConfig { type: 'MacOS'; @@ -99,6 +105,7 @@ export interface GamepadControllerConfig { export type ControllerConfig = | AdbControllerConfig | Win32ControllerConfig + | CloudWebControllerConfig | MacOSControllerConfig | WlRootsControllerConfig | LinuxControllerConfig diff --git a/src/utils/controller.ts b/src/utils/controller.ts index 27af5f12..57ab94f4 100644 --- a/src/utils/controller.ts +++ b/src/utils/controller.ts @@ -80,6 +80,14 @@ export function buildDesktopWindowControllerConfig( controller: ControllerItem | undefined, handle: number, ): ControllerConfig | null { + if (controller?.type === 'Win32' && controller.name === 'Win32-CloudWeb') { + return { + type: 'CloudWeb', + handle, + display_short_side: controller.display_short_side, + }; + } + if (controller?.type === 'Win32') { return { type: 'Win32', From b14daa69464a0ea3ea99480d5559d7a137edf90b Mon Sep 17 00:00:00 2001 From: SENyiAi <123186357+SENyiAi@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:57:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E8=B5=84=E6=BA=90=E7=B1=BB=E5=9E=8B=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/maa_core.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src-tauri/src/commands/maa_core.rs b/src-tauri/src/commands/maa_core.rs index 373eb737..ce9c5af8 100644 --- a/src-tauri/src/commands/maa_core.rs +++ b/src-tauri/src/commands/maa_core.rs @@ -10,7 +10,6 @@ use std::time::{Duration, Instant}; use tauri::State; use maa_framework::controller::{AdbControllerBuilder, Controller}; -#[cfg(target_os = "windows")] use maa_framework::resource::Resource; use maa_framework::tasker::Tasker; #[cfg(target_os = "macos")]