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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 76 additions & 8 deletions crates/wb-switch-core/src/modules/codebuddy_cn_ide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -740,19 +740,48 @@ fn close_codebuddy_cn_windows(timeout_secs: i64) -> Result<(), String> {
return Ok(());
}
let pids: Vec<u32> = rows.iter().map(|r| r.pid).collect();
for pid in &pids {
let pid_s = pid.to_string();
let _ = run_cmd("taskkill", &["/PID", &pid_s, "/T"], 10);
}

let started = Instant::now();
let timeout = Duration::from_secs(timeout_secs.max(1) as u64);
let graceful_budget = Duration::from_secs(8).min(timeout);
let remaining = process::wait_windows_pids_gone(&pids, graceful_budget);

// 1) 优雅阶段:向拥有可见顶层窗口的进程(即 Electron GUI 主进程)发送
// WM_CLOSE,让 CodeBuddy CN 自行走窗口关闭/保存/退出流程。只影响主进程,
// 渲染子进程会随主进程一并退出,无需逐个处理。
let notified = process::windows_send_close_to_pids(&pids);
if notified == 0 {
// 未发现任何可见顶层窗口(如仅驻留托盘/后台):退化为对所有进程执行
// 不带 /F 的 taskkill——对 GUI 进程而言这等价于发送关闭请求。
eprintln!("[codebuddy-cn-ide] no visible window found, fallback graceful taskkill…");
for pid in &pids {
let pid_s = pid.to_string();
let _ = run_cmd("taskkill", &["/PID", &pid_s, "/T"], 10);
}
} else {
eprintln!("[codebuddy-cn-ide] sent WM_CLOSE to {notified} visible window(s)");
}

// 2) 优雅窗口:等待进程全部消失(预算 8s 或剩余时间)。
let graceful = Duration::from_secs(8).min(timeout);
let remaining = process::wait_windows_pids_gone(&pids, graceful);
if remaining.is_empty() {
return Ok(());
}

// 3) 保护用户数据:若残留进程仍持有可见窗口(典型场景:IDE 弹了
// “未保存文件”确认框并阻塞退出),此时不自动强杀,避免丢失编辑内容,
// 改为提示用户保存/关闭后重试。这是与原实现“8s 后无条件 /F”的关键差异。
let with_window = process::windows_visible_window_pids(&remaining);
if !with_window.is_empty() {
return Err(format!(
"CodeBuddy CN 仍有可见窗口未关闭(PID {}),可能包含未保存内容。请先在 CodeBuddy CN 中保存并关闭窗口后,再重试切换。",
with_window
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ")
));
}

// 4) 无窗口残留(后台进程)→ 可安全强制结束进程树。
for pid in &remaining {
let pid_s = pid.to_string();
let _ = run_cmd("taskkill", &["/PID", &pid_s, "/T", "/F"], 10);
Expand Down Expand Up @@ -890,6 +919,42 @@ fn launch_codebuddy_cn_macos() -> Result<(), String> {
validate_macos_cn_startup(&app)
}

/// Windows 启动存活校验:spawn 后轮询 CodeBuddy CN 进程出现并持续存活。
///
/// - 从未出现 → 超时 Err;
/// - 出现后持续存活 ≥ `sustain` → Ok;
/// - 出现过但随后消失(单例锁导致秒退的典型特征)→ 立即 Err。
/// 与 macOS 分支 `validate_macos_cn_startup` 语义对齐,避免“spawn 成功即假成功”。
#[cfg(target_os = "windows")]
fn validate_windows_cn_startup(exe: &Path) -> Result<(), String> {
let deadline = Instant::now() + Duration::from_secs(30);
let sustain = Duration::from_secs(10);
let mut seen_at: Option<Instant> = None;
while Instant::now() < deadline {
let alive = !windows_cn_process_rows().is_empty();
if alive {
match seen_at {
None => seen_at = Some(Instant::now()),
Some(start) => {
if start.elapsed() >= sustain {
return Ok(());
}
}
}
} else if seen_at.is_some() {
return Err(format!(
"CodeBuddy CN 启动后立即退出(疑似残留单例锁)。请先手动打开一次 CodeBuddy CN(路径: {})。",
exe.display()
));
}
std::thread::sleep(Duration::from_millis(500));
}
Err(format!(
"启动 CodeBuddy CN 超时,未能确认运行(路径: {})。请先手动打开一次 CodeBuddy CN。",
exe.display()
))
}

pub fn launch_codebuddy_cn() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
Expand All @@ -907,11 +972,14 @@ pub fn launch_codebuddy_cn() -> Result<(), String> {
));
}
persist_cn_app_cache(&exe);
process::cmd_builder(&exe)
let spawned = process::cmd_builder(&exe)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("启动 CodeBuddy CN 失败: {e}(路径: {})", exe.display()))?;
// 启动后存活校验:避免单例锁导致秒退却仍提示“切换成功”。
validate_windows_cn_startup(&exe)?;
drop(spawned);
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
Expand Down
119 changes: 119 additions & 0 deletions crates/wb-switch-core/src/modules/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,125 @@ pub(crate) fn existing_windows_drives() -> Vec<char> {
.collect()
}

