From b44475a3b69ebee91b4f020142e19e916bee4157 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 11:33:36 +0530 Subject: [PATCH 01/14] feat(share): add Public URLs Rust client, auth, and agent Introduce the Tauri share module: API client, loopback OAuth + keychain session, relay WebSocket agent with localhost proxy, and IPC commands for CRUD, tickets, and login cancel. --- src-tauri/Cargo.lock | 64 ++++ src-tauri/Cargo.toml | 3 + src-tauri/src/lib.rs | 20 + src-tauri/src/share/agent.rs | 626 ++++++++++++++++++++++++++++++++ src-tauri/src/share/api.rs | 352 ++++++++++++++++++ src-tauri/src/share/auth.rs | 445 +++++++++++++++++++++++ src-tauri/src/share/commands.rs | 221 +++++++++++ src-tauri/src/share/config.rs | 54 +++ src-tauri/src/share/mod.rs | 58 +++ src-tauri/src/share/protocol.rs | 143 ++++++++ src-tauri/src/share/proxy.rs | 466 ++++++++++++++++++++++++ src-tauri/src/share/stream.rs | 59 +++ 12 files changed, 2511 insertions(+) create mode 100644 src-tauri/src/share/agent.rs create mode 100644 src-tauri/src/share/api.rs create mode 100644 src-tauri/src/share/auth.rs create mode 100644 src-tauri/src/share/commands.rs create mode 100644 src-tauri/src/share/config.rs create mode 100644 src-tauri/src/share/mod.rs create mode 100644 src-tauri/src/share/protocol.rs create mode 100644 src-tauri/src/share/proxy.rs create mode 100644 src-tauri/src/share/stream.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4d7cdf4..1c21e31 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4230,6 +4230,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -4250,6 +4260,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -4268,6 +4288,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -6247,6 +6276,20 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6471,6 +6514,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.5", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -8212,8 +8273,10 @@ dependencies = [ "argon2", "async-trait", "base64 0.21.7", + "bytes", "chacha20poly1305", "dirs 5.0.1", + "futures-util", "hkdf 0.13.0", "hmac 0.13.0", "keyring", @@ -8240,6 +8303,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tokio", + "tokio-tungstenite", "url", "uuid", "whoami", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ab57a3..2edd8c3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,9 @@ url = "2.5" regex = "1.12.3" tauri-plugin-clipboard-manager = "2.3.2" reqwest = { version = "0.12", features = ["json", "stream", "multipart"] } +tokio-tungstenite = { version = "0.26", features = ["native-tls"] } +futures-util = { version = "0.3", features = ["sink"] } +bytes = "1" zip = "2.2" log = "0.4" # Vault crypto (Phase 0) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d64e716..d57daba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,7 @@ mod tunnels; pub use tunnels::{remote_forward_map_key, tunnel_runtime_id, TunnelManager}; mod types; mod utils; +mod share; mod vault; use commands::AppState; @@ -64,6 +65,7 @@ pub fn run() { let data_dir = commands::get_data_dir(&app_handle); let app_state = AppState::new(data_dir.clone(), app_handle.clone()); app.manage(app_state); + app.manage(share::ShareState::new(app_handle.clone(), data_dir.clone())); app.manage(tokio::sync::Mutex::new(vault::store::VaultService::new( data_dir, ))); @@ -92,6 +94,12 @@ pub fn run() { } }); } + if let Some(share) = window.try_state::() { + let agents = share.agents.clone(); + tauri::async_runtime::block_on(async move { + agents.stop_all().await; + }); + } api.prevent_close(); let _ = window.emit("app:request-close", ()); } @@ -300,6 +308,18 @@ pub fn run() { sync::commands::sync_restore_preview, sync::commands::sync_restore_credentials, sync::commands::sync_download, + share::share_status, + share::share_auth_peek, + share::share_login, + share::share_login_cancel, + share::share_logout, + share::share_list, + share::share_create, + share::share_stop, + share::share_start, + share::share_delete, + share::share_agent_start, + share::share_agent_stop, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/share/agent.rs b/src-tauri/src/share/agent.rs new file mode 100644 index 0000000..919d2a6 --- /dev/null +++ b/src-tauri/src/share/agent.rs @@ -0,0 +1,626 @@ +use super::api::{ApiClient, ShareRecord}; +use super::auth::AuthStore; +use super::config::{to_ws_url, ShareConfig}; +use super::protocol::{ + self, DataMsg, Envelope, ErrorMsg, Hello, OkMsg, Open, TYPE_CLOSE, TYPE_DATA, TYPE_ERROR, + TYPE_OPEN, TYPE_PING, +}; +use super::proxy::handle_open; +use super::stream::Stream; +use base64::Engine; +use futures_util::{SinkExt, StreamExt}; +use serde::Serialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; +use tokio::sync::{mpsc, Mutex}; +use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; +use tokio_tungstenite::tungstenite::Message; + +const WS_MAX_MESSAGE_BYTES: usize = 1 << 20; +const INITIAL_BACKOFF: Duration = Duration::from_secs(1); +const MAX_BACKOFF: Duration = Duration::from_secs(30); +const STABLE_SESSION: Duration = Duration::from_secs(30); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentConnState { + Offline, + Connecting, + Online, + Reconnecting, + AuthFailed, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AgentSnapshot { + pub share_id: String, + pub slug: String, + pub status: AgentConnState, + pub target_port: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +struct AgentSlot { + cancel: Arc, + handle: JoinHandle<()>, +} + +pub struct AgentManager { + app: AppHandle, + data_dir: PathBuf, + config: ShareConfig, + auth: Arc, + statuses: Arc>>, + slots: Mutex>, +} + +impl AgentManager { + pub fn new( + app: AppHandle, + data_dir: PathBuf, + config: ShareConfig, + auth: Arc, + ) -> Self { + Self { + app, + data_dir, + config, + auth, + statuses: Arc::new(Mutex::new(HashMap::new())), + slots: Mutex::new(HashMap::new()), + } + } + + pub async fn snapshots(&self) -> Vec { + self.statuses.lock().await.values().cloned().collect() + } + + pub async fn start(&self, share: ShareRecord) { + self.stop(&share.id).await; + let cancel = Arc::new(AtomicBool::new(false)); + let snapshot = AgentSnapshot { + share_id: share.id.clone(), + slug: share.slug.clone(), + status: AgentConnState::Connecting, + target_port: share.target_port, + error: None, + }; + self.statuses + .lock() + .await + .insert(share.id.clone(), snapshot.clone()); + let app = self.app.clone(); + let data_dir = self.data_dir.clone(); + let config = self.config.clone(); + let auth = self.auth.clone(); + let statuses = self.statuses.clone(); + let cancel_task = cancel.clone(); + let share_id = share.id.clone(); + let handle = tokio::spawn(async move { + run_agent_loop(app, data_dir, config, auth, statuses, share, cancel_task).await; + }); + self.slots.lock().await.insert( + share_id, + AgentSlot { cancel, handle }, + ); + self.emit().await; + } + + pub async fn stop(&self, share_id: &str) { + let slot = self.slots.lock().await.remove(share_id); + if let Some(slot) = slot { + slot.cancel.store(true, Ordering::Relaxed); + slot.handle.abort(); + } + self.statuses.lock().await.remove(share_id); + self.emit().await; + } + + pub async fn stop_all(&self) { + let mut slots = self.slots.lock().await; + for (_, slot) in slots.drain() { + slot.cancel.store(true, Ordering::Relaxed); + slot.handle.abort(); + } + self.statuses.lock().await.clear(); + drop(slots); + self.emit().await; + } + + async fn emit(&self) { + let agents = self.snapshots().await; + let _ = self.app.emit("share://agent-status", agents); + } +} + +#[derive(Serialize, Deserialize)] +struct ResumeFile { + relay_url: String, + slug: String, + session_id: Option, + resume_token: Option, +} + +use serde::Deserialize; + +async fn run_agent_loop( + app: AppHandle, + data_dir: PathBuf, + config: ShareConfig, + auth: Arc, + statuses: Arc>>, + share: ShareRecord, + cancel: Arc, +) { + let resume_path = resume_path(&data_dir, &share.id); + let mut saved = load_resume(&resume_path); + let mut backoff = INITIAL_BACKOFF; + if !is_loopback_host(&share.target_host) { + emit_status( + &app, + &statuses, + &share, + AgentConnState::Offline, + Some("only localhost targets are allowed from this device".into()), + ) + .await; + return; + } + let target = format!("http://{}:{}", share.target_host, share.target_port); + let mut ticket: Option = None; + + while !cancel.load(Ordering::Relaxed) { + emit_status( + &app, + &statuses, + &share, + if saved.as_ref().and_then(|s| s.resume_token.as_ref()).is_some() { + AgentConnState::Reconnecting + } else { + AgentConnState::Connecting + }, + None, + ) + .await; + if ticket.is_none() { + match mint_ticket(&config, &auth, &share.id).await { + Ok(t) => ticket = Some(t), + Err(e) => { + if saved.as_ref().and_then(|s| s.resume_token.as_ref()).is_none() { + emit_status(&app, &statuses, &share, AgentConnState::AuthFailed, Some(e)).await; + return; + } + } + } + } + + let started = Instant::now(); + let on_online = { + let app = app.clone(); + let statuses = statuses.clone(); + let share = share.clone(); + move || { + let app = app.clone(); + let statuses = statuses.clone(); + let share = share.clone(); + tokio::spawn(async move { + emit_status(&app, &statuses, &share, AgentConnState::Online, None).await; + }); + } + }; + let result = run_session( + &config.relay_url, + ticket.clone(), + saved.as_ref().and_then(|s| s.resume_token.clone()), + saved.as_ref().and_then(|s| s.session_id.clone()), + &target, + cancel.clone(), + on_online, + ) + .await; + + match result { + SessionResult::Ok(ok) => { + saved = Some(ResumeFile { + relay_url: config.relay_url.clone(), + slug: ok.slug, + session_id: ok.session_id, + resume_token: ok.resume_token, + }); + save_resume(&resume_path, saved.as_ref().unwrap()); + } + SessionResult::ShareStopped => { + clear_resume(&resume_path); + emit_status(&app, &statuses, &share, AgentConnState::Offline, Some("stopped".into())).await; + return; + } + SessionResult::AuthFailed => { + if saved.as_ref().and_then(|s| s.resume_token.as_ref()).is_some() { + saved = None; + clear_resume(&resume_path); + ticket = None; + // Fall through to backoff before retrying with the API ticket. + } else { + match mint_ticket(&config, &auth, &share.id).await { + Ok(t) => { + ticket = Some(t); + // Fall through to backoff so auth failures are rate-limited. + } + Err(e) => { + emit_status(&app, &statuses, &share, AgentConnState::AuthFailed, Some(e)) + .await; + return; + } + } + } + } + SessionResult::Err(msg) => { + log::warn!( + "share agent session error share={} slug={}: {msg}", + share.id, + share.slug + ); + } + } + + if cancel.load(Ordering::Relaxed) { + break; + } + if started.elapsed() >= STABLE_SESSION { + backoff = INITIAL_BACKOFF; + } else { + backoff = (backoff * 2).min(MAX_BACKOFF); + } + emit_status(&app, &statuses, &share, AgentConnState::Reconnecting, None).await; + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = wait_cancel(&cancel) => break, + } + } + emit_status(&app, &statuses, &share, AgentConnState::Offline, None).await; +} + +async fn emit_status( + app: &AppHandle, + statuses: &Mutex>, + share: &ShareRecord, + status: AgentConnState, + error: Option, +) { + let snap = AgentSnapshot { + share_id: share.id.clone(), + slug: share.slug.clone(), + status, + target_port: share.target_port, + error, + }; + statuses.lock().await.insert(share.id.clone(), snap.clone()); + let all: Vec = statuses.lock().await.values().cloned().collect(); + let _ = app.emit("share://agent-status", all); + let _ = snap; +} + +async fn wait_cancel(cancel: &Arc) { + while !cancel.load(Ordering::Relaxed) { + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +async fn mint_ticket( + config: &ShareConfig, + auth: &AuthStore, + share_id: &str, +) -> Result { + let access = auth.ensure_access_token(config).await?; + let client = ApiClient::new(config.clone()); + let ticket = client.create_ticket(&access, share_id).await?; + Ok(ticket.token) +} + +enum SessionResult { + Ok(OkMsg), + ShareStopped, + AuthFailed, + Err(String), +} + +async fn run_session( + relay_url: &str, + ticket: Option, + resume_token: Option, + session_id: Option, + target: &str, + cancel: Arc, + on_online: impl FnOnce(), +) -> SessionResult { + let ws_url = match to_ws_url(&format!("{relay_url}/agent")) { + Ok(u) => u, + Err(e) => return SessionResult::Err(e), + }; + let mut request = match ws_url.into_client_request() { + Ok(r) => r, + Err(e) => return SessionResult::Err(e.to_string()), + }; + if let Some(t) = &ticket { + if let Ok(value) = format!("Bearer {t}").parse() { + request.headers_mut().insert("Authorization", value); + } + } + let mut ws_config = WebSocketConfig::default(); + ws_config.max_message_size = Some(WS_MAX_MESSAGE_BYTES); + ws_config.max_frame_size = Some(WS_MAX_MESSAGE_BYTES); + + let connect = tokio_tungstenite::connect_async_with_config(request, Some(ws_config), false); + let (ws, _) = match tokio::time::timeout(HANDSHAKE_TIMEOUT, connect).await { + Ok(Ok(pair)) => pair, + Ok(Err(e)) => { + let msg = e.to_string(); + if is_auth_failed(&msg) { + return SessionResult::AuthFailed; + } + if is_share_stopped(&msg) { + return SessionResult::ShareStopped; + } + return SessionResult::Err(msg); + } + Err(_) => return SessionResult::Err("handshake timeout".into()), + }; + + let (mut sink, mut stream) = ws.split(); + let hello = Hello::new(ticket, resume_token, session_id); + let hello_json = match serde_json::to_string(&hello) { + Ok(s) => s, + Err(e) => return SessionResult::Err(e.to_string()), + }; + if sink.send(Message::Text(hello_json.into())).await.is_err() { + return SessionResult::Err("hello send failed".into()); + } + + let first = match tokio::time::timeout(HANDSHAKE_TIMEOUT, stream.next()).await { + Ok(Some(Ok(Message::Text(t)))) => t.to_string(), + Ok(Some(Ok(Message::Binary(b)))) => String::from_utf8_lossy(&b).into_owned(), + Ok(Some(Ok(Message::Close(frame)))) => { + let msg = frame + .as_ref() + .map(|f| f.reason.to_string()) + .unwrap_or_default(); + if is_share_stopped(&msg) { + return SessionResult::ShareStopped; + } + if is_auth_failed(&msg) { + return SessionResult::AuthFailed; + } + return SessionResult::Err(msg); + } + Ok(Some(Err(e))) => { + let msg = e.to_string(); + if is_share_stopped(&msg) { + return SessionResult::ShareStopped; + } + if is_auth_failed(&msg) { + return SessionResult::AuthFailed; + } + return SessionResult::Err(msg); + } + _ => return SessionResult::Err("handshake failed".into()), + }; + + let env: Envelope = match serde_json::from_str(&first) { + Ok(v) => v, + Err(e) => return SessionResult::Err(e.to_string()), + }; + if env.kind == TYPE_ERROR { + let er: ErrorMsg = serde_json::from_str(&first).unwrap_or(ErrorMsg { + code: String::new(), + message: first.clone(), + }); + if er.code == "share_stopped" || is_share_stopped(&er.message) { + return SessionResult::ShareStopped; + } + if er.code == "auth_failed" || is_auth_failed(&er.message) { + return SessionResult::AuthFailed; + } + return SessionResult::Err(format!("{} {}", er.code, er.message)); + } + if env.kind != protocol::TYPE_OK { + return SessionResult::Err(format!("expected ok, got {}", env.kind)); + } + let ok: OkMsg = match serde_json::from_str(&first) { + Ok(v) => v, + Err(e) => return SessionResult::Err(e.to_string()), + }; + on_online(); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + let write = { + let tx = tx.clone(); + move |v: serde_json::Value| { + tx.send(v).map_err(|_| "agent write closed".to_string()) + } + }; + let streams = Arc::new(Mutex::new(HashMap::::new())); + let target = target.to_string(); + + let writer_task = async { + while let Some(value) = rx.recv().await { + let payload = match serde_json::to_string(&value) { + Ok(s) => s, + Err(_) => continue, + }; + if sink.send(Message::Text(payload.into())).await.is_err() { + break; + } + } + }; + + let reader_task = async { + while let Some(msg) = stream.next().await { + if cancel.load(Ordering::Relaxed) { + break; + } + let data = match msg { + Ok(Message::Text(t)) => t.to_string(), + Ok(Message::Binary(b)) => String::from_utf8_lossy(&b).into_owned(), + Ok(Message::Ping(_)) | Ok(Message::Pong(_)) | Ok(Message::Frame(_)) => continue, + Ok(Message::Close(frame)) => { + let reason = frame + .as_ref() + .map(|f| f.reason.to_string()) + .unwrap_or_default(); + if is_share_stopped(&reason) { + return SessionResult::ShareStopped; + } + if is_auth_failed(&reason) { + return SessionResult::AuthFailed; + } + break; + } + Err(e) => { + let msg = e.to_string(); + if is_share_stopped(&msg) { + return SessionResult::ShareStopped; + } + if is_auth_failed(&msg) { + return SessionResult::AuthFailed; + } + break; + } + }; + let env: Envelope = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => continue, + }; + match env.kind.as_str() { + TYPE_PING => { + let _ = tx.send(serde_json::json!({ "type": "pong" })); + } + TYPE_OPEN => { + let open: Open = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => continue, + }; + let (st, readers) = Stream::new(open.stream_id); + streams.lock().await.insert(open.stream_id, st); + let write = write.clone(); + let target = target.clone(); + let streams = streams.clone(); + let stream_id = open.stream_id; + tokio::spawn(async move { + handle_open(&target, open, readers, write).await; + streams.lock().await.remove(&stream_id); + }); + } + TYPE_DATA => { + let msg: DataMsg = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => continue, + }; + let raw = match base64::engine::general_purpose::STANDARD.decode(&msg.chunk) { + Ok(b) => b, + Err(_) => continue, + }; + let sender = { + let guard = streams.lock().await; + guard.get(&msg.stream_id).map(|st| st.data_sender()) + }; + if let Some(tx) = sender { + let _ = tx.send(bytes::Bytes::from(raw)).await; + } + } + TYPE_CLOSE => { + let msg: protocol::Close = match serde_json::from_str(&data) { + Ok(v) => v, + Err(_) => continue, + }; + if let Some(st) = streams.lock().await.get_mut(&msg.stream_id) { + let _ = st.finish_request_body(); + } + } + _ => {} + } + } + SessionResult::Ok(OkMsg { + slug: String::new(), + session_id: None, + resume_token: None, + }) + }; + + tokio::select! { + _ = writer_task => SessionResult::Err("writer ended".into()), + result = reader_task => { + if let SessionResult::Ok(_) = &result { + SessionResult::Ok(ok) + } else { + result + } + } + _ = wait_cancel(&cancel) => SessionResult::Ok(ok), + } +} + +fn resume_path(data_dir: &Path, share_id: &str) -> PathBuf { + data_dir.join("share-agent").join(format!("{share_id}.json")) +} + +fn load_resume(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() +} + +fn save_resume(path: &Path, file: &ResumeFile) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(raw) = serde_json::to_string_pretty(file) { + let _ = std::fs::write(path, raw); + } +} + +fn clear_resume(path: &Path) { + let _ = std::fs::remove_file(path); +} + +fn is_auth_failed(msg: &str) -> bool { + let m = msg.to_ascii_lowercase(); + m.contains("auth_failed") || m.contains("auth failed") +} + +fn is_share_stopped(msg: &str) -> bool { + let m = msg.to_ascii_lowercase(); + m.contains("share_stopped") || m.contains("share stopped") +} + +fn is_loopback_host(host: &str) -> bool { + let host = host.trim().trim_matches(|c| c == '[' || c == ']'); + if host.eq_ignore_ascii_case("localhost") { + return true; + } + match host.parse::() { + Ok(ip) => ip.is_loopback(), + Err(_) => false, + } +} + +#[cfg(test)] +mod loopback_tests { + use super::is_loopback_host; + + #[test] + fn accepts_loopback_only() { + assert!(is_loopback_host("127.0.0.1")); + assert!(is_loopback_host("localhost")); + assert!(is_loopback_host("::1")); + assert!(is_loopback_host("[::1]")); + assert!(!is_loopback_host("example.com")); + assert!(!is_loopback_host("8.8.8.8")); + } +} diff --git a/src-tauri/src/share/api.rs b/src-tauri/src/share/api.rs new file mode 100644 index 0000000..23b6483 --- /dev/null +++ b/src-tauri/src/share/api.rs @@ -0,0 +1,352 @@ +use super::config::ShareConfig; +use super::err; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +pub struct SessionTokens { + pub access_token: String, + pub refresh_token: String, + #[serde(default)] + pub expires_in: u64, +} + +#[derive(Debug, Deserialize)] +struct OAuthStartResponse { + pub url: String, +} + +#[derive(Debug, Clone)] +pub struct OAuthStart { + pub url: String, +} + +#[derive(Debug, Deserialize)] +struct ApiErrorBody { + error: ApiErrorDetail, +} + +#[derive(Debug, Deserialize)] +struct ApiErrorDetail { + code: String, + message: String, +} + +#[derive(Debug, Deserialize)] +struct MeRaw { + user: Option, + entitlement: Option, +} + +#[derive(Debug, Deserialize)] +struct MeUser { + id: Option, + email: Option, + #[serde(default)] + avatar_url: Option, +} + +#[derive(Debug, Deserialize)] +struct MeEntitlement { + max_active_shares: Option, +} + +#[derive(Debug, Clone)] +pub struct MeResponse { + pub email: Option, + pub user_id: Option, + pub avatar_url: Option, + pub quota_max: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShareRecord { + pub id: String, + pub slug: String, + pub status: String, + pub target_kind: String, + pub target_host: String, + pub target_port: i32, + #[serde(default)] + pub public_url: Option, + #[serde(default)] + pub public_host: Option, + #[serde(default)] + pub has_password: bool, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} + +#[derive(Debug, Deserialize)] +struct ListSharesResponse { + shares: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct TicketResponse { + pub token: String, + /// Present on the API response; reserved for future expiry UX. + #[serde(default)] + #[allow(dead_code)] + pub expires_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateShareBody { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub target_kind: String, + pub target_host: String, + pub target_port: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub basic_auth_password: Option, +} + +pub struct ApiClient { + config: ShareConfig, + http: reqwest::Client, +} + +impl ApiClient { + pub fn new(config: ShareConfig) -> Self { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { config, http } + } + + pub async fn oauth_start( + &self, + provider: &str, + redirect_uri: &str, + device_name: &str, + ) -> Result { + let url = format!( + "{}/v1/auth/{}/start?redirect_uri={}&device_name={}", + self.config.api_base, + provider, + urlencoding(redirect_uri), + urlencoding(device_name) + ); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| err("oauth_start_failed", e.to_string()))?; + if !resp.status().is_success() { + return Err(read_error(resp).await); + } + let body: OAuthStartResponse = resp + .json() + .await + .map_err(|e| err("oauth_start_failed", e.to_string()))?; + Ok(OAuthStart { url: body.url }) + } + + pub async fn exchange_code(&self, code: &str) -> Result { + self.post_json( + "/v1/auth/exchange", + &serde_json::json!({ "code": code }), + None, + ) + .await + } + + pub async fn refresh(&self, refresh_token: &str) -> Result { + self.post_json( + "/v1/auth/refresh", + &serde_json::json!({ "refresh_token": refresh_token }), + None, + ) + .await + } + + pub async fn logout(&self, refresh_token: &str) -> Result<(), String> { + let _ = self + .http + .post(self.config.api_url("/v1/auth/logout")) + .json(&serde_json::json!({ "refresh_token": refresh_token })) + .send() + .await; + Ok(()) + } + + pub async fn me(&self, access_token: &str) -> Result { + let resp = self + .http + .get(self.config.api_url("/v1/me")) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| err("me_failed", e.to_string()))?; + if !resp.status().is_success() { + return Err(read_error(resp).await); + } + let raw: MeRaw = resp + .json() + .await + .map_err(|e| err("me_failed", e.to_string()))?; + Ok(MeResponse { + email: raw.user.as_ref().and_then(|u| u.email.clone()), + user_id: raw.user.as_ref().and_then(|u| u.id.clone()), + avatar_url: raw + .user + .as_ref() + .and_then(|u| u.avatar_url.clone()) + .filter(|s| !s.trim().is_empty()), + quota_max: normalize_quota_max( + raw.entitlement.and_then(|e| e.max_active_shares), + ), + }) + } + + pub async fn list_shares(&self, access_token: &str) -> Result, String> { + let resp = self + .http + .get(self.config.api_url("/v1/shares")) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| err("shares_list_failed", e.to_string()))?; + if !resp.status().is_success() { + return Err(read_error(resp).await); + } + let body: ListSharesResponse = resp + .json() + .await + .map_err(|e| err("shares_list_failed", e.to_string()))?; + Ok(body.shares) + } + + pub async fn create_share( + &self, + access_token: &str, + body: &CreateShareBody, + ) -> Result { + self.post_json("/v1/shares", body, Some(access_token)) + .await + } + + pub async fn stop_share(&self, access_token: &str, id: &str) -> Result { + self.post_json( + &format!("/v1/shares/{id}/stop"), + &serde_json::json!({}), + Some(access_token), + ) + .await + } + + pub async fn start_share(&self, access_token: &str, id: &str) -> Result { + self.post_json( + &format!("/v1/shares/{id}/start"), + &serde_json::json!({}), + Some(access_token), + ) + .await + } + + pub async fn delete_share(&self, access_token: &str, id: &str) -> Result { + let resp = self + .http + .delete(self.config.api_url(&format!("/v1/shares/{id}"))) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| err("share_delete_failed", e.to_string()))?; + if !resp.status().is_success() { + return Err(read_error(resp).await); + } + resp.json() + .await + .map_err(|e| err("share_delete_failed", e.to_string())) + } + + pub async fn create_ticket( + &self, + access_token: &str, + id: &str, + ) -> Result { + self.post_json( + &format!("/v1/shares/{id}/ticket"), + &serde_json::json!({}), + Some(access_token), + ) + .await + } + + async fn post_json( + &self, + path: &str, + body: &B, + access_token: Option<&str>, + ) -> Result { + let mut req = self + .http + .post(self.config.api_url(path)) + .json(body); + if let Some(token) = access_token { + req = req.bearer_auth(token); + } + let resp = req + .send() + .await + .map_err(|e| err("request_failed", e.to_string()))?; + if !resp.status().is_success() { + return Err(read_error(resp).await); + } + resp.json() + .await + .map_err(|e| err("invalid_json", e.to_string())) + } +} + +async fn read_error(resp: reqwest::Response) -> String { + let status = resp.status(); + if let Ok(body) = resp.json::().await { + let message = body.error.message.trim(); + let message = if message.is_empty() && body.error.code == "share_quota_exceeded" { + "3 of 3 Public URLs in use. Delete one to add another.".to_string() + } else if message.is_empty() { + body.error.code.clone() + } else { + message.to_string() + }; + return err(&body.error.code, message); + } + err( + "http_error", + format!("Public URLs request failed ({status})"), + ) +} + +fn urlencoding(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +/// Beta default is 3. Legacy single-slot default (1) remaps to 3. +/// 0 is preserved so a revoked entitlement stays disabled. +pub fn normalize_quota_max(max: Option) -> i32 { + match max { + Some(0) => 0, + Some(1) => 3, + Some(n) if n > 1 => n, + _ => 3, + } +} + +#[cfg(test)] +mod tests { + use super::normalize_quota_max; + + #[test] + fn old_single_slot_default_is_beta_three() { + assert_eq!(normalize_quota_max(Some(1)), 3); + assert_eq!(normalize_quota_max(Some(0)), 0); + assert_eq!(normalize_quota_max(None), 3); + assert_eq!(normalize_quota_max(Some(3)), 3); + assert_eq!(normalize_quota_max(Some(10)), 10); + } +} diff --git a/src-tauri/src/share/auth.rs b/src-tauri/src/share/auth.rs new file mode 100644 index 0000000..2bb64ab --- /dev/null +++ b/src-tauri/src/share/auth.rs @@ -0,0 +1,445 @@ +use super::api::{ApiClient, MeResponse, SessionTokens}; +use super::config::ShareConfig; +use super::err; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tauri::AppHandle; +use tauri_plugin_opener::OpenerExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener as TokioTcpListener; +use tokio::sync::Mutex as AsyncMutex; + +const KEYRING_SERVICE: &str = "Zync Public URLs"; +const KEYRING_ACCOUNT: &str = "refresh-token"; +const ACCOUNT_FILE: &str = "share-account.json"; +const LOOPBACK_PORTS: std::ops::Range = 7457..7500; +const OAUTH_TIMEOUT: Duration = Duration::from_secs(300); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShareAccountFile { + #[serde(default)] + pub email: Option, + #[serde(default)] + pub user_id: Option, + #[serde(default)] + pub avatar_url: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ShareAuthStatus { + pub signed_in: bool, + pub email: Option, + pub user_id: Option, + pub avatar_url: Option, + pub quota_max: i32, +} + +#[derive(Clone)] +struct LiveSession { + access_token: String, + refresh_token: String, + expires_at: Instant, + email: Option, + user_id: Option, + avatar_url: Option, + quota_max: i32, +} + +pub struct AuthStore { + data_dir: PathBuf, + live: Mutex>, + login_cancel: Mutex>>, + refresh_lock: AsyncMutex<()>, +} + +impl AuthStore { + pub fn new(data_dir: PathBuf) -> Self { + Self { + data_dir, + live: Mutex::new(None), + login_cancel: Mutex::new(None), + refresh_lock: AsyncMutex::new(()), + } + } + + /// Abort an in-flight browser OAuth wait so the UI can retry. + pub fn cancel_login(&self) -> bool { + let Ok(mut guard) = self.login_cancel.lock() else { + return false; + }; + if let Some(tx) = guard.take() { + let _ = tx.send(true); + true + } else { + false + } + } + + fn arm_login_cancel(&self) -> Result, String> { + let (tx, rx) = tokio::sync::watch::channel(false); + let mut guard = self + .login_cancel + .lock() + .map_err(|_| err("share_auth", "lock"))?; + if let Some(prev) = guard.take() { + let _ = prev.send(true); + } + *guard = Some(tx); + Ok(rx) + } + + fn clear_login_cancel(&self) { + if let Ok(mut guard) = self.login_cancel.lock() { + let _ = guard.take(); + } + } + + fn account_path(&self) -> PathBuf { + self.data_dir.join(ACCOUNT_FILE) + } + + pub fn cached_status(&self) -> ShareAuthStatus { + if let Some(live) = self.live.lock().ok().and_then(|g| g.clone()) { + return ShareAuthStatus { + signed_in: true, + email: live.email, + user_id: live.user_id, + avatar_url: live.avatar_url, + quota_max: live.quota_max, + }; + } + let file = read_account_file(&self.account_path()); + let signed_in = refresh_token_entry() + .ok() + .and_then(|e| e.get_password().ok()) + .is_some_and(|p| !p.is_empty()); + ShareAuthStatus { + signed_in, + email: file.as_ref().and_then(|a| a.email.clone()), + user_id: file.as_ref().and_then(|a| a.user_id.clone()), + avatar_url: file.as_ref().and_then(|a| a.avatar_url.clone()), + quota_max: 3, + } + } + + pub async fn ensure_access_token(&self, config: &ShareConfig) -> Result { + { + let guard = self.live.lock().map_err(|_| err("share_auth", "lock"))?; + if let Some(live) = guard.as_ref() { + if Instant::now() + Duration::from_secs(30) < live.expires_at { + return Ok(live.access_token.clone()); + } + } + } + self.refresh(config).await + } + + pub async fn restore(&self, config: &ShareConfig) -> Result { + match self.refresh(config).await { + Ok(_) => Ok(self.cached_status()), + Err(e) => { + if e.contains("invalid_refresh") + || e.contains("not_signed_in") + || e.contains("no_refresh_token") + { + Ok(ShareAuthStatus { + signed_in: false, + email: None, + user_id: None, + avatar_url: None, + quota_max: 3, + }) + } else { + Err(e) + } + } + } + } + + pub async fn login( + &self, + app: &AppHandle, + config: &ShareConfig, + provider: &str, + ) -> Result { + if provider != "github" && provider != "google" { + return Err(err("invalid_provider", "Use GitHub or Google")); + } + let (port, listener) = bind_redirect_listener() + .ok_or_else(|| err("oauth_bind_failed", "Could not bind a local redirect port"))?; + let redirect_uri = format!("http://127.0.0.1:{port}/cb"); + let device_name = format!( + "Zync desktop ({})", + whoami::fallible::hostname().unwrap_or_else(|_| "this device".into()) + ); + let client = ApiClient::new(config.clone()); + let start = client + .oauth_start(provider, &redirect_uri, &device_name) + .await?; + app.opener() + .open_url(&start.url, None::) + .map_err(|e| err("oauth_browser_open_failed", e.to_string()))?; + // Keep refresh_lock released during browser wait so logout/refresh are not blocked for minutes. + let mut cancel_rx = self.arm_login_cancel()?; + let code = match wait_for_login_code(listener, &mut cancel_rx).await { + Ok(code) => { + self.clear_login_cancel(); + code + } + Err(e) => { + self.clear_login_cancel(); + return Err(e); + } + }; + let tokens = client.exchange_code(&code).await?; + // Serialize with refresh/logout so a stale refresh cannot overwrite this session. + let _guard = self.refresh_lock.lock().await; + self.apply_tokens(config, tokens).await + } + + pub async fn logout(&self, config: &ShareConfig) -> Result<(), String> { + // Hold refresh_lock for the whole clear so an in-flight refresh cannot + // re-apply tokens after live/keyring are wiped. + let _guard = self.refresh_lock.lock().await; + let refresh = { + let mut guard = self.live.lock().map_err(|_| err("share_auth", "lock"))?; + let token = guard.as_ref().map(|s| s.refresh_token.clone()); + *guard = None; + token + }; + let refresh = match refresh { + Some(t) => Some(t), + None => refresh_token_entry().ok().and_then(|e| e.get_password().ok()), + }; + let _ = delete_refresh_token(); + let _ = std::fs::remove_file(self.account_path()); + if let Some(token) = refresh { + let client = ApiClient::new(config.clone()); + let _ = client.logout(&token).await; + } + Ok(()) + } + + async fn refresh(&self, config: &ShareConfig) -> Result { + let _refresh_guard = self.refresh_lock.lock().await; + // Another task may have refreshed / logged in while we waited for the lock. + { + let guard = self.live.lock().map_err(|_| err("share_auth", "lock"))?; + if let Some(live) = guard.as_ref() { + if Instant::now() + Duration::from_secs(30) < live.expires_at { + return Ok(live.access_token.clone()); + } + } + } + let refresh = { + let guard = self.live.lock().map_err(|_| err("share_auth", "lock"))?; + if let Some(live) = guard.as_ref() { + Some(live.refresh_token.clone()) + } else { + None + } + }; + let refresh = match refresh { + Some(t) => t, + None => { + // Cold start / restore. If logout cleared state under this lock, + // keyring is already empty and this fails cleanly. + refresh_token_entry() + .ok() + .and_then(|e| e.get_password().ok()) + .filter(|t| !t.is_empty()) + .ok_or_else(|| err("not_signed_in", "Not signed in to Zync"))? + } + }; + let client = ApiClient::new(config.clone()); + let tokens = client.refresh(&refresh).await?; + let status = self.apply_tokens(config, tokens).await?; + let guard = self.live.lock().map_err(|_| err("share_auth", "lock"))?; + guard + .as_ref() + .map(|s| s.access_token.clone()) + .ok_or_else(|| err("share_auth", "session missing after refresh")) + .map(|t| { + let _ = status; + t + }) + } + + async fn apply_tokens( + &self, + config: &ShareConfig, + tokens: SessionTokens, + ) -> Result { + store_refresh_token(&tokens.refresh_token)?; + let client = ApiClient::new(config.clone()); + let me = client.me(&tokens.access_token).await.unwrap_or(MeResponse { + email: None, + user_id: None, + avatar_url: None, + quota_max: 3, + }); + // Treat missing/zero lifetime as the API default (~15m), not one second. + let lifetime = if tokens.expires_in == 0 { + 900 + } else { + tokens.expires_in + }; + let expires_in = if lifetime > 30 { + lifetime - 15 + } else { + lifetime.max(1) + }; + let live = LiveSession { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: Instant::now() + Duration::from_secs(expires_in), + email: me.email.clone(), + user_id: me.user_id.clone(), + avatar_url: me.avatar_url.clone(), + quota_max: super::api::normalize_quota_max(Some(me.quota_max)), + }; + write_account_file( + &self.account_path(), + &ShareAccountFile { + email: live.email.clone(), + user_id: live.user_id.clone(), + avatar_url: live.avatar_url.clone(), + }, + ); + let status = ShareAuthStatus { + signed_in: true, + email: live.email.clone(), + user_id: live.user_id.clone(), + avatar_url: live.avatar_url.clone(), + quota_max: live.quota_max, + }; + let mut guard = self.live.lock().map_err(|_| err("share_auth", "lock"))?; + *guard = Some(live); + Ok(status) + } +} + +fn refresh_token_entry() -> Result { + keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) + .map_err(|e| err("keyring", e.to_string())) +} + +fn store_refresh_token(token: &str) -> Result<(), String> { + refresh_token_entry()? + .set_password(token) + .map_err(|e| err("keyring", e.to_string())) +} + +fn delete_refresh_token() -> Result<(), String> { + match refresh_token_entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(err("keyring", e.to_string())), + } +} + +fn read_account_file(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() +} + +fn write_account_file(path: &Path, account: &ShareAccountFile) { + if let Ok(raw) = serde_json::to_string_pretty(account) { + let _ = std::fs::write(path, raw); + } +} + +fn bind_redirect_listener() -> Option<(u16, std::net::TcpListener)> { + for port in LOOPBACK_PORTS { + if let Ok(listener) = std::net::TcpListener::bind(("127.0.0.1", port)) { + return Some((port, listener)); + } + } + None +} + +async fn wait_for_login_code( + listener: std::net::TcpListener, + cancel_rx: &mut tokio::sync::watch::Receiver, +) -> Result { + listener + .set_nonblocking(true) + .map_err(|e| err("oauth_redirect_listener_error", e.to_string()))?; + let listener = TokioTcpListener::from_std(listener) + .map_err(|e| err("oauth_redirect_listener_error", e.to_string()))?; + + let accept = async { + tokio::time::timeout(OAUTH_TIMEOUT, listener.accept()) + .await + .map_err(|_| { + err( + "oauth_timeout", + "Sign-in timed out - no browser redirect received within 5 minutes", + ) + })? + .map_err(|e| err("oauth_redirect_listener_error", e.to_string())) + }; + + let (mut stream, _) = tokio::select! { + _ = cancel_rx.wait_for(|canceled| *canceled) => { + return Err(err("oauth_canceled", "Sign-in canceled")); + } + accepted = accept => accepted?, + }; + + let mut buf = vec![0u8; 4096]; + let n = stream + .read(&mut buf) + .await + .map_err(|e| err("oauth_redirect_parse_failed", e.to_string()))?; + let request = String::from_utf8_lossy(&buf[..n]); + let code = parse_code(&request); + let html = if code.is_some() { + SUCCESS_HTML + } else { + FAIL_HTML + }; + let _ = stream.write_all(html.as_bytes()).await; + let _ = stream.shutdown().await; + code.ok_or_else(|| { + err( + "oauth_denied", + "Sign-in was canceled or did not return a login code", + ) + }) +} + +fn parse_code(request: &str) -> Option { + let line = request.lines().next()?; + let path = line.split_whitespace().nth(1)?; + let query = path.split_once('?')?.1; + for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { + if k == "code" && !v.is_empty() { + return Some(v.into_owned()); + } + } + None +} + +const SUCCESS_HTML: &str = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n\ +\ +Zync - Signed in
\ +

