One verb instead of forty
The desktop app and the phone app talk to the same machine. The desktop used about a hundred and twenty-five API routes. The phone could reach ten.
Not because the rest were secret — because each one had to be hand-written into the relay as its own verb, and nobody had written them. Which meant every feature built for the desktop arrived on the phone as nothing at all, and the fix each time was to add another verb, which guaranteed the next feature would start life missing too.
Stop adding verbs
The relay now carries one generic action: make this HTTP request against your own API. The phone sends the same path and body it would have sent over Wi-Fi; the machine performs it against its own loopback server and sends the reply back.
The result is that there is no longer a list to maintain. A route added for the desktop is reachable from the phone the day it ships, with nothing to wire, and the two apps stop drifting because there is only one interface.
Ten verbs became a hundred and eighteen routes, and the number that matters is the third one: zero additional work for the next feature.
What a generic verb has to refuse
Generality needs a boundary, and the reasoning behind this one is worth being precise about, because it is not the obvious one.
The relay is authenticated — every command is signed with a secret established when the phone was paired. So the caller is not untrusted. The concern is blast radius: a phone can be lost, or restored onto someone else's device, and the secret goes with it. The difference between "can drive my PC" and "can mint pairing codes for further devices and read my API keys" is the difference between an inconvenience and losing the account.
So credentials and re-pairing are refused over the relay and remain things you do at the machine. Everything else is allowed. The check lives in its own module rather than inline in a request handler, because a security boundary you cannot test on its own is one nobody will test.
It matches on segment boundaries, so denying /api/account/key does not
accidentally deny /api/account/keyring — and it refuses anything that could
climb out of the API namespace or change host.
Watching a screenshot is not remote control
The machine had accepted pointer, keyboard and screen-capture commands over both transports for a long time. The phone had a still-image viewer and called none of them. So "control your PC from your phone" meant looking at a picture of it.
There is now a real remote screen: tap to click, hold for right-click, drag to scroll, type into it, with the frame updating continuously. Three details made the difference between something demonstrable and something usable.
Frames are paced by how long the last frame took. Polling on a fixed interval is the obvious approach and it is wrong on a phone: over a relay a frame can take longer than the interval, so requests stack up, each making the next one later, and the picture falls further behind the longer you watch. Scheduling the next grab only once the previous has arrived means a slow link degrades to a slower frame rate instead of an ever-growing backlog.
The picture is not the element. A screen is letterboxed inside the phone's frame, so the element's rectangle includes black bars that are not part of the desktop. Mapping a touch using that rectangle is the classic version of this bug, and it misreports itself: the error changes with the aspect ratio of the desktop, so it presents as the pointer drifting rather than as arithmetic that is simply wrong. Measured across real phone and desktop sizes, the naive mapping is a median of 18.5% of the screen away from where the finger went, and up to 37.3% at the edges. That is not a near miss — it is a different part of the screen.
The stream stops when you are not watching it. On panel close and on backgrounding. A phone in a pocket pulling a JPEG of a desktop several times a second is the fastest way to have an app killed, and the second fastest way to be blamed for the battery.
The bug underneath all of it
Most of this work was, for a while, being written into a file that gets overwritten.
The Android shell's page is generated from the page the PC serves. Editing the generated copy is editing something the next build discards. Every edit was landing downstream of the source.
Worse, checking that turned up something else: the shipped shell had been generated before an entire feature was added, so that feature was live on the desktop and simply absent from the phone. Nothing failed. Nothing could fail — a missing feature is indistinguishable from a feature nobody wrote.
There is now a test that rebuilds the shell and compares it to the one on disk, so drift fails a test rather than shipping. And a related discovery: the build never rewrote one absolute asset path to a relative one. The shipped file had been corrected by hand at some point, so the next rebuild would have silently broken it again.
The guard added for that was itself wrong on the first attempt — it checked for the path in double quotes, which is exactly the form the rewrite had already fixed, making it a check that could not fail. It now checks the bare path, and that was verified by planting a violation and confirming the build refuses it.
Which is the recurring lesson across all of this work. Every one of these bugs was invisible in normal operation, and several of them were sitting behind something that looked like it was checking for them.
Helix is free, runs on your own Windows PC, and asks before it does anything it cannot undo.
Download Helix