/// 生成内联 C#(user32 P/Invoke)PowerShell 脚本的公共常量模板。
///
/// 占位符:`__PIDS__`(PowerShell 数组字面量)、`__MODE__`(close/query)。
/// 脚本单次 Add-Type 编译后调用,仅用于低频的优雅关闭流程。
#[cfg(target_os = "windows")]
const WM_CLOSE_TEMPLATE: &str = r#"
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Collections.Generic;
public static class WbSwitchWin {
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
public static List<uint> VisiblePids(HashSet<uint> targets) {
var res = new List<uint>();
EnumWindows(delegate(IntPtr h, IntPtr l) {
uint pid; GetWindowThreadProcessId(h, out pid);
if (targets.Contains(pid) && IsWindowVisible(h)) {
if (!res.Contains(pid)) res.Add(pid);
}
return true;
}, IntPtr.Zero);
return res;
}
public static int SendClose(HashSet<uint> targets) {
int count = 0;
EnumWindows(delegate(IntPtr h, IntPtr l) {
uint pid; GetWindowThreadProcessId(h, out pid);
if (targets.Contains(pid) && IsWindowVisible(h)) {
PostMessageW(h, 0x0010, IntPtr.Zero, IntPtr.Zero);
count++;
}
return true;
}, IntPtr.Zero);
return count;
}
}
"@
$targets = New-Object 'System.Collections.Generic.HashSet[uint32]'
__PIDS__ | ForEach-Object { [void]$targets.Add($_) }
if ('__MODE__' -eq 'close') {
[WbSwitchWin]::SendClose($targets)
} else {
([WbSwitchWin]::VisiblePids($targets) -join ',')
}
"#;

/// 按脚本模式组装内联 PowerShell 脚本。
#[cfg(target_os = "windows")]
fn windows_wm_close_script(script_mode: &str, pid_csv: &str) -> String {
let pids = if pid_csv.is_empty() {
"@()".to_string()
} else {
format!(
"@({})",
pid_csv
.split(',')
.filter(|s| !s.trim().is_empty())
.map(|s| format!("[uint32]{s}"))
.collect::<Vec<_>>()
.join(",")
)
};
WM_CLOSE_TEMPLATE
.replace("__PIDS__", &pids)
.replace("__MODE__", script_mode)
}

/// 向给定 PID 的所有可见顶层窗口发送 `WM_CLOSE`,返回实际送达的窗口数。
/// 用于 GUI 优雅退出:Electron 主进程收到后可自行走保存/退出流程。
#[cfg(target_os = "windows")]
pub(crate) fn windows_send_close_to_pids(pids: &[u32]) -> usize {
if pids.is_empty() {
return 0;
}
let csv = pids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let script = windows_wm_close_script("close", &csv);
match run_cmd_timeout("powershell", &["-NoProfile", "-NonInteractive", "-Command", &script], 30)
{
Some(out) => String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<usize>()
.unwrap_or(0),
None => 0,
}
}

/// 返回给定 PID 中仍拥有可见顶层窗口的 PID 子集。
/// 供优雅关闭后判断:若目标进程仍有可见窗口(如未保存确认框),
/// 应提示用户手动处理,而不是直接强杀。
#[cfg(target_os = "windows")]
pub(crate) fn windows_visible_window_pids(pids: &[u32]) -> Vec<u32> {
if pids.is_empty() {
return Vec::new();
}
let csv = pids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let script = windows_wm_close_script("query", &csv);
let stdout = match ps_output(&script, 30) {
Some(out) => out,
None => return Vec::new(),
};
stdout
.split(',')
.filter_map(|s| s.trim().parse::<u32>().ok())
.collect()
}

#[cfg(target_os = "windows")]
fn windows_running_workbuddy_exe() -> Option<PathBuf> {
let script = "Get-Process -Name WorkBuddy,CodeBuddy -ErrorAction SilentlyContinue | \
Expand Down
Loading