A cross-platform, LAN-based remote control for touch-screen devices following the KISS principle. It allows touchscreen devices to act as a trackpad and keyboard for a desktop system through a locally served web interface.
Contributions are welcome! Please leave a star β to show your support.
- Framework: TanStack Start
- Language: TypeScript
- Following are currently being used but will be replaced:
- Real-time: Native WebSockets (
ws) - Input Simulation: @nut-tree/nut-js
- Real-time: Native WebSockets (
Note
For Linux: On Wayland, the ydotoold daemon must be running and your user must be part of the ydotool group. Additionally, some native dependencies are required : install them via your package manager (see shell.nix for the list), or use nix-shell directly.
- Install dependencies:
npm install
- Start the development server:
npm run dev
- Open the local app:
http://localhost:3000
To control this computer from your phone/tablet:
Ensure your computer allows incoming connections on:
- 3000 (Frontend + Input Server)
Linux (UFW):
sudo ufw allow 3000/tcp- Ensure your phone and computer are on the same Wi-Fi network.
- On your computer, open the app (
http://localhost:3000/settings). - Scan the QR code with your phone OR manually enter:
http://<YOUR_PC_IP>:3000
- Trackpad: Swipe to move, tap to click.
- Scroll: Toggle "Scroll Mode" or use two fingers.
- Keyboard: Tap the "Keyboard" button to use your phone's native keyboard.
Visit the Discord Channel for interacting with the community! (Go to Project-> Rein)
The diagram below describes the full end-to-end architecture after migrating from WebSocket to HTTP + WebRTC.
The following diagram is AI generated and may not be accurate
flowchart TD
subgraph DESKTOP["π₯οΈ Desktop (Server)"]
subgraph WRAPPER["Desktop App Wrapper\n(Electron / Tauri)"]
MAIN["App Process\nSpawns HTTP server\nPolls until ready\nOpens browser window"]
RENDERER["Embedded Browser Window\nHosts Settings UI\nWebRTC peer endpoint"]
end
subgraph NITRO["Nitro / Node.js HTTP Server"]
direction TB
IP_DETECT["IP Detection\ndgram UDP socket\nconnects to 1.1.1.1:1\nreads socket.address()\nβ LAN IP (no packets sent)"]
HTTP_ROUTES["HTTP API\nGET /api/ip\nPOST /api/token\nPOST /api/config\nPOST /api/signal\nGET /api/signal/ice (SSE)"]
TOKEN_STORE["Token Store\nGenerate / validate\nauth tokens"]
INPUT_HANDLER["Input Handler\nThrottle + dispatch\nOS-level injection"]
end
IP_DETECT -->|"resolved LAN IP"| HTTP_ROUTES
HTTP_ROUTES --> TOKEN_STORE
HTTP_ROUTES -->|"input events"| INPUT_HANDLER
MAIN -->|"spawns + polls HTTP"| NITRO
MAIN -->|"opens"| RENDERER
end
subgraph PHONE["π± Phone (Client Browser)"]
direction TB
subgraph SETTINGS_PAGE["Settings Page"]
SRV_SETTINGS["Server Settings\nPort\nServer IP"]
CLIENT_SETTINGS["Client Settings\nMouse sensitivity\nScroll invert\nTheme"]
QR_CODE["QR Code\nEncodes trackpad URL\nwith auth token"]
end
subgraph TRACKPAD_PAGE["Trackpad Page"]
TOUCH_AREA["Touch Area\nMouse movement\nClick / scroll / zoom"]
EXTRA_KEYS["Extra Keys\nArrows, Fn, modifiers"]
KBD["Mobile Keyboard\nText input\nComposition support"]
SCREEN_MIRROR["Screen Mirror\nVideo element\nP2P stream"]
end
CONN_PROVIDER["ConnectionProvider\nRTCPeerConnection\nDataChannels"]
end
subgraph WEBRTC["β‘ WebRTC P2P"]
DC_UNORDERED["DataChannel β unordered\nmove Β· scroll Β· zoom\nUDP-like, drop old events"]
DC_ORDERED["DataChannel β ordered\nkey Β· text Β· combo Β· clipboard\nTCP-like, reliable"]
MEDIA_TRACK["MediaTrack β video\nH.264 / VP9 / AV1\nHardware encoded\nAdaptive bitrate"]
end
%% ββ Boot & IP ββββββββββββββββββββββββββββββββββββββββββββββββββββ
MAIN -->|"1. spawn"| NITRO
NITRO -->|"ready"| MAIN
RENDERER -->|"2. GET /api/ip"| HTTP_ROUTES
HTTP_ROUTES -->|"{ ip: 192.168.x.x }"| RENDERER
%% ββ Token / QR βββββββββββββββββββββββββββββββββββββββββββββββββββ
RENDERER -->|"3. POST /api/token\n(localhost only)"| HTTP_ROUTES
HTTP_ROUTES -->|"{ token }"| RENDERER
RENDERER -->|"QR url"| QR_CODE
%% ββ Phone connects βββββββββββββββββββββββββββββββββββββββββββββββ
QR_CODE -->|"4. scan β open URL\n?token=β¦"| CONN_PROVIDER
CONN_PROVIDER -->|"POST /api/signal offer"| HTTP_ROUTES
HTTP_ROUTES -->|"SDP answer + ICE (SSE)"| CONN_PROVIDER
%% ββ WebRTC P2P βββββββββββββββββββββββββββββββββββββββββββββββββββ
CONN_PROVIDER <-->|"5. P2P established"| RENDERER
CONN_PROVIDER --- DC_UNORDERED
CONN_PROVIDER --- DC_ORDERED
RENDERER --- MEDIA_TRACK
%% ββ Input path βββββββββββββββββββββββββββββββββββββββββββββββββββ
TOUCH_AREA -->|"move / scroll / zoom"| DC_UNORDERED
EXTRA_KEYS -->|"key / combo"| DC_ORDERED
KBD -->|"text / backspace"| DC_ORDERED
DC_UNORDERED -->|"forwarded"| INPUT_HANDLER
DC_ORDERED -->|"forwarded"| INPUT_HANDLER
%% ββ Screen mirror ββββββββββββββββββββββββββββββββββββββββββββββββ
RENDERER -->|"getDisplayMedia() stream"| MEDIA_TRACK
MEDIA_TRACK -->|"P2P β server never sees frames"| SCREEN_MIRROR
%% ββ Client settings (local only) βββββββββββββββββββββββββββββββββ
CLIENT_SETTINGS -->|"persisted in localStorage\nno server call"| CLIENT_SETTINGS
%% ββ Port/config change βββββββββββββββββββββββββββββββββββββββββββ
SRV_SETTINGS -->|"6. POST /api/config\n{ frontendPort }"| HTTP_ROUTES
HTTP_ROUTES -->|"writes server-config.json"| NITRO
SRV_SETTINGS -->|"redirect to new port URL"| PHONE
| Step | What happens |
|---|---|
| Boot | The desktop app wrapper spawns the Nitro HTTP server and polls until it responds, then opens the embedded browser window pointing to localhost. |
| IP detection | On startup the server opens a dgram UDP socket and "connects" it to 1.1.1.1:1 β no packets are sent, but the OS selects the correct outbound NIC. socket.address() returns the LAN IP. |
| Token / QR | The Settings page calls POST /api/token (localhost only). A signed token is generated, stored, and encoded into the QR code URL (/trackpad?token=β¦). |
| Phone connects | Phone scans QR β opens /trackpad?token=β¦ β ConnectionProvider initiates WebRTC signalling via POST /api/signal + SSE ICE candidates. |
| WebRTC P2P | Once ICE completes, all real-time data flows peer-to-peer: an unordered DataChannel (UDP-like) for mouse/scroll/zoom and an ordered DataChannel (TCP-like) for keys/text/clipboard. |
| Screen mirroring | getDisplayMedia() feeds a MediaTrack directly into the RTCPeerConnection. The phone renders it in a <video> element. The server never handles video frames. |
| Client settings | Sensitivity, scroll invert, and theme are stored in localStorage on the phone only β no server round-trip. |
| Server settings | Port changes call POST /api/config, which writes server-config.json. The client redirects to the new port URL. The change is picked up on the next server start. |
| Input injection | Input events arrive at the server via the DataChannel bridge, dispatched through InputHandler (throttle + validation), and injected at OS level via a virtual input device. |
