Skip to content

Complete desktop streaming, input and audited reliability fixes - #26

Merged
MrBeldum merged 40 commits into
mainfrom
fix/vps-stream-clipboard
Oct 20, 2026
Merged

MrBeldum merged 40 commits into
mainfrom
fix/vps-stream-clipboard

Conversation

@MrBeldum

@MrBeldum MrBeldum commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Complete the VPS desktop streaming work and merge the audited reliability and Windows cursor fixes into Latch main. The final stack includes #28 and #27, plus the previously unpushed Mac Caps Lock fix.

  • VPS desktops can follow the viewer's size, including sizes below 1080p, restore their idle size, and recover Linux clipboard and pairing correctly.
  • Audit fixes cover update deferral after streams, Docker/Tailscale caller identity, pairing cancellation/order, keyboard and paste ordering, clipboard formats, power actions, configuration recovery, service lifecycle, Windows setup, accessibility and release packaging.
  • Windows automatic capture prefers the virtual display; the cursor guard runs only for an active viewer. Mac Caps Lock changes reach the host in the input queue.
  • Conflicts with main's installer/documentation cleanup are resolved while retaining Linux ARM64/x64 release archives and legacy upgrade compatibility.

Validation: formatting, Clippy with warnings denied, 395 local workspace tests, three bootstrap tests and 66 relay assertions pass. Fresh CI covers Windows, macOS and Linux x64/ARM64, including release builds; the stack is merged only after that combined head succeeds.

Windows cursor/VDD behavior still needs a real hardware check. This merges source; it does not publish a release or update installed hosts.

Streaming a Linux VPS desktop (software x264 on two cores):

- Latch Host reports the X screen it captures and, for software encoding
  on fewer than four cores, a 30 fps ceiling; LATCH_DISPLAY_FOLLOWS_VIEWER=1
  stops reporting a fixed size for a desktop that resizes to each viewer.
  The Linux engine profile drops the 60 fps floor and uses superfast.
- New 540p, 720p and 900p resolutions. The host's ceiling holds at 1080p
  and rises in proportion for fewer pixels (720p gets 60 fps); a fixed
  desktop is only replaced when the wanted size is larger, and captured
  mouse movement over a shrunk fixed desktop is scaled to its pixels.
- The Mac relaunches the Desktop when it asks for a different size than
  it last launched it at; resuming kept the old desktop size, since a
  prep command runs only at a launch.
- Latch Host answers the PIN as soon as the engine has taken it: Sunshine
  from October 2026 holds the PIN post open until the handshake ends,
  which the Mac only started once Latch Host had answered.
- Clipboard with Linux: Ctrl+Insert / Shift+Delete / Shift+Insert, and
  pasted text also in the X selection.
- A Mac can be only a viewer (Share this Mac off).
- The Mac bundle is staged in dist.noindex (release workflow follows).
- connect_real: an ignored live test through the window's connect path.

Verified live against stuzha (Sunshine 2026.1014.154843): every size
relaunches and resizes correctly, absolute and relative pointer land on
the exact pixel, keys reach a terminal, 720p and 540p hold ~60 fps with
scrolling text (1080p 30), and a new Mac pairs once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MrBeldum and others added 28 commits October 17, 2026 16:45
…quester's own pairing

A viewer that disconnects leaves its Desktop launched for resuming, and
Sunshine keeps reporting it as currentgame until the engine restarts. Since
PR #26 read that as "a stream is running", a Windows host refused every
pushed update after the first ordinary stream, and the Mac re-sent the whole
exe every three minutes. Whether someone watches now comes from the engine
log's last session line (CLIENT CONNECTED against CLIENT DISCONNECTED or a
Ping Timeout, which is all a sleeping Mac leaves), checked against
/serverinfo. An unknown never blocks the update, which does not touch the
engine; the stream-profile upgrade, which restarts it, still waits on
unknowns and closes a Desktop nobody watches before it restarts.

The PIN went to whichever pending pairing Sunshine listed first, which can
be another device's or an abandoned one, and an empty 2026.10 list made an
id-less post that engine always refuses. The request is now picked by the
caller's address (by name where everything is loopback), and an empty or
foreign-only list answers "waiting" without posting. Only posts with a
pairing id get the 600 ms "taken" rule. Refusals are logged once per reason
instead of once per retry, and engine calls run curl with -S so a failure
says why instead of "curl: ".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…connection

The Docker node runs tailscaled with --tun=userspace-networking, which
relays every tailnet peer's TCP connection from 127.0.0.1. The service took
any loopback connection for its own window, so any machine the ACL let reach
port 47850 skipped the owner check: it could approve its own pairing PIN,
read the clipboard, power the node off, read the service log and quit it.

