diff --git a/.env.example b/.env.example index 4f1a308c..6d5be995 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ # Optional: zync-survey API base URL (no trailing slash). # Dev default in code is http://127.0.0.1:8090 when unset. +# Production releases MUST bake this via GitHub Actions secret VITE_SURVEY_API_URL. # VITE_SURVEY_API_URL=https://survey.example.com +# +# Public URLs (Rust, compile-time). Never commit production hosts as source defaults. +# Release workflow injects GitHub secrets ZYNC_SHARE_API_BASE and ZYNC_SHARE_RELAY_URL. +# Debug/dev without these uses http://127.0.0.1:8080 and :8081 (local zync-share). +# ZYNC_SHARE_API_BASE=http://127.0.0.1:8080 +# ZYNC_SHARE_RELAY_URL=http://127.0.0.1:8081 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbe270b9..1dc4cda5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,10 @@ jobs: GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} # Baked into the Vite bundle for survey/feedback API (prod). VITE_SURVEY_API_URL: ${{ secrets.VITE_SURVEY_API_URL }} + # Baked into the Rust binary at compile time. Do not hardcode production + # Public URLs hosts in source. Set these repo secrets before tagging. + ZYNC_SHARE_API_BASE: ${{ secrets.ZYNC_SHARE_API_BASE }} + ZYNC_SHARE_RELAY_URL: ${{ secrets.ZYNC_SHARE_RELAY_URL }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e843cf2..de8b5501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to Zync are documented in this file. The format is based on ## [Unreleased] +## [2.27.1] - 2026-08-29 + +### 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. ([b44475a], [d826b82], [97ccad1], [c8da354]) + +### 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. ([1203446], [d2d12e1], [72d839b]) + +### Fixed +- **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. ([9acc835], [72d839b]) +- **Public URLs WebSocket**: Forward pre-close request bytes before later frames so upgrade payloads stay in order. ([4784865]) +- **Public URLs build config**: Production API/relay hosts are no longer hardcoded. Release builds bake `ZYNC_SHARE_API_BASE` and `ZYNC_SHARE_RELAY_URL` from GitHub Actions secrets. Debug builds may use local `zync-share` loopback. + ## [2.26.1] - 2026-08-28 ### Fixed @@ -1301,9 +1314,19 @@ Partial draft: desktop builds, AppImage Wayland strip, and APT `2.25.4` publishe [d3f4060]: https://github.com/zync-sh/zync/commit/d3f4060 [840afc2]: https://github.com/zync-sh/zync/commit/840afc2 [193f568]: https://github.com/zync-sh/zync/commit/193f568 -[Unreleased]: https://github.com/zync-sh/zync/compare/v2.26.1...HEAD +[Unreleased]: https://github.com/zync-sh/zync/compare/v2.27.1...HEAD +[2.27.1]: https://github.com/zync-sh/zync/compare/v2.26.1...v2.27.1 [2.26.1]: https://github.com/zync-sh/zync/compare/v2.26.0...v2.26.1 [2.26.0]: https://github.com/zync-sh/zync/compare/v2.25.8...v2.26.0 +[b44475a]: https://github.com/zync-sh/zync/commit/b44475a +[d826b82]: https://github.com/zync-sh/zync/commit/d826b82 +[97ccad1]: https://github.com/zync-sh/zync/commit/97ccad1 +[c8da354]: https://github.com/zync-sh/zync/commit/c8da354 +[1203446]: https://github.com/zync-sh/zync/commit/1203446 +[d2d12e1]: https://github.com/zync-sh/zync/commit/d2d12e1 +[72d839b]: https://github.com/zync-sh/zync/commit/72d839b +[9acc835]: https://github.com/zync-sh/zync/commit/9acc835 +[4784865]: https://github.com/zync-sh/zync/commit/4784865 [2.25.8]: https://github.com/zync-sh/zync/compare/v2.25.7...v2.25.8 [2.25.7]: https://github.com/zync-sh/zync/compare/v2.25.6...v2.25.7 [2.25.6]: https://github.com/zync-sh/zync/compare/v2.25.5...v2.25.6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35a07321..7c6fe772 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,8 @@ Thank you for your interest in contributing to Zync. This document provides guid 4. Run `npm install` and `npm run tauri dev` to start the development environment. +**Public URLs / survey hosts:** do not hardcode production API URLs in source. Debug builds may talk to local `zync-share` / `zync-survey`. Release builds must bake `ZYNC_SHARE_API_BASE`, `ZYNC_SHARE_RELAY_URL`, and `VITE_SURVEY_API_URL` from GitHub Actions secrets. + **Staying in sync:** Before starting new work, pull the latest from upstream: ```bash git fetch upstream diff --git a/README.md b/README.md index 34c47d7f..7c30d78f 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Operator details: [`packaging/arch/README.md`](packaging/arch/README.md). | Document | Covers | |----------|--------| -| [docs/SECURITY.md](docs/SECURITY.md) | Vault, sync, OAuth, and operator security guidance | +| [docs/SECURITY.md](docs/SECURITY.md) | Vault, sync, Public URLs, OAuth, and operator security guidance | | [docs/VAULT.md](docs/VAULT.md) | Vault, credentials, Google sync | | [docs/TERMINAL.md](docs/TERMINAL.md) | Terminal system (IPC, renderer, lifecycle, settings) | | [docs/TERMINAL_GHOST.md](docs/TERMINAL_GHOST.md) | Ghost completions (inline, history, paths, suggestion engine) | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2b51a874..9dbd249b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,13 +1,13 @@ # Security Notes -**Last updated:** 2026-07-01 -**Scope:** Vault, Google sync, credential handling, and operator guidance for current Zync releases. +**Last updated:** 2026-08-29 +**Scope:** Vault, Google Drive sync, Public URLs (Beta), credential handling, and operator guidance for current Zync releases. --- ## Summary -Zync's main security surface is **encrypted local vaulting**, optional **remember-on-device unlock**, and **Google Drive–backed encrypted sync**. This document explains how those systems behave, what users and operators should know, and what remains out of scope. +Zync’s core security surface is **encrypted local vaulting**, optional **remember-on-device unlock**, and **Google Drive–backed encrypted sync**. From v2.27.1, an optional **Public URLs (Beta)** feature adds a separate Zync account (GitHub/Google share login) and a localhost-only share agent that talks to Zync-operated API and relay hosts. Zync does not automatically send SSH sessions, vault secrets, or terminal content to those hosts. HTTP, WebSocket, and TCP bytes from the **user-selected shared loopback port** are proxied through the relay while the share is active. This document explains how those systems behave, what users and operators should know, and what remains out of scope. --- @@ -36,6 +36,17 @@ Zync's main security surface is **encrypted local vaulting**, optional **remembe - Sync collections use a **separate encryption passphrase** (local-vault-derived or custom). - OAuth uses Google's installed/desktop app flow with scoped access to Drive app data and account email. +### Public URLs (Beta) + +The Public URLs feature is **not** SSH port forwarding (`-L` / `-R` / `-D`). It is an optional SaaS surface: a Zync account, a desktop share agent, and Zync-operated API + HTTPS/WSS relay. + +- **Separate OAuth clients.** Drive Sync login is not Public URLs sign-in. GitHub/Google used for Public URLs are share-account clients (email/profile), not `drive.appdata`. +- **Localhost-only agent.** The desktop agent proxies only to loopback targets (`127.0.0.1` / `localhost` / `::1`). Non-loopback hosts are refused. +- **Link access.** Anyone with the public HTTPS URL can reach that local port while the share is active, unless you set an optional share password. Treat the URL like a capability. +- **Lifetime.** A share stays up while the **share agent** is running on this device. Dropping an SSH session does not stop a Public URL (and vice versa). Stop or delete the share (or sign out) to end it. +- **Tokens.** Share access/refresh material is stored in the OS keyring. Sign out clears the local session. +- **Beta.** Quota is a hard cap (no free/pro copy in-app). Report issues with the in-app bug link after sign-in. + --- ## Security Hardening @@ -47,7 +58,8 @@ Zync's main security surface is **encrypted local vaulting**, optional **remembe | **Tab open behavior** | Vault-backed connections defer auto-connect until explicit user reconnect | | **Sync durability** | Atomic JSON writes with fsync on production sync/vault paths; improved restore convergence and Windows finalize handling | | **Concurrent operations** | Guards against vault/sync state loss during overlapping provider and local operations | -| **Build-time secret filtering** | `build.rs` blocks most sensitive env keys from compile-time embedding; `GOOGLE_CLIENT_SECRET` is explicitly allowlisted only for desktop OAuth compatibility | +| **Build-time secret filtering** | `build.rs` blocks most sensitive env keys from compile-time embedding; Drive `GOOGLE_CLIENT_SECRET` is explicitly allowlisted only for desktop OAuth compatibility | +| **Public URLs agent** | Share proxy refuses non-loopback targets; OAuth callback binds loopback only | --- @@ -67,15 +79,23 @@ Zync's main security surface is **encrypted local vaulting**, optional **remembe - This trades convenience for risk: anyone with access to your unlocked OS session may access vault-backed connections without re-entering the passphrase until cache expiry or **Forget device**. - Do **not** enable remember-on-device on shared or untrusted machines. -### Google OAuth +### Google OAuth (Drive Sync) -- Official release builds embed **`GOOGLE_CLIENT_ID`** (and optionally **`GOOGLE_CLIENT_SECRET`**) for the Zync desktop OAuth client. +- Official release builds embed **`GOOGLE_CLIENT_ID`** (and optionally **`GOOGLE_CLIENT_SECRET`**) for the **Drive Sync** desktop OAuth client. - **Low risk, expected for desktop apps:** Google's installed-app model does not treat the client secret as confidential — it cannot be kept secret inside a distributed binary. This is normal for desktop OAuth and is **not** the same as leaking a server-side OAuth secret. - User data access still requires **per-user consent** and scoped tokens; extracting the embedded client pair alone does not grant access to someone else's Google data. - Do **not** reuse a production **web/server** OAuth client for Zync desktop builds. +- Do **not** reuse the Drive Sync client for Public URLs sign-in (and vice versa). - Google sync tokens are stored locally; disconnect/revoke flows clear provider tokens where implemented. - A future **PKCE-only** client (no embedded secret) is planned as hygiene improvement, not an urgent security blocker. +### Public URLs (Beta) operator notes + +- Treat an active Public URL as **internet exposure of that loopback port** via Zync’s relay. +- Do not share ports that bind privileged or sensitive local services unless you intend that exposure. +- Stop/delete the share when finished. Signing out of Zync ends the local agent session; revoke GitHub/Google app access if you want the account unlinked at the provider. +- Survey / Settings → Feedback POSTs (when used) go to a Zync-operated survey API. They are optional and do not include vault secrets, SSH keys, or terminal contents. + ### Backups and restore - Treat Google Drive sync collections as **encrypted backups**, not a live shared secrets broker. @@ -91,6 +111,7 @@ These are **product scope** limits today, not vulnerabilities: - **No team/org policy controls** — vault and sync are single-user oriented; shared/team vaults are deferred to later phases. - **No live bi-directional sync scheduling** — Google sync is manual upload/restore; there is no background auto-sync scheduler yet. - **Plugins** — marketplace plugins do **not** receive raw vault secrets by design; only explicit future export/copy flows could change that. +- **Public URLs Beta** — no team/org sharing, no custom domains, no pricing plans in-app; GA hardening is deferred. ### AI credential policy @@ -112,4 +133,6 @@ If you discover a vulnerability in Zync, report it privately to the maintainers - [VAULT.md](./VAULT.md) — vault and sync architecture - [VAULT_ROADMAP.md](./VAULT_ROADMAP.md) — planned vault/sync work +- [TUNNELS.md](./TUNNELS.md) — SSH port forwarding (separate from Public URLs) - [CHANGELOG.md](../CHANGELOG.md) — release history +- Privacy Policy (marketing site) — `https://zync.thesudoer.in/privacy` diff --git a/docs/TUNNELS.md b/docs/TUNNELS.md index 76de2616..7e30591c 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 rejection is kept. **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 diff --git a/package.json b/package.json index ae544112..182e1d8a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zync", "private": true, - "version": "2.26.1", + "version": "2.27.1", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4d7cdf49..d6df876c 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" @@ -8206,14 +8267,16 @@ dependencies = [ [[package]] name = "zync" -version = "2.26.1" +version = "2.27.1" dependencies = [ "anyhow", "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 5ab57a34..a50a2466 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zync" -version = "2.26.1" +version = "2.27.1" description = "A modern SSH client" authors = ["Gajendra"] edition = "2021" @@ -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 d64e716d..d57dabaf 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 00000000..464c1422 --- /dev/null +++ b/src-tauri/src/share/agent.rs @@ -0,0 +1,638 @@ +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 { + 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 => { + 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 00000000..23b64839 --- /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 00000000..2bb64ab3 --- /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 00000000..89ceacaf --- /dev/null +++ b/src-tauri/src/share/commands.rs @@ -0,0 +1,239 @@ +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() +} + +fn require_share_config(state: &ShareState) -> Result<(), String> { + if state.config.is_configured() { + Ok(()) + } else { + Err(super::err( + "share_unconfigured", + "Public URLs is not configured in this build. Release builds must set ZYNC_SHARE_API_BASE and ZYNC_SHARE_RELAY_URL at compile time.", + )) + } +} + +#[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 { + require_share_config(&state)?; + 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> { + require_share_config(&state)?; + load_shares(&state).await +} + +#[tauri::command] +pub async fn share_create( + state: State<'_, ShareState>, + port: i32, + name: Option, + password: Option, +) -> Result { + require_share_config(&state)?; + 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 { + require_share_config(&state)?; + 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 { + require_share_config(&state)?; + 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 { + require_share_config(&state)?; + 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> { + require_share_config(&state)?; + 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 00000000..592700dd --- /dev/null +++ b/src-tauri/src/share/config.rs @@ -0,0 +1,117 @@ +/// Loopback defaults for `cargo tauri dev` / debug tests only. Release builds +/// must set `ZYNC_SHARE_API_BASE` and `ZYNC_SHARE_RELAY_URL` at compile time. +const DEBUG_API_BASE: &str = "http://127.0.0.1:8080"; +const DEBUG_RELAY_URL: &str = "http://127.0.0.1:8081"; + +#[derive(Debug, Clone)] +pub struct ShareConfig { + pub api_base: String, + pub relay_url: String, +} + +impl ShareConfig { + pub fn from_env() -> Self { + Self { + api_base: baked_url(option_env!("ZYNC_SHARE_API_BASE"), DEBUG_API_BASE), + relay_url: baked_url(option_env!("ZYNC_SHARE_RELAY_URL"), DEBUG_RELAY_URL), + } + } + + pub fn is_configured(&self) -> bool { + !self.api_base.is_empty() && !self.relay_url.is_empty() + } + + pub fn api_url(&self, path: &str) -> String { + format!("{}{}", self.api_base, path) + } +} + +fn baked_url(compiled: Option<&str>, debug_fallback: &str) -> String { + if let Some(raw) = compiled { + let trimmed = raw.trim().trim_end_matches('/'); + if !trimmed.is_empty() { + return accept_share_url(trimmed); + } + } + if cfg!(debug_assertions) { + return debug_fallback.trim_end_matches('/').to_string(); + } + String::new() +} + +/// Release builds only accept https (so tickets/API never use cleartext http/ws). +/// Debug keeps http://127.0.0.1 for local zync-share. +fn accept_share_url(url: &str) -> String { + let lower = url.to_ascii_lowercase(); + if lower.starts_with("https://") || lower.starts_with("wss://") { + return url.to_string(); + } + if cfg!(debug_assertions) && (lower.starts_with("http://") || lower.starts_with("ws://")) { + return url.to_string(); + } + String::new() +} + +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://example.com/agent").unwrap(), + "wss://example.com/agent" + ); + } + + #[test] + fn baked_url_keeps_explicit_host() { + assert_eq!( + super::baked_url(Some("https://example.com/"), "http://127.0.0.1:8080"), + "https://example.com" + ); + } + + #[test] + fn baked_url_ignores_blank_compiled_value() { + let got = super::baked_url(Some(" "), "http://127.0.0.1:8080"); + if cfg!(debug_assertions) { + assert_eq!(got, "http://127.0.0.1:8080"); + } else { + assert!(got.is_empty()); + } + } + + #[test] + fn accept_share_url_allows_https() { + assert_eq!( + super::accept_share_url("https://example.com"), + "https://example.com" + ); + } + + #[test] + fn accept_share_url_rejects_cleartext_in_release() { + let got = super::accept_share_url("http://example.com"); + if cfg!(debug_assertions) { + assert_eq!(got, "http://example.com"); + } else { + assert!(got.is_empty()); + } + } +} diff --git a/src-tauri/src/share/mod.rs b/src-tauri/src/share/mod.rs new file mode 100644 index 00000000..ebff63fd --- /dev/null +++ b/src-tauri/src/share/mod.rs @@ -0,0 +1,64 @@ +//! 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 log::warn; +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(); + if !config.is_configured() { + warn!( + "[share] Public URLs is unconfigured in this build. Set ZYNC_SHARE_API_BASE and ZYNC_SHARE_RELAY_URL at compile time for release builds." + ); + } + 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 00000000..891b385b --- /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 00000000..2ba7cf8f --- /dev/null +++ b/src-tauri/src/share/proxy.rs @@ -0,0 +1,497 @@ +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; +use tokio::sync::mpsc; + +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(); + + // 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 { + write_ordered_uplink(readers.req_rx, readers.extra_rx, &mut write_half).await; + 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 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> { + let mut buf = Vec::new(); + let mut tmp = [0u8; 1024]; + loop { + 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()); + } + 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"); + } + + #[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"); + } +} diff --git a/src-tauri/src/share/stream.rs b/src-tauri/src/share/stream.rs new file mode 100644 index 00000000..77f31170 --- /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() +} diff --git a/src-tauri/src/tunnels/commands.rs b/src-tauri/src/tunnels/commands.rs index 0769c71a..523f543a 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 cb93a377..2724b664 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,52 @@ 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() + }; + let probe = find_next_available_remote_port( + &session, + &bind_address, + remote_port, + 10, + &occupied, + ) + .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, + suggested + )); + } + } + return Err(anyhow!("Remote forwarding error: {}", e)); } @@ -453,6 +497,56 @@ 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 { + let cancel_ok = if let Some(session) = &session { + let handle = session.lock().await; + match handle + .cancel_tcpip_forward(bind.clone(), port as u32) + .await + { + Ok(()) => true, + Err(e) => { + warn!( + "[TUNNEL] Failed to cancel orphan remote forward {} (bind {}): {}", + key, bind, e + ); + false + } + } + } else { + true + }; + if drop_orphan_after_cancel(session.is_some(), cancel_ok) { + self.remote_forwards.lock().await.remove(&key); + } + } + } +} + +fn drop_orphan_after_cancel(had_session: bool, cancel_ok: bool) -> bool { + if !had_session { + return true; + } + cancel_ok } /// Attempts to find which process is using the specified port. @@ -535,6 +629,81 @@ 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") +} + +struct RemotePortProbe { + suggested: Option, + unreleased: Vec, +} + +/// Probe the next remote listen ports via `tcpip-forward`, then cancel the probe bind. +/// 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, +) -> 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) { + 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) = record_remote_probe(cancel_ok, candidate, &mut unreleased) { + return RemotePortProbe { + suggested: Some(port), + unreleased, + }; + } + } + RemotePortProbe { + suggested: None, + unreleased, + } +} + +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 { for offset in 1..=max_attempts { let candidate_port = start_port.saturating_add(offset.into()); @@ -589,6 +758,30 @@ 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 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] + fn failed_orphan_cancel_keeps_map_entry() { + assert!(!drop_orphan_after_cancel(true, false)); + assert!(drop_orphan_after_cancel(true, true)); + assert!(drop_orphan_after_cancel(false, false)); + } + #[test] fn remote_forward_map_key_scopes_by_connection() { assert_eq!(remote_forward_map_key("host-1", 9000), "host-1:9000"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9c62a810..158efe49 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "zync", - "version": "2.26.1", + "version": "2.27.1", "identifier": "in.thesudoer.zync", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/layout/CommandPalette.tsx b/src/components/layout/CommandPalette.tsx index 268a48dc..6aabadc9 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 6ec296a6..7afc42a0 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 8175f031..772209d6 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/components/share/PublicUrlsLabel.tsx b/src/components/share/PublicUrlsLabel.tsx new file mode 100644 index 00000000..c59e9341 --- /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 00000000..1498664b --- /dev/null +++ b/src/components/share/PublicUrlsPanel.tsx @@ -0,0 +1,609 @@ +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, + useShareStore, +} from '../../features/share/useShareStore'; +import { + quotaFullMessage, + 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 && ( +
+ {quotaFullMessage(quotaUsed, quotaMax)} +
+ )} + {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 && ( +

{quotaFullMessage(quotaUsed, quotaMax)}

+ )} + 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/components/tunnel/GlobalTunnelList.tsx b/src/components/tunnel/GlobalTunnelList.tsx index 5627cc97..8e778a6b 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) => { + void navigator.clipboard.writeText(text).then( + () => showToast('success', 'Copied'), + () => showToast('error', 'Could not copy'), + ); + }, + }; + 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} + +
+
e.stopPropagation()} + > + {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 186e4e66..ae38fe2d 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,73 @@ 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) => { + void navigator.clipboard.writeText(text).then( + () => showToast('success', 'Copied'), + () => showToast('error', 'Could not copy'), + ); + }, + }; + 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 00000000..e7b86bec --- /dev/null +++ b/src/components/tunnel/TunnelRow.tsx @@ -0,0 +1,620 @@ +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, + }; +} diff --git a/src/features/connections/domain/types.ts b/src/features/connections/domain/types.ts index 8bb685bb..e5bee782 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/features/share/ipc.ts b/src/features/share/ipc.ts new file mode 100644 index 00000000..29ea8cb1 --- /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).trim(); + if (code === 'share_quota_exceeded' && !message) { + 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 00000000..5b91ec8d --- /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 00000000..07ab728d --- /dev/null +++ b/src/features/share/useShareStore.ts @@ -0,0 +1,277 @@ +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 }); + throw error; + } + }, + + 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 }; diff --git a/src/store/connectionSlice.ts b/src/store/connectionSlice.ts index 66448e32..fe67fc0e 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', () => ({