mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 15:01:37 +02:00
Compare commits
1
Commits
main
...
fleet/9mz9vqv2m4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7110c1f4 |
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>`);
|
||||
|
||||
Reference in new issue
Block a user