Skip to content

Commit 9acf2ad

Browse files
author
Yongmao Luo
committed
feat(remote): add built-in remote management HTTP server
Motivation ---------- Switching AI providers currently requires opening the desktop CC Switch app. When working on a headless machine or wanting to switch from a phone, there is no way to do so without a separate script that bypasses the app's state management, causing race conditions. Approach -------- Add a lightweight Axum HTTP server embedded in the Tauri desktop app. The server provides a mobile-friendly Web UI and REST API for provider switching, directly calling ProviderService::switch() to ensure backfill and state consistency. Backend (Rust): - remote/mod.rs: RemoteServer with start/stop lifecycle, SSE broadcast channel, and Tailscale IP detection (resolves binary from known paths to avoid macOS GUI app PATH limitations). - remote/handlers.rs: 7 Axum endpoints — HTML page, provider list, switch, current, SSE events, health check, and icon with dynamic SVG coloring. - remote/html.rs: Embedded single-page dark-theme HTML UI with EventSource for real-time SSE updates. - commands/remote.rs: Tauri commands for start/stop server, check Tailscale availability, and query Tailscale IP. - Integration points in lib.rs (startup), tray.rs, provider.rs, failover.rs, failover_switch.rs to broadcast SSE on provider switch. Frontend (React): - RemoteSettings.tsx: Settings UI with enable toggle (immediate start/stop), port input (stops server on change, user re-enables to apply), Tailscale toggle with pre-flight availability check (disabled when server is off), Tailscale IP display, and live status indicator. - i18n translations for en/zh/ja. Result ------ Users can now switch providers from any browser on the local network. Default: http://localhost:4000. With Tailscale enabled, accessible from mobile devices at the displayed Tailscale URL. Server is fully manageable from the settings page without app restart. 17 files changed, +1172 -1 lines.
1 parent 79eb773 commit 9acf2ad

17 files changed

Lines changed: 1214 additions & 1 deletion

File tree

src-tauri/src/commands/failover.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ pub async fn set_auto_failover_enabled(
158158
"source": "failoverEnabled"
159159
});
160160
let _ = app.emit("provider-switched", event_data);
161+
// 广播给远程浏览器(SSE)
162+
crate::remote::broadcast_provider_switch(&app, &app_type, &p1_provider_id);
161163
}
162164

163165
// 刷新托盘菜单,确保状态同步

src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ mod plugin;
2020
mod prompt;
2121
mod provider;
2222
mod proxy;
23+
pub(crate) mod remote;
2324
mod session_manager;
2425
mod settings;
2526
pub mod skill;

src-tauri/src/commands/provider.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,19 @@ pub fn switch_provider_test_hook(
100100

101101
#[tauri::command]
102102
pub fn switch_provider(
103+
app_handle: tauri::AppHandle,
103104
state: State<'_, AppState>,
104105
app: String,
105106
id: String,
106107
) -> Result<SwitchResult, String> {
107108
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
108-
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
109+
let result =
110+
switch_provider_internal(&state, app_type.clone(), &id).map_err(|e| e.to_string())?;
111+
112+
// 广播给远程浏览器(SSE)
113+
crate::remote::broadcast_provider_switch(&app_handle, app_type.as_str(), &id);
114+
115+
Ok(result)
109116
}
110117

111118
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {

src-tauri/src/commands/remote.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
use tauri::{command, AppHandle};
2+
3+
#[command]
4+
pub async fn start_remote_server(app: AppHandle) -> Result<String, String> {
5+
let settings = crate::settings::get_settings();
6+
let config = crate::remote::RemoteConfig {
7+
enabled: true,
8+
port: settings.remote_port,
9+
tailscale_enabled: settings.remote_tailscale_enabled,
10+
};
11+
12+
let urls = crate::remote::start_remote(&app, config).await?;
13+
Ok(format!("Remote server started on: {}", urls.join(", ")))
14+
}
15+
16+
#[command]
17+
pub async fn stop_remote_server(app: AppHandle) -> Result<String, String> {
18+
crate::remote::stop_remote(&app).await?;
19+
Ok("Remote server stopped".to_string())
20+
}
21+
22+
#[command]
23+
pub fn check_tailscale_available() -> bool {
24+
crate::remote::is_tailscale_available()
25+
}
26+
27+
#[command]
28+
pub fn get_tailscale_ip() -> Option<String> {
29+
crate::remote::get_tailscale_ip()
30+
}

src-tauri/src/lib.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ mod prompt_files;
2424
mod provider;
2525
mod provider_defaults;
2626
mod proxy;
27+
mod remote;
2728
mod services;
2829
mod session_manager;
2930
mod settings;
@@ -770,6 +771,42 @@ pub fn run() {
770771
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
771772
app.manage(app_state);
772773

774+
// 启动 Remote Management HTTP Server
775+
{
776+
let app_settings = crate::settings::get_settings();
777+
let remote_config = remote::RemoteConfig {
778+
enabled: app_settings.remote_enabled,
779+
port: app_settings.remote_port,
780+
tailscale_enabled: app_settings.remote_tailscale_enabled,
781+
};
782+
783+
let remote_server = remote::RemoteServer::new(
784+
app.handle().clone(),
785+
remote_config.clone(),
786+
);
787+
788+
// 注入 RemoteServer 供 Tauri 命令使用
789+
app.manage(remote::ManagedRemoteServer(Arc::new(tokio::sync::RwLock::new(Some(remote_server)))));
790+
791+
// 异步启动 remote server(仅当 enabled 时)
792+
if remote_config.enabled {
793+
let managed = app.state::<remote::ManagedRemoteServer>();
794+
let guard = managed.0.clone();
795+
let _ = &managed;
796+
tauri::async_runtime::spawn(async move {
797+
let read_guard = guard.read().await;
798+
if let Some(ref server) = *read_guard {
799+
match server.start().await {
800+
Ok(urls) => log::info!("[Remote] Management server started on: {}", urls.join(", ")),
801+
Err(e) => log::warn!("[Remote] Failed to start management server: {e}"),
802+
}
803+
}
804+
});
805+
} else {
806+
log::info!("[Remote] Remote management server disabled");
807+
}
808+
}
809+
773810
// 从数据库加载日志配置并应用
774811
{
775812
let db = &app.state::<AppState>().db;
@@ -1116,6 +1153,11 @@ pub fn run() {
11161153
commands::import_from_deeplink,
11171154
commands::import_from_deeplink_unified,
11181155
update_tray_menu,
1156+
// Remote management
1157+
commands::remote::start_remote_server,
1158+
commands::remote::stop_remote_server,
1159+
commands::remote::check_tailscale_available,
1160+
commands::remote::get_tailscale_ip,
11191161
// Environment variable management
11201162
commands::check_env_conflicts,
11211163
commands::delete_env_vars,

src-tauri/src/proxy/failover_switch.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ impl FailoverSwitchManager {
128128
if let Err(e) = app.emit("provider-switched", event_data) {
129129
log::error!("[Failover] 发射事件失败: {e}");
130130
}
131+
// 广播给远程浏览器(SSE)
132+
crate::remote::broadcast_provider_switch(app, app_type, provider_id);
131133
}
132134

133135
Ok(switched)

0 commit comments

Comments
 (0)