A macOS menu bar app that gives you a secure, full terminal session accessible from your phone — no configuration servers, no open firewall ports, no insecure exposure.
Click the menu bar icon → get a QR code → scan from your phone → type in a full shell right in your browser.
┌─────────────────────────────────────────────────────────────┐
│ Your Mac (TunnelMan.app) │
│ │
│ PTY (zsh/bash) ←→ WebSocket Server ←→ Tunnel CLI │
│ ↓ ↓ │
│ terminal.html Tunnel URL │
│ (xterm.js) in QR code │
└─────────────────────────────────────────────────────────────┘
↕ TLS via tunnel infra
┌─────────────┐
│ Your Phone │
│ Browser │
│ xterm.js │
└─────────────┘
- TunnelMan spawns your shell (
$SHELL) in a PTY (pseudo-terminal) - A local HTTP + WebSocket server streams PTY I/O to an xterm.js frontend
- A tunnel CLI (
devtunnelorcloudflared) provides an authenticated HTTPS relay — no inbound firewall ports opened - A QR code encodes the tunnel URL plus a cryptographic session token — scan it and you're in
TunnelMan uses layered security and never exposes your Mac insecurely to the internet:
| Layer | What it does |
|---|---|
| Tunnel auth | DevTunnel: only your Microsoft/GitHub account can connect. Cloudflare: session token is the auth gate. Local: LAN-only, no external exposure. |
| Session token | A random 32-char hex UUID is generated per session and embedded in the QR code URL. The WebSocket server rejects connections with an invalid or missing token with 401. |
| TLS | All traffic is encrypted in transit by the tunnel infrastructure (Microsoft or Cloudflare). No self-signed certificates needed. |
| No open ports | Tunnel CLIs create outbound-only connections. Your Mac's firewall is not touched. |
- macOS 13 Ventura or later (arm64 or x86_64)
- Xcode Command Line Tools — the only Apple dependency required
If you don't have them yet (no full Xcode install needed):
xcode-select --installThis installs the Swift compiler, linker, and macOS SDK (~2 GB). Verify:
swift --version
# Apple Swift version 5.9 or laterMicrosoft DevTunnel (recommended — account-authenticated):
brew install --cask devtunnel
devtunnel user login # one-time login with Microsoft or GitHub accountCloudflare Tunnel (no account required for quick tunnels):
brew install cloudflaredLocal Network mode requires no external tools at all — it works on your LAN immediately.
git clone <repo-url>
cd tunnelman
makeThis produces output/TunnelMan.app — a self-contained macOS app bundle you can double-click, drag to /Applications, or run from the terminal.
| Target | Description |
|---|---|
make / make build |
Release build + .app bundle (default) |
make debug |
Debug binary via swift build |
make test |
Run the test suite |
make run |
Build and run the binary directly |
make open |
Build and open the .app in macOS |
make clean |
Remove .build/ and output/ |
- Runs
swift build -c release(uses only Xcode Command Line Tools, no Xcode.app) - Creates a proper
.appbundle structure inoutput/ - Generates
Info.plistwith correct metadata (menu-bar-only, bundle ID, version) - Copies the compiled binary and resource bundle into the app bundle
If you prefer to build manually:
# Compile
swift build -c release
# The raw binary is at:
.build/release/TunnelManNote: The raw binary works but won't appear as a proper macOS app in Finder or Spotlight. Use
makefor a real.appbundle.
If you have Xcode installed and prefer its IDE:
open Package.swift # opens the project in XcodeThen Product → Run (⌘R), or Product → Archive to build a distributable .app.
Note: For distributing outside the Mac App Store you'll need to set your Apple Developer signing identity in Xcode and notarize the app. The app uses
openptyandfork(via a C helper), which are not permitted in the Mac App Store sandbox.
# After building:
open output/TunnelMan.app
# Or run directly:
output/TunnelMan.app/Contents/MacOS/TunnelManswift runThe app will appear in your menu bar as a terminal icon (⌥). It intentionally hides from the Dock.
- Click the menu bar icon to open the popover
- Choose a tunnel mode from the bottom-left dropdown:
Local Network— works on the same WiFi, no external tools neededMicrosoft DevTunnel— accessible from anywhere, requiresdevtunnel user loginCloudflare Tunnel— accessible from anywhere, no account needed
- Click "Start Session"
- Scan the QR code with your phone, or click "Copy URL" and paste it in a browser
- A full interactive terminal opens in your phone's browser — type freely, run any CLI tool
- Click "Stop Session" when done — the tunnel tears down, the session token is invalidated
Click the gear icon (⚙) in the popover to open Settings:
| Setting | Description |
|---|---|
| Default tunnel mode | Persisted across launches |
| devtunnel status | Shows ✅ if installed, ❌ with install command if not |
| cloudflared status | Same |
| Launch at login | Registers with macOS Login Items (macOS 13+) |
- No external tools required
- Detects your Mac's LAN IP (e.g.
192.168.1.42) and serves on a random port - URL:
http://192.168.1.42:PORT/terminal?token=TOKEN - Works only when your phone is on the same WiFi network
- Session token provides authentication
- For Tailscale users: this mode works across Tailscale too
- Requires
devtunnelCLI and a one-timedevtunnel user login - Creates a private tunnel — only the authenticated account can connect (not a guessable public URL)
- URL:
https://UNIQUE-ID.devtunnels.ms/terminal?token=TOKEN - Free tier available; see Microsoft DevTunnel docs
- Requires
cloudflaredCLI; no Cloudflare account needed for quick tunnels - Creates a random
*.trycloudflare.comURL that's publicly reachable via HTTPS - Auth is the session token (embedded in the QR code) — without it the terminal page returns
401 - For stronger auth: set up a named Cloudflare Tunnel with Access policies (email OTP, GitHub OAuth, etc.)
- URL:
https://random-words.trycloudflare.com/terminal?token=TOKEN
tunnelman/
├── Package.swift # Swift Package manifest (macOS 13+)
├── Makefile # Builds TunnelMan.app (no Xcode required)
├── Sources/
│ ├── TunnelManHelper/ # C helper (fork/exec into PTY)
│ │ ├── include/pty_spawn.h
│ │ └── pty_spawn.c
│ ├── TunnelManCore/ # Pure logic (parsing, no OS I/O)
│ │ └── Parsing.swift
│ ├── TunnelManServer/ # Server, terminal, tunnel logic
│ │ ├── SessionManager.swift
│ │ ├── Server/
│ │ │ ├── LocalHTTPServer.swift
│ │ │ ├── HTTPConnection.swift
│ │ │ └── WebSocketConnection.swift
│ │ ├── Terminal/
│ │ │ └── PTYManager.swift
│ │ ├── Tunnel/
│ │ │ ├── TunnelProvider.swift
│ │ │ ├── LocalProvider.swift
│ │ │ ├── DevTunnelProvider.swift
│ │ │ ├── CloudflaredProvider.swift
│ │ │ └── ExecutableFinder.swift
│ │ └── Resources/
│ │ └── terminal.html
│ └── TunnelMan/ # SwiftUI app (menu bar UI)
│ ├── TunnelManApp.swift
│ ├── StatusBarController.swift
│ └── Views/
│ ├── PopoverView.swift
│ ├── QRCodeView.swift
│ └── SettingsView.swift
└── Tests/
└── TunnelManTests/
└── TunnelManTests.swift
No third-party Swift dependencies. The entire app uses only Apple frameworks:
| Need | Solution |
|---|---|
| PTY spawning | POSIX openpty() + C fork()/execve() shim |
| HTTP server | Network.framework NWListener |
| WebSocket | Manual RFC 6455 framing (no external lib) |
| QR code | CoreImage CIQRCodeGenerator |
| Crypto | CryptoKit Insecure.SHA1 (WebSocket handshake only) |
| Terminal UI | xterm.js loaded from CDN in terminal.html |
Why a C helper? Swift marks fork() as unavailable (it conflicts with Swift's concurrency runtime). A thin C file (pty_spawn.c) calls fork() + setsid() + TIOCSCTTY + execve() to properly set up the PTY child process.
The terminal shows nothing after connecting
- Make sure you scanned the QR code from the popover (not an old screenshot)
- Each session generates a fresh token — reconnect after stopping/starting
devtunnel mode shows "Sign in to DevTunnel"
- Click one of the sign-in buttons in the popover, or run
devtunnel user loginin Terminal first - Check
devtunnelis in your PATH:which devtunnel
cloudflared mode shows no URL
- It can take 10–20 seconds for the trycloudflare.com URL to appear
- Check
cloudflaredis in your PATH:which cloudflared
App won't launch at login
- Go to System Settings → General → Login Items and verify TunnelMan is listed
- You may need to grant permission the first time
"Unauthorized" when opening the URL manually
- The token is part of the URL (the
?token=...query param). Copy the full URL from the popover, don't truncate it.
Inspired by:
- cli-tunnel — the original Node.js PTY-over-DevTunnel concept
- itwillsync — local-network terminal sync
- Simon Willison's vibe coding SwiftUI post — proof that SwiftUI apps can be built fast and well
MIT
