5ea453d5d1
取消开发态自动打开 Agent 聊天窗口 统一客户端可见标题为陶泥儿 同步启动守卫、测试与技术文档
636 lines
22 KiB
Rust
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(())
|
|
}
|
|
}
|