feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,70 @@
use axum::{
body::Body,
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use sea_orm::DatabaseConnection;
use serde_json::json;
use boosapi_core::types::GatewayKey;
/// Authenticated key injected into request extensions after auth middleware.
#[derive(Clone, Debug)]
pub struct AuthenticatedKey(pub GatewayKey);
/// Auth middleware: extracts Bearer token, verifies against gateway_keys, updates last_used_at.
pub async fn auth_middleware(
State(pool): State<DatabaseConnection>,
mut request: Request<Body>,
next: Next,
) -> Response {
let auth_header = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok());
let token = match auth_header {
Some(h) if h.starts_with("Bearer ") => &h[7..],
_ => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": {
"message": "Missing or invalid Authorization header. Expected: Bearer <api-key>",
"type": "invalid_request_error",
"code": "invalid_api_key"
}
})),
)
.into_response();
}
};
match boosapi_core::repo::gateway::verify_key(&pool, token).await {
Ok(key) => {
// Update last_used_at in background (non-blocking)
let pool_bg = pool.clone();
let key_id = key.id.clone();
tokio::spawn(async move {
let _ = boosapi_core::repo::gateway::update_last_used(&pool_bg, &key_id).await;
});
request.extensions_mut().insert(AuthenticatedKey(key));
next.run(request).await
}
Err(_) => (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": {
"message": "Invalid or disabled API key",
"type": "invalid_request_error",
"code": "invalid_api_key"
}
})),
)
.into_response(),
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
pub mod auth;
pub mod handlers;
pub mod middleware;
pub mod native;
pub mod realtime;
pub mod routes;
pub mod server;
@@ -0,0 +1,2 @@
// Gateway middleware — auth middleware is in auth.rs
// This module can be extended with rate limiting, logging, etc.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,207 @@
use axum::{
extract::{
ws::{Message, WebSocket},
Query, State, WebSocketUpgrade,
},
response::Response,
};
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use crate::server::GatewayAppState;
// --- Client → Server messages ---
#[derive(Deserialize)]
#[serde(tag = "type")]
enum RealtimeClientMessage {
#[serde(rename = "session.create")]
SessionCreate { model: String },
#[serde(rename = "input_audio_buffer.append")]
AudioAppend { audio: String },
#[serde(rename = "input_audio_buffer.commit")]
AudioCommit,
#[serde(rename = "session.close")]
SessionClose,
}
// --- Server → Client messages ---
#[derive(Serialize)]
#[serde(tag = "type")]
#[allow(dead_code)]
enum RealtimeServerMessage {
#[serde(rename = "session.created")]
SessionCreated { session_id: String },
#[serde(rename = "response.audio.delta")]
AudioDelta { delta: String },
#[serde(rename = "response.text.delta")]
TextDelta { delta: String },
#[serde(rename = "response.done")]
ResponseDone,
#[serde(rename = "error")]
Error { message: String },
}
#[derive(Deserialize)]
pub struct RealtimeQuery {
api_key: Option<String>,
}
/// GET /v1/realtime — WebSocket upgrade with query-param auth
pub async fn realtime_handler(
State(state): State<GatewayAppState>,
Query(params): Query<RealtimeQuery>,
ws: WebSocketUpgrade,
) -> Response {
let api_key = match params.api_key {
Some(k) if !k.is_empty() => k,
_ => {
return Response::builder()
.status(401)
.body(axum::body::Body::from(
r#"{"error":{"message":"Missing api_key query parameter","type":"invalid_request_error","code":"invalid_api_key"}}"#,
))
.unwrap();
}
};
// Verify key before upgrading
match boosapi_core::repo::gateway::verify_key(&state.db, &api_key).await {
Ok(key) => {
// Update last_used_at in background
let pool_bg = state.db.clone();
let key_id = key.id.clone();
tokio::spawn(async move {
let _ = boosapi_core::repo::gateway::update_last_used(&pool_bg, &key_id).await;
});
ws.on_upgrade(move |socket| handle_realtime_session(socket, state.db))
}
Err(_) => Response::builder()
.status(401)
.body(axum::body::Body::from(
r#"{"error":{"message":"Invalid or disabled API key","type":"invalid_request_error","code":"invalid_api_key"}}"#,
))
.unwrap(),
}
}
async fn handle_realtime_session(mut socket: WebSocket, _db: DatabaseConnection) {
let session_id = uuid::Uuid::new_v4().to_string();
let mut audio_buffer: Vec<String> = Vec::new();
let mut _model: Option<String> = None;
let mut session_created = false;
while let Some(msg_result) = socket.recv().await {
let msg = match msg_result {
Ok(m) => m,
Err(e) => {
tracing::debug!("WebSocket recv error: {}", e);
break;
}
};
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
Message::Ping(data) => {
if socket.send(Message::Pong(data)).await.is_err() {
break;
}
continue;
}
_ => continue,
};
let client_msg: RealtimeClientMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
let _ = send_msg(
&mut socket,
&RealtimeServerMessage::Error {
message: format!("Invalid message: {}", e),
},
)
.await;
continue;
}
};
match client_msg {
RealtimeClientMessage::SessionCreate { model } => {
_model = Some(model);
session_created = true;
if send_msg(
&mut socket,
&RealtimeServerMessage::SessionCreated {
session_id: session_id.clone(),
},
)
.await
.is_err()
{
break;
}
}
RealtimeClientMessage::AudioAppend { audio } => {
if !session_created {
let _ = send_msg(
&mut socket,
&RealtimeServerMessage::Error {
message: "Session not created. Send session.create first.".into(),
},
)
.await;
continue;
}
audio_buffer.push(audio);
}
RealtimeClientMessage::AudioCommit => {
if !session_created {
let _ = send_msg(
&mut socket,
&RealtimeServerMessage::Error {
message: "Session not created. Send session.create first.".into(),
},
)
.await;
continue;
}
// Stub: echo back a text response instead of forwarding to a provider
audio_buffer.clear();
let send_ok = send_msg(
&mut socket,
&RealtimeServerMessage::TextDelta {
delta: "Realtime voice is not yet connected to a provider".into(),
},
)
.await
.is_ok()
&& send_msg(&mut socket, &RealtimeServerMessage::ResponseDone)
.await
.is_ok();
if !send_ok {
break;
}
}
RealtimeClientMessage::SessionClose => {
let _ = socket.send(Message::Close(None)).await;
break;
}
}
}
tracing::debug!("Realtime session {} closed", session_id);
}
async fn send_msg(socket: &mut WebSocket, msg: &RealtimeServerMessage) -> Result<(), axum::Error> {
let json = serde_json::to_string(msg).unwrap();
socket.send(Message::Text(json.into())).await
}
@@ -0,0 +1,111 @@
use axum::{
middleware,
routing::{get, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use crate::auth::auth_middleware;
use crate::handlers::{chat_completions, health_check, list_models};
use crate::native::{
anthropic_count_tokens, anthropic_messages, gemini_list_models, gemini_model_operation,
openai_responses,
};
use crate::realtime::realtime_handler;
use crate::server::GatewayAppState;
pub fn create_router(state: GatewayAppState) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// Protected routes (require auth)
let protected = Router::new()
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/responses", post(openai_responses))
.route("/v1/messages", post(anthropic_messages))
.route("/v1/messages/count_tokens", post(anthropic_count_tokens))
.route("/v1/models", get(list_models))
.route("/v1beta/models", get(gemini_list_models))
.route(
"/v1beta/models/{model_action}",
post(gemini_model_operation),
)
.layer(middleware::from_fn_with_state(
state.db.clone(),
auth_middleware,
));
// Public routes (auth handled internally for realtime)
let public = Router::new()
.route("/health", get(health_check))
.route("/v1/realtime", get(realtime_handler));
Router::new()
.merge(protected)
.merge(public)
.layer(cors)
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
use boosapi_core::db::create_test_pool;
use axum::{
body::Body,
http::{Method, Request, StatusCode},
};
use tower::ServiceExt;
fn test_state(db: sea_orm::DatabaseConnection) -> GatewayAppState {
GatewayAppState {
db,
master_key: [7u8; 32],
}
}
async fn assert_protected_route_exists(method: Method, uri: &str) {
let handle = create_test_pool().await.unwrap();
let app = create_router(test_state(handle.conn));
let response = app
.oneshot(
Request::builder()
.method(method)
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"expected protected route {} {} to reject missing auth instead of 404/405",
uri,
response.status()
);
}
#[tokio::test]
async fn native_protocol_routes_require_auth() {
assert_protected_route_exists(Method::POST, "/v1/responses").await;
assert_protected_route_exists(Method::POST, "/v1/messages").await;
assert_protected_route_exists(Method::POST, "/v1/messages/count_tokens").await;
assert_protected_route_exists(Method::GET, "/v1beta/models").await;
assert_protected_route_exists(
Method::POST,
"/v1beta/models/gemini-2.5-pro:generateContent",
)
.await;
assert_protected_route_exists(
Method::POST,
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
)
.await;
assert_protected_route_exists(Method::POST, "/v1beta/models/gemini-2.5-pro:countTokens")
.await;
}
}
@@ -0,0 +1,319 @@
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use axum::{
body::Body,
extract::State as AxumState,
http::{header, Request, StatusCode},
response::{IntoResponse, Response},
Router,
};
use axum_server::{tls_rustls::RustlsConfig, Handle};
use sea_orm::DatabaseConnection;
use tokio::task::JoinHandle;
use boosapi_core::error::{BoosApiError, Result};
/// Shared state for Axum handlers (separate from Tauri AppState).
#[derive(Clone)]
pub struct GatewayAppState {
pub db: DatabaseConnection,
pub master_key: [u8; 32],
}
/// TLS certificate material.
#[derive(Debug, Clone)]
pub struct GatewayTlsConfig {
pub cert_path: String,
pub key_path: String,
}
/// SSL listener configuration: port number plus TLS certificate material.
#[derive(Debug, Clone)]
pub struct GatewaySslConfig {
pub ssl_port: u16,
pub tls: GatewayTlsConfig,
}
/// Full configuration passed to [`GatewayServer::start`].
#[derive(Debug, Clone)]
pub struct GatewayStartConfig {
pub listen_address: String,
pub http_port: u16,
/// `None` means HTTP-only mode.
pub ssl: Option<GatewaySslConfig>,
/// When `true` and `ssl` is `Some`, the HTTP listener returns 302 redirects
/// to the HTTPS URL instead of serving the gateway directly.
pub force_ssl: bool,
}
// ─── SSL redirect handler ─────────────────────────────────────────────────
#[derive(Clone)]
struct RedirectState {
https_port: u16,
}
async fn ssl_redirect_handler(
AxumState(state): AxumState<RedirectState>,
req: Request<Body>,
) -> Response {
let host_header = req
.headers()
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost");
// Strip any existing port from the Host header, handling bracketed IPv6.
let bare_host = if host_header.starts_with('[') {
// Bracketed IPv6: "[::1]:port" → "[::1]", or "[::1]" → "[::1]".
match host_header.find("]:") {
Some(pos) => &host_header[..pos + 1],
None => host_header,
}
} else {
match host_header.rfind(':') {
Some(pos) => &host_header[..pos],
None => host_header,
}
};
let path_and_query = req
.uri()
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
let location = format!(
"https://{}:{}{}",
bare_host, state.https_port, path_and_query
);
// 307 preserves the request method (POST stays POST), unlike 302.
(
StatusCode::TEMPORARY_REDIRECT,
[(header::LOCATION, location)],
)
.into_response()
}
fn create_redirect_router(https_port: u16) -> Router {
Router::new()
.fallback(ssl_redirect_handler)
.with_state(RedirectState { https_port })
}
// ─── GatewayServer ────────────────────────────────────────────────────────
pub struct GatewayServer {
http_handle: Handle,
http_task: Option<JoinHandle<()>>,
http_addr: SocketAddr,
https_handle: Option<Handle>,
https_task: Option<JoinHandle<()>>,
https_addr: Option<SocketAddr>,
force_ssl: bool,
running: Arc<AtomicBool>,
started_at: i64,
}
impl GatewayServer {
pub async fn start(
pool: DatabaseConnection,
master_key: [u8; 32],
config: GatewayStartConfig,
) -> Result<Self> {
let app_state = GatewayAppState {
db: pool,
master_key,
};
// ── Bind HTTP listener ──────────────────────────────────────────
let http_bind: SocketAddr = format!("{}:{}", config.listen_address, config.http_port)
.parse()
.map_err(|e| BoosApiError::Gateway(format!("Invalid HTTP bind address: {}", e)))?;
let http_listener = std::net::TcpListener::bind(http_bind)
.map_err(|e| BoosApiError::Gateway(format!("Failed to bind HTTP port: {}", e)))?;
http_listener
.set_nonblocking(true)
.map_err(|e| BoosApiError::Gateway(format!("Failed to set nonblocking: {}", e)))?;
let http_actual_addr = http_listener
.local_addr()
.map_err(|e| BoosApiError::Gateway(format!("Failed to get HTTP local addr: {}", e)))?;
// ── Optionally bind HTTPS listener and load TLS config ──────────
struct HttpsBinding {
listener: std::net::TcpListener,
rustls: RustlsConfig,
addr: SocketAddr,
}
let https_binding: Option<HttpsBinding> = match &config.ssl {
Some(ssl_cfg) => {
let https_bind: SocketAddr =
format!("{}:{}", config.listen_address, ssl_cfg.ssl_port)
.parse()
.map_err(|e| {
BoosApiError::Gateway(format!("Invalid HTTPS bind address: {}", e))
})?;
let listener = std::net::TcpListener::bind(https_bind).map_err(|e| {
BoosApiError::Gateway(format!("Failed to bind HTTPS port: {}", e))
})?;
listener.set_nonblocking(true).map_err(|e| {
BoosApiError::Gateway(format!("Failed to set HTTPS nonblocking: {}", e))
})?;
let addr = listener.local_addr().map_err(|e| {
BoosApiError::Gateway(format!("Failed to get HTTPS local addr: {}", e))
})?;
let rustls =
RustlsConfig::from_pem_file(&ssl_cfg.tls.cert_path, &ssl_cfg.tls.key_path)
.await
.map_err(|e| {
BoosApiError::Gateway(format!("Failed to load TLS certificate: {}", e))
})?;
Some(HttpsBinding {
listener,
rustls,
addr,
})
}
None => None,
};
let https_actual_addr = https_binding.as_ref().map(|b| b.addr);
// ── Build router(s) ─────────────────────────────────────────────
// HTTP router: redirect (force_ssl) or full gateway.
// HTTPS router: always the full gateway when SSL is configured.
let http_router: Router = if config.force_ssl && https_actual_addr.is_some() {
create_redirect_router(https_actual_addr.unwrap().port())
} else {
crate::routes::create_router(app_state.clone())
};
let https_router: Option<Router> = if https_binding.is_some() {
Some(crate::routes::create_router(app_state))
} else {
None
};
// ── Spawn HTTP task ─────────────────────────────────────────────
// Pre-create the HTTPS Handle (when HTTPS will be active) before
// spawning the HTTP task so that each task holds a clone of its
// sibling's handle for mutual-shutdown: if one listener exits
// unexpectedly, it triggers a graceful shutdown of the other so
// the gateway never ends up half-dead.
let running = Arc::new(AtomicBool::new(true));
let http_handle = Handle::new();
let https_handle: Option<Handle> = if https_binding.is_some() && https_router.is_some() {
Some(Handle::new())
} else {
None
};
let http_task = {
let server_handle = http_handle.clone();
let running_flag = running.clone();
let router = http_router;
let addr = http_actual_addr;
let peer_handle = https_handle.clone();
tokio::spawn(async move {
tracing::info!("Gateway HTTP listener on http://{}", addr);
let result = axum_server::from_tcp(http_listener)
.handle(server_handle)
.serve(router.into_make_service())
.await;
if let Err(e) = result {
tracing::error!("Gateway HTTP server error: {}", e);
}
// Shut down the sibling HTTPS listener if still running.
if let Some(h) = peer_handle {
h.graceful_shutdown(Some(Duration::from_secs(5)));
}
running_flag.store(false, Ordering::SeqCst);
tracing::info!("Gateway HTTP server stopped");
})
};
// ── Spawn HTTPS task (when SSL is configured) ───────────────────
let https_task = match (https_binding, https_router) {
(Some(binding), Some(router)) => {
// Reuse the pre-created handle so the HTTP task already
// holds a clone for mutual-shutdown.
let server_handle = https_handle
.as_ref()
.expect("handle pre-created above")
.clone();
let addr = binding.addr;
let running_flag = running.clone();
let peer_handle = http_handle.clone();
let task = tokio::spawn(async move {
tracing::info!("Gateway HTTPS listener on https://{}", addr);
let result = axum_server::from_tcp_rustls(binding.listener, binding.rustls)
.handle(server_handle)
.serve(router.into_make_service())
.await;
if let Err(e) = result {
tracing::error!("Gateway HTTPS server error: {}", e);
}
// Shut down the sibling HTTP listener if still running.
peer_handle.graceful_shutdown(Some(Duration::from_secs(5)));
running_flag.store(false, Ordering::SeqCst);
tracing::info!("Gateway HTTPS server stopped");
});
Some(task)
}
_ => None,
};
Ok(Self {
http_handle,
http_task: Some(http_task),
http_addr: http_actual_addr,
https_handle,
https_task,
https_addr: https_actual_addr,
force_ssl: config.force_ssl,
running,
started_at: boosapi_core::utils::now_ts(),
})
}
pub async fn stop(&mut self) -> Result<()> {
// Signal graceful shutdown on both listeners.
self.http_handle
.graceful_shutdown(Some(Duration::from_secs(5)));
if let Some(ref h) = self.https_handle {
h.graceful_shutdown(Some(Duration::from_secs(5)));
}
// Await both tasks.
if let Some(task) = self.http_task.take() {
let _ = task.await;
}
if let Some(task) = self.https_task.take() {
let _ = task.await;
}
self.running.store(false, Ordering::SeqCst);
Ok(())
}
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
/// Bound address of the HTTP listener.
pub fn http_addr(&self) -> SocketAddr {
self.http_addr
}
/// Bound address of the HTTPS listener, or `None` if SSL is not active.
pub fn https_addr(&self) -> Option<SocketAddr> {
self.https_addr
}
pub fn force_ssl(&self) -> bool {
self.force_ssl
}
pub fn started_at(&self) -> i64 {
self.started_at
}
}