cline--cline
524 行
16 KiB
Rust
524 行
16 KiB
Rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fs;
|
|
use std::io::{BufRead, BufReader};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
use tauri::{Manager, RunEvent, State};
|
|
|
|
#[derive(Clone)]
|
|
struct AppContext {
|
|
launch_cwd: String,
|
|
workspace_root: String,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct DesktopBackendState {
|
|
ws_endpoint: Mutex<Option<String>>,
|
|
process: Mutex<Option<Child>>,
|
|
shutting_down: Mutex<bool>,
|
|
}
|
|
|
|
impl DesktopBackendState {
|
|
fn is_shutting_down(&self) -> bool {
|
|
self.shutting_down
|
|
.lock()
|
|
.map(|guard| *guard)
|
|
.unwrap_or(true)
|
|
}
|
|
|
|
fn stop(&self) {
|
|
if let Ok(mut guard) = self.shutting_down.lock() {
|
|
*guard = true;
|
|
}
|
|
|
|
if let Ok(endpoint_guard) = self.ws_endpoint.lock() {
|
|
if let Some(endpoint) = endpoint_guard.as_ref() {
|
|
request_desktop_backend_shutdown(endpoint);
|
|
}
|
|
}
|
|
|
|
if let Ok(mut process_guard) = self.process.lock() {
|
|
if let Some(child) = process_guard.as_mut() {
|
|
for _ in 0..30 {
|
|
match child.try_wait() {
|
|
Ok(Some(_)) => break,
|
|
Ok(None) => thread::sleep(Duration::from_millis(100)),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
match child.try_wait() {
|
|
Ok(Some(_)) => {}
|
|
Ok(None) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
Err(_) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
}
|
|
}
|
|
*process_guard = None;
|
|
}
|
|
|
|
if let Ok(mut endpoint_guard) = self.ws_endpoint.lock() {
|
|
*endpoint_guard = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for DesktopBackendState {
|
|
fn drop(&mut self) {
|
|
self.stop();
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct DesktopBackendReadyLine {
|
|
#[serde(rename = "type")]
|
|
line_type: String,
|
|
endpoint: Option<String>,
|
|
ws_endpoint: Option<String>,
|
|
pid: Option<u64>,
|
|
mode: Option<String>,
|
|
}
|
|
|
|
fn resolve_workspace_root(launch_cwd: &str) -> String {
|
|
let output = Command::new("git")
|
|
.arg("-C")
|
|
.arg(launch_cwd)
|
|
.arg("rev-parse")
|
|
.arg("--show-toplevel")
|
|
.output();
|
|
|
|
match output {
|
|
Ok(result) if result.status.success() => {
|
|
let value = String::from_utf8_lossy(&result.stdout).trim().to_string();
|
|
if value.is_empty() {
|
|
launch_cwd.to_string()
|
|
} else {
|
|
value
|
|
}
|
|
}
|
|
_ => launch_cwd.to_string(),
|
|
}
|
|
}
|
|
|
|
fn request_desktop_backend_shutdown(endpoint: &str) {
|
|
let trimmed = endpoint.trim();
|
|
if trimmed.is_empty() {
|
|
return;
|
|
}
|
|
let base = trimmed.strip_suffix('/').unwrap_or(trimmed);
|
|
let url = format!("{base}/shutdown");
|
|
let timeout_seconds = "2";
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let _ = Command::new("powershell")
|
|
.args([
|
|
"-NoProfile",
|
|
"-Command",
|
|
&format!(
|
|
"try {{ Invoke-WebRequest -UseBasicParsing -Method Post -Uri '{}' -TimeoutSec {} | Out-Null }} catch {{ }}",
|
|
url.replace('\'', "''"),
|
|
timeout_seconds
|
|
),
|
|
])
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status();
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = Command::new("curl")
|
|
.args([
|
|
"-fsS",
|
|
"--connect-timeout",
|
|
timeout_seconds,
|
|
"--max-time",
|
|
timeout_seconds,
|
|
"-X",
|
|
"POST",
|
|
&url,
|
|
])
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status();
|
|
}
|
|
}
|
|
|
|
fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf> {
|
|
let launch_cwd = PathBuf::from(&context.launch_cwd);
|
|
let candidates = [
|
|
PathBuf::from(&context.workspace_root)
|
|
.join("apps")
|
|
.join("examples")
|
|
.join("desktop-app")
|
|
.join("sidecar")
|
|
.join("index.ts"),
|
|
launch_cwd.join("sidecar").join("index.ts"),
|
|
launch_cwd
|
|
.parent()
|
|
.map(|parent| parent.join("sidecar").join("index.ts"))
|
|
.unwrap_or_else(|| PathBuf::from("")),
|
|
launch_cwd
|
|
.join("apps")
|
|
.join("examples")
|
|
.join("desktop-app")
|
|
.join("sidecar")
|
|
.join("index.ts"),
|
|
];
|
|
candidates.into_iter().find(|path| path.exists())
|
|
}
|
|
|
|
fn desktop_backend_binary_names() -> Vec<String> {
|
|
let extension = if cfg!(windows) { ".exe" } else { "" };
|
|
let bundled_name = format!("code-sidecar{extension}");
|
|
let target_triple = option_env!("TAURI_ENV_TARGET_TRIPLE").unwrap_or("").trim();
|
|
if target_triple.is_empty() {
|
|
return vec![bundled_name];
|
|
}
|
|
|
|
vec![
|
|
bundled_name,
|
|
format!("code-sidecar-{target_triple}{extension}"),
|
|
]
|
|
}
|
|
|
|
fn resolve_desktop_backend_binary_path(context: &AppContext) -> Option<PathBuf> {
|
|
if cfg!(debug_assertions) {
|
|
return None;
|
|
}
|
|
let explicit = std::env::var("CLINE_CODE_SIDECAR_BIN")
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.map(PathBuf::from);
|
|
let current_exe = std::env::current_exe().ok();
|
|
let mut candidates = Vec::new();
|
|
if let Some(path) = explicit {
|
|
candidates.push(path);
|
|
}
|
|
|
|
for binary_name in desktop_backend_binary_names() {
|
|
candidates.push(
|
|
PathBuf::from(&context.workspace_root)
|
|
.join("apps")
|
|
.join("examples")
|
|
.join("desktop-app")
|
|
.join("src-tauri")
|
|
.join("bin")
|
|
.join(&binary_name),
|
|
);
|
|
if let Some(path) = current_exe
|
|
.as_ref()
|
|
.and_then(|path| path.parent().map(|parent| parent.join(&binary_name)))
|
|
{
|
|
candidates.push(path);
|
|
}
|
|
if let Some(path) = current_exe.as_ref().and_then(|path| {
|
|
path.parent()
|
|
.and_then(|parent| parent.parent())
|
|
.map(|parent| parent.join("Resources").join(&binary_name))
|
|
}) {
|
|
candidates.push(path);
|
|
}
|
|
}
|
|
|
|
candidates.into_iter().find(|path| path.exists())
|
|
}
|
|
|
|
fn ensure_desktop_backend_started(
|
|
state: &Arc<DesktopBackendState>,
|
|
context: &AppContext,
|
|
) -> Result<(), String> {
|
|
if state.is_shutting_down() {
|
|
return Ok(());
|
|
}
|
|
|
|
{
|
|
let mut process_guard = state
|
|
.process
|
|
.lock()
|
|
.map_err(|_| "failed to lock desktop backend process state")?;
|
|
if let Some(existing) = process_guard.as_mut() {
|
|
match existing.try_wait() {
|
|
Ok(None) => {
|
|
if state
|
|
.ws_endpoint
|
|
.lock()
|
|
.ok()
|
|
.and_then(|value| value.as_ref().cloned())
|
|
.map(|value| !value.trim().is_empty())
|
|
.unwrap_or(false)
|
|
{
|
|
return Ok(());
|
|
}
|
|
}
|
|
Ok(Some(_)) | Err(_) => {
|
|
*process_guard = None;
|
|
if let Ok(mut endpoint_guard) = state.ws_endpoint.lock() {
|
|
*endpoint_guard = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut command = if let Some(binary_path) = resolve_desktop_backend_binary_path(context) {
|
|
let mut command = Command::new(binary_path);
|
|
command.current_dir(&context.workspace_root);
|
|
command
|
|
} else if let Some(script_path) = resolve_desktop_backend_script_path(context) {
|
|
let mut command = Command::new("bun");
|
|
command
|
|
.arg("run")
|
|
.arg(script_path.to_string_lossy().to_string())
|
|
.current_dir(&context.workspace_root);
|
|
command
|
|
} else {
|
|
return Err(format!(
|
|
"desktop backend sidecar not found. checked binary/script under workspace_root={} and launch_cwd={}",
|
|
context.workspace_root, context.launch_cwd
|
|
));
|
|
};
|
|
|
|
let mut child = command
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))?;
|
|
|
|
let stdout = child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| "failed to capture desktop backend stdout".to_string())?;
|
|
let stderr = child
|
|
.stderr
|
|
.take()
|
|
.ok_or_else(|| "failed to capture desktop backend stderr".to_string())?;
|
|
|
|
let state_for_stdout = state.clone();
|
|
thread::spawn(move || {
|
|
let mut reader = BufReader::new(stdout);
|
|
let mut line = String::new();
|
|
loop {
|
|
line.clear();
|
|
let Ok(bytes) = reader.read_line(&mut line) else {
|
|
break;
|
|
};
|
|
if bytes == 0 {
|
|
break;
|
|
}
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
if let Ok(parsed) = serde_json::from_str::<DesktopBackendReadyLine>(trimmed) {
|
|
if parsed.line_type == "ready" {
|
|
if let Some(endpoint) = parsed.ws_endpoint.or(parsed.endpoint) {
|
|
if let Ok(mut endpoint_guard) = state_for_stdout.ws_endpoint.lock() {
|
|
*endpoint_guard = Some(endpoint);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
eprintln!("[desktop-backend] {trimmed}");
|
|
}
|
|
if let Ok(mut endpoint_guard) = state_for_stdout.ws_endpoint.lock() {
|
|
*endpoint_guard = None;
|
|
}
|
|
});
|
|
|
|
thread::spawn(move || {
|
|
let reader = BufReader::new(stderr);
|
|
for line in reader.lines() {
|
|
match line {
|
|
Ok(text) if !text.trim().is_empty() => {
|
|
eprintln!("[desktop-backend:err] {}", text.trim());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
|
|
let mut process_guard = state
|
|
.process
|
|
.lock()
|
|
.map_err(|_| "failed to lock desktop backend process state")?;
|
|
*process_guard = Some(child);
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_mcp_settings_path() -> Result<PathBuf, String> {
|
|
if let Ok(value) = std::env::var("CLINE_MCP_SETTINGS_PATH") {
|
|
let trimmed = value.trim();
|
|
if !trimmed.is_empty() {
|
|
return Ok(PathBuf::from(trimmed));
|
|
}
|
|
}
|
|
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
|
|
Ok(PathBuf::from(home)
|
|
.join(".cline")
|
|
.join("data")
|
|
.join("settings")
|
|
.join("cline_mcp_settings.json"))
|
|
}
|
|
|
|
fn open_path_with_default_app(path: &Path) -> Result<(), String> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let status = Command::new("open")
|
|
.arg(path)
|
|
.status()
|
|
.map_err(|e| format!("failed to open path: {e}"))?;
|
|
if status.success() {
|
|
return Ok(());
|
|
}
|
|
return Err(format!("open command exited with status {status}"));
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let path_arg = path.to_string_lossy().to_string();
|
|
let status = Command::new("cmd")
|
|
.args(["/C", "start", "", &path_arg])
|
|
.status()
|
|
.map_err(|e| format!("failed to open path: {e}"))?;
|
|
if status.success() {
|
|
return Ok(());
|
|
}
|
|
return Err(format!("start command exited with status {status}"));
|
|
}
|
|
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
{
|
|
let status = Command::new("xdg-open")
|
|
.arg(path)
|
|
.status()
|
|
.map_err(|e| format!("failed to open path: {e}"))?;
|
|
if status.success() {
|
|
return Ok(());
|
|
}
|
|
return Err(format!("xdg-open command exited with status {status}"));
|
|
}
|
|
|
|
#[allow(unreachable_code)]
|
|
Err("opening files is not supported on this platform".to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_desktop_backend_endpoint(
|
|
backend_state: State<'_, Arc<DesktopBackendState>>,
|
|
context: State<'_, AppContext>,
|
|
) -> Result<String, String> {
|
|
ensure_desktop_backend_started(backend_state.inner(), context.inner())?;
|
|
for _ in 0..50 {
|
|
if let Some(endpoint) = backend_state
|
|
.ws_endpoint
|
|
.lock()
|
|
.ok()
|
|
.and_then(|value| value.as_ref().cloned())
|
|
.filter(|value| !value.trim().is_empty())
|
|
{
|
|
return Ok(endpoint);
|
|
}
|
|
thread::sleep(Duration::from_millis(100));
|
|
}
|
|
Err("desktop backend endpoint not ready".to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn pick_workspace_directory(initial_path: Option<String>) -> Option<String> {
|
|
let mut dialog = rfd::FileDialog::new();
|
|
if let Some(path) = initial_path
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
dialog = dialog.set_directory(path);
|
|
}
|
|
dialog
|
|
.pick_folder()
|
|
.map(|path| path.to_string_lossy().to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn open_mcp_settings_file() -> Result<String, String> {
|
|
let settings_path = resolve_mcp_settings_path()?;
|
|
if !settings_path.exists() {
|
|
if let Some(parent) = settings_path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.map_err(|e| format!("failed creating MCP settings directory: {e}"))?;
|
|
}
|
|
let initial = serde_json::json!({
|
|
"mcpServers": {}
|
|
});
|
|
let mut body = serde_json::to_vec_pretty(&initial)
|
|
.map_err(|e| format!("failed encoding MCP settings: {e}"))?;
|
|
body.push(b'\n');
|
|
fs::write(&settings_path, body)
|
|
.map_err(|e| format!("failed writing MCP settings file: {e}"))?;
|
|
}
|
|
open_path_with_default_app(&settings_path)?;
|
|
Ok(settings_path.to_string_lossy().to_string())
|
|
}
|
|
|
|
fn main() {
|
|
let desktop_backend = Arc::new(DesktopBackendState::default());
|
|
let launch_cwd = std::env::current_dir()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_else(|_| ".".to_string());
|
|
let workspace_root = resolve_workspace_root(&launch_cwd);
|
|
let app_context = AppContext {
|
|
launch_cwd,
|
|
workspace_root,
|
|
};
|
|
|
|
tauri::Builder::default()
|
|
.manage(desktop_backend)
|
|
.manage(app_context)
|
|
.setup(|app| {
|
|
let app_context = app.state::<AppContext>().inner().clone();
|
|
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
|
|
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
|
|
eprintln!("[desktop-backend] startup failed: {error}");
|
|
}
|
|
thread::spawn(move || loop {
|
|
thread::sleep(Duration::from_secs(5));
|
|
if backend_state.is_shutting_down() {
|
|
break;
|
|
}
|
|
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
|
|
eprintln!("[desktop-backend] health check failed: {error}");
|
|
}
|
|
});
|
|
Ok(())
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
get_desktop_backend_endpoint,
|
|
pick_workspace_directory,
|
|
open_mcp_settings_file
|
|
])
|
|
.build(tauri::generate_context!())
|
|
.expect("error while building tauri app")
|
|
.run(|app_handle, event| match event {
|
|
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
|
|
app_handle
|
|
.state::<Arc<DesktopBackendState>>()
|
|
.inner()
|
|
.stop();
|
|
}
|
|
_ => {}
|
|
});
|
|
}
|