feat(webui): v0.7.3 — Kimi Code WebUI 应用内嵌窗口(单例/独立顶层窗口/服务器自动清理)+ 发布说明与网站同步
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s

This commit is contained in:
KimiSwitch Dev committed 2026-08-07 15:46:45 +08:00
1 parent fc09a29b9f
commit a0e36e928d
16 files changed
+145759 -143691

No files matched your search

+169 -7
View File
@@ -7,6 +7,7 @@ use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::Manager;
use tauri_plugin_opener::OpenerExt;
fn fmt_anyhow(err: anyhow::Error) -> String {
@@ -950,14 +951,19 @@ pub fn open_installer(app: tauri::AppHandle, path: String) -> Result<(), String>
.map_err(|e| e.to_string())
}
/// Launch `kimi web` — starts the local Kimi Code Web UI server and opens it
/// in the default browser (the code-app web bundle built into kimi-code 0.33+).
/// The kimi CLI must be on PATH; the spawned process is detached so the server
/// keeps running while Kimi Switch stays open.
/// Open the Kimi Code WebUI in the system default browser. Reuses an
/// already-running local `kimi web` server (embedded window, terminal, …);
/// otherwise spawns `kimi web`, which starts the server and opens the browser.
#[tauri::command]
pub fn open_kimi_web() -> Result<(), String> {
use std::process::Command;
let mut cmd = Command::new("kimi");
pub async fn open_kimi_web(app: tauri::AppHandle) -> Result<(), String> {
if kimi_web_alive(Duration::from_millis(800)).await {
// Server already running — just open the browser at the local origin.
return app
.opener()
.open_url(kimi_web_url(), None::<&str>)
.map_err(|e| e.to_string());
}
let mut cmd = std::process::Command::new("kimi");
cmd.arg("web");
#[cfg(windows)]
{
@@ -975,6 +981,162 @@ pub fn open_kimi_web() -> Result<(), String> {
}
})
}
// ---------------------------------------------------------------------------
// Kimi Code WebUI — embedded in-app window
//
// `open_kimi_web_embedded` opens the Kimi Code Web UI inside a dedicated,
// independent top-level window (no parent/child relationship with the main
// window). At most one such window exists: re-invoking focuses it. The local
// `kimi web` server is reused when already running; otherwise one is spawned
// (`--no-open`) and terminated when the window closes or the app exits.
// ---------------------------------------------------------------------------
/// Shared state for the embedded WebUI window and the server we may have spawned.
#[derive(Default)]
pub struct KimiWebState {
/// The embedded WebUI window (at most one).
window: Mutex<Option<tauri::WebviewWindow>>,
/// The `kimi web` child spawned by this app (None when reusing a server).
child: Mutex<Option<std::process::Child>>,
}
const KIMI_WEB_PORT: u16 = 58627;
const KIMI_WEB_ORIGIN: &str = "http://127.0.0.1:58627";
const KIMI_WEB_WINDOW_LABEL: &str = "kimi-web-ui";
/// Build the Web UI URL; the home-wide bearer token rides in the `#token=`
/// fragment (same as the `kimi web` ready banner, see kimi-code
/// `cli/sub/web/access-urls.ts`).
fn kimi_web_url() -> String {
let token = dirs::home_dir()
.map(|h| h.join(".kimi-code").join("server.token"))
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
match token {
Some(t) => format!("{KIMI_WEB_ORIGIN}/#token={t}"),
None => format!("{KIMI_WEB_ORIGIN}/"),
}
}
/// Best-effort probe of the local Kimi web server root.
async fn kimi_web_alive(timeout: Duration) -> bool {
let Ok(client) = reqwest::Client::builder().timeout(timeout).build() else {
return false;
};
client
.get(KIMI_WEB_ORIGIN)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
/// Kill the `kimi web` server spawned by this app (no-op when reusing one).
pub fn kill_spawned_kimi_web(state: &tauri::State<'_, KimiWebState>) {
if let Ok(mut guard) = state.child.lock() {
if let Some(mut child) = guard.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// Open the Kimi Code WebUI in an embedded, independent top-level window.
#[tauri::command]
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.
if let Ok(guard) = state.window.lock() {
if let Some(w) = guard.as_ref() {
if w.is_visible().unwrap_or(false) {
let _ = w.unminimize();
let _ = w.set_focus();
return Ok(());
}
}
}
// Reuse an already-running server, otherwise spawn `kimi web --no-open`.
if !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)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW — no console flash
}
let child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"kimi executable not found on PATH — install Kimi Code CLI first".to_string()
} else {
format!("failed to start `kimi web`: {e}")
}
})?;
if let Ok(mut guard) = state.child.lock() {
*guard = Some(child);
}
}
// 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);
return Err(format!("kimi web did not come up within 8s ({KIMI_WEB_ORIGIN})"));
}
// Create the window on the main thread (required on macOS).
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::<KimiWebState>().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::<KimiWebState>();
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::<KimiWebState>());
}
}
});
});
Ok(())
}
/// Preference for Linux is AppImage (portable, no install). If the preferred
/// extension is not present, returns None so the UI can fall back to
/// "open release page" instead of guessing a less-preferred format.
+12 -2
View File
@@ -110,6 +110,7 @@ pub fn run() {
commands::open_installer,
commands::open_external_url,
commands::open_kimi_web,
commands::open_kimi_web_embedded,
commands::kimi_oauth_start,
commands::kimi_oauth_poll,
commands::get_experimental_env_status,
@@ -123,6 +124,15 @@ pub fn run() {
dashboard::delete_workspace,
dashboard::get_session_preview,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.manage(commands::KimiWebState::default())
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app: &tauri::AppHandle, event| {
// Ensure a `kimi web` server spawned for the embedded WebUI window
// is stopped when the app exits (windows already destroyed first).
if let tauri::RunEvent::Exit = event {
let state = app.state::<commands::KimiWebState>();
commands::kill_spawned_kimi_web(&state);
}
});
}