Status now reads Tailscale's TUN flag (kept once seen, and read before the
first request). With TUN false, a loopback request is judged after it has
been read, when tailscaled has recorded the relayed peer: `tailscale whois
--proto=tcp 127.0.0.1:<port>` names it and Owner::admits decides, never as
local (no log, no /v1/quit). Only a port Tailscale knows nothing about is a
local process; a failed lookup admits nobody. Loopback answers are never
cached, since every relayed peer shares 127.0.0.1. Kernel-TUN machines (the
Mac, Windows, stuzha) keep today's behaviour with no extra whois.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…paths unknown, fix DERP ids

- `tailscale status --json` writes empty maps and lists as null ("Peer":
  null on a machine alone in its tailnet; Peer, User and Self.TailscaleIPs
  when signed out). The parse failed, so a fresh host showed "parse
  tailscale status" instead of working, and a signed-out Mac never said so.
  Nulls now read as empty, and the state reads "signed out", not
  "needslogin".
- public_ipv4() took any CurAddr, so two machines on one LAN overwrote the
  stored wake address (the router that forwards UDP 9) with a LAN address.
  Only internet addresses count now.
- direct() called every idle peer relayed, because Relay (the home DERP) is
  set for all of them; a direct PC flashed "Relayed via Los Angeles" at
  every launch. Without traffic the path is unknown, as documented.
- machine_peers() listed Mullvad's exit nodes (hundreds with the add-on)
  and machines this one is shared out to, which `tailscale status` hides.
- The DERP id table was off by one from 14 up (Amsterdam was missing), so a
  Mac in Los Angeles logged "nearest relay Paris". The table now matches
  1.102's derp-map, and the host prefers the home region Tailscale names
  in its own status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fixes

apply() only replaced egui's dark style. egui picks the style by the
system theme every frame, so a Mac or Windows PC in Light mode drew with
egui's stock light style, which has no caption or title text styles, and
the first caption panicked: Latch quit at launch, or mid-stream when Auto
appearance flipped at sunrise. The theme is now pinned to dark and both
slots get Latch's style.

Menus: an Escape that closes a menu or a select list is used up, so the
stream no longer forwards it to the PC and Stream settings no longer
closes behind a list. A menu is known by its label instead of its
button's position, so a row added above no longer hands an open "More
for X" menu to another machine, and a menu whose button is no longer
drawn (the toolbar folded into More, the stream ended) is closed at the
end of the pass instead of blocking stream input forever.

Accessibility: a select is named after its setting row and reports its
choice; the chosen option in a list is drawn in full-strength text (the
accent on the accent wash was 3.7:1); the bitrate slider takes a screen
reader's increment, decrement and set-value, and reports its range.

A spinner in a window that is not in front slows to four frames a
second, and the system's Korean/CJK font is the last fallback of every
family, so a Korean PC's error messages are no longer empty boxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clipboard::fit capped raw bytes at 32 KiB, but a quote or newline takes
two bytes in JSON and other control characters six, so 32 KiB of
newlines or control-heavy text became a body over the 64 KiB limit both
sides enforce: the PC's copy never reached the Mac (the reply was
refused as too large, silently, until the PC's clipboard changed) and a
paste failed with "body too large". fit now counts the escaped length in
the same pass and stops at 60 KiB of it, leaving room for the other
fields; a test pins the per-character lengths to serde_json's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rvice keeps its last good host.toml

