Skip to content

feat(remote): add built-in remote management HTTP server with Web UI#2450

Open
YongmaoLuo wants to merge 1 commit intofarion1231:mainfrom
YongmaoLuo:feat/remote-management
Open

feat(remote): add built-in remote management HTTP server with Web UI#2450
YongmaoLuo wants to merge 1 commit intofarion1231:mainfrom
YongmaoLuo:feat/remote-management

Conversation

@YongmaoLuo
Copy link
Copy Markdown

@YongmaoLuo YongmaoLuo commented Apr 29, 2026

Summary

Adds a lightweight HTTP server embedded in the CC Switch Tauri app, providing a mobile-friendly Web UI and REST API for provider switching. The server shares state with the desktop app via ProviderService::switch(), ensuring backfill and consistency. SSE keeps all connected browsers and the desktop app in sync in real-time.

Closes #2418

Architecture

┌──────────────────────────────────────────────────────┐
│                  CC Switch Desktop App                │
│                                                      │
│  Frontend (React)          Backend (Rust / Tauri)    │
│  ┌─────────────────┐      ┌────────────────────┐    │
│  │ RemoteSettings   │      │ remote/ module     │    │
│  │ - Enable toggle  │      │  ├─ mod.rs         │    │
│  │ - Port config    │◀────▶│  ├─ handlers.rs    │    │
│  │ - Tailscale IP   │      │  └─ html.rs        │    │
│  │ - Status display │      │                    │    │
│  └─────────────────┘      └───────┬────────────┘    │
│                                   │                  │
│  tray.rs ─── provider.rs ── failover_switch.rs      │
│       │         │                  │                 │
│       └─────────┼──────────────────┘                 │
│                 │ broadcast_provider_switch()         │
│                 ▼                                     │
│          SSE broadcast channel                        │
└──────────────────────────────────────────────────────┘
                   │
        ┌──────────┴──────────┐
        │   Remote Browser     │
        │  (phone / tablet)    │
        │                      │
        │  GET /          → Web UI (embedded HTML)      │
        │  GET /api/providers → list providers          │
        │  POST /api/switch → switch provider           │
        │  GET /api/events → SSE real-time updates      │
        └──────────────────────┘

Backend Components

File Purpose
remote/mod.rs RemoteServer with start/stop lifecycle, SSE broadcast, Tailscale IP detection
remote/handlers.rs 8 Axum endpoints — HTML page, provider list/switch/current, SSE events, health check, app icon, provider icons with dynamic SVG coloring
remote/html.rs Embedded single-page HTML UI with light/dark theme (follows prefers-color-scheme), EventSource for real-time SSE updates
commands/remote.rs Tauri commands: start_remote_server, stop_remote_server, check_tailscale_available, get_tailscale_ip
lib.rs Server initialization and command registration
settings.rs remote_enabled, remote_port, remote_tailscale_enabled settings fields
tray.rs, provider.rs, failover.rs, failover_switch.rs Broadcast SSE on provider switch from desktop side

Frontend Components

File Purpose
RemoteSettings.tsx Settings UI — enable toggle (immediate start/stop), port input, Tailscale toggle with pre-flight check, Tailscale IP display, live status indicator
types.ts TypeScript types for remote settings
en.json, zh.json, ja.json i18n translations

Key Design Decisions

  1. Embedded server — Runs inside the Tauri process, calls ProviderService::switch() directly, no separate binary needed
  2. SSE over WebSocket — Unidirectional push is sufficient (server → browser), simpler to implement
  3. Tailscale binary detection — Resolves from known paths (/usr/local/bin, /opt/homebrew/bin, /usr/bin) to avoid macOS GUI app PATH limitations
  4. System theme for Web UI — Uses @media (prefers-color-scheme) so remote devices follow their own OS appearance

Expected Behavior

  • Default: Server listens on http://localhost:4000 (configurable port 1024-65535)
  • Web UI: Open the URL in any browser — see provider list, tap to switch, real-time SSE updates
  • REST API: GET /api/providers, POST /api/switch, GET /api/current, GET /api/events for programmatic access (e.g., cc-connect / IM bots)
  • Settings: Enable/disable server, change port, toggle Tailscale remote access — all take effect immediately without app restart
  • Tailscale: When enabled, displays the full access URL (e.g., http://100.x.x.x:4000) for mobile access
  • Bidirectional sync: Switching from desktop (tray/failover/frontend) broadcasts to all browsers; switching from browser updates desktop tray and frontend

Screenshots

Settings Page Web UI (Light) Web UI (Dark)
image image image

Checklist

  • pnpm typecheck passes
  • pnpm format:check passes
  • cargo clippy passes (0 warnings)
  • cargo fmt --check passes
  • pnpm test:unit passes (32 files, 204 tests)
  • Updated i18n files if user-facing text changed (en/zh/ja)

@farion1231
Copy link
Copy Markdown
Owner

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9acf2ad699

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/src/remote/mod.rs Outdated
Comment thread src/components/settings/RemoteSettings.tsx Outdated
Comment thread src/components/settings/RemoteSettings.tsx Outdated
@YongmaoLuo
Copy link
Copy Markdown
Author

YongmaoLuo commented Apr 30, 2026

@farion1231 已根据 review 意见修复,请再次检阅。最新 commit: `d80a73a`。

P1: Avoid blocking Tokio runtime in provider-switch broadcast

问题: `broadcast_provider_switch` 使用 `blocking_read()`,在 async runtime 线程中调用会 panic。

修改:

  • `broadcast_provider_switch` 改为 `async fn`,内部使用 `.read().await` 替代 `blocking_read()`
  • 更新所有 4 个调用点(`tray.rs`, `failover_switch.rs`, `commands/failover.rs`, `commands/provider.rs`),使用 `tauri::async_runtime::spawn` 包装异步调用
  • 移除手动 `drop(guard)`,由作用域自动管理

效果: 在 tray 切换、failover 切换、provider 切换等路径下,SSE 广播不再 panic,状态同步正常执行。


P2: Restart remote server when toggling Tailscale access

问题: Tailscale 开关仅持久化设置,不重新配置运行中的服务器。

修改:

  • `handleTailscaleToggle` 中,当服务器正在运行时:
    1. 先调用 `stop_remote_server` 停止服务器
    2. 轮询检查旧端口已完全关闭(最多 2 秒)
    3. 更新 `remoteTailscaleEnabled` 设置
    4. 使用新配置重新 `start_remote_server`
    5. 健康检查验证重启成功
  • 添加 `restarting` 状态,切换期间显示黄色脉冲指示器
  • Tailscale toggle 在重启期间禁用,避免重复触发

效果: 服务器运行中切换 Tailscale 时,自动重启并绑定/解绑 Tailscale IP,UI 实时反馈重启进度。


P2: Preserve enabled state when changing remote server port

问题: 端口变更时停止服务器并设置 `remoteEnabled: false`,导致用户必须手动重新启用。

修改:

  • 端口变更时不再禁用远程管理,而是保持启用并重启服务器
  • `start_remote_server` 命令改为接收显式的 `port` 和 `tailscale_enabled` 参数,不再从 settings 读取(消除竞态条件)
  • 前端在重启时显式传递新端口 `val`,而不是依赖可能尚未更新的 `settings.remotePort`
  • 修复 `checkServerStatus` 的闭包捕获问题:改为显式传入 `port` 参数,避免检查 stale 端口
  • 重启期间显示黄色脉冲指示器

效果: 端口变更即时生效,服务器自动在新端口重启,无需手动重新启用。经过测试,从 3999 → 4000 等切换均正常。


额外补充

  • 新增 `RemoteSettings` 前端单元测试(8 个用例)
  • 新增 remote 后端基础测试(`src-tauri/tests/remote_management.rs`)

@YongmaoLuo YongmaoLuo force-pushed the feat/remote-management branch from eb3443d to d80a73a Compare April 30, 2026 08:21
@farion1231
Copy link
Copy Markdown
Owner

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 136daa55b0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/src/remote/mod.rs Outdated
Comment on lines +173 to +175
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| format!("Failed to bind {addr}: {e}"))?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Roll back already-started listeners on bind error

