diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 058bbed..fcd4598 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1014,6 +1014,9 @@ pub struct KimiWebState { window: Mutex>, /// The `kimi web` child spawned by this app (None when reusing a server). child: Mutex>, + /// Serializes `open_kimi_web_embedded` invocations: a rapid double-click + /// queues here instead of racing the window/server setup. + launch_lock: tokio::sync::Mutex<()>, } const KIMI_WEB_PORT: u16 = 58627; @@ -1064,7 +1067,13 @@ pub async fn open_kimi_web_embedded( app: tauri::AppHandle, state: tauri::State<'_, KimiWebState>, ) -> Result<(), String> { - // Singleton: focus the existing window instead of creating a second one. + // Serialize concurrent invocations (rapid double-click): a second call + // waits here until the first has finished building the window, then + // re-checks the singleton below and focuses it instead. + let _guard = state.launch_lock.lock().await; + + // Singleton (re-checked after acquiring the lock): focus the existing + // window instead of creating a second one. if let Ok(guard) = state.window.lock() { if let Some(w) = guard.as_ref() { if w.is_visible().unwrap_or(false) { @@ -1075,8 +1084,21 @@ pub async fn open_kimi_web_embedded( } } - // Reuse an already-running server, otherwise spawn `kimi web --no-open`. - if !kimi_web_alive(Duration::from_millis(800)).await { + // Reuse the server this app spawned earlier (still running), or a server + // already running outside the app; only spawn when neither is present. + // `spawned_by_me` gates every failure-path kill, so a reused server is + // never terminated. + let mut spawned_by_me = false; + let mut child_running = false; + if let Ok(mut guard) = state.child.lock() { + // try_wait: Ok(None) = still running. Exited or unwaitable children + // are treated as gone so a fresh server is spawned below. + child_running = guard + .as_mut() + .map(|child| child.try_wait().map(|st| st.is_none()).unwrap_or(false)) + .unwrap_or(false); + } + if !child_running && !kimi_web_alive(Duration::from_millis(800)).await { let mut cmd = std::process::Command::new("kimi"); cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]); #[cfg(windows)] @@ -1091,6 +1113,7 @@ pub async fn open_kimi_web_embedded( format!("failed to start `kimi web`: {e}") } })?; + spawned_by_me = true; if let Ok(mut guard) = state.child.lock() { *guard = Some(child); } @@ -1098,57 +1121,79 @@ pub async fn open_kimi_web_embedded( // Wait for the server to come up (fresh start takes a moment). if !kimi_web_alive(Duration::from_secs(8)).await { - kill_spawned_kimi_web(&state); + if spawned_by_me { + kill_spawned_kimi_web(&state); + } return Err(format!("kimi web did not come up within 8s ({KIMI_WEB_ORIGIN})")); } - // Create the window on the main thread (required on macOS). + // Build the URL, then create the window on the main thread (required on + // macOS). The build result is awaited while still holding `launch_lock`, + // so a concurrent second invocation blocks here and then lands on the + // singleton re-check above once the window exists. let url: tauri::Url = match kimi_web_url().parse() { Ok(u) => u, Err(_) => return Err("invalid kimi web url".to_string()), }; - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let builder_app = app.clone(); - let _ = app.run_on_main_thread(move || { - match tauri::WebviewWindowBuilder::new( - &builder_app, - KIMI_WEB_WINDOW_LABEL, - tauri::WebviewUrl::External(url), - ) - .title("Kimi Code WebUI") - .inner_size(1100.0, 750.0) - .resizable(true) - .center() - .build() - { - Ok(window) => { - // Track the window for the singleton check. - if let Ok(mut guard) = builder_app.state::().window.lock() { - *guard = Some(window.clone()); - } - // Clean up when the window closes: forget it and stop the - // `kimi web` server we spawned (reused servers are untouched). - let w = window.clone(); - window.on_window_event(move |event| match event { - tauri::WindowEvent::Destroyed => { - let app = w.app_handle().clone(); - let state = app.state::(); - if let Ok(mut guard) = state.window.lock() { - *guard = None; - } - kill_spawned_kimi_web(&state); - } - _ => {} - }); - } - Err(_) => { - // Window creation failed (e.g. rapid double-click racing - // the singleton check): don't leak the server we spawned. - kill_spawned_kimi_web(&builder_app.state::()); + let (tx, rx) = tokio::sync::oneshot::channel::>(); + let builder_app = app.clone(); + app.run_on_main_thread(move || { + let result = build_kimi_web_window(&builder_app, &url); + let _ = tx.send(result); + }) + .map_err(|e| format!("failed to queue window creation: {e}"))?; + match rx.await { + Ok(result) => { + if let Err(e) = result { + // Window creation failed (e.g. label already taken): only stop + // the server when this invocation spawned it. + if spawned_by_me { + kill_spawned_kimi_web(&state); } + return Err(e); } - }); + Ok(()) + } + // Sender dropped without a result (app shutting down, build never + // ran): never kill the server — a later invocation may reuse it. + Err(_) => Ok(()), + } +} + +/// Build the embedded WebUI window on the main thread and track it for the +/// singleton check. On success, wires close/destroy events to forget the +/// window and stop the `kimi web` server this app spawned (reused servers are +/// untouched). +fn build_kimi_web_window(app: &tauri::AppHandle, url: &tauri::Url) -> Result<(), String> { + let window = tauri::WebviewWindowBuilder::new( + app, + KIMI_WEB_WINDOW_LABEL, + tauri::WebviewUrl::External(url.clone()), + ) + .title("Kimi Code WebUI") + .inner_size(1100.0, 750.0) + .resizable(true) + .center() + .build() + .map_err(|e| format!("failed to create kimi web window: {e}"))?; + + // Track the window for the singleton check. + if let Ok(mut guard) = app.state::().window.lock() { + *guard = Some(window.clone()); + } + // Clean up when the window closes: forget it and stop the `kimi web` + // server we spawned (reused servers are untouched). + let w = window.clone(); + window.on_window_event(move |event| match event { + tauri::WindowEvent::Destroyed => { + let app = w.app_handle().clone(); + let state = app.state::(); + if let Ok(mut guard) = state.window.lock() { + *guard = None; + } + kill_spawned_kimi_web(&state); + } + _ => {} }); Ok(()) } diff --git a/src/components/SubagentSettingsPage.tsx b/src/components/SubagentSettingsPage.tsx index d13d0b4..8b11a6f 100644 --- a/src/components/SubagentSettingsPage.tsx +++ b/src/components/SubagentSettingsPage.tsx @@ -162,6 +162,8 @@ export function SubagentSettingsPage({ const [poolErrors, setPoolErrors] = useState([]); /** Local draft for the "add pool entry" select (reset after each pick). */ const [addSelection, setAddSelection] = useState(""); + /** WebUI-open button in flight; disables both buttons while non-null. */ + const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null); useEffect(() => { invoke("get_experimental_env_status") @@ -371,29 +373,37 @@ export function SubagentSettingsPage({
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index bba7df1..1861483 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -528,6 +528,7 @@ export const enTranslations: Record = { "Open the new Web UI in an independent in-app window, or in your system browser (built into kimi-code 0.33+). Requires the kimi CLI on PATH.", openWebUIEmbedded: "Open in App", openWebUIBrowser: "Open in Browser", + webuiOpening: "Opening...", // Plugin marketplace pluginMarketplace: "Plugin Marketplace", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 55b0b96..a50dbdc 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -520,6 +520,7 @@ export const zhTranslations = { "以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。", openWebUIEmbedded: "在应用内打开", openWebUIBrowser: "在浏览器打开", + webuiOpening: "打开中...", // Plugin marketplace pluginMarketplace: "插件市场",