load() set the whole file aside and returned defaults on any parse error.
A client.toml with resolution = "P720" read by a build without 720p (4.1.1,
or a branch cut before PR #26) lost every pairing certificate, Hermes's wake
MAC and the GitHub token. Well-formed TOML that does not fit is now salvaged
key by key (a whole table when it fits, else its parts), the original kept
as .bad-* beside it and the readable part written back, so only
stream.resolution falls back. Text that is not TOML at all goes aside as
before, and so does a file that is not UTF-8.

A read error other than "not found" (permissions, a sharing violation)
counted as a missing file, and update() then wrote defaults over it. Such a
file is now an error for update(), which leaves it alone.

The service re-read host.toml every 5 s with that loader, so a typo in a
hand edit on a headless node moved the file away and ran with remote power
allowed and no engine login. It now uses a strict try_load that never moves
anything, treats an empty file (an editor mid-save) as unreadable, keeps the
last good settings, and logs once per problem and once when it is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PIN went to Latch Host on the PC in front of the first pairing
request: the handshake could only go on once that loop returned, up to
75 s later, whether or not the PC had long answered (the stuzha stall
with Sunshine 2026.10). Now the request is polled every 100 ms and the
hand-off runs on its own thread beside it: the handshake goes on the
moment the PC answers, however the PIN got in, a first request that
fails shows its error at once, and Cancel works throughout. A wait that
runs out reads "nobody entered the PIN" instead of a bare "timed out".

The host's reply is read whatever its status, so its refusal reason is
shown ("isn't answering" is kept for silence). A refusal for good (400,
403, 422, twice in a row) ends the attempt with the host's reason; a
host that cannot log in to its engine gets 15 s, then the card says to
type the PIN there; passing refusals are asked again less and less
often. Tests run the handshake against a fake host on loopback ports.

Connecting: a cancel no longer quits an app it rejoined, nor one that
was running before anything was touched; failures are logged with the
step and the whole cause chain, and the banner shows the chain, with
each step naming itself ("Asking stuzha to start the stream: ...").
The Desktop is relaunched for its size only where the desktop follows
the viewer (a Linux host without a fixed size), and the size it was
launched at is kept in client.toml so an app restart does not relaunch
it. Saved machines that have left the tailnet for 30 days are forgotten,
guarded by the tailnet they were seen on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A self-update that failed set the release aside and skipped the host
pushes until the next check, then downloaded the same 5 MB again every
15 minutes: a release whose Mac build cannot install (a version that
does not match its tag, say) kept every PC on the old host. The failure
is now remembered on its own: the pushes go on, a failed download is
tried again after 15 minutes, and a download that cannot work is not
fetched again until the release or its asset changes.

The app that is installed is the one just checked: the tarball is
re-verified against GitHub's digest at install time and unpacked
straight into the staging folder beside the running app, where its
signature and version are checked, instead of reusing whatever an
earlier run left unpacked under the same tag.

manual_update_message says what to do about a Linux host or container
behind the release, which this Mac does not update (the lobby shows it
from the next commit). The stream handover finds a pre-3.1 host under
its BroLink process name and path and replaces it in place, so its
firewall rules still apply, and the GitHub token is looked up on the
handover's thread, not the window's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Power actions found their machine by display name, which a tailnet does
not keep unique: Restart, Shut down and the sleep offer now go by node
id, and a machine that has gone away gets a notice, not a request to its
namesake. A power action on one machine no longer cancels a connection
to another, and the stream from its own machine ends only once the host
has agreed, so a refusal leaves it up and is shown over the picture.

The stream's machine menu follows the owner's power setting (no menu
when it is off), asks before Sleep as before Restart, and offers Sleep
only for a Windows PC: a Linux VPS or a Mac would stay asleep with
nothing here able to wake it. The lobby row menu and the sleep offer
after a session follow the same rule.

A machine that is away can be forgotten from its row menu, after a
question that says what goes with it (its pairing, how to wake it).
A Mac with Share this Mac off reads "only connects to your other
machines", not "the background service is stopped". Hosts this Mac
cannot update are named once a newer release is out.

The PC is asked why only about a black picture, never about video that
stopped or cannot be decoded, and a Linux or Mac host, a failed ask or
an empty answer all leave an answer, so "Asking the PC why…" no longer
stays up. A display change reconnects only when it changes what the
stream asks for. Full screen follows what the system reports (the green
button), survives a reconnect without leaving and coming back, and the
window returns to what it was before the stream. Stream failures and
abnormal ends are logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, survive bad updates

Starting and restarting:
- The window started the service as its own child, outside launchd, so a
  crash was never restarted, and Share on started a second copy beside
  launchd's (whichever lost exited 0, and launchd then left its job
  stopped). ensure_service_running now asks the login item first: launchctl
  bootstrap/kickstart (no -k) on a Mac, systemctl --user start --no-block on
  Linux, a bare copy only where there is none. After an update the window
  quits the old service, waits for the port, and starts it the same way.
- Turning Share on used legacy `launchctl load`, which silently skips a
  label macOS marked disabled (this Mac's state: not loaded, disabled, the
  service running unsupervised). It now enables the label, bootstraps it,
  and reports when macOS still does not load it.
- A Mac or Linux install replaced under a running service (same version,
  so the window saw nothing) kept serving old code indefinitely. The
  service notices its program file changed and, once nobody watches,
  restarts into it: by exiting non-zero under a supervisor, or by handing
  over to the new file.

Respawn storms:
- The window restarted a service that exits by design every 4 s for hours
  (6,438 spawns overnight on 2026-10-12) because it never re-read
  host.toml. ensure_service_running and the poller now check that the Mac
  is shared (the window becomes a viewer if not), retries back off from
  2 s to once a minute, a copy that stops as it starts is reported with
  its exit status, and the --background error reaches service.log.
- A window opened from an older copy relaunched the same old file every
  second, forever. It relaunches only when its own file was replaced, and
  otherwise says to open Latch from Applications/the Start menu and leaves
  the service alone.

Updates:
- The rollback only caught a new exe that died within 2 s while it was
  still waiting for the port, and the new service deleted the last good
  exe as soon as it bound. The old service now starts a watcher from the
  retired exe (--probation) that puts it back unless the new version
  answers /v1/status and keeps answering; the retired file is removed only
  after ten minutes.
- host.toml accept_updates (default on, a toggle on Windows) refuses pushed
  updates; accepted and refused pushes are logged with sender and SHA-256.

