diff --git a/tests/test_link.py b/tests/test_link.py index 1a8589e..090b18b 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -263,6 +263,36 @@ class Connecting(unittest.TestCase): self.assertEqual(self.link.active_device()["alias"], "frame-bare") self.assertEqual(self.routes[-1], ("frame-bare", [])) + def test_setup_changing_the_login_waits_for_installs(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + cfg = self.dir / "ssh" / "config" + cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User steamos\n" + f" Port {self.port}\nHost *\n# <<< steam-frame (frame-t) <<<\n") + self.link.watch_config() # the block's login is recorded + routes = len(self.routes) + cfg.write_text(cfg.read_text().replace("User steamos", "User deck")) + running = [1] + self.link.work = lambda: running[0] + self.link.config_mtime = None + self.link.watch_config() + self.assertEqual(len(self.routes), routes) # an install is running: not yet + running[0] = 0 + self.link.watch_config() + self.assertIn("User=deck", self.routes[-1][1]) + + def test_a_rename_leaves_the_login_in_the_config_alone(self): + d = self.device("localhost") + cfg = self.dir / "ssh" / "config" + cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User deck\n" + "Host *\n# <<< steam-frame (frame-t) <<<\n") # setup wrote a new user, not yet imported + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None) + self.assertIn("User deck", cfg.read_text()) + out = fl.devices_action(self.link, {"action": "update", "id": d["id"], "port": 2200}, None) + self.assertIn("User deck", cfg.read_text()) # changed meanwhile: left as it is + self.assertIn("left as it is", out["message"]) + def test_probes_from_an_earlier_attempt_leave_the_new_rows_alone(self): self.link.state.update(attempt=2, probes=[{"host": "b", "state": "waiting"}]) self.link.probe_update(0, 1, state="answered", ip="10.0.0.2") diff --git a/ui/frame_host.py b/ui/frame_host.py index a3842f3..c2ecff4 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -257,9 +257,9 @@ def open_steam_link(): return "Steam Link isn't installed; opened its download page" -def open_rdp(alias): - """Remote desktop to the Frame's xrdp (user steamos).""" - host = ssh_hostname(alias) +def open_rdp(alias, host=None): + """Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points.""" + host = host or ssh_hostname(alias) if MAC: if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0: return "Opened Windows App" diff --git a/ui/frame_link.py b/ui/frame_link.py index 90eb0d5..8ab5350 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -153,6 +153,9 @@ class Link: def __init__(self, registry, *, env_alias, mux_base, control, apply, explain): self.reg = registry self.override = env_alias # FRAME_ALIAS, if set: the headset this server starts on + self.work_lock = threading.Lock() # the server's: held, no install starts (see server.working) + self.work = lambda: 0 # how many installs are running + self.deferred = False # a login change from ~/.ssh/config waiting for them self.session_alias = env_alias # ...and stays selectable after switching away self.mux_base = list(mux_base) # ["ssh", "-o", "BatchMode=yes", ControlPath...] self.control = control # ControlPath, or None where ssh can't share connections @@ -407,11 +410,19 @@ class Link: if mtime != self.config_mtime: self.config_mtime = mtime before = self.active_device() + if before.get("transient") and not before.get("none") and self.routed is not None: + # A bare alias is in use: a headset set up now doesn't take over by itself. + self.override = self.override or before["alias"] if self.reg.sync_from_config(): self.devices_changed() after = self.active_device() if (before.get("user"), before.get("port")) != (after.get("user"), after.get("port")): - self.invalidate() # Set Up Connection changed the active headset's login + self.deferred = True # Set Up Connection changed the active headset's login + if self.deferred: + with self.work_lock: # not while an install runs: it reads the route step by step + if not self.work(): + self.deferred = False + self.invalidate() def refresh_network(self): net = frame_network.current_network(self.last_fp) @@ -937,13 +948,21 @@ def devices_action(link, body, open_setup, busy=lambda: 0): elif action == "update": before = reg.get(did) d = reg.update_device(did, name=body.get("name"), user=body.get("user"), port=body.get("port")) - if is_active and (d["user"], d["port"]) != (before["user"], before["port"]): + login_changed = (d["user"], d["port"]) != (before["user"], before["port"]) + if is_active and login_changed: link.invalidate() # before anything else can fail: the old login mustn't stay in use - try: - frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"]) - except OSError as e: - raise frame_devices.DeviceError(f"Saved, but couldn't update ~/.ssh/config: {e}") msg = f"Saved {d['name']}" + if login_changed: + # Only what changed, and only if the block still says what it did: Set Up + # Connection may have written a new login meanwhile, which then stands. + try: + if not frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"], + expect={"user": before["user"], "port": before["port"]}) \ + and any(b["alias"] == d["alias"] and (b["user"], b["port"]) != (d["user"], d["port"]) + for b in frame_devices.parse_blocks(frame_devices.read_config())): + msg += "; ~/.ssh/config changed meanwhile, so it was left as it is" + except OSError as e: + raise frame_devices.DeviceError(f"Saved, but couldn't update ~/.ssh/config: {e}") elif action == "remove": if not body.get("config") and len(reg.devices()) == 1 and reg.get(did)["alias"] in { b["alias"] for b in frame_devices.parse_blocks(frame_devices.read_config())}: diff --git a/ui/server.py b/ui/server.py index 9e406da..841b1d5 100755 --- a/ui/server.py +++ b/ui/server.py @@ -580,19 +580,25 @@ def open_thing(body): if what in ("reboot", "poweroff", "suspend"): return power(what, body.get("password")) raise Failure("open that from the app", 400) + # The headset and address in use, as every other command gets them (one snapshot). + with _route_lock: + alias, opts = FRAME, list(HOST_OPTS) + host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), None) + if what in ("terminal", "reboot", "poweroff", "suspend", "rdp", "sftp") and host and host.endswith(".invalid"): + raise Failure("No headset address to use: add one on the Devices tab", 400) try: if what == "terminal": - return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"} + return {"message": f"Opened an SSH session in {terminal(['ssh', *opts, alias])}"} if what in ("reboot", "poweroff", "suspend"): # logind answers "challenge" over SSH, so sudo (and the password) is needed. - where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what]) + where = terminal(["ssh", "-t", *opts, alias, "sudo", "systemctl", what]) return {"message": f"Confirm with the Developer Mode password in {where} to {what}"} if what == "steamlink": return {"message": frame_host.open_steam_link()} if what == "rdp": - return {"message": frame_host.open_rdp(FRAME)} + return {"message": frame_host.open_rdp(alias, host)} if what == "sftp": - return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"} + return {"message": f"Opened an SFTP session in {terminal(['sftp', *opts, alias])}"} if what == "shots": SHOTS_DIR.mkdir(parents=True, exist_ok=True) frame_host.open_path(SHOTS_DIR) @@ -1659,6 +1665,7 @@ def main(): if not LOCAL: LINK = frame_link.Link(frame_devices.Registry(), env_alias=FRAME if FRAME_FROM_ENV else None, mux_base=MUX_BASE, control=CONTROL, apply=route, explain=unreachable) + LINK.work_lock, LINK.work = _work_lock, lambda: _work[0] LINK.start() if not frame_host.WINDOWS: signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))