refactor: rebuild desktop app with Tauri
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "cursor_byok_desktop"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
cursor-server = { path = "../../../server" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
tokio-util = "0.7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
url = "2"
|
||||
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
|
||||
.expect("failed to build Tauri application")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Cursor BYOK 主窗口的最小权限",
|
||||
"remote": {
|
||||
"urls": [
|
||||
"http://127.0.0.1:*",
|
||||
"http://localhost:*"
|
||||
]
|
||||
},
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"allow-open-terminal-with-command",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"autostart:default",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-open-terminal-with-command"
|
||||
description = "Enables the open_terminal_with_command command without any pre-configured scope."
|
||||
commands.allow = ["open_terminal_with_command"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-open-terminal-with-command"
|
||||
description = "Denies the open_terminal_with_command command without any pre-configured scope."
|
||||
commands.deny = ["open_terminal_with_command"]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.cursorbyok.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/linux-32x32.png",
|
||||
"icons/linux-128x128.png",
|
||||
"icons/linux-256x256.png",
|
||||
"icons/linux-512x512.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc1MEY2RjQzRjA0MjhCMDUKUldRRmkwTHdRMjhQZGJJam9zT1NwcW83L1V4ckJvbkQzQ0FYVFZ6MGRpa1N2NVFkUHk1dW14c1AK",
|
||||
"endpoints": [
|
||||
"https://github.com/leookun/cursor-byok/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"bundle": {
|
||||
"targets": ["app"]
|
||||
}
|
||||
}
|
||||