Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .github/workflows/rust-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
- "src-tauri/**"
- ".github/workflows/rust-ci.yml"
- ".github/workflows/release.yml"
pull_request_target:
pull_request:
paths:
- "src-tauri/**"
- ".github/workflows/rust-ci.yml"
Expand Down Expand Up @@ -47,8 +47,8 @@ jobs:
working-directory: src-tauri
run: cargo fmt --check

failover-e2e:
name: failover E2E test
integration-tests:
name: Integration tests
runs-on: ubuntu-22.04
steps:
- name: Checkout
Expand All @@ -63,13 +63,22 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
key: failover-e2e
key: integration-tests

- name: Run failover E2E test
- name: Failover
working-directory: src-tauri
run: |
sandbox_home="$(mktemp -d)"
export HOME="$sandbox_home"
export USERPROFILE="$sandbox_home"
export CC_SWITCH_CONFIG_DIR="$sandbox_home/.cc-switch"
cargo test --test proxy_claude_forwarder_alignment proxy_claude_auto_failover_uses_activated_queue_providers -- --exact --nocapture

- name: Proxy daemon
working-directory: src-tauri
run: |
sandbox_home="$(mktemp -d)"
export HOME="$sandbox_home"
export USERPROFILE="$sandbox_home"
export CC_SWITCH_CONFIG_DIR="$sandbox_home/.cc-switch"
cargo test --test proxy_daemon proxy_enable_and_disable_cli_manage_daemon_worker -- --exact --nocapture
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,17 +365,22 @@ cc-switch config reset # Reset to default configuration

### 🌉 Proxy Management

Inspect and control the local multi-app proxy used by supported apps.
Inspect and control daemon-managed per-app proxy routes for supported apps.

**Features:** Persisted enable/disable switch, current route inspection, dashboard telemetry, and foreground serve mode for debugging.
**Features:** independent enable/disable per app, per-app listen ports, daemon-managed workers, current route inspection, dashboard telemetry, and foreground serve mode for debugging.

```bash
cc-switch proxy show # Show proxy configuration and routes
cc-switch proxy enable # Enable the persisted proxy switch
cc-switch proxy disable # Disable the persisted proxy switch
cc-switch proxy serve # Run the proxy in foreground
cc-switch proxy show # Show proxy configuration, routes, and daemon worker status
cc-switch proxy enable # Enable the Claude proxy route (default app)
cc-switch --app codex proxy enable # Enable the Codex proxy route
cc-switch --app gemini proxy disable # Disable the Gemini proxy route
cc-switch --app claude proxy config --listen-port 15721
cc-switch --app codex proxy config --listen-port 15722
cc-switch proxy serve --takeover claude # Foreground debug mode; refused while daemon-managed routes are active
```

Normal CLI/TUI proxy enable/disable actions are routed through the daemon. The daemon auto-starts when the first app proxy route is activated, runs one worker per active supported app (Claude, Codex, Gemini), and exits automatically when no proxy routes remain active.

### 🧪 Environment & Local Tools

Inspect environment conflicts and whether required local CLIs are installed.
Expand Down
17 changes: 11 additions & 6 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,17 +366,22 @@ cc-switch config reset # 重置为默认配置

### 🌉 代理管理

查看并控制服务于各应用的本地多应用代理
查看并控制由守护进程管理的按应用代理路由

**功能:** 持久化开关、当前路由检查、首页遥测,以及用于调试的前台运行模式。
**功能:** 每个应用可独立启用/禁用代理、每个应用可配置监听端口、由 daemon 管理 worker、当前路由检查、首页遥测,以及用于调试的前台运行模式。

```bash
cc-switch proxy show # 显示代理配置和路由
cc-switch proxy enable # 启用持久化代理开关
cc-switch proxy disable # 禁用持久化代理开关
cc-switch proxy serve # 以前台模式运行代理
cc-switch proxy show # 显示代理配置、路由和 daemon worker 状态
cc-switch proxy enable # 启用 Claude 代理路由(默认应用)
cc-switch --app codex proxy enable # 启用 Codex 代理路由
cc-switch --app gemini proxy disable # 禁用 Gemini 代理路由
cc-switch --app claude proxy config --listen-port 15721
cc-switch --app codex proxy config --listen-port 15722
cc-switch proxy serve --takeover claude # 前台调试模式;存在 daemon 托管路由时会拒绝运行
```

普通 CLI/TUI 的代理启用/禁用操作都会通过 daemon 执行。首次启用任一应用代理路由时 daemon 会自动启动;每个活跃的受支持应用(Claude、Codex、Gemini)各有一个 worker;当没有任何活跃代理路由时 daemon 会自动退出。

### 🧪 环境与本地工具

检查环境变量冲突,以及 Claude/Codex/Gemini/OpenCode CLI 是否已经装好。
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ json-five = "0.3.1"

