diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9987ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run build + + rust: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src-tauri + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install Tauri system dependencies + working-directory: . + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev + - run: cargo check --locked + - run: cargo test --locked --lib diff --git a/.gitignore b/.gitignore index 8c0ecee..608c176 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ src-tauri/target/ src-tauri/gen/ .kimi/ AGENTS.md + +# Built installers — publish via GitHub Releases, not git +release/ diff --git a/README.md b/README.md index 5617636..172d150 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,12 @@ You can also **add any custom port** via the in-app input. --- + ## 🚀 Installation ### For Users (Just want the .exe) -1. Download `EnvTunnel_0.1.0_x64-setup.exe` from [Releases](../../releases) +1. Download the Windows installer from [Releases](../../releases) 2. Run the installer 3. Launch EnvTunnel from Start Menu or Desktop @@ -83,7 +84,7 @@ You can also **add any custom port** via the in-app input. ```bash # Clone -git clone https://github.com/YOUR_USERNAME/envtunnel.git +git clone https://github.com/hsr88/envtunnel.git cd envtunnel # Install dependencies @@ -93,7 +94,7 @@ npm install npm run tauri build # The .exe will be at: -# src-tauri/target/release/app.exe +# src-tauri/target/release/envtunnel.exe ``` > **Note for Windows builders:** If you get `LNK1181: cannot open input file legacy_stdio_definitions.lib`, add this to your `LIB` environment variable: diff --git a/docs/images/favicon.png b/docs/images/favicon.png index f66494c..0e955b4 100644 Binary files a/docs/images/favicon.png and b/docs/images/favicon.png differ diff --git a/docs/index.html b/docs/index.html index d150c5d..66e563a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -136,24 +136,24 @@

> DOWNLOAD

INSTALLER
-
EnvTunnel_0.1.0_x64-setup.exe
-
~2 MB | Recommended
+
EnvTunnel Setup (x64)
+
v1.0.0 · ~4 MB · Recommended
MSI
-
EnvTunnel_0.1.0_x64_en-US.msi
-
~3 MB | Enterprise / IT
+
EnvTunnel MSI (x64)
+
v1.0.0 · ~5 MB · Enterprise / IT
PORTABLE
EnvTunnel.exe
-
~9 MB | No install
+
v1.0.0 · ~12 MB · No install
- Direct download links from GitHub Releases. + Direct downloads from GitHub Releases.
- Looking for macOS or Linux? Build from source. + Looking for macOS or Linux? Build from source.
@@ -166,9 +166,9 @@

> DOWNLOAD

