Peer-to-peer screen sharing from the terminal
A Python CLI that shares your screen over WebRTC: a small WebSocket server introduces the peers, then the video goes straight from sharer to viewer.
p2pshare shares your screen with anyone over the internet using one command. Viewers watch in a native window, not a browser. There is no account, and the video never passes through a server: it goes directly from the sharer to each viewer over an encrypted WebRTC connection.
$ p2pshare share
Sharing monitor 1 in room 'k3v9x0q2mz'. Viewers can join with:
p2pshare view k3v9x0q2mz --server wss://signal.example.com
It is about 650 lines of Python, built on aiortc for WebRTC, websockets for signaling, mss for screen capture and OpenCV for the viewer window.
Try it#
It needs Python 3.10 or newer, on Windows, macOS or Linux (X11):
pipx install git+https://github.com/0xgitpushpray/P2P-screen-sharing.git
To see it work on one machine, run each of these in its own terminal:
p2pshare server
p2pshare share --room demo
p2pshare view demo
To share with someone else, run the server somewhere they can reach, then share with --server pointing at it. A password is a good idea:
p2pshare share --server wss://signal.example.com --password hunter2
Send them the p2pshare view ... line it prints. In the viewer window, f toggles fullscreen and q or Esc quits. Setting P2PSHARE_SERVER and P2PSHARE_PASSWORD saves typing them each time.
The server only introduces the two sides#
Two computers on different home networks cannot just connect to each other. Neither knows the other’s address, and both are usually behind a router doing NAT. WebRTC solves the connection part: each side asks a STUN server for its public address, collects its possible addresses (ICE candidates), and puts them in a session description (SDP). Once both sides have the other’s SDP, they find a path and connect directly.
What WebRTC leaves to you is getting the SDP from one side to the other. That is the signaling server’s job, and it is the only part p2pshare needs to run somewhere public:
- The sharer and the viewer each connect over a WebSocket and join the same room.
- The server tells the sharer a viewer has joined.
- The sharer sends an SDP offer, and the server forwards it to that viewer.
- The viewer sends an SDP answer, and the server forwards it back.
- The video now flows directly between the two, encrypted with DTLS-SRTP.
After that, the server is not involved until someone joins or leaves. It handles a few kilobytes of JSON per connection, so the smallest VPS or a free PaaS tier is enough. It only needs websockets, there is a Dockerfile, it reads $PORT, and it answers GET /healthz for platforms that probe it.
The server also limits what it will forward. A sharer can only send offers and a viewer can only send answers. Every message is capped at 64 KB, and a client that does not send a valid join within 10 seconds is disconnected:
# Which message types each role may relay to the other side.
RELAYABLE = {"sharer": {"offer"}, "viewer": {"answer"}}
Room passwords, even for early viewers#
The password belongs to the room, and the sharer sets it. Viewers who join after the sharer are checked straight away. But a viewer can also join first and wait, and at that point there is no password to check against yet. So when the sharer arrives, the server goes through the waiting viewers, drops the ones with the wrong password, and only introduces the rest:
for viewer in list(room.viewers.values()):
if _password_ok(viewer.password, password):
await _send(ws, {"type": "peer-joined", "peer": viewer.id})
else:
room.viewers.pop(viewer.id, None)
await _reject(viewer.ws, "wrong room password")
_password_ok uses hmac.compare_digest, so the comparison takes the same time whether the password is close or not. Without --room, the room name is 10 random characters from secrets, which works as a shared secret on its own.
One capture, many viewers#
The sharer opens a separate WebRTC connection for each viewer, since each one is a different peer with its own network path. But the screen should only be captured once. aiortc’s MediaRelay handles that: the capture track is the single source, and each connection subscribes to it. buffered=False means a slow viewer gets the newest frame instead of a growing backlog:
pc.addTrack(self._relay.subscribe(self.source, buffered=False))
Each viewer still gets its own encoder, so the bitrate follows that viewer’s bandwidth. If one viewer sends a broken answer, only that connection is dropped. The share keeps running for everyone else.
Capturing the screen without blocking#
The capture track grabs the monitor with mss, scales it to at most --max-width pixels wide, and converts it to the YUV format the encoder wants. That takes long enough to slow down the asyncio event loop that is also handling the network, so it runs in a worker thread:
frame = await asyncio.get_running_loop().run_in_executor(self._executor, self._grab)
mss handles are not thread safe, so the worker thread keeps its own in a threading.local(). The width and height are rounded down to even numbers, because YUV 4:2:0 stores colour at half resolution and cannot have odd dimensions.
Frames are paced to --fps (15 by default). If a grab takes longer than the frame interval, the next frame is scheduled from the current time rather than from where it should have been, so the track does not try to catch up with a burst of frames.
Tuning aiortc for screens#
Two of aiortc’s defaults work well for video calls but not for sharing a screen.
Bitrate. aiortc caps the encoder at 1.5 Mbps. A camera image looks fine at that rate. A screen full of small text does not: it turns to mush. --max-bitrate raises the ceiling for both VP8 and H.264 (for example, --max-bitrate 4000). The encoder still lowers the bitrate if the viewer’s connection cannot keep up.
Connection time. aioice, the ICE library under aiortc, asks the STUN server for an address from every network adapter and waits up to 5 seconds for all of them to answer. On a machine with Hyper-V, WSL, Docker or a VPN, some of those virtual adapters can never get an answer, so every connection waited the full 5 seconds, on both sides in turn. A public STUN server answers in well under a second, so when no TURN server is configured, p2pshare lowers that wait to 2 seconds. With TURN, which can be slower to set up, it keeps the default.
Strict networks#
STUN is enough for most home and office networks. Symmetric NATs, some mobile carriers and locked-down corporate networks block direct connections, and the viewer shows “Connection failed (try a TURN server)”. A TURN server relays the encrypted video when there is no direct path. You can run your own with coturn or use a hosted one:
p2pshare share --turn turn:turn.example.com:3478 --turn-user alice --turn-password s3cret
Privacy, and what it does not do#
The video is encrypted between the peers and never touches the signaling server. The server does see room names, room passwords and the peers’ IP addresses, which are part of the SDP, so use wss:// and a server you run or trust. The STUN server (Google’s by default, or whatever you pass to --stun) sees your public IP address.
Anyone with the room name and password can watch. There is no approval step for each viewer yet, but the sharer’s log shows every viewer that connects. The capture does not include the mouse cursor, there is no audio, and it is view only: viewers cannot control the shared screen.
Tests#
The tests use pytest and cover the CLI, the signaling server’s rules (wrong passwords, early viewers, full rooms, messages a role is not allowed to send) and one real end-to-end run. That test starts a signaling server on localhost, shares a generated video track, connects one and then two viewers over real WebRTC, and checks that they receive the frames. It uses no STUN server, because local addresses are enough on one machine, so it runs offline. CI runs the tests on Python 3.10 to 3.13 on Linux and on 3.13 on Windows and macOS, along with ruff.
pip install -e ".[dev]"
pytest