CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI
- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens, management API /api/* (providers CRUD, apply/restore, settings, backups, logs) - Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming state machine keyed by content block index (text, tool_use, parallel calls) - ~/.claude/settings.json guarded read/write: timestamped backups, managed env keys only, one-click restore - Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout, stdin pipe watchdog prevents orphans, kills the child on exit - React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query - Tests: Go table-driven + httptest integration (all green), vitest + RTL
This commit is contained in:
1 parent
8ee5e98ea3
commit
f8acf31ebc
71 files changed
+15727
-1
No files matched your search
Generated
+4914
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "crossrelay"
|
||||
version = "0.1.0"
|
||||
description = "CrossRelay"
|
||||
authors = ["kate522"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "crossrelay_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-http = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for CrossRelay: local gateway HTTP access.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "http://127.0.0.1:*" },
|
||||
{ "url": "http://localhost:*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 765 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 312 B |
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
@@ -0,0 +1,205 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
/// Shared gateway process state.
|
||||
struct GatewayInner {
|
||||
child: Mutex<Option<Child>>,
|
||||
stdin: Mutex<Option<ChildStdin>>,
|
||||
port: Mutex<Option<u16>>,
|
||||
}
|
||||
|
||||
impl GatewayInner {
|
||||
fn kill(&self) {
|
||||
// Closing the stdin pipe first lets the gateway shut down
|
||||
// gracefully; kill() then guarantees it is gone.
|
||||
drop(self.stdin.lock().unwrap().take());
|
||||
if let Some(mut child) = self.child.lock().unwrap().take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayState(Arc<GatewayInner>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GatewayStatus {
|
||||
running: bool,
|
||||
port: Option<u16>,
|
||||
pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// Waits (up to 20s) until the gateway reports its port, then returns it.
|
||||
#[tauri::command]
|
||||
fn gateway_port(state: tauri::State<'_, GatewayState>) -> Result<Option<u16>, String> {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
if let Some(port) = *state.0.port.lock().unwrap() {
|
||||
return Ok(Some(port));
|
||||
}
|
||||
if state.0.child.lock().unwrap().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn gateway_status(state: tauri::State<'_, GatewayState>) -> GatewayStatus {
|
||||
let mut guard = state.0.child.lock().unwrap();
|
||||
let (running, pid) = match guard.as_mut() {
|
||||
Some(child) => match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
*guard = None;
|
||||
(false, None)
|
||||
}
|
||||
Ok(None) => (true, Some(child.id())),
|
||||
Err(_) => (false, None),
|
||||
},
|
||||
None => (false, None),
|
||||
};
|
||||
let port = *state.0.port.lock().unwrap();
|
||||
GatewayStatus { running, port, pid }
|
||||
}
|
||||
|
||||
/// Locates the gateway binary: CROSSRELAY_GATEWAY_BIN wins, then a set of
|
||||
/// paths relative to the app executable (dev builds, packaged app).
|
||||
fn gateway_binary() -> Option<PathBuf> {
|
||||
if let Ok(path) = std::env::var("CROSSRELAY_GATEWAY_BIN") {
|
||||
if !path.trim().is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let dir = exe.parent()?;
|
||||
let names: &[&str] = if cfg!(windows) {
|
||||
&["gateway.exe", "gateway"]
|
||||
} else {
|
||||
&["gateway"]
|
||||
};
|
||||
let candidates = [
|
||||
dir.join("gateway"),
|
||||
dir.join("bin").join("gateway"),
|
||||
// Dev layout: src-tauri/target/{debug,release} -> repo/bin
|
||||
dir.join("..").join("..").join("..").join("bin").join("gateway"),
|
||||
dir.join("..").join("bin").join("gateway"),
|
||||
];
|
||||
for candidate in candidates {
|
||||
for name in names {
|
||||
let path = if candidate.extension().is_some() {
|
||||
candidate.clone()
|
||||
} else {
|
||||
candidate.with_file_name(name)
|
||||
};
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn spawn_gateway(handle: &tauri::AppHandle, inner: &Arc<GatewayInner>) {
|
||||
let Some(bin) = gateway_binary() else {
|
||||
eprintln!("crossrelay: gateway binary not found; set CROSSRELAY_GATEWAY_BIN");
|
||||
return;
|
||||
};
|
||||
eprintln!("crossrelay: starting gateway {}", bin.display());
|
||||
let mut command = Command::new(&bin);
|
||||
command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.stdin(Stdio::piped())
|
||||
.env("CROSSRELAY_WATCH_PARENT", "1");
|
||||
match command.spawn() {
|
||||
Ok(mut child) => {
|
||||
let stdout = child.stdout.take();
|
||||
*inner.stdin.lock().unwrap() = child.stdin.take();
|
||||
*inner.child.lock().unwrap() = Some(child);
|
||||
if let Some(stdout) = stdout {
|
||||
let inner = Arc::clone(inner);
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines().flatten() {
|
||||
if let Some(rest) = line.strip_prefix("CROSSRELAY_LISTENING ") {
|
||||
if let Some(port_str) = rest.rsplit(':').next() {
|
||||
if let Ok(port) = port_str.trim().parse::<u16>() {
|
||||
*inner.port.lock().unwrap() = Some(port);
|
||||
let _ = handle.emit("gateway-ready", port);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("[gateway] {}", line);
|
||||
}
|
||||
// Gateway exited: forget the port.
|
||||
*inner.port.lock().unwrap() = None;
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("crossrelay: failed to start gateway: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the gateway state file so a stale port never lingers.
|
||||
fn cleanup_state_file() {
|
||||
let mut dir = std::env::var("CROSSRELAY_HOME")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.map(PathBuf::from);
|
||||
if dir.is_none() {
|
||||
if let Ok(home) = std::env::var("USERPROFILE") {
|
||||
dir = Some(PathBuf::from(home).join(".crossrelay"));
|
||||
}
|
||||
}
|
||||
if let Some(dir) = dir {
|
||||
let _ = std::fs::remove_file(dir.join("state.json"));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let inner = Arc::new(GatewayInner {
|
||||
child: Mutex::new(None),
|
||||
stdin: Mutex::new(None),
|
||||
port: Mutex::new(None),
|
||||
});
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(GatewayState(Arc::clone(&inner)))
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.setup(move |app| {
|
||||
spawn_gateway(app.handle(), &inner);
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(move |window, event| {
|
||||
if let tauri::WindowEvent::Destroyed = event {
|
||||
if let Some(state) = window.try_state::<GatewayState>() {
|
||||
state.0.kill();
|
||||
cleanup_state_file();
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![gateway_port, gateway_status])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building CrossRelay")
|
||||
.run(|app_handle, event| {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
if let Some(state) = app_handle.try_state::<GatewayState>() {
|
||||
state.0.kill();
|
||||
cleanup_state_file();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
crossrelay_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CrossRelay",
|
||||
"version": "0.1.0",
|
||||
"identifier": "cn.rutua.crossrelay",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "CrossRelay",
|
||||
"width": 1120,
|
||||
"height": 760,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:* http://localhost:*"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user