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:
kate522 committed 2026-10-18 17:19:13 +08:00
1 parent 8ee5e98ea3
commit f8acf31ebc
71 files changed
+15727 -1

No files matched your search

+4914
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+17
View File
@@ -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

+205
View File
@@ -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();
}
}
});
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
crossrelay_lib::run()
}
+36
View File
@@ -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"
]
}
}