Your phone is the trackpad for your Mac.
Open a web page on your phone, run a small agent on your Mac, and drive the cursor. The two talk directly, peer to peer, so your movements never travel through a server.
The phone is a bare input surface. The green trail is the pad drawing your finger path in real time.
The web page is only the input surface. It is hosted on Vercel, but nothing on Vercel can move a cursor, so a small local agent does the actual input injection. The phone and the agent negotiate a direct WebRTC data channel and then talk straight to each other. Vercel carries only the few hundred bytes of handshake, never the pointer stream.
flowchart LR
subgraph Cloud["Vercel (public)"]
W["Static PWA"]
S["POST /api/signal<br/>SDP exchange only"]
end
subgraph PhoneBox["Phone browser"]
P["Trackpad PWA"]
end
subgraph MacBox["Your Mac"]
A["Node agent"]
I["Swift injector<br/>(CGEvent)"]
C["Cursor and keyboard"]
end
P -.->|loads over HTTPS| W
P -->|SDP only| S
A -->|SDP only| S
P ==>|"direct WebRTC DataChannel"| A
A --> I --> C
The obvious design is to serve the page and open a ws://192.168.x.x socket to the Mac. It does not work: a page served over HTTPS is hard blocked from opening an insecure socket to a LAN IP (mixed content), and only localhost counts as a secure origin, not arbitrary LAN addresses.
WebRTC solves both at once. Its DTLS transport counts as a secure context, so HTTPS is satisfied, and on a LAN the peers resolve to host candidates and connect directly, typically 2 to 5 ms round trip. A cloud relay would have added 40 to 100 ms, which for a cursor is the entire product.
Two data channels carry the traffic:
ctrl, reliable and ordered, for auth, clicks, keystrokes, and telemetry.input, unreliable and unordered, for movement and scroll. A dropped move packet is superseded a frame later, and retransmitting it would stall every packet behind it. That head of line blocking is the usual reason these apps feel laggy.
Requires macOS with the Xcode command line tools (for swiftc) and Node 20+.
git clone https://github.com/karimbabasf/glide.git
cd glide/agent
npm install
npm startnpm start compiles the Swift injector, starts the agent, and prints a QR code. Scan it with your phone. The pairing link carries a 128 bit secret and expires after three minutes; the agent issues a fresh one automatically.
Phone and Mac should be on the same network. Most home and office wifi connects directly with no setup; networks that block device to device traffic need a TURN relay (see Networks).
macOS blocks synthetic input until the process posting it is trusted. Without it, the events are dropped silently: the agent pairs and reports no error while the cursor does not move.
Grant it under System Settings > Privacy & Security > Accessibility, to the terminal you run npm start from (Terminal, iTerm, VS Code, and so on), then restart the agent. macOS usually prompts on first run.
Modeled on the Apple trackpad, tracked per touch so a two-finger scroll is never confused with a one-finger-held drag.
| Input | Action |
|---|---|
| One finger drag | Move pointer |
| Tap | Left click |
| Double tap | Double click |
| Two finger tap | Right click |
| Two finger drag | Scroll |
| Three finger swipe up | Mission Control |
| Three finger swipe down | App Expose |
| Three finger swipe left or right | Switch spaces |
| Hold, or tap then hold, then drag | Drag lock |
| Two fingers, one anchored one moving | Click and drag |
| Kbd button | Keyboard passthrough |
The pointer uses a nonlinear acceleration curve: near 1 to 1 when your finger moves slowly for precision, saturating on a flick so you can cross the screen in one swipe. Sens scales it, Natural flips scroll direction. Three-finger swipes fire the default macOS keyboard shortcuts, so they follow whatever those are set to.
sequenceDiagram
participant Ph as Phone
participant Sig as Vercel /api/signal
participant Ag as Mac agent
Ag->>Ag: make room id + 128 bit secret
Ag->>Sig: publish(offer, sha256(secret))
Ag-->>Ph: QR code carries room + raw secret
Ph->>Sig: fetch(room, sha256(secret))
Sig-->>Ph: offer
Ph->>Sig: answer(room, sha256(secret), SDP)
Ag->>Sig: poll(room)
Sig-->>Ag: answer
Ph->>Ag: ICE connects, data channels open
Ph->>Ag: auth(raw secret) over the channel
Ag->>Ag: verify secret a second time
Ag-->>Ph: ok
Note over Ph,Ag: pointer packets now flow peer to peer
The signaling server only ever sees sha256(secret). The raw secret travels in the QR code and is checked again by the agent over the data channel. A fully compromised signaling server cannot drive your Mac: it never holds a value it could replay. Pairing records are one shot and expire after 180 seconds. The agent opens no listening port; it dials out.
Three harnesses, no physical phone required for the first two:
# Signaling protocol against production, including the rejection paths
node scripts/test-signal.mjs
# Is Accessibility actually granted? Moves the cursor 60px and back.
node scripts/test-injector.mjs
# Full loopback: plays the phone on the Mac through real signaling,
# proving handshake, auth, and data flow end to end.
cd agent && node test-e2e.mjsGlide fetches its ICE config at runtime from /api/ice, so connectivity scales with how the network is set up:
- Any network that allows device to device traffic (most home and office wifi): the phone and Mac connect directly over host candidates, 2 to 5 ms, no relay, nothing to configure.
- Client-isolated networks (some cafe, co-working, and guest wifi block peer to peer): a direct link is impossible, so you need a TURN relay. Configure one (below) and it works there too, at a latency cost since the stream is relayed. The phone rail shows
DIRECTorRELAYso you always know which path you are on.
Set either of these on the Vercel project, then redeploy. No code change; the phone and agent both read it from /api/ice.
Cloudflare (free, recommended). Create a TURN key in the Cloudflare dashboard, then set:
CF_TURN_TOKEN_ID=...
CF_TURN_API_TOKEN=...
Or any static-credential TURN (self-hosted coturn, Twilio):
TURN_URLS=turn:your.host:3478,turns:your.host:5349
TURN_USERNAME=...
TURN_CREDENTIAL=...
There is no working free zero-config TURN anymore (the old public relays have shut down), which is why this is opt-in rather than baked in.
The transport, signaling, and input injection are each verified, including a loopback harness that completes the full pairing and pushes commands to the injector. Direct connections work on any network without client isolation; isolated networks need TURN, above. Bluetooth is not an option: iOS Safari has no Web Bluetooth API, and iOS blocks the HID peripheral role even for native apps, so a phone cannot present itself as a mouse over Bluetooth.
- Optional Upstash Redis for the signaling store, so the handshake never depends on a warm serverless instance.
- Native iOS client if the web input surface hits a feel ceiling.
agent/
index.js WebRTC peer, pairing, routes input to the injector
injector.swift CGEvent injection, reads newline delimited JSON on stdin
test-e2e.mjs loopback pairing probe
web/
app/page.tsx trackpad surface, per-touch gesture engine, telemetry
app/api/signal/route.ts SDP exchange
lib/store.ts memory or Upstash backend
scripts/
test-signal.mjs signaling protocol test
test-injector.mjs Accessibility permission check
Next.js and React for the PWA, deployed on Vercel. Node with node-datachannel for the agent side of WebRTC. A single Swift file using CoreGraphics for input injection, chosen over a Node native module because the maintained options were either unpublished or stale, and swiftc ships with the Xcode command line tools.
MIT. See LICENSE.