A panic hook appends the panic and its place to service.log/panel.log
before panic=abort ends the process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When Latch Host entered the PIN but the engine never answered the first
request, the timeout said nobody entered it; it now says the engine
never finished. The wording of a failed pairing (a refusal in the
host's words, a timeout with its reason, else the handshake's error
under "Pairing with <PC>") is one function with a test, and a power
confirmation that finds its machine gone says it may also have stopped
taking power actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stream view translated a frame's events with the frame-end state, so
order inside a frame was lost: modifiers were synced once before the
keys (Shift let go with T in one frame typed "t", ⌘Z with ⌘ let go sent
a bare Z), and a click went out before the frame's pointer position or
captured movement. Each key and click now goes with its own modifiers
and position, in order; the frame-end state follows for modifier-only
changes. Horizontal scroll had the wrong sign (egui's +x scrolls left,
Moonlight's right).

Pastes: the chord was pressed by a worker after the POST, so Return
typed after ⌘V reached a PC 150 ms away before the paste. The worker now
only sends the text; the window presses the chord through Held and holds
everything typed or clicked after it until the text has arrived (at most
2 s, then the PC pastes what it has). ⌘V with an image or nothing on the
Mac clipboard was swallowed whole by egui-winit; its V release now sends
the paste. A copy made on the PC (files, an image, or text not yet read
after a ⌘C) is no longer overwritten by the Mac's older text while the
Mac clipboard's change count is unchanged. A paste over 32 KB is sent
cut, says so, and its cut copy no longer comes back over the Mac's
clipboard; a Windows PC's CRLF read-back is no echo either.

Host key and capture: the host key lets go of everything held before
toggling (a key let go during the chord stuck down on the PC), closes a
panel, question or menu before capturing, waits for both keys to be up
(no lone Alt), and can free a capture before the stream connects. A
click on the picture no longer captures the mouse while connecting.
In free mode egui gives up focus while the toolbar is hidden, so Tab
reaches the PC instead of the Stats panel.

Mac: while the stream has the keys, the app menu's Quit, Hide and Hide
Others lose their shortcuts (⌘Q quit Latch mid-stream with Command as
Ctrl) and egui's ⌘+/⌘-/⌘0 zoom is off. A Mac host gets ⌘ as Command
whatever "Command acts as Ctrl" says. The keypad's + sends VK_ADD
instead of the = key.

The translation sends through a Sink trait, so tests record what the PC
would see: order, masks and signs for each case above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…estarts fail, keep a shared Mac awake

- One click on Sleep for stuzha (an OCI KVM guest, s2idle, no wake source,
  polkit allowing it) would have taken Tailscale, SSH, Latch and the Hermes
  agent down until someone reset the VM from the cloud console. The host
  now refuses Sleep on a Linux VM and on a Mac (Latch has no wake path to
  either), refuses every action in a container (no systemd), and asks
  logind before ending anyone's stream. Restart and Shut down stay, behind
  power_allowed as before. A refusal comes back as an Ack with the reason,
  which every Mac client shows as it is.
- On a Mac, Restart and Shut down go through System Events, which needs an
  Automation permission macOS asks for on the Mac's own screen; the Mac
  that asked was told it worked. osascript now runs before the reply, at
  most 3 s, and its failure is reported in words (not allowed, nobody
  answered the prompt, an app cancelled). Failures of the actions that run
  after the reply reach the service log instead of only stderr.
- keep_awake did nothing off Windows while the log said "keeping this PC
  awake" at every start, on stuzha and on a shared MacBook that went on to
  idle-sleep off the tailnet. A shared Mac now holds a
  PreventUserIdleSystemSleep assertion while plugged in (not
  PreventSystemSleep, which turns a requested sleep into dark wake), the
  switch shows on the Mac's Sharing page, and the log speaks only when it
  starts or stops being true.
- The wake-packet listener ran everywhere but can only match a MAC on
  Windows: it held UDP 9 on every interface of the Mac and logged a
  permission error at every start on Linux. It is Windows-only now, and the
  Sharing page no longer shows "LAN address None" where none is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… for Latch

- On a native Linux node the engine is latch-engine.service, with the
  desktop's environment and its CPU priority. Setup pkilled it (a clean
  exit, which Restart=on-failure does not restart) and started its own
  copy; run over SSH that copy had no DISPLAY, bound its ports and said
  "started" while every stream failed. With the unit present, setup now
  writes the config and restarts the unit (latch.service follows through
  Requires=), and the service's keep-alive starts the unit rather than a
  copy beside it.
- The macOS firewall blocked incoming connections to the ad-hoc signed
  Latch.app on this Mac, so other machines could not reach the control
  service at all (no PIN entry, no status) while its window said
  "Shared". The service now notices and lists it as a setup item, and
  setup asks for an administrator to allow Latch through, verifying after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om taking it over

This Mac's launch agent was marked disabled in launchd while its plist
existed and host.toml said shared: the toggle showed on, the service ran
with nothing to restart it, and it would not have come back after a
restart. The service now lists that as a setup item, and setup (or turning
Share on) enables the label even when the job is loaded.

The service rewrote the login item to whatever copy it ran from, so a
build started from dist.noindex, a disk image or App Translocation took
it over. It now points it only at an app installed in /Applications or
~/Applications; turning Share on from any copy still does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tate

