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
@@ -166,9 +166,9 @@ > DOWNLOAD
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 @@
-