Lossless desktop replication: send only the pixels that changed.
Install · Everyday tasks · Usage · How it works · Architecture · Relay · Limitations · Design records
A conventional screen stream re-encodes the whole picture every frame. PixelChangeCheck keeps a lossless authoritative surface: the sharer diffs each captured frame against the framebuffer viewers actually hold, and sends only what changed. For a terminal, an IDE, or a document, that is usually a few kilobytes per frame. When nothing changes, it sends nothing but a keep-alive.
It shares to a native viewer over QUIC, to any web browser including a phone over a WebSocket that speaks the same change protocol, or to a relay when both ends are behind NAT.
flowchart LR
A[Captured frame] --> B[Detect changed blocks]
B --> C{Diff against the<br/>authoritative surface}
C --> D[Plan: fill / copy / rect]
D --> E{Is the patch set<br/>cheaper than a snapshot?}
E -->|no| F[Lossless snapshot]
E -->|yes| G[Partial update]
C -->|nothing changed| H[Keep-alive]
F --> I[Sequence: revision + epoch]
G --> I
I --> J[Encode once]
J --> K[QUIC viewer]
J --> L[Relay, TCP + TLS]
J --> M[Browser over WebSocket]
K --> N[Compositor]
L --> N
M --> O[Browser compositor]
N --> P[Bytes identical to<br/>the sharer's surface]
O --> P
The diff is against the reference — the framebuffer an up-to-date viewer holds — not against the previous capture. That is what makes sub-threshold changes accumulate instead of being discarded forever. The reference advances only over rectangles that actually shipped, so anything too small to send this frame is still there to send later.
| Area | What PixelChangeCheck provides |
|---|---|
| Detection | Allocation-free block comparison, tile-hash region merging, and a bounded verified-displacement search for scroll reuse |
| Representation | A three-way choice per region: solid fill, verified copy, or an exact LZ4 patch — whichever is genuinely cheaper |
| Cost control | When the patches for a frame would cost more than a fresh lossless snapshot, the sharer sends the snapshot instead |
| Snapshots | Chunked and atomic (begin/chunk/commit), so an interrupted snapshot can never half-replace a working surface |
| Sequencing | One authority for revisions and epochs, so a late joiner, a recovered viewer, and a viewer that fell behind all converge to the same pixels |
| Authentication | A viewer token authorizes access; a certificate pin proves who you are talking to |
| Encryption | Every frame sealed end to end, per viewer, on the native path and in the browser |
| Relay | Per-viewer byte budgets, generational ownership, a defined slow-viewer policy, and no inspection of frame contents |
| Reachability | An explicit ladder — global IPv6, STUN, NAT-PMP, an ICE-lite peer check — with the relay as the deliberate last rung |
| Observability | A stats table, Prometheus metrics on loopback, and JSON logs for bug reports |
| Viewers | A native window, and a WebSocket compositor for any browser, with an explicitly lossy MJPEG fallback |
| Transports | Direct QUIC, multi-relay fan with redirect-based broadcast mode, iroh tickets (ADR 0007), and manual-signalling WebRTC data channels (ADR 0008) |
pccThat is the whole thing. Run with no arguments, pcc walks you through what to
share, how viewers connect, audio, and whether to ask before admitting each
viewer — then prints the exact command and what the other person runs, and
offers to start it. Nothing is hidden behind flags.
pcc menu does the same when you want it explicitly (or off a terminal).
If a viewer sends you a pcc://view?... link, choose View → A link and paste
it whole: both long values in it — the token, and a pin that means the relay
in one form and the sharer in the other — are easy to mistype and impossible to
tell apart afterwards.
On the machine you want to show:
cargo install cargo-binstall # once, if you do not have it
cargo binstall pixel-change-check-client
pcc sharepcc share prints a fingerprint and ready-made viewer lines. How the other
person watches:
| The two of you are… | The viewer does |
|---|---|
| on the same network | Runs the pcc view --connect … --token … --pin … line pcc share printed (they need the binary installed). The printed browser URL is loopback, so it only opens on the sharing machine. |
| anywhere else, or they want no install at all | You both go through a relay: the viewer opens one link in any browser and installs nothing. See the one-link setup. |
Run a relay on any host both sides can reach — a cheap VPS, a home server, even your own machine for a quick test:
pcc relay --listen 0.0.0.0:5900 --web--web makes the relay serve the browser viewer too, on the same port and
behind the same certificate. It prints its token, its certificate fingerprint,
and then a complete share line you can copy verbatim — its own token and pin
already filled in, and an address a remote side can actually dial:
On the machine being shared, run:
pcc share --relay 203.0.113.5:5900 --relay-pin e495ea… --token LBYYJV…
Run that on the machine being shared. It prints a Viewer link: pcc://… and a
Browser link: https://…; send either one. The browser link needs nothing
installed on the other end — just open it.
Do that once. Add --remember <name> to the relay and none of it is
copied again:
# on the relay host, once
pcc relay --listen 0.0.0.0:5900 --web --remember hetzner
# on the machine being shared, from then on
pcc share --use hetznerThe address, the pin and the token are remembered
($XDG_CONFIG_HOME/pcc/relays.json, 0600 because it holds a token;
PCC_RELAYS overrides the path). It is the ~/.ssh/config + known_hosts
pattern rather than a certificate authority: the pin is recorded when you
save and is still checked against the live certificate on every connection,
so a relay that changes identity is refused. pcc with no arguments lists
saved relays as a numbered choice, so you pick one instead of pasting.
Two things that will otherwise cost you a round trip:
- The share's
--tokenmust be the relay's token, exactly as printed. One value across all three sides; a mismatch closes withbad credential. This is the thing--useexists to remove. - Without
--cert/--cert-keythe relay generates a certificate per process, so browsers warn and the fingerprint changes on restart. Supply a real certificate for anything beyond a test.
pcc with no arguments walks through all of this interactively and prints the
same lines for you.
-
Rust 1.91 or newer (see
rust-versioninCargo.toml) -
System dependencies, Linux only (macOS/Windows: none). Each entry is here because a release run failed without it or the lockfile proves the probe — never vibes. Install them all at once:
You install Because Chain pkg-configevery -sysprobe needs the tool itself— libasound2-devaudio capture links ALSA alsa-sys←alsa←cpal←audiofeaturelibdbus-1-devdisplay enumeration over D-Bus libdbus-sys←dbus←screenshotslibegl-dev,libgbm-devGBM/EGL screen capture — a release run failed in khronos-egl's probe without themkhronos-egl+gbm-sys←gl/gbm←libwayshot-xcap←xcaplibpipewire-0.3-devPipeWire screen capture — a release run failed in libspa-sys's probe without itlibspa-sys←pipewire←xcap(not cpal — cpal's Linux audio is ALSA-only)libwayland-devWayland capture + native window — a release run failed in wayland-sys's probe without itwayland-sys←wayland-backend←gbm/libwayshot-xcap←xcap, andwayland-sys←minifb(native-viewerfeature)libxcb1-devX11 display info xcb←display-info/screenshots/xcap(all three list it directly)libx11-devand friendsthe cursor pointer queries X11 x11←device_query← capture (cursor sampler;xlibfeature only, so the probe that fires today isx11itself — the rest ofx11's build-script list fires only on cargo features nothing enables)libudev-dev,libxkbcommon-dev,libxrandr-devkept, unverified — no libudev-sys,xkbcommon-sys, or enabledxrandrprobe anywhere inCargo.lock. They ride with the stacks above on every real system, and dropping a working dep blind to prove a point risks the next release run. Prove-or-drop on the next'Dependencies bump: remove one, watch the Ubuntu release leg, keep or restore on evidence.Two things this table deliberately does not list:
libopus-dev: worth installing (apt install libopus-dev, orbrew install opuson macOS) so the build links the system Opus instead of compiling a vendored copy with cmake — but optional, because the vendored build works without it. SetOPUS_LIB_DIR/OPUS_LIB_STATIConly if you need to point at a non-standard install; the default finds pkg-config's answer.- Anything for
--no-default-features: that build dropscpal/Opus (no ALSA, no libopus cmake) andminifb(no X11/Wayland headers), so a headless relay needs onlypkg-configplus the capture rows. The capture stack itself (screenshots,display-info,xcap,device_query) is not feature-gated — that is the honest gap in the table above, and gating it is future work, not a flag that exists today.
The fastest route is a prebuilt binary — no compiler, no system
headers, no twenty-minute build. binstall is a separate tool, so
install it once first:
cargo install cargo-binstall
cargo binstall pixel-change-check-clientThat downloads the release archive for your platform from the
releases page
and installs one binary, pcc. The archives are named
pcc-<version>-<target> (.tar.gz, .zip on Windows), covering
x86_64 and ARM Linux, Apple Silicon Macs, and x86_64 Windows. Intel
Macs are not covered: GitHub retired the last Intel macOS runner, so
there is no x86_64-apple-darwin archive — install from source there,
or run the Apple Silicon build under Rosetta 2.
If
pccruns a C compiler: the name collides with the Portable C Compiler, which some systems put earlier on PATH.pcc shareansweringclang: error: unknown argumentmeans you are running clang, not this tool. Checkwhich -a pcc, then use the cargo path directly (~/.cargo/bin/pcc …) or fix PATH order. Confirm withpcc --version— it must printpcc 0.1.x, and anything else is the wrong binary.
From source, on any machine with a Rust toolchain:
cargo install pixel-change-check-client --locked--locked matters: the committed Cargo.lock pins every transitive
dependency, so the build you get is the build CI tested rather than
whatever is newest today. The default build includes audio capture and
the native window. A headless machine that needs neither — a relay
host, a CI runner — can skip the system libraries entirely:
cargo install pixel-change-check-client --locked --no-default-featuresThat drops cpal/Opus (no ALSA headers, no vendored libopus cmake build) and minifb (no X11/Wayland dev headers); the web viewer and every transport keep working. With default features on Linux you still need the system dependencies above, because the capture path links against them.
Headless/CI rule of thumb: if a source build fails on a system library (
libpipewire,libasound,wayland-client, …) and this machine only runs a relay — or never opens a window — stop installing-devpackages: you want--no-default-features, not more headers. The build error cannot say this (cargo owns that output), so the docs say it here instead.
A note on Opus specifically: with libopus-dev (Debian/Ubuntu) or
opus (Homebrew) plus pkg-config installed, the build links the
system library instead of compiling a vendored copy with cmake (see
the prerequisites table above for why it stays optional). That one
package is the difference between a pure-Rust build and a C toolchain
step. There is deliberately no .cargo/config.toml forcing a linker
here, so nothing about that choice is hidden: the default build works
with stock rustup, and faster linkers stay opt-in.
pcc shareThat prints the certificate fingerprint and a complete, copy-pasteable viewer command:
Certificate fingerprint (sha256): 9f2c...
Browser viewer: http://127.0.0.1:8080/#token=ABC...
Direct viewers on 0.0.0.0:5800
pcc view --connect 192.168.1.20:5800 --token ABC... --pin 9f2c...
The browser line is loopback because --web defaults to loopback: a
non-loopback browser address without --web-cert/--web-key is refused
rather than served over plaintext. The direct line prints the reachable
address on your network (see below); the relay line, when --relay is
set, prints the exact viewer command with the relay pin substituted.
pcc pair prints a single line a viewer can open or paste, carrying both
the token and the pin, so nobody retypes a 64-character hex string.
Native window, on another machine:
pcc view --connect <sharer-ip>:5800 --token <token> --pin <fingerprint>Any browser, including a phone: open
http://<sharer-ip>:8080/#token=<token>
That page runs the same compositor as the native client. If your browser
cannot, /fallback serves a lossy MJPEG preview, labelled as such.
Behind NAT on both ends? Use the relay — see the one-link setup for running one and the links it produces. If you would rather pass the pieces explicitly:
pcc view --relay <relay-ip>:5900 --pin <relay-pin> --session <session> --token <token>
pcc view --connect <sharer-ip>:5800 --relay <relay-ip>:5900 --pin <relay-pin> --session <session> --token <token>The second form races the direct path and the relay and takes whichever answers first, so you do not have to know which one will work.
No relay at all, if the two ends can reach each other directly:
pcc share --transport iroh # prints a ticket
pcc view --transport iroh --ticket <ticket> --token <token>iroh handles hole-punching and falls back to its own relay; the ticket is
the whole address, so there are no ports to open. (There is also a
manual-signalling WebRTC transport — --transport webrtc — for the case
where both ends can only exchange a blob by hand; see the usage guide.)
Through a disconnect: add --reconnect to the viewer. It keeps the
last frame on screen, then resumes from where it stopped — the sharer
replays the recent updates it still holds, or sends a fresh snapshot if
the viewer fell too far behind.
Most of the time, no. Ask the tool:
pcc diagnoseIt reports your local addresses, whether you have a global IPv6 address (a link-local one is not routable and saying otherwise sends you down a dead end), whether there is a default route, and a STUN reflexive address. Then:
pcc share --reach auto # try IPv6, then STUN, then the relay
pcc share --reach direct # refuse the relay rather than use it
pcc share --reach relay # skip discoveryThe relay is the last rung on purpose. It is TCP+TLS, so it already traverses the corporate proxies that block UDP -- which is exactly where a direct-only path fails. Symmetric NAT and UDP-blocking proxies are the two cases nothing free fixes.
Running one is cheap here specifically: at roughly 570 bytes/frame, a
session is about 160 MB per viewer per hour. See deploy/relay/ for a
free-tier setup.
The web port is plaintext unless you give it a real certificate, because a self-signed one would only produce a browser warning:
pcc share --web-cert cert.pem --web-key key.pemThe surface content is encrypted either way. A certificate stops the key exchange itself from being readable on the wire.
| I want to… | Command |
|---|---|
| share my whole screen | pcc share |
| share one display | pcc share --display 1 — list them with pcc diagnose --displays |
| share part of the screen | pcc share --region 100,200,1280,720 (x,y,w,h in display pixels) |
| share one window | pcc share --window "Firefox" — title substring, fails fast on no match |
| share one app | pcc share --application "Code" |
| include audio | pcc share --audio-source system — or mic, both, none; list devices with pcc diagnose --audio |
| approve each viewer by hand | pcc share --approve — prompts Admit? [y/N] per viewer |
| add a browser viewer on another machine without a certificate | use the relay's --web (above) — the relay brings its own certificate |
| share with no open port and no relay to run | pcc share --transport iroh — prints a ticket; the viewer runs pcc view --transport iroh --ticket <ticket> --token <token> |
| keep watching through a disconnect | pcc view --reconnect — the last frame stays on screen and it catches up |
| see what this machine can do | pcc diagnose (add --displays or --audio) |
The exhaustive reference for every flag is docs/usage.md.
pcc share --help
pcc view --help
pcc relay --help
pcc diagnosepcc share --synthetic --stats-interval 5 --metrics-listen 127.0.0.1:9100
curl -s 127.0.0.1:9100/metrics--stats-interval prints a table; --metrics-listen serves Prometheus
text on loopback. RUST_LOG=pcc=debug narrows the log, and
--log-format json is what you want in a bug report.
- Capture a frame.
- Diff it against the reference -- the framebuffer an up-to-date viewer holds. The reference advances only over rectangles that actually shipped, so a change too small to send this frame is still there to be sent later.
- If much of the screen moved as one displacement, verify it byte for
byte and emit a
Copy; hash agreement alone is never trusted. - Represent each remaining rectangle as a solid
Fillor an exact, LZ4-compressed patch, whichever is smaller. - If the patches would cost more than a fresh lossless snapshot, send the snapshot instead.
- Send nothing but a keep-alive if nothing changed.
The viewer applies each transaction atomically against a single
sequencing authority, and a Copy reads the surface as it was before
the transaction -- which is what lets two regions swap in one update.
capture
|
v
reference <- detect <- current frame
(what | ^
viewers v |
hold) planner (fill / copy / rect, cost-based)
|
v
snapshot | partial update <- one sequencer: revision + epoch
|
v
encoded ONCE, shared with every viewer
|
+-------------+--------------+-------------------+
v v v
QUIC viewer relay (TLS) web (WS / MJPEG)
| | |
v v v
Compositor <--------------- forwards ------------> browser compositor
(a JS port of the same
state machine)
- Sharer (
pcc share): captures, diffs against the reference, plans, encodes once, and fans the same bytes out to every viewer. - Viewer (
pcc view): authenticates, reconstructs through theCompositor, and presents. - Relay (
pcc relay): pairs a host with viewers by session code and forwards framed bytes. It authenticates a session with a value derived from the session token, so it never holds the secret that authenticates the end-to-end encryption handshake. A relay that learns that derived value still cannot forge a viewer's proof, and so cannot read a stream it claims not to be able to read.
The properties below are asserted by the test suite, not promised in prose:
- Exactness is measured. After a snapshot plus its updates, a viewer's pixels are byte-identical to the sender's reference. The suite asserts exactly that, including over a real socket and through the browser's JavaScript compositor.
- Malformed input is refused atomically. A hostile frame length, an impossible geometry, or a truncated payload leaves the framebuffer unchanged and the revision unadvanced.
- Revisions and epochs make out-of-order delivery harmless, and a geometry change starts a new epoch instead of ending the share.
- A wrong token is refused before any key agreement, and a pinned certificate is what the client trusts -- not the CA chain.
- A viewer that falls behind gets a fresh snapshot, never silently dropped patches that later ones depended on.
- Each viewer has its own keys. The sharer's plaintext broadcast exists nowhere; it is sealed per viewer, which is what makes a per-viewer revocation meaningful on a broadcast path.
- Budgets are tripwires, not silent truncation. Every limit failure names the budget, the configured limit, and the observed value.
The reasoning behind these choices is in docs/adr/. The normative
roadmap is in docs/spec/roadmap.md.
- A browser gets a sealed stream, but over a different primitive than
the native client.
crypto.subtlehas no X25519 in the versions most people run, so the browser does ECDH on P-256 with HKDF and AES-GCM while the native client does X25519 with ChaCha20-Poly1305. The protocol shape is identical; the primitives differ. A certificate is still worth supplying, because it stops the key exchange from being readable on the wire, but the surface content is encrypted either way. - Screen capture is full-frame; platforms that expose dirty rectangles (DXGI, ScreenCaptureKit, PipeWire) are not yet used to skip work.
- Motion video is not implemented. The exact-replication path is the
product; see
docs/adr/0001-lossless-authoritative-surface.mdfor why a lossy base is not an option, anddocs/adr/for the rest. - There is no input injection or clipboard sync.
| Document | Purpose |
|---|---|
| Usage | Every entry point — all five commands, every flag, the browser routes, the three transports — with how to invoke each |
| Agent skill | Every flow, the three secrets, troubleshooting, and the observability recipes, written to be read by a coding agent (also installed at .omp/skills/pixelchangecheck/SKILL.md) |
| Architecture decisions | Why the surface is lossless, how tokens and pins work, and how sequencing is enforced |
| Roadmap | The specified workstreams and their definition of done |
| Design | Product and design guidance, including what was deliberately not built |
| Contributing | Development workflow, the release process, and the trusted-publisher setup |
| Agent guidance | Repository-specific instructions for coding agents |
| Changelog | What changed in each release |
Read CONTRIBUTING.md before making changes. The short version:
cargo test # unit, replication, and real-socket end-to-end
cargo clippy --all-targets # lint, must be warning-free
cargo fmt --all -- --check
bash scripts/smoke.sh # end-to-end against the real binariesscripts/smoke.sh is the only check that exercises the CLI end to end.
cargo test passes while the printed pin does not work.
AGPL-3.0-only. See LICENSE.