A 4.0 PC's engine folder, with the pairing state in its config, was moved
to C:\Program Files\Latch\engine with one Move-Item. A held handle (an
antivirus scan, an Explorer window, an engine restarted in the gap) failed
the move, setup then installed a fresh engine with a new identity and no
paired devices, and the old state stayed stranded where nothing read it
again. A part-filled Latch\engine left by an earlier failed run made the
move nest the old folder at engine\engine, with the same result.

The move is now tried five times, stopping any engine that runs from the
old folder before each try. A folder in the way is removed when empty and
kept aside as engine.partial-<time> otherwise. If the move still fails the
old engine stays put and $retiredState names its config: the install step
copies the state (server identity, paired devices, apps, settings) into the
fresh engine before it is configured and registered. Nothing throws, since
the old firewall rules are already gone and setup has to go on to open the
new ones.

The fragment takes its old layout as parameters, so a test can dump it for
temporary folders and a dummy service (LATCH_DUMP_RETIRE_*); the Windows CI
job runs it there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TEM engine beside it

Run over SSH (session 0, as the Hermes migration does), setup's engine
take-over stopped the working service engine and started sunshine.exe in
session 0, which has no desktop: a black stream that nothing repaired
until a reboot. The helper now refuses to act from session 0
(NOT_INTERACTIVE). Setup run there installs the LatchEngineUser logon task
for its account, stops the service and every engine, and has the task start
the engine in the signed-in user's session; if no engine appears in 30 s
(nobody signed in) the LatchStream service runs it again. A take-over that
fails from the desktop session also leaves the service engine running
instead of none.

Re-running setup with the user's engine up started the stopped LatchStream
service (Restart-Service starts a stopped service), and the helper looked
only at the first sunshine.exe listed, the user's, so the SYSTEM engine
stayed beside it on the same ports. The restart now touches only a running
service, and the helper weighs every engine process and the service state:
anything not this user's in this session, or a running service, is taken
over. Without administrator rights it stops before killing the user's own
engine. Setup runs it forced, so the user's engine rereads the login and
settings setup just wrote.

install-host.ps1 stopped the running host and, with -NoStart or over SSH,
left none running until the next logon (or started one in the SSH session).
The background service now always comes back: through a one-off
Interactive, limited scheduled task with no time limit when run from
session 0, as --background otherwise; -NoStart only keeps the window shut.

