From the review of the Reconnect change:
- A toast raised while a dialog was open (Reconnect refused during an install,
say) sat under the dialog's backdrop, dimmed. The toast is now a manual
popover, raised again when a dialog has opened since, so it shows on top.
- The new test could wait for ever if the first test never reached its probe,
and didn't check its threads finished. It now waits on events with time
limits, releases everything in finally, and also checks the first test
finishing doesn't mark the second, still running, done.
- docs/devices.md: Reconnect applies your changes by reconnecting; which
address wins still depends on ranking and timing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three review rounds kept finding ways Use now could point at an address a
reconnect wouldn't pick: the page was predicting the outcome of the server's
race from test results that could be unfinished, from another network, or out
of date after a reorder or a Tailscale change. Adding the offered LAN address
first in the list is what makes it win; Use now only hurried that along.
The dialog's Retry button now also shows while connected, as Reconnect: it tries
the addresses again in their current order and promises nothing about which
answers first. The test results go back to plain rows.
The review also found a test started earlier could overwrite a newer one still
running, and mark it done. Each headset's tests now have a generation; only the
newest one publishes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fix review found Use now could still point the wrong way: the button was
worked out from per-address ranks in the devices list while the test was still
running (a better-ranked address might yet answer), and the test's successes
changed those ranks before the list was reloaded.
The test now finishes by publishing the order a reconnect on this network would
try, worked out after it has recorded where each address works, together with
the network it ran on. The page offers Use now only once the test is done, only
for that network, and only on the first address in that order that passed. The
ranks in the devices list are gone again. docs/devices.md no longer promises
where a reconnect lands: a slow address loses to a later one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From the second independent review of this PR:
- Use now sends a plain reconnect, which picks the first-ranked address that
answers, but it was offered on every tested address listed above the one in
use. The devices list now carries each address's rank on the current network
(frame_devices.order_addresses), and only the address a reconnect would pick
gets the button.
- Saving one address, cancelling, then editing another: the first save's answer
closed the second editor and lost what was typed. Each edit now has its own
session, and a late answer leaves a newer one alone.
- Switching headsets with the dialog open drew the address offer from the
previous headset's status before it was cleared, so Add could save its IP to
the new headset. The dialog now renders after the old status is cleared.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ssh's %C hashes only address, user and port, so two headsets reached at one
address shared a ControlMaster and one's commands could run on the other: the
ControlPath now names the headset. Another Frame Control server choosing a
different headset no longer moves this one's commands mid-install.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- While the connector is taking a queued reconnect off its list, the old
connection no longer counts as live, so no install starts on it.
- Importing a block without a Port takes the port ssh would really use
(ssh -F <config> -G), e.g. one a later Host * sets.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Behind a jump host, a forward it couldn't open moves on to the next address;
only a refused key stops (judged by ssh's words, not the step).
- Add a headset suggests an alias no Host in ~/.ssh/config already uses.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A jump host's own "Authenticated to" line no longer counts as the headset's,
and a master that logs in but doesn't start lets the next address be tried.
- Devices tab changes refresh through the ordered list load, so a late answer
can't undo a newer selection.
- A probe's time out starts after the name lookup: macOS can take 5 s to look
up a .local name (found on the real Frame once its USB link went away).
- An attempt's ending is published from a method, not a return in finally.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A switch the server refuses no longer drops answers the page is waiting for
(an install's job id): only an actual change of headset does.
- Test now goes through a jump host when the alias uses one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A set-up headset whose alias goes through a jump host (ProxyJump or
ProxyCommand in ~/.ssh/config) is reached through it, address by address,
still pinned per headset.
- A refused switch puts the header's switcher back on the headset in use.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A command that fails after a switch doesn't make the connector drop the new
headset's connection.
- On first import, the app keeps using the `frame` headset even when Set Up
Connection put another block above it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Terminals, power and a reconnect's probes use the route commands have now
(a pinned bare-alias destination, a login change still deferred).
- Saving port 22 keeps an explicit Port line where there was one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A bare alias's route is pinned to where ~/.ssh/config sent it when it was
routed (HostName, Port, User), so editing that file can't move an install.
- Renaming during an install is allowed: only a real user or port change waits.
- The Devices tab follows a network change even while the headset is offline.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Retry now (while connected) and Forget identity wait for running installs.
- Terminal windows get the headset's address by name, so a link-local IPv6
zone never has to pass through Windows' console.
- Renaming the headset in use shows at once in the header.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Every command to a set-up headset checks its pinned key
(StrictHostKeyChecking=yes, whatever ~/.ssh/config says); only the
connector's first handshake may save one.
- A reconnect during an install keeps the whole route it started with, also
when a bare alias is set up meanwhile.
- Removing the headset FRAME_ALIAS named doesn't bring it back as a bare alias.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A reconnect while an install runs keeps the login it started with; a new
one from ~/.ssh/config applies after.
- Frame > Open SSH goes through the server, so it uses the same headset and
address as the app and refuses when there's none.
- A bare frame alias in use when a headset is set up stays selectable.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A headset set up while a bare alias is in use doesn't take over by itself;
a login change from ~/.ssh/config waits for running installs.
- Saving a headset writes only the login fields that changed, and only if the
block still holds the old ones.
- SSH, SFTP, power and remote desktop open with the same headset and address
as every other command, and refuse when there's no address.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Removing or moving the active headset's address waits for running installs,
like switching.
- A volume change still waiting to be sent goes to the headset whose slider
it was, and a switch cancels it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A title waiting its turn to be read stays with the headset it was dropped
on, and is dropped if the app switches meanwhile.
- Probes still finishing from an earlier attempt can't overwrite the rows of
a newer one.
- The FRAME_ALIAS the server started with stays on the list after switching
away, so it can be picked again.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A batch of dropped files stays with the headset it was dropped on, and
stops if the app switches.
- Removing or moving the address in use reroutes at once; a headset with no
addresses reaches nothing rather than whatever ~/.ssh/config says.
- A late answer to an older device-list request is ignored.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Removing every headset leaves none in use (commands fail at once) instead of
falling back to the `frame` alias.
- ssh goes to the IP that answered for IPv6 too, with a link-local address's
interface (verified: frame.local over fe80::…%en9 on the real Frame).
- Find results only show in the panel of the headset they were for.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The headset a change is meant for is checked and the work counted in one
step, so a switch can't slip in between (uploads too).
- A sideloaded title read on one headset can't be installed on another; open
confirmations close on a switch.
- The only headset can't be removed while its ssh alias stays behind.
- Answers about the previous headset are dropped without touching panels; the
catalogue's Installed tags are rebuilt for the new headset.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A switch publishes the new headset at once, so the page clears the old one's
panels and the lists behind them (games, store, Android apps, screenshots).
- The page names the headset its changes are for (X-Frame-Device); the server
refuses one meant for a headset it has switched away from (409).
- A rejected address edit changes nothing.
- Test now goes to the IPv4 address that answered, like the connection.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Attempts carry a generation: one overtaken by a switch, a removal or a login
change routes nothing back to the old headset and can't report connected.
- Removing the active headset or changing its user/port reroutes at once,
before anything that can fail.
- One known_hosts file per headset (~/.ssh/frame-control-hosts/<id>):
forgetting one headset's key can't drop another's, whoever else writes.
- learn() checks, under the config lock, that the block is still what the
attempt started from before writing to it.
- A switch stops live video and drops captures from the previous headset.
- A probe shares its time between the addresses a name resolves to.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Switching headsets is serialized with the start of any install; background
work counts as running from before its thread starts. use() reroutes every
command at once and makes ensure() wait for the new headset.
- A new user or port reroutes commands even if the attempt then fails.
- ~/.ssh/config edits take a lock file shared with Set Up Connection
(frame_connect.py and connect.sh, which now also writes atomically).
- A finished attempt no longer writes its older settings over a change Set
Up Connection made meanwhile.
- Pin edits are locked and swapped atomically.
- A bare alias behind ProxyJump/ProxyCommand is left to ssh to reach.
- The page drops answers about the previous headset after a switch; the
header switcher takes clicks in the macOS title bar.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- No switching headsets (or changing the active one's user/port, or removing
it) while installs run: they read the ssh settings step by step.
- Switching reroutes every command to the new headset at once, even if it
never answers.
- ~/.ssh/config edits are serialized, use unique temp files, and back off if
another program wrote the file meanwhile.
- stop() ends a handshake in progress and joins the connector.
- Pinned keys are written unhashed (HashKnownHosts=no); hashed ones are still
found and forgotten via ssh-keygen.
- Set Up Connection changing a headset's user or port updates the registry.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Frame Control can now manage more than one Steam Frame, and reach each at any
of several addresses (LAN IPs per network, its .local name, Tailscale). A
connector in the server tries them all at once, picks the best one that
answers, follows ssh -v through each stage (network, finding, SSH, identity,
login) and streams that to the page. The header shows it live; a new Devices
tab (key 5) manages headsets, addresses and network names.
- ui/frame_devices.py: registry in devices.json, imported from the managed
~/.ssh/config blocks; per-headset host key pinning; config block updates.
- ui/frame_network.py: gateway IP+MAC fingerprint, Wi-Fi name, Tailscale.
- ui/frame_link.py: the connector, Test now, Tailscale/mDNS discovery, API.
- server.py: ensure_master delegates to the connector; /api/connection,
/api/connection/events (SSE), /api/devices.
- Electron: headset switcher and Devices item in the Frame menu.
- frame_connect.py --alias; FRAME_CONTROL_DATA_DIR / FRAME_CONTROL_SSH_DIR
keep tests off real data.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>