Your Mac screen, live on any device.
Mac Mirror captures your Mac's display in real time and streams it to any browser on your Tailscale network. See your screen and control your Mac from your phone, tablet, or another laptop — fully private, zero cloud.
- Real-time screen streaming — your Mac's display as live JPEG frames at ~3-4 FPS
- Full remote control — tap to click, drag to move windows, type with virtual keyboard
- Touch gestures — single tap (click), double-tap (double-click), two-finger tap (right-click), drag (move/resize windows)
- Works on any device — phone, tablet, laptop — anything with a browser
- Secure by default — runs entirely on your Tailscale mesh, no cloud, no accounts, no exposure
- Single port — everything runs on port 3847 (server, viewer, WebSocket)
- macOS — the machine being mirrored
- Node.js 20+ — check with
node --version - Tailscale — installed and connected on both your Mac and viewing device
- cliclick — for remote input —
brew install cliclick
# 1. Clone the repo
git clone https://github.com/NodeSaint/mac-mirror.git
cd mac-mirror
# 2. Install dependencies
npm install
# 3. Start everything (server + daemon)
./start-prod.shThe terminal will print your Tailscale IP. On your phone/tablet, open:
http://<your-tailscale-ip>:3847
That's it. You should see your Mac screen with a status bar showing FPS and latency.
| Gesture | Action |
|---|---|
| Tap | Left click |
| Double-tap | Double-click |
| Two-finger tap | Right-click |
| Touch and drag | Drag (move windows, select text, etc.) |
| Keyboard icon (top right) | Toggle virtual keyboard for typing |
Keys typed in the virtual keyboard are sent directly to your Mac, including modifier combos (Cmd, Alt, Ctrl, Shift).
Edit config.json in the project root:
{
"port": 3847,
"capture": {
"fps": 10,
"quality": 60,
"scale": 0.5
},
"input": {
"enabled": true
}
}| Setting | Default | Description |
|---|---|---|
port |
3847 | Server port (HTTP + WebSocket) |
capture.fps |
10 | Target frames per second (actual will depend on machine) |
capture.quality |
60 | JPEG quality (1-100, lower = smaller frames, faster) |
capture.scale |
0.5 | Resolution scale (0.5 = half resolution, good for mobile) |
input.enabled |
true | Set to false to disable remote input (view only) |
Environment variable overrides: MAC_MIRROR_PORT, MAC_MIRROR_HOST, MAC_MIRROR_FPS, MAC_MIRROR_QUALITY, MAC_MIRROR_SCALE.
Mac (daemon) ──binary JPEG frames──> Relay Server ──frames──> Browser
│
<──JSON input commands── │ <──touch/keyboard──
- Daemon (
src/daemon/) — captures screen via macOSscreencapture, injects input viacliclick - Server (
src/server/) — Express + WebSocket relay on port 3847, serves the viewer page - Viewer (
src/viewer/) — standalone HTML/JS page with touch input handling
All traffic stays on your Tailscale network. Screen frames are binary JPEG over WebSocket (~150-250KB each). Input events are small JSON messages routed back through the server to the daemon.
The daemon needs Screen Recording permission to capture your display. If you see a permission error:
- Open System Settings > Privacy & Security > Screen Recording
- Enable your terminal app (Terminal, iTerm2, VS Code, etc.)
- Restart the terminal and try again
You're probably hitting an old cached version. Hard-refresh the page or open in an incognito/private tab.
This happens when multiple daemon processes are competing for the single daemon slot. Fix:
# Kill all stale processes
pkill -f "tsx src/daemon"
pkill -f "tsx src/server"
lsof -ti:3847 | xargs kill -9
# Then start fresh
./start-prod.shThe start scripts now do this automatically, but if you started processes manually (e.g. npm run server and npm run daemon separately), orphan processes can linger.
- Check Tailscale — make sure it's connected on both devices. Run
tailscale statuson your Mac. - Check the URL — use your Mac's Tailscale IP (starts with
100.), not your local network IP. Find it withtailscale ip -4. - Check the server — visit
http://<tailscale-ip>:3847/healthin your phone's browser. You should see a JSON response. - Firewall — macOS firewall must allow Node.js. Check System Settings > Network > Firewall > Options.
Remote input (click, type, drag) requires cliclick:
brew install cliclickWithout it, the viewer will display your screen but taps/keyboard won't do anything.
- Lower the quality: set
capture.qualityto 40 inconfig.json - Lower the scale: set
capture.scaleto 0.25 - The
screencaptureCLI approach tops out around 3-5 FPS — this is a known limitation
This is normal. The browser suspends WebSocket connections when the phone screen locks. The viewer will auto-reconnect when you unlock.
| Script | Description |
|---|---|
./start-prod.sh |
Production launcher — builds client, starts server + daemon, shows URLs |
./start.sh |
Dev launcher — same but also starts Vite dev server |
npm run server |
Start relay server only |
npm run daemon |
Start capture daemon only |
| Component | Tech |
|---|---|
| Capture daemon | Node.js, TypeScript, macOS screencapture + sips |
| Relay server | Node.js, Express 5, ws (WebSocket) |
| Viewer | Vanilla HTML/JS (258 lines) |
| Input injection | cliclick (mouse/keyboard), osascript (scroll) |
| Networking | Tailscale |
MIT