mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 13:44:21 +08:00
refactor: rebuild desktop app with Tauri
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
use std::{
|
||||
process::Command,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Mutex,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Json},
|
||||
http::StatusCode,
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use tauri::{
|
||||
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
|
||||
WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[cfg(dev)]
|
||||
use cursor_server::config::ConsoleSource;
|
||||
use cursor_server::{App, Config, Result};
|
||||
|
||||
#[cfg(not(dev))]
|
||||
use crate::frontend;
|
||||
use crate::tray;
|
||||
|
||||
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
|
||||
|
||||
struct DesktopRuntime {
|
||||
shutdown: CancellationToken,
|
||||
server: Mutex<Option<JoinHandle<Result<()>>>>,
|
||||
exiting: AtomicBool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_terminal_with_command(command: String) -> tauri::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = command;
|
||||
Command::new("open").args(["-a", "Terminal"]).status()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "cmd", "/K", &command])
|
||||
.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = command;
|
||||
Err(tauri::Error::from(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"terminal guidance is unsupported on this platform",
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OpenExternalUrlRequest {
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn open_external_url(app: &AppHandle, url: &str) -> std::result::Result<(), String> {
|
||||
let parsed = url::Url::parse(url).map_err(|error| format!("invalid URL: {error}"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||
return Err("only absolute HTTP and HTTPS URLs are allowed".into());
|
||||
}
|
||||
|
||||
app.opener()
|
||||
.open_url(parsed.as_str(), None::<&str>)
|
||||
.map_err(|error| format!("failed to open URL: {error}"))
|
||||
}
|
||||
|
||||
async fn open_external_url_handler(
|
||||
Extension(app): Extension<AppHandle>,
|
||||
Json(request): Json<OpenExternalUrlRequest>,
|
||||
) -> std::result::Result<StatusCode, (StatusCode, String)> {
|
||||
open_external_url(&app, &request.url)
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(|error| (StatusCode::BAD_REQUEST, error))
|
||||
}
|
||||
|
||||
fn desktop_api_router(app: AppHandle) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/__byok-api__/api/desktop/open-external-url",
|
||||
post(open_external_url_handler),
|
||||
)
|
||||
.layer(Extension(app))
|
||||
}
|
||||
|
||||
fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::Result<()> {
|
||||
let url = format!("http://{address}/__byok-api__/")
|
||||
.parse()
|
||||
.expect("local frontend URL");
|
||||
let builder = WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, WebviewUrl::External(url))
|
||||
.title("Cursor BYOK")
|
||||
.inner_size(820.0, 558.0)
|
||||
.min_inner_size(820.0, 558.0)
|
||||
.center()
|
||||
.background_color(Color(20, 20, 20, 255))
|
||||
.decorations(cfg!(target_os = "macos"))
|
||||
.shadow(true)
|
||||
.resizable(true);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true);
|
||||
|
||||
builder.build()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "cursor_server=info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let app = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||
tray::show_main_window(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
app.handle().plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))?;
|
||||
let config = Config::desktop()?;
|
||||
#[cfg(dev)]
|
||||
let config = {
|
||||
let mut config = config;
|
||||
config.console = Some(ConsoleSource::Proxy(
|
||||
"http://127.0.0.1:1420"
|
||||
.parse()
|
||||
.expect("Vite development URL"),
|
||||
));
|
||||
config
|
||||
};
|
||||
let server = tauri::async_runtime::block_on(App::new(config))?
|
||||
.merge_router(desktop_api_router(app.handle().clone()));
|
||||
#[cfg(not(dev))]
|
||||
let server = server.merge_router(frontend::router(app.handle().clone()));
|
||||
let listener = tauri::async_runtime::block_on(server.bind())?;
|
||||
let address = listener.local_addr()?;
|
||||
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
|
||||
let shutdown = CancellationToken::new();
|
||||
let server_shutdown = shutdown.clone();
|
||||
let app_handle = app.handle().clone();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
let result = server.serve_on(listener, server_shutdown).await;
|
||||
if let Err(error) = &result {
|
||||
tracing::error!(%error, "desktop server stopped unexpectedly");
|
||||
app_handle.exit(1);
|
||||
}
|
||||
result
|
||||
});
|
||||
app.manage(DesktopRuntime {
|
||||
shutdown,
|
||||
server: Mutex::new(Some(task)),
|
||||
exiting: AtomicBool::new(false),
|
||||
});
|
||||
create_main_window(app.handle(), address)?;
|
||||
tray::create(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to build Cursor BYOK desktop app");
|
||||
|
||||
app.run(|app, event| match event {
|
||||
RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} if label == MAIN_WINDOW_LABEL => {
|
||||
let runtime = app.state::<DesktopRuntime>();
|
||||
if !runtime.exiting.load(Ordering::Acquire) {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
RunEvent::ExitRequested { api, .. } => {
|
||||
let runtime = app.state::<DesktopRuntime>();
|
||||
if !runtime.exiting.swap(true, Ordering::AcqRel) {
|
||||
api.prevent_exit();
|
||||
runtime.shutdown.cancel();
|
||||
let server = runtime.server.lock().expect("server lock poisoned").take();
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(server) = server {
|
||||
match tokio::time::timeout(Duration::from_secs(11), server).await {
|
||||
Ok(Ok(Ok(()))) => {}
|
||||
Ok(Ok(Err(error))) => {
|
||||
tracing::error!(%error, "desktop server shutdown failed")
|
||||
}
|
||||
Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"),
|
||||
Err(_) => tracing::warn!("desktop server shutdown timed out"),
|
||||
}
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Reopen {
|
||||
has_visible_windows: false,
|
||||
..
|
||||
} => tray::show_main_window(app),
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Serves Tauri's embedded frontend assets through the local HTTP server.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{header, HeaderValue, Response, StatusCode, Uri},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub(crate) fn router(app: AppHandle) -> Router {
|
||||
Router::new()
|
||||
.route("/__byok-api__/", get(asset))
|
||||
.route("/__byok-api__/{*path}", get(asset))
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
async fn asset(State(app): State<AppHandle>, uri: Uri) -> Response<Body> {
|
||||
let path = uri
|
||||
.path()
|
||||
.strip_prefix("/__byok-api__/")
|
||||
.filter(|path| !path.is_empty())
|
||||
.unwrap_or("index.html");
|
||||
let Some(asset) = app.asset_resolver().get(path.to_string()) else {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.expect("static not-found response");
|
||||
};
|
||||
let mut response = Response::new(Body::from(asset.bytes));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(&asset.mime_type)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
if let Some(csp) = asset.csp_header.and_then(|value| value.parse().ok()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_SECURITY_POLICY, csp);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod desktop;
|
||||
#[cfg(not(dev))]
|
||||
mod frontend;
|
||||
mod tray;
|
||||
|
||||
pub use desktop::run;
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
cursor_byok_desktop::run();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
App, AppHandle, Manager,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
|
||||
|
||||
use crate::desktop::MAIN_WINDOW_LABEL;
|
||||
|
||||
const OPEN_MENU_ID: &str = "tray-open";
|
||||
const QUIT_MENU_ID: &str = "tray-quit";
|
||||
|
||||
pub fn create(app: &mut App) -> tauri::Result<()> {
|
||||
let open = MenuItem::with_id(app, OPEN_MENU_ID, "打开 Cursor BYOK", true, None::<&str>)?;
|
||||
let separator = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, QUIT_MENU_ID, "退出", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open, &separator, &quit])?;
|
||||
|
||||
TrayIconBuilder::with_id("main")
|
||||
.icon(tauri::include_image!("./icons/32x32.png"))
|
||||
.tooltip("Cursor BYOK")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
OPEN_MENU_ID => show_main_window(app),
|
||||
QUIT_MENU_ID => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = (tray, event);
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user