# Network and async
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "socks"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "signal", "net", "process", "io-util"] }
futures = "0.3"
async-stream = "0.3"
bytes = "1.5"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ use crate::error::AppError;
use crate::provider::ProviderManager;

/// 应用类型
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, clap::ValueEnum)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
Claude,
Expand Down
157 changes: 157 additions & 0 deletions src-tauri/src/cli/commands/daemon.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
use std::path::PathBuf;

use clap::Subcommand;

use crate::cli::ui::{highlight, info, success, warning};
use crate::daemon;
use crate::daemon::ipc::client;
use crate::daemon::ipc::protocol::{Request, Response};
use crate::error::AppError;

#[derive(Subcommand, Debug, Clone)]
pub enum DaemonCommand {
/// Start the supervisor daemon. Without --detach, runs in the foreground
/// (useful for debugging or running under systemd / launchd).
Start {
/// Detach from the terminal (double-fork) and write the pidfile.
#[arg(long)]
detach: bool,
},
/// Tell the running daemon to stop the worker (if any) and exit.
Stop,
/// Print daemon status (running, worker pid, restart count).
Status,
/// Show the path to the daemon log file.
Logs,
}

pub fn execute(cmd: DaemonCommand) -> Result<(), AppError> {
match cmd {
DaemonCommand::Start { detach } => start_daemon(detach),
DaemonCommand::Stop => stop_daemon(),
DaemonCommand::Status => status_daemon(),
DaemonCommand::Logs => show_log_path(),
}
}

fn start_daemon(detach: bool) -> Result<(), AppError> {
if detach {
detach_into_background()?;
}

let binary_path = current_executable()?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|err| AppError::Message(format!("build daemon runtime failed: {err}")))?;
runtime
.block_on(daemon::run(binary_path))
.map_err(AppError::Message)
}

fn stop_daemon() -> Result<(), AppError> {
let socket = daemon::paths::socket_path();
let response = client::round_trip(&socket, &Request::Shutdown)
.map_err(|err| AppError::Message(format!("send shutdown to daemon: {err}")))?;
match response {
Response::Ok => {
println!("{}", success("daemon shutdown signalled"));
Ok(())
}
Response::Error { message } => Err(AppError::Message(message)),
other => Err(AppError::Message(format!(
"unexpected response from daemon: {other:?}"
))),
}
}

fn status_daemon() -> Result<(), AppError> {
let socket = daemon::paths::socket_path();
let response = match client::round_trip(&socket, &Request::Status) {
Ok(r) => r,
Err(err) => {
println!("{}", warning(&format!("daemon not reachable: {err}")));
return Ok(());
}
};
match response {
Response::Status {
running,
address,
port,
worker_pid,
takeovers,
restart_count,
last_restart_at,
workers,
} => {
println!("{}", highlight("cc-switch daemon"));
println!(
" worker: {}",
if running {
format!(
"running at {address}:{port} (pid {})",
worker_pid.unwrap_or(0)
)
} else {
"not running".to_string()
}
);
for worker in &workers {
println!(
" worker[{}]: {}:{} (pid {})",
worker.app_type,
worker.address,
worker.port,
worker.pid.unwrap_or(0)
);
}
println!(
" takeovers: claude={}, codex={}, gemini={}",
takeovers.claude, takeovers.codex, takeovers.gemini
);
println!(" restart count: {restart_count}");
if let Some(at) = last_restart_at {
println!(" last restart: {at}");
}
Ok(())
}
Response::Error { message } => Err(AppError::Message(message)),
other => Err(AppError::Message(format!(
"unexpected response from daemon: {other:?}"
))),
}
}

fn show_log_path() -> Result<(), AppError> {
let path = daemon::paths::log_path();
println!("{}", info(&path.display().to_string()));
Ok(())
}

fn current_executable() -> Result<PathBuf, AppError> {
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_cc-switch") {
return Ok(PathBuf::from(path));
}
std::env::current_exe()
.map_err(|err| AppError::Message(format!("resolve daemon executable: {err}")))
}

#[cfg(unix)]
fn detach_into_background() -> Result<(), AppError> {
// Double-fork via libc::daemon. nochdir=1 keeps cwd, noclose=0 redirects
// stdio to /dev/null so the daemon doesn't keep the parent terminal open.
let rc = unsafe { libc::daemon(1, 0) };
if rc != 0 {
let err = std::io::Error::last_os_error();
return Err(AppError::Message(format!("daemonize failed: {err}")));
}
Ok(())
}

#[cfg(not(unix))]
fn detach_into_background() -> Result<(), AppError> {
Err(AppError::Message(
"--detach is only supported on unix targets".to_string(),
))
}
2 changes: 2 additions & 0 deletions src-tauri/src/cli/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ pub mod completions;
pub mod config;
mod config_common;
pub mod config_webdav;
#[cfg(unix)]
pub mod daemon;
pub mod env;
pub mod failover;
pub mod internal;
Expand Down
Loading
Loading