Open source under MIT License diff --git a/package-lock.json b/package-lock.json index e15c04e..38d2cec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "envtunnel", - "version": "0.0.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "envtunnel", - "version": "0.0.0", + "version": "1.0.0", "dependencies": { "@tailwindcss/vite": "^4.3.0", "@tauri-apps/api": "^2.11.0", diff --git a/package.json b/package.json index 1372b4c..8865ecd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "envtunnel", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/public/icon.png b/public/icon.png index f66494c..0e955b4 100644 Binary files a/public/icon.png and b/public/icon.png differ diff --git a/release/EnvTunnel.exe b/release/EnvTunnel.exe deleted file mode 100644 index 8c18a98..0000000 Binary files a/release/EnvTunnel.exe and /dev/null differ diff --git a/release/EnvTunnel_0.1.0_x64-setup.exe b/release/EnvTunnel_0.1.0_x64-setup.exe deleted file mode 100644 index 7ecd1e2..0000000 Binary files a/release/EnvTunnel_0.1.0_x64-setup.exe and /dev/null differ diff --git a/release/EnvTunnel_0.1.0_x64_en-US.msi b/release/EnvTunnel_0.1.0_x64_en-US.msi deleted file mode 100644 index d2367e0..0000000 Binary files a/release/EnvTunnel_0.1.0_x64_en-US.msi and /dev/null differ diff --git a/screenshot-1.png b/screenshot-1.png deleted file mode 100644 index 0672809..0000000 Binary files a/screenshot-1.png and /dev/null differ diff --git a/screenshot-2.png b/screenshot-2.png deleted file mode 100644 index 153cfdb..0000000 Binary files a/screenshot-2.png and /dev/null differ diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index 0672809..0000000 Binary files a/screenshot.png and /dev/null differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 397140a..4cd96f7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -927,7 +927,7 @@ dependencies = [ [[package]] name = "envtunnel" -version = "0.1.0" +version = "1.0.0" dependencies = [ "local-ip-address", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 12dc6f3..6961eff 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "envtunnel" -version = "0.1.0" +version = "1.0.0" description = "EnvTunnel - Local Development Port Scanner with QR Codes" authors = ["envtunnel"] license = "MIT" -repository = "" +repository = "https://github.com/hsr88/envtunnel" edition = "2021" rust-version = "1.77.2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dcd65fc..547f542 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,14 +13,117 @@ pub struct PortStatus { pub framework: Option, } +fn is_skipped_iface(name: &str) -> bool { + let n = name.to_lowercase(); + const SKIP: &[&str] = &[ + "vpn", + "tun", + "tap", + "wsl", + "vethernet", + "docker", + "hyper-v", + "vbox", + "vmware", + "virtual", + "loopback", + "isatap", + "teredo", + "bluetooth", + "nordlynx", + "tailscale", + "wg0", + "wireguard", + "hamachi", + "zerotier", + "br-", + ]; + SKIP.iter().any(|s| n.contains(s)) +} + +/// Prefer typical LAN ranges (192.168, then 10.x, then 172.16-31) and skip +/// VPN/WSL/CGNAT addresses so QR codes point at an IP phones can actually reach. +fn lan_priority(v4: std::net::Ipv4Addr) -> Option { + if v4.is_loopback() || v4.is_unspecified() || v4.is_link_local() || v4.is_multicast() { + return None; + } + let o = v4.octets(); + // CGNAT / Tailscale 100.64.0.0/10 + if o[0] == 100 && (64..=127).contains(&o[1]) { + return None; + } + if o[0] == 192 && o[1] == 168 { + Some(0) + } else if o[0] == 10 { + Some(1) + } else if o[0] == 172 && (16..=31).contains(&o[1]) { + Some(2) + } else { + None + } +} + #[tauri::command] fn get_local_ip() -> Result { + if let Ok(ifaces) = local_ip_address::list_afinet_netifas() { + let mut best: Option<(u8, String)> = None; + for (name, ip) in ifaces { + if is_skipped_iface(&name) { + continue; + } + let std::net::IpAddr::V4(v4) = ip else { + continue; + }; + let Some(prio) = lan_priority(v4) else { + continue; + }; + match &best { + None => best = Some((prio, v4.to_string())), + Some((best_prio, _)) if prio < *best_prio => { + best = Some((prio, v4.to_string())); + } + _ => {} + } + } + if let Some((_, ip)) = best { + return Ok(ip); + } + } + match local_ip_address::local_ip() { Ok(ip) => Ok(ip.to_string()), Err(e) => Err(format!("Failed to get local IP: {}", e)), } } +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn lan_priority_prefers_rfc1918() { + assert_eq!(lan_priority(Ipv4Addr::new(192, 168, 1, 15)), Some(0)); + assert_eq!(lan_priority(Ipv4Addr::new(10, 0, 0, 2)), Some(1)); + assert_eq!(lan_priority(Ipv4Addr::new(172, 16, 0, 1)), Some(2)); + assert_eq!(lan_priority(Ipv4Addr::new(100, 64, 0, 1)), None); + assert_eq!(lan_priority(Ipv4Addr::new(8, 8, 8, 8)), None); + assert_eq!(lan_priority(Ipv4Addr::LOCALHOST), None); + assert_eq!(lan_priority(Ipv4Addr::new(169, 254, 1, 1)), None); + } + + #[test] + fn skips_virtual_ifaces() { + assert!(is_skipped_iface("vEthernet (WSL)")); + assert!(is_skipped_iface("NordLynx")); + assert!(is_skipped_iface("Tailscale")); + assert!(is_skipped_iface("Docker Bridge")); + assert!(!is_skipped_iface("Wi-Fi")); + assert!(!is_skipped_iface("Ethernet")); + assert!(!is_skipped_iface("en0")); + } +} + async fn detect_framework(url: &str) -> Option { let client = reqwest::Client::builder() .timeout(Duration::from_secs(2)) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8cad7cc..4ab7b41 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "EnvTunnel", - "version": "0.1.0", + "version": "1.0.0", "identifier": "com.envtunnel.app", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index f51e3e5..3de046a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,31 @@ interface Toast { message: string } +const STORAGE_PORTS = 'envtunnel-custom-ports' +const STORAGE_PATH = 'envtunnel-custom-path' + +function readStoredCustomPorts(): number[] { + try { + const raw = localStorage.getItem(STORAGE_PORTS) + if (!raw) return [] + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return [...new Set(parsed.filter((p): p is number => + typeof p === 'number' && Number.isInteger(p) && p > 0 && p < 65536 + ))] + } catch { + return [] + } +} + +function readStoredCustomPath(): string { + try { + return localStorage.getItem(STORAGE_PATH) ?? '' + } catch { + return '' + } +} + function App() { const [ip, setIp] = useState(null) const [ports, setPorts] = useState([]) @@ -28,22 +53,27 @@ function App() { const [autostartLoading, setAutostartLoading] = useState(true) const [customPortInput, setCustomPortInput] = useState('') - const [customPorts, setCustomPorts] = useState([]) - const [customPath, setCustomPath] = useState('') + const [customPorts, setCustomPorts] = useState(readStoredCustomPorts) + const [customPath, setCustomPath] = useState(readStoredCustomPath) const [toasts, setToasts] = useState([]) const toastIdRef = useRef(0) const prevPortsRef = useRef([]) + const selectedPortRef = useRef(null) const [freshPorts, setFreshPorts] = useState>(new Set()) const qrWrapperRef = useRef(null) - const addToast = (message: string) => { + useEffect(() => { + selectedPortRef.current = selectedPort + }, [selectedPort]) + + const addToast = useCallback((message: string) => { const id = ++toastIdRef.current setToasts(prev => [...prev, { id, message }]) setTimeout(() => { setToasts(prev => prev.filter(t => t.id !== id)) }, 4000) - } + }, []) const fetchIp = useCallback(async () => { try { @@ -51,7 +81,7 @@ function App() { setIp(localIp) setError(null) return localIp - } catch (err) { + } catch { setError('ERROR: Failed to retrieve local IP') setLoading(false) return null @@ -85,11 +115,13 @@ function App() { } prevPortsRef.current = results - const activePorts = results.filter((p: PortStatus) => p.active) + const activePorts = results.filter(p => p.active) + const currentSelected = selectedPortRef.current if (activePorts.length > 0) { - if (!selectedPort || !results.find((p: PortStatus) => p.port === selectedPort.port && p.active)) { - setSelectedPort(activePorts[0]) - } + const stillActive = currentSelected + ? results.find(p => p.port === currentSelected.port && p.active) + : undefined + setSelectedPort(stillActive ?? activePorts[0]) } else { setSelectedPort(null) } @@ -102,28 +134,47 @@ function App() { setScanning(false) setLoading(false) } - }, [customPorts, selectedPort]) + }, [customPorts, addToast]) useEffect(() => { - let isMounted = true - const init = async () => { - const localIp = await fetchIp() - if (localIp && isMounted) await scanPorts(localIp) + let cancelled = false + let timeoutId: number + + const tick = async (targetIp: string) => { + await scanPorts(targetIp) + if (!cancelled) { + timeoutId = window.setTimeout(() => tick(targetIp), 3000) + } + } + + const start = async () => { + const localIp = ip ?? await fetchIp() + if (cancelled || !localIp) return + await tick(localIp) + } + + start() + return () => { + cancelled = true + clearTimeout(timeoutId) } - init() - return () => { isMounted = false } - }, [fetchIp, scanPorts]) + }, [ip, scanPorts, fetchIp]) useEffect(() => { - if (!ip) return - let timeoutId: number - const tick = async () => { - await scanPorts(ip) - timeoutId = window.setTimeout(tick, 3000) + try { + localStorage.setItem(STORAGE_PORTS, JSON.stringify(customPorts)) + } catch { + // storage may be unavailable + } + }, [customPorts]) + + useEffect(() => { + try { + localStorage.setItem(STORAGE_PATH, customPath) + } catch { + // storage may be unavailable } - timeoutId = window.setTimeout(tick, 3000) - return () => clearTimeout(timeoutId) - }, [ip, scanPorts]) + }, [customPath]) useEffect(() => { const checkAutostart = async () => { @@ -214,7 +265,7 @@ function App() { ENVTUNNEL - v1.0 + v1.0.0
diff --git a/src/assets/hero.png b/src/assets/hero.png deleted file mode 100644 index 02251f4..0000000 Binary files a/src/assets/hero.png and /dev/null differ diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite