refactor: 拆分客户端lib库用于桌面程序和CLI、修复客户端停止时未正确关闭控制连接问题、删除Tauri桌面模块

This commit is contained in:
lxien
2026-08-13 21:15:35 +08:00
parent 410188c1e5
commit 4e6e612ee8
10 changed files with 5661 additions and 125 deletions
-9
View File
@@ -10,15 +10,6 @@
/server/assets/
.orbien.run_id
/desktop/src-tauri/binaries/orbien-*
!/desktop/src-tauri/binaries/README.md
!/desktop/src-tauri/binaries/.gitkeep
/desktop/src-tauri/gen/schemas
/desktop/src/assets/*.icns
/desktop/src/assets/app-icon.png
/desktop/src/assets/logo_origin.png
/desktop/pnpm-lock.yaml
node_modules/
dist-ssr/
.vite/
Generated
+5183 -24
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -4,9 +4,7 @@ members = [
"core",
"client",
"server",
]
exclude = [
"desktop/src-tauri",
"desktop",
]
[workspace.package]
@@ -29,8 +27,9 @@ opt-level = "z"
[workspace.dependencies]
orbien-core = { path = "core" }
orbien-client = { path = "client" }
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io", "compat"] }
tokio-util = { version = "0.7", features = ["io", "compat", "rt"] }
futures = "0.3"
yamux = "0.13"
serde = { version = "1", features = ["derive"] }
+6 -1
View File
@@ -3,7 +3,11 @@ name = "orbien-client"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "orbien client — TCP proxy over TCP/QUIC"
description = "Orbien client library and CLI"
[lib]
name = "orbien_client"
path = "src/lib.rs"
[[bin]]
name = "orbien"
@@ -12,6 +16,7 @@ path = "src/main.rs"
[dependencies]
orbien-core = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
+102 -21
View File
@@ -5,23 +5,26 @@ use anyhow::{anyhow, Result};
use orbien_core::auth;
use orbien_core::config::ClientConfig;
use orbien_core::msg::{self, Login, Message, NewProxy, NewWorkConn, Ping};
use orbien_core::transport::DynStream;
use orbien_core::VERSION;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncWriteExt, ReadHalf, WriteHalf};
use tokio::sync::Mutex;
use tokio::task::JoinSet;
use tokio::time::interval;
use tokio_util::sync::CancellationToken;
#[derive(Debug)]
pub enum SessionEnd {
Disconnected { run_id: String },
Kicked { run_id: String, reason: String },
}
use orbien_core::transport::DynStream;
use orbien_core::VERSION;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{ReadHalf, WriteHalf};
use tokio::sync::Mutex;
use tokio::time::interval;
type CtrlRead = ReadHalf<DynStream>;
type CtrlWrite = WriteHalf<DynStream>;
type OnProxyRemote = Arc<dyn Fn(String, String) + Send + Sync>;
pub struct Control {
cfg: ClientConfig,
@@ -30,6 +33,9 @@ pub struct Control {
writer: Mutex<CtrlWrite>,
proxies: ProxyManager,
connector: Arc<dyn Connector>,
cancel: CancellationToken,
work_tasks: Mutex<JoinSet<()>>,
on_proxy_remote: OnProxyRemote,
}
impl Control {
@@ -37,7 +43,11 @@ impl Control {
cfg: &ClientConfig,
previous_run_id: String,
config_path: &Path,
parent_cancel: CancellationToken,
on_connected: impl FnOnce(),
on_proxy_remote: OnProxyRemote,
) -> Result<SessionEnd> {
let session_cancel = parent_cancel.child_token();
let connector = build_connector(cfg).await?;
let mut stream = connector.open().await?;
tracing::info!(
@@ -96,16 +106,28 @@ impl Control {
writer: Mutex::new(writer),
proxies: ProxyManager::from_config(cfg)?,
connector,
cancel: session_cancel.clone(),
work_tasks: Mutex::new(JoinSet::new()),
on_proxy_remote,
});
ctl.register_all_proxies().await?;
on_connected();
let hb = Arc::clone(&ctl);
let heartbeat = tokio::spawn(async move { hb.heartbeat_loop().await });
let hb_cancel = session_cancel.clone();
let heartbeat = tokio::spawn(async move {
tokio::select! {
_ = hb_cancel.cancelled() => {}
_ = hb.heartbeat_loop() => {}
}
});
let result = ctl.reader_loop().await;
let result = ctl.clone().reader_loop().await;
ctl.shutdown().await;
heartbeat.abort();
let _ = heartbeat.await;
match result {
Ok(ReaderEnd::Kicked(reason)) => Ok(SessionEnd::Kicked {
run_id: resp.run_id,
@@ -118,6 +140,17 @@ impl Control {
}
}
async fn shutdown(&self) {
self.cancel.cancel();
{
let mut writer = self.writer.lock().await;
let _ = writer.shutdown().await;
}
let mut tasks = self.work_tasks.lock().await;
tasks.abort_all();
while tasks.join_next().await.is_some() {}
}
async fn register_all_proxies(&self) -> Result<()> {
for p in &self.cfg.proxies {
let msg = match p.proxy_type.as_str() {
@@ -212,11 +245,22 @@ impl Control {
async fn reader_loop(self: Arc<Self>) -> Result<ReaderEnd> {
loop {
let msg = {
let mut reader = self.reader.lock().await;
match msg::read_msg(&mut *reader).await {
Ok(m) => m,
Err(_) => return Ok(ReaderEnd::Closed),
if self.cancel.is_cancelled() {
return Ok(ReaderEnd::Closed);
}
let msg = tokio::select! {
_ = self.cancel.cancelled() => {
return Ok(ReaderEnd::Closed);
}
msg = async {
let mut reader = self.reader.lock().await;
msg::read_msg(&mut *reader).await
} => {
match msg {
Ok(m) => m,
Err(_) => return Ok(ReaderEnd::Closed),
}
}
};
@@ -227,19 +271,30 @@ impl Control {
}
Message::ReqWorkConn(_) => {
let ctl = Arc::clone(&self);
tokio::spawn(async move {
if let Err(e) = ctl.handle_req_work_conn().await {
tracing::error!(error = %e, "work tunnel failed");
let cancel = self.cancel.clone();
self.work_tasks.lock().await.spawn(async move {
tokio::select! {
_ = cancel.cancelled() => {}
res = ctl.handle_req_work_conn() => {
if let Err(e) = res {
tracing::error!(error = %e, "work tunnel failed");
}
}
}
});
}
Message::NewProxyResp(resp) => {
if resp.error.is_empty() {
let remote = normalize_remote_addr(
&self.cfg.server_addr,
&resp.remote_addr,
);
tracing::info!(
name = %resp.proxy_name,
remote = %resp.remote_addr,
remote = %remote,
"proxy started"
);
(self.on_proxy_remote)(resp.proxy_name.clone(), remote);
} else {
tracing::error!(
name = %resp.proxy_name,
@@ -268,6 +323,9 @@ impl Control {
let mut tick = interval(Duration::from_secs(secs as u64));
tick.tick().await;
loop {
if self.cancel.is_cancelled() {
break;
}
tick.tick().await;
let timestamp = now_secs();
let ping = Ping {
@@ -298,9 +356,18 @@ impl Control {
)
.await?;
let start = match msg::read_msg(&mut work).await? {
Message::StartWorkConn(s) => s,
other => return Err(anyhow!("expected StartWorkConn, got {}", other.type_byte())),
let start = tokio::select! {
_ = self.cancel.cancelled() => {
return Ok(());
}
msg = msg::read_msg(&mut work) => {
match msg? {
Message::StartWorkConn(s) => s,
other => {
return Err(anyhow!("expected StartWorkConn, got {}", other.type_byte()))
}
}
}
};
if !start.error.is_empty() {
@@ -346,6 +413,20 @@ fn omit_client_mode(mode: &str) -> String {
}
}
fn normalize_remote_addr(server_addr: &str, remote_addr: &str) -> String {
let remote = remote_addr.trim();
if remote.is_empty() {
return String::new();
}
if let Some(port) = remote.strip_prefix(':') {
let host = server_addr.trim();
if !host.is_empty() && !port.is_empty() {
return format!("{host}:{port}");
}
}
remote.to_string()
}
fn new_proxy_base(
name: &str,
proxy_type: &str,
+279
View File
@@ -0,0 +1,279 @@
use crate::service::Service;
use anyhow::{bail, Result};
use orbien_core::config::ClientConfig;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientStatus {
Stopped,
Starting,
Running,
Reconnecting,
Stopping,
}
impl ClientStatus {
pub fn is_active(self) -> bool {
matches!(
self,
Self::Starting | Self::Running | Self::Reconnecting | Self::Stopping
)
}
}
#[derive(Debug, Default)]
struct ProxyRemoteState {
gen: u64,
by_name: HashMap<String, String>,
}
struct Inner {
status: Mutex<ClientStatus>,
last_error: Mutex<Option<String>>,
pending_logs: Mutex<Vec<String>>,
proxy_remotes: Mutex<ProxyRemoteState>,
cancel: Mutex<Option<CancellationToken>>,
join: Mutex<Option<JoinHandle<()>>>,
}
#[derive(Clone)]
pub struct ClientHandle {
inner: Arc<Inner>,
}
impl Default for ClientHandle {
fn default() -> Self {
Self::new()
}
}
impl ClientHandle {
pub fn new() -> Self {
Self {
inner: Arc::new(Inner {
status: Mutex::new(ClientStatus::Stopped),
last_error: Mutex::new(None),
pending_logs: Mutex::new(Vec::new()),
proxy_remotes: Mutex::new(ProxyRemoteState::default()),
cancel: Mutex::new(None),
join: Mutex::new(None),
}),
}
}
pub fn status(&self) -> ClientStatus {
*self.inner.status.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn last_error(&self) -> Option<String> {
self.inner
.last_error
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn push_log(&self, line: impl Into<String>) {
self.enqueue_log(line.into());
}
pub fn drain_logs(&self) -> Vec<String> {
std::mem::take(
&mut *self
.inner
.pending_logs
.lock()
.unwrap_or_else(|e| e.into_inner()),
)
}
pub fn proxy_remotes_if_changed(
&self,
since_gen: u64,
) -> Option<(u64, HashMap<String, String>)> {
let g = self
.inner
.proxy_remotes
.lock()
.unwrap_or_else(|e| e.into_inner());
if g.gen == since_gen {
return None;
}
Some((g.gen, g.by_name.clone()))
}
pub fn clear_proxy_remotes(&self) {
let mut g = self
.inner
.proxy_remotes
.lock()
.unwrap_or_else(|e| e.into_inner());
g.by_name.clear();
g.gen = g.gen.wrapping_add(1);
}
fn set_proxy_remote(&self, name: String, remote_addr: String) {
if name.is_empty() {
return;
}
let mut g = self
.inner
.proxy_remotes
.lock()
.unwrap_or_else(|e| e.into_inner());
if g.by_name.get(&name) == Some(&remote_addr) {
return;
}
g.by_name.insert(name, remote_addr);
g.gen = g.gen.wrapping_add(1);
}
fn enqueue_log(&self, line: String) {
if let Ok(mut g) = self.inner.pending_logs.lock() {
const MAX_PENDING: usize = 500;
if g.len() >= MAX_PENDING {
let drop_n = g.len() - MAX_PENDING + 1;
g.drain(0..drop_n);
}
g.push(line);
}
}
fn set_status(&self, s: ClientStatus) {
if let Ok(mut g) = self.inner.status.lock() {
*g = s;
}
}
fn set_error(&self, e: Option<String>) {
if let Ok(mut g) = self.inner.last_error.lock() {
*g = e;
}
}
pub async fn run_foreground(self, cfg: ClientConfig, config_path: PathBuf) -> Result<()> {
let cancel = CancellationToken::new();
self.set_status(ClientStatus::Starting);
self.set_error(None);
self.clear_proxy_remotes();
let result = Service::new(cfg, config_path)
.run(
cancel.clone(),
{
let h = self.clone();
move |st| h.set_status(st)
},
{
let h = self.clone();
move |line| {
h.enqueue_log(line);
}
},
{
let h = self.clone();
Arc::new(move |name, remote| h.set_proxy_remote(name, remote))
},
{
let h = self.clone();
Arc::new(move || h.clear_proxy_remotes())
},
)
.await;
self.clear_proxy_remotes();
self.set_status(ClientStatus::Stopped);
if let Err(ref e) = result {
self.set_error(Some(e.to_string()));
}
result
}
pub fn start(&self, cfg: ClientConfig, config_path: PathBuf) -> Result<()> {
if self.status().is_active() {
bail!("client already running");
}
let cancel = CancellationToken::new();
*self.inner.cancel.lock().unwrap_or_else(|e| e.into_inner()) = Some(cancel.clone());
self.set_status(ClientStatus::Starting);
self.set_error(None);
self.clear_proxy_remotes();
let handle = self.clone();
let join = tokio::spawn(async move {
let on_status = {
let h = handle.clone();
move |st| h.set_status(st)
};
let on_log = {
let h = handle.clone();
move |line: String| {
h.enqueue_log(line);
}
};
let on_proxy_remote: Arc<dyn Fn(String, String) + Send + Sync> = {
let h = handle.clone();
Arc::new(move |name: String, remote: String| h.set_proxy_remote(name, remote))
};
let on_remotes_clear: Arc<dyn Fn() + Send + Sync> = {
let h = handle.clone();
Arc::new(move || h.clear_proxy_remotes())
};
let result = Service::new(cfg, config_path)
.run(cancel, on_status, on_log, on_proxy_remote, on_remotes_clear)
.await;
if let Err(e) = result {
tracing::error!(error = %e, "client service ended with error");
handle.set_error(Some(e.to_string()));
}
handle.clear_proxy_remotes();
handle.set_status(ClientStatus::Stopped);
*handle
.inner
.cancel
.lock()
.unwrap_or_else(|e| e.into_inner()) = None;
});
*self.inner.join.lock().unwrap_or_else(|e| e.into_inner()) = Some(join);
Ok(())
}
pub async fn stop(&self) {
if matches!(self.status(), ClientStatus::Stopped) {
return;
}
self.set_status(ClientStatus::Stopping);
if let Some(token) = self
.inner
.cancel
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
{
token.cancel();
}
let join = self
.inner
.join
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
if let Some(join) = join {
let abort = join.abort_handle();
match tokio::time::timeout(std::time::Duration::from_secs(5), join).await {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!(error = %e, "client task join error"),
Err(_) => {
tracing::warn!("client stop timed out after 5s — aborting task");
abort.abort();
self.set_status(ClientStatus::Stopped);
}
}
} else {
self.set_status(ClientStatus::Stopped);
}
self.clear_proxy_remotes();
}
}
+13
View File
@@ -0,0 +1,13 @@
mod connector;
mod control;
mod handle;
mod plugin;
mod proxy;
mod run_id;
mod service;
pub use handle::{ClientHandle, ClientStatus};
pub use orbien_core::config::{resolve_client_config_path, ClientConfig};
pub use service::Service;
pub use orbien_core::VERSION;
+6 -10
View File
@@ -1,12 +1,6 @@
mod connector;
mod control;
mod plugin;
mod proxy;
mod run_id;
mod service;
use anyhow::Result;
use clap::Parser;
use orbien_client::ClientHandle;
use tracing_subscriber::EnvFilter;
#[derive(Parser, Debug)]
@@ -29,10 +23,10 @@ async fn main() -> Result<()> {
.init();
let args = Args::parse();
let config_path = orbien_core::config::resolve_client_config_path(args.config.as_deref())?;
let config_path = orbien_client::resolve_client_config_path(args.config.as_deref())?;
tracing::info!(config = %config_path.display(), "loading config");
let cfg = orbien_core::config::ClientConfig::load(&config_path)?;
let cfg = orbien_client::ClientConfig::load(&config_path)?;
tracing::info!(
server = %cfg.server_endpoint(),
protocol = %cfg.transport.protocol,
@@ -40,5 +34,7 @@ async fn main() -> Result<()> {
"starting orbien"
);
service::Service::new(cfg, &config_path).run().await
ClientHandle::new()
.run_foreground(cfg, config_path)
.await
}
+69 -8
View File
@@ -1,10 +1,13 @@
use crate::control::{Control, SessionEnd};
use crate::handle::ClientStatus;
use crate::run_id;
use anyhow::Result;
use anyhow::{anyhow, Result};
use orbien_core::config::ClientConfig;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
pub struct Service {
cfg: ClientConfig,
@@ -19,14 +22,51 @@ impl Service {
}
}
pub async fn run(self) -> Result<()> {
pub async fn run(
self,
cancel: CancellationToken,
mut on_status: impl FnMut(ClientStatus),
mut on_log: impl FnMut(String),
on_proxy_remote: Arc<dyn Fn(String, String) + Send + Sync>,
on_remotes_clear: Arc<dyn Fn() + Send + Sync>,
) -> Result<()> {
let mut run_id = run_id::load(&self.config_path);
if !run_id.is_empty() {
tracing::info!(%run_id, "restored persisted run_id");
}
let mut first_attempt = true;
loop {
match Control::start(&self.cfg, run_id.clone(), &self.config_path).await {
if cancel.is_cancelled() {
tracing::info!("client service cancelled");
return Ok(());
}
on_remotes_clear();
if first_attempt {
on_status(ClientStatus::Starting);
on_log("INFO connecting to server".into());
} else {
on_status(ClientStatus::Reconnecting);
}
let end = Control::start(
&self.cfg,
run_id.clone(),
&self.config_path,
cancel.clone(),
|| {
on_status(ClientStatus::Running);
on_log("INFO connected to server".into());
},
Arc::clone(&on_proxy_remote),
)
.await;
on_remotes_clear();
match end {
Ok(SessionEnd::Kicked {
run_id: rid,
reason,
@@ -34,19 +74,40 @@ impl Service {
tracing::error!(
run_id = %rid,
%reason,
"kicked by server — process will exit (no reconnect)"
"kicked by server — stopping (no reconnect)"
);
return Ok(());
on_log(format!("ERROR kicked by server: {reason}"));
return Err(anyhow!("kicked by server: {reason}"));
}
Ok(SessionEnd::Disconnected { run_id: rid }) => {
if cancel.is_cancelled() {
tracing::info!(run_id = %rid, "session ended after cancel");
return Ok(());
}
run_id = rid;
tracing::warn!("control session ended, reconnecting in 3s...");
on_log("WARN disconnected from server".into());
on_status(ClientStatus::Reconnecting);
}
Err(e) => {
tracing::error!(error = %e, "failed to establish control, retry in 3s");
if cancel.is_cancelled() {
tracing::info!("session error after cancel: {e}");
return Ok(());
}
on_log(format!("ERROR failed to connect: {e}"));
on_status(ClientStatus::Reconnecting);
}
}
sleep(Duration::from_secs(3)).await;
first_attempt = false;
on_log("INFO retrying in 3s".into());
tokio::select! {
_ = cancel.cancelled() => {
tracing::info!("client service cancelled during backoff");
return Ok(());
}
_ = sleep(Duration::from_secs(3)) => {}
}
}
}
}
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BIN_DIR="$ROOT/desktop/src-tauri/binaries"
mkdir -p "$BIN_DIR"
TARGET="${1:-}"
if [[ -z "$TARGET" ]]; then
TARGET="$(rustc --print host-tuple 2>/dev/null || rustc -Vv | awk '/^host:/{print $2}')"
fi
EXT=""
case "$TARGET" in
*windows*) EXT=".exe" ;;
esac
LOCKED_ARGS=()
if [[ "${CI:-}" == "true" || "${CARGO_LOCKED:-}" == "1" ]]; then
LOCKED_ARGS+=(--locked)
fi
HOST="$(rustc --print host-tuple 2>/dev/null || rustc -Vv | awk '/^host:/{print $2}')"
echo "building orbien sidecar for target=${TARGET} (host=${HOST})"
build_sidecar() {
if [[ "$TARGET" == "$HOST" ]]; then
cargo build --release ${LOCKED_ARGS[@]+"${LOCKED_ARGS[@]}"} \
-p orbien-client --manifest-path "$ROOT/Cargo.toml"
SRC="$ROOT/target/release/orbien${EXT}"
else
cargo build --release ${LOCKED_ARGS[@]+"${LOCKED_ARGS[@]}"} \
-p orbien-client --target "$TARGET" --manifest-path "$ROOT/Cargo.toml"
SRC="$ROOT/target/${TARGET}/release/orbien${EXT}"
fi
}
build_sidecar
if [[ ! -f "$SRC" ]]; then
echo "sidecar binary not found: $SRC" >&2
exit 1
fi
DEST="$BIN_DIR/orbien-${TARGET}${EXT}"
cp "$SRC" "$DEST"
chmod +x "$DEST" 2>/dev/null || true
ls -lh "$DEST"
echo "sidecar ready: $DEST"