Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/windows.rs
T
kdletters 5ea453d5d1 收口AGC单窗口启动
取消开发态自动打开 Agent 聊天窗口

统一客户端可见标题为陶泥儿

同步启动守卫、测试与技术文档
2026-08-17 14:32:13 +08:00

636 lines
22 KiB
Rust

use super::*;
#[cfg(windows)]
fn configure_windows_background_std_command_with_suspension(
command: &mut std::process::Command,
create_process_group: bool,
create_suspended: bool,
) {
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
const CREATE_SUSPENDED: u32 = 0x0000_0004;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(
CREATE_NO_WINDOW
| if create_process_group {
CREATE_NEW_PROCESS_GROUP
} else {
0
}
| if create_suspended {
CREATE_SUSPENDED
} else {
0
},
);
}
#[cfg(windows)]
pub(crate) fn configure_windows_background_std_command(
command: &mut std::process::Command,
create_process_group: bool,
) {
configure_windows_background_std_command_with_suspension(command, create_process_group, false);
}
#[cfg(not(windows))]
pub(crate) fn configure_windows_background_std_command(
_command: &mut std::process::Command,
_create_process_group: bool,
) {
}
pub(crate) fn new_windows_background_std_command<S>(program: S) -> std::process::Command
where
S: AsRef<std::ffi::OsStr>,
{
let mut command = std::process::Command::new(program);
configure_windows_background_std_command(&mut command, false);
command
}
pub(crate) fn configure_windows_background_tokio_command(
command: &mut tokio::process::Command,
create_process_group: bool,
) {
configure_windows_background_std_command(command.as_std_mut(), create_process_group);
}
#[cfg(all(test, windows))]
mod windows_background_command_tests {
use super::*;
use std::fs;
use std::process::Stdio;
const CHILD_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_CHILD";
const RESULT_ENV: &str = "GENARRATIVE_WINDOWS_NO_CONSOLE_RESULT";
const FIXTURE_TEST: &str =
"windows::windows_background_command_tests::background_command_console_fixture";
#[test]
#[ignore = "child-process fixture"]
fn background_command_console_fixture() {
if std::env::var_os(CHILD_ENV).is_none() {
return;
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetConsoleWindow() -> windows_sys::Win32::Foundation::HWND;
}
let result_path = std::env::var_os(RESULT_ENV).expect("result path");
let has_console_window = unsafe { !GetConsoleWindow().is_null() };
fs::write(
result_path,
if has_console_window {
"console"
} else {
"hidden"
},
)
.expect("write console-window result");
}
#[test]
fn background_std_command_does_not_allocate_a_console_window() {
let directory = tempfile::tempdir().expect("create no-console test directory");
let result_path = directory.path().join("console-window.txt");
let mut command = new_windows_background_std_command(
std::env::current_exe().expect("current test binary"),
);
command
.args(["--exact", FIXTURE_TEST, "--ignored"])
.env(CHILD_ENV, "1")
.env(RESULT_ENV, &result_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let status = command.status().expect("run no-console child fixture");
assert!(status.success(), "no-console child fixture must succeed");
assert_eq!(
fs::read_to_string(result_path).expect("read console-window result"),
"hidden",
"CREATE_NO_WINDOW must keep background command probes from flashing a console window"
);
}
}
#[cfg(all(windows, feature = "game-chat-release"))]
pub(crate) fn configure_windows_suspended_background_std_command(
command: &mut std::process::Command,
create_process_group: bool,
) {
configure_windows_background_std_command_with_suspension(command, create_process_group, true);
}
#[cfg(all(windows, feature = "game-chat-release"))]
pub(crate) struct WindowsKillOnCloseJob {
handle: windows_sys::Win32::Foundation::HANDLE,
}
#[cfg(all(windows, feature = "game-chat-release"))]
unsafe impl Send for WindowsKillOnCloseJob {}
#[cfg(all(windows, feature = "game-chat-release"))]
impl WindowsKillOnCloseJob {
pub(crate) fn assign_runner(child: &std::process::Child) -> Result<Self, String> {
use std::mem::size_of;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if handle.is_null() || handle == INVALID_HANDLE_VALUE {
return Err(format!(
"创建 game-chat Agent Runner Windows Job Object 失败:{}",
std::io::Error::last_os_error()
));
}
let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let configured = unsafe {
SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
&information as *const _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if configured == 0 {
let error = std::io::Error::last_os_error();
unsafe {
CloseHandle(handle);
}
return Err(format!(
"配置 game-chat Agent Runner Windows Job Object 失败:{error}"
));
}
let process = child.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
if process.is_null() || unsafe { AssignProcessToJobObject(handle, process) } == 0 {
let error = std::io::Error::last_os_error();
unsafe {
CloseHandle(handle);
}
return Err(format!(
"将 game-chat Agent Runner 加入 Windows Job Object 失败:{error}"
));
}
Ok(Self { handle })
}
pub(crate) fn resume_suspended_runner(
&self,
child: &std::process::Child,
) -> Result<(), String> {
use std::mem::size_of;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
};
use windows_sys::Win32::System::Threading::{
GetProcessIdOfThread, OpenThread, ResumeThread, THREAD_QUERY_LIMITED_INFORMATION,
THREAD_SUSPEND_RESUME,
};
const ERROR_NO_MORE_FILES: i32 = 18;
const RESUME_THREAD_FAILED: u32 = u32::MAX;
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
return Err(format!(
"枚举 game-chat Agent Runner 挂起线程失败:{}",
std::io::Error::last_os_error()
));
}
let mut entry = THREADENTRY32 {
dwSize: size_of::<THREADENTRY32>() as u32,
..Default::default()
};
let mut runner_thread_id = None;
if unsafe { Thread32First(snapshot, &mut entry) } == 0 {
let error = std::io::Error::last_os_error();
unsafe {
CloseHandle(snapshot);
}
return Err(format!("读取 game-chat Agent Runner 挂起线程失败:{error}"));
}
loop {
if entry.th32OwnerProcessID == child.id() {
if runner_thread_id.replace(entry.th32ThreadID).is_some() {
unsafe {
CloseHandle(snapshot);
}
return Err(
"恢复 game-chat Agent Runner 失败:挂起进程存在多个线程".to_string()
);
}
}
if unsafe { Thread32Next(snapshot, &mut entry) } != 0 {
continue;
}
let error = std::io::Error::last_os_error();
if error.raw_os_error() != Some(ERROR_NO_MORE_FILES) {
unsafe {
CloseHandle(snapshot);
}
return Err(format!(
"继续读取 game-chat Agent Runner 挂起线程失败:{error}"
));
}
break;
}
unsafe {
CloseHandle(snapshot);
}
let thread_id = runner_thread_id
.ok_or_else(|| "恢复 game-chat Agent Runner 失败:找不到挂起线程".to_string())?;
let thread = unsafe {
OpenThread(
THREAD_SUSPEND_RESUME | THREAD_QUERY_LIMITED_INFORMATION,
0,
thread_id,
)
};
if thread.is_null() || thread == INVALID_HANDLE_VALUE {
return Err(format!(
"打开 game-chat Agent Runner 挂起线程失败:{}",
std::io::Error::last_os_error()
));
}
if unsafe { GetProcessIdOfThread(thread) } != child.id() {
unsafe {
CloseHandle(thread);
}
return Err("恢复 game-chat Agent Runner 失败:挂起线程所属进程已发生变化".to_string());
}
let previous_suspend_count = unsafe { ResumeThread(thread) };
let resume_error = if previous_suspend_count == RESUME_THREAD_FAILED {
Some(format!(
"恢复 game-chat Agent Runner 挂起线程失败:{}",
std::io::Error::last_os_error()
))
} else if previous_suspend_count != 1 {
Some(format!(
"恢复 game-chat Agent Runner 挂起线程失败:异常挂起计数 {previous_suspend_count}"
))
} else {
None
};
unsafe {
CloseHandle(thread);
}
if let Some(error) = resume_error {
return Err(error);
}
Ok(())
}
}
#[cfg(all(windows, feature = "game-chat-release"))]
impl Drop for WindowsKillOnCloseJob {
fn drop(&mut self) {
unsafe {
windows_sys::Win32::Foundation::CloseHandle(self.handle);
}
}
}
#[cfg(all(test, windows, feature = "game-chat-release"))]
mod windows_kill_on_close_job_tests {
use super::*;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
#[test]
fn game_chat_runner_starts_suspended_then_job_kills_it_when_handle_closes() {
let directory = tempfile::tempdir().expect("create Windows Job test directory");
let marker = directory.path().join("runner-started.txt");
let mut command = Command::new("cmd.exe");
command
.args([
"/D",
"/S",
"/C",
"echo started>runner-started.txt & ping.exe -n 30 127.0.0.1 >NUL",
])
.current_dir(directory.path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_windows_suspended_background_std_command(&mut command, true);
let mut child = command.spawn().expect("spawn Windows Job test child");
let job = match WindowsKillOnCloseJob::assign_runner(&child) {
Ok(job) => job,
Err(error) => {
let _ = child.kill();
let _ = child.wait();
panic!("assign Windows Job test child: {error}");
}
};
thread::sleep(Duration::from_millis(150));
assert!(
!marker.exists(),
"CREATE_SUSPENDED child must not execute before ResumeThread"
);
if let Err(error) = job.resume_suspended_runner(&child) {
drop(job);
let _ = child.kill();
let _ = child.wait();
panic!("resume Windows Job test child: {error}");
}
let started_deadline = Instant::now() + Duration::from_secs(3);
while !marker.exists() {
assert!(
Instant::now() < started_deadline,
"resumed Windows Job test child must execute"
);
thread::sleep(Duration::from_millis(25));
}
drop(job);
let deadline = Instant::now() + Duration::from_secs(3);
loop {
if child
.try_wait()
.expect("poll Windows Job test child")
.is_some()
{
break;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("closing the kill-on-close Job must terminate its assigned process");
}
thread::sleep(Duration::from_millis(25));
}
}
}
const GAME_CHAT_LAUNCH_USAGE: &str =
"用法:--game-chat [--project-path <本地项目绝对路径>] [--initial-message <首条消息>]";
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(crate) struct GameChatLaunchOptions {
pub(crate) project_path: Option<String>,
pub(crate) initial_message: Option<String>,
}
pub(crate) fn select_game_chat_launch_options(
explicit: Option<GameChatLaunchOptions>,
debug_build: bool,
game_chat_release: bool,
) -> Result<Option<GameChatLaunchOptions>, String> {
if debug_build {
return Ok(explicit);
}
if game_chat_release {
return Ok(Some(explicit.unwrap_or_default()));
}
if explicit.is_some() {
return Err("--game-chat 仅在开发构建或 game-chat release 中可用".to_string());
}
Ok(None)
}
pub(crate) fn should_shutdown_runner_on_tauri_event(
game_chat_release: bool,
event: &tauri::RunEvent,
) -> bool {
game_chat_release && matches!(event, tauri::RunEvent::Exit)
}
pub(crate) fn parse_game_chat_launch_args(
args: &[String],
) -> Result<Option<GameChatLaunchOptions>, String> {
let has_game_chat_arg = args.iter().any(|arg| {
matches!(
arg.as_str(),
"--game-chat" | "--project-path" | "--initial-message"
)
});
if !has_game_chat_arg {
return Ok(None);
}
if args.first().map(String::as_str) != Some("--game-chat") {
return Err(GAME_CHAT_LAUNCH_USAGE.to_string());
}
let mut project_path = None;
let mut initial_message = None;
let mut index = 1;
while index < args.len() {
let flag = args[index].as_str();
let value = args
.get(index + 1)
.ok_or_else(|| GAME_CHAT_LAUNCH_USAGE.to_string())?;
match flag {
"--project-path" if project_path.is_none() => {
project_path = Some(validate_workspace_window_project_path(value)?.to_string());
}
"--initial-message" if initial_message.is_none() => {
let value = value.trim();
if value.is_empty()
|| value.chars().count() > 4_000
|| value.chars().any(char::is_control)
{
return Err(GAME_CHAT_LAUNCH_USAGE.to_string());
}
initial_message = Some(value.to_string());
}
_ => return Err(GAME_CHAT_LAUNCH_USAGE.to_string()),
}
index += 2;
}
Ok(Some(GameChatLaunchOptions {
project_path,
initial_message,
}))
}
pub(crate) fn workspace_window_url(project_path: &str) -> tauri::WebviewUrl {
tauri::WebviewUrl::App(PathBuf::from(format!(
"index.html?main&projectPath={}",
percent_encode_query_value(project_path)
)))
}
pub(crate) fn launcher_window_url() -> tauri::WebviewUrl {
tauri::WebviewUrl::App(PathBuf::from("index.html?launcher"))
}
pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUrl {
tauri::WebviewUrl::App(PathBuf::from(format!(
"index.html?supervisor-chat&projectPath={}",
percent_encode_query_value(project_path)
)))
}
pub(crate) fn game_chat_window_url(
project_path: Option<&str>,
initial_message: Option<&str>,
) -> tauri::WebviewUrl {
let query = game_chat_window_query(project_path, initial_message);
tauri::WebviewUrl::App(PathBuf::from(format!("index.html?{query}")))
}
pub(crate) fn apply_game_chat_initial_window_url(
config: &mut tauri::Config,
options: &GameChatLaunchOptions,
) -> Result<(), String> {
let client = config
.app
.windows
.iter_mut()
.find(|window| window.label == "client")
.ok_or_else(|| "找不到 AI 游戏创作客户端窗口配置".to_string())?;
client.url = game_chat_window_url(
options.project_path.as_deref(),
options.initial_message.as_deref(),
);
Ok(())
}
fn game_chat_window_query(project_path: Option<&str>, initial_message: Option<&str>) -> String {
let mut query = "game-chat".to_string();
if let Some(project_path) = project_path {
query.push_str("&projectPath=");
query.push_str(&percent_encode_query_value(project_path));
}
if let Some(initial_message) = initial_message {
query.push_str("&initialMessage=");
query.push_str(&percent_encode_query_value(initial_message));
}
query
}
pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> {
let project_path = project_path.trim();
if project_path.is_empty() {
return Err("请提供工作区绝对路径".to_string());
}
if !Path::new(project_path).is_absolute() {
return Err("工作区路径必须是绝对路径".to_string());
}
if project_path.chars().any(char::is_control) {
return Err("工作区路径不能包含控制字符".to_string());
}
Ok(project_path)
}
fn percent_encode_query_value(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char)
}
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
#[tauri::command]
pub(crate) fn open_game_creator_workspace_window(
app: tauri::AppHandle,
window: tauri::Window,
project_path: String,
) -> Result<(), String> {
if cfg!(all(not(debug_assertions), feature = "game-chat-release")) {
return Err("game-chat 独立版只能打开游戏创作对话页面".to_string());
}
let project_path = validate_workspace_window_project_path(&project_path)?;
if let Some(existing) = app.get_webview_window("main") {
existing.close().map_err(|error| error.to_string())?;
}
tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path))
.title("陶泥儿")
.inner_size(1180.0, 820.0)
.min_inner_size(760.0, 560.0)
.build()
.map_err(|error| error.to_string())?;
window.close().map_err(|error| error.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) fn open_game_creator_launcher_window(
app: tauri::AppHandle,
window: tauri::Window,
) -> Result<(), String> {
if cfg!(all(not(debug_assertions), feature = "game-chat-release")) {
return Err("game-chat 独立版只能打开游戏创作对话页面".to_string());
}
if let Some(existing) = app.get_webview_window("launcher") {
existing.set_focus().map_err(|error| error.to_string())?;
} else {
tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url())
.title("陶泥儿")
.inner_size(820.0, 640.0)
.min_inner_size(720.0, 520.0)
.build()
.map_err(|error| error.to_string())?;
}
window.close().map_err(|error| error.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) fn open_project_supervisor_chat_window(
app: tauri::AppHandle,
project_path: String,
) -> Result<(), String> {
let project_path = validate_workspace_window_project_path(&project_path)?;
#[cfg(not(debug_assertions))]
{
let _ = app;
let _ = project_path;
return Err("项目总控对话窗口仅在开发构建中可用".to_string());
}
#[cfg(debug_assertions)]
{
if let Some(existing) = app.get_webview_window("supervisor-chat") {
let mut current_url = existing.url().map_err(|error| error.to_string())?;
let current_project_path = current_url
.query_pairs()
.find_map(|(key, value)| (key == "projectPath").then(|| value.into_owned()));
if current_project_path.as_deref() != Some(project_path) {
current_url.set_query(Some(&format!(
"supervisor-chat&projectPath={}",
percent_encode_query_value(project_path)
)));
current_url.set_fragment(None);
existing
.navigate(current_url)
.map_err(|error| error.to_string())?;
}
existing.show().map_err(|error| error.to_string())?;
existing.unminimize().map_err(|error| error.to_string())?;
existing.set_focus().map_err(|error| error.to_string())?;
return Ok(());
}
tauri::WebviewWindowBuilder::new(
&app,
"supervisor-chat",
supervisor_chat_window_url(project_path),
)
.title("项目总控 Agent 对话")
.inner_size(820.0, 720.0)
.min_inner_size(560.0, 480.0)
.build()
.map_err(|error| error.to_string())?;
Ok(())
}
}