Devices: fixes from review round 8

- 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>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 22:45:34 +10:00
1 parent 51ef5d8283
commit 175c39a2b0
3 files changed
+37 -8

No files matched your search

+12 -4
View File
@@ -338,8 +338,10 @@ class Link:
"""What every ssh command adds to reach DEVICE at HOST."""
if device.get("none"):
return ["-o", "HostName=no-headset.invalid"] # fails at once, with ssh's own "can't resolve"
if device.get("transient") or not host:
if device.get("transient"):
return []
if not host: # a headset with no addresses: reach nothing, not whatever ~/.ssh/config says
return ["-o", "HostName=no-address.invalid"]
return ["-o", f"HostName={frame_devices.ssh_host(host)}",
"-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}",
"-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt(device['id'])}", "-o", "HashKnownHosts=no",
@@ -454,7 +456,7 @@ class Link:
self.state.update(phase="connected", retry_at=None, error=None)
else:
self.fails += 1
self.state.update(phase="failed", retry_at=None if device.get("none") else
self.state.update(phase="failed", retry_at=None if device.get("none") or not (device.get("transient") or device["addresses"]) else
now() + RETRY[min(self.fails, len(RETRY)) - 1])
if not self.state["error"]:
self.state["error"] = {"stage": "find", "message": "Couldn't connect", "raw": ""}
@@ -485,6 +487,9 @@ class Link:
if device.get("none"):
self.fail("find", "No headset is set up. Add one on the Devices tab.")
return False
if not device.get("transient") and not device["addresses"]:
self.fail("find", f"{device['name']} has no addresses. Add one on the Devices tab.")
return False
# 1. this computer's network
self.stage("network", "active")
net = frame_network.current_network(self.last_fp)
@@ -506,8 +511,7 @@ class Link:
# 2. find the headset
self.stage("find", "active")
port = device.get("port") or 22
bare = device.get("transient") or not device["addresses"]
if bare:
if device.get("transient"):
host, port, user, proxied = ssh_g(device["alias"])
if user and not device.get("user"):
device["user"] = user
@@ -951,9 +955,13 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
elif action == "address-update":
a = reg.update_address(did, body.get("host"), new_host=body.get("newHost"), kind=body.get("kind"),
label=body.get("label"))
if is_active and a["host"] != body.get("host"):
link.invalidate() # the address in use may have moved
msg = f"Saved {a['host']}"
elif action == "address-remove":
reg.remove_address(did, body.get("host"))
if is_active:
link.invalidate() # it may be the address in use: stop using it now
msg = f"Removed {body.get('host')}"
elif action == "address-move":
delta = body.get("delta")
+14 -4
View File
@@ -1537,12 +1537,12 @@ window.addEventListener("drop", e => {
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
sendFiles([...e.dataTransfer.files], dirs);
});
function upload(file, mode) {
function upload(file, mode, dev = window.connDevice) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
if (window.connDevice) xhr.setRequestHeader("X-Frame-Device", window.connDevice);
if (dev) xhr.setRequestHeader("X-Frame-Device", dev);
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
xhr.setRequestHeader("X-Mode", mode);
const bar = $("prog").firstElementChild;
@@ -1603,7 +1603,12 @@ $("apkAltClose").onclick = () => $("apkAltDlg").close();
const TITLE_EXT = /\.(zip|exe)$/i;
async function sendFiles(files, dirs = new Set()) {
const dev = window.connDevice; // the whole batch is for the headset it was dropped on
for (const [i, f] of files.entries()) {
if (window.connDevice !== dev) {
toast(`Switched headsets: didn't send ${files.length - i} remaining file${files.length - i === 1 ? "" : "s"}`, true);
break;
}
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
if (dirs.has(i)) {
if (path) await sideload(f, path, true);
@@ -1613,7 +1618,7 @@ async function sendFiles(files, dirs = new Set()) {
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push", dev));
}
$("fileInput").value = "";
refresh();
@@ -2639,9 +2644,14 @@ $("connSetup").onclick = setUpActive;
// ---- Devices tab ----
PAGES.push("devices");
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
async function loadDevices() {
if (!link.live) return;
try { dv.data = await api("/api/devices"); } catch (e) { return failed($("devList"), e); }
const seq = ++devicesSeq;
let data;
try { data = await api("/api/devices"); } catch (e) { if (seq === devicesSeq) failed($("devList"), e); return; }
if (seq !== devicesSeq) return;
dv.data = data;
dv.list = dv.data.devices;
if (!dv.list.some(d => d.id === dv.sel)) dv.sel = dv.data.active;
const sel = $("devSel");