Also:
- Setup clicked during a stream is turned down once with an explanation:
  the UAC prompt sits on the secure desktop, which a stream cannot show or
  click, and setup restarts the engine anyway. A second click within two
  minutes runs it. A viewer counts as connected while the engine's last
  session line is CLIENT CONNECTED (sessions also end on a Ping Timeout
  with no disconnect line, as stuzha's log shows).
- An engine archive beside the exe that is not the pinned release (left by
  an exe-only update after an engine bump, or cut short) is skipped for the
  pinned download instead of failing every later setup.
- The audio take-over, which compiled C# every 30 s for as long as the
  engine had no sound (forever on a PC with no playback device), backs off
  from 30 s to 10 minutes per complaint and compiles only when there is a
  device to set.
- /v1/display lists the engine's service under LatchStream, BroLinkStream
  or any name whose program is sunshinesvc.exe; it matched only *unshine*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r good

On X11 arboard keeps one connection for the whole process. When the X
server restarts under a Latch Host that keeps running, that connection is
dead, and dropping the handle (what the code did on any error, saying it
would be replaced) hands back the same dead context on the next call:
every /v1/clipboard read and write failed until someone restarted the
service by hand, and a Mac paste fell back to whatever the X selection
held.

Only a new process gets a working clipboard. After two unknown arboard
errors in a row, if a fresh connection to the display works, the service
logs and exits 1 for systemd (Restart=on-failure, in the user unit Latch
writes and in deploy/native) to start it again. When the display itself
does not answer, nothing is restarted, so a desktop that is down cannot
cause a restart loop. macOS keeps the old behaviour: it has no connection
to lose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…updates, desktop after logout

Docker node: tailscaled ran in userspace mode, which hands every peer's
connection to Latch Host from 127.0.0.1. Latch Host trusts loopback as the
machine itself, so any device that could reach the node over the tailnet
could pair its own client, use the clipboard and stop the service without
the owner check. The node now runs tailscaled with kernel networking
(/dev/net/tun, NET_ADMIN and NET_RAW in the compose file), so peers keep
their own address, and refuses to start Tailscale without the device rather
than fall back to userspace. The selftest fails on userspace mode.

.dockerignore excluded only the root .env, so deploy/node/.env and
deploy/relay/.env (Tailscale auth keys) went into the node's build stage,
which `docker build --target build -t ...` keeps as an image. Every .env and
.env.* is excluded at any depth now, with dist.noindex, app bundles and
__pycache__; the node selftest checks it. The image builds with --locked.

Relay: Compose fetches tailscale/tailscale:latest only when it has no copy,
so the internet-facing relay kept the tailscaled it was first installed
with (1.102.3 on sj-instance, two releases behind). install-relay.sh pulls
the image before `up -d` (a failed pull keeps the local copy) and prints
the version it runs; RELAY.md says how to update.

Native desktop: XFCE's Log Out ends xfce4-session with status 0, which
Restart=on-failure left stopped, so every later stream showed an empty
screen until someone started the unit over SSH. latch-desktop.service now
restarts always, slowing from 2 s to 2 minutes instead of giving up, as
stuzha's drop-in already does. The README says how to install the Linux
host from a release and how a unit change reaches a running node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rsion, --locked, retire step run on Windows

Both production Linux hosts (stuzha and the relay VPS) are arm64 and ran
hand cross-built binaries that no CI job had compiled for their
architecture; an i8/c_char mismatch once reached the VPS that way. CI now
runs clippy, the tests and a release build on ubuntu-24.04-arm as well as
x64, and the release workflow builds, tests and publishes
latch-host-linux-arm64.tar.gz and latch-host-linux-x64.tar.gz (the host,
LICENSE, NOTICE and deploy/native), each with a .sha256 beside it.

A tag pushed before the Cargo.toml bump published a release every Mac
downloads, rejects for its bundle version, deletes and fetches again every
15 minutes. The publish job now fails unless the tag is the workspace
version, read the way bundle-macos.sh reads it, and the macOS job checks
the bundle's own version on tags. Every cargo build, test and clippy runs
with --locked, so a stale Cargo.lock fails instead of being re-resolved on
the runner.

The Windows job runs the BroLink -> Latch retire step for real, through
Windows PowerShell, on temporary folders and a dummy service: a clean
move, a second run that changes nothing, an empty and a part-filled engine
folder in the way, and a move that fails on a held file, which must still
leave the pairing state in the new engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… UI crate

stream.rs: both branches added helpers after machine_noun (W2's power
menu helpers, W1's is_mac_os); both are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…back to its idle size

The host now checks every other tick whether a viewer is connected and
reports it as Streamer.viewer. The Mac skips a host update while it is
true instead of sending the whole executable to be turned down, and an
older host that still answers 409 (4.0 and 4.1 call any Desktop left
running a stream) is shown an Info notice and asked less often each
time, up to once an hour, instead of every three minutes.

On a desktop that follows its viewer (LATCH_DISPLAY_FOLLOWS_VIEWER, a
Linux VPS) a session nobody has watched for two minutes is ended, so
the engine runs its prep command's undo and the desktop goes back to
its idle size instead of staying at the last viewer's (stuzha sat at
3024x1964 between streams, which an agent's screenshots then paid for).

setup.rs's viewer check now reads the log through the streamer's
reader, so both treat a "Ping Timeout" as the end of a session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at once

Older versions saved whatever address Tailscale last reached a machine
on as its public address, which can be its LAN address; a wake packet
sent there goes nowhere. Saved addresses are now filtered through
tailscale::is_public_v4 when they are read. Peers are probed in
parallel: each can take two timeouts when nothing answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
an_address_that_refuses_is_skipped_for_the_next_one freed an ephemeral
port to get a closed address, and another test running in parallel
could bind that same port. Port 0 can never be connected to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shared Mac restarts and shuts down through System Events. Without
NSAppleEventsUsageDescription macOS never asks, and a hardened-runtime
(Developer ID) build is not allowed to send Apple Events at all without
com.apple.security.automation.apple-events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MACOS.md: keys in order, Command for a Mac host, Cmd-Q/H/+ to the PC
while the stream has the keys, paste with an image or a file on the
clipboard, power items that ask first. Settings help for Command acts
as Ctrl, and the manual-update texts point Linux at the release's
latch-host-linux tarball.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MrBeldum added a commit that referenced this pull request Oct 18, 2026
…icture

Work from 2026-10-03, not yet tried on Windows hardware (Hermes offline).

- With capture on automatic, Latch prefers the Virtual Display Driver even
  while a physical monitor is connected; the engine restores the physical
  displays when the stream disconnects.
- A windowless user task (LatchCursorUser) keeps a custom game cursor's
  whole shape inside the streamed display (crates/host/src/cursor.rs), so
  a cursor with a transparent top-left hotspot no longer disappears off
  the bottom or right edge; it follows resolution and shape changes,
  respects the game's own confinement and lets go when the game hides
  its cursor.
- Mac bundle staged in dist.noindex, release workflow to match.

Rebased onto PR #26 (fix/vps-stream-clipboard). Both changed how the
stream profile is kept, and neither side's version could simply win: #26's
one-time marker (now stream-profile-v4) would never run the virtual
display choice on a host that already has it, and #27's reconcile every
30 s re-wrote all of the engine conf, so settings an owner picked in the
engine's own page (nvenc_twopass, minimum_fps_target, the display option)
were put back and the engine restarted, and on Linux #26's engine conf
would have been enforced the same way. Now:

- The full engine conf stays a versioned one-time migration, exactly as
  #26 has it, on every platform.
- On Windows only, once that is in place, the service keeps just the
  capture display current every 30 s (virtual_display::capture/follow):
  output_name and dd_configuration_option, nothing else, and it logs
  which keys it changed whenever it restarts the engine.
- The display ID Latch set is remembered (capture-display in the data
  directory), so a driver that comes back under a new ID after a
  reinstall still counts as automatic and capture follows it, instead of
  staying pointed at a display that no longer exists. When the owner
  picks another display, the display option Latch set goes back.
- A PC that never had the driver is not asked anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows takes about a second to refuse a connection to a closed
localhost port (it retries the SYN), so the notice was not up yet when
the test looked; it failed on the Windows runner. The closed address
is now port 0, which no test running beside it can take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MrBeldum added a commit that referenced this pull request Oct 18, 2026
…icture

Work from 2026-10-03, not yet tried on Windows hardware (Hermes offline).

- With capture on automatic, Latch prefers the Virtual Display Driver even
  while a physical monitor is connected; the engine restores the physical
  displays when the stream disconnects.
- A windowless user task (LatchCursorUser) keeps a custom game cursor's
  whole shape inside the streamed display (crates/host/src/cursor.rs), so
  a cursor with a transparent top-left hotspot no longer disappears off
  the bottom or right edge; it follows resolution and shape changes,
  respects the game's own confinement and lets go when the game hides
  its cursor.
- Mac bundle staged in dist.noindex, release workflow to match.

Rebased onto PR #26 (fix/vps-stream-clipboard). Both changed how the
stream profile is kept, and neither side's version could simply win: #26's
one-time marker (now stream-profile-v4) would never run the virtual
display choice on a host that already has it, and #27's reconcile every
30 s re-wrote all of the engine conf, so settings an owner picked in the
engine's own page (nvenc_twopass, minimum_fps_target, the display option)
were put back and the engine restarted, and on Linux #26's engine conf
would have been enforced the same way. Now:

- The full engine conf stays a versioned one-time migration, exactly as
  #26 has it, on every platform.
- On Windows only, once that is in place, the service keeps just the
  capture display current every 30 s (virtual_display::capture/follow):
  output_name and dd_configuration_option, nothing else, and it logs
  which keys it changed whenever it restarts the engine.
- The display ID Latch set is remembered (capture-display in the data
  directory), so a driver that comes back under a new ID after a
  reinstall still counts as automatic and capture follows it, instead of
  staying pointed at a display that no longer exists. When the owner
  picks another display, the display option Latch set goes back.
- A PC that never had the driver is not asked anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The new Windows CI step caught it: with a file in the old engine held
open, every try failed as it should, but Move-Item left the destination
folder behind, so the next try moved the old engine into it
(engine\engine) and setup then installed beside a nested copy.
[IO.Directory]::Move is one rename that happens whole or not at all;
should a failed try still leave a folder there, it is kept aside like
any other partial one before the fresh engine goes in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MrBeldum added a commit that referenced this pull request Oct 18, 2026
…icture

Work from 2026-10-03, not yet tried on Windows hardware (Hermes offline).

- With capture on automatic, Latch prefers the Virtual Display Driver even
  while a physical monitor is connected; the engine restores the physical
  displays when the stream disconnects.
- A windowless user task (LatchCursorUser) keeps a custom game cursor's
  whole shape inside the streamed display (crates/host/src/cursor.rs), so
  a cursor with a transparent top-left hotspot no longer disappears off
  the bottom or right edge; it follows resolution and shape changes,
  respects the game's own confinement and lets go when the game hides
  its cursor.
- Mac bundle staged in dist.noindex, release workflow to match.

Rebased onto PR #26 (fix/vps-stream-clipboard). Both changed how the
stream profile is kept, and neither side's version could simply win: #26's
one-time marker (now stream-profile-v4) would never run the virtual
display choice on a host that already has it, and #27's reconcile every
30 s re-wrote all of the engine conf, so settings an owner picked in the
engine's own page (nvenc_twopass, minimum_fps_target, the display option)
were put back and the engine restarted, and on Linux #26's engine conf
would have been enforced the same way. Now:

- The full engine conf stays a versioned one-time migration, exactly as
  #26 has it, on every platform.
- On Windows only, once that is in place, the service keeps just the
  capture display current every 30 s (virtual_display::capture/follow):
  output_name and dd_configuration_option, nothing else, and it logs
  which keys it changed whenever it restarts the engine.
- The display ID Latch set is remembered (capture-display in the data
  directory), so a driver that comes back under a new ID after a
  reinstall still counts as automatic and capture follows it, instead of
  staying pointed at a display that no longer exists. When the owner
  picks another display, the display option Latch set goes back.
- A PC that never had the driver is not asked anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MrBeldum and others added 9 commits October 17, 2026 18:35
a_refused_first_request_surfaces_at_once took 1.07 s on a macOS runner
against a 600 ms bound. The bounds now only tell 'at once' from the 75
to 90 s these flows used to take, and fixed sleeps before checking a
notice are a wait of up to five seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…icture

Work from 2026-10-03, not yet tried on Windows hardware (Hermes offline).

- With capture on automatic, Latch prefers the Virtual Display Driver even
  while a physical monitor is connected; the engine restores the physical
  displays when the stream disconnects.
- A windowless user task (LatchCursorUser) keeps a custom game cursor's
  whole shape inside the streamed display (crates/host/src/cursor.rs), so
  a cursor with a transparent top-left hotspot no longer disappears off
  the bottom or right edge; it follows resolution and shape changes,
  respects the game's own confinement and lets go when the game hides
  its cursor.
- Mac bundle staged in dist.noindex, release workflow to match.

Rebased onto PR #26 (fix/vps-stream-clipboard). Both changed how the
stream profile is kept, and neither side's version could simply win: #26's
one-time marker (now stream-profile-v4) would never run the virtual
display choice on a host that already has it, and #27's reconcile every
30 s re-wrote all of the engine conf, so settings an owner picked in the
engine's own page (nvenc_twopass, minimum_fps_target, the display option)
were put back and the engine restarted, and on Linux #26's engine conf
would have been enforced the same way. Now:

- The full engine conf stays a versioned one-time migration, exactly as
  #26 has it, on every platform.
- On Windows only, once that is in place, the service keeps just the
  capture display current every 30 s (virtual_display::capture/follow):
  output_name and dd_configuration_option, nothing else, and it logs
  which keys it changed whenever it restarts the engine.
- The display ID Latch set is remembered (capture-display in the data
  directory), so a driver that comes back under a new ID after a
  reinstall still counts as automatic and capture follows it, instead of
  staying pointed at a display that no longer exists. When the owner
  picks another display, the display option Latch set goes back.
- A PC that never had the driver is not asked anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…viewer watches

The guard confined every custom cursor by its whole bitmap. A 32x32 or
64x64 game cursor with its tip at the hotspot (most RTS and MOBA
cursors) stopped 31-63 px short of the right and bottom edges: edge
panning never triggered and buttons in that band could not be clicked,
for no gain, since that tip was always visible at the edge. It now reads
the cursor's pixels (alpha, or the AND/XOR masks) and only keeps the
visible pixel nearest the hotspot on screen; a cursor drawn at its
hotspot is never clipped.

The guard was on whenever the engine reported a running app, and the
Desktop app keeps running after the viewer disconnects, so a local
player's cursor stayed confined until the engine restarted. It now also
needs the engine log's last word to be CLIENT CONNECTED (a disconnect or
a ping timeout ends it).

The node launched PowerShell every 30 s to check the guard's task, on
the service's tick thread. A node in the desktop session (the Run-key
start) now runs the guard as its own child process; only a node in
session 0 uses the task, registered once, without a logon trigger that
could start it for a dead node's process ID, and run with schtasks. The
guard checks that its parent PID is still a latch-host process, and
sleeps a second at a time while no viewer is connected.