Signed in to Zync

\ +

Return to Zync to continue. This is your Zync account - not Google Drive Sync.

\ +
Zync account
\ +

You can close this tab now.

\ +
"; + +const FAIL_HTML: &str = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n\ +Zync - Sign-in not completed\ +\ +

Sign-in not completed

\ +

Return to Zync and try Continue with GitHub or Google again.

"; diff --git a/src-tauri/src/share/commands.rs b/src-tauri/src/share/commands.rs new file mode 100644 index 0000000..7d33a54 --- /dev/null +++ b/src-tauri/src/share/commands.rs @@ -0,0 +1,221 @@ +use super::api::{ApiClient, CreateShareBody, ShareRecord}; +use super::auth::ShareAuthStatus; +use super::ShareState; +use serde::Serialize; +use tauri::{AppHandle, Emitter, State}; + +#[derive(Debug, Clone, Serialize)] +pub struct ShareStatusPayload { + pub auth: ShareAuthStatus, + pub shares: Vec, + pub agents: Vec, + pub quota_used: i32, + pub quota_max: i32, +} + +/// Fast local peek (keyring + account file). No network — used to avoid signed-out flash. +#[tauri::command] +pub fn share_auth_peek(state: State<'_, ShareState>) -> ShareAuthStatus { + state.auth.cached_status() +} + +#[tauri::command] +pub async fn share_status(state: State<'_, ShareState>) -> Result { + let cached = state.auth.cached_status(); + let auth = if cached.signed_in { + match state.auth.restore(&state.config).await { + Ok(status) => status, + // Transient errors (network, etc.): keep the local signed-in hint. + // restore() already returns Ok(signed-out) for invalid/missing refresh. + Err(_) => cached, + } + } else { + cached + }; + let (shares, quota_used) = if auth.signed_in { + match load_shares(&state).await { + Ok(list) => { + let used = list + .iter() + .filter(|s| { + s.status == "reserved" || s.status == "active" || s.status == "stopped" + }) + .count() as i32; + (list, used) + } + Err(_) => (Vec::new(), 0), + } + } else { + (Vec::new(), 0) + }; + Ok(ShareStatusPayload { + quota_max: super::api::normalize_quota_max(Some(auth.quota_max)), + auth, + shares, + agents: state.agents.snapshots().await, + quota_used, + }) +} + +#[tauri::command] +pub async fn share_login( + app: AppHandle, + state: State<'_, ShareState>, + provider: String, +) -> Result { + state.auth.login(&app, &state.config, &provider).await?; + let payload = share_status(state.clone()).await?; + let _ = app.emit("share://auth", &payload.auth); + Ok(payload) +} + +#[tauri::command] +pub async fn share_login_cancel(state: State<'_, ShareState>) -> Result { + Ok(state.auth.cancel_login()) +} + +#[tauri::command] +pub async fn share_logout( + app: AppHandle, + state: State<'_, ShareState>, +) -> Result { + state.agents.stop_all().await; + state.auth.logout(&state.config).await?; + let payload = ShareStatusPayload { + auth: ShareAuthStatus { + signed_in: false, + email: None, + user_id: None, + avatar_url: None, + quota_max: 3, + }, + shares: Vec::new(), + agents: Vec::new(), + quota_used: 0, + quota_max: 3, + }; + let _ = app.emit("share://auth", &payload.auth); + Ok(payload) +} + +#[tauri::command] +pub async fn share_list(state: State<'_, ShareState>) -> Result, String> { + load_shares(&state).await +} + +#[tauri::command] +pub async fn share_create( + state: State<'_, ShareState>, + port: i32, + name: Option, + password: Option, +) -> Result { + if !(1..=65535).contains(&port) { + return Err(super::err("invalid_port", "Local port must be between 1 and 65535")); + } + let access = state.auth.ensure_access_token(&state.config).await?; + let client = ApiClient::new(state.config.clone()); + let name = name.and_then(|n| { + let t = n.trim().to_string(); + if t.is_empty() { + None + } else { + Some(t) + } + }); + let password = password.and_then(|p| { + let t = p.trim().to_string(); + if t.is_empty() { + None + } else { + Some(t) + } + }); + let share = client + .create_share( + &access, + &CreateShareBody { + name, + target_kind: "localhost".into(), + target_host: "127.0.0.1".into(), + target_port: port, + basic_auth_password: password, + }, + ) + .await?; + if share.status != "stopped" && share.status != "disabled" { + state.agents.start(share.clone()).await; + } + Ok(share) +} + +#[tauri::command] +pub async fn share_stop(state: State<'_, ShareState>, id: String) -> Result { + let access = state.auth.ensure_access_token(&state.config).await?; + let client = ApiClient::new(state.config.clone()); + let share = client.stop_share(&access, &id).await?; + state.agents.stop(&id).await; + Ok(share) +} + +#[tauri::command] +pub async fn share_start(state: State<'_, ShareState>, id: String) -> Result { + let access = state.auth.ensure_access_token(&state.config).await?; + let client = ApiClient::new(state.config.clone()); + let share = client.start_share(&access, &id).await?; + state.agents.start(share.clone()).await; + Ok(share) +} + +#[tauri::command] +pub async fn share_delete(state: State<'_, ShareState>, id: String) -> Result { + let access = state.auth.ensure_access_token(&state.config).await?; + let client = ApiClient::new(state.config.clone()); + let share = client.delete_share(&access, &id).await?; + state.agents.stop(&id).await; + Ok(share) +} + +#[tauri::command] +pub async fn share_agent_start( + state: State<'_, ShareState>, + id: Option, +) -> Result<(), String> { + let shares = load_shares(&state).await?; + let targets: Vec = match id { + Some(id) => shares.into_iter().filter(|s| s.id == id).collect(), + None => shares + .into_iter() + .filter(|s| s.status == "reserved" || s.status == "active") + .collect(), + }; + if targets.is_empty() { + return Ok(()); + } + for share in targets { + if share.status == "stopped" || share.status == "disabled" || share.status == "deleted" { + continue; + } + state.agents.start(share).await; + } + Ok(()) +} + +#[tauri::command] +pub async fn share_agent_stop( + state: State<'_, ShareState>, + id: Option, +) -> Result<(), String> { + if let Some(id) = id { + state.agents.stop(&id).await; + } else { + state.agents.stop_all().await; + } + Ok(()) +} + +async fn load_shares(state: &ShareState) -> Result, String> { + let access = state.auth.ensure_access_token(&state.config).await?; + let client = ApiClient::new(state.config.clone()); + client.list_shares(&access).await +} diff --git a/src-tauri/src/share/config.rs b/src-tauri/src/share/config.rs new file mode 100644 index 0000000..f840e3e --- /dev/null +++ b/src-tauri/src/share/config.rs @@ -0,0 +1,54 @@ +#[derive(Debug, Clone)] +pub struct ShareConfig { + pub api_base: String, + pub relay_url: String, +} + +impl ShareConfig { + pub fn from_env() -> Self { + let api_base = option_env!("ZYNC_SHARE_API_BASE") + .unwrap_or("https://zync-share.thesudoer.in") + .trim_end_matches('/') + .to_string(); + let relay_url = option_env!("ZYNC_SHARE_RELAY_URL") + .unwrap_or("https://relay.thesudoer.in") + .trim_end_matches('/') + .to_string(); + Self { + api_base, + relay_url, + } + } + + pub fn api_url(&self, path: &str) -> String { + format!("{}{}", self.api_base, path) + } +} + +pub fn to_ws_url(http_url: &str) -> Result { + let mut u = url::Url::parse(http_url).map_err(|e| format!("invalid relay url: {e}"))?; + match u.scheme() { + "https" => { + let _ = u.set_scheme("wss"); + } + "http" => { + let _ = u.set_scheme("ws"); + } + "wss" | "ws" => {} + other => return Err(format!("unsupported scheme {other}")), + } + Ok(u.to_string()) +} + +#[cfg(test)] +mod tests { + use super::to_ws_url; + + #[test] + fn https_becomes_wss() { + assert_eq!( + to_ws_url("https://relay.thesudoer.in/agent").unwrap(), + "wss://relay.thesudoer.in/agent" + ); + } +} diff --git a/src-tauri/src/share/mod.rs b/src-tauri/src/share/mod.rs new file mode 100644 index 0000000..fbea9f5 --- /dev/null +++ b/src-tauri/src/share/mod.rs @@ -0,0 +1,58 @@ +//! Zync Public URLs — desktop client for the private `zync-share` SaaS. +//! +//! This is not SSH port forwarding. Protocol is mirrored from +//! `zync-share/internal/protocol`; do not import that private repo. + +mod agent; +mod api; +mod auth; +mod commands; +mod config; +mod protocol; +mod proxy; +mod stream; + +pub use commands::*; +pub use config::ShareConfig; + +use std::path::PathBuf; +use std::sync::Arc; +use tauri::AppHandle; + +use crate::share::agent::AgentManager; +use crate::share::auth::AuthStore; + +#[derive(Clone)] +pub struct ShareState { + #[allow(dead_code)] + pub app: AppHandle, + #[allow(dead_code)] + pub data_dir: PathBuf, + pub config: ShareConfig, + pub auth: Arc, + pub agents: Arc, +} + +impl ShareState { + pub fn new(app: AppHandle, data_dir: PathBuf) -> Self { + let config = ShareConfig::from_env(); + let auth = Arc::new(AuthStore::new(data_dir.clone())); + let agents = Arc::new(AgentManager::new( + app.clone(), + data_dir.clone(), + config.clone(), + auth.clone(), + )); + Self { + app, + data_dir, + config, + auth, + agents, + } + } +} + +pub fn err(code: &str, message: impl Into) -> String { + format!("[{code}] {}", message.into()) +} diff --git a/src-tauri/src/share/protocol.rs b/src-tauri/src/share/protocol.rs new file mode 100644 index 0000000..891b385 --- /dev/null +++ b/src-tauri/src/share/protocol.rs @@ -0,0 +1,143 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub const PROTOCOL_VERSION: i32 = 1; + +pub const TYPE_HELLO: &str = "hello"; +pub const TYPE_OK: &str = "ok"; +pub const TYPE_ERROR: &str = "error"; +pub const TYPE_OPEN: &str = "open"; +pub const TYPE_DATA: &str = "data"; +pub const TYPE_CLOSE: &str = "close"; +pub const TYPE_PING: &str = "ping"; + +pub const MODE_STREAM: &str = "stream"; +pub const MODE_TCP: &str = "tcp"; + +#[derive(Debug, Deserialize)] +pub struct Envelope { + #[serde(rename = "type")] + pub kind: String, +} + +#[derive(Debug, Serialize)] +pub struct Hello { + #[serde(rename = "type")] + pub kind: &'static str, + pub v: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub ticket: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resume_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +impl Hello { + pub fn new( + ticket: Option, + resume_token: Option, + session_id: Option, + ) -> Self { + Self { + kind: TYPE_HELLO, + v: PROTOCOL_VERSION, + ticket, + resume_token, + session_id, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct OkMsg { + pub slug: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub resume_token: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ErrorMsg { + #[serde(default)] + pub code: String, + #[serde(default)] + pub message: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Open { + pub stream_id: i64, + #[serde(default)] + pub method: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub query: String, + #[serde(default)] + pub headers: HashMap>, + #[serde(default)] + pub mode: String, +} + +#[derive(Debug, Deserialize)] +pub struct DataMsg { + pub stream_id: i64, + #[serde(default)] + pub chunk: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Close { + #[serde(rename = "type")] + pub kind: String, + pub stream_id: i64, +} + +pub fn is_websocket(open: &Open) -> bool { + if open.mode == MODE_STREAM { + return true; + } + header_has_token(&open.headers, "Upgrade", "websocket") +} + +pub fn header_get(headers: &HashMap>, key: &str) -> Option { + for (existing, values) in headers { + if existing.eq_ignore_ascii_case(key) { + return values.first().cloned(); + } + } + None +} + +pub fn header_has_token(headers: &HashMap>, key: &str, token: &str) -> bool { + for (existing, values) in headers { + if !existing.eq_ignore_ascii_case(key) { + continue; + } + for value in values { + for part in value.split(',') { + if part.trim().eq_ignore_ascii_case(token) { + return true; + } + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hello_uses_type_field() { + let hello = Hello::new(Some("t".into()), None, None); + let v = serde_json::to_value(&hello).unwrap(); + assert_eq!(v["type"], "hello"); + assert_eq!(v["v"], 1); + assert_eq!(v["ticket"], "t"); + assert!(v.get("resume_token").is_none()); + } +} diff --git a/src-tauri/src/share/proxy.rs b/src-tauri/src/share/proxy.rs new file mode 100644 index 0000000..a556bd2 --- /dev/null +++ b/src-tauri/src/share/proxy.rs @@ -0,0 +1,466 @@ +use super::protocol::{header_get, is_websocket, Open, MODE_TCP}; +use super::stream::{is_cancelled, StreamReaders}; +use bytes::Bytes; +use futures_util::StreamExt; +use std::collections::HashMap; +use std::sync::OnceLock; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +const CHUNK_SIZE: usize = 24 * 1024; +const DIAL_TIMEOUT: Duration = Duration::from_secs(10); + +fn http_proxy_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(DIAL_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("share http proxy client") + }) +} + +pub struct FrameWriter { + stream_id: i64, + write: Box Result<(), String> + Send + Sync>, + sent_end: bool, + sent_close: bool, +} + +impl FrameWriter { + pub fn new( + stream_id: i64, + write: impl Fn(serde_json::Value) -> Result<(), String> + Send + Sync + 'static, + ) -> Self { + Self { + stream_id, + write: Box::new(write), + sent_end: false, + sent_close: false, + } + } + + pub fn end(&mut self, status: u16, headers: HashMap>) -> Result<(), String> { + self.sent_end = true; + (self.write)(serde_json::json!({ + "type": "end", + "stream_id": self.stream_id, + "status": status, + "headers": headers, + })) + } + + pub fn data(&self, chunk: &[u8]) -> Result<(), String> { + if chunk.is_empty() { + return Ok(()); + } + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(chunk); + (self.write)(serde_json::json!({ + "type": "data", + "stream_id": self.stream_id, + "chunk": encoded, + })) + } + + pub fn close(&mut self) -> Result<(), String> { + if self.sent_close { + return Ok(()); + } + self.sent_close = true; + (self.write)(serde_json::json!({ + "type": "close", + "stream_id": self.stream_id, + })) + } + + pub fn has_sent_end(&self) -> bool { + self.sent_end + } +} + +pub async fn handle_open( + target: &str, + open: Open, + readers: StreamReaders, + write: impl Fn(serde_json::Value) -> Result<(), String> + Send + Sync + Clone + 'static, +) { + let mut w = FrameWriter::new(open.stream_id, write); + let result = if open.mode == MODE_TCP { + proxy_tcp(target, readers, &mut w).await + } else if is_websocket(&open) { + proxy_websocket(target, &open, readers, &mut w).await + } else { + proxy_http(target, &open, readers, &mut w).await + }; + if result.is_err() && !w.has_sent_end() { + let mut headers = HashMap::new(); + headers.insert("Content-Type".into(), vec!["text/plain".into()]); + headers.insert("X-Zync-Error".into(), vec!["dial".into()]); + let _ = w.end(502, headers); + let _ = w.data(b"bad gateway"); + } + let _ = w.close(); +} + +async fn proxy_http( + target: &str, + open: &Open, + readers: StreamReaders, + w: &mut FrameWriter, +) -> Result<(), String> { + let target_url = parse_target_url(target)?; + let mut req_url = target_url.clone(); + let path = if open.path.is_empty() { + "/" + } else { + open.path.as_str() + }; + req_url.set_path(path); + req_url.set_query(if open.query.is_empty() { + None + } else { + Some(open.query.as_str()) + }); + + let method = if open.method.is_empty() { + reqwest::Method::GET + } else { + reqwest::Method::from_bytes(open.method.as_bytes()) + .unwrap_or(reqwest::Method::GET) + }; + + let client = http_proxy_client(); + + let mut builder = client.request(method.clone(), req_url); + for (key, values) in &open.headers { + if hop_header(key) { + continue; + } + for value in values { + builder = builder.header(key, value); + } + } + + let host = localhost_http_host(&target_url); + builder = builder.header("Host", &host); + if is_loopback_url(&target_url) { + if header_get(&open.headers, "Origin").is_some() { + builder = builder.header("Origin", loopback_origin(target_url.scheme(), &host)); + } + if let Some(referer) = header_get(&open.headers, "Referer") { + if let Ok(mut u) = url::Url::parse(&referer) { + let _ = u.set_scheme(if target_url.scheme().is_empty() { + "http" + } else { + target_url.scheme() + }); + let _ = u.set_host(Some("localhost")); + if let Ok(port) = host.rsplit(':').next().unwrap_or("").parse::() { + let _ = u.set_port(Some(port)); + } + builder = builder.header("Referer", u.as_str()); + } + } + builder = builder.header("X-Forwarded-Host", &host); + } + + let no_body = method == reqwest::Method::GET + || method == reqwest::Method::HEAD + || method == reqwest::Method::OPTIONS; + if !no_body { + let stream = futures_util::stream::unfold(readers.req_rx, |mut rx| async move { + rx.recv().await.map(|chunk| (Ok::(chunk), rx)) + }); + builder = builder.body(reqwest::Body::wrap_stream(stream)); + } else { + drop(readers.req_rx); + } + + let resp = builder.send().await.map_err(|e| e.to_string())?; + let status = resp.status().as_u16(); + let mut headers: HashMap> = HashMap::new(); + for (key, value) in resp.headers() { + if hop_header(key.as_str()) { + continue; + } + headers + .entry(key.to_string()) + .or_default() + .push(String::from_utf8_lossy(value.as_bytes()).into_owned()); + } + w.end(status, headers)?; + + let mut body = resp.bytes_stream(); + while let Some(next) = body.next().await { + if is_cancelled(&readers.cancel_rx) { + break; + } + let chunk = next.map_err(|e| e.to_string())?; + for piece in chunk.chunks(CHUNK_SIZE) { + w.data(piece)?; + } + } + Ok(()) +} + +async fn proxy_websocket( + target: &str, + open: &Open, + mut readers: StreamReaders, + w: &mut FrameWriter, +) -> Result<(), String> { + let target_url = parse_target_url(target)?; + let addr = socket_addr(&target_url)?; + let mut stream = tokio::time::timeout(DIAL_TIMEOUT, TcpStream::connect(addr)) + .await + .map_err(|_| "dial timeout".to_string())? + .map_err(|e| e.to_string())?; + + let host = localhost_http_host(&target_url); + let path = if open.path.is_empty() { "/" } else { open.path.as_str() }; + let request_line = if open.query.is_empty() { + format!("GET {path} HTTP/1.1\r\n") + } else { + format!("GET {path}?{} HTTP/1.1\r\n", open.query) + }; + let mut req = request_line.into_bytes(); + req.extend_from_slice(format!("Host: {host}\r\n").as_bytes()); + req.extend_from_slice(b"Connection: Upgrade\r\nUpgrade: websocket\r\n"); + for (key, values) in &open.headers { + if hop_header(key) || key.eq_ignore_ascii_case("Host") || key.eq_ignore_ascii_case("Connection") { + continue; + } + if key.eq_ignore_ascii_case("Origin") && is_loopback_url(&target_url) { + req.extend_from_slice( + format!("Origin: {}\r\n", loopback_origin(target_url.scheme(), &host)).as_bytes(), + ); + continue; + } + for value in values { + req.extend_from_slice(format!("{key}: {value}\r\n").as_bytes()); + } + } + req.extend_from_slice(b"\r\n"); + stream.write_all(&req).await.map_err(|e| e.to_string())?; + + let (status, headers, leftover) = read_http_headers(&mut stream).await?; + w.end(status, headers)?; + if !leftover.is_empty() { + w.data(&leftover)?; + } + + let (mut read_half, mut write_half) = stream.into_split(); + let mut extra_rx = readers.extra_rx; + drop(readers.req_rx); + + let uplink = async { + while let Some(chunk) = extra_rx.recv().await { + if write_half.write_all(&chunk).await.is_err() { + break; + } + } + let _ = write_half.shutdown().await; + }; + let downlink = async { + let mut buf = vec![0u8; CHUNK_SIZE]; + loop { + match read_half.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if w.data(&buf[..n]).is_err() { + break; + } + } + } + } + }; + tokio::select! { + _ = uplink => {} + _ = downlink => {} + _ = readers.cancel_rx.changed() => {} + } + Ok(()) +} + +async fn proxy_tcp( + target: &str, + mut readers: StreamReaders, + w: &mut FrameWriter, +) -> Result<(), String> { + let target_url = parse_target_url(target)?; + let addr = socket_addr(&target_url)?; + let stream = tokio::time::timeout(DIAL_TIMEOUT, TcpStream::connect(addr)) + .await + .map_err(|_| "dial timeout".to_string())? + .map_err(|e| e.to_string())?; + let mut headers = HashMap::new(); + headers.insert("Content-Type".into(), vec!["application/octet-stream".into()]); + w.end(200, headers)?; + + let (mut read_half, mut write_half) = stream.into_split(); + let uplink = async { + loop { + tokio::select! { + chunk = readers.req_rx.recv() => { + match chunk { + Some(c) => { if write_half.write_all(&c).await.is_err() { break; } } + None => break, + } + } + chunk = readers.extra_rx.recv() => { + match chunk { + Some(c) => { if write_half.write_all(&c).await.is_err() { break; } } + None => break, + } + } + } + } + let _ = write_half.shutdown().await; + }; + let downlink = async { + let mut buf = vec![0u8; CHUNK_SIZE]; + loop { + match read_half.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if w.data(&buf[..n]).is_err() { + break; + } + } + } + } + }; + tokio::select! { + _ = uplink => {} + _ = downlink => {} + _ = readers.cancel_rx.changed() => {} + } + Ok(()) +} + +fn parse_target_url(target: &str) -> Result { + let raw = if target.contains("://") { + target.to_string() + } else { + format!("http://{target}") + }; + let mut u = url::Url::parse(&raw).map_err(|e| e.to_string())?; + if u.host_str().is_none() { + return Err(format!("invalid target {target}")); + } + if u.scheme().is_empty() { + let _ = u.set_scheme("http"); + } + Ok(u) +} + +fn socket_addr(target: &url::Url) -> Result { + let host = target.host_str().unwrap_or("127.0.0.1"); + let port = target + .port() + .unwrap_or(if target.scheme() == "https" { 443 } else { 80 }); + Ok(format!("{host}:{port}")) +} + +fn hop_header(key: &str) -> bool { + matches!( + key.to_ascii_lowercase().as_str(), + "host" | "content-length" | "transfer-encoding" | "connection" | "keep-alive" | "te" + | "trailers" | "upgrade" | "proxy-connection" + ) +} + +pub fn localhost_http_host(target: &url::Url) -> String { + if !is_loopback_url(target) { + return target.host_str().unwrap_or("localhost").to_string(); + } + let port = target.port().unwrap_or(if target.scheme() == "https" { + 443 + } else { + 80 + }); + if (port == 80 && (target.scheme() == "http" || target.scheme().is_empty())) + || (port == 443 && target.scheme() == "https") + { + "localhost".into() + } else { + format!("localhost:{port}") + } +} + +fn loopback_origin(scheme: &str, host: &str) -> String { + let scheme = if scheme.is_empty() { "http" } else { scheme }; + format!("{scheme}://{host}") +} + +fn is_loopback_url(target: &url::Url) -> bool { + match target.host_str() { + Some("localhost") | Some("127.0.0.1") | Some("::1") => true, + Some(host) => host.parse::().is_ok_and(|ip| ip.is_loopback()), + None => false, + } +} + +async fn read_http_headers( + stream: &mut TcpStream, +) -> Result<(u16, HashMap>, Vec), String> { + let mut buf = Vec::new(); + let mut tmp = [0u8; 1024]; + loop { + let n = stream.read(&mut tmp).await.map_err(|e| e.to_string())?; + if n == 0 { + return Err("connection closed before headers".into()); + } + buf.extend_from_slice(&tmp[..n]); + if let Some(pos) = find_double_crlf(&buf) { + let header = std::str::from_utf8(&buf[..pos]).map_err(|e| e.to_string())?; + let leftover = buf[pos + 4..].to_vec(); + return parse_status_headers(header, leftover); + } + if buf.len() > 64 * 1024 { + return Err("response headers too large".into()); + } + } +} + +fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +fn parse_status_headers( + header: &str, + leftover: Vec, +) -> Result<(u16, HashMap>, Vec), String> { + let mut lines = header.split("\r\n"); + let status_line = lines.next().ok_or("empty response")?; + let status = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(502); + let mut headers: HashMap> = HashMap::new(); + for line in lines { + if let Some((k, v)) = line.split_once(':') { + headers + .entry(k.trim().to_string()) + .or_default() + .push(v.trim().to_string()); + } + } + Ok((status, headers, leftover)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loopback_host_includes_port() { + let u = url::Url::parse("http://127.0.0.1:3000").unwrap(); + assert_eq!(localhost_http_host(&u), "localhost:3000"); + } +} diff --git a/src-tauri/src/share/stream.rs b/src-tauri/src/share/stream.rs new file mode 100644 index 0000000..77f3117 --- /dev/null +++ b/src-tauri/src/share/stream.rs @@ -0,0 +1,59 @@ +use bytes::Bytes; +use tokio::sync::mpsc; + +pub struct Stream { + req_tx: Option>, + extra_tx: mpsc::Sender, + cancel: tokio::sync::watch::Sender, +} + +pub struct StreamReaders { + pub req_rx: mpsc::Receiver, + pub extra_rx: mpsc::Receiver, + pub cancel_rx: tokio::sync::watch::Receiver, +} + +impl Stream { + pub fn new(_id: i64) -> (Self, StreamReaders) { + let (req_tx, req_rx) = mpsc::channel(32); + let (extra_tx, extra_rx) = mpsc::channel(32); + let (cancel, cancel_rx) = tokio::sync::watch::channel(false); + ( + Self { + req_tx: Some(req_tx), + extra_tx, + cancel, + }, + StreamReaders { + req_rx, + extra_rx, + cancel_rx, + }, + ) + } + + /// Clone the active uplink sender so callers can release locks before awaiting. + pub fn data_sender(&self) -> mpsc::Sender { + self.req_tx + .clone() + .unwrap_or_else(|| self.extra_tx.clone()) + } + + /// First close completes the HTTP request body. Later close aborts the stream. + pub fn finish_request_body(&mut self) -> bool { + if self.req_tx.take().is_some() { + true + } else { + self.cancel(); + false + } + } + + pub fn cancel(&self) { + let _ = self.cancel.send(true); + } +} + +pub fn is_cancelled(rx: &tokio::sync::watch::Receiver) -> bool { + *rx.borrow() +} From d826b820de2f54cce859a40e9ea32ec039fc3bf0 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 11:33:38 +0530 Subject: [PATCH 02/14] feat(share): add Public URLs panel, store, and Beta label Add the React store/IPC layer and Public URLs panel with Zync sign-in, quota UX, OAuth cancel/retry, avatar display, and Beta badge helpers. --- src/components/share/PublicUrlsLabel.tsx | 34 ++ src/components/share/PublicUrlsPanel.tsx | 600 +++++++++++++++++++++++ src/features/share/ipc.ts | 71 +++ src/features/share/types.ts | 99 ++++ src/features/share/useShareStore.ts | 276 +++++++++++ 5 files changed, 1080 insertions(+) create mode 100644 src/components/share/PublicUrlsLabel.tsx create mode 100644 src/components/share/PublicUrlsPanel.tsx create mode 100644 src/features/share/ipc.ts create mode 100644 src/features/share/types.ts create mode 100644 src/features/share/useShareStore.ts diff --git a/src/components/share/PublicUrlsLabel.tsx b/src/components/share/PublicUrlsLabel.tsx new file mode 100644 index 0000000..c59e934 --- /dev/null +++ b/src/components/share/PublicUrlsLabel.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; +import { cn } from '../../lib/utils'; + +export function BetaBadge({ className }: { className?: string }) { + return ( + + Beta + + ); +} + +/** “Public URLs” plus a Beta chip for titles and nav. */ +export function PublicUrlsLabel({ + className, + badgeClassName, + children = 'Public URLs', +}: { + className?: string; + badgeClassName?: string; + children?: ReactNode; +}) { + return ( + + {children} + + + ); +} diff --git a/src/components/share/PublicUrlsPanel.tsx b/src/components/share/PublicUrlsPanel.tsx new file mode 100644 index 0000000..f74930d --- /dev/null +++ b/src/components/share/PublicUrlsPanel.tsx @@ -0,0 +1,600 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Copy, + ExternalLink, + Github, + Globe, + Link2, + Play, + Plus, + RefreshCw, + Square, + Trash2, +} from 'lucide-react'; +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; +import { openUrl } from '@tauri-apps/plugin-opener'; +import { Button } from '../ui/Button'; +import { Input } from '../ui/Input'; +import { Modal } from '../ui/Modal'; +import { ConfirmModal } from '../ui/ConfirmModal'; +import { cn } from '../../lib/utils'; +import { GoogleMarkIcon } from '../icons/providerIcons'; +import { useAppStore } from '../../store/useAppStore'; +import { + agentLine, + quotaFull, + SHARE_QUOTA_FULL_MESSAGE, + useShareStore, +} from '../../features/share/useShareStore'; +import { + shareChip, + shareDisplayUrl, + type ShareChip, + type ShareProvider, + type ShareRecord, +} from '../../features/share/types'; +import { parseShareError } from '../../features/share/ipc'; +import { PublicUrlsLabel } from './PublicUrlsLabel'; + +const CHIP_STYLE: Record = { + online: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-300', + offline: 'border-app-border/50 bg-app-surface text-app-muted', + stopped: 'border-amber-400/25 bg-amber-400/10 text-amber-300', +}; + +const CHIP_LABEL: Record = { + online: 'Online', + offline: 'Offline', + stopped: 'Stopped', +}; + +export function PublicUrlsPanel() { + const showToast = useAppStore((s) => s.showToast); + const hydrate = useShareStore((s) => s.hydrate); + const login = useShareStore((s) => s.login); + const cancelLogin = useShareStore((s) => s.cancelLogin); + const signingIn = useShareStore((s) => s.signingIn); + const logout = useShareStore((s) => s.logout); + const create = useShareStore((s) => s.create); + const stop = useShareStore((s) => s.stop); + const start = useShareStore((s) => s.start); + const remove = useShareStore((s) => s.remove); + const startSharing = useShareStore((s) => s.startSharing); + const auth = useShareStore((s) => s.auth); + const shares = useShareStore((s) => s.shares); + const agents = useShareStore((s) => s.agents); + const hydrated = useShareStore((s) => s.hydrated); + const loading = useShareStore((s) => s.loading); + const busy = useShareStore((s) => s.busy); + const error = useShareStore((s) => s.error); + const quotaUsed = useShareStore((s) => s.quotaUsed); + const quotaMax = useShareStore((s) => s.quotaMax); + + const [createOpen, setCreateOpen] = useState(false); + const [deleteId, setDeleteId] = useState(null); + const [port, setPort] = useState('3000'); + const [name, setName] = useState(''); + const [requirePassword, setRequirePassword] = useState(false); + const [password, setPassword] = useState(''); + const [createdId, setCreatedId] = useState(null); + const [lastSignInAttempt, setLastSignInAttempt] = useState(null); + + useEffect(() => { + void hydrate(); + }, [hydrate]); + + const full = quotaFull({ quotaUsed, quotaMax, shares }); + const line = useMemo(() => agentLine(agents, shares), [agents, shares]); + const deleting = shares.find((s) => s.id === deleteId) || null; + const retryProvider = signingIn ?? lastSignInAttempt; + + const handleLogin = async (provider: ShareProvider) => { + setLastSignInAttempt(provider); + try { + await login(provider); + setLastSignInAttempt(null); + showToast('success', 'Signed in to Zync'); + } catch (err) { + const parsed = parseShareError(err); + if (parsed.code === 'oauth_canceled') { + setLastSignInAttempt(null); + showToast('info', 'Sign-in canceled'); + return; + } + // Keep lastSignInAttempt so Retry stays available after a real failure. + showToast('error', parsed.message); + } + }; + + const handleCancelLogin = async () => { + await cancelLogin(); + setLastSignInAttempt(null); + showToast('info', 'Sign-in canceled'); + }; + + const handleRetryLogin = async () => { + const provider = retryProvider; + if (!provider) return; + if (signingIn) { + await cancelLogin(); + } + await handleLogin(provider); + }; + + const handleLogout = async () => { + try { + await logout(); + showToast('success', 'Signed out of Zync'); + } catch (err) { + showToast('error', parseShareError(err).message); + } + }; + + const handleCreate = async () => { + const parsed = Number(port); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + showToast('error', 'Local port must be between 1 and 65535'); + return; + } + if (requirePassword && !password.trim()) { + showToast('error', 'Enter a visitor password, or turn off Require a password'); + return; + } + try { + const share = await create( + parsed, + name.trim() || undefined, + requirePassword ? password.trim() : undefined, + ); + setCreateOpen(false); + setName(''); + setPassword(''); + setRequirePassword(false); + setCreatedId(share.id); + showToast('success', 'Public URL ready'); + } catch (err) { + showToast('error', parseShareError(err).message); + } + }; + + const handleCopy = async (url: string) => { + try { + await writeText(url); + showToast('success', 'Copied Public URL'); + } catch { + showToast('error', 'Could not copy URL'); + } + }; + + const handleOpen = async (url: string) => { + try { + await openUrl(url); + } catch { + showToast('error', 'Could not open URL'); + } + }; + + if (!hydrated) { + if (auth.signed_in) { + return ( +
+
+
+ {auth.avatar_url ? ( + + ) : null} +
+
+

