Skip to content

Audit fixes: updates after a stream, Docker trust, pairing, input order, clipboard, power, config, service lifecycle, Windows setup, CI - #28

Open
MrBeldum wants to merge 31 commits into
fix/vps-stream-clipboardfrom
fix/audit-2026-10-14
Open

MrBeldum wants to merge 31 commits into
fix/vps-stream-clipboardfrom
fix/audit-2026-10-14

Conversation

@MrBeldum

Copy link
Copy Markdown
Owner

A senior-dev audit of Latch, stacked on #26 (base fix/vps-stream-clipboard). Sixteen reviewers swept every crate, the deploy kits, CI and the live Mac and stuzha logs. Each finding was then checked by a skeptic told to disprove it, and five fix workstreams worked in separate branches, merged here. The CHANGELOG's Unreleased section has the user-facing list.

Must-fix before #26 merges

  • Windows refused every pushed update after any stream (streamer::session_active counted a Desktop left running as a live stream), and the Mac re-sent the whole exe every 3 minutes. The host now defers only while a viewer is connected; Ping Timeout without CLIENT DISCONNECTED counts as gone, as seen live on stuzha. It also reports streamer.viewer in /v1/status, so the Mac waits instead of uploading. An old host that still answers 409 gets asked with backoff, up to once an hour.
  • Docker node / userspace Tailscale: every tailnet peer arrived from 127.0.0.1 and was trusted as the local panel. Loopback callers are now resolved with tailscale whois 127.0.0.1:<port> when TUN is false. The node kit runs Tailscale with kernel networking.

By area

  • Pairing (client + host):
    • The PIN hand-off runs beside the first pairing request instead of in front of it.
    • Cancel works throughout.
    • A host's refusal is shown with its reason and ends the attempt (this was the 0.9 s refusal flood in stuzha's log).
    • The PIN goes to this Mac's own pending pairing.
    • Tests cover the ordering.
  • Session:
    • Cancelling a connect no longer quits an app it rejoined.
    • Failures are logged with every cause.
    • The Desktop is relaunched only where the desktop follows the viewer.
  • Stream input:
    • Events are sent in order: modifiers per event, a click at its own position, keys typed after ⌘V queued behind the paste.
    • Horizontal scroll sign fixed.
    • ⌘Q/⌘H/⌘± reach the PC while it has the keys.
    • Host-key chord no longer leaves keys stuck.
    • Tab works in Free mode.
    • A Mac host gets ⌘ as Command.
    • View::input now has a test harness.
  • Clipboard: ⌘V with an image, files or an empty Mac clipboard; PC file/image copies; the 32 KB cut no longer clobbers the Mac clipboard; the size bound counts JSON-escaped length; the Linux X11 clipboard recovers after the X server restarts.
  • Power:
    • Sleep is offered only for Windows hosts and refused by hosts nothing can wake (Linux VM, Mac, container).
    • Every action asks first and respects power_allowed.
    • Actions are keyed by node id.
    • Mac Restart/Shut down failures are reported, and the bundle gets the Apple Events usage string and entitlement.
  • Config: one unreadable value no longer wipes client.toml with its pairing certs, because the parse salvages everything else and keeps a copy of the original. The service keeps its last good host.toml.
  • Service lifecycle:
    • macOS: start/restart through launchd, login-item and firewall checks, real stay-awake.
    • The panel no longer restarts a by-design-exiting service every 4 s (the Mac logged 6,400 restarts in 7 h).
    • A self-update probation watcher rolls back a version that starts but never serves.
    • Panic hook.
    • accept_updates switch.
    • Wake listener Windows-only.
  • Tailscale/core: null Peer/User maps, LAN addresses kept out of public_ip (also when already saved), idle peers no longer "relayed", Mullvad exit nodes filtered, DERP ids from 14 up (LA was "Paris"), peers probed in parallel.
  • Lobby: forget a machine (and auto-forget after 30 days off the tailnet); "Share this Mac off" wording; the black-screen question no longer gets stuck; fullscreen sync; handover off the UI thread and BroLink-era host names.
  • VPS desktop that follows its viewer: a session nobody has watched for 2 minutes is ended, so the prep command's undo restores the idle size.
  • Windows setup:
    • The engine starts in the signed-in session even when setup runs over SSH.
    • No SYSTEM engine beside the user's.
    • install-host.ps1 restarts the host.
    • The BroLink engine move retries and keeps pairing.
    • Stale-archive download fallback.
    • The display diagnostic finds LatchStream.
    • The audio take-over backs off.
    • Setup asks before running during a stream.
  • UI crate: Light-mode crash at launch fixed; Escape consumed by menus; orphaned menus closed; accessibility names, values and slider actions; contrast; spinner idle repaint; CJK fallback font.
  • CI/release: Linux host built for x64 and arm64 in CI and published as latch-host-linux-{arm64,x64}.tar.gz with digests; tag must match the workspace version; --locked; the BroLink retire step runs on a fake layout on Windows CI; .dockerignore excludes every .env; the relay pulls current Tailscale.

Verified

  • On the Mac: cargo fmt --check, cargo clippy --workspace --all-targets -D warnings and cargo test --workspace (375 tests) all pass.
  • Windows host code cross-checked with cargo-zigbuild clippy --target x86_64-pc-windows-gnu -D warnings.
  • The flaky http refusing-address test is fixed.

Not verified on real hardware (Hermes is offline)

  • The Windows session-engine handover, scheduled tasks, the probation watcher on Windows, and the retire step on a real BroLink layout. CI is their first run.
  • Nothing reaches Hermes until a release is published.

The draft #27 (Windows cursor) will be restacked on this branch separately.

🤖 Generated with Claude Code

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>
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 and others added 2 commits October 17, 2026 18:10
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>
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>
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