The clip bookkeeping is a small state machine with the Windows calls
behind a trait, so its rules are tested: a game's own confinement is
kept and put back, a clip someone else set is never lifted, a desktop
that was free is freed again (not set to the old desktop's size), and a
clip left by a guard that was killed is recognised and lifted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…covery

After a stream, Sunshine's display recovery logs "Currently available
devices:" with a list of "<device_id> - <friendly_name>" strings
(display_device.cpp, try_revert_settings), not the objects it logs at
startup. output_in() parsed that list, found no friendly_name on the
strings and reported no virtual display, so a host still on automatic
capture was not switched to the driver until a later engine start
logged the object form. Both forms are read now; the latest device list
still decides, and other text that begins "Currently available " is
skipped instead of hiding the list before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One reading of 'is a viewer connected' for the update deferral, setup
and the cursor guard: the last CLIENT CONNECTED, CLIENT DISCONNECTED or
Ping Timeout line decides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui reports neither a Caps Lock key nor a modifier for it, so with Caps
Lock on the Mac the PC still typed lowercase. The Mac's state is read
from AppKit (+[NSEvent modifierFlags]) each frame; the first state seen
while the stream has the keys is taken as matching (the PC's own can't
be read), and each change after it reaches the PC as one tap of Caps
Lock, queued like any key so it keeps its place behind a pending paste.
A change made while Latch or another app had the keys goes once the
stream has them again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows: prefer the virtual display and keep a game's cursor in the picture
Audit fixes: updates after a stream, Docker trust, pairing, input order, clipboard, power, config, service lifecycle, Windows setup, CI
@MrBeldum MrBeldum changed the title VPS desktops: own size, sizes below 1080p, Linux clipboard, pairing fix Complete desktop streaming, input and audited reliability fixes Oct 20, 2026
@MrBeldum
MrBeldum merged commit 8aa2165 into main Oct 20, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant