diff --git a/scripts/codexmonitor-tunnel.sh b/scripts/codexmonitor-tunnel.sh new file mode 100755 index 000000000..898235d24 --- /dev/null +++ b/scripts/codexmonitor-tunnel.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ───────────────────────────────────────────────────────────────────── +# CodexMonitor Daemon + Cloudflare Quick Tunnel +# Adapted from GARMR's quick-tunnel pattern for CodexMonitor's +# JSON-RPC daemon (port 4732). +# ───────────────────────────────────────────────────────────────────── + +DAEMON_PORT="${CODEX_MONITOR_PORT:-4732}" +DAEMON_TOKEN="${CODEX_MONITOR_DAEMON_TOKEN:-}" +DAEMON_DATA_DIR="${CODEX_MONITOR_DATA_DIR:-$HOME/.local/share/codex-monitor-daemon}" +CODEX_MONITOR_DIR="${CODEX_MONITOR_DIR:-$HOME/CodexMonitor}" + +STATE_DIR="${HOME}/.codexmonitor/cloudflared" +PID_FILE_TUNNEL="${STATE_DIR}/tunnel.pid" +PID_FILE_DAEMON="${STATE_DIR}/daemon.pid" +LOG_FILE_TUNNEL="${STATE_DIR}/tunnel.log" +LOG_FILE_DAEMON="${STATE_DIR}/daemon.log" +URL_FILE="${STATE_DIR}/tunnel.url" +TOKEN_FILE="${STATE_DIR}/daemon.token" +TIMEOUT_SECONDS=30 + +usage() { + cat < [options] + +Commands: + start Start the CodexMonitor daemon + Cloudflare tunnel + stop Stop both daemon and tunnel + status Show current status (daemon, tunnel, URL) + restart Stop then start + url Print just the public tunnel URL + token Print or set the daemon auth token + +Options: + --port Daemon port (default: 4732, or \$CODEX_MONITOR_PORT) + --token Auth token (default: \$CODEX_MONITOR_DAEMON_TOKEN or auto-generated) + --data-dir Daemon data directory + --codex-dir Path to CodexMonitor repo (default: ~/CodexMonitor) + --no-tunnel Start daemon only, skip Cloudflare tunnel + --help Show this help + +Environment: + CODEX_MONITOR_PORT Daemon listen port (default 4732) + CODEX_MONITOR_DAEMON_TOKEN Auth token + CODEX_MONITOR_DATA_DIR Daemon data dir + CODEX_MONITOR_DIR CodexMonitor repo path +USAGE +} + +# ── Flags ────────────────────────────────────────────────────────── +command="${1:-help}" +shift 2>/dev/null || true +NO_TUNNEL=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --port) DAEMON_PORT="${2:-}"; shift 2 ;; + --token) DAEMON_TOKEN="${2:-}"; shift 2 ;; + --data-dir) DAEMON_DATA_DIR="${2:-}"; shift 2 ;; + --codex-dir) CODEX_MONITOR_DIR="${2:-}"; shift 2 ;; + --no-tunnel) NO_TUNNEL=1; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +mkdir -p "$STATE_DIR" +mkdir -p "$DAEMON_DATA_DIR" + +# ── Helpers ──────────────────────────────────────────────────────── + +is_daemon_running() { + [[ -f "$PID_FILE_DAEMON" ]] && kill -0 "$(cat "$PID_FILE_DAEMON")" >/dev/null 2>&1 +} + +is_tunnel_running() { + [[ -f "$PID_FILE_TUNNEL" ]] && kill -0 "$(cat "$PID_FILE_TUNNEL")" >/dev/null 2>&1 +} + +extract_url() { + if [[ -f "$LOG_FILE_TUNNEL" ]]; then + grep -Eo 'https://[-a-z0-9]+\.trycloudflare\.com' "$LOG_FILE_TUNNEL" | tail -1 || true + fi +} + +ensure_token() { + if [[ -z "$DAEMON_TOKEN" ]]; then + if [[ -f "$TOKEN_FILE" ]]; then + DAEMON_TOKEN="$(cat "$TOKEN_FILE")" + else + DAEMON_TOKEN="$(openssl rand -hex 24)" + echo "$DAEMON_TOKEN" > "$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + echo "Generated new auth token (saved to $TOKEN_FILE)" + fi + else + echo "$DAEMON_TOKEN" > "$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + fi +} + +require_cloudflared() { + if ! command -v cloudflared >/dev/null 2>&1; then + echo "Error: cloudflared is not installed." >&2 + echo "Install with: brew install cloudflare/cloudflare/cloudflared" >&2 + exit 1 + fi +} + +find_daemon_binary() { + # Check for pre-built release binary first + local release_bin="${CODEX_MONITOR_DIR}/src-tauri/target/release/codex_monitor_daemon" + local debug_bin="${CODEX_MONITOR_DIR}/src-tauri/target/debug/codex_monitor_daemon" + + if [[ -x "$release_bin" ]]; then + echo "$release_bin" + elif [[ -x "$debug_bin" ]]; then + echo "$debug_bin" + else + echo "" + fi +} + +# ── Start ────────────────────────────────────────────────────────── + +do_start() { + ensure_token + + # 1. Start daemon + if is_daemon_running; then + echo "Daemon already running (pid $(cat "$PID_FILE_DAEMON"))" + else + local daemon_bin + daemon_bin="$(find_daemon_binary)" + + if [[ -z "$daemon_bin" ]]; then + echo "Daemon binary not found. Building..." >&2 + (cd "${CODEX_MONITOR_DIR}/src-tauri" && cargo build --bin codex_monitor_daemon 2>&1 | tail -5) + daemon_bin="$(find_daemon_binary)" + if [[ -z "$daemon_bin" ]]; then + echo "Error: Failed to build daemon binary." >&2 + exit 1 + fi + fi + + echo "Starting CodexMonitor daemon on 127.0.0.1:${DAEMON_PORT}..." + nohup "$daemon_bin" \ + --listen "127.0.0.1:${DAEMON_PORT}" \ + --data-dir "$DAEMON_DATA_DIR" \ + --token "$DAEMON_TOKEN" \ + >"$LOG_FILE_DAEMON" 2>&1 & + echo $! > "$PID_FILE_DAEMON" + + sleep 1 + if ! is_daemon_running; then + echo "Error: Daemon failed to start. Check log: $LOG_FILE_DAEMON" >&2 + tail -20 "$LOG_FILE_DAEMON" >&2 || true + rm -f "$PID_FILE_DAEMON" + exit 1 + fi + echo "Daemon started (pid $(cat "$PID_FILE_DAEMON"))" + fi + + # 2. Start tunnel + if [[ "$NO_TUNNEL" -eq 1 ]]; then + echo "Skipping Cloudflare tunnel (--no-tunnel)" + do_print_connection_info + return + fi + + require_cloudflared + + if is_tunnel_running; then + echo "Tunnel already running (pid $(cat "$PID_FILE_TUNNEL"))" + do_print_connection_info + return + fi + + : > "$LOG_FILE_TUNNEL" + rm -f "$URL_FILE" + + echo "Starting Cloudflare Quick Tunnel -> 127.0.0.1:${DAEMON_PORT}..." + nohup cloudflared tunnel --no-autoupdate --url "http://127.0.0.1:${DAEMON_PORT}" \ + >"$LOG_FILE_TUNNEL" 2>&1 & + echo $! > "$PID_FILE_TUNNEL" + + # Wait for URL + local url="" + local waited=0 + while [[ $waited -lt $TIMEOUT_SECONDS ]]; do + if ! kill -0 "$(cat "$PID_FILE_TUNNEL")" >/dev/null 2>&1; then + echo "Tunnel exited early. Check log: $LOG_FILE_TUNNEL" >&2 + tail -20 "$LOG_FILE_TUNNEL" >&2 || true + rm -f "$PID_FILE_TUNNEL" + exit 1 + fi + + url="$(extract_url)" + if [[ -n "$url" ]]; then + echo "$url" > "$URL_FILE" + # Wait for DNS propagation + local hostname="${url#https://}" + local dns_wait=0 + while [[ $dns_wait -lt 15 ]]; do + if host "$hostname" >/dev/null 2>&1; then + break + fi + sleep 1 + dns_wait=$((dns_wait + 1)) + done + break + fi + + sleep 1 + waited=$((waited + 1)) + done + + if [[ -z "$url" ]]; then + echo "Tunnel started but URL not discovered yet." + echo "Run: $(basename "$0") status" + fi + + do_print_connection_info +} + +# ── Stop ─────────────────────────────────────────────────────────── + +do_stop() { + local stopped=0 + + if is_tunnel_running; then + local pid="$(cat "$PID_FILE_TUNNEL")" + kill "$pid" 2>/dev/null || true + sleep 1 + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true + rm -f "$PID_FILE_TUNNEL" + echo "Tunnel stopped." + stopped=1 + fi + + if is_daemon_running; then + local pid="$(cat "$PID_FILE_DAEMON")" + kill "$pid" 2>/dev/null || true + sleep 1 + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true + rm -f "$PID_FILE_DAEMON" + echo "Daemon stopped." + stopped=1 + fi + + if [[ "$stopped" -eq 0 ]]; then + echo "Nothing running." + fi +} + +# ── Status ───────────────────────────────────────────────────────── + +do_status() { + echo "═══ CodexMonitor Remote Access ═══" + echo "" + + if is_daemon_running; then + echo " Daemon: RUNNING (pid $(cat "$PID_FILE_DAEMON"), port $DAEMON_PORT)" + else + echo " Daemon: STOPPED" + fi + + if is_tunnel_running; then + echo " Tunnel: RUNNING (pid $(cat "$PID_FILE_TUNNEL"))" + else + echo " Tunnel: STOPPED" + fi + + local url="" + [[ -f "$URL_FILE" ]] && url="$(cat "$URL_FILE" 2>/dev/null || true)" + [[ -z "$url" ]] && url="$(extract_url)" + + if [[ -n "$url" ]]; then + if is_tunnel_running; then + echo " URL: $url" + else + echo " URL: $url (inactive)" + fi + else + echo " URL: not available" + fi + + if [[ -f "$TOKEN_FILE" ]]; then + echo " Token: $(cat "$TOKEN_FILE" | head -c 8)..." + fi + + echo "" + echo " Logs: $LOG_FILE_DAEMON" + echo " $LOG_FILE_TUNNEL" +} + +# ── Connection info ──────────────────────────────────────────────── + +do_print_connection_info() { + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + local url="" + [[ -f "$URL_FILE" ]] && url="$(cat "$URL_FILE" 2>/dev/null || true)" + + if [[ -n "$url" ]]; then + echo " Public URL: $url" + fi + echo " Local: 127.0.0.1:${DAEMON_PORT}" + echo " Token: $(cat "$TOKEN_FILE")" + echo "" + echo " iOS Setup:" + echo " 1. Open CodexMonitor on iPhone" + echo " 2. Settings > Server" + if [[ -n "$url" ]]; then + echo " 3. Host: ${url#https://}:443" + else + echo " 3. Host: :${DAEMON_PORT}" + fi + echo " 4. Token: paste the token above" + echo " 5. Tap Connect & Test" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +} + +do_url() { + local url="" + [[ -f "$URL_FILE" ]] && url="$(cat "$URL_FILE" 2>/dev/null || true)" + [[ -z "$url" ]] && url="$(extract_url)" + if [[ -n "$url" ]]; then + echo "$url" + else + echo "No tunnel URL available." >&2 + exit 1 + fi +} + +do_token() { + ensure_token + echo "$DAEMON_TOKEN" +} + +# ── Dispatch ─────────────────────────────────────────────────────── + +case "$command" in + start) do_start ;; + stop) do_stop ;; + restart) do_stop; sleep 1; do_start ;; + status) do_status ;; + url) do_url ;; + token) do_token ;; + help|--help|-h) usage ;; + *) + echo "Unknown command: $command" >&2 + usage + exit 1 + ;; +esac diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 59bfc00bc..7cff8096d 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -25,6 +25,8 @@ mod shared; mod storage; #[path = "codex_monitor_daemon/transport.rs"] mod transport; +#[path = "codex_monitor_daemon/ws_transport.rs"] +mod ws_transport; #[allow(dead_code)] #[path = "../types.rs"] mod types; @@ -71,7 +73,7 @@ use std::path::PathBuf; use std::sync::Arc; use ignore::WalkBuilder; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, mpsc, Mutex, Semaphore}; @@ -1500,7 +1502,7 @@ fn usage() -> String { format!( "\ USAGE:\n codex-monitor-daemon [--listen ] [--data-dir ] [--token | --insecure-no-auth]\n\n\ -OPTIONS:\n --listen Bind address (default: {DEFAULT_LISTEN_ADDR})\n --data-dir Data dir holding workspaces.json/settings.json\n --token Shared token required by TCP clients\n --insecure-no-auth Disable TCP auth (dev only)\n -h, --help Show this help\n" +OPTIONS:\n --listen Bind address (default: {DEFAULT_LISTEN_ADDR})\n --data-dir Data dir holding workspaces.json/settings.json\n --token Shared token required by clients\n --insecure-no-auth Disable auth (dev only)\n -h, --help Show this help\n" ) } @@ -1940,7 +1942,7 @@ fn main() { } }; eprintln!( - "codex-monitor-daemon listening on {} (data dir: {})", + "codex-monitor-daemon listening on {} (tcp+ws, data dir: {})", config.listen, state .storage_path @@ -1956,7 +1958,32 @@ fn main() { let state = Arc::clone(&state); let events = events_tx.clone(); tokio::spawn(async move { - transport::handle_client(socket, config, state, events).await; + // Peek at first bytes to detect HTTP (WebSocket upgrade) vs raw TCP JSON + let mut peek_buf = [0u8; 4]; + match socket.peek(&mut peek_buf).await { + Ok(n) if n >= 3 => { + let is_http = peek_buf.starts_with(b"GET") + || peek_buf.starts_with(b"get"); + if is_http { + ws_transport::handle_ws_client( + socket, config, state, events, + ) + .await; + } else { + transport::handle_client( + socket, config, state, events, + ) + .await; + } + } + _ => { + // Fallback to TCP on peek failure + transport::handle_client( + socket, config, state, events, + ) + .await; + } + } }); } Err(_) => continue, diff --git a/src-tauri/src/bin/codex_monitor_daemon/ws_transport.rs b/src-tauri/src/bin/codex_monitor_daemon/ws_transport.rs new file mode 100644 index 000000000..46dfe756f --- /dev/null +++ b/src-tauri/src/bin/codex_monitor_daemon/ws_transport.rs @@ -0,0 +1,128 @@ +use futures_util::{SinkExt, StreamExt}; +use tokio::net::TcpStream; +use tokio_tungstenite::{accept_async, WebSocketStream}; +use tokio_tungstenite::tungstenite::Message; +use tokio::sync::{broadcast, mpsc, Semaphore}; +use serde_json::{json, Value}; +use std::sync::Arc; + +use super::rpc::{ + build_error_response, build_result_response, forward_events, parse_auth_token, + spawn_rpc_response_task, +}; +use super::*; + +pub(super) async fn handle_ws_client( + socket: TcpStream, + config: Arc, + state: Arc, + events: broadcast::Sender, +) { + let ws_stream = match accept_async(socket).await { + Ok(ws) => ws, + Err(err) => { + eprintln!("daemon: websocket handshake failed: {err}"); + return; + } + }; + + let (mut ws_writer, mut ws_reader) = ws_stream.split(); + + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let write_task = tokio::spawn(async move { + while let Some(message) = out_rx.recv().await { + if ws_writer.send(Message::Text(message.into())).await.is_err() { + break; + } + } + let _ = ws_writer.close().await; + }); + + let mut authenticated = config.token.is_none(); + let mut events_task: Option> = None; + let request_limiter = Arc::new(Semaphore::new(MAX_IN_FLIGHT_RPC_PER_CONNECTION)); + let client_version = format!("daemon-{}", env!("CARGO_PKG_VERSION")); + + if authenticated { + let rx = events.subscribe(); + let out_tx_events = out_tx.clone(); + events_task = Some(tokio::spawn(forward_events(rx, out_tx_events))); + } + + while let Some(msg_result) = ws_reader.next().await { + let msg = match msg_result { + Ok(msg) => msg, + Err(_) => break, + }; + + let line = match msg { + Message::Text(text) => text, + Message::Close(_) => break, + Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => continue, + Message::Binary(_) => continue, + }; + + let line = line.trim(); + if line.is_empty() { + continue; + } + + let message: Value = match serde_json::from_str(line) { + Ok(value) => value, + Err(_) => continue, + }; + + let id = message.get("id").and_then(|value| value.as_u64()); + let method = message + .get("method") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + let params = message.get("params").cloned().unwrap_or(Value::Null); + + if !authenticated { + if method != "auth" { + if let Some(response) = build_error_response(id, "unauthorized") { + let _ = out_tx.send(response); + } + continue; + } + + let expected = config.token.clone().unwrap_or_default(); + let provided = parse_auth_token(¶ms).unwrap_or_default(); + if expected != provided { + if let Some(response) = build_error_response(id, "invalid token") { + let _ = out_tx.send(response); + } + continue; + } + + authenticated = true; + if let Some(response) = build_result_response(id, json!({ "ok": true })) { + let _ = out_tx.send(response); + } + + let rx = events.subscribe(); + let out_tx_events = out_tx.clone(); + events_task = Some(tokio::spawn(forward_events(rx, out_tx_events))); + + continue; + } + + spawn_rpc_response_task( + Arc::clone(&state), + out_tx.clone(), + id, + method, + params, + client_version.clone(), + Arc::clone(&request_limiter), + ); + } + + drop(out_tx); + if let Some(task) = events_task { + task.abort(); + } + write_task.abort(); +} diff --git a/src-tauri/src/remote_backend/mod.rs b/src-tauri/src/remote_backend/mod.rs index 5cdb56d7c..f36159157 100644 --- a/src-tauri/src/remote_backend/mod.rs +++ b/src-tauri/src/remote_backend/mod.rs @@ -1,6 +1,7 @@ mod protocol; mod tcp_transport; mod transport; +mod ws_transport; use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -17,6 +18,7 @@ use crate::types::BackendMode; use self::protocol::{build_request_line, DEFAULT_REMOTE_HOST, DISCONNECTED_MESSAGE}; use self::tcp_transport::TcpTransport; use self::transport::{PendingMap, RemoteTransport, RemoteTransportConfig, RemoteTransportKind}; +use self::ws_transport::WebSocketTransport; const REMOTE_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); const REMOTE_SEND_TIMEOUT: Duration = Duration::from_secs(15); @@ -201,6 +203,7 @@ async fn ensure_remote_backend(state: &AppState, app: AppHandle) -> Result = match transport_config.kind() { RemoteTransportKind::Tcp => Box::new(TcpTransport), + RemoteTransportKind::WebSocket => Box::new(WebSocketTransport), }; let connection = transport.connect(app, transport_config).await?; @@ -213,13 +216,11 @@ async fn ensure_remote_backend(state: &AppState, app: AppHandle) -> Result TransportFuture { Box::pin(async move { - let RemoteTransportConfig::Tcp { host, .. } = config; + let RemoteTransportConfig::Tcp { host, .. } = config else { + return Err("expected TCP transport config".to_string()); + }; let stream = TcpStream::connect(host.clone()) .await diff --git a/src-tauri/src/remote_backend/transport.rs b/src-tauri/src/remote_backend/transport.rs index 57e7a4cbf..20b71297e 100644 --- a/src-tauri/src/remote_backend/transport.rs +++ b/src-tauri/src/remote_backend/transport.rs @@ -20,23 +20,30 @@ pub(crate) enum RemoteTransportConfig { host: String, auth_token: Option, }, + WebSocket { + url: String, + auth_token: Option, + }, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub(crate) enum RemoteTransportKind { Tcp, + WebSocket, } impl RemoteTransportConfig { pub(crate) fn kind(&self) -> RemoteTransportKind { match self { RemoteTransportConfig::Tcp { .. } => RemoteTransportKind::Tcp, + RemoteTransportConfig::WebSocket { .. } => RemoteTransportKind::WebSocket, } } pub(crate) fn auth_token(&self) -> Option<&str> { match self { RemoteTransportConfig::Tcp { auth_token, .. } => auth_token.as_deref(), + RemoteTransportConfig::WebSocket { auth_token, .. } => auth_token.as_deref(), } } } diff --git a/src-tauri/src/remote_backend/ws_transport.rs b/src-tauri/src/remote_backend/ws_transport.rs new file mode 100644 index 000000000..17c5fe0c2 --- /dev/null +++ b/src-tauri/src/remote_backend/ws_transport.rs @@ -0,0 +1,85 @@ +use futures_util::{SinkExt, StreamExt}; +use tauri::AppHandle; +use tokio::sync::{mpsc, Mutex}; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use super::transport::{ + PendingMap, RemoteTransport, RemoteTransportConfig, TransportConnection, TransportFuture, + dispatch_incoming_line, mark_disconnected, +}; + +const OUTBOUND_QUEUE_CAPACITY: usize = 512; + +pub(crate) struct WebSocketTransport; + +impl RemoteTransport for WebSocketTransport { + fn connect(&self, app: AppHandle, config: RemoteTransportConfig) -> TransportFuture { + Box::pin(async move { + let RemoteTransportConfig::WebSocket { url, .. } = config else { + return Err("expected WebSocket transport config".to_string()); + }; + + let (ws_stream, _response) = connect_async(&url) + .await + .map_err(|err| format!("Failed to connect via WebSocket to {url}: {err}"))?; + + let (mut ws_writer, mut ws_reader) = ws_stream.split(); + + let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_QUEUE_CAPACITY); + let pending = Arc::new(Mutex::new(PendingMap::new())); + let connected = Arc::new(AtomicBool::new(true)); + + let pending_for_writer = Arc::clone(&pending); + let connected_for_writer = Arc::clone(&connected); + + // Write loop: send outbound messages as WebSocket text frames + tokio::spawn(async move { + while let Some(message) = out_rx.recv().await { + if ws_writer.send(Message::Text(message.into())).await.is_err() { + mark_disconnected(&pending_for_writer, &connected_for_writer).await; + break; + } + } + let _ = ws_writer.close().await; + }); + + let pending_for_reader = Arc::clone(&pending); + let connected_for_reader = Arc::clone(&connected); + + // Read loop: receive WebSocket messages and dispatch + tokio::spawn(async move { + while let Some(msg_result) = ws_reader.next().await { + let msg = match msg_result { + Ok(msg) => msg, + Err(_) => break, + }; + + let line = match msg { + Message::Text(text) => text, + Message::Close(_) => break, + Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => continue, + Message::Binary(_) => continue, + }; + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + dispatch_incoming_line(&app, &pending_for_reader, trimmed).await; + } + + mark_disconnected(&pending_for_reader, &connected_for_reader).await; + }); + + Ok(TransportConnection { + out_tx, + pending, + connected, + }) + }) + } +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 6b595106b..9ed6e3d1e 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -667,6 +667,7 @@ impl Default for BackendMode { #[serde(rename_all = "lowercase")] pub(crate) enum RemoteBackendProvider { Tcp, + WebSocket, } impl Default for RemoteBackendProvider { diff --git a/src/features/settings/components/sections/SettingsServerSection.tsx b/src/features/settings/components/sections/SettingsServerSection.tsx index 7a1139d6b..33cd2f795 100644 --- a/src/features/settings/components/sections/SettingsServerSection.tsx +++ b/src/features/settings/components/sections/SettingsServerSection.tsx @@ -420,6 +420,47 @@ export function SettingsServerSection({ )} + {/* Cloudflare Tunnel helper (non-mobile only) */} + {!isMobileSimplified && ( + <> +
+
Cloudflare Tunnel
+
+ Connect from anywhere using a Cloudflare Quick Tunnel. No VPN needed — + the daemon auto-detects WebSocket connections on the same port. +
+
+ +
+
+ To expose the daemon via Cloudflare Tunnel, run on your desktop: +
+
+                cloudflared tunnel --url http://127.0.0.1:4732
+              
+
+ Then paste the generated *.trycloudflare.com URL as the + remote host with a wss:// prefix. + Example: wss://abc-xyz.trycloudflare.com +
+
+ + )} + + {!isMobileSimplified && (
Mobile access daemon
diff --git a/src/types.ts b/src/types.ts index 51b1515c9..8122fce8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -196,7 +196,7 @@ export type PullRequestSelectionRange = { export type AccessMode = "read-only" | "current" | "full-access"; export type ServiceTier = "fast" | "flex"; export type BackendMode = "local" | "remote"; -export type RemoteBackendProvider = "tcp"; +export type RemoteBackendProvider = "tcp" | "websocket"; export type RemoteBackendTarget = { id: string; name: string;