Compare commits

..
Author SHA1 Message Date
SAPHID 9c7110c1f4 Handle Frame OS 0.5.6 discovery and SteamOS name changes 2026-10-24 12:14:19 +00:00
22 changed files with 339 additions and 26 deletions

No files matched your search

+1
View File
@@ -254,6 +254,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
| [SteamOS October 2026 compatibility](docs/steamos-2026-10-compat.md) | Frame OS 0.5.6 connection changes and SteamOS 3.9.5 PC host notes |
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
+4 -2
View File
@@ -8,13 +8,15 @@ installed; the desktop app does not bundle this command.
```sh
scripts/frame-doctor.sh
scripts/frame-doctor.sh --alias bedroom --lan frame.local
scripts/frame-doctor.sh --alias bedroom --lan 192.168.1.40
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
```
Use only addresses belonging to your intended headset. The command tries the
existing `frame` SSH alias first (Tailscale if setup configured it that way),
then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb`
then an optional `--lan` address, then USB-C at `10.86.200.233`. SteamOS 0.5.6
stopped publishing mDNS names, so use a known IP for `--lan` if `.local` no longer
resolves. `--alias`, `--lan` and `--usb`
override those defaults. It reports the successful path without printing its
address. It neither discovers other devices nor enables Tailscale.
+6 -2
View File
@@ -46,14 +46,14 @@ unless your router's DNS registers DHCP client names.
```sh
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_frame -N '' -C "mac->steam-frame"
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@frame.local
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@192.168.1.40
```
`~/.ssh/config` block (managed between marker lines by the script):
```
Host frame
HostName frame.local
HostName 192.168.1.40
User steamos
IdentityFile ~/.ssh/id_ed25519_frame
IdentityFile ~/.ssh/id_rsa_frame_devkit
@@ -62,6 +62,10 @@ Host frame
```
The script only asks for the password if the pairing below doesn't work.
Replace the example IP with the one in the Frame's Quick Settings. SteamOS
0.5.6 stopped publishing `.local` names; an existing SSH alias or the headset's
current IP is more reliable. Frame Control remembers authenticated IPs per
network and checks the saved SSH identity on reconnect.
## Password-free pairing (SteamOS devkit service)
+62
View File
@@ -0,0 +1,62 @@
# SteamOS October 2026 compatibility
SteamOS 3.9.5 is the **PC host / Steam Machine** update. It is not the Frame
headset OS. The headset release discussed here is **SteamOS 0.5.6 beta**.
## Finding and connecting to the headset
SteamOS 0.5.6 stopped publishing mDNS names, including `frame.local`. Existing
addresses in Frame Control still work if they resolve, and the app tries all
saved addresses. After an authenticated connection through a `.local` name,
Frame Control also remembers the numeric IP on that network. If the name stops
resolving, it tries that IP and checks the same pinned SSH identity before using
it. The IP is never reused automatically on a different network.
In **Devices**, **Find on this network** checks the SteamOS devkit advertisement,
known IPs and the local IPv4 `/24`. It offers scan results only when their SSH
key matches the headset's saved key. The scan is bounded to a private subnet;
it cannot find a headset across a router or on another subnet. Enter the IP
shown in the headset's Quick Settings in the address field in those cases.
For initial pairing, **Set Up Connection** accepts a manual IP and reuses an
existing `~/.ssh/config` `HostName` before trying mDNS. A configured SSH alias
continues to work. Frame doctor no longer assumes `frame.local`: supply
`--lan <IP>` when a separate LAN route is needed.
## SteamOS script names
Session switching already prefers `holo-session-select` over
`steamos-session-select`, after the Frame's `steamosctl` control. Desktop
process detection accepts `holo-nested-desktop` and its old name. The sideload
helper prefers `holo-prepare-upload` and `holo-delete` if a future devkit
provides them, falling back to the vendored `steamos-*` scripts. The devkit
service name `_steamos-devkit._tcp` is a protocol identifier, so it is not
renamed.
## SteamOS 3.9.5 Linux PC hosts
NetworkManager's `nmcli` supplies the active SSID and route diagnostics; the
SSID reader falls back to `iwgetid`. Neither depends on whether NetworkManager
uses wpa_supplicant (new installs) or iwd (upgraded installs). The current code
does not manage either backend or the Steam Frame Wireless Adapter. An adapter
doctor belongs in its own PC host feature.
## Android APKs and telemetry
The 0.5.6 Desktop Mode **Add to Steam** action for `.apk` files is another way
to make a Steam library entry. Frame Control already creates a non-Steam
shortcut for every installed APK through the running Steam client's API, with
its own persistent Lepton instance and artwork. It does not edit
`shortcuts.vdf` or require a Steam restart, so no extra shortcut option is
needed in its install flow.
The battery reader selects a system battery by power-supply `type` and `scope`
and reads standard sysfs fields. The memory reader uses `/proc/meminfo`.
Neither relies on the performance overlay's labels or hard-coded SteamOS
paths. SteamOS 0.5.6's improved overlay reporting does not establish a sysfs
path change. The dominant-eye setting is not read yet; recording metadata
must not claim an eye until its source and effect on `/dev/video99` are verified.
Sources: [SteamOS 3.9 news](https://store.steampowered.com/news/app/1675200),
[Frame OS news](https://steamcommunity.com/app/4165890/allnews/), and the
Frame Control implementation in `ui/frame_network.py`, `ui/frame_status.py`,
`ui/frame_android.py`, `ui/frame_link.py` and `frame/android/steam_shortcuts.py`.
+6 -1
View File
@@ -73,7 +73,11 @@ discover_devkit() {
pick_host() {
local candidates=()
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
[[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
if [[ -z "$host_arg" ]]; then
local saved=$(ssh -G "$FRAME_ALIAS" 2>/dev/null | awk '$1 == "hostname" {print $2; exit}')
[[ -n "$saved" && "$saved" != "$FRAME_ALIAS" && "$saved" =~ $HOST_RE ]] && candidates+=("$saved")
candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
fi
local h
for h in "${candidates[@]}"; do
if reachable "$h"; then
@@ -224,6 +228,7 @@ pair_with_devkit() {
print "==> Looking for the Steam Frame"
if ! HOST=$(pick_host); then
print -u2 "Could not reach the Frame on port 22 or $DEVKIT_PORT."
print -u2 "SteamOS 0.5.6 stopped publishing .local names; use its IP from Quick Settings."
print -u2 "Check: Developer Mode on + user password set; same Wi-Fi; no client isolation."
print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y"
exit 1
+8 -2
View File
@@ -293,7 +293,7 @@ def load_baseline(path):
def main(argv=None):
parser = argparse.ArgumentParser(description='Read-only Frame setup diagnostics. Never pairs, repairs or asks for a password.')
parser.add_argument('--alias', default='frame', help='Existing trusted SSH alias (default: frame)')
parser.add_argument('--lan', default='frame.local', help='LAN fallback host/IP')
parser.add_argument('--lan', help='Optional LAN IP or host name; SteamOS 0.5.6 no longer publishes .local names')
parser.add_argument('--usb', default='10.86.200.233', help='USB fallback IP; use only with the intended headset connected')
parser.add_argument('--plan', action='store_true', help='Print a read-only repair plan from recognized evidence')
parser.add_argument('--save-report', metavar='FILE', help='Create a local JSON baseline (never overwrite an existing file)')
@@ -307,6 +307,8 @@ def main(argv=None):
print('UNKNOWN Baseline: Cannot read a valid doctor report; no comparison performed.')
return 2
for value in (args.alias, args.lan, args.usb):
if value is None:
continue
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,252}', value):
parser.error('Use a plain alias, hostname or IP address.')
print('Frame doctor: read-only; no passwords, pairing, repairs or raw logs. Reports are saved only with --save-report.')
@@ -327,7 +329,11 @@ def main(argv=None):
print('UNKNOWN Baseline: SSH target configuration differs; comparison refused.')
return 2
source = Path(__file__).read_text()
for label, host in (('configured alias (Tailscale if configured)', None), ('LAN', args.lan), ('USB-C', args.usb)):
routes = [('configured alias (Tailscale if configured)', None)]
if args.lan:
routes.append(('LAN', args.lan))
routes.append(('USB-C', args.usb))
for label, host in routes:
result = run(ssh_args(args.alias, host, identity) + ['python3 -B - --probe'], timeout=90, input=source)
if result is not None and result.returncode == 0:
try:
+3 -2
View File
@@ -230,8 +230,9 @@ def cleanup(smoke):
# It logs a failed sync and exits 0, so read its log; a second run lists
# what Steam still has registered, which must not include ours.
try:
first = ssh('python3 ~/devkit-utils/steamos-delete 2>&1', timeout=120)
second = ssh('python3 ~/devkit-utils/steamos-delete 2>&1', timeout=120)
command = frame_titles.devkit_script('delete') + ' 2>&1'
first = ssh(command, timeout=120)
second = ssh(command, timeout=120)
for out in (first, second):
if 'sync of devkit games failed' in out:
raise AssertionError(out.strip().splitlines()[-1])
+7
View File
@@ -292,6 +292,13 @@ class Session(unittest.TestCase):
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n in available else None):
self.assertEqual(remote.session()['selector'], expected)
def test_holo_nested_desktop_is_recognized(self):
with patch.object(remote, 'SDDM_CONFS', ()), \
patch.object(remote, '_session_target', return_value='not-found'), \
patch.object(remote, '_running', side_effect=lambda name, full=False: name == 'holo-nested-desktop'), \
patch.object(remote.shutil, 'which', return_value=None):
self.assertTrue(remote.session()['nested_desktop'])
def test_script_probe_does_not_match_names_in_transmitted_python_source(self):
with patch.object(remote.subprocess, 'call', return_value=1) as call:
self.assertFalse(remote._running('steamos-nested-desktop', full=True))
+1
View File
@@ -185,6 +185,7 @@ class ConfigBlock(unittest.TestCase):
fc.write_config("10.0.0.5", 22, "deck")
text = fc.CONFIG.read_text(encoding="utf-8")
self.assertEqual(fc.configured_user(), "deck") # not "me" from Host other
self.assertEqual(fc.configured_host(), "10.0.0.5")
finally:
fc.SSH_DIR, fc.CONFIG = saved
self.assertEqual(text.count(fc.BEGIN), 1)
+10
View File
@@ -323,6 +323,16 @@ class Pins(Base):
class Registry(Base):
def test_mdns_result_remembers_numeric_ip_only_on_its_network(self):
d = self.reg.add_device("frame-cache", hosts=["frame-cache.local"])
self.reg.record_success(d["id"], "frame-cache.local", "n-home", 2.0, ip="192.168.1.40")
a = fd.Registry(self.reg.path).get(d["id"])["addresses"][0]
self.assertEqual((a["last_ip"], a["last_ip_network"]), ("192.168.1.40", "n-home"))
self.reg.record_success(d["id"], "frame-cache.local", "n-home", 2.0, ip="host;bad")
self.assertEqual(self.reg.get(d["id"])["addresses"][0]["last_ip"], "192.168.1.40")
self.reg.update_address(d["id"], "frame-cache.local", new_host="frame-next.local")
self.assertNotIn("last_ip", self.reg.get(d["id"])["addresses"][0])
def test_replaced_registry_is_read_even_when_timestamp_and_size_match(self):
d = self.reg.add_device('frame', name='First')
before = self.reg.path.stat()
+6 -1
View File
@@ -46,7 +46,8 @@ class FrameDoctor(unittest.TestCase):
self.assertFalse(any('/register' in str(c.args[0]) for c in run.call_args_list))
def test_fallback_preserves_identity_and_disables_mutating_ssh_options(self):
_, text, run = self.main([result(code=255), result(json.dumps({'guard': 'loop', 'checks': []}))])
_, text, run = self.main([result(code=255), result(json.dumps({'guard': 'loop', 'checks': []}))],
argv=['--lan', 'frame.local'])
args = run.call_args_list[2].args[0]
for flag in ('HostKeyAlias=pinned-frame', 'HostName=frame.local', 'BatchMode=yes',
'StrictHostKeyChecking=yes', 'UpdateHostKeys=no', 'PermitLocalCommand=no',
@@ -55,6 +56,10 @@ class FrameDoctor(unittest.TestCase):
self.assertIn('OK Connection: LAN', text)
self.assertIn('Counters are rising', text)
def test_no_implicit_mdns_fallback(self):
_, _, run = self.main([result(code=255), result(code=255)])
self.assertFalse(any('HostName=frame.local' in str(c.args[0]) for c in run.call_args_list))
def test_hostkey_failure_does_not_suggest_deleting_identity(self):
_, text, _ = self.main([result(code=255, err='REMOTE HOST IDENTIFICATION HAS CHANGED')] * 3)
self.assertIn('Do not delete known_hosts', text)
+9
View File
@@ -14,6 +14,15 @@ import frame_titles # noqa: E402
from frame_titles import FrameError # noqa: E402
class DevkitNames(unittest.TestCase):
def test_holo_script_is_preferred_with_vendored_steamos_fallback(self):
command = frame_titles.devkit_script('prepare-upload', '--gameid MyGame')
self.assertLess(command.index('holo-prepare-upload'), command.index('steamos-prepare-upload'))
self.assertIn('[ -f ~/devkit-utils/holo-prepare-upload ]', command)
with self.assertRaises(ValueError):
frame_titles.devkit_script('unknown')
def elf(machine, e_type=3, interp=True, pad=0):
"""A 64-bit little-endian ELF header plus one program header (PT_INTERP or PT_LOAD)."""
ident = b'\x7fELF' + bytes([2, 1, 1]) + b'\0' * 9
+44
View File
@@ -58,6 +58,50 @@ class FailureDetail(unittest.TestCase):
class Probe(unittest.TestCase):
def test_mdns_falls_back_only_to_ip_authenticated_on_this_network(self):
address = {"host": "frame.local", "kind": "mdns", "last_ip": "192.168.1.40",
"last_ip_network": "n-home"}
def result(host, port, **kwargs):
return {"state": "answered", "ip": host, "rtt_ms": 1} if host == "192.168.1.40" else \
{"state": "unresolved", "detail": "Can't find name"}
with mock.patch.object(fl, "probe", side_effect=result) as probe:
self.assertEqual(fl.probe_address(address, 22, "n-home")["ip"], "192.168.1.40")
self.assertEqual([c.args[0] for c in probe.call_args_list], ["frame.local", "192.168.1.40"])
probe.reset_mock()
self.assertEqual(fl.probe_address(address, 22, "n-cafe")["state"], "unresolved")
probe.assert_called_once()
def test_find_uses_cached_ip_and_neighbours_without_trusting_them(self):
device = {"id": "d1", "alias": "frame", "port": 22, "addresses": [{"host": "frame.local", "kind": "mdns",
"last_ip": "192.168.1.40", "last_ip_network": "n-test"}]}
link = mock.Mock()
link.active_device.return_value = device
link.state = {"network": NET}
def answer(host, port, timeout=3):
return {"state": "answered" if host in ("192.168.1.40", "192.168.1.41") else "unresolved",
"detail": "test", "ip": host if host.startswith("192.") else None, "rtt_ms": 1}
with mock.patch.object(fl, "probe", side_effect=answer), \
mock.patch.object(fl.frame_network, "local_scan_candidates", return_value=["192.168.1.40", "192.168.1.41"]), \
mock.patch.object(fl.frame_network, "neighbour_cache", return_value=""), \
mock.patch.object(fl.frame_devices, "pinned_keys", return_value=[("ssh-ed25519", "SHA256:test")]), \
mock.patch.object(fl, "matches_pinned_key", return_value=True), \
mock.patch.object(fl.frame_host, "which", return_value=None), \
mock.patch("frame_connect.discover_devkit", return_value=[]):
found = fl.mdns_find(link)
self.assertEqual(found["scanned"], 1) # cached IP is tried once
self.assertEqual({h["host"] for h in found["hosts"]}, {"192.168.1.40", "192.168.1.41"})
self.assertTrue(found["hosts"][0]["added"] is False)
def test_scan_requires_saved_matching_ssh_key(self):
key = ("ssh-ed25519", fd.fingerprint("a2V5"))
response = subprocess.CompletedProcess([], 0, "192.168.1.41 ssh-ed25519 a2V5\n", "")
with mock.patch.object(fl.frame_host, "which", return_value="ssh-keyscan"), \
mock.patch.object(fl.frame_host, "run_ssh", return_value=response):
self.assertTrue(fl.matches_pinned_key("192.168.1.41", 22, [key]))
self.assertFalse(fl.matches_pinned_key("192.168.1.41", 22, [("ssh-ed25519", "SHA256:other")]))
self.assertFalse(fl.matches_pinned_key("name;bad", 22, [key]))
self.assertFalse(fl.matches_pinned_key("192.168.1.41", 22, []))
def test_answers_refusals_and_unknown_names(self):
with socket.socket() as srv:
srv.bind(("127.0.0.1", 0))
+11
View File
@@ -102,6 +102,17 @@ class Parsers(unittest.TestCase):
self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs")
self.assertIsNone(fn.parse_nmcli("no:Neighbour\n"))
def test_neighbour_scan_stays_on_local_subnet(self):
nearby = ("192.168.50.31 dev wlan0 lladdr 00:11:22:33:44:55 REACHABLE\n"
"192.168.51.31 dev wlan0 lladdr 00:11:22:33:44:66 STALE\n"
"192.168.50.1 dev wlan0 lladdr 00:11:22:33:44:77 STALE\n")
self.assertEqual(fn.parse_neighbours(nearby, "192.168.50.9", "192.168.50.1"), ["192.168.50.31"])
candidates = fn.local_scan_candidates("192.168.50.9", "192.168.50.1", nearby)
self.assertEqual(candidates[0], "192.168.50.31")
self.assertEqual(len(candidates), 252)
self.assertNotIn("192.168.51.31", candidates)
self.assertEqual(fn.local_scan_candidates("100.101.1.4", "100.101.1.1"), [])
def test_windows(self):
self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50"))
self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55")
+2 -1
View File
@@ -428,7 +428,8 @@ def session():
gamescope = (_running('gamescope') or _running('gamescope-wl') or
_running('start-gamescope-session', full=True))
plasma = _running('plasmashell')
nested = _running('steamos-nested-desktop', full=True) # a script: its process name is the shell's
nested = (_running('holo-nested-desktop', full=True) or
_running('steamos-nested-desktop', full=True)) # scripts: process name is the shell's
targets = {unit: _session_target(unit) for unit in SESSION_TARGETS}
game, desktop = (targets[unit] for unit in SESSION_TARGETS)
mode = 'unknown'
+19 -1
View File
@@ -249,7 +249,10 @@ def pick_host(arg):
if arg and not HOST_RE.fullmatch(arg):
say(f" - {arg!r} isn't a host name or IP address")
return None
for cand in [arg] if arg else [f"{FRAME_ALIAS}.local", FRAME_ALIAS]:
candidates = [arg] if arg else list(dict.fromkeys([configured_host(), f"{FRAME_ALIAS}.local", FRAME_ALIAS]))
for cand in candidates:
if not cand:
continue
host, port = split_port(cand)
if reachable(host, port):
return host, port
@@ -374,6 +377,20 @@ def configured_user():
return None
def configured_host():
"""Reuse the last configured route before trying a deprecated .local name."""
if not CONFIG.exists():
return None
inside = False
for line in CONFIG.read_text(encoding="utf-8").splitlines():
if line in (BEGIN, END):
inside = line == BEGIN
elif inside and line.startswith(" HostName "):
host = line[11:].strip()
return host if HOST_RE.fullmatch(host) else None
return None
def pair_with_devkit(host, port, user):
"""Try devkit pairing and confirm key login. -> (user, None) or (user, reason to fall back)."""
say("==> Pairing through the headset's SteamOS devkit service (no password)")
@@ -424,6 +441,7 @@ def main(argv):
found = pick_host(argv[0] if argv else None)
while not found:
say("Could not reach the Frame over SSH.")
say("SteamOS 0.5.6 stopped publishing .local names; use the IP shown in Quick Settings.")
say("Check: Developer Mode on and a user password set; same network; no client isolation.")
try:
typed = input("Type the Frame's IP address (Quick Settings shows it), or press Enter to quit: ").strip()
+11 -1
View File
@@ -599,6 +599,11 @@ class Registry:
for a in d["addresses"]:
a.setdefault("networks", [])
a.setdefault("label", "")
# Old or hand-edited registries must not turn a cached route into an
# arbitrary hostname or an SSH option. Only a numeric address is used.
if not frame_network.valid_ip(a.get("last_ip", "")):
a.pop("last_ip", None)
a.pop("last_ip_network", None)
if d.get("identity") or any(a.get("last_ok") for a in d["addresses"]):
# Files from before the flag existed: it had an identity (or a connection that
# worked, which saved one). Kept from now on, whatever happens to its addresses.
@@ -774,6 +779,8 @@ class Registry:
label = None if label is None else check_text(label, "label")
if moved:
a.update(host=new_host, networks=[], last_ok=None, last_rtt_ms=None) # a new place: learn again
a.pop("last_ip", None)
a.pop("last_ip_network", None)
if kind is not None:
a["kind"] = kind
if label is not None:
@@ -796,7 +803,7 @@ class Registry:
d["addresses"].insert(j, d["addresses"].pop(i))
self.save()
def record_success(self, device_id, host, network_id, rtt_ms):
def record_success(self, device_id, host, network_id, rtt_ms, ip=None):
"""Learn: this address worked on this network."""
with self._changing():
try:
@@ -807,6 +814,9 @@ class Registry:
a["networks"] = (a["networks"] + [network_id])[-16:]
a["last_ok"] = time.time()
a["last_rtt_ms"] = rtt_ms
if ip and frame_network.valid_ip(ip) and network_id:
a["last_ip"] = ip
a["last_ip_network"] = network_id
self._find(device_id)["identity_established"] = True # survives editing the address away
self.save()
+77 -8
View File
@@ -26,6 +26,7 @@ when the page asks.
Python stdlib only. Runs on this computer, never on the Frame.
"""
from bisect import bisect_right
from concurrent.futures import ThreadPoolExecutor, as_completed
import copy
import hashlib
import ipaddress
@@ -169,6 +170,18 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
return last or {"state": "timeout", "detail": "No answer"}
def probe_address(address, port, network_id, update=None):
"""Try a saved mDNS address, then its last authenticated IP on this network."""
res = probe(address["host"], port, update=update)
cached = address.get("last_ip") if address.get("last_ip_network") == network_id else None
if (address["kind"] == "mdns" and cached and frame_network.valid_ip(cached) and
res["state"] in ("unresolved", "timeout", "unreachable")):
fallback = probe(cached, port)
if fallback["state"] == "answered":
return dict(fallback, detail="Last known IP answered (mDNS unavailable)")
return res
def _ip_kind(text):
"""ipv4, ipv6, ipv6 link-local or tailscale for an IP address (zone allowed), else None."""
try:
@@ -1049,7 +1062,8 @@ class Link:
attempt_no = self.state["attempt"]
def run_probe(i, host):
res = probe(host, port, update=lambda **f: self.probe_update(i, attempt_no, **f))
res = probe_address(ranked[i][0], port, net.get("id"),
update=lambda **f: self.probe_update(i, attempt_no, **f))
res.setdefault("ip", None)
res.setdefault("rtt_ms", None)
self.probe_update(i, attempt_no, **{k: res[k] for k in ("state", "detail", "ip", "rtt_ms")})
@@ -1080,7 +1094,7 @@ class Link:
"rtt_ms": results[pick]["rtt_ms"], "why": ranked[pick][1], "network": net.get("id"),
"network_name": net["name"]}
self.publish(via=via)
self.learn(device, a["host"], net, results[pick]["rtt_ms"])
self.learn(device, a["host"], net, results[pick]["rtt_ms"], results[pick]["ip"])
return True
with self.cond:
why_not = failure_detail(self.state["error"])
@@ -1135,10 +1149,10 @@ class Link:
else:
done.wait(min(0.5, max(0.01, deadline - time.monotonic())))
def learn(self, device, host, net, rtt):
def learn(self, device, host, net, rtt, ip=None):
if device.get("transient"):
return
self.reg.record_success(device["id"], host, net.get("id"), rtt)
self.reg.record_success(device["id"], host, net.get("id"), rtt, ip=ip)
self.reg.record_identity(device["id"], [f for _, f in frame_devices.pinned_keys(device["id"])])
# Set Up Connection may have changed the block while this attempt ran: take that
# in (and reconnect) rather than writing this attempt's older settings over it.
@@ -1858,8 +1872,32 @@ def tailscale_find(link, device_id=None):
return {"up": True, "peers": peers, "tailnet": ts.get("tailnet"), "message": None}
def matches_pinned_key(host, port, saved):
"""Only suggest a scanned SSH host when its key matches this headset's pin."""
if not saved or not frame_network.valid_ip(host):
return False
keyscan = frame_host.which("ssh-keyscan")
if not keyscan:
return False
try:
reply = frame_host.run_ssh([keyscan, "-T", "2", "-p", str(port), host],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=4)
except (OSError, subprocess.TimeoutExpired):
return False
pins = set(saved)
for line in reply.stdout.splitlines():
fields = line.split()
if len(fields) >= 3 and (fields[1], frame_devices.fingerprint(fields[2])) in pins:
return True
return False
def mdns_find(link, device_id=None):
"""Headsets on this network: SteamOS devkit services (mDNS) and <alias>.local."""
"""Find nearby SSH endpoints using mDNS, a remembered IP and a bounded /24 scan.
Finding an SSH port does not identify a headset. Adding an address still uses
the normal pinned SSH identity check before any command is run on it.
"""
device = link.reg.get(device_id) if device_id else link.active_device()
known = {a["host"].rstrip(".").lower() for a in device.get("addresses") or []}
try:
@@ -1867,11 +1905,15 @@ def mdns_find(link, device_id=None):
found = frame_connect.discover_devkit()
except (ImportError, SystemExit, OSError):
found = []
names = list(dict.fromkeys([h.rstrip(".") for h in found] + [f"{device['alias']}.local", "frame.local"]))
cached = [a["last_ip"] for a in device.get("addresses", []) if a.get("kind") == "mdns" and a.get("last_ip") and
a.get("last_ip_network") == (link.state.get("network") or {}).get("id")]
names = list(dict.fromkeys([h.rstrip(".") for h in found] + cached +
[f"{device['alias']}.local", "frame.local"]))
rows = [None] * len(names)
port = device.get("port") or 22
def check(i, host):
res = probe(host, 22, timeout=3)
res = probe(host, port, timeout=3)
rows[i] = {"host": host, "state": res["state"], "ip": res.get("ip"), "rtt_ms": res.get("rtt_ms"),
"detail": res["detail"], "advertised": host in [h.rstrip(".") for h in found],
"added": host.lower() in known or (res.get("ip") or "").lower() in known}
@@ -1882,4 +1924,31 @@ def mdns_find(link, device_id=None):
for t in threads:
t.join(8)
hosts = [r for r in rows if r and (r["advertised"] or r["state"] in ("answered", "refused"))]
return {"hosts": hosts, "tool": bool(frame_host.which("dns-sd") or frame_host.which("avahi-browse"))}
network = link.state.get("network") or {}
local = network.get("local_ip") or frame_network.local_ip()
candidates = frame_network.local_scan_candidates(local, network.get("gateway"), frame_network.neighbour_cache())
candidates = [ip for ip in candidates if ip not in names]
saved = frame_devices.pinned_keys(device["id"]) if device.get("id") else []
# A random SSH service on the subnet is not a Frame. With no existing pin,
# use explicit IP entry; do not offer unverified scan results for pairing.
if not saved:
candidates = []
if candidates:
def scan(ip):
res = probe(ip, port, timeout=0.3)
return res if res["state"] == "answered" and matches_pinned_key(ip, port, saved) else None
with ThreadPoolExecutor(max_workers=24) as pool:
futures = {pool.submit(scan, ip): ip for ip in candidates}
for future in as_completed(futures):
ip = futures[future]
try:
res = future.result()
except OSError:
continue
if res:
hosts.append({"host": ip, "state": "answered", "ip": ip,
"rtt_ms": res.get("rtt_ms"), "detail": "Matches this headset's saved SSH identity",
"advertised": False, "added": ip.lower() in known})
return {"hosts": hosts, "tool": bool(frame_host.which("dns-sd") or frame_host.which("avahi-browse")),
"scanned": len(candidates), "scan_requires_pin": not bool(saved)}
+36
View File
@@ -240,6 +240,42 @@ def local_ip(towards="192.0.2.1"):
return None
def parse_neighbours(text, local, gateway=None):
"""Numeric IPv4 neighbours on this host's /24, excluding itself and its router.
Accepts Linux `ip neigh`, macOS `arp -an` and Windows `arp -a`. The active
search uses the same /24; the cache just puts recently seen peers first.
"""
if not valid_ip(local):
return []
subnet = ipaddress.ip_network(local + "/24", strict=False)
found = []
for match in re.finditer(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])", text):
ip = match.group()
if valid_ip(ip) and ipaddress.ip_address(ip) in subnet and ip not in (local, gateway) and ip not in found:
found.append(ip)
return found
def local_scan_candidates(local=None, gateway_ip=None, neighbours=""):
"""Bounded IPv4 search order for an explicit Find action; never scan a WAN."""
local = local or local_ip()
if (not valid_ip(local) or not ipaddress.ip_address(local).is_private or
ipaddress.ip_address(local).is_loopback or is_tailscale(local)):
return []
subnet = ipaddress.ip_network(local + "/24", strict=False)
recent = parse_neighbours(neighbours, local, gateway_ip)
return recent + [str(ip) for ip in subnet.hosts() if str(ip) not in recent and str(ip) not in (local, gateway_ip)]
def neighbour_cache():
if frame_host.MAC:
return run(["arp", "-an"])
if frame_host.WINDOWS:
return run(["arp", "-a"])
return run(["ip", "-4", "neigh", "show"])
def gateway():
"""(gateway IP, interface) of the default route."""
if frame_host.MAC:
+11 -3
View File
@@ -31,6 +31,14 @@ GAMES = 'devkit-game' # ditto; steamos-prepare-upload makes
STAMP = '.frame-control-stamp'
PY = 'python3 ~/' + UTILS + '/'
def devkit_script(name, args=''):
"""Prefer Valve's holo name when a newer devkit ships it; use our vendored copy otherwise."""
if name not in ('prepare-upload', 'delete'):
raise ValueError('unknown devkit script')
return (f'if [ -f ~/{UTILS}/holo-{name} ]; then {PY}holo-{name} {args}; '
f'else {PY}steamos-{name} {args}; fi')
# Valve's reserved sideload names: uploading one of these replaces the Steam client itself.
# devkit-steam is the trampoline file that switches SteamOS to a sideloaded client
# (select_steam.sh); a folder there breaks Valve's devkit tools.
@@ -675,7 +683,7 @@ def _install(plan, step, artwork=None):
ensure_utils()
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
step('Preparing the title folder', 0.05)
ready = _json_out(ssh(f'{PY}steamos-prepare-upload --gameid {gid}', timeout=60), 'steamos-prepare-upload')
ready = _json_out(ssh(devkit_script('prepare-upload', f'--gameid {gid}'), timeout=60), 'steamos-prepare-upload')
directory = str(ready.get('directory') or '')
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
@@ -726,7 +734,7 @@ def _install(plan, step, artwork=None):
if shortcut:
frame_android.shortcut_tool('remove', str(shortcut))
else:
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
ssh(devkit_script('delete', f'--delete-title {gid}'), timeout=120)
except FrameError:
pass
registered = False
@@ -896,7 +904,7 @@ def remove(gid):
shortcut = None
# steamos-delete removes the folder, the shortcut and its Proton prefix (found through the
# shortcut), so it runs first; its json files stay, so clear them too.
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
ssh(devkit_script('delete', f'--delete-title {gid}'), timeout=120)
ssh(f'rm -f {_json_files(gid)}', timeout=30)
# Then tidy what it leaves (artwork, collections); best effort.
if shortcut:
+2 -1
View File
@@ -276,7 +276,8 @@ def panels(limit=None):
# The headset's Linux desktop, by process name (docs/how-the-frame-works.md).
DESKTOP = ("plasmashell", "kwin_wayland", "kwin_wayland_wrapper", "steamos-nested-desktop", "startplasma-wayland")
DESKTOP = ("plasmashell", "kwin_wayland", "kwin_wayland_wrapper", "holo-nested-desktop",
"steamos-nested-desktop", "startplasma-wayland")
def desktop_running():
+3 -1
View File
@@ -7233,6 +7233,7 @@ function renderDetail() {
<span class="sub">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
: d.identity_lost ? "the saved key is missing or can't be read, so nothing is trusted until you check its key."
: "not saved yet; the next connection saves it."}</span></div>
<div class="hint">SteamOS 0.5.6 stopped publishing mDNS names. If a <code>.local</code> name stops working, Frame Control tries its last known IP on the same network. Find on this network checks nearby IPs; you can also enter the headset's IP above or use its SSH alias. A found SSH port is checked against the headset's saved identity before use.</div>
<div class="hint">An address's kind is worked out from it: <code>.local</code> names are mDNS, <code>100.x</code> and
<code>.ts.net</code> are Tailscale, and other IPs are this network. Edit an address to set it yourself.</div>
</details>`;
@@ -7310,8 +7311,9 @@ function renderFound(d) {
rows = f.data.hosts.map(h => `<div class="item"><span class="dot ${h.state === "answered" ? "on" : ""}"></span>
<div class="grow"><div class="t">${esc(h.host)} ${h.advertised ? '<span class="tag">SteamOS devkit</span>' : ""}</div>
<div class="s res-${esc(h.state)}">${esc(h.detail)}${h.ip ? " · " + esc(h.ip) : ""}</div></div>
${add(h.host, "mdns", "mDNS", h.added)}${h.ip && !h.ip.includes(":") && !h.added ? add(h.ip, "lan", "", false) : ""}</div>`);
${add(h.host, h.host.endsWith('.local') ? "mdns" : "lan", h.host.endsWith('.local') ? "mDNS" : "Local network", h.added)}${h.ip && h.ip !== h.host && !h.ip.includes(":") && !h.added ? add(h.ip, "lan", "", false) : ""}</div>`);
if (!f.data.tool) rows.push(`<div class="sub">Install Bonjour (Windows) or avahi (Linux) to search for SteamOS devkit services.</div>`);
rows.push(`<div class="sub">${f.data.scan_requires_pin ? "Enter this headset's IP above to pair it first. The subnet search starts after its SSH identity is saved." : `Checked ${Number(f.data.scanned || 0)} nearby IPs against this headset's saved SSH identity.`} A device may be absent if SSH is off or this computer is on another subnet.</div>`);
}
el.innerHTML = `<h3>${f.where === "tailscale" ? "On your tailnet" : "On this network"}</h3>` +
(rows.length ? `<div class="list">${rows.join("")}</div>` : `<div class="sub">Nothing found.</div>`);