If tailscale_enabled is on, this loop can successfully bind and spawn the localhost server, then fail on the Tailscale bind and return Err immediately; the already-started listener keeps running and its shutdown handle remains registered. That leaves the app in a partially-running state (command reports failure, but one server is alive) and can cause later start calls to behave as “already running.”

Useful? React with 👍 / 👎.

Comment thread src-tauri/src/tray.rs Outdated
Comment on lines +289 to +293
let app = app.clone();
let app_type_str = app_type_str.to_string();
let provider_id = provider_id.to_string();
tauri::async_runtime::spawn(async move {
crate::remote::broadcast_provider_switch(&app, &app_type_str, &provider_id).await;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove duplicate SSE broadcast in tray provider switch

This path now sends the same remote switch event twice: commands::switch_provider already spawns broadcast_provider_switch, and this additional spawn sends it again for tray-triggered switches. Clients listening on /api/events will receive duplicate switch events for a single user action, which can cause duplicate notifications and unnecessary reloads.

Useful? React with 👍 / 👎.

Comment on lines +52 to +54
setToggling(true);
onChange({ remoteEnabled: value });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Revert remoteEnabled when start/stop command fails

The setting is persisted before the start/stop RPC succeeds, and the catch path only shows a toast. If start_remote_server fails (for example, port already in use), remoteEnabled remains saved as enabled even though the server is not running, so the UI/state becomes inconsistent and can mislead users on next launch.

Useful? React with 👍 / 👎.

Comment thread src-tauri/src/remote/mod.rs Outdated
Comment on lines +70 to +71
std::process::Command::new("/usr/bin/which")
.arg("tailscale")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use cross-platform Tailscale binary resolution

Tailscale detection is hardcoded to Unix paths and /usr/bin/which, so check_tailscale_available will incorrectly return false on Windows even when Tailscale is installed. This makes the new Tailscale toggle effectively unusable on supported Windows environments.

Useful? React with 👍 / 👎.

@farion1231
Copy link
Copy Markdown
Owner

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

Claude finished @farion1231's task in 4m 0s —— View job


Claude PR Review

Three blocking issues and one nit. The async fixes from the previous round look correct; new findings are about double-broadcast wiring, a cross-platform regression, and an SSE message sent for a no-op.


🔴 Important — Duplicate SSE broadcast on tray-clicked provider switch

File: src-tauri/src/tray.rs:288-294 (combined with src-tauri/src/commands/provider.rs:111-118) · confidence 95

handle_provider_click calls crate::commands::switch_provider(...) (line 258), which now spawns its own broadcast_provider_switch. Then handle_provider_click spawns it again at line 292. Every tap on a tray-menu provider therefore fires two identical {"type":"switch", ...} SSE events. All connected browsers will receive duplicate notifications and render the toast/refresh twice.

The same was already noted by Codex; flagging because it's still present in 136daa5. Fix: remove the broadcast block at tray.rs:288-294 (the one in commands::switch_provider already covers both the frontend-invoked and tray-invoked paths).

Fix this →


🔴 Important — Tailscale detection unusable on Windows

File: src-tauri/src/remote/mod.rs:58-89 · confidence 95

find_tailscale_binary() only probes Unix paths (/usr/local/bin/tailscale, /opt/homebrew/bin/tailscale, /usr/bin/tailscale) and falls back to /usr/bin/which. On Windows, none of these exist, so is_tailscale_available() always returns false — the Tailscale toggle in RemoteSettings.tsx will refuse to enable even when C:\Program Files\Tailscale\tailscale.exe is installed and reachable. README/release workflow both list Windows as a supported target, so this is a cross-platform regression for a documented feature.

Suggested fix: gate the Unix paths behind #[cfg(unix)], add cfg(windows) candidates such as C:\Program Files\Tailscale\tailscale.exe and C:\Program Files (x86)\Tailscale\tailscale.exe, and use where instead of /usr/bin/which on Windows (or just Command::new("tailscale") and let the OS resolve via PATH).

Fix this →


🔴 Important — Spurious "switched to empty provider" SSE on failover-disable

File: src-tauri/src/commands/failover.rs:126-170 · confidence 90

When set_auto_failover_enabled is called with enabled=false, p1_provider_id is set to String::new() (line 127) and no actual provider switch happens (the "switch to P1" block at line 148 is gated on if enabled). However, the broadcast spawn at line 163-170 runs unconditionally, sending an SSE event with provider_id: "", name: "" to every connected browser:

{"type":"switch","provider_id":"","name":""}

Browsers will surface this as a switch toast for an empty provider. Move the broadcast inside the if enabled { ... } block above so it only fires when there really was a switch.

Fix this →


🟡 Nit — remoteEnabled not reverted when start RPC fails

File: src/components/settings/RemoteSettings.tsx:51-77 · confidence 80

onChange({ remoteEnabled: value }) runs before the invoke("start_remote_server", ...) await. If the start fails (port in use, EACCES on a privileged port slipped through, etc.), the persisted setting stays true while the server isn't running. On next launch lib.rs will retry and may succeed, so it self-heals — but the in-session UI is inconsistent and the toggle visually says "enabled" even though there's no listener. The catch block should revert: onChange({ remoteEnabled: !value }).

The same shape applies to the else branch (stop fails → setting flipped to disabled but server may still be running).


Notes on prior Codex findings

  • Bind rollback (Codex P1, mod.rs:175) — when the Tailscale bind fails after the localhost bind succeeds, Err is returned and the unstored RemoteServer is dropped. Because Arc<Mutex<Vec<oneshot::Sender>>> reaches refcount 0, the senders drop, the receivers fire Canceled, and the spawned tasks exit through their select! — so the listener is released. There's a small async window before the port is actually free, but I couldn't construct a concrete failure for it. Skipping.
  • Async broadcast / Tailscale-toggle restart / port-change preserve-enabled — the d80a73a fixes look correct; the previous P1/P2 issues from the first Codex pass are resolved.

  • Read CLAUDE.md and project conventions
  • Review backend (remote/*, commands/remote.rs, lib.rs)
  • Review frontend (RemoteSettings.tsx)
  • Review broadcast wiring (tray.rs, provider.rs, failover_switch.rs, commands/failover.rs)
  • Cross-platform / settings concerns
  • Post review
    · branch: feat/remote-management

Add a lightweight Axum-based HTTP server for remote provider switching
via browser/mobile. Replaces the external Python remote script to avoid
race conditions and state inconsistency.

Key features:
- Web UI at http://localhost:4000 for provider switching
- SSE events for real-time sync across multiple browser tabs
- Per-address listener management: localhost is preserved when
  Tailscale IP is toggled (no "connection lost" page)
- AtomicBool runtime gate: stopped server rejects new requests even
  on keep-alive connections
- Graceful shutdown with abort fallback for timed-out listeners
- Tailscale IP support for remote access from mobile devices

Architecture:
- remote/mod.rs: RemoteServer with HashMap<SocketAddr, ListenerEntry>
  for incremental address updates
- remote/handlers.rs: Axum handlers with running flag checks
- remote/html.rs: Embedded responsive web UI (dark mode support)
- commands/remote.rs: Tauri commands (start/stop/restart/check)
- Frontend: RemoteSettings component with health polling and restart
  flow

Bug fixes in this commit:
- health_check now returns 503 when server is stopped
- restartServer only persists settings after confirming server is up
- ManagedRemoteServer encapsulation (private field + lock() method)
- start_addrs simplified to return Vec<String> instead of Result
- Removed hardcoded "claude" in favor of REMOTE_APP constant

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@YongmaoLuo YongmaoLuo force-pushed the feat/remote-management branch from 136daa5 to a07c254 Compare May 5, 2026 03:32
@YongmaoLuo
Copy link
Copy Markdown
Author

Review 意见回复

本次提交将所有 commit 压缩为 1 个(a07c254),并针对 Codex 和 Claude 的所有 review 意见进行了修复。以下为逐项回复:


Codex Review

P1: Avoid blocking Tokio runtime in provider-switch broadcast

  • broadcast_provider_switch 已改为 async fn,内部使用 .read().await 替代 blocking_read()
  • 所有 4 个调用点(tray.rsfailover_switch.rscommands/failover.rscommands/provider.rs)均使用 tauri::async_runtime::spawn 包装异步调用
  • ✅ 已修复

P2: Restart remote server when toggling Tailscale access

  • handleTailscaleToggle 中,当服务器正在运行时调用 restartServer() 辅助函数
  • 流程:停止服务器 → 更新 Tailscale 配置 → 重新启动 → 健康检查验证
  • 重启期间 UI 显示黄色脉冲指示器,toggle 禁用避免重复触发
  • ✅ 已修复

P2: Preserve enabled state when changing remote server port

  • 端口变更不再禁用远程管理,而是保持启用并调用 restartServer() 在新端口重启
  • checkServerStatus 改为显式传入 port 参数,避免检查 stale 端口
  • ✅ 已修复

P1: Roll back already-started listeners on bind error

  • 架构重构为按地址独立管理 listener(HashMap<SocketAddr, ListenerEntry>
  • start_addrs 遍历绑定,单个地址失败仅跳过该地址,成功地址继续运行并纳入管理
  • 消除了旧架构中「部分绑定成功却无法回滚」的问题
  • ✅ 已修复(架构层面解决)

P2: Remove duplicate SSE broadcast in tray provider switch

  • 移除 tray.rs 中额外的 broadcast_provider_switch spawn
  • 仅保留 commands::switch_provider 内部的广播,确保 tray 和前端切换都走同一路径
  • ✅ 已修复

P2: Revert remoteEnabled when start/stop command fails

  • onChange({ remoteEnabled: value }) 移至 RPC 调用成功后执行
  • 失败时仅 toast 报错,不修改持久化设置,避免状态与实际不一致
  • ✅ 已修复

P2: Use cross-platform Tailscale binary resolution

  • 移除硬编码 Unix 路径和 /usr/bin/which
  • 改为 Command::new("tailscale") 依赖系统 PATH 解析,支持 Windows/macOS/Linux
  • ✅ 已修复

Claude Review

Duplicate SSE broadcast on tray-clicked provider switch

  • 同 Codex P2「Remove duplicate SSE broadcast」条目,已移除 tray.rs 中重复广播
  • ✅ 已修复

Tailscale detection unusable on Windows

  • 同 Codex P2「Use cross-platform Tailscale binary resolution」条目,已改为 PATH 依赖解析
  • ✅ 已修复

Spurious "switched to empty provider" SSE on failover-disable

  • commands/failover.rs 中广播已移入 if enabled { ... } 块内
  • 禁用 failover 时 p1_provider_id 为空字符串,不再触发广播
  • ✅ 已修复

Nit: remoteEnabled not reverted when start RPC fails

  • 同 Codex P2「Revert remoteEnabled when start/stop command fails」条目,设置持久化已移至 RPC 成功后
  • ✅ 已修复

额外修复(squash 过程中发现)

问题 修复方式
impl IntoResponse 混合返回类型编译错误 所有分支统一添加 .into_response()
ManagedRemoteServer.inner()tauri::State::inner() 冲突 重命名为 lock()
compute_target_addrs 误提取为自由函数 恢复为 impl RemoteServer 关联函数
stop_addrs 超时后未 abort 任务 添加 abort_handle.abort()
RemoteSettings.tsx 重启成功后 updateSettings() 执行过早 移至 checkServerStatus 确认之后
handlers.rs 返回路径缺少 503 状态码 统一添加 StatusCode::SERVICE_UNAVAILABLE

检查结果

  • cargo fmt:通过
  • cargo clippy(remote 相关文件):无错误
  • cargo test --test remote_management:3/3 通过
  • pnpm tsc --noEmit:通过

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RESTful API support for provider switching

2 participants