+ Zync · {auth.email || 'Signed in'} +

+
+
+
+
+

Loading Public URLs…

+
+
+ ); + } + return ( +
+
+
+

Loading…

+
+
+ ); + } + + if (!auth.signed_in) { + return ( +
+
+
+

+ Put a local port on the internet with an HTTPS link. Distinct from SSH port forwarding. +

+
+

Sign in to Zync

+

+ Public URLs uses your Zync account (GitHub or Google). This is separate from Google Drive Sync and will not merge with Connect Google Sync. +

+
+
+ {signingIn ? ( + <> +

+ Waiting for {signingIn === 'github' ? 'GitHub' : 'Google'} in your browser… + Close the tab or finish there, then Cancel or Retry here. +

+ + + + ) : lastSignInAttempt ? ( + <> +

+ Sign-in did not finish. Retry or pick another provider. +

+ + + + + ) : ( + <> + + + + )} +
+ {error &&

{error}

} +
+
+ ); + } + + return ( +
+
+
+
+
+ {auth.avatar_url ? ( + + ) : null} +
+
+

+ Zync · {auth.email || 'Signed in'} · Anyone with the link can reach that port while this device is sharing. +

+
+
+
+
+ + +
+
+
+

+ Beta - if you hit a bug, please{' '} + + . +

+
+ {full && ( +
+ {SHARE_QUOTA_FULL_MESSAGE} +
+ )} + {error && !full && ( +

{error}

+ )} +
+ +
+ {loading && shares.length === 0 && ( +

Loading Public URLs…

+ )} + {!loading && shares.length === 0 && ( +
+ +

No Public URLs yet.

+

+ Anyone with the link can reach that port while this device is sharing. +

+ +
+ )} + {shares.map((share) => ( + void handleCopy(url)} + onOpen={(url) => void handleOpen(url)} + onStop={() => void stop(share.id).catch((e) => showToast('error', parseShareError(e).message))} + onStart={() => void start(share.id).catch((e) => showToast('error', parseShareError(e).message))} + onDelete={() => setDeleteId(share.id)} + /> + ))} +
+ + {line.label && ( +
+

{line.label}

+ {(line.state === 'offline' || line.state === 'auth_failed') && ( + + )} +
+ )} + + setCreateOpen(false)} + title="Share a port" + subtitle="This local port is now a hostname." + width="max-w-md" + > +
+ {full && ( +

{SHARE_QUOTA_FULL_MESSAGE}

+ )} + setPort(e.target.value)} + placeholder="3000" + autoFocus + /> + setName(e.target.value)} + placeholder="Optional" + /> + + {requirePassword && ( + setPassword(e.target.value)} + /> + )} +
+ + +
+
+
+ + setDeleteId(null)} + onConfirm={() => { + if (!deleting) return; + const id = deleting.id; + setDeleteId(null); + void remove(id).catch((e) => showToast('error', parseShareError(e).message)); + }} + title="Delete Public URL" + message="This URL will stop working. You can create a new one after." + confirmLabel="Delete" + variant="danger" + /> +
+ ); +} + +function Header() { + return ( +
+ +

+ +

+
+ ); +} + +function ShareRow({ + share, + chip, + highlight, + busy, + onCopy, + onOpen, + onStop, + onStart, + onDelete, +}: { + share: ShareRecord; + chip: ShareChip; + highlight: boolean; + busy: boolean; + onCopy: (url: string) => void; + onOpen: (url: string) => void; + onStop: () => void; + onStart: () => void; + onDelete: () => void; +}) { + const url = shareDisplayUrl(share); + const copyRef = useCallback((node: HTMLButtonElement | null) => { + if (highlight && node) node.focus(); + }, [highlight]); + + return ( +
+
+
+

{url || share.slug}

+

+ localhost:{share.target_port} +

+
+ + {CHIP_LABEL[chip]} + +
+
+ + + {share.status === 'stopped' ? ( + + ) : ( + + )} + +
+ {chip === 'offline' && share.status !== 'stopped' && ( +

+ This device isn't sharing. Start sharing to bring the URL back. +

+ )} +
+ ); +} diff --git a/src/features/share/ipc.ts b/src/features/share/ipc.ts new file mode 100644 index 0000000..c4b038c --- /dev/null +++ b/src/features/share/ipc.ts @@ -0,0 +1,71 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { + AgentSnapshot, + ShareAuthStatus, + ShareProvider, + ShareRecord, + ShareStatusPayload, +} from './types'; +import { SHARE_QUOTA_FULL_MESSAGE } from './types'; + +export interface ParsedShareError { + code?: string; + message: string; +} + +const ERROR_PATTERN = /^\[(?[^\]]+)\]\s*(?[\s\S]*)$/; + +export function parseShareError(error: unknown): ParsedShareError { + let raw = ''; + if (typeof error === 'string') raw = error; + else if (error && typeof error === 'object' && 'message' in error) { + raw = String((error as { message: unknown }).message); + } else if (error != null) raw = String(error); + + const trimmed = raw.trim(); + const parsed = trimmed.match(ERROR_PATTERN); + const code = parsed?.groups?.code; + let message = parsed?.groups?.body ?? trimmed; + if (code === 'share_quota_exceeded') { + message = SHARE_QUOTA_FULL_MESSAGE; + } + return { code, message }; +} + +export const shareIpc = { + status: () => invoke('share_status'), + authPeek: () => invoke('share_auth_peek'), + login: (provider: ShareProvider) => + invoke('share_login', { provider }), + cancelLogin: () => invoke('share_login_cancel'), + logout: () => invoke('share_logout'), + list: () => invoke('share_list'), + create: (port: number, name?: string, password?: string) => + invoke('share_create', { + port, + name: name || null, + password: password || null, + }), + stop: (id: string) => invoke('share_stop', { id }), + start: (id: string) => invoke('share_start', { id }), + delete: (id: string) => invoke('share_delete', { id }), + agentStart: (id?: string) => + invoke('share_agent_start', { id: id ?? null }), + agentStop: (id?: string) => + invoke('share_agent_stop', { id: id ?? null }), +}; + +export function listenShareAuth( + handler: (auth: ShareAuthStatus) => void, +): Promise { + return listen('share://auth', (event) => handler(event.payload)); +} + +export function listenShareAgents( + handler: (agents: AgentSnapshot[]) => void, +): Promise { + return listen('share://agent-status', (event) => { + handler(Array.isArray(event.payload) ? event.payload : []); + }); +} diff --git a/src/features/share/types.ts b/src/features/share/types.ts new file mode 100644 index 0000000..5b91ec8 --- /dev/null +++ b/src/features/share/types.ts @@ -0,0 +1,99 @@ +export type ShareProvider = 'github' | 'google'; + +export type ShareStatus = 'reserved' | 'active' | 'stopped' | 'disabled' | 'deleted'; + +export type AgentConnState = 'offline' | 'connecting' | 'online' | 'reconnecting' | 'auth_failed'; + +export interface ShareAuthStatus { + signed_in: boolean; + email?: string | null; + user_id?: string | null; + avatar_url?: string | null; + quota_max: number; +} + +export interface ShareRecord { + id: string; + slug: string; + status: ShareStatus | string; + target_kind: string; + target_host: string; + target_port: number; + public_url?: string | null; + public_host?: string | null; + has_password?: boolean; + created_at?: string | null; + updated_at?: string | null; +} + +export interface AgentSnapshot { + share_id: string; + slug: string; + status: AgentConnState; + target_port: number; + error?: string | null; +} + +export interface ShareStatusPayload { + auth: ShareAuthStatus; + shares: ShareRecord[]; + agents: AgentSnapshot[]; + quota_used: number; + quota_max: number; +} + +export const SHARE_BETA_QUOTA_MAX = 3; + +export const SHARE_QUOTA_FULL_MESSAGE = + '3 of 3 Public URLs in use. Delete one to add another.'; + +const OWNED_STATUSES = new Set(['reserved', 'active', 'stopped']); + +/** Owned URLs count toward the beta cap. Disabled/deleted do not. */ +export function ownedShareCount(shares: ShareRecord[] | undefined | null): number { + if (!shares?.length) return 0; + return shares.filter((share) => OWNED_STATUSES.has(String(share.status))).length; +} + +/** + * Beta default is 3. Legacy DB default of 1 is remapped to 3. + * 0 is preserved so a revoked/disabled entitlement keeps sharing off. + */ +export function normalizeQuotaMax(max: unknown): number { + const n = typeof max === 'number' ? max : Number(max); + if (!Number.isFinite(n) || n < 0) return SHARE_BETA_QUOTA_MAX; + if (n === 1) return SHARE_BETA_QUOTA_MAX; + return n; +} + +export function quotaIsFull(used: number, max: number): boolean { + const cap = normalizeQuotaMax(max); + // Cap 0 = sharing disabled / revoked. + if (cap === 0) return true; + const owned = Number.isFinite(used) ? used : 0; + return owned >= cap; +} + +export function quotaFullMessage(used: number, max: number): string { + const cap = normalizeQuotaMax(max); + if (cap === 0) { + return 'Public URLs are disabled for this account.'; + } + if (cap === SHARE_BETA_QUOTA_MAX) return SHARE_QUOTA_FULL_MESSAGE; + return `${used} of ${cap} Public URLs in use. Delete one to add another.`; +} + +export const SHARE_AUTH_EVENT = 'share://auth'; +export const SHARE_AGENT_STATUS_EVENT = 'share://agent-status'; + +export function shareDisplayUrl(share: ShareRecord): string { + return share.public_url || (share.public_host ? `https://${share.public_host}` : ''); +} + +export type ShareChip = 'online' | 'offline' | 'stopped'; + +export function shareChip(share: ShareRecord, agent?: AgentSnapshot | null): ShareChip { + if (share.status === 'stopped') return 'stopped'; + if (agent?.status === 'online') return 'online'; + return 'offline'; +} diff --git a/src/features/share/useShareStore.ts b/src/features/share/useShareStore.ts new file mode 100644 index 0000000..3781062 --- /dev/null +++ b/src/features/share/useShareStore.ts @@ -0,0 +1,276 @@ +import { create } from 'zustand'; +import { + SHARE_QUOTA_FULL_MESSAGE, + normalizeQuotaMax, + ownedShareCount, + quotaIsFull, + type AgentConnState, + type AgentSnapshot, + type ShareAuthStatus, + type ShareProvider, + type ShareRecord, + type ShareStatusPayload, +} from './types'; +import { listenShareAgents, listenShareAuth, parseShareError, shareIpc } from './ipc'; + +export interface ShareStore { + hydrated: boolean; + loading: boolean; + busy: boolean; + signingIn: ShareProvider | null; + error: string | null; + auth: ShareAuthStatus; + shares: ShareRecord[]; + agents: Record; + quotaUsed: number; + quotaMax: number; + hydrate: () => Promise; + login: (provider: ShareProvider) => Promise; + cancelLogin: () => Promise; + logout: () => Promise; + refresh: () => Promise; + create: (port: number, name?: string, password?: string) => Promise; + stop: (id: string) => Promise; + start: (id: string) => Promise; + remove: (id: string) => Promise; + startSharing: () => Promise; +} + +const signedOutAuth: ShareAuthStatus = { + signed_in: false, + email: null, + user_id: null, + avatar_url: null, + quota_max: 3, +}; + +function applyPayload( + set: (partial: Partial) => void, + payload: ShareStatusPayload, +) { + const raw = payload as ShareStatusPayload & { + quotaUsed?: number; + quotaMax?: number; + agents?: AgentSnapshot[]; + }; + const shares = payload.shares || []; + const agentList = raw.agents || []; + const agents: Record = {}; + for (const agent of agentList) { + agents[agent.share_id] = agent; + } + const quotaMax = normalizeQuotaMax( + raw.quota_max ?? raw.quotaMax ?? payload.auth?.quota_max, + ); + const quotaUsed = ownedShareCount(shares); + set({ + hydrated: true, + loading: false, + auth: payload.auth, + shares, + agents, + quotaUsed, + quotaMax, + error: null, + }); +} + +let listenersStarted = false; + +export const useShareStore = create((set, get) => ({ + hydrated: false, + loading: false, + busy: false, + signingIn: null, + error: null, + auth: signedOutAuth, + shares: [], + agents: {}, + quotaUsed: 0, + quotaMax: 3, + + hydrate: async () => { + if (!listenersStarted) { + listenersStarted = true; + void listenShareAuth((auth) => { + set({ auth }); + }); + void listenShareAgents((incoming) => { + set((state) => { + const agents = { ...state.agents }; + for (const agent of incoming) { + agents[agent.share_id] = agent; + } + return { agents }; + }); + }); + } + if (get().loading) return; + set({ loading: true, error: null }); + try { + // Instant local session hint so the panel does not flash the signed-out screen. + try { + const peek = await shareIpc.authPeek(); + if (peek?.signed_in) { + set({ auth: peek }); + } + } catch { + // Peek is best-effort; full status still runs. + } + const payload = await shareIpc.status(); + applyPayload(set, payload); + } catch (error) { + set({ + loading: false, + hydrated: true, + error: parseShareError(error).message, + }); + } + }, + + login: async (provider) => { + set({ busy: true, signingIn: provider, error: null }); + try { + const payload = await shareIpc.login(provider); + applyPayload(set, payload); + set({ busy: false, signingIn: null }); + } catch (error) { + const parsed = parseShareError(error); + const canceled = parsed.code === 'oauth_canceled'; + set({ + busy: false, + signingIn: null, + error: canceled ? null : parsed.message, + }); + throw error; + } + }, + + cancelLogin: async () => { + try { + await shareIpc.cancelLogin(); + } catch { + // Local cancel should always unlock the UI. + } finally { + set({ busy: false, signingIn: null, error: null }); + } + }, + + logout: async () => { + set({ busy: true, error: null }); + try { + const payload = await shareIpc.logout(); + applyPayload(set, payload); + set({ busy: false }); + } catch (error) { + set({ busy: false, error: parseShareError(error).message }); + throw error; + } + }, + + refresh: async () => { + try { + const payload = await shareIpc.status(); + applyPayload(set, payload); + } catch (error) { + set({ error: parseShareError(error).message }); + } + }, + + create: async (port, name, password) => { + set({ busy: true, error: null }); + try { + const share = await shareIpc.create(port, name, password); + await get().refresh(); + set({ busy: false }); + return share; + } catch (error) { + const parsed = parseShareError(error); + set({ busy: false, error: parsed.message }); + throw error; + } + }, + + stop: async (id) => { + set({ busy: true, error: null }); + try { + await shareIpc.stop(id); + await get().refresh(); + set({ busy: false }); + } catch (error) { + set({ busy: false, error: parseShareError(error).message }); + throw error; + } + }, + + start: async (id) => { + set({ busy: true, error: null }); + try { + await shareIpc.start(id); + await get().refresh(); + set({ busy: false }); + } catch (error) { + set({ busy: false, error: parseShareError(error).message }); + throw error; + } + }, + + remove: async (id) => { + set({ busy: true, error: null }); + try { + await shareIpc.delete(id); + set((state) => { + const agents = { ...state.agents }; + delete agents[id]; + return { agents }; + }); + await get().refresh(); + set({ busy: false }); + } catch (error) { + set({ busy: false, error: parseShareError(error).message }); + throw error; + } + }, + + startSharing: async () => { + set({ busy: true, error: null }); + try { + await shareIpc.agentStart(); + await get().refresh(); + set({ busy: false }); + } catch (error) { + set({ busy: false, error: parseShareError(error).message }); + throw error; + } + }, +})); + +export function quotaFull( + store: Pick, +): boolean { + const used = store.shares ? ownedShareCount(store.shares) : store.quotaUsed; + return quotaIsFull(used, store.quotaMax); +} + +export function agentLine( + agents: Record, + shares: ShareRecord[], +): { state: AgentConnState | 'idle'; label: string } { + const live = Object.values(agents); + if (live.some((a) => a.status === 'online')) { + return { state: 'online', label: 'Sharing from this device' }; + } + if (live.some((a) => a.status === 'reconnecting' || a.status === 'connecting')) { + return { state: 'reconnecting', label: 'Reconnecting…' }; + } + if (live.some((a) => a.status === 'auth_failed')) { + return { state: 'auth_failed', label: 'Sign in again or retry' }; + } + const shareable = shares.some((s) => s.status === 'reserved' || s.status === 'active'); + if (shareable) { + return { state: 'offline', label: 'Not sharing - Start sharing' }; + } + return { state: 'idle', label: '' }; +} + +export { SHARE_QUOTA_FULL_MESSAGE }; From 97ccad1d3a0edfdc61cb410ea88ec80a0a2546fd Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 11:33:39 +0530 Subject: [PATCH 03/14] feat(share): wire Public URLs into sidebar, profile, and tabs Expose Public URLs (Beta) from the sidebar, profile menu, command palette, and workspace tabs, preferring the Zync account avatar over Drive Sync when signed in. --- src/components/layout/CommandPalette.tsx | 13 ++- src/components/layout/MainLayout.tsx | 15 +++ src/components/layout/Sidebar.tsx | 11 +- src/components/layout/TabBar.tsx | 130 ++++++++++++++++++----- src/features/connections/domain/types.ts | 2 +- src/store/connectionSlice.ts | 15 ++- 6 files changed, 158 insertions(+), 28 deletions(-) diff --git a/src/components/layout/CommandPalette.tsx b/src/components/layout/CommandPalette.tsx index 268a48d..6aabadc 100644 --- a/src/components/layout/CommandPalette.tsx +++ b/src/components/layout/CommandPalette.tsx @@ -11,8 +11,10 @@ import { Network, FolderPlus, - Shield + Shield, + Link2 } from "lucide-react"; +import { PublicUrlsLabel } from "../share/PublicUrlsLabel"; import { useAppStore, Connection } from "../../store/useAppStore"; import { useShallow } from 'zustand/react/shallow'; import { usePlugins } from "../../context/PluginContext"; @@ -394,6 +396,15 @@ export function CommandPalette() { Port Forwarding + runCommand(() => useAppStore.getState().openPublicUrlsTab())} + className="relative flex cursor-pointer select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-app-accent/20 data-[selected=true]:text-app-accent text-app-text transition-colors group mb-0.5" + > + + + + runCommand(() => useAppStore.getState().openVaultTab('local'))} diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 6ec296a..7afc42a 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -68,6 +68,7 @@ const TunnelManager = lazy(() => import('../tunnel/TunnelManager').then(module = const SnippetsManager = lazy(() => import('../snippets/SnippetsManager').then(module => ({ default: module.SnippetsManager }))); const TerminalManager = lazy(() => import('../terminal/TerminalManager').then(module => ({ default: module.TerminalManager }))); const GlobalTunnelList = lazy(() => import('../tunnel/GlobalTunnelList').then(module => ({ default: module.GlobalTunnelList }))); +const PublicUrlsPanel = lazy(() => import('../share/PublicUrlsPanel').then(module => ({ default: module.PublicUrlsPanel }))); const PluginPanel = lazy(() => import('../plugins/PluginPanel').then(module => ({ default: module.PluginPanel }))); const SettingsJsonEditorPanel = lazy(() => import('../settings/SettingsJsonEditorPanel').then(module => ({ default: module.SettingsJsonEditorPanel })) @@ -347,6 +348,20 @@ const TabContent = memo(function TabContent({ tab, isActive }: { ); } + if (tab.type === 'public-urls') { + return ( +
+ }> + + +
+ ); + } + if (tab.type === 'release-notes') { return (
state.activeConnectionId); const openTab = useAppStore(state => state.openTab); const openPortForwardingTab = useAppStore(state => state.openPortForwardingTab); + const openPublicUrlsTab = useAppStore(state => state.openPublicUrlsTab); const folders = useAppStore(state => state.folders); const addFolder = useAppStore(state => state.addFolder); const updateConnectionFolder = useAppStore(state => state.updateConnectionFolder); @@ -907,6 +909,13 @@ export function Sidebar({ className }: { className?: string }) { onClick={() => openPortForwardingTab()} /> + } + label="Public URLs" + trailing={} + onClick={() => openPublicUrlsTab()} + /> +
diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx index 8175f03..772209d 100644 --- a/src/components/layout/TabBar.tsx +++ b/src/components/layout/TabBar.tsx @@ -1,4 +1,4 @@ -import { X, Settings as SettingsIcon, Network, Gift, Plus, Laptop, FolderPlus, Home, Shield, UserRound, ChevronDown, LogOut, RefreshCw, Monitor } from 'lucide-react'; +import { X, Settings as SettingsIcon, Network, Gift, Plus, Laptop, FolderPlus, Home, Shield, UserRound, ChevronDown, LogOut, RefreshCw, Monitor, Link2 } from 'lucide-react'; import { GoogleMarkIcon } from '../icons/providerIcons'; import { OSIcon } from '../icons/OSIcon'; import { ZyncMark } from '../brand/ZyncMark'; @@ -17,6 +17,9 @@ import { matchShortcut } from '../../lib/shortcuts'; import { useWindowDrag } from '../../hooks/useWindowDrag'; import { isEditorOverlayOpen } from '../editor/overlayState'; import { syncIpc, SYNC_STATUS_CHANGED_EVENT, type SyncProviderStatus } from '../../vault/syncIpc'; +import { useShareStore } from '../../features/share/useShareStore'; +import { parseShareError } from '../../features/share/ipc'; +import { PublicUrlsLabel } from '../share/PublicUrlsLabel'; import { DndContext, closestCenter, @@ -36,6 +39,7 @@ import { CSS } from '@dnd-kit/utilities'; function getIconForTab(tab: Tab, connections: Connection[], size: 12 | 13 = 12) { if (tab.type === 'port-forwarding') return ; + if (tab.type === 'public-urls') return ; if (tab.type === 'settings') return ; if (tab.type === 'release-notes') return ; if (tab.type === 'vault') return ; @@ -122,12 +126,16 @@ function SortableTab({ ? "bg-app-surface text-app-text shadow-sm font-semibold" : "text-app-muted hover:bg-app-surface/60 hover:text-app-text border-transparent" )} - title={tab.title} + title={tab.type === 'public-urls' ? 'Public URLs (Beta)' : tab.title} > {/* Icon based on type */} {getIconForTab(tab, connections, 12)} - {tab.title} + {tab.type === 'public-urls' ? ( + + ) : ( + {tab.title} + )} {isProfileMenuOpen && ( - -
-

Account

-

{googleSync?.email || 'Not connected to Google Drive'}

-
-
+ +
{!googleSync?.connected && ( )} {googleSync?.connected && ( @@ -558,7 +588,55 @@ export function TabBar() { ) : ( )} - {isGoogleSyncDisconnecting ? 'Disconnecting Google Sync…' : 'Disconnect Google Sync'} + {isGoogleSyncDisconnecting ? 'Disconnecting…' : 'Disconnect Drive'} + + )} +
+ + {shareAuth.signed_in && ( + )} @@ -582,7 +660,11 @@ export function TabBar() { return (
{getIconForTab(tab, connections, 13)} - {tab.title} + {tab.type === 'public-urls' ? ( + + ) : ( + {tab.title} + )}
diff --git a/src/features/connections/domain/types.ts b/src/features/connections/domain/types.ts index 8bb685b..e5bee78 100644 --- a/src/features/connections/domain/types.ts +++ b/src/features/connections/domain/types.ts @@ -50,7 +50,7 @@ export interface Folder { export interface Tab { id: string; - type: 'connection' | 'settings' | 'port-forwarding' | 'release-notes' | 'vault' | 'sync'; + type: 'connection' | 'settings' | 'port-forwarding' | 'release-notes' | 'vault' | 'sync' | 'public-urls'; title: string; connectionId?: string; vaultProfileId?: VaultProfileId; diff --git a/src/store/connectionSlice.ts b/src/store/connectionSlice.ts index 66448e3..fe67fc0 100644 --- a/src/store/connectionSlice.ts +++ b/src/store/connectionSlice.ts @@ -138,6 +138,7 @@ export interface ConnectionSlice { // Tab Actions openTab: (connectionId: string, startView?: CoreTabView) => void; openPortForwardingTab: () => void; + openPublicUrlsTab: () => void; openSnippetsTab: () => void; openReleaseNotesTab: () => void; openSettingsJsonTab: () => void; @@ -181,7 +182,7 @@ export interface ConnectionSlice { } const VALID_RESTORABLE_VIEWS = new Set(['terminal', 'files', 'port-forwarding', 'snippets', 'dashboard']); -const RESTORABLE_TAB_TYPES = new Set(['connection', 'port-forwarding', 'release-notes', 'snippets', 'settings', 'vault', 'sync']); +const RESTORABLE_TAB_TYPES = new Set(['connection', 'port-forwarding', 'release-notes', 'snippets', 'settings', 'vault', 'sync', 'public-urls']); type PersistedConnection = Omit & Partial>; @@ -975,6 +976,18 @@ export const createConnectionSlice: StateCreator { + set(state => { + return { ...ensureSingleTabByType(state.tabs, 'public-urls', () => ({ + id: crypto.randomUUID(), + type: 'public-urls', + title: 'Public URLs', + view: 'terminal', + })), showWelcomeScreen: false }; + }); + get().saveSession(); + }, + openReleaseNotesTab: () => { set(state => { return { ...ensureSingleTabByType(state.tabs, 'release-notes', () => ({ From afd4f9cfc3c6191c1bfb2c3a35c51ead39355315 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 11:33:39 +0530 Subject: [PATCH 04/14] docs(changelog): note Public URLs Beta under Unreleased --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e843cf..247fe5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Zync are documented in this file. The format is based on ## [Unreleased] +### Added +- **Public URLs (Beta)**: Share a local port on the internet with an HTTPS link via Zync account sign-in (GitHub/Google). Separate from Google Drive Sync and from SSH port forwarding. Includes create/stop/start/delete, copy URL, local share agent, OAuth cancel/retry, Beta badge, and bug-report link. Profile avatar prefers the Zync account photo when signed in. + ## [2.26.1] - 2026-08-28 ### Fixed From c8da354a5e4d2c9f510a24f3382d341897f944ac Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 12:13:00 +0530 Subject: [PATCH 05/14] fix(share): harden agent delivery and Public URLs error UX Use non-blocking stream sends, time out WS header reads, merge early WS body data, surface start-sharing failures, prefer server quota copy, and fail mutations when post-refresh status fails. --- src-tauri/src/share/agent.rs | 14 ++++++++- src-tauri/src/share/proxy.rs | 36 ++++++++++++++++++++---- src/components/share/PublicUrlsPanel.tsx | 17 ++++++++--- src/features/share/ipc.ts | 4 +-- src/features/share/useShareStore.ts | 1 + 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/share/agent.rs b/src-tauri/src/share/agent.rs index 919d2a6..464c142 100644 --- a/src-tauri/src/share/agent.rs +++ b/src-tauri/src/share/agent.rs @@ -532,7 +532,19 @@ async fn run_session( guard.get(&msg.stream_id).map(|st| st.data_sender()) }; if let Some(tx) = sender { - let _ = tx.send(bytes::Bytes::from(raw)).await; + match tx.try_send(bytes::Bytes::from(raw)) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + // Slow/local stream backlog — drop only this stream so + // the session keeps handling other streams and pings. + if let Some(st) = streams.lock().await.remove(&msg.stream_id) { + st.cancel(); + } + } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + streams.lock().await.remove(&msg.stream_id); + } + } } } TYPE_CLOSE => { diff --git a/src-tauri/src/share/proxy.rs b/src-tauri/src/share/proxy.rs index a556bd2..f49d094 100644 --- a/src-tauri/src/share/proxy.rs +++ b/src-tauri/src/share/proxy.rs @@ -254,13 +254,36 @@ async fn proxy_websocket( } let (mut read_half, mut write_half) = stream.into_split(); + let mut req_rx = readers.req_rx; let mut extra_rx = readers.extra_rx; - drop(readers.req_rx); + // Merge body-channel and post-close uplink so early TYPE_DATA before the + // first TYPE_CLOSE is not dropped (WebSocket upgrade request bodies). let uplink = async { - while let Some(chunk) = extra_rx.recv().await { - if write_half.write_all(&chunk).await.is_err() { - break; + let mut req_done = false; + let mut extra_done = false; + while !req_done || !extra_done { + tokio::select! { + chunk = req_rx.recv(), if !req_done => { + match chunk { + Some(c) => { + if write_half.write_all(&c).await.is_err() { + break; + } + } + None => req_done = true, + } + } + chunk = extra_rx.recv(), if !extra_done => { + match chunk { + Some(c) => { + if write_half.write_all(&c).await.is_err() { + break; + } + } + None => extra_done = true, + } + } } } let _ = write_half.shutdown().await; @@ -411,7 +434,10 @@ async fn read_http_headers( let mut buf = Vec::new(); let mut tmp = [0u8; 1024]; loop { - let n = stream.read(&mut tmp).await.map_err(|e| e.to_string())?; + let n = tokio::time::timeout(DIAL_TIMEOUT, stream.read(&mut tmp)) + .await + .map_err(|_| "timed out waiting for response headers".to_string())? + .map_err(|e| e.to_string())?; if n == 0 { return Err("connection closed before headers".into()); } diff --git a/src/components/share/PublicUrlsPanel.tsx b/src/components/share/PublicUrlsPanel.tsx index f74930d..1498664 100644 --- a/src/components/share/PublicUrlsPanel.tsx +++ b/src/components/share/PublicUrlsPanel.tsx @@ -23,10 +23,10 @@ import { useAppStore } from '../../store/useAppStore'; import { agentLine, quotaFull, - SHARE_QUOTA_FULL_MESSAGE, useShareStore, } from '../../features/share/useShareStore'; import { + quotaFullMessage, shareChip, shareDisplayUrl, type ShareChip, @@ -382,7 +382,7 @@ export function PublicUrlsPanel() {
{full && (
- {SHARE_QUOTA_FULL_MESSAGE} + {quotaFullMessage(quotaUsed, quotaMax)}
)} {error && !full && ( @@ -431,7 +431,16 @@ export function PublicUrlsPanel() {

{line.label}

{(line.state === 'offline' || line.state === 'auth_failed') && ( - )} @@ -447,7 +456,7 @@ export function PublicUrlsPanel() { >
{full && ( -

{SHARE_QUOTA_FULL_MESSAGE}

+

{quotaFullMessage(quotaUsed, quotaMax)}

)} ((set, get) => ({ applyPayload(set, payload); } catch (error) { set({ error: parseShareError(error).message }); + throw error; } }, From 1203446264daa2df3abb83392ad3be9c1566b1ca Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 14:11:11 +0530 Subject: [PATCH 06/14] feat(tunnels): redesign Port Forwarding list and grid Replace dense cards with a compact host-grouped table, optional matching tiles, search and type/status filters, persisted view, host icons, and Copy/Open on the row. --- src/components/tunnel/GlobalTunnelList.tsx | 447 +++++++-------- src/components/tunnel/TunnelManager.tsx | 313 ++++++----- src/components/tunnel/TunnelRow.tsx | 618 +++++++++++++++++++++ 3 files changed, 999 insertions(+), 379 deletions(-) create mode 100644 src/components/tunnel/TunnelRow.tsx diff --git a/src/components/tunnel/GlobalTunnelList.tsx b/src/components/tunnel/GlobalTunnelList.tsx index 5627cc9..18ed486 100644 --- a/src/components/tunnel/GlobalTunnelList.tsx +++ b/src/components/tunnel/GlobalTunnelList.tsx @@ -4,12 +4,28 @@ import { Button } from '../ui/Button'; import { Modal } from '../ui/Modal'; import { TopbarDropdown } from '../ui/TopbarDropdown'; import { cn } from '../../lib/utils'; -import { Plus, Network, ChevronDown, FileText, Play, Square, Folder, FolderOpen, LayoutGrid, List, ChevronRight, ArrowRight } from 'lucide-react'; +import { Plus, Network, ChevronDown, FileText, Play, Square, ChevronRight, ArrowRight } from 'lucide-react'; import { TUNNEL_PRESETS, TunnelPreset } from '../../lib/tunnelPresets'; import { AddTunnelModal } from '../modals/AddTunnelModal'; import { ImportSSHCommandModal } from '../modals/ImportSSHCommandModal'; -import { TunnelCard, TunnelConfig } from './TunnelCard'; +import type { TunnelConfig } from './TunnelCard'; +import { + TunnelRow, + TunnelGridCard, + TunnelTableHeader, + TunnelSearchInput, + TunnelViewToggle, + useTunnelViewMode, + TunnelFilterToolbar, + countTunnelFilters, + tunnelMatchesQuery, + tunnelMatchesStatus, + tunnelMatchesType, + type TunnelStatusFilter, + type TunnelTypeFilter, +} from './TunnelRow'; import { getConnectionDisplayLabels } from '../../features/connections/domain/connectionDisplay'; +import { OSIcon } from '../icons/OSIcon'; import { parsePortConflictError, tunnelWithSwappedPort, @@ -35,14 +51,16 @@ export function GlobalTunnelList() { const showToast = useAppStore((state) => state.showToast); // const [tunnels, setTunnels] = useState([]); // Removed local state const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); const [loading, setLoading] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [editingTunnel, setEditingTunnel] = useState(null); const [initialConnectionId, setInitialConnectionId] = useState(undefined); const [showPresetDropdown, setShowPresetDropdown] = useState(false); const [showImportModal, setShowImportModal] = useState(false); - const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [viewMode, setViewMode] = useTunnelViewMode(); + const [collapsedHosts, setCollapsedHosts] = useState>(new Set()); const dropdownRef = useRef(null); // Port suggestion dialog state @@ -124,45 +142,55 @@ export function GlobalTunnelList() { setIsAddModalOpen(true); }; - const toggleGroup = (group: string) => { - const newSet = new Set(collapsedGroups); - if (newSet.has(group)) { - newSet.delete(group); - } else { - newSet.add(group); - } - setCollapsedGroups(newSet); + const toggleHost = (connectionId: string) => { + setCollapsedHosts(prev => { + const next = new Set(prev); + if (next.has(connectionId)) next.delete(connectionId); + else next.add(connectionId); + return next; + }); }; - const filteredTunnels = allTunnels.filter(t => { - const query = searchQuery.toLowerCase(); - const conn = connections.find(c => c.id === t.connectionId); - return t.name.toLowerCase().includes(query) || - t.localPort.toString().includes(query) || - t.remotePort.toString().includes(query) || - t.remoteHost.toLowerCase().includes(query) || - conn?.name?.toLowerCase().includes(query) || - conn?.host?.toLowerCase().includes(query); - }); - - // Grouping by connection - // Grouping by Group Name - const groupedTunnels = filteredTunnels.reduce((acc, t) => { - const groupName = t.group || 'Ungrouped'; - if (!acc[groupName]) acc[groupName] = []; - acc[groupName].push(t); - return acc; - }, {} as Record); - - // Sort groups: named groups alphabetical, then Ungrouped - const sortedGroupNames = Object.keys(groupedTunnels).sort((a, b) => { - if (a === 'Ungrouped') return 1; - if (b === 'Ungrouped') return -1; - return a.localeCompare(b); - }); - - const activeCount = allTunnels.filter(t => t.status === 'active').length; + const filterCounts = useMemo(() => countTunnelFilters(allTunnels), [allTunnels]); + + const filteredTunnels = useMemo(() => { + return allTunnels.filter(t => { + const conn = connections.find(c => c.id === t.connectionId); + const extra = [conn?.name, conn?.host].filter(Boolean) as string[]; + return ( + tunnelMatchesQuery(t, searchQuery, extra) && + tunnelMatchesStatus(t, statusFilter) && + tunnelMatchesType(t, typeFilter) + ); + }); + }, [allTunnels, connections, searchQuery, statusFilter, typeFilter]); + + const hostGroups = useMemo(() => { + const byConn = new Map(); + for (const t of filteredTunnels) { + const list = byConn.get(t.connectionId) ?? []; + list.push(t); + byConn.set(t.connectionId, list); + } + return Array.from(byConn.entries()) + .map(([connectionId, tunnels]) => { + const conn = connections.find(c => c.id === connectionId); + return { + connectionId, + hostLabel: conn + ? getConnectionDisplayLabels(conn, false).primary + : 'Unknown host', + icon: conn?.icon || 'Server', + connected: conn?.status === 'connected', + tunnels, + }; + }) + .sort((a, b) => a.hostLabel.localeCompare(b.hostLabel)); + }, [filteredTunnels, connections]); + + const activeCount = filterCounts.running; const serversCount = new Set(allTunnels.map(t => t.connectionId)).size; + const filtersActive = Boolean(searchQuery.trim()) || statusFilter !== 'all' || typeFilter !== 'all'; const handleToggleTunnel = async (tunnel: TunnelConfig) => { const conn = connections.find(c => c.id === tunnel.connectionId); @@ -332,59 +360,19 @@ export function GlobalTunnelList() { return (
- {/* Compact Stacked Header */} -
- {/* Title Row */} -
+
+

Port Forwarding

{allTunnels.length > 0 && ( - - {activeCount} Active · {serversCount} Servers + + {activeCount} running · {serversCount} {serversCount === 1 ? 'host' : 'hosts'} )}
- {/* Search and Actions Row */} -
- {/* Search Bar */} -
-
- - - -
- setSearchQuery(e.target.value)} - className="w-full bg-app-surface/30 border border-app-border/30 rounded-lg py-1 pl-8 pr-3 text-[11px] focus:outline-none focus:ring-1 focus:ring-app-accent/40 transition-all placeholder:text-app-muted/30" - /> -
- - {/* Actions */} -
- - -
+
+ +
- {/* Preset Dropdown */} {showPresetDropdown && (
- {/* Content Area */}
- { - filteredTunnels.length === 0 ? ( -
-
- -
-

- {searchQuery ? 'No results found' : 'No Port Forwards'} -

-

- {searchQuery - ? `We couldn't find anything matching "${searchQuery}"` - : 'Bridge your local environment with remote servers securely.'} -

- {!searchQuery && ( - - )} + {allTunnels.length > 0 && ( + + )} + {filteredTunnels.length === 0 ? ( +
+
+
- ) : ( -
- {sortedGroupNames.map(groupName => { - const ports = groupedTunnels[groupName]; - const activeCount = ports.filter(t => t.status === 'active').length; - - const isCollapsed = collapsedGroups.has(groupName); - - return ( -
- {/* Group Header */} -
toggleGroup(groupName)} - > -
- + {filtersActive ? 'No matching forwards' : 'No Port Forwards'} + +

+ {filtersActive + ? 'Try a different search or clear the filters.' + : 'SSH local, remote, and SOCKS forwards across your hosts.'} +

+ {!filtersActive && ( + + )} +
+ ) : ( +
+ {viewMode === 'list' && } +
+ {hostGroups.map(group => { + const running = group.tunnels.filter(t => t.status === 'active').length; + const collapsed = collapsedHosts.has(group.connectionId); + const copyHandlers = { + onToggle: handleToggleTunnel, + onEdit: (t: TunnelConfig) => { + setEditingTunnel(t); + setIsAddModalOpen(true); + }, + onDelete: handleDeleteTunnel, + onOpenBrowser: handleOpenBrowser, + onCopy: (text: string) => { + navigator.clipboard.writeText(text); + showToast('success', 'Copied'); + }, + }; + return ( +
+
toggleHost(group.connectionId)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleHost(group.connectionId); + } + }} + > +
+ + + + - {groupName === 'Ungrouped' ? ( - - ) : ( - - )} -
-

- {groupName} -

- - {activeCount}/{ports.length} active - -
-
-
- {activeCount > 0 && ( - - )} + +

+ {group.hostLabel} +

+ + {running}/{group.tunnels.length} + +
+
+ {running > 0 && ( -
+ )} +
- - {/* Ports Grid/List */} - {!isCollapsed && ( - viewMode === 'grid' ? ( -
- {ports.map((tunnel) => { - const conn = connections.find(c => c.id === tunnel.connectionId); - const hostLabel = conn - ? getConnectionDisplayLabels(conn, false).primary - : undefined; - return ( - { - setEditingTunnel(t); - setIsAddModalOpen(true); - }} - onDelete={handleDeleteTunnel} - onOpenBrowser={handleOpenBrowser} - onCopy={(text) => { - navigator.clipboard.writeText(text); - showToast('success', 'Copied'); - }} - /> - ); - })} -
- ) : ( - // List View -
- {ports.map((tunnel) => { - const conn = connections.find(c => c.id === tunnel.connectionId); - const hostLabel = conn - ? getConnectionDisplayLabels(conn, false).primary - : undefined; - return ( - { - setEditingTunnel(t); - setIsAddModalOpen(true); - }} - onDelete={handleDeleteTunnel} - onOpenBrowser={handleOpenBrowser} - onCopy={(text) => { - navigator.clipboard.writeText(text); - showToast('success', 'Copied'); - }} - /> - ); - })} -
- ) - )}
- ); - })} + {!collapsed && ( + viewMode === 'grid' ? ( +
+ {group.tunnels.map(tunnel => ( + + ))} +
+ ) : ( +
+ {group.tunnels.map(tunnel => ( + + ))} +
+ ) + )} +
+ ); + })}
- )} +
+ )}
{/* Port Conflict Modal */} diff --git a/src/components/tunnel/TunnelManager.tsx b/src/components/tunnel/TunnelManager.tsx index 186e4e6..165c55f 100644 --- a/src/components/tunnel/TunnelManager.tsx +++ b/src/components/tunnel/TunnelManager.tsx @@ -2,13 +2,27 @@ import { useEffect, useState, useRef } from 'react'; import { useAppStore } from '../../store/useAppStore'; import { Button } from '../ui/Button'; import { cn } from '../../lib/utils'; -import { Plus, Network, ChevronDown, FileText, Play, Square, Folder, FolderOpen, LayoutGrid, List, ArrowRight } from 'lucide-react'; +import { Plus, Network, ChevronDown, FileText, Play, Square, Folder, FolderOpen, ArrowRight } from 'lucide-react'; import { TUNNEL_PRESETS, TunnelPreset } from '../../lib/tunnelPresets'; import { AddTunnelModal } from '../modals/AddTunnelModal'; import { ImportSSHCommandModal } from '../modals/ImportSSHCommandModal'; import { Modal } from '../ui/Modal'; import { TopbarDropdown } from '../ui/TopbarDropdown'; -import { TunnelCard, TunnelConfig } from './TunnelCard'; +import type { TunnelConfig } from './TunnelCard'; +import { + TunnelRow, + TunnelGridCard, + TunnelTableHeader, + TunnelViewToggle, + useTunnelViewMode, + TunnelFilterToolbar, + countTunnelFilters, + tunnelMatchesQuery, + tunnelMatchesStatus, + tunnelMatchesType, + type TunnelStatusFilter, + type TunnelTypeFilter, +} from './TunnelRow'; import { getConnectionDisplayLabels } from '../../features/connections/domain/connectionDisplay'; import { @@ -41,7 +55,10 @@ export function TunnelManager({ connectionId }: { connectionId?: string }) { const [editingTunnel, setEditingTunnel] = useState(null); const [showPresetDropdown, setShowPresetDropdown] = useState(false); const [showImportModal, setShowImportModal] = useState(false); - const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); + const [viewMode, setViewMode] = useTunnelViewMode(); const dropdownRef = useRef(null); // Port suggestion dialog state @@ -228,45 +245,71 @@ export function TunnelManager({ connectionId }: { connectionId?: string }) { if (!activeConnectionId) return
No connection selected
; + const hostLabel = conn ? getConnectionDisplayLabels(conn, false).primary : undefined; + const filterCounts = countTunnelFilters(tunnels); + const filteredTunnels = tunnels.filter(t => + tunnelMatchesQuery(t, searchQuery) && + tunnelMatchesStatus(t, statusFilter) && + tunnelMatchesType(t, typeFilter), + ); + const namedGroups = new Set(filteredTunnels.map(t => t.group).filter(Boolean)); + const showNamedGroups = namedGroups.size > 0; + const tunnelActions = { + onToggle: handleToggleTunnel, + onEdit: (t: TunnelConfig) => { + setEditingTunnel(t); + setIsAddModalOpen(true); + }, + onDelete: handleDeleteTunnel, + onOpenBrowser: handleOpenBrowser, + onCopy: (text: string) => { + navigator.clipboard.writeText(text); + showToast('success', 'Copied'); + }, + }; + const renderTunnels = (items: TunnelConfig[], showGroup = true) => + viewMode === 'grid' ? ( +
+ {items.map(tunnel => ( + + ))} +
+ ) : ( +
+ {items.map(tunnel => ( + + ))} +
+ ); + return (
- {/* Minimal Header for Tab View */} -
-
-

Port Forwarding

+
+
+
+

Port Forwarding

+ {tunnels.length > 0 && ( + + {filterCounts.running} running + + )} +
+
{tunnels.length > 0 && ( - - {tunnels.filter(t => t.status === 'active').length} Active - + )} -
-
-
- - -
+
+ {tunnels.length > 0 && ( + + )} {tunnels.length === 0 ? ( -
-
- +
+
+

No Port Forwards

-

- Bridge your local environment with remote servers securely. +

+ SSH local, remote, and SOCKS forwards for this host.

+ ) : filteredTunnels.length === 0 ? ( +
+

No matching forwards

+

+ Try a different search or clear the filters. +

+
) : ( -
+
+ {viewMode === 'list' && } {(() => { - // Group tunnels + if (!showNamedGroups) { + return renderTunnels(filteredTunnels); + } + const groups: Record = {}; - tunnels.forEach(t => { + filteredTunnels.forEach(t => { const g = t.group || 'Ungrouped'; if (!groups[g]) groups[g] = []; groups[g].push(t); }); - - // Sort groups: named groups alphabetical, then Ungrouped const sortedGroupNames = Object.keys(groups).sort((a, b) => { if (a === 'Ungrouped') return 1; if (b === 'Ungrouped') return -1; return a.localeCompare(b); }); - return sortedGroupNames.map(groupName => { - const groupTunnels = groups[groupName]; - const activeCount = groupTunnels.filter(t => t.status === 'active').length; - - return ( -
- {/* Group Header */} -
-
- {groupName === 'Ungrouped' ? ( - - ) : ( - - )} -

- {groupName} -

- - {activeCount}/{groupTunnels.length} active - + return ( +
+ {sortedGroupNames.map(groupName => { + const groupTunnels = groups[groupName]; + const activeCount = groupTunnels.filter(t => t.status === 'active').length; + return ( +
+
+
+ {groupName === 'Ungrouped' ? ( + + ) : ( + + )} +

+ {groupName} +

+ + {activeCount}/{groupTunnels.length} + +
+
+ {activeCount > 0 && ( + + )} + +
+
+ {renderTunnels(groupTunnels, false)}
- -
- {activeCount > 0 && ( - - )} - -
-
- - {viewMode === 'grid' ? ( -
- {groupTunnels.map(port => ( - { - setEditingTunnel(t); - setIsAddModalOpen(true); - }} - onDelete={handleDeleteTunnel} - onOpenBrowser={handleOpenBrowser} - onCopy={(text) => { - navigator.clipboard.writeText(text); - showToast('success', 'Copied'); - }} - /> - ))} -
- ) : ( -
- {groupTunnels.map(port => ( - { - setEditingTunnel(t); - setIsAddModalOpen(true); - }} - onDelete={handleDeleteTunnel} - onOpenBrowser={handleOpenBrowser} - onCopy={(text) => { - navigator.clipboard.writeText(text); - showToast('success', 'Copied'); - }} - /> - ))} -
- )} -
- ); - }); + ); + })} +
+ ); })()}
)} diff --git a/src/components/tunnel/TunnelRow.tsx b/src/components/tunnel/TunnelRow.tsx new file mode 100644 index 0000000..a92cbc1 --- /dev/null +++ b/src/components/tunnel/TunnelRow.tsx @@ -0,0 +1,618 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Copy, + ExternalLink, + LayoutGrid, + List, + MoreHorizontal, + Pencil, + Search, + Trash2, +} from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { isDynamicTunnel, socks5Url, type TunnelType } from '../../features/tunnels/domain/tunnelTypes'; +import { + formatTunnelFlow, + TUNNEL_TYPE_META, + tunnelCopyAddress, +} from '../../features/tunnels/presentation/tunnelDisplay'; +import { TopbarDropdown } from '../ui/TopbarDropdown'; +import type { TunnelConfig } from './TunnelCard'; + +export type TunnelStatusFilter = 'all' | 'running' | 'stopped' | 'error'; +export type TunnelTypeFilter = 'all' | 'local' | 'remote' | 'dynamic'; + +export function tunnelMatchesQuery( + tunnel: TunnelConfig, + query: string, + extra?: string[], +): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + const hay = [ + tunnel.name, + tunnel.localPort != null ? String(tunnel.localPort) : '', + tunnel.remotePort != null ? String(tunnel.remotePort) : '', + tunnel.remoteHost, + tunnel.group, + ...(extra ?? []), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return hay.includes(q); +} + +export function tunnelMatchesStatus(tunnel: TunnelConfig, filter: TunnelStatusFilter): boolean { + if (filter === 'all') return true; + if (filter === 'running') return tunnel.status === 'active'; + if (filter === 'error') return tunnel.status === 'error'; + return tunnel.status !== 'active' && tunnel.status !== 'error'; +} + +export function tunnelMatchesType(tunnel: TunnelConfig, filter: TunnelTypeFilter): boolean { + if (filter === 'all') return true; + return tunnel.type === filter; +} + +const TYPE_RING: Record = { + local: 'border border-sky-400/15', + remote: 'border border-amber-400/15', + dynamic: 'border border-violet-400/15', +}; + +const TYPE_LABEL: Record = { + local: 'text-sky-400', + remote: 'text-amber-400', + dynamic: 'text-violet-400', +}; + +function TypeMetaLabel({ type }: { type: TunnelType }) { + const meta = TUNNEL_TYPE_META[type]; + return ( + + {meta.label} + {meta.flag} + + ); +} + +function StatusDot({ status, error }: { status: TunnelConfig['status']; error?: string }) { + const color = + status === 'active' + ? 'bg-app-success' + : status === 'error' + ? 'bg-red-400' + : 'bg-app-muted/40'; + return ( + + ); +} + +type TunnelItemHandlers = { + onToggle: (tunnel: TunnelConfig) => void; + onEdit: (tunnel: TunnelConfig) => void; + onDelete: (id: string) => void; + onOpenBrowser: (port: number) => void; + onCopy: (text: string) => void; +}; + +function tunnelFlowLabel(tunnel: TunnelConfig, hostLabel?: string) { + const flow = formatTunnelFlow(tunnel, hostLabel); + return flow.targetPort == null + ? `${flow.source} → ${flow.targetHost}` + : `${flow.source} → ${flow.targetHost}:${flow.targetPort}`; +} + +function TunnelQuickActions({ + tunnel, + isActive, + copyText, + onToggle, + onCopy, + onOpenBrowser, +}: { + tunnel: TunnelConfig; + isActive: boolean; + copyText: string; + onToggle: (tunnel: TunnelConfig) => void; + onCopy: (text: string) => void; + onOpenBrowser: (port: number) => void; +}) { + return ( +
+ + + {tunnel.type === 'local' ? ( + + ) : ( + + )} +
+ ); +} + +function TunnelOverflowMenu({ + tunnel, + socksUrl, + onCopy, + onEdit, + onDelete, +}: { + tunnel: TunnelConfig; + socksUrl: string; + onCopy: (text: string) => void; + onEdit: (tunnel: TunnelConfig) => void; + onDelete: (id: string) => void; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + const isDynamic = isDynamicTunnel(tunnel.type); + + useEffect(() => { + if (!menuOpen) return; + const onDown = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + }; + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + }, [menuOpen]); + + return ( +
+ + {menuOpen && ( + + {isDynamic && ( + + )} + + + + )} +
+ ); +} + +export function TunnelRow({ + tunnel, + hostLabel, + showHost, + showGroup = true, + onToggle, + onEdit, + onDelete, + onOpenBrowser, + onCopy, +}: { + tunnel: TunnelConfig; + hostLabel?: string; + showHost?: boolean; + showGroup?: boolean; +} & TunnelItemHandlers) { + const isActive = tunnel.status === 'active'; + const socksUrl = socks5Url(tunnel.bindAddress, tunnel.localPort); + const copyText = tunnelCopyAddress(tunnel, socksUrl); + const flowLabel = tunnelFlowLabel(tunnel, hostLabel); + + return ( +
+ + +
+ + {tunnel.name} + + {showGroup && tunnel.group ? ( + + {tunnel.group} + + ) : null} +
+ + + + + + + + {showHost && ( + + {hostLabel || 'Unknown host'} + + )} + + + + +
+ ); +} + +export function TunnelGridCard({ + tunnel, + hostLabel, + showGroup = true, + onToggle, + onEdit, + onDelete, + onOpenBrowser, + onCopy, +}: { + tunnel: TunnelConfig; + hostLabel?: string; + showGroup?: boolean; +} & TunnelItemHandlers) { + const isActive = tunnel.status === 'active'; + const socksUrl = socks5Url(tunnel.bindAddress, tunnel.localPort); + const copyText = tunnelCopyAddress(tunnel, socksUrl); + const flowLabel = tunnelFlowLabel(tunnel, hostLabel); + + return ( +
+
+
+ +
+ + {tunnel.name} + + {showGroup && tunnel.group ? ( + + {tunnel.group} + + ) : null} +
+
+ +
+ + + + + + +
+ ); +} + +const TUNNEL_VIEW_STORAGE_KEY = 'zync-port-forwarding-view'; + +export function useTunnelViewMode() { + const [viewMode, setViewModeState] = useState<'grid' | 'list'>(() => { + try { + return localStorage.getItem(TUNNEL_VIEW_STORAGE_KEY) === 'grid' ? 'grid' : 'list'; + } catch { + return 'list'; + } + }); + + const setViewMode = (mode: 'grid' | 'list') => { + setViewModeState(mode); + try { + localStorage.setItem(TUNNEL_VIEW_STORAGE_KEY, mode); + } catch { + /* ignore quota / private mode */ + } + }; + + return [viewMode, setViewMode] as const; +} + +export function TunnelViewToggle({ + viewMode, + onChange, +}: { + viewMode: 'grid' | 'list'; + onChange: (mode: 'grid' | 'list') => void; +}) { + return ( +
+ + +
+ ); +} + +export function TunnelFilterChip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function TunnelTableHeader({ showHost }: { showHost?: boolean }) { + return ( +
+ + Name + Type + Forward + {showHost && Host} + + +
+ ); +} + +export function TunnelSearchInput({ + query, + onQueryChange, + placeholder = 'Search name, port, or host...', +}: { + query: string; + onQueryChange: (query: string) => void; + placeholder?: string; +}) { + return ( +
+ + onQueryChange(e.target.value)} + placeholder={placeholder} + className="h-7 w-full rounded-lg border border-app-border/30 bg-app-surface/30 py-1 pl-7 pr-3 text-[11px] placeholder:text-app-muted/30 focus:outline-none focus:ring-1 focus:ring-app-accent/40" + /> +
+ ); +} + +function countLabel(n: number | undefined) { + return n && n > 0 ? ` ${n}` : ''; +} + +export function TunnelFilterToolbar({ + query, + onQueryChange, + status, + onStatusChange, + type, + onTypeChange, + counts, + placeholder, +}: { + query?: string; + onQueryChange?: (query: string) => void; + status: TunnelStatusFilter; + onStatusChange: (status: TunnelStatusFilter) => void; + type: TunnelTypeFilter; + onTypeChange: (type: TunnelTypeFilter) => void; + counts?: { + running: number; + stopped: number; + error: number; + local: number; + remote: number; + dynamic: number; + }; + placeholder?: string; +}) { + return ( +
+ {onQueryChange && ( + + )} +
+ onStatusChange('all')}> + All + + onStatusChange('running')}> + Running{countLabel(counts?.running)} + + onStatusChange('stopped')}> + Stopped{countLabel(counts?.stopped)} + + {(status === 'error' || (counts?.error ?? 0) > 0) && ( + onStatusChange('error')}> + Error{countLabel(counts?.error)} + + )} +
+
+ onTypeChange('all')}> + All + + onTypeChange('local')}> + Local + + onTypeChange('remote')}> + Remote + + onTypeChange('dynamic')}> + SOCKS + +
+
+ ); +} + +export function countTunnelFilters(tunnels: TunnelConfig[]) { + return { + running: tunnels.filter(t => t.status === 'active').length, + stopped: tunnels.filter(t => t.status !== 'active' && t.status !== 'error').length, + error: tunnels.filter(t => t.status === 'error').length, + local: tunnels.filter(t => t.type === 'local').length, + remote: tunnels.filter(t => t.type === 'remote').length, + dynamic: tunnels.filter(t => t.type === 'dynamic').length, + }; +} From 9acc835609e574d1376c1541cf237449a51bdfdf Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 14:11:17 +0530 Subject: [PATCH 07/14] feat(tunnels): suggest next remote port on busy -R When tcpip-forward is rejected, probe nearby remote ports and reuse the local port-conflict picker if one bind succeeds and is released. Policy rejects still surface the original error. --- src-tauri/src/tunnels/manager.rs | 114 ++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/tunnels/manager.rs b/src-tauri/src/tunnels/manager.rs index cb93a37..e87a64f 100644 --- a/src-tauri/src/tunnels/manager.rs +++ b/src-tauri/src/tunnels/manager.rs @@ -5,7 +5,7 @@ use crate::types::SavedTunnel; use anyhow::{anyhow, Result}; use log::warn; use russh::client::Handle; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; use tokio::net::TcpListener; @@ -370,8 +370,43 @@ impl TunnelManager { }; if let Err(e) = res { - let mut map = self.remote_forwards.lock().await; - map.remove(&map_key); + { + let mut map = self.remote_forwards.lock().await; + map.remove(&map_key); + } + + let err_text = e.to_string(); + if is_remote_forward_rejected(&err_text) { + let occupied: HashSet = { + let map = self.remote_forwards.lock().await; + map.keys() + .filter_map(|key| { + let (conn, port) = key.rsplit_once(':')?; + if conn == connection_id { + port.parse().ok() + } else { + None + } + }) + .collect() + }; + if let Some(suggested) = find_next_available_remote_port( + &session, + &bind_address, + remote_port, + 10, + &occupied, + ) + .await + { + return Err(anyhow!( + "Port {} is already in use. Port {} is available.", + remote_port, + suggested + )); + } + } + return Err(anyhow!("Remote forwarding error: {}", e)); } @@ -535,6 +570,65 @@ async fn find_process_using_port(port: u16) -> Option { } } +fn is_remote_forward_rejected(err: &str) -> bool { + err.to_ascii_lowercase() + .contains("rejected by the other party") +} + +/// Probe the next remote listen ports via `tcpip-forward`, then cancel the probe bind. +/// If none succeed, the original reject is likely policy (forwards disabled), not a busy port. +async fn find_next_available_remote_port( + session: &Arc>>, + bind_address: &str, + start_port: u16, + max_attempts: u8, + occupied: &HashSet, +) -> Option { + for offset in 1..=max_attempts { + let candidate = start_port.saturating_add(offset.into()); + if candidate == 0 || candidate == start_port || occupied.contains(&candidate) { + continue; + } + + let bound = { + let mut handle = session.lock().await; + handle + .tcpip_forward(bind_address.to_string(), candidate as u32) + .await + .is_ok() + }; + if !bound { + continue; + } + + let cancel_ok = { + let handle = session.lock().await; + match handle + .cancel_tcpip_forward(bind_address.to_string(), candidate as u32) + .await + { + Ok(()) => true, + Err(e) => { + warn!( + "[TUNNEL] Failed to release remote probe port {}: {}", + candidate, e + ); + false + } + } + }; + if let Some(port) = remote_probe_suggestion(candidate, cancel_ok) { + return Some(port); + } + } + None +} + +/// A probed remote port is only reusable if we also released the probe bind. +fn remote_probe_suggestion(candidate: u16, cancel_ok: bool) -> Option { + cancel_ok.then_some(candidate) +} + async fn find_next_available_port(start_port: u16, max_attempts: u8) -> Option { for offset in 1..=max_attempts { let candidate_port = start_port.saturating_add(offset.into()); @@ -589,6 +683,20 @@ mod tests { assert_eq!(tunnel_runtime_id(&t), "remote:conn-b:5432:127.0.0.1:8080"); } + #[test] + fn is_remote_forward_rejected_matches_russh_text() { + assert!(is_remote_forward_rejected( + "The request was rejected by the other party" + )); + assert!(!is_remote_forward_rejected("connection reset")); + } + + #[test] + fn remote_probe_not_suggested_when_cancel_fails() { + assert_eq!(remote_probe_suggestion(8081, false), None); + assert_eq!(remote_probe_suggestion(8081, true), Some(8081)); + } + #[test] fn remote_forward_map_key_scopes_by_connection() { assert_eq!(remote_forward_map_key("host-1", 9000), "host-1:9000"); From d2d12e19d4ddab5d43abd9128dfebaba3b92f5af Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 14:11:23 +0530 Subject: [PATCH 08/14] docs(tunnels): note Port Forwarding UI and remote port picker Update TUNNELS.md surfaces/file map and CHANGELOG Unreleased for the compact list/grid and remote -R conflict flow. --- CHANGELOG.md | 6 ++++++ docs/TUNNELS.md | 23 +++++++++++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 247fe5b..3be5859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to Zync are documented in this file. The format is based on ### Added - **Public URLs (Beta)**: Share a local port on the internet with an HTTPS link via Zync account sign-in (GitHub/Google). Separate from Google Drive Sync and from SSH port forwarding. Includes create/stop/start/delete, copy URL, local share agent, OAuth cancel/retry, Beta badge, and bug-report link. Profile avatar prefers the Zync account photo when signed in. +### Changed +- **Port Forwarding list**: Compact table grouped by SSH host, with a matching quiet grid. Search, status/type filters, host icons, persisted list/grid, and Copy/Open on the row. + +### Fixed +- **Remote port conflict**: Busy SSH `-R` listen ports use the same next-port / manual picker as local forwards. If every probe fails (sshd policy), the original reject is kept. + ## [2.26.1] - 2026-08-28 ### Fixed diff --git a/docs/TUNNELS.md b/docs/TUNNELS.md index 76de261..9ec0c3a 100644 --- a/docs/TUNNELS.md +++ b/docs/TUNNELS.md @@ -1,6 +1,6 @@ # Port Forwarding & Tunnels — Architecture & Reference -**Last updated:** 2026-07-09 +**Last updated:** 2026-08-29 **Applies to:** Zync v2.21.0+ **User-facing guide:** [zync.thesudoer.in/docs/port-forwarding](https://zync.thesudoer.in/docs/port-forwarding) @@ -104,9 +104,9 @@ flowchart TB GL[GlobalTunnelList] AM[AddTunnelModal] IM[ImportSSHCommandModal] - TC[TunnelCard] - TM --> TC - GL --> TC + TR[TunnelRow] + TM --> TR + GL --> TR AM --> TM AM --> GL end @@ -163,6 +163,7 @@ Equivalent to `ssh -R [bind:]remotePort:localHost:localPort`. - Requests `tcpip_forward` on the SSH server for `bindAddress:remotePort`. - Incoming forwarded connections are proxied to the local target (`remoteHost` in the saved config stores the **local target host** for `-R`). +- If the server rejects the listen and a later port on the same bind succeeds, Zync surfaces the same **port conflict** picker as local (suggested next port or manual). If every probe fails, the original reject is kept (usually `sshd` policy, not a busy port). **Server requirement:** Remote binds on non-loopback addresses need `GatewayPorts` / `AllowTcpForwarding` on `sshd` (documented on the marketing site). @@ -213,13 +214,15 @@ Failed restarts on reconnect surface a **toast** per tunnel. | Surface | Role | |---------|------| -| **Port Forwarding tab** | Per-host tunnel list; grid/list toggle; start/stop/edit | -| **Global dashboard** | All tunnels across connections; search; grid/list; group collapse | +| **Port Forwarding tab** | Per-host compact table or matching grid tiles; search + status/type filters | +| **Global dashboard** | All tunnels across connections; same views + filters; collapsible **host** groups | | **Add / edit modal** | Presets, bind address, auto-start, groups | | **Import modal** | Paste `ssh -L` / `-R` / `-D` command | -| **Tunnel card** | Status, flow line, type badges (incl. SOCKS), action bar | +| **Tunnel row / tile** | Status, name, type (`-L`/`-R`/`-D`), flow, Start/Stop, copy, open, overflow menu | -**Global list grouping:** UI groups by user-defined `group` field, **not** by connection. Connection name appears on each card. +**Global list grouping:** UI groups by **SSH host** (connection). User-defined `group` is a secondary label on the row. The per-host tab still sections by named group when any tunnel on that host has a group. + +**Views:** List is default. Grid uses the same type, status, flow, and actions as the list, laid out as quiet tiles. Copy and Open sit on the row/tile; Edit/Delete stay in the overflow menu. **Parity:** Both surfaces share `tunnelSlice` state. Start/stop from either surface reflects immediately in the other via events. @@ -368,7 +371,7 @@ Directional work — not a commitment order. ### Observability - Bytes in/out per tunnel (optional) -- Last error + uptime on tunnel cards +- Last error + uptime on tunnel rows - Health probe for HTTP forwards (optional HEAD request) ### UI @@ -388,7 +391,7 @@ Directional work — not a commitment order. ``` src/components/tunnel/ - TunnelManager.tsx, GlobalTunnelList.tsx, TunnelCard.tsx + TunnelManager.tsx, GlobalTunnelList.tsx, TunnelRow.tsx, TunnelCard.tsx src/components/modals/ AddTunnelModal.tsx, ImportSSHCommandModal.tsx From 4784865aeb246ac2ad0adb73be9dc5329f77be9b Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 14:39:07 +0530 Subject: [PATCH 09/14] fix(share): drain WebSocket uplink in channel order Write req_rx fully before extra_rx so TYPE_DATA before the first TYPE_CLOSE is not interleaved with later frames. --- src-tauri/src/share/proxy.rs | 65 +++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/src-tauri/src/share/proxy.rs b/src-tauri/src/share/proxy.rs index f49d094..2ba7cf8 100644 --- a/src-tauri/src/share/proxy.rs +++ b/src-tauri/src/share/proxy.rs @@ -7,6 +7,7 @@ use std::sync::OnceLock; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; +use tokio::sync::mpsc; const CHUNK_SIZE: usize = 24 * 1024; const DIAL_TIMEOUT: Duration = Duration::from_secs(10); @@ -254,38 +255,11 @@ async fn proxy_websocket( } let (mut read_half, mut write_half) = stream.into_split(); - let mut req_rx = readers.req_rx; - let mut extra_rx = readers.extra_rx; - // Merge body-channel and post-close uplink so early TYPE_DATA before the - // first TYPE_CLOSE is not dropped (WebSocket upgrade request bodies). + // Drain the HTTP-body channel fully before post-close uplink so TYPE_DATA + // before the first TYPE_CLOSE stays ahead of later frames. let uplink = async { - let mut req_done = false; - let mut extra_done = false; - while !req_done || !extra_done { - tokio::select! { - chunk = req_rx.recv(), if !req_done => { - match chunk { - Some(c) => { - if write_half.write_all(&c).await.is_err() { - break; - } - } - None => req_done = true, - } - } - chunk = extra_rx.recv(), if !extra_done => { - match chunk { - Some(c) => { - if write_half.write_all(&c).await.is_err() { - break; - } - } - None => extra_done = true, - } - } - } - } + write_ordered_uplink(readers.req_rx, readers.extra_rx, &mut write_half).await; let _ = write_half.shutdown().await; }; let downlink = async { @@ -428,6 +402,23 @@ fn is_loopback_url(target: &url::Url) -> bool { } } +async fn write_ordered_uplink( + mut req_rx: mpsc::Receiver, + mut extra_rx: mpsc::Receiver, + write: &mut W, +) { + while let Some(chunk) = req_rx.recv().await { + if write.write_all(&chunk).await.is_err() { + return; + } + } + while let Some(chunk) = extra_rx.recv().await { + if write.write_all(&chunk).await.is_err() { + return; + } + } +} + async fn read_http_headers( stream: &mut TcpStream, ) -> Result<(u16, HashMap>, Vec), String> { @@ -489,4 +480,18 @@ mod tests { let u = url::Url::parse("http://127.0.0.1:3000").unwrap(); assert_eq!(localhost_http_host(&u), "localhost:3000"); } + + #[tokio::test] + async fn uplink_writes_req_channel_before_extra() { + let (req_tx, req_rx) = mpsc::channel(8); + let (extra_tx, extra_rx) = mpsc::channel(8); + extra_tx.send(Bytes::from_static(b"B")).await.unwrap(); + req_tx.send(Bytes::from_static(b"A")).await.unwrap(); + drop(req_tx); + drop(extra_tx); + + let mut buf = Vec::new(); + write_ordered_uplink(req_rx, extra_rx, &mut buf).await; + assert_eq!(&buf, b"AB"); + } } From 72d839bb35bad22440d412a5f42b515a4ddc2608 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Sat, 29 Aug 2026 14:40:08 +0530 Subject: [PATCH 10/14] fix(tunnels): retain orphan remote probes and harden PF UX Keep remote probe binds that fail to cancel so disconnect can clean them up. Await clipboard writes, reveal overflow and Start/Stop all on keyboard focus, and tone down remote-probe docs wording. --- CHANGELOG.md | 3 +- docs/TUNNELS.md | 2 +- src-tauri/src/tunnels/commands.rs | 13 ++++ src-tauri/src/tunnels/manager.rs | 91 ++++++++++++++++++---- src/components/tunnel/GlobalTunnelList.tsx | 8 +- src/components/tunnel/TunnelManager.tsx | 6 +- src/components/tunnel/TunnelRow.tsx | 4 +- 7 files changed, 105 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be5859..c9c723b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ All notable changes to Zync are documented in this file. The format is based on - **Port Forwarding list**: Compact table grouped by SSH host, with a matching quiet grid. Search, status/type filters, host icons, persisted list/grid, and Copy/Open on the row. ### Fixed -- **Remote port conflict**: Busy SSH `-R` listen ports use the same next-port / manual picker as local forwards. If every probe fails (sshd policy), the original reject is kept. +- **Remote port conflict**: Busy SSH `-R` listen ports use the same next-port / manual picker as local forwards. If every probe fails, the original reject is kept. Probe binds that cannot be cancelled stay tracked until disconnect. +- **Public URLs WebSocket**: Forward pre-close request bytes before later frames so upgrade payloads stay in order. ## [2.26.1] - 2026-08-28 diff --git a/docs/TUNNELS.md b/docs/TUNNELS.md index 9ec0c3a..7e30591 100644 --- a/docs/TUNNELS.md +++ b/docs/TUNNELS.md @@ -163,7 +163,7 @@ Equivalent to `ssh -R [bind:]remotePort:localHost:localPort`. - Requests `tcpip_forward` on the SSH server for `bindAddress:remotePort`. - Incoming forwarded connections are proxied to the local target (`remoteHost` in the saved config stores the **local target host** for `-R`). -- If the server rejects the listen and a later port on the same bind succeeds, Zync surfaces the same **port conflict** picker as local (suggested next port or manual). If every probe fails, the original reject is kept (usually `sshd` policy, not a busy port). +- If the server rejects the listen and a later port on the same bind succeeds, Zync surfaces the same **port conflict** picker as local (suggested next port or manual). If every probe fails, the original rejection is kept. **Server requirement:** Remote binds on non-loopback addresses need `GatewayPorts` / `AllowTcpForwarding` on `sshd` (documented on the marketing site). diff --git a/src-tauri/src/tunnels/commands.rs b/src-tauri/src/tunnels/commands.rs index 0769c71..523f543 100644 --- a/src-tauri/src/tunnels/commands.rs +++ b/src-tauri/src/tunnels/commands.rs @@ -270,6 +270,19 @@ pub(crate) async fn stop_tunnels_for_connections( ); } + for connection_id in connection_ids { + let session = { + let connections = state.connections.lock().await; + connections + .get(connection_id) + .and_then(|c| c.session.clone()) + }; + state + .tunnel_manager + .cancel_orphan_remote_forwards(session, connection_id) + .await; + } + Ok(()) } diff --git a/src-tauri/src/tunnels/manager.rs b/src-tauri/src/tunnels/manager.rs index e87a64f..6a5ff71 100644 --- a/src-tauri/src/tunnels/manager.rs +++ b/src-tauri/src/tunnels/manager.rs @@ -390,15 +390,24 @@ impl TunnelManager { }) .collect() }; - if let Some(suggested) = find_next_available_remote_port( + let probe = find_next_available_remote_port( &session, &bind_address, remote_port, 10, &occupied, ) - .await - { + .await; + if !probe.unreleased.is_empty() { + let mut map = self.remote_forwards.lock().await; + for port in &probe.unreleased { + map.insert( + remote_forward_map_key(&connection_id, *port), + (local_host.clone(), local_port, bind_address.clone()), + ); + } + } + if let Some(suggested) = probe.suggested { return Err(anyhow!( "Port {} is already in use. Port {} is available.", remote_port, @@ -488,6 +497,41 @@ impl TunnelManager { } Ok(()) } + + /// Cancel leftover remote binds for a connection (probe ports whose cancel failed). + pub async fn cancel_orphan_remote_forwards( + &self, + session: Option>>>, + connection_id: &str, + ) { + let leftovers: Vec<(String, String, u16)> = { + let map = self.remote_forwards.lock().await; + map.iter() + .filter_map(|(key, (_, _, bind))| { + let (conn, port) = key.rsplit_once(':')?; + if conn != connection_id { + return None; + } + Some((key.clone(), bind.clone(), port.parse().ok()?)) + }) + .collect() + }; + for (key, bind, port) in leftovers { + if let Some(session) = &session { + let handle = session.lock().await; + if let Err(e) = handle + .cancel_tcpip_forward(bind.clone(), port as u32) + .await + { + warn!( + "[TUNNEL] Failed to cancel orphan remote forward {} (bind {}): {}", + key, bind, e + ); + } + } + self.remote_forwards.lock().await.remove(&key); + } + } } /// Attempts to find which process is using the specified port. @@ -575,15 +619,21 @@ fn is_remote_forward_rejected(err: &str) -> bool { .contains("rejected by the other party") } +struct RemotePortProbe { + suggested: Option, + unreleased: Vec, +} + /// Probe the next remote listen ports via `tcpip-forward`, then cancel the probe bind. -/// If none succeed, the original reject is likely policy (forwards disabled), not a busy port. +/// Ports that bind but fail to cancel stay in `unreleased` so the caller can keep ownership. async fn find_next_available_remote_port( session: &Arc>>, bind_address: &str, start_port: u16, max_attempts: u8, occupied: &HashSet, -) -> Option { +) -> RemotePortProbe { + let mut unreleased = Vec::new(); for offset in 1..=max_attempts { let candidate = start_port.saturating_add(offset.into()); if candidate == 0 || candidate == start_port || occupied.contains(&candidate) { @@ -617,16 +667,26 @@ async fn find_next_available_remote_port( } } }; - if let Some(port) = remote_probe_suggestion(candidate, cancel_ok) { - return Some(port); + if let Some(port) = record_remote_probe(cancel_ok, candidate, &mut unreleased) { + return RemotePortProbe { + suggested: Some(port), + unreleased, + }; } } - None + RemotePortProbe { + suggested: None, + unreleased, + } } -/// A probed remote port is only reusable if we also released the probe bind. -fn remote_probe_suggestion(candidate: u16, cancel_ok: bool) -> Option { - cancel_ok.then_some(candidate) +fn record_remote_probe(cancel_ok: bool, candidate: u16, unreleased: &mut Vec) -> Option { + if cancel_ok { + Some(candidate) + } else { + unreleased.push(candidate); + None + } } async fn find_next_available_port(start_port: u16, max_attempts: u8) -> Option { @@ -692,9 +752,12 @@ mod tests { } #[test] - fn remote_probe_not_suggested_when_cancel_fails() { - assert_eq!(remote_probe_suggestion(8081, false), None); - assert_eq!(remote_probe_suggestion(8081, true), Some(8081)); + fn failed_cancel_retains_unreleased_candidate() { + let mut unreleased = Vec::new(); + assert_eq!(record_remote_probe(false, 9001, &mut unreleased), None); + assert_eq!(unreleased, vec![9001]); + assert_eq!(record_remote_probe(true, 9002, &mut unreleased), Some(9002)); + assert_eq!(unreleased, vec![9001]); } #[test] diff --git a/src/components/tunnel/GlobalTunnelList.tsx b/src/components/tunnel/GlobalTunnelList.tsx index 18ed486..8bdb971 100644 --- a/src/components/tunnel/GlobalTunnelList.tsx +++ b/src/components/tunnel/GlobalTunnelList.tsx @@ -485,8 +485,10 @@ export function GlobalTunnelList() { onDelete: handleDeleteTunnel, onOpenBrowser: handleOpenBrowser, onCopy: (text: string) => { - navigator.clipboard.writeText(text); - showToast('success', 'Copied'); + void navigator.clipboard.writeText(text).then( + () => showToast('success', 'Copied'), + () => showToast('error', 'Could not copy'), + ); }, }; return ( @@ -534,7 +536,7 @@ export function GlobalTunnelList() { {running}/{group.tunnels.length}
-
+
{running > 0 && (
-
+
e.stopPropagation()} + > {running > 0 && (