diff --git a/Cargo.lock b/Cargo.lock index 80ac9673..cd82aaa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1079,6 +1079,7 @@ dependencies = [ "tiny_http", "toml", "ureq", + "windows-sys 0.61.2", "winreg", ] diff --git a/crates/maa-cli/Cargo.toml b/crates/maa-cli/Cargo.toml index 7ec6d5e1..39bd8b99 100644 --- a/crates/maa-cli/Cargo.toml +++ b/crates/maa-cli/Cargo.toml @@ -43,6 +43,11 @@ toml = { workspace = true } ureq = { workspace = true, features = ["json", "platform-verifier"] } [target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", +] } winreg = { workspace = true } [dev-dependencies] diff --git a/crates/maa-cli/config_examples/profiles/default.toml b/crates/maa-cli/config_examples/profiles/default.toml index ed1322be..7ed1d3a1 100644 --- a/crates/maa-cli/config_examples/profiles/default.toml +++ b/crates/maa-cli/config_examples/profiles/default.toml @@ -17,6 +17,18 @@ config = "CompatMac" # connect config name # address = "localhost:1717" # PlayTools address # config = "CompatMac" # connect config name +# 3. Attach to the official Windows PC client (Windows only, no ADB) +# The title is matched exactly against visible top-level windows. Set a PID or executable path +# when more than one window has the same title. The control method values below are defaults. +# [connection] +# preset = "Win32" +# window_title = "Arknights" +# window_process_id = 1234 # optional disambiguator +# window_executable = "C:\\Games\\Arknights.exe" # optional canonical path match +# screencap_method = 2 # FramePool +# mouse_method = 32 # SendMessageWithCursorPos +# keyboard_method = 2 # SendMessage + # Resource options for MAA Core [resource] diff --git a/crates/maa-cli/docs/en-US/config.md b/crates/maa-cli/docs/en-US/config.md index 5a770a6b..d4bae5b5 100644 --- a/crates/maa-cli/docs/en-US/config.md +++ b/crates/maa-cli/docs/en-US/config.md @@ -356,6 +356,22 @@ There are two special presets: `PlayCover` (macOS) and `Waydroid` (Linux) session is already running, starts one if needed, and then connects ADB via `waydroid adb connect`. See [Waydroid documentation][waydroid-doc] for details. +- `Win32` attaches MaaCore to the official Windows PC client without ADB. It is only available on Windows. + + `window_title` is required and must exactly match a visible top-level window. `window_process_id` and `window_executable` are optional disambiguators; the executable is compared by canonical path. The default control methods are `screencap_method = 2` (`FramePool`), `mouse_method = 32` (`SendMessageWithCursorPos`), and `keyboard_method = 2` (`SendMessage`). + + `MaaWin32ControlUnit.dll` must be installed beside `MaaCore.dll`. The `PC` platform resources are loaded automatically. maa-cli does not launch the PC client, and it disables `StartUp.start_game_enabled` for this preset, so start the selected client before running a task. + +```toml +[connection] +preset = "Win32" +window_title = "Arknights" +window_process_id = 1234 # optional +window_executable = "C:\\Games\\Arknights.exe" # optional +``` + +Use `maa connection test --profile --screencap --json` to attach and verify a non-empty screenshot without running tasks. On success it exits with code 0 and prints `{"ok":true,"connection":"Win32","screenshot_bytes":...}`. + ### Resource The `resource` section specifies which resources MaaCore should load: @@ -367,7 +383,7 @@ platform_diff_resource = "iOS" # Non-Android resources user_resource = true # Whether to load user-defined resources ``` -When using a non-Simplified Chinese game client, set `global_resource` to load non-Chinese resources. When using an iOS client, set `platform_diff_resource` to `iOS`. Both are optional - leave empty to not load these resources. These are also auto-set based on your `startup` task's `client_type` and when using `PlayTools` connection. To load user-defined resources, set `user_resource` to `true`, which loads resources from `$MAA_CONFIG_DIR/resource`. +When using a non-Simplified Chinese game client, set `global_resource` to load non-Chinese resources. When using an iOS client, set `platform_diff_resource` to `iOS`. Both are optional - leave empty to not load these resources. These are also auto-set based on your `startup` task's `client_type`, `PlayTools` connections load `iOS`, and `Win32` connections load `PC`. To load user-defined resources, set `user_resource` to `true`, which loads resources from `$MAA_CONFIG_DIR/resource`. ### Static options diff --git a/crates/maa-cli/docs/ko-KR/config.md b/crates/maa-cli/docs/ko-KR/config.md index 2e701537..02ae107d 100644 --- a/crates/maa-cli/docs/ko-KR/config.md +++ b/crates/maa-cli/docs/ko-KR/config.md @@ -333,6 +333,22 @@ address = "127.0.0.1:7777" # 필요 시 사전 설정된 주소를 재정의할 - `Waydroid`는 Linux에서 `Waydroid`를 통해 Android 앱을 실행하는 데 사용됩니다。이 경우 MaaCore의 장치 연결에 `adb_path`를 지정해야 합니다。maa-cli는 `waydroid status`를 사용하여 세션이 이미 실행 중인지 감지하고, 필요 시 세션을 시작한 다음 `waydroid adb connect`를 통해 ADB 연결을 설정합니다。자세한 내용은 [Waydroid 지원 문서][waydroid-doc]을 참조하세요。 +- `Win32`는 ADB 없이 명일방주 공식 Windows PC 클라이언트 창에 MaaCore를 연결하며 Windows에서만 사용할 수 있습니다. + + `window_title`은 필수이며 표시 중인 최상위 창 제목과 정확히 일치해야 합니다. 동일한 제목의 창이 둘 이상이면 `window_process_id` 또는 정규화된 경로로 비교되는 `window_executable`을 지정해야 합니다. 기본 제어 방식은 `screencap_method = 2`(`FramePool`), `mouse_method = 32`(`SendMessageWithCursorPos`), `keyboard_method = 2`(`SendMessage`)입니다. + + `MaaWin32ControlUnit.dll`이 `MaaCore.dll`과 같은 디렉터리에 있어야 하며 `PC` 플랫폼 리소스는 자동으로 로드됩니다. maa-cli는 PC 클라이언트를 직접 실행하지 않고 이 프리셋에서는 `StartUp.start_game_enabled`를 비활성화하므로, 작업 실행 전에 선택한 클라이언트를 시작해야 합니다. + +```toml +[connection] +preset = "Win32" +window_title = "Arknights" +window_process_id = 1234 # 선택 사항 +window_executable = "C:\\Games\\Arknights.exe" # 선택 사항 +``` + +작업을 실행하지 않고 창 연결과 스크린샷을 검증하려면 `maa connection test --profile <이름> --screencap --json`을 사용하세요. 성공하면 종료 코드 0과 `{"ok":true,"connection":"Win32","screenshot_bytes":...}`를 출력합니다. + ### 리소스 설정 `[resource]` 관련 필드는 MaaCore가 로드하는 리소스를 지정하는 데 사용됩니다: @@ -344,7 +360,7 @@ platform_diff_resource = "iOS" # 비 안드로이드 버전의 리소스 user_resource = true # 사용자 정의 리소스를 로드할지 여부 ``` -비 중국어 게임 클라이언트를 사용할 때, MaaCore는 기본적으로 간체 중국어 리소스를 로드하기 때문에 `global_resource` 필드를 지정하여 비중문 버전의 리소스를 로드해야 합니다. iOS 버전의 게임 클라이언트를 사용할 때는 `platform_diff_resource` 필드를 지정하여 iOS 버전의 리소스를 로드해야 합니다. 이 두 필드는 선택 사항이며, 리소스를 로드할 필요가 없는 경우 두 필드를 비워둘 수 있습니다. 또한, `startup` 작업에서 `client_type` 필드를 지정한 경우 `global_resource`는 해당 클라이언트의 리소스로 설정되며, `PlayTools` 연결을 사용하는 경우 `platform_diff_resource`는 iOS로 설정됩니다. 마지막으로 사용자 정의 리소스를 로드하려는 경우 `user_resource` 필드를 `true`로 설정해야 합니다. +비 중국어 게임 클라이언트를 사용할 때, MaaCore는 기본적으로 간체 중국어 리소스를 로드하기 때문에 `global_resource` 필드를 지정하여 비중문 버전의 리소스를 로드해야 합니다. iOS 버전의 게임 클라이언트를 사용할 때는 `platform_diff_resource` 필드를 지정하여 iOS 버전의 리소스를 로드해야 합니다. 이 두 필드는 선택 사항이며, 리소스를 로드할 필요가 없는 경우 두 필드를 비워둘 수 있습니다. 또한, `startup` 작업에서 `client_type` 필드를 지정한 경우 `global_resource`는 해당 클라이언트의 리소스로 설정되며, `PlayTools` 연결은 `iOS`, `Win32` 연결은 `PC` 플랫폼 리소스를 자동으로 설정합니다. 마지막으로 사용자 정의 리소스를 로드하려는 경우 `user_resource` 필드를 `true`로 설정해야 합니다. ### 정적 옵션 diff --git a/crates/maa-cli/docs/zh-CN/config.md b/crates/maa-cli/docs/zh-CN/config.md index 5354d4a8..c8f533f9 100644 --- a/crates/maa-cli/docs/zh-CN/config.md +++ b/crates/maa-cli/docs/zh-CN/config.md @@ -337,6 +337,22 @@ address = "127.0.0.1:7777" # 如果你需要的话,你可以覆盖预设的地 - `Waydroid`用于在 Linux 上连接直接通过 `Waydroid` 原生运行的游戏客户端。这种情况下仍需要指定 `adb_path` 供 MaaCore 连接设备使用。maa-cli 会自动管理会话:通过 `waydroid status` 检测会话是否已在运行,必要时启动会话,然后通过 `waydroid adb connect` 建立 ADB 连接。 具体使用参见 [Waydroid 支持文档][waydroid-doc]. +- `Win32` 不通过 ADB,而是让 MaaCore 附加到明日方舟官方 Windows PC 客户端窗口;该预设仅支持 Windows。 + + `window_title` 为必填项,且必须与可见顶层窗口的标题完全一致。如果多个窗口标题相同,可使用 `window_process_id` 或按规范化路径匹配的 `window_executable` 来消除歧义。默认控制方式为 `screencap_method = 2`(`FramePool`)、`mouse_method = 32`(`SendMessageWithCursorPos`)和 `keyboard_method = 2`(`SendMessage`)。 + + `MaaWin32ControlUnit.dll` 必须与 `MaaCore.dll` 位于同一目录,`PC` 平台资源会自动加载。maa-cli 不负责启动 PC 客户端,并会在该预设下禁用 `StartUp.start_game_enabled`,因此运行任务前需要先启动所选客户端。 + +```toml +[connection] +preset = "Win32" +window_title = "Arknights" +window_process_id = 1234 # 可选 +window_executable = "C:\\Games\\Arknights.exe" # 可选 +``` + +可使用 `maa connection test --profile <名称> --screencap --json` 在不运行任务的情况下附加窗口并验证截图非空。成功时退出码为 0,并输出 `{"ok":true,"connection":"Win32","screenshot_bytes":...}`。 + ### 资源配置 `[resource]` 相关字段用于指定 MaaCore 加载的资源: @@ -348,7 +364,7 @@ platform_diff_resource = "iOS" # 非安卓版本的资源 user_resource = true # 是否加载用户自定义的资源 ``` -当使用非简体中文游戏客户端时,由于 MaaCore 默认加载的资源是简体中文的,你需要指定 `global_resource` 字段来加载非中文版本的资源。当使用 iOS 版本的游戏客户端时,你需要指定 `platform_diff_resource` 字段来加载 iOS 版本的资源。这两者都是可选的,如果你不需要加载这些资源,你可以将这两个字段设置为空。其次,这两者也会被自动设置,如果你的 `startup` 任务中指定了 `client_type` 字段,那么 `global_resource` 将会被设置为对应客户端的资源,而当你使用 `PlayTools` 连接时,`platform_diff_resource` 将会被设置为 `iOS`。最后,当你想要加载用户自定义的资源时,你需要将 `user_resource` 字段设置为 `true`。 +当使用非简体中文游戏客户端时,由于 MaaCore 默认加载的资源是简体中文的,你需要指定 `global_resource` 字段来加载非中文版本的资源。当使用 iOS 版本的游戏客户端时,你需要指定 `platform_diff_resource` 字段来加载 iOS 版本的资源。这两者都是可选的,如果你不需要加载这些资源,你可以将这两个字段设置为空。其次,这两者也会被自动设置,如果你的 `startup` 任务中指定了 `client_type` 字段,那么 `global_resource` 将会被设置为对应客户端的资源;`PlayTools` 连接会加载 `iOS`,`Win32` 连接会加载 `PC` 平台资源。最后,当你想要加载用户自定义的资源时,你需要将 `user_resource` 字段设置为 `true`。 ### 静态选项 diff --git a/crates/maa-cli/schemas/asst.schema.json b/crates/maa-cli/schemas/asst.schema.json index 771a0eca..0427b292 100644 --- a/crates/maa-cli/schemas/asst.schema.json +++ b/crates/maa-cli/schemas/asst.schema.json @@ -8,7 +8,7 @@ "properties": { "preset": { "type": "string", - "enum": ["ADB", "PlayCover", "MuMuPro", "Waydroid"], + "enum": ["ADB", "PlayCover", "MuMuPro", "Waydroid", "Win32"], "default": "ADB" }, "adb_path": { @@ -17,8 +17,23 @@ "default": "adb" }, "address": { "type": "string" }, - "config": { "type": "string" } - } + "config": { "type": "string" }, + "window_title": { "type": "string", "minLength": 1 }, + "window_process_id": { "type": "integer", "minimum": 1 }, + "window_executable": { "type": "string", "format": "path" }, + "screencap_method": { "type": "integer", "minimum": 0, "default": 2 }, + "mouse_method": { "type": "integer", "minimum": 0, "default": 32 }, + "keyboard_method": { "type": "integer", "minimum": 0, "default": 2 } + }, + "allOf": [ + { + "if": { + "properties": { "preset": { "const": "Win32" } }, + "required": ["preset"] + }, + "then": { "required": ["window_title"] } + } + ] }, "resource": { "type": "object", diff --git a/crates/maa-cli/src/command.rs b/crates/maa-cli/src/command.rs index 0ddbd80e..095b1785 100644 --- a/crates/maa-cli/src/command.rs +++ b/crates/maa-cli/src/command.rs @@ -88,6 +88,9 @@ pub(crate) enum Command { #[arg(default_value = "all")] component: Component, }, + /// Test a configured MaaCore connection without running tasks + #[command(subcommand)] + Connection(ConnectionCommand), /// Run a custom task /// /// All arguments will be passed to maa-run, @@ -246,6 +249,12 @@ pub(crate) enum Command { }, } +#[derive(Subcommand)] +pub(crate) enum ConnectionCommand { + /// Connect or attach to the configured client and optionally capture one screenshot + Test(run::ConnectionTestArgs), +} + #[cfg(feature = "cli_installer")] #[derive(Subcommand)] #[command(name = "self")] @@ -544,6 +553,28 @@ mod test { )); } + #[test] + fn connection_test() { + assert_matches!( + parse_from([ + "maa", + "connection", + "test", + "--profile", + "arkconsole", + "--screencap", + "--json", + ]) + .command, + Command::Connection(ConnectionCommand::Test(run::ConnectionTestArgs { + profile: Some(profile), + screencap: true, + json: true, + .. + })) if profile == "arkconsole" + ); + } + #[test] fn convert() { assert_matches!( diff --git a/crates/maa-cli/src/config/asst.rs b/crates/maa-cli/src/config/asst.rs index 5e9c5745..572f7a9b 100644 --- a/crates/maa-cli/src/config/asst.rs +++ b/crates/maa-cli/src/config/asst.rs @@ -32,6 +32,9 @@ impl AsstConfig { info!("Detected connection with PlayTools"); instance_options.force_playtools(); resource.use_platform_diff_resource("iOS"); + } else if matches!(connection.preset, Preset::Win32) { + info!("Detected native Windows client connection"); + resource.use_platform_diff_resource("PC"); } Self { @@ -86,6 +89,43 @@ pub struct ConnectionConfig { pub(super) address: Option, #[serde(default)] pub(super) config: Option, + #[serde(default)] + pub(super) window_title: Option, + #[serde(default)] + pub(super) window_process_id: Option, + #[serde(default)] + pub(super) window_executable: Option, + #[serde(default)] + pub(super) screencap_method: Option, + #[serde(default)] + pub(super) mouse_method: Option, + #[serde(default)] + pub(super) keyboard_method: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowSelector<'a> { + pub title: &'a str, + pub process_id: Option, + pub executable: Option<&'a Path>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Win32ConnectArgs<'a> { + pub selector: WindowSelector<'a>, + pub screencap_method: u64, + pub mouse_method: u64, + pub keyboard_method: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionArgs<'a> { + Adb { + adb_path: Cow<'static, str>, + address: Cow<'a, str>, + config: &'a str, + }, + Win32(Win32ConnectArgs<'a>), } impl ConnectionConfig { @@ -98,6 +138,37 @@ impl ConnectionConfig { self } + pub fn win32_args(&self) -> Result> { + let title = self + .window_title + .as_deref() + .filter(|title| !title.is_empty()) + .context("Win32 connection requires a non-empty window_title")?; + Ok(Win32ConnectArgs { + selector: WindowSelector { + title, + process_id: self.window_process_id, + executable: self.window_executable.as_deref(), + }, + screencap_method: self.screencap_method.unwrap_or(2), + mouse_method: self.mouse_method.unwrap_or(32), + keyboard_method: self.keyboard_method.unwrap_or(2), + }) + } + + pub fn connection_args(&self) -> Result> { + if matches!(self.preset, Preset::Win32) { + Ok(ConnectionArgs::Win32(self.win32_args()?)) + } else { + let (adb_path, address, config) = self.connect_args(); + Ok(ConnectionArgs::Adb { + adb_path, + address, + config, + }) + } + } + pub fn connect_args(&self) -> (Cow<'static, str>, Cow<'_, str>, &str) { let adb_path: Cow<'static, str> = self .adb_path @@ -146,6 +217,7 @@ pub enum Preset { PlayCover, Waydroid, Androws, + Win32, #[default] Adb, } @@ -174,6 +246,7 @@ impl<'de> Deserialize<'de> for Preset { "ADB" | "Adb" | "adb" => Ok(Preset::Adb), "Waydroid" | "waydroid" => Ok(Preset::Waydroid), "Androws" | "androws" => Ok(Preset::Androws), + "Win32" | "win32" | "PC" | "pc" => Ok(Preset::Win32), _ => { warn!("Unknown connection preset: {value}, ignoring"); Ok(Preset::Adb) @@ -201,6 +274,7 @@ impl Preset { } Cow::Borrowed("adb") } + Preset::Win32 => Cow::Borrowed(""), } } @@ -221,6 +295,7 @@ impl Preset { warn!("Failed to detect device address, using emulator-5554"); "emulator-5554".into() }), + Preset::Win32 => "".into(), } } @@ -229,7 +304,9 @@ impl Preset { Preset::Waydroid => "Waydroid", Preset::Androws => "Androws", // May be preset specific in the future - Preset::MuMuPro | Preset::PlayCover | Preset::Adb => config_based_on_os(), + Preset::MuMuPro | Preset::PlayCover | Preset::Adb | Preset::Win32 => { + config_based_on_os() + } } } } @@ -674,6 +751,7 @@ mod tests { adb_path: Some(String::from("adb")), address: Some(String::from("emulator-5554")), config: Some(String::from("CompatMac")), + ..Default::default() }, resource: ResourceConfig { resource_base_dirs: { @@ -751,6 +829,7 @@ mod tests { adb_path: Some(String::from("/path/to/adb")), address: Some(String::from("127.0.0.1:5555")), config: Some(String::from("SomeConfig")), + ..Default::default() }, &[ Token::Map { len: Some(4) }, @@ -948,11 +1027,122 @@ mod tests { ], ); } + + #[test] + fn win32_automatically_loads_pc_platform_resources() { + let config: AsstConfig = toml::from_str( + r#" + [connection] + preset = "Win32" + window_title = "Arknights" + "#, + ) + .unwrap(); + + assert_eq!( + config.resource.platform_diff_resource, + Some(PathBuf::from("PC")) + ); + } } mod connection_config { use super::*; + #[test] + fn schema_declares_win32_connection_fields() { + let schema: serde_json::Value = + serde_json::from_str(include_str!("../../schemas/asst.schema.json")).unwrap(); + let connection = &schema["properties"]["connection"]["properties"]; + let presets = connection["preset"]["enum"].as_array().unwrap(); + assert!(presets.iter().any(|value| value == "Win32")); + assert_eq!(connection["window_title"]["type"], "string"); + assert_eq!(connection["window_process_id"]["type"], "integer"); + assert_eq!(connection["window_executable"]["format"], "path"); + assert_eq!(connection["screencap_method"]["default"], 2); + assert_eq!(connection["mouse_method"]["default"], 32); + assert_eq!(connection["keyboard_method"]["default"], 2); + } + + #[test] + fn parses_win32_window_selector_and_method_defaults() { + let config: ConnectionConfig = toml::from_str( + r#" + preset = "Win32" + window_title = "Arknights" + window_process_id = 4242 + window_executable = "C:\\Games\\Arknights.exe" + "#, + ) + .unwrap(); + + assert_eq!(config.preset(), Preset::Win32); + let args = config.win32_args().unwrap(); + assert_eq!(args.selector.title, "Arknights"); + assert_eq!(args.selector.process_id, Some(4242)); + assert_eq!( + args.selector.executable, + Some(Path::new(r"C:\Games\Arknights.exe")) + ); + assert_eq!(args.screencap_method, 2); + assert_eq!(args.mouse_method, 32); + assert_eq!(args.keyboard_method, 2); + } + + #[test] + fn win32_requires_an_exact_window_title() { + let config: ConnectionConfig = toml::from_str( + r#" + preset = "Win32" + window_executable = "C:\\Games\\Arknights.exe" + "#, + ) + .unwrap(); + + assert!( + config + .win32_args() + .unwrap_err() + .to_string() + .contains("window_title") + ); + } + + #[test] + fn win32_accepts_explicit_control_methods() { + let config: ConnectionConfig = toml::from_str( + r#" + preset = "Win32" + window_title = "Arknights" + screencap_method = 4 + mouse_method = 8 + keyboard_method = 16 + "#, + ) + .unwrap(); + + let args = config.win32_args().unwrap(); + assert_eq!(args.screencap_method, 4); + assert_eq!(args.mouse_method, 8); + assert_eq!(args.keyboard_method, 16); + } + + #[test] + fn win32_connection_args_never_construct_an_adb_request() { + let config: ConnectionConfig = toml::from_str( + r#" + preset = "Win32" + window_title = "Arknights" + "#, + ) + .unwrap(); + + assert!(matches!( + config.connection_args().unwrap(), + ConnectionArgs::Win32(_) + )); + } + #[test] fn default() { assert_matches!(ConnectionConfig::default(), ConnectionConfig { @@ -960,6 +1150,12 @@ mod tests { adb_path: None, address: None, config: None, + window_title: None, + window_process_id: None, + window_executable: None, + screencap_method: None, + mouse_method: None, + keyboard_method: None, }); } @@ -1019,6 +1215,7 @@ mod tests { adb_path: None, address: None, config: None, + ..Default::default() } .connect_args(), ( @@ -1034,6 +1231,7 @@ mod tests { adb_path: None, address: None, config: None, + ..Default::default() } .connect_args(), ("", "127.0.0.1:1717", config_based_on_os()), @@ -1045,6 +1243,7 @@ mod tests { adb_path: None, address: None, config: None, + ..Default::default() } .connect_args(), ("adb", "waydroid", "Waydroid"), @@ -1056,6 +1255,7 @@ mod tests { adb_path: None, address: Some("127.0.0.1:11111".to_owned()), config: None, + ..Default::default() } .connect_args(), ("adb", "waydroid", "Waydroid"), @@ -1067,6 +1267,7 @@ mod tests { adb_path: None, address: None, config: None, + ..Default::default() } .connect_args(), ( @@ -1082,6 +1283,7 @@ mod tests { adb_path: Some("/path/to/adb".to_owned()), address: Some("127.0.0.1:11111".to_owned()), config: Some("SomeConfig".to_owned()), + ..Default::default() } .connect_args(), ("/path/to/adb", "127.0.0.1:11111", "SomeConfig"), diff --git a/crates/maa-cli/src/config/task/mod.rs b/crates/maa-cli/src/config/task/mod.rs index e5af62bb..a11a8c3a 100644 --- a/crates/maa-cli/src/config/task/mod.rs +++ b/crates/maa-cli/src/config/task/mod.rs @@ -308,6 +308,16 @@ impl TaskConfig { }), } } + + /// Disable package-based game launching when the already-running Windows client is attached. + pub fn prepare_for_win32(&mut self) { + self.start_app = false; + for task in &mut self.tasks { + if task.task_type == TaskType::StartUp { + insert!(task.params, "start_game_enabled" => false); + } + } + } } fn determine_start_app(params: &MAAValue) -> bool { @@ -1286,5 +1296,29 @@ mod tests { .is_err() ); } + + #[test] + fn win32_preparation_disables_package_launch() { + let mut task_config = TaskConfig::new_with_task(Task::new( + StartUp, + object!( + "start_game_enabled" => true, + "client_type" => "Official", + ), + )) + .unwrap(); + + assert!(task_config.start_app); + task_config.prepare_for_win32(); + + assert!(!task_config.start_app); + assert_eq!( + task_config.tasks[0] + .params + .get("start_game_enabled") + .and_then(|value| value.as_bool()), + Some(false) + ); + } } } diff --git a/crates/maa-cli/src/installer/maa_core.rs b/crates/maa-cli/src/installer/maa_core.rs index 0ae282fd..ee35adf3 100644 --- a/crates/maa-cli/src/installer/maa_core.rs +++ b/crates/maa-cli/src/installer/maa_core.rs @@ -264,6 +264,17 @@ mod tests { Some(lib_dir.join(MAA_CORE_LIB)) ); + #[cfg(windows)] + assert_eq!( + extract_mapper( + Path::new("MaaWin32ControlUnit.dll"), + lib_dir, + resource_dir, + &config + ), + Some(lib_dir.join("MaaWin32ControlUnit.dll")) + ); + assert_eq!( extract_mapper( &Path::new("resource").join("config.json"), diff --git a/crates/maa-cli/src/main.rs b/crates/maa-cli/src/main.rs index 89becacf..26ef52bb 100644 --- a/crates/maa-cli/src/main.rs +++ b/crates/maa-cli/src/main.rs @@ -20,7 +20,7 @@ mod tasks; use anyhow::{Context, Result}; use clap::{CommandFactory, Parser}; -use crate::command::{Cli, Command, Component, Dir}; +use crate::command::{Cli, Command, Component, ConnectionCommand, Dir}; fn main() -> Result<()> { CompleteEnv::with_factory(Cli::command) @@ -94,6 +94,7 @@ fn main() -> Result<()> { _ => {} } } + Command::Connection(ConnectionCommand::Test(args)) => run::test_connection(args)?, Command::Run { task, common } => run::run_custom(task, common)?, Command::StartUp { params, common } => run::run_preset(params, common)?, Command::CloseDown { params, common } => run::run_preset(params, common)?, diff --git a/crates/maa-cli/src/run/mod.rs b/crates/maa-cli/src/run/mod.rs index 70d3bca9..5f1ae714 100644 --- a/crates/maa-cli/src/run/mod.rs +++ b/crates/maa-cli/src/run/mod.rs @@ -3,6 +3,11 @@ use callback::summary; mod external; +mod window; + +#[cfg(test)] +mod window_tests; + pub mod preset; use std::{ @@ -92,6 +97,20 @@ pub struct CommonArgs { pub no_auto_reconnect: bool, } +#[cfg_attr(test, derive(Debug, PartialEq))] +#[derive(Args, Default)] +pub struct ConnectionTestArgs { + /// Profile (asst config file) name + #[arg(short, long)] + pub profile: Option, + /// Take one fresh screenshot after connecting + #[arg(long)] + pub screencap: bool, + /// Print a machine-readable JSON result + #[arg(long)] + pub json: bool, +} + impl CommonArgs { pub fn apply_to(&self, config: &mut AsstConfig) { if let Some(addr) = self.addr.as_ref() { @@ -122,6 +141,104 @@ fn find_profile(root: impl AsRef, profile: Option<&str>) -> Result Result<()> { + #[cfg(not(windows))] + if matches!(_connection.preset(), crate::config::asst::Preset::Win32) { + bail!("Win32 connection is only supported on Windows"); + } + Ok(()) +} + +fn connect_assistant( + asst: &Assistant, + connection: &crate::config::asst::ConnectionConfig, + address_override: Option<&str>, +) -> Result<()> { + match connection.connection_args()? { + crate::config::asst::ConnectionArgs::Win32(args) => { + #[cfg(windows)] + { + let library = dirs::find_library(); + window::validate_win32_control_unit_at(library.as_deref())?; + let hwnd = window::resolve_window(&args.selector)?; + asst.async_attach_window( + hwnd, + args.screencap_method, + args.mouse_method, + args.keyboard_method, + true, + )?; + Ok(()) + } + #[cfg(not(windows))] + { + window::resolve_window(&args.selector)?; + Ok(()) + } + } + crate::config::asst::ConnectionArgs::Adb { + adb_path, + address, + config, + } => { + let address = address_override.unwrap_or(address.as_ref()); + asst.async_connect(adb_path.as_ref(), address, config, true)?; + Ok(()) + } + } +} + +fn require_screenshot_bytes(image: Option>) -> Result { + let image = image.context("Connection succeeded but MaaCore returned no screenshot")?; + if image.is_empty() { + bail!("Connection succeeded but MaaCore returned an empty screenshot"); + } + Ok(image.len()) +} + +fn connection_label(preset: crate::config::asst::Preset) -> &'static str { + use crate::config::asst::Preset; + + match preset { + Preset::Adb => "ADB", + Preset::MuMuPro => "MuMuPro", + Preset::PlayCover => "PlayCover", + Preset::Waydroid => "Waydroid", + Preset::Androws => "Androws", + Preset::Win32 => "Win32", + } +} + +pub fn test_connection(args: ConnectionTestArgs) -> Result<()> { + let asst_config = find_profile(dirs::config(), args.profile.as_deref())?; + ensure_connection_supported(&asst_config.connection)?; + load_core().context("Failed to load MaaCore!")?; + setup_core(&asst_config)?; + + let asst = Assistant::new().context("Failed to create Assistant")?; + asst_config.instance_options.apply_to(&asst)?; + connect_assistant(&asst, &asst_config.connection, None)?; + let screenshot_bytes = if args.screencap { + require_screenshot_bytes(asst.get_fresh_image()?)? + } else { + 0 + }; + let connection = connection_label(asst_config.connection.preset()); + if args.json { + println!( + "{}", + serde_json::json!({ + "ok": true, + "connection": connection, + "screenshot_bytes": screenshot_bytes, + }) + ); + } else { + println!("Connection test succeeded ({connection}, screenshot bytes: {screenshot_bytes})"); + } + Ok(()) +} + fn run_core(f: F, args: CommonArgs) -> Result<()> where F: FnOnce(&AsstConfig) -> Result, @@ -134,8 +251,15 @@ where let mut asst_config = find_profile(dirs::config(), args.profile.as_deref())?; args.apply_to(&mut asst_config); - - let task_config = f(&asst_config)?; + ensure_connection_supported(&asst_config.connection)?; + + let mut task_config = f(&asst_config)?; + if matches!( + asst_config.connection.preset(), + crate::config::asst::Preset::Win32 + ) { + task_config.prepare_for_win32(); + } if let Some(resource) = task_config.client_type.resource() { asst_config.resource.use_global_resource(resource); } @@ -193,15 +317,21 @@ where } if !args.dry_run { - // Prepare connection - let (adb_path, address, config) = asst_config.connection.connect_args(); + #[cfg(target_os = "macos")] + let playcover_address = matches!( + asst_config.connection.preset(), + crate::config::asst::Preset::PlayCover + ) + .then(|| asst_config.connection.connect_args().1.into_owned()); // Launch external apps let app: Option> = match asst_config.connection.preset() { #[cfg(target_os = "macos")] crate::config::asst::Preset::PlayCover => Some(Box::new(external::PlayCoverApp::new( task_config.client_type, - address.as_ref(), + playcover_address + .as_deref() + .context("PlayCover address is unavailable")?, ))), #[cfg(target_os = "linux")] crate::config::asst::Preset::Waydroid => Some(Box::new(external::WaydroidApp::new())), @@ -215,10 +345,8 @@ where .transpose()? .flatten(); - let address = runtime_address.as_deref().unwrap_or(&address); - // Connect to game or emulator - asst.async_connect(adb_path.as_ref(), address, config, true)?; + connect_assistant(&asst, &asst_config.connection, runtime_address.as_deref())?; debug!("Starting MAA..."); asst.start()?; @@ -334,6 +462,44 @@ mod tests { use super::*; + #[test] + fn screenshot_probe_requires_a_non_empty_image() { + assert!(require_screenshot_bytes(None).is_err()); + assert!(require_screenshot_bytes(Some(Vec::new())).is_err()); + assert_eq!(require_screenshot_bytes(Some(vec![1, 2, 3])).unwrap(), 3); + } + + #[test] + fn connection_probe_reports_the_configured_preset() { + use crate::config::asst::Preset; + + assert_eq!(connection_label(Preset::Adb), "ADB"); + assert_eq!(connection_label(Preset::MuMuPro), "MuMuPro"); + assert_eq!(connection_label(Preset::PlayCover), "PlayCover"); + assert_eq!(connection_label(Preset::Waydroid), "Waydroid"); + assert_eq!(connection_label(Preset::Androws), "Androws"); + assert_eq!(connection_label(Preset::Win32), "Win32"); + } + + #[cfg(not(windows))] + #[test] + fn win32_is_rejected_before_loading_core_on_non_windows() { + let config: crate::config::asst::ConnectionConfig = toml::from_str( + r#" + preset = "Win32" + window_title = "Arknights" + "#, + ) + .unwrap(); + + assert_eq!( + ensure_connection_supported(&config) + .unwrap_err() + .to_string(), + "Win32 connection is only supported on Windows" + ); + } + #[test] #[ignore = "need installed MaaCore"] fn basic_ffi() { diff --git a/crates/maa-cli/src/run/window.rs b/crates/maa-cli/src/run/window.rs new file mode 100644 index 00000000..907cf253 --- /dev/null +++ b/crates/maa-cli/src/run/window.rs @@ -0,0 +1,184 @@ +#[cfg(any(windows, test))] +use std::{ + num::NonZeroIsize, + path::{Path, PathBuf}, +}; + +#[cfg(any(windows, test))] +use anyhow::Context; +use anyhow::{Result, bail}; + +use crate::config::asst::WindowSelector; + +#[cfg(any(windows, test))] +const WIN32_CONTROL_UNIT: &str = "MaaWin32ControlUnit.dll"; + +#[cfg(any(windows, test))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct WindowCandidate { + pub handle: isize, + pub title: String, + pub process_id: u32, + pub executable: Option, + pub visible: bool, +} + +#[cfg(any(windows, test))] +pub(super) fn select_window( + selector: &WindowSelector<'_>, + candidates: &[WindowCandidate], +) -> Result { + let expected_executable = selector + .executable + .map(canonical_path) + .transpose() + .context("Failed to canonicalize configured window_executable")?; + let matches: Vec<_> = candidates + .iter() + .filter(|candidate| candidate.visible && candidate.title == selector.title) + .filter(|candidate| { + selector + .process_id + .is_none_or(|process_id| candidate.process_id == process_id) + }) + .filter(|candidate| match &expected_executable { + Some(expected) => candidate + .executable + .as_deref() + .and_then(|path| canonical_path(path).ok()) + .as_ref() + .is_some_and(|path| paths_equal(path, expected)), + None => true, + }) + .collect(); + + match matches.as_slice() { + [] => bail!( + "No visible top-level window exactly matched title {:?}", + selector.title + ), + [candidate] => { + NonZeroIsize::new(candidate.handle).context("Matched window has a null handle") + } + _ => bail!( + "Multiple visible top-level windows exactly matched title {:?}; set window_process_id or window_executable", + selector.title + ), + } +} + +#[cfg(any(windows, test))] +fn canonical_path(path: &Path) -> std::io::Result { + dunce::canonicalize(path) +} + +#[cfg(any(windows, test))] +fn paths_equal(left: &Path, right: &Path) -> bool { + if cfg!(windows) { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) + } else { + left == right + } +} + +#[cfg(any(windows, test))] +pub(super) fn validate_win32_control_unit_at(library_dir: Option<&Path>) -> Result<()> { + let directory = library_dir.context("MaaCore library directory could not be located")?; + let control_unit = directory.join(WIN32_CONTROL_UNIT); + if !control_unit.is_file() { + bail!( + "Win32 connection requires {} beside MaaCore.dll (not found at {})", + WIN32_CONTROL_UNIT, + control_unit.display() + ); + } + Ok(()) +} + +#[cfg(windows)] +mod platform { + use std::ptr; + + use anyhow::{Context, Result}; + use windows_sys::Win32::{ + Foundation::{CloseHandle, HWND, LPARAM}, + System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, + }, + UI::WindowsAndMessaging::{ + EnumWindows, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, + IsWindowVisible, + }, + }; + + use super::WindowCandidate; + + unsafe extern "system" fn collect_window(hwnd: HWND, state: LPARAM) -> i32 { + let candidates = unsafe { &mut *(state as *mut Vec) }; + let title_length = unsafe { GetWindowTextLengthW(hwnd) }; + if title_length <= 0 { + return 1; + } + let mut title = vec![0u16; title_length as usize + 1]; + let copied = unsafe { GetWindowTextW(hwnd, title.as_mut_ptr(), title.len() as i32) }; + if copied <= 0 { + return 1; + } + let mut process_id = 0; + unsafe { GetWindowThreadProcessId(hwnd, &mut process_id) }; + candidates.push(WindowCandidate { + handle: hwnd as isize, + title: String::from_utf16_lossy(&title[..copied as usize]), + process_id, + executable: process_executable(process_id), + visible: unsafe { IsWindowVisible(hwnd) } != 0, + }); + 1 + } + + fn process_executable(process_id: u32) -> Option { + if process_id == 0 { + return None; + } + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + return None; + } + let mut path = vec![0u16; 32_768]; + let mut length = path.len() as u32; + let queried = + unsafe { QueryFullProcessImageNameW(process, 0, path.as_mut_ptr(), &mut length) }; + unsafe { CloseHandle(process) }; + (queried != 0) + .then(|| std::path::PathBuf::from(String::from_utf16_lossy(&path[..length as usize]))) + } + + pub(super) fn enumerate() -> Result> { + let mut candidates = Vec::new(); + let result = unsafe { + EnumWindows( + Some(collect_window), + ptr::from_mut(&mut candidates) as LPARAM, + ) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()) + .context("Failed to enumerate top-level windows"); + } + Ok(candidates) + } +} + +#[cfg(windows)] +pub(super) fn resolve_window(selector: &WindowSelector<'_>) -> Result { + let candidates = platform::enumerate()?; + let handle = select_window(selector, &candidates)?; + maa_core::WindowHandle::new(handle.get() as *mut std::ffi::c_void) + .context("Matched window has a null handle") +} + +#[cfg(not(windows))] +pub(super) fn resolve_window(_selector: &WindowSelector<'_>) -> Result<()> { + bail!("Win32 connection is only supported on Windows") +} diff --git a/crates/maa-cli/src/run/window_tests.rs b/crates/maa-cli/src/run/window_tests.rs new file mode 100644 index 00000000..5acb905b --- /dev/null +++ b/crates/maa-cli/src/run/window_tests.rs @@ -0,0 +1,139 @@ +use std::path::PathBuf; + +use super::window::{WindowCandidate, select_window, validate_win32_control_unit_at}; +use crate::config::asst::WindowSelector; + +fn candidate( + handle: isize, + title: &str, + process_id: u32, + executable: Option<&str>, +) -> WindowCandidate { + WindowCandidate { + handle, + title: title.to_owned(), + process_id, + executable: executable.map(PathBuf::from), + visible: true, + } +} + +#[test] +fn selects_only_an_exact_visible_title() { + let selector = WindowSelector { + title: "Arknights", + process_id: None, + executable: None, + }; + let mut hidden = candidate(1, "Arknights", 100, None); + hidden.visible = false; + let candidates = vec![ + hidden, + candidate(2, "Arknights ", 101, None), + candidate(3, "Arknights", 102, None), + ]; + + assert_eq!(select_window(&selector, &candidates).unwrap().get(), 3); +} + +#[test] +fn optional_pid_disambiguates_matching_titles() { + let selector = WindowSelector { + title: "Arknights", + process_id: Some(102), + executable: None, + }; + let candidates = vec![ + candidate(1, "Arknights", 101, None), + candidate(2, "Arknights", 102, None), + ]; + + assert_eq!(select_window(&selector, &candidates).unwrap().get(), 2); +} + +#[test] +fn optional_canonical_executable_disambiguates_matching_titles() { + let root = tempfile::tempdir().unwrap(); + let expected_dir = root.path().join("expected"); + let other_dir = root.path().join("other"); + std::fs::create_dir_all(&expected_dir).unwrap(); + std::fs::create_dir_all(&other_dir).unwrap(); + let expected_executable = expected_dir.join("Arknights.exe"); + let other_executable = other_dir.join("Arknights.exe"); + std::fs::write(&expected_executable, []).unwrap(); + std::fs::write(&other_executable, []).unwrap(); + let selector = WindowSelector { + title: "Arknights", + process_id: None, + executable: Some(&expected_executable), + }; + let candidates = vec![ + candidate(1, "Arknights", 101, other_executable.to_str()), + candidate(2, "Arknights", 102, expected_executable.to_str()), + ]; + + assert_eq!(select_window(&selector, &candidates).unwrap().get(), 2); +} + +#[test] +fn rejects_ambiguous_visible_windows() { + let selector = WindowSelector { + title: "Arknights", + process_id: None, + executable: None, + }; + let candidates = vec![ + candidate(1, "Arknights", 101, None), + candidate(2, "Arknights", 102, None), + ]; + + assert!( + select_window(&selector, &candidates) + .unwrap_err() + .to_string() + .contains("Multiple visible top-level windows") + ); +} + +#[test] +fn rejects_missing_window() { + let selector = WindowSelector { + title: "Arknights", + process_id: None, + executable: None, + }; + + assert!( + select_window(&selector, &[]) + .unwrap_err() + .to_string() + .contains("No visible top-level window") + ); +} + +#[test] +fn win32_control_unit_capability_requires_the_dll() { + let dir = tempfile::tempdir().unwrap(); + let error = validate_win32_control_unit_at(Some(dir.path())).unwrap_err(); + assert!(error.to_string().contains("MaaWin32ControlUnit.dll")); + + std::fs::write(dir.path().join("MaaWin32ControlUnit.dll"), []).unwrap(); + validate_win32_control_unit_at(Some(dir.path())).unwrap(); +} + +#[cfg(not(windows))] +#[test] +fn win32_connection_reports_a_clear_platform_error() { + let selector = WindowSelector { + title: "Arknights", + process_id: None, + executable: None, + }; + + assert_eq!( + super::window::resolve_window(&selector) + .unwrap_err() + .to_string(), + "Win32 connection is only supported on Windows" + ); +} diff --git a/crates/maa-core/src/lib.rs b/crates/maa-core/src/lib.rs index e0639845..fb26bead 100644 --- a/crates/maa-core/src/lib.rs +++ b/crates/maa-core/src/lib.rs @@ -41,6 +41,24 @@ pub struct Assistant { _callback: Option>, } +/// A non-null native top-level window handle accepted by MaaCore's Win32 controller. +#[cfg(windows)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowHandle(std::ptr::NonNull); + +#[cfg(windows)] +impl WindowHandle { + /// Create a typed window handle from a native HWND value. + pub fn new(handle: *mut c_void) -> Option { + std::ptr::NonNull::new(handle).map(Self) + } + + /// Return the native HWND value. + pub fn as_ptr(self) -> *mut c_void { + self.0.as_ptr() + } +} + impl Drop for Assistant { fn drop(&mut self) { // Destroy the handle before the callback is dropped to make sure the callback is not used @@ -232,6 +250,32 @@ impl Assistant { .to_maa_result() } + /// Attach to a native Windows game window asynchronously. + /// + /// MaaCore owns neither the window nor its lifetime. Callers must select a valid visible + /// top-level window and keep the owning process alive while tasks are running. + #[cfg(windows)] + pub fn async_attach_window( + &self, + window: WindowHandle, + screencap_method: u64, + mouse_method: u64, + keyboard_method: u64, + block: bool, + ) -> Result { + unsafe { + maa_sys::binding::AsstAsyncAttachWindow( + self.handle, + window.as_ptr(), + screencap_method, + mouse_method, + keyboard_method, + block.into(), + ) + } + .to_maa_result() + } + /// Click the screen at the given position. pub fn async_click(&self, x: i32, y: i32, block: bool) -> Result { unsafe { maa_sys::binding::AsstAsyncClick(self.handle, x, y, block.into()) }.to_maa_result() @@ -351,4 +395,11 @@ mod tests { assert!(Assistant::unload().is_ok()); assert!(Assistant::loaded()); } + + #[cfg(windows)] + #[test] + fn async_attach_window_has_a_safe_typed_signature() { + let _method: fn(&Assistant, WindowHandle, u64, u64, u64, bool) -> Result = + Assistant::async_attach_window; + } }