Compare commits

...
9 Commits
Author SHA1 Message Date
Alex SouthwellandSAPHID 5c987ae8b8 Bump Frame Control to 0.4.20 (#167)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 13:32:38 +00:00
Alex SouthwellandSAPHID 42b0d729cb Document Frame OS 0.5.6 live compatibility findings (#168)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 13:24:32 +00:00
Alex SouthwellandSAPHID ae63fa584c Add read-only Windows Wireless Adapter doctor (#166)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:44:24 +00:00
Alex SouthwellandSAPHID 337ac99b3e Add a read-only Linux Wireless Adapter doctor (#165)
* Add read-only Linux PC Wireless Adapter doctor

* Cross-reference coordinated SteamOS compatibility notes

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:30:08 +00:00
Alex SouthwellandSAPHID aada257325 Handle Frame OS 0.5.6 discovery and SteamOS name changes (#164)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:24:04 +00:00
Alex SouthwellandSAPHID 8bc3d63126 Add local Linux helper for Steam PipeWire desktop sharing (#163)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:18:08 +00:00
Alex SouthwellandSAPHID c690e691a3 Bump Frame Control to 0.4.19 (#162)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:04:30 +00:00
e3fcd3eef8 Use gamescope input for Frame trackpad and keyboard (#66)
* Live view: a Desktop view that stays still, and Control to tap on the Frame

The live view gets a second source and a way to use the Frame from it:

- Desktop: the app panel in use in the headset, streamed from its own window
  (x11grab of gamescope's redirected window), so it doesn't move as the
  wearer looks around. A picker shows any other panel, view only.
- Control: on the Desktop view a tap or click lands exactly where you put it;
  drag is a mouse drag, press and hold right-clicks, two fingers scroll, and
  on a computer the mouse, wheel and keyboard work directly. On the headset
  view the view is a trackpad. A text field and key row type from a phone.

Input goes through gamescope's own EIS socket (the way Steam feeds Remote
Play input) with the libei already on the image: ui/frame_touch.py, over
the same long-lived ssh machinery as the keyboard agent, nothing to
install. It reaches the panel that has focus on either X display, which
the KDE Connect route can't. Verified on the Frame and from the iPhone app
in the Simulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: fixes from review

- Keys held on the Frame are released with buttons when Control stops or
  the view loses focus; keys for the Frame no longer trigger Frame Control's
  own shortcuts.
- Taps only act when the picture on screen is the panel in use; positions,
  presses, keys, text and scrolls name their panel (display and window: ids
  repeat across :0 and :1, told apart by pid), and the Frame drops them if
  focus has moved on. Releases always go.
- While connecting, a tap keeps its position; on an error only releases wait
  and retries back off; trimming a long queue never drops a release.
- Lifting one of two scrolling fingers ends the scroll; a cancelled touch
  isn't a tap; clicks and holds on the bars around the picture do nothing.
- A capture loop from before a Live restart can't stop the new video.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: close the targeting gaps from the second review

- The focused panel's display comes from GAMESCOPE_FOCUS_DISPLAY (gamescope
  packs ":1" into the first value), so a window id repeated across :0 and :1
  can't be mistaken; the pid is only the fallback.
- Presses, keys, text and scrolls read focus afresh on the Frame; only moves
  use a reading up to a second old.
- A gesture remembers the panel it started on and does nothing more if that
  stops being the one in use; a press with no panel to aim at isn't sent.
- Opening a screenshot clears the panel Control would act on; switching to
  another app releases held keys and buttons.
- Trimming keeps a click with its position; the error backoff holds for new
  input too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: fixes from the SWE-2 Max review

- The Frame side tracks keys as well as buttons and lets go of both when the
  session ends.
- A stale tap tells the page, which re-reads the panels at once.
- A paused input device waits instead of ending the session; only a
  disconnect does. An OS error on one event skips it.
- Presses check focus with two property reads and do the full lookup only
  when it changed.
- Writes to an agent's stdin are serialized, so two devices sending at once
  can't tear a line (the keyboard agent too).
- Connecting gives up with a message after 15 s instead of hanging on
  "Connecting…"; text goes in 100-character pieces so releases don't wait
  behind a long paste; a cancelled mouse gesture releases what's held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: stale clears, pauses reconverge, pastes split per request

- The Frame says it has caught up as soon as an aimed event lands after a
  stale one, so the page stops re-reading the panels.
- After a device pause it lets go of everything it holds (releases that
  arrived while paused were dropped), and waits for the device once per
  batch, not once per event.
- The quick focus check no longer freshens the panel geometry's age.
- Each request carries at most about 100 characters of text.
- Turning Control off while it connects doesn't report an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* frame_touch: build the socket path on the Frame, so Windows can import it for tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* frame_touch: any event that goes through clears the stale flag

A trackpad move names no panel, so waiting for an aimed event could leave
the page re-reading panels for the rest of the session; a release still
aimed at the old panel doesn't count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Trackpad and key row go through gamescope's input; KDE Connect only for accents and emoji

The pad reaches whichever panel has focus (Steam's menus too), needs nothing installed, and no longer clamps to 1280x720. Characters that aren't on a US keyboard start KDE Connect the first time they're typed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Touch: a bare release no longer clears the stale-focus notice before the page has seen it

The page leaves the panel off releases, so a release says nothing about focus.
Found in review of the stale-latch fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pad: keep typing in order and acknowledge it once, with accents through one KDE Connect queue

Review found accent-only input repeated forever (sent was false), text split
across two transports out of order, accents stuck until the next keypress,
duplicated on retry, and racy. A batch with an accent now sends its whole
keyboard through KDE Connect; later keys follow while any are queued; the
server owns one ordered, bounded queue drained by a single thread; sent means
the whole batch is taken.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pad: drain the KDE Connect queue without losing, stranding or reordering input

Second review found the queue could be trimmed under an in-flight send (deleting
unsent events), the worker could retire as a request enqueued, and ASCII could
overtake an accent still being typed (or the reverse). The worker now takes its
batch out while sending and puts it back on failure, retires under the lock, and
each way waits out the other's estimated typing time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pad: count gamescope's real typing time, with the settle margin added once

Review found the estimate used 12 ms a character where the agent takes 16 ms
(32 shifted), and added the margin on every request so it piled up.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Document gamescope pad verification and shared-device limits

* Prevent Unicode input startup from deadlocking a headset switch

* Record real focused-panel click and ASCII key delivery

* Retain accepted Unicode input under queue saturation and failure

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 11:54:36 +00:00
Alex SouthwellandSAPHID 0da1fe5077 Bump Frame Control to 0.4.18 (#161)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 10:53:48 +00:00
62 changed files with 4243 additions and 78 deletions

No files matched your search

+19
View File
@@ -41,6 +41,10 @@ jobs:
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
- name: Server tests
run: python -m unittest discover -s tests -v
- name: Linux Wireless Adapter doctor (read-only PC smoke)
run: |
python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json"
python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1'
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
- name: App tests
@@ -70,6 +74,21 @@ jobs:
- name: Server tests
run: python -m unittest discover -s tests -v
# Fast, PC-only Windows query validation, without capture libraries or a headset.
wireless-windows:
runs-on: windows-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Wireless doctor fixtures, UI and live read-only Windows inventory
run: python -m unittest discover -s tests -p 'test_wireless.py' -v
# The iPhone app: builds for the Simulator and runs its unit tests.
ios:
runs-on: macos-latest
+32
View File
@@ -2,6 +2,38 @@
## Unreleased
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
visible; no adapter disable action is offered because an unused instance
cannot be identified reliably. No headset access or settings changes.
[Sources, verification and limits](docs/windows-wireless-doctor.md).
- Add a Linux PC Wireless Adapter doctor in Settings and Devices, with a source
CLI and local JSON report. Select observed hardware without assuming Valve
IDs; inspect USB/interface presence, driver/module binding, udev/permissions,
rfkill, channel/band and network manager evidence. Missing evidence stays
unknown; all checks are read-only and never contact the Frame.
[Usage and verification limits](docs/linux-wireless-adapter-doctor.md).
- Add Linux Tools → Steam desktop sharing: request `steam -pipewire` only after
checking Steam is not running, or open KDE Application Permissions with
instructions to revoke a verified Steam screen-sharing session manually. No
automatic permission reset, Steam restart, or headset access. Frame Control
Linux capture remains disabled; live portal behavior is unverified.
[Usage and limits](docs/steam-pipewire.md).
- Use gamescope's input for the keyboard and trackpad, following the focused
headset panel without installing an input service. Start KDE Connect only for
accents and emoji, keeping queued text ordered and cancelling it on headset
switches. Retain intended-headset checks before starting either control and
prevent Unicode helper startup from deadlocking with a headset switch.
- Keep accepted keyboard text when the Unicode queue is full: reject new batches
before sending their clicks or keys, and count in-flight text toward the limit.
Retain unsent text after helper failures/timeouts, report the error and resume
it with Try again. Clean up failed workers without touching a new headset's queue.
[Current verification and device limits](docs/evidence/pad-gamescope-2026-10-24.md).
- Keep media playback running when SteamVR refuses the optional theatre dark
surround, including a retry after standby. Log the refusal and play without
the surround. Overlay teardown attempts every owned handle and always calls
+8 -3
View File
@@ -44,6 +44,9 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
**🔋 Battery and status**<br>
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
For PC-side USB, driver, radio and network manager evidence, use the
[Linux Wireless Adapter doctor](docs/linux-wireless-adapter-doctor.md).
For repeatable read-only battery, power-rail, temperature and fan measurements,
use the [power and heat sampler](docs/power-and-heat.md).
@@ -73,10 +76,10 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
<td valign="top">
**📸 Screenshots and recordings**<br>
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record SteamVR's mirrored headset view, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder. [Recording details and passthrough limits](docs/recording.md#does-record-include-passthrough).
**⌨️ Type on the headset**<br>
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
**⌨️ Keyboard and trackpad**<br>
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one. Copy text to the headset's clipboard and back again. Press T to open it.
</td>
</tr>
@@ -254,12 +257,14 @@ 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 |
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [Steam desktop sharing (Linux)](docs/steam-pipewire.md) | External Steam helper: launch with `-pipewire` after exiting Steam, or open KDE permissions to re-pick a screen manually; no automatic permission reset |
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.17",
"version": "0.4.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.17",
"version": "0.4.20",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.17",
"version": "0.4.20",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+178
View File
@@ -0,0 +1,178 @@
# Keyboard and trackpad through gamescope, 2026-10-24
PR: [#66](https://github.com/saphid/frame-control/pull/66), `pad-gamescope`.
Parent/main: v0.4.18 commit `0da1fe5`. Merge commit: `1737366`, with parents
`cba23fb` (the existing PR) and `0da1fe5` (main). No history was rewritten.
## Merge and implementation
Both `/api/input` (keyboard/trackpad) and `/api/touch` (live-view Control)
start and poll the gamescope helper. KDE Connect starts only when Unicode
typing needs it. The existing ordered, bounded queue and switch cancellation
remain in place; cross-service typing order is an estimate, not an application
acknowledgement.
The merge keeps main's intended-device validation and work reservation before
a GET starts an input helper, immutable SSH routes, launch generations and
serialized writes. Switching headsets also stops the power report and resets
both input agents and queued typing before publishing the new route. The page
keeps main's Tools sections, clipboard controls and single instances of the
keyboard/trackpad and VR tools, with the PR's gamescope help text.
The start/status regression tests now exercise the gamescope helper for both
endpoints. An explicit `/api/input` test checks that start and poll use that
helper without starting KDE Connect. Existing stale/missing-headset and
route-reservation tests remain active.
## Real Frame: focused click and ASCII keys received
Host: lxso2, `ssh frame`. SteamOS **0.5.6**, BUILD_ID **20261015.6141207**,
SteamVR **2.18.2**. At **11:05 UTC**, Game Mode was active, full Plasma was
inactive, Steam and SteamVR were running, battery was **99%**, and gamescope's
focusable/focused window lists were empty. The shared test lock was absent.
Existing X11 tools and libX11 were available; Tk could not load its library.
At **11:09:23 UTC**, an initial read-only preflight saw no focused window
but activity level **0** (idle), rather than the required **3** (standby), and
aborted before lock acquisition or input. At **11:09:57 UTC**, another task
held the shared lock with a focused panel; that lock and panel were left alone.
At **11:20:22 UTC**, read-only checks found the lock absent, no focused or
focusable windows, activity **3** and battery **98%**. The bounded probe ran at
**11:20:37 UTC** against implementation commit `5641d3c`:
1. Rechecked empty focus and standby, acquired `/tmp/frame-test.lock` with
atomic `mkdir`, wrote a unique owner marker and rechecked both conditions.
2. Created only a disposable 640×400 X11 event-recorder window on `:0`, tagged
with test app ID `2000999066`. It had a 45-second alarm and closed on stdin
EOF. No existing application, full desktop or dashboard was started.
3. Waited for gamescope to report that exact window (`62914561`) focused.
Started the production server's `TouchAgent` over SSH; it reported ready.
4. Called production `server.remote_touch` for positioning, then
`server.remote_input` with a left click, `Frame66` and the Enter key button.
Both responses reported `sent: true`. More importantly, the **window itself
recorded ButtonPress/Release (button 1), the ASCII key events in order and
Return**. `XLookupString` decoded exactly `Frame66\r`. The events were
delivered through gamescope's EIS socket, not fabricated with XSendEvent.
5. Closed the helper by EOF and waited for exit, closed the recorder, checked
empty focus and standby, then removed only the owner-matching test lock.
[State and received events](pad-gamescope-2026-10-24/readback.json) and the
[probe](pad-gamescope-2026-10-24/probe.py)/[X11 recorder](pad-gamescope-2026-10-24/recorder.py)
are retained. The archived scripts use checkout-relative source paths; the
recorded run used the same harness from `/tmp`. Motion records omit an unused
field that only has meaning for key/button events.
Delayed readback at **11:21:46 UTC** confirmed no test lock or recorder process,
empty focused/focusable lists, Game Mode active, full Plasma inactive, activity
3 and battery 98%. The original Steam and vrserver PIDs were unchanged. No
install, security/global setting change, dashboard/session switch, reset or
reboot was performed. No headset helper or owned lock remained.
This establishes **focused-panel click and ASCII key delivery on `:0`**.
It does not establish wearer-visible presentation, Unicode delivery or GUI
interaction. The absolute-position setup reached the recorder's edge
(639,399), rather than its centre; pixel-accurate absolute pointing is **not**
proved by this small-window probe. The trackpad click and typed keys still
arrived in the owned focused window. The original PR's earlier acceptance-only
observations remain historical; this observation adds actual window events.
## Offline checks and remaining limits
- `python3 -m unittest discover -s tests -p test_input.py -v`: **58 passed**.
- `python3 -m unittest discover -s tests -p test_touch.py -v`: **43 passed**.
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
**34 passed**.
- `python3 -m unittest discover -s tests -v`: **1,403 ran, OK, 15 skipped**
(Linux x86_64, Python 3.12; 150.825 seconds).
- Full fake-Frame e2e in task-specific Docker project `frame-control-pr66`:
**47 passed** after the lock fix (192.270 seconds). Both task containers,
their network and all three volumes were removed afterward. The run emitted
an unclosed test-socket ResourceWarning; the suite returned OK.
- Node page suites: `ctrl_gestures.mjs`, `pad_queue.mjs`, `desk_panels.mjs`
passed, using the production page functions.
- `node --test app/test/*.test.js`: **3 test files passed**.
- `node --test site/test/*.test.mjs`: **2 test files passed**.
- Python compilation, desktop/site JavaScript syntax and `git diff --check`
passed. Fake-Frame e2e and exact-head CI results are recorded in the PR after completion.
The first full regression run executed 1,401 tests and found two tests still
expecting `/api/input` to start the old KDE Connect helper. They were updated
to the intended gamescope route. The final full run includes the lazy-KDE
regression above and the lock-order regression below. The initial restricted-sandbox input run could not open
loopback sockets; the focused rerun with managed network access passed.
Review of the merged code then found a lock inversion: the Unicode worker
held the pad-send lock while `InputAgent.start()` acquired the route lock,
whereas a headset switch held the route lock before resetting the pad. A
bounded subprocess regression reproduced the deadlock with the real
`InputAgent.send/start` methods and an offline launch stub. Unicode delivery
and resets now take the route lock before the pad lock; the route lock is
reentrant so an agent can capture its route inside that reservation. The new
regression passed after the fix, as did all **58 focused input tests** and
the **1,403-test full suite** (15 skipped). The local fake-Frame suite also
passed **47 tests after this fix**. Exact-head CI results are recorded in the PR.
Remaining device work requires an idle, available Frame and the shared lock:
verify Unicode in a real text field, ordering under real typing load and
precise absolute pointing for small panels. Worn-headset presentation,
packaged Electron pointer lock and Windows GUI execution remain separate
checks. Automated Windows/macOS CI does not establish those interactions.
## Review round 2: queue saturation and failure recovery
The coordinator identified silent eviction in both `_queue_for_kde()` and the
worker's retry path. Both sliced the oldest accepted events off at 200 entries,
even though the page had already received `sent: true` and discarded its copy.
Admission now serializes concurrent requests, checks capacity before any pointer
or keyboard send, and returns `sent: false, state: waiting` if the whole batch
will not fit. Text stays in the bounded queue while in flight, and is removed
only after the helper accepts it. Rejected mixed batches send no clicks; retrying
them cannot duplicate clicks already sent by this server. The page polls until
it can retry. Pointer input stays usable while accepted text is pending.
The old worker also left its worker marker and one-hour timing sentinel behind
if `send()` raised. Failure/timeout handling now retains unsent text, clears the
worker/timing state and exposes an error through input status. The page polls
accepted text without requiring another keypress. **Try again** explicitly
restarts the retained queue. Thread-start failures follow the same retention
rule. A stale worker's exception cannot change the new route's state.
The proposed send-to-enqueue headset-switch race was not reachable through the
normal HTTP switch path: `Handler.do_POST()` reserves `working(meant)` across
both operations; `devices_post()` shares `_work_lock` and refuses a moving
operation while work is active. Connector configuration/reconnect paths defer
changed headsets while work runs. A production-handler regression attempts a
switch at both send and enqueue and passes even on the previous implementation.
No separate connector change was needed. Atomic queue admission now uses the
existing route-before-pad lock order as well, because starting either input
agent can capture its route synchronously.
Three deterministic saturation regressions fail on `215db7b` and pass with the
fix: capacity during the typing delay, capacity during an unsuccessful-send
retry, and two concurrent producers competing for the last slots. They check
exact accepted event sequences, reject a mixed click/text batch before its
pointer effects, and verify delivery exactly once after retry. Additional tests
cover explicit recovery after timeout, helper errors, send exceptions and
thread-start failure, stale-worker isolation, HTTP status/start behavior, and
the production page's polling/error behavior.
No new active headset test was performed: this change affects host queue
admission/recovery and status handling, not Frame event translation or injection.
The earlier real click/ASCII evidence remains scoped to its recorded commit.
Real Unicode, packaged Electron pointer lock, Windows GUI, wearer-visible
presentation and precise small-window absolute positioning remain unverified.
`sent: true` means accepted by this server, not application delivery. The queue
is in memory; switching headsets cancels it and server exit loses it. A transport
failure after a partial write can still make delivery ambiguous; this is not an
end-to-end exactly-once protocol.
Round 2 local verification: **141 focused input/touch/service tests passed**
(62 input, 43 touch, 36 service; 20.061 seconds), and **1,409 server tests ran, OK, 15 skipped**
(Linux x86_64, Python 3.12, 153.198 seconds). All three production-page Node
suites passed; app tests passed (3 files), site tests passed (2 files), Python
compilation and `git diff --check` passed. The existing skipped tests require
zsh, macOS/Swift or built native PC libraries. Current-head CI is tracked in
PR #66; the previous 47-test local fake-Frame run above is historical.
@@ -0,0 +1,129 @@
"""Active real-Frame evidence probe: aborts unless idle, then owns the shared lock.
Run manually on an authorised host: python3 docs/evidence/pad-gamescope-2026-10-24/probe.py
The recorded run used /tmp paths; archived paths are relative to this checkout.
"""
import base64
import datetime
import json
import os
from pathlib import Path
import queue
import shlex
import subprocess
import sys
import tempfile
import threading
import time
import uuid
ROOT = Path(__file__).resolve().parents[3]
data = tempfile.TemporaryDirectory(prefix='pr66-live-')
os.environ.update(FRAME_CONTROL_DATA_DIR=data.name, FRAME_CONTROL_TELEMETRY='0', FRAME_PRIVATE_SSH='1')
sys.path.insert(0, str(ROOT / 'ui'))
import server
SSH = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame']
owner = 'pr66-' + uuid.uuid4().hex
result = {'time': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'owner': owner,
'base': '0da1fe5', 'route': 'production server.remote_input -> TouchAgent SSH -> frame_touch.py -> gamescope EIS',
'events': []}
recorder = None
locked = False
rows = queue.Queue()
def ssh(cmd):
return subprocess.run([*SSH, cmd], capture_output=True, text=True, timeout=15, check=True).stdout
def focus():
return json.loads(server.ssh('python3 - focus', stdin=(ROOT/'ui/frame_touch.py').read_text(), timeout=15))
def activity():
return json.loads(ssh('/opt/steamvr/bin/linuxarm64/vrcmd --stats'))[0]['activity_level']
def read_rows():
for raw in recorder.stdout:
rows.put(json.loads(raw))
def collect(seconds):
end = time.monotonic() + seconds
while time.monotonic() < end:
try:
result['events'].append(rows.get(timeout=max(0.01, end-time.monotonic())))
except queue.Empty:
break
try:
result['initial_focus'] = focus()
result['initial_activity'] = activity()
assert not result['initial_focus'].get('window'), 'existing focused panel; do not interrupt'
assert result['initial_activity'] == 3, 'headset is not in standby'
ssh('mkdir /tmp/frame-test.lock && printf %s ' + shlex.quote(owner) + ' > /tmp/frame-test.lock/owner')
locked = True
result['lock_acquired'] = True
assert not focus().get('window') and activity() == 3, 'session changed during lock acquisition'
code = base64.b64encode(Path(__file__).with_name('recorder.py').read_bytes()).decode()
command = 'python3 -u -c ' + shlex.quote("import base64;exec(compile(base64.b64decode('" + code + "'),'pr66-recorder','exec'))")
recorder = subprocess.Popen([*SSH, command], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
threading.Thread(target=read_rows, daemon=True).start()
initial = rows.get(timeout=8)
result['recorder'] = initial
assert initial.get('ready'), initial
window = initial['window']
for _ in range(20):
current = focus()
if current.get('window') == window:
break
time.sleep(0.2)
result['focused_panel'] = current
assert current.get('window') == window and current.get('display') == ':0', 'owned panel did not obtain focus; no input sent'
server.remote_input({})
for _ in range(50):
if server._touch.status.get('state') in ('ready', 'error'):
break
time.sleep(0.1)
result['agent_status'] = dict(server._touch.status)
assert result['agent_status'].get('state') == 'ready', result['agent_status']
target = {'window': window, 'display': ':0'}
assert focus().get('window') == window and activity() == 3
result['position_response'] = server.remote_touch({'events': [dict(target, fx=0.5, fy=0.5)]})
time.sleep(0.2)
assert focus().get('window') == window and activity() == 3
result['pad_response'] = server.remote_input({'events': [{'singleclick': True}, {'key': 'Frame66'}, {'specialKey': 12}]})
collect(2)
received = result['events']
result['received_text'] = ''.join(e.get('text', '') for e in received if e.get('type') == 2)
result['click_received'] = any(e.get('type') == 4 and e.get('code') == 1 and e.get('window') == window for e in received)
result['release_received'] = any(e.get('type') == 5 and e.get('code') == 1 and e.get('window') == window for e in received)
result['passed'] = result['click_received'] and result['release_received'] and result['received_text'] == 'Frame66\r'
assert result['passed'], 'event recorder did not observe expected click and keys'
except Exception as e:
result['error'] = str(e)
finally:
proc = server._touch.proc
server.reset_pad_route()
if proc:
try:
proc.wait(timeout=8)
result['input_helper_exited'] = True
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=5)
result['input_helper_terminated'] = True
if recorder:
recorder.stdin.close()
recorder.wait(timeout=8)
collect(0.2)
result['recorder_exit'] = recorder.returncode
result['recorder_stderr'] = recorder.stderr.read()[-1000:]
if locked:
result['final_focus'] = focus()
result['final_activity'] = activity()
ssh('test "$(cat /tmp/frame-test.lock/owner)" = ' + shlex.quote(owner)
+ ' && rm /tmp/frame-test.lock/owner && rmdir /tmp/frame-test.lock')
result['owned_lock_released'] = True
Path('/tmp/pr66-live.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))
data.cleanup()
sys.exit(0 if result.get('passed') else 1)
@@ -0,0 +1,280 @@
{
"time": "2026-10-24T11:20:37.392607+00:00",
"owner": "pr66-7c2a8e8851f449bcb0bb6a040be94d99",
"base": "0da1fe5",
"route": "production server.remote_input -> TouchAgent SSH -> frame_touch.py -> gamescope EIS",
"events": [
{
"type": 6,
"window": 62914561,
"x": 320,
"y": 200,
"synthetic": false
},
{
"type": 6,
"window": 62914561,
"x": 639,
"y": 399,
"synthetic": false
},
{
"type": 4,
"window": 62914561,
"x": 639,
"y": 399,
"code": 1,
"synthetic": false
},
{
"type": 5,
"window": 62914561,
"x": 639,
"y": 399,
"code": 1,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 50,
"synthetic": false,
"text": "",
"keysym": 65505
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 41,
"synthetic": false,
"text": "F",
"keysym": 70
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 41,
"synthetic": false
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 50,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 27,
"synthetic": false,
"text": "r",
"keysym": 114
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 27,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 38,
"synthetic": false,
"text": "a",
"keysym": 97
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 38,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 58,
"synthetic": false,
"text": "m",
"keysym": 109
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 58,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 26,
"synthetic": false,
"text": "e",
"keysym": 101
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 26,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 15,
"synthetic": false,
"text": "6",
"keysym": 54
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 15,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 15,
"synthetic": false,
"text": "6",
"keysym": 54
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 15,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 36,
"synthetic": false,
"text": "\r",
"keysym": 65293
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 36,
"synthetic": false
},
{
"cleaned": true
}
],
"initial_focus": {
"window": null
},
"initial_activity": 3,
"lock_acquired": true,
"recorder": {
"ready": true,
"window": 62914561,
"pid": 317210,
"display": ":0",
"appid": 2000999066
},
"focused_panel": {
"window": 62914561,
"display": ":0",
"name": "Frame Control PR66 disposable input probe",
"x": 0,
"y": 0,
"width": 640,
"height": 400,
"root": [
1920,
1080
],
"app": 2000999066
},
"agent_status": {
"state": "ready",
"focus": 62914561
},
"position_response": {
"state": "ready",
"focus": 62914561,
"sent": true
},
"pad_response": {
"state": "ready",
"focus": 62914561,
"sent": true
},
"received_text": "Frame66\r",
"click_received": true,
"release_received": true,
"passed": true,
"input_helper_exited": true,
"recorder_exit": 0,
"recorder_stderr": "",
"final_focus": {
"window": null
},
"final_activity": 3,
"owned_lock_released": true,
"implementation_commit": "5641d3c316dfe225321f7233840cc83ff8c3f527",
"device": {
"steamos": "0.5.6",
"build_id": "20261015.6141207",
"steamvr": "2.18.2",
"battery_before": 98,
"battery_after": 98
},
"delayed_cleanup": {
"time": "2026-10-24T11:21:46Z",
"shared_lock_absent": true,
"owned_recorder_absent": true,
"game_mode": "active",
"full_plasma": "inactive",
"steam_pid": 297826,
"vrserver_pid": 297506,
"focusable_windows": [],
"focused_windows": [],
"activity_level": 3
},
"limits": [
"No wearer or packaged Electron/GUI interaction",
"ASCII only; Unicode and cross-service ordering not tested on device",
"The positioning setup reached the recorder edge (639,399); pixel-accurate absolute targeting is not established by this probe"
]
}
@@ -0,0 +1,82 @@
"""Disposable X11 event recorder used for the PR #66 focused-input observation."""
import ctypes as C
import json
import os
import select
import signal
import time
X = C.CDLL('libX11.so.6')
P, U = C.c_void_p, C.c_ulong
for name, restype, args in [
('XOpenDisplay', P, [C.c_char_p]), ('XDefaultRootWindow', U, [P]),
('XCreateSimpleWindow', U, [P, U, C.c_int, C.c_int, C.c_uint, C.c_uint, C.c_uint, U, U]),
('XInternAtom', U, [P, C.c_char_p, C.c_int]),
('XChangeProperty', C.c_int, [P, U, U, U, C.c_int, C.c_int, P, C.c_int]),
('XStoreName', C.c_int, [P, U, C.c_char_p]),
('XSelectInput', C.c_int, [P, U, C.c_long]), ('XMapWindow', C.c_int, [P, U]),
('XFlush', C.c_int, [P]), ('XPending', C.c_int, [P]),
('XConnectionNumber', C.c_int, [P]), ('XNextEvent', C.c_int, [P, P]),
('XLookupString', C.c_int, [P, P, C.c_int, P, P]),
('XDestroyWindow', C.c_int, [P, U]), ('XCloseDisplay', C.c_int, [P]),
]:
fn = getattr(X, name)
fn.restype, fn.argtypes = restype, args
class InputEvent(C.Structure):
_fields_ = [('type', C.c_int), ('serial', U), ('send_event', C.c_int),
('display', P), ('window', U), ('root', U), ('subwindow', U),
('time', U), ('x', C.c_int), ('y', C.c_int), ('x_root', C.c_int),
('y_root', C.c_int), ('state', C.c_uint), ('code', C.c_uint),
('same_screen', C.c_int)]
class Event(C.Union):
_fields_ = [('type', C.c_int), ('input', InputEvent), ('pad', C.c_long * 24)]
def say(**row):
print(json.dumps(row), flush=True)
def end(*_):
raise TimeoutError('probe ended')
signal.signal(signal.SIGTERM, end)
signal.signal(signal.SIGALRM, end)
signal.alarm(45)
d = X.XOpenDisplay(b':0')
assert d, 'X display unavailable'
w = 0
try:
w = X.XCreateSimpleWindow(d, X.XDefaultRootWindow(d), 0, 0, 640, 400, 0, 0, 0x224466)
for name, value in [(b'STEAM_GAME', 2000999066), (b'_NET_WM_PID', os.getpid())]:
val = U(value)
X.XChangeProperty(d, w, X.XInternAtom(d, name, 0), 6, 32, 0, C.byref(val), 1)
X.XStoreName(d, w, b'Frame Control PR66 disposable input probe')
X.XSelectInput(d, w, 1 | 2 | 4 | 8 | 64 | 32768 | 2097152)
X.XMapWindow(d, w)
X.XFlush(d)
say(ready=True, window=w, pid=os.getpid(), display=':0', appid=2000999066)
while True:
while X.XPending(d):
event = Event()
X.XNextEvent(d, C.byref(event))
if event.type in (2, 3, 4, 5, 6):
e = event.input
row = dict(type=event.type, window=e.window, x=e.x, y=e.y,
synthetic=bool(e.send_event))
if event.type != 6:
row['code'] = e.code
if event.type == 2:
buf, keysym = C.create_string_buffer(32), U()
n = X.XLookupString(C.byref(e), buf, 32, C.byref(keysym), None)
row.update(text=buf.raw[:n].decode('utf-8', 'replace'), keysym=keysym.value)
say(**row)
ready, _, _ = select.select([0, X.XConnectionNumber(d)], [], [], 0.2)
if 0 in ready and not os.read(0, 1024):
break
except TimeoutError:
pass
finally:
if w:
X.XDestroyWindow(d, w)
X.XCloseDisplay(d)
say(cleaned=True)
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

@@ -0,0 +1,22 @@
# SteamOS 0.5.6 compatibility check, 25 October 2026
The real Frame was accessed **after** the 00:00 AEDT hold, under a separately
acquired `/tmp/frame-test.lock`. The coordinator had finished its v0.4.19 smoke
test and released its lock first. This check took place approximately 00:04–
00:08 AEDT (13:04–13:08 UTC). A device clock reading in the first snapshot was
**00:04:58 AEDT**. The one temporary capture was removed and our lock was
released. Steam and SteamVR were left running.
| Check | Observation |
|---|---|
| OS | `VERSION_ID=0.5.6`, `VARIANT_ID=vr`, `BUILD_ID=20261022.6153909` |
| Update channel | `atomupd-manager tracked-branch` returned `beta`; tracked variant `vr`; update status `idle`. `atomupd-manager status` and `check` returned D-Bus `AccessDenied` to the normal user. No root, update, or channel change was attempted. |
| Discovery | `frame.local` and a sample `frame-0000.local` name were unresolved from the Linux host. Avahi was active on the Frame, but `/etc/avahi/avahi-daemon.conf` has `publish-addresses=no`. The configured `ssh frame` alias worked. The host reached the Frame over Tailscale, so a local-subnet scan and same-LAN cached-IP reconnection were not exercised. |
| Script fallback | `steamos-session-select` was present and `holo-session-select` absent. The installed devkit utilities contained `steamos-prepare-upload` and `steamos-delete`; their `holo-*` replacements were absent. No session switch, install, or delete command was run. |
| Battery and memory | Frame Control's `ui/frame_status.py` returned a system battery at 98%, Charging, with its source under `/sys/class/power_supply/max1720x_bat_7-36`; memory was 16,422,952,960 bytes total and 11,256,848,384 bytes available. The battery was 97% at the initial raw sysfs check and 98% at the final check while charging. |
| Recording source | The user `steamvr-v4l2cam.service` and `/dev/video99` were present. A single frame from that device was black while the unworn headset was in standby. Frame Control's MP4 path reads that same SteamVR `system.HeadsetView` mirror and never opens a passthrough camera. Active passthrough inclusion in the mirror remains unverified. The sample image was not retained or published. |
| Dominant eye | Neither standard `steamvr.vrsettings` path checked had a readable dominant-eye setting. No source or effect on the mirror was verified; Frame Control does not attach dominant-eye metadata. |
This was a bounded compatibility check, not a rerun of the coordinator's
13-step smoke test. The coordinator reported that smoke passed on this same
build, with battery 95% to 96% while charging and cleanup complete.
@@ -0,0 +1,74 @@
# Windows Wireless Adapter doctor verification, 24 October 2026
PC-only implementation and verification. No Frame was contacted, before or
after 13:00 UTC. No adapters or security settings were changed. This evidence
does not establish a physical Steam wireless adapter fix or RF interference.
## Windows VM
An existing local Windows 11 VM (build 26200), Python 3.12.14 from the installed
Frame Control bundle, ran the isolated suite from a task-specific Downloads
folder:
```powershell
& 'C:\Users\frame\AppData\Local\Programs\Frame Control\resources\python\python.exe' -m unittest discover -s tests -p test_wireless.py -v
```
Result: **15 tests, OK, one skipped** (Node absent). This includes the real
read-only PowerShell inventory and authenticated HTTP handler tests. The app's
connector was never started. Existing test SSH credentials were used only for
the Windows VM; no credentials were copied into the repository.
- [Exact test output](windows-wireless-tests-2026-10-24.txt).
- [Sanitized live inventory](windows-wireless-summary-2026-10-24.json): two
virtual-machine network devices, with PnP/presence/adapter/route sources
available. WLAN unavailable; channels/bands remained null. All PnP IDs,
device names and interface names were removed from this artifact.
- The guest clock was approximately seven hours ahead of the host. `observedAt`
is retained as returned by Windows, not used to establish test timing.
The first probe used a list of requested default prefixes. A missing IPv6
route caused PowerShell to throw away available IPv4 data. The final query
filters `Get-NetRoute` results instead, and the rerun above confirms routes
are available. No network configuration was changed to make the query work.
## Browser UI and local tests
Production `ui/index.html` ran against a loopback-only synthetic API fixture,
with no connector and no headset transport. Opening/revisiting the tool caused
no wireless query; an explicit click rendered two distinct Wi-Fi Direct PnP
instances and a 2.4 GHz PC interface while the headset state was offline.
Unknown virtual-adapter channels, non-present/disabled status, and unconfirmed
interference remained visible. Checked at 1280 and 640 CSS pixels without
horizontal overflow.
![Production browser UI with explicitly labelled synthetic Windows readings](windows-wireless-synthetic-2026-10-24.png)
The screenshot uses synthetic readings, not the VM's two Ethernet interfaces.
The yellow synthetic-data label was added by the browser fixture for this
capture; it is not product UI.
Local commands:
```sh
python3 -m unittest discover -s tests -p 'test_wireless.py' -v
node tests/wireless.cjs
python3 -m unittest discover -s tests -v
python3 -m py_compile ui/*.py
node --check /tmp/wireless-index-scripts.js
```
The targeted suite passed 15 tests with the live Windows-only test skipped;
the direct Node run passed all four UI tests. The complete server suite passed
1,423 tests with 16 skips before the additional missing-metadata test; a final
complete run and Windows CI results are recorded in the PR. The syntax check
used inline scripts extracted from the production HTML.
## What remains unverified
No physical Steam dongle, Razer headset, live Wi-Fi Direct session, channel
reading from real wireless hardware, dropout reproduction or recovery action.
The English netsh parser is fixture-tested; localised or denied readings remain
unknown. Windows CI validates query execution on hosted virtual hardware.
There is no adapter disable action because the available evidence cannot
reliably identify a safe extra instance.
@@ -0,0 +1,40 @@
{
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.12.14",
"state": "partial",
"sources": {
"pnp": "ok",
"present": "ok",
"adapters": "ok",
"routes": "ok",
"wlan": "unavailable"
},
"observedAt": "2026-10-24T19:15:45.946191+00:00",
"deviceCount": 2,
"devicesWithoutIdentifiers": [
{
"present": true,
"pnpStatus": "OK",
"problemCode": 0,
"operational": 6,
"media": 0,
"defaultRoute": false,
"wifi": false,
"hardware": false,
"channel": null,
"band": null
},
{
"present": true,
"pnpStatus": "OK",
"problemCode": 0,
"operational": 1,
"media": 1,
"defaultRoute": true,
"wifi": false,
"hardware": true,
"channel": null,
"band": null
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

@@ -0,0 +1,24 @@
test_ambiguous_adapter_join_never_selects_the_first_match (test_wireless.Inventory.test_ambiguous_adapter_join_never_selects_the_first_match) ... ok
test_channels_require_exact_guid_and_band_is_never_guessed (test_wireless.Inventory.test_channels_require_exact_guid_and_band_is_never_guessed) ... ok
test_counts_unique_pnp_ids_not_rows_or_suffix_order (test_wireless.Inventory.test_counts_unique_pnp_ids_not_rows_or_suffix_order) ... ok
test_distinct_instances_presence_disabled_and_activity_are_separate (test_wireless.Inventory.test_distinct_instances_presence_disabled_and_activity_are_separate) ... ok
test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct (test_wireless.Inventory.test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct) ... ok
test_missing_interface_metadata_and_invalid_guids_stay_unknown (test_wireless.Inventory.test_missing_interface_metadata_and_invalid_guids_stay_unknown) ... ok
test_missing_sources_are_unknown_and_adapter_fallback_is_retained (test_wireless.Inventory.test_missing_sources_are_unknown_and_adapter_fallback_is_retained) ... ok
test_raw_ssid_mac_and_errors_are_not_returned (test_wireless.Inventory.test_raw_ssid_mac_and_errors_are_not_returned) ... ok
test_concurrent_requests_do_not_spawn_more_probes (test_wireless.Probe.test_concurrent_requests_do_not_spawn_more_probes) ... ok
test_failure_timeout_and_invalid_output_are_explicit_and_release_lock (test_wireless.Probe.test_failure_timeout_and_invalid_output_are_explicit_and_release_lock) ... ok
test_http_is_authenticated_pc_only_and_never_calls_ssh (test_wireless.Probe.test_http_is_authenticated_pc_only_and_never_calls_ssh) ... GET "GET /api/host/wireless HTTP/1.1" 403 -
GET "GET /api/host/wireless HTTP/1.1" 400 -
GET "GET /api/host/wireless HTTP/1.1" 200 -
ok
test_live_windows_inventory (test_wireless.Probe.test_live_windows_inventory) ... ok
test_non_windows_starts_nothing (test_wireless.Probe.test_non_windows_starts_nothing) ... ok
test_static_noninteractive_local_command_and_timeout (test_wireless.Probe.test_static_noninteractive_local_command_and_timeout) ... ok
test_ui_behavior (test_wireless.Probe.test_ui_behavior) ... skipped 'Node required'
----------------------------------------------------------------------
Ran 15 tests in 8.139s
OK (skipped=1)
Windows wireless smoke: partial {'pnp': 'ok', 'present': 'ok', 'adapters': 'ok', 'routes': 'ok', 'wlan': 'unavailable'} devices= 2
+5
View File
@@ -58,6 +58,11 @@ counts them while they run.
restore chosen parts after a factory reset, and **Return to Game Mode** when
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
- **Wireless Adapter doctor** (Windows, Tools → This PC): explicitly read local
PnP instances, presence/link states and available channel/band readings;
explain possible Wi-Fi Direct duplicates and 2.4 GHz audio overlap. No headset
access, automatic changes or disable button. See
[Windows wireless diagnosis](windows-wireless-doctor.md).
- **Battery** with charging state: charge rate in watts, time to full or empty,
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
+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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+118
View File
@@ -0,0 +1,118 @@
# Linux Wireless Adapter doctor
![Linux PC doctor before any checks run, with explicit hardware selection and read-only limits](img/linux-wireless-doctor.png)
On the **Linux PC**, open **Settings → Linux Wireless Adapter doctor**, or
**Devices & connection → Linux Wireless Adapter doctor**. It works without a
connected headset. Choose **Read PC device inventory**, select the intended
USB device or wireless interface, then **Check selected device**. Nothing runs
until requested. Re-read the inventory after reconnecting a device.
From a source checkout, the same collector is available without starting the
app or its headset connection workers:
```sh
python3 ui/frame_wireless_doctor.py
python3 ui/frame_wireless_doctor.py --device net:wlan0
python3 ui/frame_wireless_doctor.py --device usb:1-2 --json > adapter-report.json
```
Use a selector actually listed on your PC. No device is selected automatically.
All USB devices are included, including hubs and devices without a network
interface. A product label, observed VID/PID, or a single available interface
**does not identify Valve's adapter**. There is no trusted adapter ID table in
this feature. Compare inventory before and after reconnecting the intended
adapter when safe. For a USB device exposing multiple wireless interfaces,
select the specific interface for radio/manager evidence.
## Evidence and limits
- **Presence and binding:** `/sys/bus/usb/devices` and `/sys/class/net` correlate
USB ancestry, wireless interfaces, driver links and module links. An absent
interface does not establish which driver should be installed. A missing
module link may mean a built-in driver. A bound driver does not prove Steam
streaming support.
- **Permissions and udev:** `udevadm info --query=property` supplies a small
allowlist of driver, ID and tag fields. If available, the report includes USB
node mode, owner/group IDs and the current user's read/write access. Direct
USB access is not necessarily required by kernel networking; a restrictive
node alone is not diagnosed as the cause. No udev rules are installed or run.
- **Radio blocks:** read-only WLAN rfkill sysfs entries are matched to the
selected interface's PHY name. An unrelated block cannot establish that
this adapter is blocked. Unavailable or unmatched entries remain unknown.
- **Channel/band:** `iw dev IFACE info` provides the current channel and
frequency where reported; `iw phy PHY info` supplies enabled frequencies.
An idle interface may have no current channel. These observations do not
establish the channel used by a Steam streaming session, supported regions,
or the correct regulatory setting.
- **Network managers:** visible `/proc/*/comm` process names show
NetworkManager, iwd and wpa_supplicant. `nmcli` reads only the selected
interface's general state, managed flag and driver. NetworkManager can use
iwd or wpa_supplicant as its backend, so coexistence is not itself a conflict.
If both backends are visible, the report suggests checking who owns this
exact interface. It does not claim a confirmed conflict or stop services.
Restricted process visibility and unverified backend ownership are explicit.
Every command uses fixed read-only arguments, no shell, closed stdin and a
three-second timeout. Optional tools are not installed automatically. Missing
tools, denied reads, disappearing devices, and restricted VM/container hardware
visibility remain unknown. A successful report is a snapshot, not a hardware
health certificate. CLI exit 0 means collection is supported, not that the
adapter works; unsupported platforms/headset-local mode return 2, as does an
invalid/stale selector. JSON has schema version 1.
The doctor never uses SSH, scans nearby Wi-Fi networks, opens a connection to
the Frame, escalates privileges, loads drivers, changes security settings,
changes channels, unblocks radios, changes network settings, or restarts
services. Run it as your ordinary user, never with sudo. The app hides the
entry on macOS, Windows and phone sessions; its API refuses collection in
headset-local (`FRAME_LOCAL=1`) mode. The source CLI is intended only for the PC.
Reports stay local unless you explicitly save, copy or share them. Exported
fields include observed USB IDs/product labels, interface names, driver names
and permission metadata. USB serials, Wi-Fi names, MAC/IP address fields,
credentials, process arguments and raw command output are not exported. Review
the report before posting it: hardware labels and local paths can identify
parts of your setup.
## Motivation and shipped comparison
[The 23 October 2026 complaint](https://x.com/ExpressGinga81/status/2113457683143741720)
says the Wi-Fi dongle does not work on Ubuntu/Kubuntu 26.04 while SteamVR works
under Wayland. It supplies **no adapter VID/PID, driver, logs or established
cause**. This feature helps gather that missing evidence; it does not claim to
fix that complaint or certify Ubuntu/Kubuntu 26.04 support.
Shipped **v0.4.18** has local network/gateway discovery in `frame_network.py`
and headset connection management, but no local USB/driver/rfkill adapter
report or adapter diagnostic UI. The existing `frame-doctor.sh` diagnoses the
headset through SSH and is a different tool. This doctor never calls it.
General SteamOS 3.9.5 / Frame OS 0.5.6 compatibility and OS backend transition
notes are covered separately in
[`docs/steamos-2026-10-compat.md`](https://github.com/saphid/frame-control/blob/9c7110c1f48d160b0fb3c3b9f658d4937bd2032d/docs/steamos-2026-10-compat.md)
([PR #164](https://github.com/saphid/frame-control/pull/164)). Existing PC
route/SSID diagnostics use NetworkManager's `nmcli` with an `iwgetid` SSID
fallback, independently of whether NetworkManager uses wpa_supplicant (SteamOS
3.9.5 new installs) or iwd (upgrades). This doctor reads actual PC evidence and
does not infer a conflict, hardware identity or fix from those OS defaults.
There is no adapter-specific device observation in that compatibility work.
## Verification
```sh
python3 -m unittest discover -s tests -p test_wireless_doctor.py -v
python3 -m unittest discover -s tests
```
Tests use an explicitly synthetic USB `1234:5678`, sysfs trees, command replies
and loopback HTTP. They cover an unbound USB device, module binding, multiple
interfaces, PHY-matched and unrelated rfkill entries, channel parsing, manager
coexistence, missing/denied commands, stale/injected selectors, export field
filtering, authentication and refusal to probe in headset-local mode. The
HTTP tests instantiate the handler without starting headset workers.
Linux host collection was exercised on kernel `6.8.0-139-generic`: an existing
PC interface was visible with its `iwlwifi` binding and unblocked PHY; `iw` and
`nmcli` were absent and reported as unavailable. This is **not a physical Steam
Frame Wireless Adapter test**, an Ubuntu/Kubuntu 26.04 test, or a headset test.
No Frame access is part of this work.
+4
View File
@@ -1,5 +1,9 @@
# PC in the headset (Experimental)
For Steam's own Linux Wayland desktop-sharing flow, see the separate
[Steam desktop sharing helper](steam-pipewire.md). It does not enable this
feature on Linux.
**Tools → PC in the headset**, on Windows only. It's the Windows version of
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
becomes its own SteamVR panel in the Frame. Individual windows are available only
+16 -1
View File
@@ -1,6 +1,6 @@
# Recording and Steam clips
Frame Control can record what the headset shows, with its sound, and save it as
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
an `.mp4` on your computer. It can also export the clips and background
recordings from Steam's own game recording, at any length, as one `.mp4` each.
Both are on **Tools → Capture & Live**. Everything is saved to
@@ -47,6 +47,21 @@ the Frame, converted to `.mp4` there (without re-encoding) and copied to
Recording and **Live** can run together; both read the same device.
### Does Record include passthrough?
**Record captures SteamVR's `system.HeadsetView` mirror from `/dev/video99`.**
Frame Control does not open the headset's passthrough cameras or add a separate
camera layer to the MP4. If SteamVR puts passthrough into that mirror, it will
appear in the recording; Frame Control cannot force or promise that result.
Steam's own **Steam clips** use a separate game recording path and may differ.
On 25 October 2026, the Frame was in standby. A frame captured from
`/dev/video99` was black, as expected in standby, so this check **did not
establish whether active passthrough appears in the mirror**. A wearer-visible
passthrough session and its simultaneous mirror frame are still needed for a
yes-or-no answer about passthrough pixels. See the
[live compatibility evidence](evidence/steamos-compat-live-2026-10-25.md).
## Steam clips
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
+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)
+92
View File
@@ -0,0 +1,92 @@
# Steam desktop sharing on a Linux PC
**Tools → Steam desktop sharing** helps with Steam's own `steam -pipewire`
desktop-in-headset flow on a local Wayland PC. It needs no Frame connection.
It is separate from Frame Control's [PC in the headset](pc-in-headset.md),
which remains disabled on Linux. Steam and the desktop portal own the capture,
screen picker and headset flow; this helper does not provide a stream.
## Launch Steam with PipeWire
Save your game and use **Steam → Exit** first. Then click **Launch Steam with
PipeWire**. Frame Control checks this user's local processes, finds `steam` on
PATH, and requests `steam -pipewire`. This flag applies to that launch only.
No Steam settings, login files or startup entries are changed. An existing
Steam session is never stopped, signalled or silently switched.
If Steam or a Steam launcher/helper is running, the button explains that Steam
must be exited first. If the process inventory cannot be read, it refuses to
launch. It also refuses root, SSH, headless, X11 and Flatpak-sandboxed Frame
Control sessions. Run Frame Control as your normal user in the local Wayland
desktop with its session bus.
For a Flatpak-only Steam install without a `steam` command on PATH, the helper
provides a manual command instead of choosing an installation for you. After
exiting Steam, run `flatpak run com.valvesoftware.Steam -pipewire` in a local
terminal. Flatpak Steam processes are included in the existing-session check.
A successful request is **not confirmation of a picker or stream**. Steam may
exit or fail after the initial check, and another launcher can race the local
process check. Do not start Steam elsewhere at the same time. If nothing opens,
run the command yourself in a terminal to see Steam's error.
## Reset screen-share choice
Click **Reset screen-share choice…** to request KDE's **Application Permissions**
page. This button does **not** revoke anything or report a completed reset.
Frame Control cannot reliably map a Steam runtime display name to a
Steam-specific portal grant or restore token across backends. It never reads,
edits or clears the portal permission store or Steam tokens.
On KDE:
1. Open **System Settings → Applications → Application Permissions** if the
button cannot open it.
2. Find the entry for Steam. The Ubuntu 26.04 KDE report showed **Steam Linux
Runtime 3.0**. That display name alone is not proof that every session there
is safe to remove; verify the session belongs to your Steam desktop share.
3. Under **Screen sharing**, choose **Manage 1 Session** (the count may differ).
4. Choose **Revoke** for that verified session only. **Do not use Revoke All**
or change another application's grants. If ownership is ambiguous, stop.
5. Retry Steam's desktop sharing flow. The reporter saw the portal picker again
after revoking that session. Results can differ by Steam and portal version.
The reported entry sometimes appeared intermittently. If it is missing, do not
clear other entries or the whole portal store. Check again when Steam's sharing
session is present. If **Application Permissions** itself is missing on
Ubuntu/Kubuntu, check your distribution's `kde-config-flatpak` package: the
reporter needed that package and a reboot before the page appeared. Frame
Control does not install packages, reboot, request root or change security
settings. Older KDE modules may lack this page; other desktops receive guidance
only, with no automatic reset or guessed permission target.
The launcher uses `kcmshell6 --list` to verify the exact module, then
`kcmshell6 kcm_app-permissions`. It does not substitute the older Flatpak-only
page. A request to open the page does not prove that a window became visible.
## Evidence and limits
- User-supplied report and screenshot description:
[ExpressGinga81's thread and replies](https://x.com/ExpressGinga81/status/2113242533530911058).
The report describes Ubuntu 26.04 KDE, the initial picker appearing once,
the Steam Linux Runtime 3.0 entry, and Revoke making the picker reappear.
This is reported behavior, not a live test performed for this feature.
- KDE upstream [`flatpak-kcm`](https://github.com/KDE/flatpak-kcm):
`CMakeLists.txt` and `kcm_app-permissions.json` identify Application
Permissions; `ui/SessionList.qml` distinguishes Revoke from Revoke All.
- Automated tests mock Steam, processes and KDE commands. HTTP tests verify
request guards, no actions on GET, and no headset work. Existing CI discovers
these tests on Linux, macOS and Windows; they do not need a desktop portal.
- lxso2 has no interactive Wayland/PipeWire portal for a chooser test.
**No live chooser, revoke, Steam launch or headset display is verified here.**
No Frame access was performed for this task, including during the hold before
2026-10-24 13:00 UTC. The helper never invokes SSH or Frame controls.
## Announcement
Linux Wayland users can now find **Steam desktop sharing** under Tools to
request Steam with `-pipewire` after exiting Steam, or open KDE's Application
Permissions to revoke only a verified Steam screen-sharing session and try the
picker again. Frame Control does not clear permissions automatically. This is
help for Steam's own desktop-sharing flow; Frame Control's Linux capture
feature remains disabled. Live picker behavior still needs desktop validation.
+67
View File
@@ -0,0 +1,67 @@
# 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. A live check on build `20261022.6153909` confirmed that Frame
Control's existing readers return battery and memory values. 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. The same check found the
`beta` update branch, but the normal user could not query update availability
through Atomupd; no update was applied. See the
[live evidence](evidence/steamos-compat-live-2026-10-25.md).
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`.
+25 -4
View File
@@ -154,10 +154,31 @@ two fingers to scroll, two-finger tap to right-click) plus a text field that
types on the Frame. On a computer, clicking the pad passes your mouse and
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
It goes through **KDE Connect**, the first-party route (KDE makes the Frame's
desktop): Frame Control's server runs [`ui/frame_input_agent.py`](../ui/frame_input_agent.py)
on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
`kdeconnectd` as if it were a phone. KDE Connect does the typing and clicking.
Since Control (below), the pad goes through **gamescope's own input socket**
([`ui/frame_touch.py`](../ui/frame_touch.py)), so it reaches whichever panel has
focus, Steam's own menus included, and needs nothing installed on the Frame.
Only characters that aren't on a US keyboard (accents, emoji) go through
**KDE Connect** as below; it starts the first time you type one. (Before
Control, everything went through KDE Connect, which is why the notes below
describe it in detail.)
The server keyboard queue holds at most 200 events, including text being sent.
A batch with an accent sends its whole keyboard part through KDE Connect, and
later keys follow it there while typing is pending. If the batch will not fit,
the server rejects it before sending any clicks or keys; the page keeps it and
retries after polling. Accepted events are never evicted to make room. A timeout
or helper failure retains unsent text and shows **Try again**; that button resumes
the retained queue. The queue lives in memory and is lost if the server exits.
Switching headsets cancels queued text and stops both input helpers before the
new route is published. Both controls validate the intended headset before they
start. Ordering between the two input services uses a typing-time estimate;
neither service acknowledges when characters reach the application.
[PR #66 verification on 2026-10-24](evidence/pad-gamescope-2026-10-24.md)
records an owned focused X11 panel receiving a click and `Frame66` plus Enter
on a real Frame with SteamOS 0.5.6, after the shared headset became available.
The helper, window and owner-marked lock were cleaned up. This is ASCII input
proof, not wearer-visible, Unicode or packaged Electron GUI verification.
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
+26
View File
@@ -12,6 +12,16 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
## Unit tests
The gamescope keyboard/trackpad route has focused suites in `test_input.py`,
`test_touch.py` and `test_service_regressions.py`. They cover pad translation,
ASCII/modifier keys, Unicode queuing/retries, stale panel targets, release
handling, route cancellation, and intended-headset start/status guards.
`tests/page/pad_queue.mjs` and `tests/page/ctrl_gestures.mjs` execute the page's
real queue and gesture functions in Node. These are offline tests; their
acknowledgements do not prove that a real application received input.
See [the 2026-10-24 PR #66 evidence](evidence/pad-gamescope-2026-10-24.md)
for the real focused-panel click/key observation, cleanup and remaining limits.
The read-only power/heat sampler has fixture and transport tests in
`tests/test_power_sample.py`. Run
`python3 -m unittest discover -s tests -p test_power_sample.py -v`.
@@ -473,3 +483,19 @@ rule, which the original source-only assertion missed. Check computed wrapping,
the complete explanation, tooltip punctuation, Refresh bounds and selection
after **Use this headset** when changing these styles. This fixture does not
exercise Electron integration or a real SSH identity change.
## PC-only Windows wireless diagnosis
```sh
python3 -m unittest discover -s tests -p 'test_wireless.py' -v
node --test tests/wireless.cjs
```
The checks workflow runs this targeted suite on Windows, including the real
read-only PowerShell query, and the normal server suite includes it on all
platforms. HTTP tests instantiate only a handler, never the app connector.
Fixtures cover duplicate instance identity, unknown states, GUID matching,
localised/denied channel data and 6 GHz ambiguity. No tests contact a real Frame.
A Windows CI runner/VM cannot prove physical Steam dongle behaviour or audio RF
interference. See [sources and limits](windows-wireless-doctor.md).
+101
View File
@@ -0,0 +1,101 @@
# Windows Wireless Adapter doctor
Open **Tools → This PC → Wireless Adapter doctor**, then choose **Read PC
adapters**. It works without a headset connection. It only queries this Windows
PC, with no automatic polling, changes, elevation or UAC prompt. It never
contacts the Frame. A direct `#wireless` link on another platform explains that
Windows is required.
The snapshot shows:
- Exact network PnP instance IDs, including non-present devices, with separate
presence, PnP status/problem code, operational state and media connection state.
- Microsoft Wi-Fi Direct Virtual Adapter candidates and their distinct instance
count. `SWD\WIFIDIRECT\` is enumerator evidence; an English device-name match
is explicitly labelled **Name only**. A repeated row for one ID is counted once.
- PC Wi-Fi interfaces, hardware-interface status and whether Windows has a
default route configured on an interface. Neither a route nor **Up** proves
that Steam uses that adapter. Multiple configured defaults are possible.
- Reported channel and band, when `netsh wlan show interfaces` supplies recognised
English fields and an exact matching interface GUID. Missing, localised,
disconnected or denied readings remain **Unknown**. There is no name-based
channel association. A PC's router channel is not the Steam dongle's channel.
- Possible spectrum overlap when an interface reports **2.4 GHz**, without
claiming interference. With no such reading, overlap stays **Unknown**:
unreported virtual/proprietary radios and audio dongles may still use 2.4 GHz.
Channel 1–14 alone does not identify a band: 6 GHz reuses low channel numbers.
## Why there is no disable button
[夕凪's report](https://x.com/Oo_yuunagi_oO/status/2113904070352343135)
(24 October 2026) says extra Microsoft Wi-Fi Direct Virtual Adapter instances
appear when their wireless adapter becomes unavailable, and disabling the newly
appeared one *seems* to stop recurrence. This is useful owner evidence, but does
not establish which instance would be safe to disable on another PC.
Windows can legitimately create several Wi-Fi Direct virtual interfaces.
A `#2` suffix is not a creation timestamp; non-present, down, or disconnected
status is not proof of an unused instance. The inventory does not reliably map
Steam's active radio/session to an expendable PnP instance. Disabling the wrong
one could interrupt streaming, PC Wi-Fi, Mobile Hotspot or wireless displays.
Frame Control therefore supplies evidence and guidance, with no disable,
re-enable, driver removal or other write endpoint. It never changes security or
location settings, requests administrator access, or bypasses execution policy.
Before any manual changes, retain the exact instance IDs and compare snapshots
before/after a drop with Steam Support. Do not disable a device based only on
this report. A future disable feature needs a verified Steam adapter/session
mapping, fresh protection for primary/active instances, explicit confirmation
for one exact instance, and a tested recovery path that re-enables that same ID.
## Checking a 2.4 GHz audio conflict
[Deko's report](https://x.com/DekoDrak/status/2113412044644897023)
(22 October 2026) describes Razer wireless-headset interference and temporarily
disabling the Steam Frame dongle. It also mentions a PC/Wi-Fi shutdown during
Windows Update and asks whether the dongle caused it. The post does not prove
that connection or specify a measured audio radio channel. Treat the suggested
2.4 GHz conflict as a hypothesis, not a diagnosis of the shutdown.
Try wired audio briefly, separate radio dongles with a USB extension lead, or
compare PC Ethernet / an existing 5 or 6 GHz network. Change one thing at a time
and record whether symptoms recur. The doctor cannot measure RF congestion,
read an audio dongle's channel, identify a faulty driver, or prove a dropout fix.
## Sources, privacy and limits
The single local PowerShell process uses `Get-PnpDevice -Class Net`, its
`-PresentOnly` variant, `Get-NetAdapter -IncludeHidden`, `Get-NetRoute` for
IPv4/IPv6 defaults, and `netsh wlan show interfaces`. Each source reports
availability separately. Access denial, unavailable cmdlets/services and
unsupported features produce partial/unknown results; there is no permission
workaround. Queries run sequentially, so even one snapshot can span a state
change. One probe runs at a time, with a 20-second process timeout.
SSIDs, MAC addresses and raw command/error output are omitted from the API/UI.
PnP IDs and adapter/interface names can identify this PC; review before sharing.
The tool does not send or save reports, and does not add these readings to
telemetry or problem-report diagnostics. Copy relevant text manually if needed.
The shipped basic network-identity probe (`frame_network.py`) finds a gateway,
network name and Tailscale state. It does not inventory virtual adapters or
check channel overlap. The new doctor is a separate, explicit PC query.
## Verification
`tests/test_wireless.py` checks duplicate identity, presence/disabled/link
separation, ambiguous joins, partial sources, privacy filtering, 6 GHz/channel
ambiguity, localisation, missing band and denied output. HTTP tests require the
UI key, reject headset-local mode, and assert no SSH call. `tests/wireless.cjs`
executes the production UI with boundary fixtures: navigation starts no query,
clicks query once, unknown values and failure/retry remain visible, and PnP IDs
and names are escaped as text.
Windows CI also executes the real read-only PowerShell probe against the
runner's network devices. That proves Windows query execution, not a physical
Steam dongle, Wi-Fi Direct session or Razer headset. See the PR for exact CI
results. An existing local Windows 11 VM passed 15 targeted tests (one UI test skipped
because Node is absent), including the live query. PnP, presence, adapters and
routes were available; WLAN was unavailable in this VM. See
[VM evidence](evidence/windows-wireless-2026-10-24.md). No Frame was accessed; no physical
adapter/dropout fix or RF measurements are claimed.
+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:
+27 -1
View File
@@ -31,7 +31,8 @@ function page(api) {
// A request that fails, or reaches a link that had dropped, keeps its keys and releases.
for (const [label, api] of [["request failed", async () => { throw new Error("offline"); }],
["not sent", async () => ({ state: "starting", sent: false })]]) {
["not sent", async () => ({ state: "starting", sent: false })],
["queue full", async () => ({ state: "waiting", sent: false })]]) {
const { pad, padSend, shown } = page(api);
padSend({ dx: 5 }); // sent and lost: a move, fine to drop
await new Promise(r => setTimeout(r, 0));
@@ -45,4 +46,29 @@ for (const [label, api] of [["request failed", async () => { throw new Error("of
fail(label + ": " + JSON.stringify(q));
if (!shown.length || shown.at(-1).state === "ready") fail(label + ": state not shown");
}
// Acknowledged text is removed locally, but its pending status must reach padShow
// so failures reported after acceptance are polled without another keypress.
{
const { pad, padSend, shown } = page(async () => ({ state: "ready", sent: true, queued: 1 }));
padSend({ key: "é" });
await new Promise(r => setTimeout(r, 0));
if (pad.queue.length || shown.at(-1)?.queued !== 1) fail("accepted text is not polled");
}
// Exercise the real status/poll function: queued text leaves pointer controls
// usable, still polls, shows an eventual error, and does not silently retry it.
{
const pad = { state: "ready", queue: [] }, elements = {};
let poll = null;
const show = new Function("pad", "$", "PAD_STATES", "TOUCH", "padFlush", "devGen", "api",
"setTimeout", "clearTimeout", grab("padShow") + "; return padShow;")(
pad, id => elements[id] ||= { classList: { toggle() {} } }, {}, false, () => {}, 0,
async () => ({ state: "error", message: "Text is retained; try again." }),
fn => { poll = fn; return 1; }, () => { poll = null; });
show({ state: "ready", queued: 1 });
if (pad.state !== "ready" || !poll) fail("pending text disabled pointer or skipped poll");
await poll();
if (pad.state !== "error" || poll || !elements.padHint.textContent.includes("retained"))
fail("asynchronous error not surfaced");
}
console.log("queue rules ok");
+70
View File
@@ -0,0 +1,70 @@
// Run the real doctor page script with a minimal DOM; no browser or hardware I/O.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
class Element {
constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.value = ''; this.hidden = false; this.disabled = false; this.textContent = ''; }
append(...nodes) { this.children.push(...nodes); }
prepend(...nodes) { this.children.unshift(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
get firstChild() { return this.children[0]; }
get options() { return this.children; }
}
const ids = ['back','inventory','diagnose','device','message','results','json','reportSection','selection','download','copy'];
const elements = Object.fromEntries(ids.map(id => [id,new Element(id === 'device' ? 'select' : 'div')]));
const requests = [];
let pending;
const context = vm.createContext({
document:{getElementById:id=>elements[id],createElement:tag=>new Element(tag)},
URLSearchParams,location:{search:'?key=test-key'},
fetch:(path,options)=>{ requests.push({path,options}); return new Promise(resolve=>{pending=resolve;}); },
navigator:{clipboard:{writeText:async()=>{throw Error('clipboard denied');}}},
});
const source = readFileSync(new URL('../../ui/wireless-doctor.html',import.meta.url),'utf8');
vm.runInContext(source.match(/<script>([\s\S]*)<\/script>/)[1],context);
assert.equal(requests.length,0,'opening the page must not probe');
assert.equal(elements.back.href,'/?key=test-key#devices');
const fixture = {
schema:1,supported:true,selected:null,
usb:[{id:'usb:1-2',vid:'1234',pid:'5678',product:'<img src=x onerror=alert(1)>'}],
interfaces:[{id:'net:wlan0',name:'wlan0',usb:'usb:1-2',driver:'synthetic'}],
checks:[{id:'presence',title:'Presence',status:'ok',suggestion:'Select hardware',evidence:['Synthetic evidence']}]
};
let promise=elements.inventory.onclick();
assert.equal(elements.device.disabled,true,'selection must stay fixed during a read');
assert.equal(requests[0].path,'/api/wireless-doctor');
assert.equal(requests[0].options.headers['X-Frame-UI'],'test-key');
pending({ok:true,json:async()=>fixture}); await promise;
assert.equal(elements.device.value,'','no automatic hardware choice');
assert.equal(elements.diagnose.disabled,true);
assert.equal(elements.device.options.length,3);
assert.match(elements.device.options[2].textContent,/<img src=x/,'hardware labels must stay plain text');
assert.equal(elements.device.options[2].children.length,0);
assert.equal(elements.reportSection.hidden,false);
assert.equal(elements.results.children.length,1);
elements.device.value='net:wlan0'; elements.device.onchange();
assert.equal(elements.diagnose.disabled,false);
assert.equal(elements.results.children.length,0,'changing hardware clears the previous result');
assert.equal(elements.reportSection.hidden,true,'cannot export an old report for a new selection');
promise=elements.diagnose.onclick();
assert.equal(requests[1].path,'/api/wireless-doctor?device=net%3Awlan0');
assert.equal(elements.device.disabled,true);
pending({ok:true,json:async()=>({...fixture,selected:'net:wlan0'})}); await promise;
assert.equal(elements.device.value,'net:wlan0');
assert.equal(elements.device.disabled,false);
await elements.copy.onclick();
assert.match(elements.message.textContent,/Clipboard unavailable/);
promise=elements.diagnose.onclick();
assert.equal(elements.results.children.length,0,'clear a healthy snapshot before retrying');
assert.equal(elements.reportSection.hidden,true);
pending({ok:false,json:async()=>({error:'Device is no longer present. Refresh and select an observed device.'})}); await promise;
assert.match(elements.message.textContent,/no longer present/);
assert.equal(elements.results.children.length,0);
assert.equal(elements.reportSection.hidden,true,'failed requests cannot export stale success');
assert.equal(elements.inventory.disabled,false,'can refresh after failure');
promise=elements.inventory.onclick();
pending({ok:true,json:async()=>({...fixture,supported:false,usb:[],interfaces:[]})}); await promise;
assert.equal(elements.selection.hidden,true);
assert.equal(elements.diagnose.disabled,true);
assert.match(elements.message.textContent,/only on a Linux PC/);
console.log('doctor UI: on-demand reads, explicit selection, safe labels, stale report clearing and failure recovery passed');
+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])
+82
View File
@@ -0,0 +1,82 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '../ui/index.html'), 'utf8');
const code = html.split('// Local Steam helper: never start')[1].split('// ---- shared destinations')[0];
function fixture() {
const elements = new Map(), requests = [];
const $ = id => {
if (!elements.has(id)) elements.set(id, {disabled:true, textContent:''});
return elements.get(id);
};
const ctx = {$, api:(url, body) => new Promise((resolve, reject) => requests.push({url, body, resolve, reject}))};
vm.createContext(ctx);
vm.runInContext('// never start' + code, ctx);
return {ctx, $, requests};
}
test('navigation reads status; only clicks request launch or permission UI', async () => {
const {ctx, $, requests} = fixture();
assert.equal(requests.length, 0);
const load = ctx.loadSteamPipewire();
assert.equal(requests[0].url, '/api/steam-pipewire');
assert.equal(requests[0].body, undefined);
requests[0].resolve({available:true, guidance:'Verify the Steam session'});
await load;
assert.equal($('steamPipewireLaunch').disabled, false);
assert.equal($('steamPipewireGuidance').textContent, 'Verify the Steam session');
const launch = $('steamPipewireLaunch').onclick();
assert.deepEqual(JSON.parse(JSON.stringify(requests[1].body)), {action:'launch', confirmed:true});
assert.equal($('steamPipewireReset').disabled, true);
await $('steamPipewireReset').onclick(); // repeated action suppressed
assert.equal(requests.length, 2);
requests[1].reject(new Error('Exit Steam first'));
await launch;
assert.equal($('steamPipewireStatus').textContent, 'Exit Steam first');
const reset = $('steamPipewireReset').onclick();
assert.deepEqual(JSON.parse(JSON.stringify(requests[2].body)), {action:'permissions', confirmed:true});
requests[2].resolve({reset:false, opened:true, message:'No permission was changed'});
await reset;
assert.equal($('steamPipewireStatus').textContent, 'No permission was changed');
});
test('unsupported status and read failure keep actions disabled', async () => {
const {ctx, $, requests} = fixture();
const load = ctx.loadSteamPipewire();
requests[0].resolve({available:false, reason:'Local Wayland required'});
await load;
assert.equal($('steamPipewireLaunch').disabled, true);
assert.equal($('steamPipewireReset').disabled, true);
assert.equal($('steamPipewireStatus').textContent, 'Local Wayland required');
const retry = ctx.loadSteamPipewire();
requests[1].reject(new Error('Unavailable'));
await retry;
assert.equal($('steamPipewireLaunch').disabled, true);
});
test('stale status cannot overwrite an action result', async () => {
const {ctx, $, requests} = fixture();
const load = ctx.loadSteamPipewire();
const reset = ctx.steamPipewireAction('permissions');
requests[1].resolve({message:'No permission was changed'});
await reset;
requests[0].resolve({available:true});
await load;
assert.equal($('steamPipewireStatus').textContent, 'No permission was changed');
});
test('Tools navigation opens the separate Steam panel without starting PC capture', () => {
const {workspaceFixture} = require('./workspace_fixture.cjs');
const f = workspaceFixture();
f.navigate('#steamPipewire');
assert.equal(f.activePage, 'tools');
assert.equal(f.el('steamPipewire').hidden, false);
assert.equal(f.el('macview').hidden, true);
assert.ok(f.calls.some(call => call[0] === 'steam-pipewire'));
assert.ok(!f.calls.some(call => call[0] === 'macview'));
assert.match(html, /\$\("steamPipewireLink"\)\.hidden = h.mobile \|\| h.os !== "Linux"/);
});
+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
+426
View File
@@ -18,6 +18,7 @@ import tempfile
import threading
import time
import unittest
from unittest import mock
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -631,3 +632,428 @@ class PageQueue(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class PadEvents(unittest.TestCase):
"""The trackpad and key row go through gamescope; accents and emoji go the KDE Connect way."""
@classmethod
def setUpClass(cls):
import server
cls.pad = staticmethod(server.pad_events)
def test_moves_clicks_and_scroll(self):
out, extra = self.pad([{"dx": 3, "dy": -2}, {"singleclick": True}, {"singlehold": True},
{"singlerelease": True}, {"scroll": True, "dy": 1}, {"rightclick": True}])
self.assertEqual(out[0], {"dx": 3, "dy": -2})
self.assertEqual(out[1:3], [{"button": "left", "down": True}, {"button": "left", "down": False}])
self.assertEqual(out[3:5], [{"button": "left", "down": True}, {"button": "left", "down": False}])
self.assertEqual(out[5], {"scroll": [0, -15]}) # up = content follows the finger
self.assertEqual(out[6:], [{"button": "right", "down": True}, {"button": "right", "down": False}])
self.assertEqual(extra, [])
def test_keys_and_modifiers(self):
out, _ = self.pad([{"specialKey": 12}, {"specialKey": 1, "ctrl": True}, {"key": "c", "ctrl": True}])
self.assertEqual(out[:2], [{"key": 28, "down": True}, {"key": 28, "down": False}])
self.assertEqual([e["key"] for e in out[2:6]], [29, 14, 14, 29]) # Ctrl+Backspace
self.assertEqual([(e["key"], e["down"]) for e in out[6:]],
[(29, True), (46, True), (46, False), (29, False)]) # Ctrl+C
def test_text_stays_ascii_through_gamescope(self):
out, slow = self.pad([{"key": "Hi "}, {"key": "there"}])
self.assertEqual(out, [{"text": "Hi there"}])
self.assertEqual(slow, [])
def test_keyboard_goes_to_kde_whole_and_in_order(self):
events = [{"key": "a"}, {"key": "é"}, {"specialKey": 12}, {"key": "c", "ctrl": True}, {"dx": 2, "dy": 1}]
out, slow = self.pad(events, kde=True)
self.assertEqual(out, [{"dx": 2, "dy": 1}]) # the pointer still goes through gamescope
self.assertEqual(slow, events[:4]) # the keyboard as sent, in order
def test_shifted_key_with_modifier(self):
out, _ = self.pad([{"key": "A", "ctrl": True}])
self.assertEqual([e["key"] for e in out], [29, 42, 30, 30, 42, 29])
class PadDelivery(unittest.TestCase):
"""remote_input's promises: "sent" means the whole batch is taken; accents go once, in order."""
class Agent:
def __init__(self, sent=True, state="ready"):
self.sent, self.state, self.got = sent, state, []
def send(self, events):
if events and self.sent:
self.got.append(events)
return {"state": self.state, "sent": self.sent and bool(events)}
def setUp(self):
import server
self.s = server
self.saved = (server._touch, server._input, server.KDE_WAIT)
server._touch, server._input = self.Agent(), self.Agent()
self.reset()
def tearDown(self):
self.s._touch, self.s._input, self.s.KDE_WAIT = self.saved
self.settle()
self.reset()
def reset(self):
self.s._kde_queue.clear()
self.s._kde_error = None
self.s._kde_until = self.s._typed_until = 0.0
self.s._kde_worker[0] = None
def settle(self):
worker = self.s._kde_worker[0]
if worker:
worker.join(5)
def typed(self):
return [e for batch in self.s._input.got for e in batch]
def test_accent_only_batch_is_acknowledged_and_typed_once(self):
status = self.s.remote_input({"events": [{"key": "é"}]})
self.assertTrue(status["sent"]) # so the page doesn't send it again
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}])
def test_mixed_text_stays_in_one_ordered_stream(self):
self.s.remote_input({"events": [{"key": "aéb"}, {"specialKey": 12}]})
self.settle()
self.assertEqual(self.typed(), [{"key": "aéb"}, {"specialKey": 12}])
self.assertEqual(self.s._touch.got, []) # none of it split off through gamescope
def test_batch_gamescope_didnt_take_is_not_queued(self):
self.s._touch = self.Agent(sent=False, state="starting")
status = self.s.remote_input({"events": [{"dx": 1, "dy": 1}, {"key": "é"}]})
self.assertFalse(status["sent"]) # the page tries the whole batch again...
self.assertEqual(self.s._kde_queue, []) # ...so the accent isn't queued twice
def test_waits_for_kde_connect_then_sends_without_more_input(self):
self.s._input = self.Agent(sent=False, state="starting")
self.s.remote_input({"events": [{"key": "é"}]})
time.sleep(0.2)
self.assertEqual(self.s._kde_queue, [{"key": "é"}])
self.s._input.sent = True
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}])
self.assertEqual(self.s._kde_queue, [])
def test_later_ascii_waits_behind_a_pending_accent(self):
self.s._input = self.Agent(sent=False, state="starting")
self.s.remote_input({"events": [{"key": "é"}]})
self.s.remote_input({"events": [{"key": "x"}]})
self.assertEqual(self.s._touch.got, []) # "x" can't overtake the "é"
self.assertEqual(self.s._kde_queue, [{"key": "é"}, {"key": "x"}])
self.s._input.sent = True
self.settle()
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
def test_errors_and_timeout_retain_accepted_text_for_explicit_retry(self):
events = [{"key": "é"}] * 150
for state in ("error", "starting"):
with self.subTest(state=state):
self.reset()
self.s._input = self.Agent(sent=False, state=state)
self.s.KDE_WAIT = 0
self.assertTrue(self.s.remote_input({"events": events})["sent"])
self.settle()
self.assertEqual(self.s._kde_queue, events)
self.assertIsNone(self.s._kde_worker[0])
self.assertEqual(self.s._kde_until, 0)
status = self.s.pad_status({"state": "ready"})
self.assertEqual(status["state"], "error")
self.assertIn("retained", status["message"])
self.assertFalse(self.s.remote_input({"events": [{"key": "x"}]})["sent"])
self.s._input = self.Agent()
self.s.start_pad()
self.settle()
self.assertEqual(self.typed(), events)
self.assertEqual(self.s._kde_queue, [])
self.assertIsNone(self.s._kde_error)
def test_worker_exception_and_start_failure_are_visible_and_retryable(self):
for start_failure in (False, True):
with self.subTest(start_failure=start_failure):
self.reset()
events = [{"key": "é"}]
if start_failure:
with mock.patch.object(threading.Thread, "start", side_effect=RuntimeError("no thread")):
self.assertTrue(self.s.remote_input({"events": events})["sent"])
else:
with mock.patch.object(self.s._input, "send", side_effect=OSError("send failed")):
self.assertTrue(self.s.remote_input({"events": events})["sent"])
self.settle()
self.assertEqual(self.s._kde_queue, events)
self.assertIsNone(self.s._kde_worker[0])
self.assertEqual(self.s._kde_until, 0)
self.assertEqual(self.s.pad_status({"state": "ready"})["state"], "error")
self.s._input = self.Agent()
self.s.start_pad()
self.settle()
self.assertEqual(self.typed(), events)
def test_ascii_just_after_an_accent_stays_behind_it(self):
self.s.remote_input({"events": [{"key": "é"}]})
self.settle()
self.s.remote_input({"events": [{"key": "x"}]}) # KDE Connect may still be typing the é
self.settle()
self.assertEqual(self.s._touch.got, [])
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
def test_accent_just_after_gamescope_text_waits_for_it(self):
self.s.remote_input({"events": [{"key": "x" * 50}]})
self.assertEqual(self.s._touch.got, [[{"text": "x" * 50}]])
start = time.time()
self.s.remote_input({"events": [{"key": "é"}]})
self.settle()
self.assertGreater(time.time() - start, 0.6) # 50 keys at 16 ms, plus the margin
self.assertEqual(self.typed(), [{"key": "é"}])
def test_typing_time_counts_every_key_transition(self):
t = self.s.typing_seconds
self.assertAlmostEqual(t([{"text": "ab"}]), 4 * 0.008)
self.assertAlmostEqual(t([{"text": "A"}]), 4 * 0.008) # shift down, key down, key up, shift up
self.assertAlmostEqual(t([{"text": "x" * 500}]), 8.0) # a long paste
self.assertAlmostEqual(t([{"key": 29, "down": True}, {"dx": 3, "dy": 0}]), 0.008)
def test_the_margin_is_added_once_not_per_request(self):
for _ in range(10):
self.s.remote_input({"events": [{"key": "ab"}]})
# ten requests of 4 transitions each: the work queues up, with no margin added per request
self.assertLess(self.s._typed_until - time.time(), 10 * 4 * 0.008 + 0.05)
def hold_worker(self):
entered, release = threading.Event(), threading.Event()
class Gate:
def wait(self, timeout):
entered.set()
if not release.wait(3):
raise RuntimeError("test did not release worker")
return False
patch = mock.patch.object(self.s, "_kde_cancel", Gate())
patch.start()
self.addCleanup(patch.stop)
self.addCleanup(release.set)
return entered, release
def test_saturation_rejects_whole_batch_and_retry_delivers_every_accepted_event_once(self):
entered, release = self.hold_worker()
self.s._typed_until = time.time() + 60
first = [{"key": "é"}] * 150
remainder = [{"key": "ü"}] * 50
overflow = [{"singleclick": True}, {"key": "ß"}] * 75
self.assertTrue(self.s.remote_input({"events": first})["sent"])
self.assertTrue(entered.wait(1)) # batch is in flight, waiting for prior ASCII
try:
self.assertTrue(self.s.remote_input({"events": remainder})["sent"])
status = self.s.remote_input({"events": overflow})
self.assertFalse(status["sent"])
self.assertEqual(status["state"], "waiting")
self.assertEqual(self.s._kde_queue, first + remainder)
self.assertEqual(len(self.s._kde_queue), self.s.KDE_QUEUE_LIMIT)
self.assertEqual(self.s._touch.got, []) # rejected clicks cannot be duplicated
finally:
self.s._typed_until = 0
release.set()
self.settle()
self.assertEqual(self.typed(), first + remainder)
self.assertTrue(self.s.remote_input({"events": overflow})["sent"])
self.settle()
self.assertEqual(self.typed(), first + remainder + [{"key": "ß"}] * 75)
self.assertEqual(self.s._touch.got, [[e for _ in range(75) for e in (
{"button": "left", "down": True}, {"button": "left", "down": False})]])
def test_retrying_batch_keeps_its_capacity_and_order(self):
entered, release = self.hold_worker()
self.s._input = self.Agent(sent=False, state="starting")
first, second = [{"key": "é"}] * 150, [{"key": "ü"}] * 150
self.assertTrue(self.s.remote_input({"events": first})["sent"])
self.assertTrue(entered.wait(1)) # first send failed; worker is in retry delay
try:
self.assertFalse(self.s.remote_input({"events": second})["sent"])
self.assertEqual(self.s._kde_queue, first)
finally:
self.s._input.sent = True
release.set()
self.settle()
self.assertTrue(self.s.remote_input({"events": second})["sent"])
self.settle()
self.assertEqual(self.typed(), first + second)
def test_concurrent_requests_cannot_both_claim_the_last_capacity(self):
entered, release = self.hold_worker()
self.s._typed_until = time.time() + 60
first = [{"key": "é"}]
self.s.remote_input({"events": first})
self.assertTrue(entered.wait(1))
barrier, results = threading.Barrier(3), []
def request(key):
barrier.wait(2)
batch = [{"key": key}] * 150
results.append((batch, self.s.remote_input({"events": batch})["sent"]))
threads = [threading.Thread(target=request, args=(key,)) for key in ("ü", "ß")]
try:
for thread in threads:
thread.start()
barrier.wait(2)
for thread in threads:
thread.join(2)
self.assertFalse(thread.is_alive())
self.assertEqual(sorted(sent for _, sent in results), [False, True])
finally:
self.s._typed_until = 0
release.set()
self.settle()
accepted = next(batch for batch, sent in results if sent)
rejected = next(batch for batch, sent in results if not sent)
self.assertEqual(self.typed(), first + accepted)
self.assertTrue(self.s.remote_input({"events": rejected})["sent"])
self.settle()
self.assertEqual(self.typed(), first + accepted + rejected)
def test_input_queued_as_the_worker_finishes_is_still_sent(self):
for _ in range(40):
self.reset()
self.s._input.got.clear()
self.s.remote_input({"events": [{"key": "é"}]})
time.sleep(0.002)
self.s.remote_input({"events": [{"key": "ü"}]})
self.settle()
self.assertEqual(self.s._kde_queue, [])
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "ü"}])
class PadRoute(unittest.TestCase):
def test_unicode_start_and_headset_switch_do_not_deadlock(self):
# Real InputAgent.send/start takes the route lock. Race it with route(),
# which cancels the pad worker; mocks of send() hide this lock inversion.
# A child keeps a regression's blocked daemon threads out of this suite.
code = r'''
import sandbox
import server as s
import threading
from unittest import mock
entered, release, switching = threading.Event(), threading.Event(), threading.Event()
s._input = s.InputAgent(packages=[])
s._input._launch = lambda *args: None # exercise real start, without any SSH
s._touch = mock.Mock()
send = s._input.send
def held(events):
entered.set()
assert release.wait(2)
return send(events)
s._input.send = held
s._power.stop = lambda *args: switching.set()
s._queue_for_kde([{'key': 'é'}])
worker = s._kde_worker[0]
assert entered.wait(2), 'Unicode worker never reached send'
switch = threading.Thread(target=s.route, args=('frame-pr66-new', []), daemon=True)
switch.start()
switching.wait(0.2)
release.set()
worker.join(2)
switch.join(2)
assert not worker.is_alive(), 'Unicode worker deadlocked starting input during headset switch'
assert not switch.is_alive(), 'headset switch deadlocked cancelling Unicode input'
assert not s._kde_queue
'''
env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(ROOT / 'tests'), str(ROOT / 'ui')])}
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True,
text=True, timeout=8)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def setUp(self):
import server
self.s = server
self.agents = mock.patch.multiple(server, _touch=mock.Mock(), _input=mock.Mock())
self.agents.start()
self.addCleanup(self.agents.stop)
self.s._touch.send.return_value = {"state": "ready", "sent": True}
self.s._input.send.return_value = {"state": "ready", "sent": True}
self.s.reset_pad_route()
self.s._touch.reset_mock(); self.s._input.reset_mock()
self.addCleanup(self.s.reset_pad_route)
def switch(self):
with mock.patch.multiple(self.s, FRAME="frame-a", HOST_OPTS=[], MUX=[], SSH=[], macview=None), \
mock.patch.multiple(self.s.frame_android, FRAME="frame-a", SSH_OPTS=[]):
self.s.route("frame-b", [])
def test_stale_worker_exception_cannot_damage_new_route(self):
entered, release = threading.Event(), threading.Event()
class BrokenWait:
def wait(self, timeout):
entered.set()
if not release.wait(2):
raise RuntimeError("test did not release old worker")
raise OSError("old route wait failed")
def set(self):
pass # hold its exception until the new route has been published
self.s._kde_cancel = BrokenWait()
self.s._typed_until = time.time() + 60
self.s.remote_input({"events": [{"key": "é"}]})
worker = self.s._kde_worker[0]
try:
self.assertTrue(entered.wait(1))
self.switch()
marker = object()
with self.s._kde_lock:
self.s._kde_queue.append({"key": "new"})
self.s._kde_worker[0] = marker
self.s._kde_until = 42
finally:
release.set()
worker.join(2)
self.assertFalse(worker.is_alive())
self.assertEqual(self.s._kde_queue, [{"key": "new"}])
self.assertIs(self.s._kde_worker[0], marker)
self.assertEqual(self.s._kde_until, 42)
self.assertIsNone(self.s._kde_error)
def test_switch_stops_both_input_agents_and_discards_old_queue(self):
self.s._kde_queue.append({"key": "old"})
self.s._kde_until = self.s._typed_until = time.time() + 60
cancel = self.s._kde_cancel
self.switch()
self.s._input.stop.assert_called_once()
self.s._touch.stop.assert_called_once()
self.assertEqual(self.s._kde_queue, [])
self.assertEqual((self.s._kde_until, self.s._typed_until), (0, 0))
self.assertTrue(cancel.is_set())
def test_switch_cancels_delayed_and_retrying_text_before_new_input(self):
for delaying in (True, False):
with self.subTest(delaying=delaying):
waiting, cancel = threading.Event(), threading.Event()
class ObservedCancel:
def wait(self, timeout):
waiting.set()
return cancel.wait(timeout)
def set(self):
cancel.set()
self.s.reset_pad_route()
self.s._kde_cancel = ObservedCancel()
self.s._typed_until = time.time() + 60 if delaying else 0
self.s._input.reset_mock()
self.s._input.send.return_value = {"state": "starting", "sent": False}
self.s.remote_input({"events": [{"key": "é"}]})
worker = self.s._kde_worker[0]
self.assertTrue(waiting.wait(1), "worker never reached its cancellable delay")
self.switch()
worker.join(1)
self.assertFalse(worker.is_alive())
attempts = self.s._input.send.call_count
self.assertEqual(attempts, 0 if delaying else 1)
self.s._input.send.return_value = {"state": "ready", "sent": True}
self.s.remote_input({"events": [{"key": "ü"}]})
next_worker = self.s._kde_worker[0]
if next_worker:
next_worker.join(1)
self.assertFalse(next_worker.is_alive())
self.assertEqual(self.s._input.send.call_count, attempts + 1)
self.assertEqual(self.s._input.send.call_args.args[0], [{"key": "ü"}])
self.assertEqual(self.s._kde_queue, [])
+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")
+1 -1
View File
@@ -9,7 +9,7 @@ import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
RUN = r'''
+73 -6
View File
@@ -2,6 +2,7 @@
import sandbox # noqa: F401
import contextlib
import hashlib
import io
import json
import os
from pathlib import Path
@@ -54,6 +55,7 @@ class TargetTests(unittest.TestCase):
self.stack.enter_context(mock.patch.object(server, '_backfill', {'running': False, 'last': 0.0}))
self.stack.enter_context(mock.patch.object(server.macview, 'retarget'))
self.stack.enter_context(mock.patch.object(server._input, 'stop'))
self.real_devices_action = server.frame_link.devices_action
self.stack.enter_context(mock.patch.object(server.frame_link, 'devices_action', self.switch))
def switch(self, link, body, setup, busy):
@@ -112,6 +114,39 @@ class TargetTests(unittest.TestCase):
server._live_tunnels.discard(proc)
server.route(*before)
def test_input_post_reserves_headset_through_send_and_enqueue(self):
body = json.dumps({'events': [{'singleclick': True}, {'key': 'é'}]}).encode()
handler = server.Handler.__new__(server.Handler)
handler.path = '/api/input'
handler.local_request = lambda: True
handler.headers = {'Content-Length': str(len(body)), 'X-Frame-Device': 'a'}
handler.rfile = io.BytesIO(body)
handler.send_json, handler.send_error_json = mock.Mock(), mock.Mock()
stages = []
def check_switch(stage):
self.assertEqual(server._work[0], 1)
with self.assertRaises(server.Failure):
server.devices_post({'action': 'use', 'id': 'b'})
stages.append(stage)
def send(events):
check_switch('send')
return {'state': 'ready', 'sent': True}
def enqueue(events):
check_switch('enqueue')
self.assertEqual(events, [{'key': 'é'}])
return True
with mock.patch.object(server.frame_link, 'devices_action', self.real_devices_action), \
mock.patch.object(server._touch, 'send', side_effect=send), \
mock.patch.object(server, '_queue_for_kde', side_effect=enqueue):
handler.do_POST()
handler.send_error_json.assert_not_called()
self.assertTrue(handler.send_json.call_args.args[0]['sent'])
self.assertEqual(stages, ['send', 'enqueue'])
self.assertEqual(self.active['id'], 'a')
self.assertEqual(server._work[0], 0)
server.devices_post({'action': 'use', 'id': 'b'})
self.assertEqual(self.active['id'], 'b')
def test_backfill_counts_work_until_error_cleanup(self):
entered, release = threading.Event(), threading.Event()
writes = []
@@ -402,9 +437,9 @@ class FinalGetStartTests(unittest.TestCase):
self.active.update(id='b', name='Headset B')
for name in ('input', 'touch'):
with self.subTest(name=name):
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
fresh = server.TouchAgent()
handler = self.handler('/api/%s?start=1' % name, 'a')
with mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
with mock.patch.object(server, '_touch', fresh), mock.patch.object(fresh, 'start') as start:
handler.do_GET()
start.assert_not_called()
self.assertEqual(handler.send_error_json.call_args.args[1], 409)
@@ -413,14 +448,46 @@ class FinalGetStartTests(unittest.TestCase):
def test_desktop_get_start_requires_an_intended_device(self):
for name in ('input', 'touch'):
with self.subTest(name=name):
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
fresh = server.TouchAgent()
handler = self.handler('/api/%s?start=1' % name, None)
with mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
with mock.patch.object(server, '_touch', fresh), mock.patch.object(fresh, 'start') as start:
handler.do_GET()
start.assert_not_called()
self.assertEqual(handler.send_error_json.call_args.args[1], 400)
self.assertEqual(server._work[0], 0)
def test_input_get_starts_and_polls_gamescope_without_starting_kde_connect(self):
touch, kde = mock.Mock(), mock.Mock()
touch.status = {'state': 'ready', 'focus': 7}
touch.send.return_value = {'state': 'starting', 'sent': False}
with mock.patch.object(server, '_touch', touch), mock.patch.object(server, '_input', kde):
start = self.handler('/api/input?start=1', 'a')
start.do_GET()
touch.send.assert_called_once_with([])
start.send_json.assert_called_once_with(touch.send.return_value)
status = self.handler('/api/input', 'a')
status.do_GET()
status.send_json.assert_called_once_with(touch.status)
kde.send.assert_not_called()
kde.start.assert_not_called()
def test_input_poll_exposes_retained_text_error_and_start_retries(self):
touch = mock.Mock(status={'state': 'ready'})
touch.send.return_value = {'state': 'ready', 'sent': False}
with mock.patch.object(server, '_touch', touch), \
mock.patch.object(server, '_kde_queue', [{'key': 'é'}]), \
mock.patch.object(server, '_kde_error', 'Text is retained; try again.'), \
mock.patch.object(server, '_kde_worker', [None]), \
mock.patch.object(server, '_start_kde_locked') as retry:
status = self.handler('/api/input', 'a')
status.do_GET()
self.assertEqual(status.send_json.call_args.args[0]['state'], 'error')
retry.assert_not_called() # polling never retries typing on its own
start = self.handler('/api/input?start=1', 'a')
start.do_GET()
retry.assert_called_once_with()
touch.send.assert_called_once_with([])
def test_get_start_validation_and_thread_registration_are_one_reservation(self):
attempts = []
fresh = server.TouchAgent()
@@ -480,10 +547,10 @@ class FinalGetStartTests(unittest.TestCase):
(None, None, '?start=1')):
for name in ('input', 'touch'):
with self.subTest(name=name, query=query, local=link is None):
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
fresh = server.TouchAgent()
handler = self.handler('/api/' + name + query, meant)
with mock.patch.object(server, 'LINK', link), mock.patch.object(server, 'LOCAL', link is None), \
mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
mock.patch.object(server, '_touch', fresh), mock.patch.object(fresh, 'start') as start:
handler.do_GET()
self.assertEqual(start.call_count, 1 if query else 0)
handler.send_error_json.assert_not_called()
+261
View File
@@ -0,0 +1,261 @@
"""PC-only Steam helper tests. All app launches and KDE calls are mocked.
No portal, interactive Wayland session, Steam installation or Frame is used.
"""
import sandbox # noqa: F401
import contextlib
import http.client
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
from unittest import mock
from http.server import ThreadingHTTPServer
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_steam_pipewire as helper
import server
ENV = {'XDG_SESSION_TYPE': 'wayland', 'WAYLAND_DISPLAY': 'wayland-0',
'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/mock/session-bus', 'XDG_CURRENT_DESKTOP': 'KDE'}
class HelperTests(unittest.TestCase):
def setUp(self):
self.stack = contextlib.ExitStack()
self.addCleanup(self.stack.close)
patches = [mock.patch.dict(os.environ, ENV, clear=True),
mock.patch.object(helper.sys, 'platform', 'linux'),
mock.patch.object(helper.os, 'geteuid', return_value=1000, create=True),
mock.patch.object(helper.Path, 'exists', return_value=False),
mock.patch.object(helper, '_launch', None),
mock.patch.object(helper, '_steam_running', return_value=False),
mock.patch.object(helper.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n),
mock.patch.object(helper.subprocess, 'Popen'),
mock.patch.object(helper.subprocess, 'run')]
started = [self.stack.enter_context(p) for p in patches]
self.running, self.which, self.spawn, self.run = started[-4:]
self.spawn.return_value.poll.return_value = None
self.spawn.return_value.wait.side_effect = subprocess.TimeoutExpired('app', 1)
self.run.return_value = subprocess.CompletedProcess([], 0, 'kcm_app-permissions - Application Permissions\n', '')
def action(self, action):
return helper.action({'action': action, 'confirmed': True})
def test_status_is_read_only(self):
self.assertTrue(helper.status()['available'])
self.spawn.assert_not_called()
self.run.assert_not_called()
self.running.assert_not_called()
def test_platform_and_local_session_gates(self):
for platform in ('win32', 'darwin'):
with self.subTest(platform=platform), mock.patch.object(helper.sys, 'platform', platform):
self.assertFalse(helper.status()['available'])
with self.assertRaises(helper.HelperError):
self.action('launch')
for change in ({'FRAME_LOCAL': '1'}, {'XDG_SESSION_TYPE': 'x11'}, {'WAYLAND_DISPLAY': ''},
{'DBUS_SESSION_BUS_ADDRESS': ''}, {'SSH_CONNECTION': 'remote'}, {'SSH_CLIENT': 'remote'}):
with self.subTest(change=change), mock.patch.dict(os.environ, change):
for action in ('launch', 'permissions'):
with self.assertRaises(helper.HelperError):
self.action(action)
self.spawn.assert_not_called()
self.run.assert_not_called()
def test_root_and_sandbox_refused(self):
with mock.patch.object(helper.os, 'geteuid', return_value=0):
self.assertIn('not root', helper.status()['reason'])
with mock.patch.object(helper.Path, 'exists', return_value=True):
with self.assertRaisesRegex(helper.HelperError, 'sandbox'):
self.action('launch')
self.spawn.assert_not_called()
def test_only_explicit_supported_actions(self):
for body in ({}, {'action': 'launch'}, {'action': 'permissions', 'confirmed': 'true'},
{'action': 'delete-all', 'confirmed': True}):
with self.subTest(body=body), self.assertRaises(helper.HelperError):
helper.action(body)
self.spawn.assert_not_called()
self.run.assert_not_called()
def test_launch_exact_command_and_duplicate_guard(self):
result = self.action('launch')
self.assertTrue(result['launched'])
self.assertIn('does not confirm', result['message'])
self.spawn.assert_called_once_with(['/usr/bin/steam', '-pipewire'], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
with self.assertRaisesRegex(helper.HelperError, 'already running or starting'):
self.action('launch')
self.assertEqual(self.spawn.call_count, 1)
self.run.assert_not_called()
def test_running_and_unknown_steam_fail_closed(self):
self.running.return_value = True
with self.assertRaisesRegex(helper.HelperError, 'Steam > Exit'):
self.action('launch')
self.running.side_effect = helper.HelperError('Cannot verify')
with self.assertRaisesRegex(helper.HelperError, 'Cannot verify'):
self.action('launch')
self.spawn.assert_not_called()
def test_missing_launcher_does_not_guess_flatpak(self):
self.which.side_effect = None
self.which.return_value = None
with self.assertRaisesRegex(helper.HelperError, 'Flatpak-only'):
self.action('launch')
self.spawn.assert_not_called()
def test_launch_error_is_not_success(self):
self.spawn.side_effect = OSError('missing')
with self.assertRaisesRegex(helper.HelperError, 'Could not start'):
self.action('launch')
def test_immediate_application_failure_is_not_success(self):
self.spawn.return_value.wait.side_effect = None
self.spawn.return_value.wait.return_value = 1
for action in ('launch', 'permissions'):
with self.subTest(action=action), mock.patch.object(helper, '_launch', None):
with self.assertRaisesRegex(helper.HelperError, 'exited with an error'):
self.action(action)
def test_permissions_opens_only_verified_exact_module_never_resets(self):
result = self.action('permissions')
self.assertTrue(result['opened'])
self.assertFalse(result['reset'])
self.assertIn('No permission was changed', result['message'])
self.run.assert_called_once_with(['/usr/bin/kcmshell6', '--list'], capture_output=True,
text=True, timeout=5, stdin=subprocess.DEVNULL)
self.assertEqual(self.spawn.call_args.args[0], ['/usr/bin/kcmshell6', 'kcm_app-permissions'])
self.running.assert_not_called()
def test_wrong_module_missing_tool_and_other_desktops_only_give_guidance(self):
for listing in ('kcm_flatpak - Flatpak Permissions', 'evil_kcm_app-permissions - nope', ''):
self.run.return_value.stdout = listing
result = self.action('permissions')
self.assertFalse(result['reset'])
self.assertFalse(result['opened'])
self.which.side_effect = None
self.which.return_value = None
self.assertFalse(self.action('permissions')['opened'])
self.which.reset_mock()
with mock.patch.dict(os.environ, {'XDG_CURRENT_DESKTOP': 'GNOME'}):
self.assertFalse(self.action('permissions')['opened'])
self.which.assert_not_called()
self.spawn.assert_not_called()
def test_module_listing_error_or_timeout_is_guidance_not_reset(self):
for error in (OSError('missing'), subprocess.TimeoutExpired('kcm', 5)):
self.run.side_effect = error
result = self.action('permissions')
self.assertFalse(result['opened'])
self.assertFalse(result['reset'])
self.assertIn('kde-config-flatpak', result['message'])
self.spawn.assert_not_called()
class ProcessTests(unittest.TestCase):
def setUp(self):
self.stack = contextlib.ExitStack()
self.addCleanup(self.stack.close)
self.tmp = self.stack.enter_context(tempfile.TemporaryDirectory())
self.proc = Path(self.tmp)
# Match the temp files' owner even when tests run as root or on Windows.
self.stack.enter_context(mock.patch.object(helper.os, 'geteuid', return_value=self.proc.stat().st_uid, create=True))
self.add('1', 'python', b'python\0server.py\0')
def add(self, pid, comm, cmdline):
p = self.proc / pid
p.mkdir()
(p / 'comm').write_text(comm)
(p / 'cmdline').write_bytes(cmdline)
def test_native_launcher_webhelper_and_flatpak_detected(self):
for comm, cmd in [('steam', b'/x/steam\0'), ('bash', b'bash\0/x/steam.sh\0'),
('steamwebhelper', b'/x/steamwebhelper\0'),
('flatpak', b'flatpak\0run\0com.valvesoftware.Steam\0')]:
with self.subTest(comm=comm):
(self.proc / '1' / 'comm').write_text(comm)
(self.proc / '1' / 'cmdline').write_bytes(cmd)
self.assertTrue(helper._steam_running(self.proc))
def test_unrelated_names_are_not_steam(self):
self.add('2', 'steamdeck-tools', b'/bin/steam-tools\0')
self.assertFalse(helper._steam_running(self.proc))
def test_other_user_steam_is_not_this_users_instance(self):
self.add('2', 'steam', b'steam\0')
with mock.patch.object(helper.os, 'geteuid', return_value=self.proc.stat().st_uid + 1):
self.assertFalse(helper._steam_running(self.proc))
def test_exited_process_is_skipped(self):
(self.proc / '2').mkdir()
self.assertFalse(helper._steam_running(self.proc))
def test_unreadable_or_missing_inventory_fails_closed(self):
with self.assertRaisesRegex(helper.HelperError, 'Cannot verify'):
helper._steam_running(self.proc / 'missing')
with mock.patch.object(helper.Path, 'read_bytes', side_effect=PermissionError('denied')):
with self.assertRaisesRegex(helper.HelperError, 'Cannot verify'):
helper._steam_running(self.proc)
class RouteTests(unittest.TestCase):
def test_headset_and_private_servers_never_call_helper(self):
for local, private in ((True, False), (False, True)):
with mock.patch.object(server, 'LOCAL', local), mock.patch.object(server, 'PRIVATE', private), \
mock.patch.object(helper, 'action') as action, mock.patch.object(helper, 'status') as status:
self.assertFalse(server.steam_pipewire_state()['available'])
with self.assertRaises(server.Failure):
server.steam_pipewire_action({'action': 'launch', 'confirmed': True})
action.assert_not_called()
status.assert_not_called()
def test_http_guards_get_read_only_and_post_bypasses_headset_work(self):
with ThreadingHTTPServer(('127.0.0.1', 0), server.Handler) as httpd:
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
def request(method, body=None, headers=None):
conn = http.client.HTTPConnection('127.0.0.1', httpd.server_port)
conn.request(method, '/api/steam-pipewire', json.dumps(body) if body else None, headers or {})
reply = conn.getresponse()
code, data = reply.status, json.loads(reply.read())
conn.close()
return code, data
with mock.patch.object(server, 'LOCAL', False), mock.patch.object(server, 'PRIVATE', False), \
mock.patch.object(server, 'working', side_effect=AssertionError('headset work')), \
mock.patch.object(server, 'ssh', side_effect=AssertionError('Frame access')), \
mock.patch.object(helper, 'action', return_value={'reset': False}) as action, \
mock.patch.object(helper, 'status', return_value={'available': True}) as status:
self.assertEqual(request('POST', {'action': 'launch'})[0], 403)
self.assertEqual(request('GET')[0], 403)
action.assert_not_called()
headers = {'X-Frame-UI': server.UI_KEY, 'X-Frame-Device': 'unrelated-device'}
self.assertEqual(request('GET', headers=headers), (200, {'available': True}))
action.assert_not_called()
status.assert_called_once()
body = {'action': 'permissions', 'confirmed': True}
self.assertEqual(request('POST', body, headers), (200, {'reset': False}))
action.assert_called_once_with(body)
finally:
httpd.shutdown()
thread.join()
class PageTests(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page handlers')
def test_production_page_handlers(self):
result = subprocess.run(['node', '--test', str(ROOT / 'tests/steam_pipewire_ui.cjs')],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == '__main__':
unittest.main()
+8
View File
@@ -150,6 +150,14 @@ class Apply(unittest.TestCase):
self.assertFalse(self.t.STALE[0])
self.assertEqual(self.said[-1][1].get("stale"), None)
def test_stale_survives_a_bare_release(self):
gs = FakeGamescope()
panel = self.t.apply(gs, {"button": "left", "down": True, "window": 99, "display": ":1"}, None)
self.assertTrue(self.t.STALE[0])
self.t.apply(gs, {"button": "left", "down": False}, panel) # the page's release names no panel
self.assertTrue(self.t.STALE[0])
self.assertEqual(self.said[-1][1].get("stale"), True)
def test_same_window_id_on_the_other_display_is_another_panel(self):
gs = FakeGamescope()
self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None)
+233
View File
@@ -0,0 +1,233 @@
"""PC-only wireless diagnosis: fixtures, HTTP boundaries and live Windows queries.
Never starts the app connector or contacts a headset, including on Windows CI.
"""
import sandbox # noqa: F401
import base64
import copy
import http.client
import json
import shutil
import subprocess
import sys
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
import frame_wireless as wireless
import server
G1 = '11111111-2222-3333-4444-555555555555'
G2 = '66666666-2222-3333-4444-555555555555'
DIRECT = 'SWD\\WIFIDIRECT\\one'
DIRECT2 = 'SWD\\WIFIDIRECT\\two'
PC = 'PCI\\radio'
def wlan(guid=G1, channel=1, band='2.4', state='connected'):
return f' Name : Wi-Fi\n GUID : {{{guid}}}\n State : {state}\n Channel : {channel}\n Band : {band} GHz\n'
def sample():
return {'sources': dict.fromkeys(('pnp', 'present', 'adapters', 'routes', 'wlan'), 'ok'),
'pnp': [{'InstanceId': DIRECT, 'FriendlyName': 'Microsoft Wi-Fi Direct Virtual Adapter', 'Status': 'OK', 'Problem': 0},
{'InstanceId': DIRECT2, 'FriendlyName': 'Microsoft Wi-Fi Direct Virtual Adapter #2', 'Status': 'Error', 'Problem': 22},
{'InstanceId': PC, 'FriendlyName': 'PC radio', 'Status': 'OK', 'Problem': 0}],
'present': [DIRECT, PC],
'adapters': [{'PnPDeviceID': DIRECT, 'InterfaceGuid': G2, 'ifIndex': 2, 'InterfaceOperationalStatus': 2, 'MediaConnectState': 2},
{'PnPDeviceID': PC, 'InterfaceGuid': '{' + G1.upper() + '}', 'ifIndex': 3,
'InterfaceOperationalStatus': 1, 'MediaConnectState': 1, 'NdisPhysicalMedium': 9, 'HardwareInterface': True}],
'routes': [{'InterfaceIndex': 3}], 'wlan': wlan()}
class Inventory(unittest.TestCase):
def test_distinct_instances_presence_disabled_and_activity_are_separate(self):
r = wireless.analyse(sample())
self.assertTrue(r['multipleWifiDirect'])
self.assertEqual(r['wifiDirectInstances'], [DIRECT, DIRECT2])
first, second, pc = r['devices']
self.assertTrue(first['present'])
self.assertEqual(first['operational'], 2)
self.assertFalse(second['present'])
self.assertEqual(second['problemCode'], 22)
self.assertIsNone(second['operational'])
self.assertTrue(pc['defaultRoute'])
self.assertTrue(pc['wifi'])
self.assertTrue(pc['hardware'])
self.assertFalse(first['defaultRoute'])
self.assertEqual(pc['channel'], 1)
self.assertIsNone(first['channel'])
self.assertEqual(r['overlap'], 'possible')
def test_counts_unique_pnp_ids_not_rows_or_suffix_order(self):
d = sample()
d['pnp'].append(dict(d['pnp'][0], InstanceId=DIRECT.upper()))
d['pnp'][1]['FriendlyName'] = 'Microsoft Wi-Fi Direct Virtual Adapter #57'
r = wireless.analyse(d)
self.assertEqual(len(r['wifiDirectInstances']), 2)
self.assertEqual(len(r['devices']), 3)
d['pnp'] = [dict(d['pnp'][0], FriendlyName='ローカライズされた名前')]
d['adapters'] = []
r = wireless.analyse(d)
self.assertTrue(r['devices'][0]['wifiDirect'])
self.assertEqual(r['devices'][0]['identityEvidence'], 'PnP enumerator')
self.assertFalse(r['multipleWifiDirect'])
def test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct(self):
d = sample()
d['pnp'] = [{'InstanceId': 'ROOT\\1', 'FriendlyName': 'Microsoft Wi-Fi Direct Virtual Adapter #4'},
{'InstanceId': 'ROOT\\2', 'FriendlyName': 'Ethernet'},
{'InstanceId': 'ROOT\\3', 'FriendlyName': 'Ethernet'}]
d['adapters'] = []
r = wireless.analyse(d)
self.assertEqual(r['wifiDirectInstances'], ['ROOT\\1'])
self.assertEqual(r['devices'][0]['identityEvidence'], 'Name only')
def test_missing_sources_are_unknown_and_adapter_fallback_is_retained(self):
for source in ('present', 'adapters', 'routes', 'wlan'):
d = sample()
d['sources'][source] = 'unavailable'
r = wireless.analyse(d)
pc = r['devices'][2]
self.assertEqual(r['state'], 'partial')
field = {'present': 'present', 'adapters': 'operational', 'routes': 'defaultRoute', 'wlan': 'channel'}[source]
self.assertIsNone(pc[field])
d = sample()
d['sources']['pnp'] = 'unavailable'
r = wireless.analyse(d)
pc = next(x for x in r['devices'] if x['id'] == PC)
self.assertTrue(pc['wifi'])
self.assertIsNone(pc['pnpStatus'])
def test_ambiguous_adapter_join_never_selects_the_first_match(self):
d = sample()
d['adapters'].append(copy.deepcopy(d['adapters'][1]))
pc = wireless.analyse(d)['devices'][2]
self.assertIsNone(pc['channel'])
self.assertIsNone(pc['operational'])
self.assertIsNone(pc['defaultRoute'])
def test_missing_interface_metadata_and_invalid_guids_stay_unknown(self):
d = sample()
d['adapters'][1].pop('NdisPhysicalMedium')
d['adapters'][1].pop('ifIndex')
d['routes'].append({})
pc = wireless.analyse(d)['devices'][2]
self.assertIsNone(pc['wifi'])
self.assertIsNone(pc['defaultRoute'])
for invalid in ('{' + G1, G1 + '}', 'not-a-guid'):
d['adapters'][1]['InterfaceGuid'] = invalid
self.assertIsNone(wireless.analyse(d)['devices'][2]['channel'])
def test_channels_require_exact_guid_and_band_is_never_guessed(self):
d = sample()
d['wlan'] = wlan(G2, 149, '5') + wlan(G1, 1, '6')
r = wireless.analyse(d)
self.assertEqual([(x['channel'], x['band']) for x in (r['devices'][0], r['devices'][2])], [(149, '5'), (1, '6')])
self.assertEqual(r['overlap'], 'unknown')
d['wlan'] = wlan().replace(' Band : 2.4 GHz\n', '')
r = wireless.analyse(d)
self.assertEqual(r['devices'][2]['channel'], 1)
self.assertIsNone(r['devices'][2]['band'])
self.assertEqual(r['overlap'], 'unknown')
for raw in (wlan(G2), wlan(state='disconnected'), wlan(channel='oops'),
wlan().replace('Channel', 'チャネル'), 'Access denied. Location consent required.'):
d['wlan'] = raw
self.assertIsNone(wireless.analyse(d)['devices'][2]['channel'])
d['wlan'] = wlan() + wlan()
self.assertIsNone(wireless.analyse(d)['devices'][2]['channel'])
def test_raw_ssid_mac_and_errors_are_not_returned(self):
d = sample()
d['wlan'] += ' SSID : private-network\n BSSID : 11:22:33:44:55:66\nsecret error text\n'
out = json.dumps(wireless.analyse(d))
for value in ('private-network', '11:22:33:44:55:66', 'secret error text'):
self.assertNotIn(value, out)
class Probe(unittest.TestCase):
def test_non_windows_starts_nothing(self):
with patch.object(wireless.frame_host, 'WINDOWS', False), patch.object(wireless.subprocess, 'run') as run:
self.assertEqual(wireless.diagnose()['state'], 'unsupported')
run.assert_not_called()
def test_static_noninteractive_local_command_and_timeout(self):
result = subprocess.CompletedProcess([], 0, json.dumps(sample()).encode(), b'')
with patch.object(wireless.frame_host, 'WINDOWS', True), patch.object(wireless.subprocess, 'run', return_value=result) as run:
r = wireless.diagnose()
self.assertEqual(r['state'], 'ok')
self.assertIn('observedAt', r)
args, kwargs = run.call_args
command = args[0]
self.assertTrue(command[0].endswith('powershell.exe'))
self.assertEqual(command[1:4], ['-NoProfile', '-NonInteractive', '-EncodedCommand'])
self.assertEqual(base64.b64decode(command[4]).decode('utf-16-le'), wireless.PROBE)
self.assertEqual(kwargs['stdin'], subprocess.DEVNULL)
self.assertEqual(kwargs['timeout'], 20)
self.assertNotIn('shell', kwargs)
def test_failure_timeout_and_invalid_output_are_explicit_and_release_lock(self):
for error in (OSError('private error'), subprocess.TimeoutExpired('powershell', 20)):
with patch.object(wireless.frame_host, 'WINDOWS', True), patch.object(wireless.subprocess, 'run', side_effect=error):
r = wireless.diagnose()
self.assertEqual(r['state'], 'unavailable')
self.assertNotIn('private error', json.dumps(r))
self.assertFalse(wireless._lock.locked())
for output in (b'not JSON', b'[]', b'{}', b'x' * 1_000_001):
with patch.object(wireless.frame_host, 'WINDOWS', True), patch.object(wireless.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, output, b'')):
self.assertEqual(wireless.diagnose()['state'], 'unavailable')
def test_concurrent_requests_do_not_spawn_more_probes(self):
wireless._lock.acquire()
try:
with patch.object(wireless.frame_host, 'WINDOWS', True), patch.object(wireless.subprocess, 'run') as run:
self.assertEqual(wireless.diagnose()['state'], 'busy')
run.assert_not_called()
finally:
wireless._lock.release()
@unittest.skipUnless(wireless.frame_host.WINDOWS, 'Live read-only Windows inventory runs on Windows CI')
def test_live_windows_inventory(self):
r = wireless.diagnose()
self.assertIn(r['state'], ('ok', 'partial'), r)
self.assertEqual(r['sources']['adapters'], 'ok', r['sources'])
self.assertTrue(r['devices'], 'Windows runner should expose at least its virtual network interface')
print('Windows wireless smoke:', r['state'], r['sources'], 'devices=', len(r['devices']))
def test_http_is_authenticated_pc_only_and_never_calls_ssh(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
with patch.object(server, 'ssh', side_effect=AssertionError('No headset access')), patch.object(server.frame_wireless, 'diagnose', return_value={'state': 'partial'}) as diagnose:
conn = http.client.HTTPConnection(*httpd.server_address)
conn.request('GET', '/api/host/wireless')
response = conn.getresponse()
self.assertEqual(response.status, 403)
response.read()
diagnose.assert_not_called()
with patch.object(server, 'LOCAL', True):
conn.request('GET', '/api/host/wireless', headers={'X-Frame-UI': server.UI_KEY})
response = conn.getresponse()
self.assertEqual(response.status, 400)
response.read()
diagnose.assert_not_called()
with patch.object(server, 'LOCAL', False):
conn.request('GET', '/api/host/wireless', headers={'X-Frame-UI': server.UI_KEY})
response = conn.getresponse()
self.assertEqual(response.status, 200)
self.assertEqual(json.loads(response.read()), {'state': 'partial'})
diagnose.assert_called_once_with()
conn.close()
finally:
httpd.shutdown()
httpd.server_close()
thread.join()
@unittest.skipUnless(shutil.which('node'), 'Node required')
def test_ui_behavior(self):
r = subprocess.run(['node', '--test', str(ROOT / 'tests/wireless.cjs')], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+287
View File
@@ -0,0 +1,287 @@
"""Synthetic sysfs fixtures and loopback HTTP; never require an adapter or a Frame."""
import sandbox # noqa: F401
import contextlib
import http.client
import io
import json
import os
import shutil
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_wireless_doctor as wd
class Parsers(unittest.TestCase):
def test_channel_without_leaking_identifiers(self):
for frequency, band, channel in [(2412, '2.4 GHz', 1), (5180, '5 GHz', 36), (5975, '6 GHz', 5)]:
text = f'Interface wlan0\n addr aa:bb:cc:dd:ee:ff\n ssid private\n channel {channel} ({frequency} MHz), width: 80 MHz'
self.assertEqual(wd.radio_info(text), dict(channel=channel, frequency_mhz=frequency, band=band))
self.assertIsNone(wd.radio_info('Interface wlan0\n type managed\n'))
self.assertEqual(wd.supported_frequencies('* 2412 MHz [1] (20.0 dBm)\n* 5975 MHz [5] (disabled)\n* 5180 MHz [36] (23.0 dBm)'), [2412, 5180])
def test_non_linux_and_headset_mode_do_not_read_or_run(self):
for kwargs in ({'system': 'darwin'}, {'system': 'win32'}, {'system': 'linux', 'local': True}):
with patch.object(wd, 'Reader', side_effect=AssertionError('must not probe')):
self.assertFalse(wd.diagnose(**kwargs)['supported'])
with patch.dict(os.environ, {'FRAME_LOCAL': '1'}), patch.object(wd, 'Reader') as reader:
self.assertFalse(wd.diagnose(system='linux')['supported'])
reader.assert_not_called()
def test_command_failure_is_not_empty_success(self):
reader = wd.Reader()
for error, status in [(FileNotFoundError(), 'missing'), (PermissionError(), 'unavailable'),
(subprocess.TimeoutExpired('iw', 3), 'timeout')]:
with patch.object(wd.subprocess, 'run', side_effect=error):
self.assertEqual(reader.command(['iw', 'dev', 'wlan0', 'info'])['status'], status)
with patch.object(wd.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, '', 'Operation not permitted')) as run:
result = reader.command(['iw', 'dev', 'wlan0', 'info'])
self.assertEqual(result['status'], 'denied')
self.assertEqual(result['text'], '')
self.assertEqual(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
self.assertEqual(run.call_args.kwargs['timeout'], 3)
self.assertNotIn('shell', run.call_args.kwargs)
def test_read_failure_and_empty_inventory_remain_unknown(self):
with tempfile.TemporaryDirectory() as root:
reader = wd.Reader(root, root, root)
report = wd.diagnose(reader=reader, system='linux', local=False)
checks = {c['id']: c for c in report['checks']}
self.assertEqual(checks['presence']['status'], 'unknown')
self.assertIn('reads', checks)
with patch.object(Path, 'open', side_effect=PermissionError()):
self.assertIsNone(reader.read(Path('/synthetic/denied')))
self.assertIn('PermissionError', reader.gaps[-1])
class Page(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for real page script checks')
def test_on_demand_selection_and_failure_recovery(self):
result = subprocess.run(['node', str(Path(__file__).parent / 'page/wireless_doctor.mjs')],
capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class FixtureReader(wd.Reader):
"""Invented USB 1234:5678; it is deliberately not a claimed Valve ID."""
def __init__(self, root):
super().__init__(root / 'sys', root / 'dev', root / 'proc')
self.commands = []
self.responses = {}
def command(self, argv):
self.commands.append(argv)
return self.responses.get(argv[0], {'status': 'missing', 'detail': argv[0] + ' is not installed', 'text': ''})
@unittest.skipUnless(sys.platform == 'linux', 'Linux sysfs symlink fixture')
class LinuxEvidence(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.r = FixtureReader(self.root)
self.usb = self.r.sysfs / 'devices/pci0000:00/usb1/1-2'
for name, content in {'idVendor': '1234', 'idProduct': '5678', 'product': 'Synthetic adapter', 'busnum': '1', 'devnum': '2'}.items():
self.write(self.usb / name, content)
bus = self.r.sysfs / 'bus/usb/devices'
bus.mkdir(parents=True)
(bus / '1-2').symlink_to(self.usb, target_is_directory=True)
(self.r.sysfs / 'class/net').mkdir(parents=True)
(self.r.sysfs / 'class/rfkill').mkdir(parents=True)
self.r.proc.mkdir(parents=True)
self.write(self.r.dev / 'bus/usb/001/002', '')
def write(self, path, text):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
def nic(self, name='wlan0', index=0, driver=True):
device = self.usb / f'1-2:1.{index}'
device.mkdir(parents=True, exist_ok=True)
net = self.r.sysfs / 'class/net' / name
net.mkdir()
(net / 'device').symlink_to(device, target_is_directory=True)
(net / 'wireless').mkdir()
self.write(net / 'operstate', 'down')
phy = self.r.sysfs / f'devices/ieee80211/phy{index}'
phy.mkdir(parents=True)
(net / 'phy80211').symlink_to(phy, target_is_directory=True)
if driver:
binding = self.r.sysfs / 'bus/usb/drivers/synthetic_driver'
binding.mkdir(parents=True, exist_ok=True)
module = self.r.sysfs / 'module/synthetic_module'
module.mkdir(parents=True, exist_ok=True)
if not (binding / 'module').exists():
(binding / 'module').symlink_to(module, target_is_directory=True)
(device / 'driver').symlink_to(binding, target_is_directory=True)
return net
def report(self, device='usb:1-2'):
return wd.diagnose(device, reader=self.r, system='linux', local=False)
def checks(self, report):
return {c['id']: c for c in report['checks']}
def test_inventory_does_not_select_or_execute_commands(self):
self.nic()
report = self.report(None)
self.assertIsNone(report['selected'])
self.assertEqual(report['usb'][0]['interfaces'], ['net:wlan0'])
self.assertEqual(report['interfaces'][0]['usb'], 'usb:1-2')
self.assertIn('Unverified', report['identity'])
self.assertEqual(self.r.commands, [])
def test_nearest_usb_ancestor_wins_over_upstream_hub(self):
hub = self.usb.parent / '1-1'
hub.mkdir()
old = self.usb
self.usb = hub / old.name
old.rename(self.usb)
bus = self.r.sysfs / 'bus/usb/devices'
(bus / '1-2').unlink()
(bus / '1-2').symlink_to(self.usb, target_is_directory=True)
for name, content in {'idVendor': '4321', 'idProduct': '8765', 'busnum': '1', 'devnum': '1'}.items():
self.write(hub / name, content)
(bus / '1-1').symlink_to(hub, target_is_directory=True)
self.nic()
report = self.report(None)
self.assertEqual(report['interfaces'][0]['usb'], 'usb:1-2')
upstream_hub = next(d for d in report['usb'] if d['port'] == '1-1')
self.assertEqual(upstream_hub['interfaces'], [])
def test_unbound_usb_is_selectable_without_guessing_driver(self):
report = self.report()
checks = self.checks(report)
self.assertEqual(checks['binding']['status'], 'unknown')
self.assertIn('No wireless interface', checks['binding']['evidence'][0])
self.assertEqual(checks['channel']['status'], 'unknown')
self.assertNotIn('iw', [a[0] for a in self.r.commands])
self.assertIn('1234:5678', json.dumps(report))
def test_selected_radio_and_all_commands_are_read_only(self):
self.nic()
for key, value in {'type': 'wlan', 'name': 'phy0', 'soft': '1', 'hard': '0'}.items():
self.write(self.r.sysfs / 'class/rfkill/rfkill0' / key, value)
for pid, name in enumerate(('NetworkManager', 'iwd', 'wpa_supplicant'), 1):
self.write(self.r.proc / str(pid) / 'comm', name)
self.r.responses = {
'udevadm': dict(status='ok', detail='udevadm completed', text='ID_NET_DRIVER=synthetic_driver\nID_SERIAL=secret_serial\nTAGS=:uaccess:\n'),
'iw': dict(status='ok', detail='iw completed', text='channel 5 (5975 MHz)\naddr aa:bb:cc:dd:ee:ff\nssid private\n* 5975 MHz [5] (23.0 dBm)'),
'nmcli': dict(status='ok', detail='nmcli completed', text='30 (disconnected)\nyes\nsynthetic_driver\n')}
report = self.report('net:wlan0')
checks = self.checks(report)
self.assertEqual(checks['binding']['status'], 'ok')
self.assertIn('module=synthetic_module', checks['binding']['evidence'][0])
self.assertEqual(checks['rfkill']['status'], 'warning')
self.assertIn('6 GHz', json.dumps(checks['channel']))
self.assertIn('coexistence alone is not a conflict', checks['managers']['suggestion'])
self.assertIn('Both iwd and wpa_supplicant', checks['managers']['suggestion'])
for secret in ('secret_serial', 'aa:bb:cc:dd:ee:ff', 'ssid private'):
self.assertNotIn(secret, json.dumps(report))
self.assertEqual(self.r.commands, [
['udevadm', 'info', '--query=property', '--path=' + str(self.r.sysfs / 'class/net/wlan0')],
['iw', 'dev', 'wlan0', 'info'], ['iw', 'phy', 'phy0', 'info'],
['nmcli', '-g', 'GENERAL.STATE,GENERAL.NM-MANAGED,GENERAL.DRIVER', 'device', 'show', 'wlan0']])
def test_unrelated_rfkill_and_missing_tools_are_not_healthy(self):
self.nic(driver=False)
for key, value in {'type': 'wlan', 'name': 'phy7', 'soft': '1', 'hard': '1'}.items():
self.write(self.r.sysfs / 'class/rfkill/rfkill7' / key, value)
checks = self.checks(self.report())
self.assertEqual(checks['binding']['status'], 'unknown')
self.assertEqual(checks['rfkill']['status'], 'unknown')
self.assertEqual(checks['permissions']['status'], 'unknown')
self.assertEqual(checks['channel']['status'], 'unknown')
self.r.responses['iw'] = dict(status='denied', detail='iw exited 1 (permission denied)', text='')
self.assertIn('permission denied', json.dumps(self.checks(self.report())['channel']))
def test_unmanaged_interface_does_not_trigger_a_network_repair(self):
self.nic()
self.r.responses['nmcli'] = dict(status='ok', detail='nmcli completed', text='10 (unmanaged)\nno\nsynthetic_driver\n')
check = self.checks(self.report())['managers']
self.assertIn('NetworkManager managed: no', check['evidence'])
self.assertIn('unmanaged alone is not a fault', check['suggestion'])
self.assertIn('Do not force management on', check['suggestion'])
def test_multiple_interfaces_require_explicit_interface(self):
self.nic()
self.nic('wlan1', 1)
checks = self.checks(self.report())
self.assertIn('interface-selection', checks)
self.assertEqual(checks['channel']['status'], 'unknown')
self.assertNotIn('iw', [a[0] for a in self.r.commands])
self.assertNotIn('interface-selection', self.checks(self.report('net:wlan1')))
def test_disappeared_or_injected_selector_is_rejected_before_commands(self):
for selector in ('net:missing', 'usb:../../etc', 'net:;sudo reboot', '', ['wlan0']):
with self.assertRaises(ValueError):
self.report(selector)
self.assertEqual(self.r.commands, [])
def test_cli_json_and_exit_contract(self):
with patch.object(wd, 'diagnose', return_value=self.report(None)), contextlib.redirect_stdout(io.StringIO()) as out:
self.assertEqual(wd.main(['--json']), 0)
self.assertEqual(json.loads(out.getvalue())['schema'], 1)
with patch.object(wd, 'diagnose', return_value={'supported': False}), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(wd.main(['--json']), 2)
class HttpBoundary(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server_module = server
# Serve the real handler only: main() would start unrelated headset workers.
cls.httpd = server.LoopbackServer(('127.0.0.1', 0), server.Handler)
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
cls.thread.join()
def request(self, path, key=True):
conn = http.client.HTTPConnection('127.0.0.1', self.httpd.server_address[1], timeout=10)
headers = {'X-Frame-UI': self.server_module.UI_KEY} if key else {}
conn.request('GET', path, headers=headers)
response = conn.getresponse()
result = response.status, response.read()
conn.close()
return result
def test_auth_and_headset_independence(self):
with patch.object(self.server_module, 'ssh', side_effect=AssertionError('no SSH')), \
patch.object(self.server_module, 'working', side_effect=AssertionError('no headset lock')), \
patch.object(wd, 'diagnose', return_value={'supported': True, 'checks': []}) as probe:
self.assertEqual(self.request('/api/wireless-doctor', key=False)[0], 403)
probe.assert_not_called()
code, data = self.request('/api/wireless-doctor?device=net%3Awlan0')
self.assertEqual(code, 200)
self.assertTrue(json.loads(data)['supported'])
probe.assert_called_once_with('net:wlan0', local=self.server_module.LOCAL)
def test_local_mode_refuses_before_collection(self):
with patch.object(self.server_module, 'LOCAL', True), patch.object(wd, 'Reader', side_effect=AssertionError('no probes')):
code, body = self.request('/api/wireless-doctor')
self.assertEqual(code, 200)
self.assertFalse(json.loads(body)['supported'])
def test_stale_selection_is_bad_request_and_page_is_packaged(self):
with patch.object(wd, 'diagnose', side_effect=ValueError('Device is no longer present')):
self.assertEqual(self.request('/api/wireless-doctor?device=net:gone')[0], 400)
code, page = self.request('/wireless-doctor')
self.assertEqual(code, 200)
self.assertIn(b'No checks have run', page)
package = json.loads((Path(__file__).resolve().parent.parent / 'app/package.json').read_text())
filters = next(r['filter'] for r in package['build']['extraResources'] if r['from'] == '../ui')
self.assertIn('*.py', filters)
self.assertIn('*.html', filters)
+49
View File
@@ -0,0 +1,49 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
function fixture() {
const f=workspaceFixture();
const start=html.indexOf('// ---- Read-only PC wireless doctor ----');
f.run(html.slice(start,html.indexOf('// ---- Read-only game storage ----',start)));
return f;
}
const sample={state:'partial',observedAt:'2026-10-24T12:00:00Z',sources:{pnp:'ok',wlan:'unavailable'},
multipleWifiDirect:true,wifiDirectInstances:['one','two'],overlap:'unknown',devices:[{
id:'SWD\\WIFIDIRECT\\<unsafe>',name:'<img onerror=alert(1)>',interface:'<script>bad</script>',
wifiDirect:true,identityEvidence:'PnP enumerator',present:false,pnpStatus:'Error',problemCode:22,
operational:null,media:null,defaultRoute:null,channel:1,band:null}]};
test('opening and revisiting the tool never starts a query',()=>{
const f=fixture();let calls=0;f.context.api=()=>calls++;
f.navigate('#wireless');assert.equal(f.activePage,'tools');
assert.equal(f.el('wireless').hidden,false);assert.equal(f.el('wireless').parentNode,f.el('toolWorkspace'));
f.navigate('#tools');f.navigate('#wireless');assert.equal(calls,0);
});
test('distinct instances, unknown states and privacy limits remain explicit and escaped',()=>{
const f=fixture();f.context.sample=sample;const out=f.run('renderWireless(sample)');
for(const phrase of ['2 distinct','do not prove','overlap is unknown','Unknown','(disabled)',
'English netsh','not the Steam dongle channel','review before sharing','&lt;img','&lt;script','&lt;unsafe&gt;']) assert.ok(out.includes(phrase),phrase);
assert.doesNotMatch(out,/<script>|<img/);
assert.doesNotMatch(out,/disable.*button|onclick=/);
f.context.sample={...sample,overlap:'possible'};
assert.match(f.run('renderWireless(sample)'),/spectrum overlap.*possible; interference is unconfirmed/);
});
test('click reads once, disables repeated clicks and renders partial inventory',async()=>{
const f=fixture();let resolve,calls=0;
f.context.api=path=>{assert.equal(path,'/api/host/wireless');calls++;return new Promise(yes=>resolve=yes);};
const request=f.el('wirelessRead').onclick();
assert.equal(f.el('wirelessRead').disabled,true);assert.match(f.el('wirelessStatus').textContent,/Reading/);
resolve(sample);await request;
assert.equal(calls,1);assert.equal(f.el('wirelessRead').disabled,false);
assert.match(f.el('wirelessResult').innerHTML,/distinct Wi-Fi Direct/);
assert.match(f.el('wirelessStatus').textContent,/Partial PC snapshot/);
});
test('unsupported, busy and failed probes leave no stale inventory and allow retry',async()=>{
for(const result of [{state:'unsupported',message:'Windows only'},{state:'busy',message:'Try again'},new Error('Denied')]) {
const f=fixture();f.el('wirelessResult').textContent='stale inventory';
f.context.api=async()=>{if(result instanceof Error) throw result; return result;};
await f.el('wirelessRead').onclick();
assert.equal(f.el('wirelessResult').textContent,'');
assert.match(f.el('wirelessStatus').textContent,/Windows only|Try again|Denied/);
assert.equal(f.el('wirelessRead').disabled,false);
}
});
+1 -1
View File
@@ -25,7 +25,7 @@ function workspaceFixture() {
const context={URL,document:{querySelectorAll:s=>s==='.page'?pages:s==='#nav a'?links:controls[s]||[],title:'',body:{addEventListener(k,fn){listeners['body-'+k]=fn;}}},
window:win,location,scroller:()=>({get scrollTop(){return win.scrollY;},scrollTo:o=>win.scrollTo(o)}),scrollToY:y=>win.scrollTo({top:y}),requestAnimationFrame:fn=>fn(),$:el,live:false,toggleLive(on){this.live=on;calls.push(['live',on]);},
loadAndroid(){calls.push(['android']);},closeDevPop(){},metaLib:{connected:false},renderMetaApps(){},
loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
loadSteamPipewire(){calls.push(['steam-pipewire']);},loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
gm:{src:'owned',dl:null,byId:new Map(),shown:0,shownBySource:{owned:0,store:0}},setGameSource(src){context.gm.src=src;},state:null,stateRead:{valid:false},online:null,devGen:0,androidApps:[],jobsRunning:0,viewGen:0,
esc:s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))};
vm.createContext(context);
+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:
+147
View File
@@ -0,0 +1,147 @@
"""Local Linux helper for Steam's own desktop sharing; never a Frame client.
No portal grants/tokens are read or written. KDE owns the manual Revoke UI.
"""
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import threading
MODULE = 'kcm_app-permissions'
GUIDANCE = (
'In KDE System Settings > Applications > Application Permissions, find the Steam entry '
'(reported as Steam Linux Runtime 3.0). Under Screen sharing, choose Manage 1 Session '
'(the count may differ), verify it belongs to Steam, then Revoke only that session. '
'Do not use Revoke All or change another app. Retry Steam desktop sharing to choose a screen. '
'If the entry is missing or ambiguous, stop; it can appear intermittently. '
'If Application Permissions is missing on Ubuntu/Kubuntu, check whether your distribution '
'provides kde-config-flatpak; the reporter needed that package and a reboot. '
'Frame Control does not install packages or change permissions.'
)
_lock = threading.Lock()
_launch = None
class HelperError(RuntimeError):
pass
def unavailable():
if sys.platform != 'linux' or os.environ.get('FRAME_LOCAL') == '1':
return 'This helper runs on your Linux PC, not the headset.'
if os.geteuid() == 0:
return 'Open Frame Control as your normal desktop user, not root.'
if Path('/.flatpak-info').exists():
return 'This sandbox cannot verify host Steam processes. Use a terminal on your PC.'
if os.environ.get('SSH_CONNECTION') or os.environ.get('SSH_CLIENT'):
return 'Open Frame Control in your local Wayland desktop session, not over SSH.'
if (os.environ.get('XDG_SESSION_TYPE', '').lower() != 'wayland'
or not os.environ.get('WAYLAND_DISPLAY') or not os.environ.get('DBUS_SESSION_BUS_ADDRESS')):
return 'A local Wayland desktop with its session bus is required. X11 and headless sessions are unsupported.'
return ''
def status():
reason = unavailable()
return {'available': not reason, 'reason': reason, 'guidance': GUIDANCE}
def _steam_running(proc=Path('/proc')):
"""Fail closed if our process inventory cannot be read. No command execution.
Include launch scripts and Flatpak Steam, not just the final Steam binary.
Other users' processes are irrelevant to this user's Steam instance.
"""
try:
entries = list(proc.iterdir())
if not any(p.name.isdecimal() for p in entries):
raise OSError('empty process inventory')
for p in entries:
if not p.name.isdecimal():
continue
try:
if p.stat().st_uid != os.geteuid():
continue
comm = (p / 'comm').read_text().strip().lower()
args = (p / 'cmdline').read_bytes().decode('utf-8', 'replace').split('\0')
except FileNotFoundError:
continue # a process exited while scanning
names = {Path(a).name.lower() for a in args if a}
if (comm in {'steam', 'steamwebhelper', 'steam.sh'}
or names.intersection({'steam', 'steam.sh', 'steamwebhelper', 'com.valvesoftware.steam'})):
return True
return False
except OSError as exc:
raise HelperError('Cannot verify whether Steam is running. Exit Steam and launch '
'steam -pipewire yourself from a local terminal.') from exc
def _spawn(argv):
try:
return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
except OSError as exc:
raise HelperError('Could not start the local application. Check it is installed and opens '
'from your desktop. No permission was changed.') from exc
def _check_start(child):
try:
code = child.wait(timeout=1)
except subprocess.TimeoutExpired:
return
if code:
raise HelperError('The local application exited with an error. Try opening it from your '
'desktop to see its error. No permission was changed.')
def action(body):
global _launch
kind = body.get('action')
if kind not in ('launch', 'permissions'):
raise HelperError('Unknown Steam helper action.')
if body.get('confirmed') is not True:
raise HelperError('Use the Steam helper button to request this action explicitly.')
reason = unavailable()
if reason:
raise HelperError(reason)
with _lock:
if kind == 'launch':
if (_launch is not None and _launch.poll() is None) or _steam_running():
raise HelperError('Steam is already running or starting. Exit Steam using Steam > Exit '
'(after saving games), then click Launch Steam with PipeWire again. '
'Frame Control will not stop it or change an existing session.')
steam = shutil.which('steam')
if not steam:
raise HelperError('No steam launcher was found on PATH. For a Flatpak-only install, '
'exit Steam and run flatpak run com.valvesoftware.Steam -pipewire '
'yourself from a local terminal. Nothing was launched.')
_launch = _spawn([steam, '-pipewire'])
_check_start(_launch)
return {'launched': True, 'message': 'Requested Steam with -pipewire for this launch only. '
'Use Steam\'s desktop-in-headset flow; Steam and your desktop portal control the picker. '
'This does not confirm screen sharing started.'}
# There is no portable verified mapping from Steam's runtime display name
# to a portal restore token. Never guess an app ID or delete a grant.
desktop = os.environ.get('XDG_CURRENT_DESKTOP', '').upper().split(':')
kcm = shutil.which('kcmshell6') if 'KDE' in desktop else None
if not kcm:
return {'reset': False, 'opened': False, 'message': 'No permission was changed. '
'KDE Application Permissions could not be opened automatically. ' + GUIDANCE}
try:
listing = subprocess.run([kcm, '--list'], capture_output=True, text=True, timeout=5,
stdin=subprocess.DEVNULL)
except (OSError, subprocess.TimeoutExpired):
listing = None
if (listing is None or listing.returncode
or not re.search(r'^\s*' + re.escape(MODULE) + r'\s+-', listing.stdout, re.M)):
return {'reset': False, 'opened': False, 'message': 'No permission was changed. '
'The KDE Application Permissions module is unavailable. ' + GUIDANCE}
child = _spawn([kcm, MODULE])
_check_start(child)
return {'reset': False, 'opened': True, 'message': 'Requested KDE Application Permissions. '
'No permission was changed by Frame Control. ' + GUIDANCE}
+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:
+5 -2
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():
@@ -511,8 +512,10 @@ def apply(gs, event, panel):
say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs
STALE[0] = True
return panel
if STALE[0] and not stale:
release = event.get("down") is False and ("button" in event or "key" in event)
if STALE[0] and not stale and not (release and "window" not in event):
# Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing.
# A bare release doesn't: the page leaves the panel off releases, so it says nothing about focus.
STALE[0] = False
say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display"))
if "fx" in event and panel and panel.get("window"):
+188
View File
@@ -0,0 +1,188 @@
"""On-demand, read-only Windows PC wireless inventory. Never contacts a headset.
PnP identity is evidence, not permission to disable a device. Multiple Wi-Fi
Direct instances are normal on some PCs; neither their suffix nor link state
identifies an unused instance or proves which radio belongs to Steam.
"""
import base64
import json
import os
import re
import subprocess
import threading
from datetime import datetime, timezone
import frame_host
TIMEOUT = 20
_lock = threading.Lock()
# Static queries only. No user values, execution-policy override, elevation,
# scans, network traffic, or settings changes. Do not return raw netsh output:
# it can contain SSIDs, MACs and location-permission instructions.
PROBE = r"""
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
$r = @{pnp=@(); present=@(); adapters=@(); routes=@(); wlan=''; sources=@{}}
try {
$r.pnp = @(Get-PnpDevice -Class Net | Select-Object InstanceId,FriendlyName,Status,Problem)
$r.sources.pnp = 'ok'
} catch { $r.sources.pnp = 'unavailable' }
try {
$r.present = @(Get-PnpDevice -Class Net -PresentOnly | ForEach-Object { $_.InstanceId })
$r.sources.present = 'ok'
} catch { $r.sources.present = 'unavailable' }
try {
$r.adapters = @(Get-NetAdapter -IncludeHidden | Select-Object PnPDeviceID,Name,InterfaceDescription,
InterfaceGuid,ifIndex,InterfaceOperationalStatus,MediaConnectState,NdisPhysicalMedium,HardwareInterface)
$r.sources.adapters = 'ok'
} catch { $r.sources.adapters = 'unavailable' }
try {
$r.routes = @(Get-NetRoute | Where-Object {
$_.DestinationPrefix -eq '0.0.0.0/0' -or $_.DestinationPrefix -eq '::/0'
} | Select-Object InterfaceIndex)
$r.sources.routes = 'ok'
} catch { $r.sources.routes = 'unavailable' }
try {
$r.wlan = (& "$env:SystemRoot\System32\netsh.exe" wlan show interfaces | Out-String)
if ($LASTEXITCODE -eq 0) { $r.sources.wlan = 'ok' } else { $r.sources.wlan = 'unavailable' }
} catch { $r.sources.wlan = 'unavailable' }
ConvertTo-Json -InputObject $r -Depth 5 -Compress
"""
GUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
def guid(value):
value = str(value or '').strip()
if value.startswith('{') and value.endswith('}'):
value = value[1:-1]
return value.lower() if GUID.fullmatch(value) else None
def number(value):
return value if type(value) is int else None
def text(value):
return value[:2048] if isinstance(value, str) else ''
def rows(value):
return [r for r in value if isinstance(r, dict)] if isinstance(value, list) else []
def parse_wlan(raw):
"""English netsh labels only; localised/denied/missing fields stay unknown.
Bind each block by exact GUID, never by friendly name or output order.
Channels 1–14 also exist in 6 GHz: never infer band from channel alone.
"""
blocks, block = [], {}
for line in raw.splitlines() if isinstance(raw, str) else []:
m = re.match(r'^\s*(Name|GUID|State|Channel|Band)\s*:\s*(.*?)\s*$', line)
if not m:
continue
key, value = m.groups()
if key == 'Name':
if block:
blocks.append(block)
block = {}
block[key] = value
if block:
blocks.append(block)
out = {}
for b in blocks:
identity = guid(b.get('GUID'))
if not identity or identity in out:
# Repeated identities cannot be joined reliably.
if identity:
out[identity] = {'channel': None, 'band': None}
continue
channel = b.get('Channel', '')
channel = int(channel) if re.fullmatch(r'[0-9]{1,3}', channel) else None
band = re.fullmatch(r'(2\.4|5|6)\s*GHz', b.get('Band', ''), re.I)
connected = b.get('State') == 'connected'
out[identity] = {'channel': channel if connected and channel and 1 <= channel <= 233 else None,
'band': band.group(1) if connected and band else None}
return out
def analyse(data):
if not isinstance(data, dict) or not isinstance(data.get('sources'), dict):
raise ValueError('Invalid inventory')
sources = {k: 'ok' if data['sources'].get(k) == 'ok' else 'unavailable'
for k in ('pnp', 'present', 'adapters', 'routes', 'wlan')}
present = {p.casefold() for p in (data.get('present') or []) if isinstance(p, str)}
adapters = {}
for a in rows(data.get('adapters')) if sources['adapters'] == 'ok' else []:
key = text(a.get('PnPDeviceID')).casefold()
if key:
adapters.setdefault(key, []).append(a)
routes = {r['InterfaceIndex'] for r in rows(data.get('routes'))
if number(r.get('InterfaceIndex')) is not None} if sources['routes'] == 'ok' else set()
wlan = parse_wlan(data.get('wlan', '')) if sources['wlan'] == 'ok' else {}
devices, seen = [], set()
inventory = rows(data.get('pnp')) if sources['pnp'] == 'ok' else []
pnp_ids = {text(p.get('InstanceId')).casefold() for p in inventory}
# Retain PC interfaces even when PnP inventory is incomplete/unavailable.
inventory += [{'InstanceId': matches[0]['PnPDeviceID'],
'FriendlyName': matches[0].get('InterfaceDescription')}
for key, matches in adapters.items() if key not in pnp_ids]
for p in inventory:
identity = text(p.get('InstanceId'))
if not identity or identity.casefold() in seen:
continue
seen.add(identity.casefold())
matches = adapters.get(identity.casefold(), [])
a = matches[0] if len(matches) == 1 else {}
name = text(p.get('FriendlyName')) or text(a.get('InterfaceDescription')) or 'Unnamed network device'
direct_id = identity.upper().startswith('SWD\\WIFIDIRECT\\')
direct_name = re.sub(r'\s*#\d+$', '', name).casefold() == 'microsoft wi-fi direct virtual adapter'
radio = wlan.get(guid(a.get('InterfaceGuid')), {})
devices.append({
'id': identity, 'name': name, 'interface': text(a.get('Name')),
'wifiDirect': direct_id or direct_name,
'identityEvidence': 'PnP enumerator' if direct_id else 'Name only' if direct_name else None,
'wifi': number(a.get('NdisPhysicalMedium')) in (1, 9) if number(a.get('NdisPhysicalMedium')) is not None else None,
'hardware': a.get('HardwareInterface') if type(a.get('HardwareInterface')) is bool else None,
'present': identity.casefold() in present if sources['present'] == 'ok' else None,
'pnpStatus': text(p.get('Status')) or None,
'problemCode': number(p.get('Problem')),
'operational': number(a.get('InterfaceOperationalStatus')),
'media': number(a.get('MediaConnectState')),
'defaultRoute': a['ifIndex'] in routes if number(a.get('ifIndex')) is not None and sources['routes'] == 'ok' else None,
'channel': radio.get('channel'), 'band': radio.get('band'),
})
direct = [d['id'] for d in devices if d['wifiDirect']]
return {'sources': sources, 'devices': devices, 'wifiDirectInstances': direct,
'multipleWifiDirect': len(direct) > 1,
'overlap': 'possible' if any(d['band'] == '2.4' for d in devices) else 'unknown',
'state': 'ok' if all(s == 'ok' for s in sources.values()) else 'partial'}
def diagnose():
"""No cached inventory, automatic retries, UAC or SSH. One bounded probe."""
if not frame_host.WINDOWS:
return {'state': 'unsupported', 'message': 'Wireless Adapter doctor is available on a Windows PC.'}
if not _lock.acquire(blocking=False):
return {'state': 'busy', 'message': 'A PC wireless inventory is already running. Try again shortly.'}
try:
executable = os.path.join(os.environ.get('SystemRoot', r'C:\Windows'),
'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
command = base64.b64encode(PROBE.encode('utf-16-le')).decode('ascii')
result = subprocess.run([executable, '-NoProfile', '-NonInteractive', '-EncodedCommand', command],
stdin=subprocess.DEVNULL, capture_output=True, timeout=TIMEOUT,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
if result.returncode or len(result.stdout) > 1_000_000:
raise ValueError('Probe failed')
report = analyse(json.loads(result.stdout.decode('utf-8-sig')))
report['observedAt'] = datetime.now(timezone.utc).isoformat()
return report
except subprocess.TimeoutExpired:
return {'state': 'unavailable', 'message': 'Windows wireless inventory timed out. No settings were changed.'}
except (OSError, ValueError, TypeError):
return {'state': 'unavailable', 'message': 'Windows wireless inventory could not be read. No settings were changed.'}
finally:
_lock.release()
+269
View File
@@ -0,0 +1,269 @@
"""Read-only Linux PC Wireless Adapter doctor. No SSH, scans or repairs.
Run on the PC: python3 ui/frame_wireless_doctor.py [--device net:wlan0] [--json]
USB IDs are observations, never a claim that a device is Valve's adapter.
"""
import argparse
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import platform
import re
import stat
import subprocess
import sys
class Reader:
"""Bounded local reads; failures remain evidence rather than healthy defaults."""
def __init__(self, sysfs=Path('/sys'), dev=Path('/dev'), proc=Path('/proc')):
self.sysfs, self.dev, self.proc = Path(sysfs), Path(dev), Path(proc)
self.gaps = []
def read(self, path):
try:
with path.open(errors='replace') as source:
return source.read(4096).strip()
except OSError as exc:
self.gaps.append(f'{path}: {type(exc).__name__}')
return None
def entries(self, path):
try:
return sorted(path.iterdir())
except OSError as exc:
self.gaps.append(f'{path}: {type(exc).__name__}')
return []
def link(self, path):
try:
return path.resolve(strict=True).name
except OSError as exc:
if not isinstance(exc, FileNotFoundError):
self.gaps.append(f'{path}: {type(exc).__name__}')
return None
def command(self, argv):
try:
result = subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True,
text=True, errors='replace', timeout=3,
env={**os.environ, 'LC_ALL': 'C'})
except FileNotFoundError:
return {'status': 'missing', 'detail': f'{argv[0]} is not installed', 'text': ''}
except subprocess.TimeoutExpired:
return {'status': 'timeout', 'detail': f'{argv[0]} timed out', 'text': ''}
except OSError as exc:
return {'status': 'unavailable', 'detail': f'{argv[0]}: {type(exc).__name__}', 'text': ''}
if result.returncode:
# Do not export command output that may contain SSIDs, MACs or other identifiers.
denied = bool(re.search(r'permission|not permitted|access denied', result.stderr, re.I))
return {'status': 'denied' if denied else 'unavailable',
'detail': f'{argv[0]} exited {result.returncode}' + (' (permission denied)' if denied else ''),
'text': ''}
return {'status': 'ok', 'detail': f'{argv[0]} completed', 'text': result.stdout[:131072]}
def inventory(reader):
usb = []
for path in reader.entries(reader.sysfs / 'bus/usb/devices'):
# USB interface entries have no idVendor; inspect them under their parent below.
if ':' in path.name or not (path / 'idVendor').exists():
continue
vid, pid = reader.read(path / 'idVendor'), reader.read(path / 'idProduct')
bindings = []
for child in reader.entries(path):
if ':' in child.name:
bindings.append({'interface': child.name, 'driver': reader.link(child / 'driver'),
'module': reader.link(child / 'driver/module')})
usb.append({'id': 'usb:' + path.name, 'port': path.name, 'vid': vid, 'pid': pid,
'product': reader.read(path / 'product') if (path / 'product').exists() else None,
'bus': reader.read(path / 'busnum'), 'number': reader.read(path / 'devnum'),
'bindings': bindings, 'interfaces': []})
interfaces = []
for path in reader.entries(reader.sysfs / 'class/net'):
if not ((path / 'wireless').exists() or (path / 'phy80211').exists()):
continue
try:
device_path = (path / 'device').resolve(strict=True)
ancestors = [device_path, *device_path.parents]
except OSError:
ancestors = []
# Nearest USB ancestor: a downstream adapter must not be attributed to its hub.
by_port = {d['port']: d for d in usb}
parent = next((by_port[p.name] for p in ancestors if p.name in by_port), None)
item = {'id': 'net:' + path.name, 'name': path.name,
'usb': parent['id'] if parent else None, 'phy': reader.link(path / 'phy80211'),
'driver': reader.link(path / 'device/driver'),
'module': reader.link(path / 'device/driver/module'),
'state': reader.read(path / 'operstate')}
interfaces.append(item)
if parent:
parent['interfaces'].append(item['id'])
return usb, interfaces
def radio_info(text):
"""Parse only channel facts; omit interface MAC, SSID and link identifiers."""
match = re.search(r'channel (\d+) \((\d+) MHz\)', text)
if not match:
return None
frequency = int(match[2])
band = '2.4 GHz' if 2400 <= frequency < 2500 else '5 GHz' if 4900 <= frequency < 5925 else '6 GHz' if 5925 <= frequency <= 7125 else 'other'
return {'channel': int(match[1]), 'frequency_mhz': frequency, 'band': band}
def supported_frequencies(text):
return sorted(set(int(n) for n in re.findall(r'\* (\d+) MHz \[\d+\](?![^\n]*disabled)', text)))
def diagnose(device=None, *, reader=None, system=None, local=None):
"""Collect only on explicit request. The server calls this outside headset work."""
report = {'schema': 1, 'captured_at': datetime.now(timezone.utc).isoformat(), 'scope': 'Linux PC only; read-only', 'supported': False,
'identity': 'Unverified: select hardware by its observed USB port or network interface. '
'No trusted Steam Frame Wireless Adapter VID/PID is assumed.',
'selected': device, 'usb': [], 'interfaces': [], 'checks': []}
def check(key, status, title, evidence, suggestion):
report['checks'].append(dict(id=key, status=status, title=title,
evidence=evidence, suggestion=suggestion))
if (system or sys.platform) != 'linux' or (os.environ.get('FRAME_LOCAL') == '1' if local is None else local):
check('platform', 'unknown', 'Linux PC required', [],
'Run this doctor on the Linux PC, not on the Frame or the phone server.')
return report
report.update(supported=True, kernel=platform.release())
r = reader or Reader()
usb, interfaces = inventory(r)
report.update(usb=usb, interfaces=interfaces)
items = usb + interfaces
selected = next((d for d in items if d['id'] == device), None)
if device is not None and selected is None:
raise ValueError('Device is no longer present. Refresh and select an observed device.')
check('presence', 'ok' if items else 'unknown', 'USB and wireless interface presence',
[f'{len(usb)} USB devices; {len(interfaces)} wireless interfaces visible in sysfs'],
'Select the intended adapter. USB presence alone does not establish wireless support.' if items else
'No candidates are visible. Check the PC USB connection and whether a VM/container hides hardware; this does not prove the adapter is absent.')
if selected is None:
check('selection', 'unknown', 'Hardware identity needs selection', [],
'Compare the inventory before and after reconnecting the adapter when safe. Select its USB port even if no network interface exists.')
if r.gaps:
check('reads', 'unknown', 'Some local evidence is unavailable', r.gaps,
'Review unavailable paths locally; do not infer healthy hardware from missing evidence.')
return report
correlated = [d for d in interfaces if d['usb'] == device]
nic = selected if device.startswith('net:') else correlated[0] if len(correlated) == 1 else None
if len(correlated) > 1:
check('interface-selection', 'unknown', 'More than one wireless interface',
[d['id'] for d in correlated], 'Select a network interface for per-interface radio and ownership checks.')
parent = selected if device.startswith('usb:') else next((d for d in usb if d['id'] == selected['usb']), None)
names = [d['name'] for d in interfaces if parent and d['usb'] == parent['id']]
check('binding', 'ok' if nic and nic['driver'] else 'unknown', 'Driver and module binding',
([f"{nic['name']}: driver={nic['driver'] or 'unavailable'}, module={nic['module'] or 'unavailable (possibly built in)'}, state={nic['state'] or 'unknown'}"] if nic else ['Multiple wireless interfaces are correlated; select one explicitly.' if len(correlated) > 1 else 'No wireless interface is correlated with this USB device.']) +
([f"USB {parent['port']} observed ID {parent['vid']}:{parent['pid']}; wireless interfaces: {', '.join(names) or 'none'}"] +
[f"{b['interface']}: driver={b['driver'] or 'unbound'}, module={b['module'] or 'unavailable'}" for b in parent['bindings']] if parent else []),
'A bound driver does not prove streaming support. A missing module link can mean a built-in driver.' if nic and nic['driver'] else
'Check this observed device ID against your distribution/kernel and vendor documentation. No driver package or cause can be inferred; do not install a guessed driver or disable Secure Boot.')
path = r.sysfs / ('class/net/' + nic['name'] if nic else 'bus/usb/devices/' + parent['port'])
udev = r.command(['udevadm', 'info', '--query=property', '--path=' + str(path)])
properties = [line for line in udev['text'].splitlines() if line.split('=', 1)[0] in
('ID_NET_DRIVER', 'ID_USB_DRIVER', 'ID_VENDOR_ID', 'ID_MODEL_ID', 'TAGS', 'CURRENT_TAGS')]
access = []
if parent and (parent['bus'] or '').isdigit() and (parent['number'] or '').isdigit():
node = r.dev / f"bus/usb/{int(parent['bus']):03d}/{int(parent['number']):03d}"
try:
st = node.stat()
access.append(f'{node}: mode={stat.S_IMODE(st.st_mode):04o}, uid={st.st_uid}, gid={st.st_gid}, '
f'user read={os.access(node, os.R_OK)}, write={os.access(node, os.W_OK)}')
except OSError as exc:
access.append(f'{node}: {type(exc).__name__}')
check('permissions', 'info' if udev['status'] == 'ok' else 'unknown', 'Permissions and udev evidence',
[udev['detail'], *properties, *access],
'USB node access is only an observation; kernel networking may not require direct USB access. '
'If Steam reports access denied for this exact device, compare the vendor/distribution udev guidance. Do not use broad chmod rules or run Steam as root.')
blocks = []
for entry in r.entries(r.sysfs / 'class/rfkill'):
if r.read(entry / 'type') != 'wlan':
continue
phy = r.read(entry / 'name')
blocks.append({'name': phy, 'soft': r.read(entry / 'soft'), 'hard': r.read(entry / 'hard'),
'selected_phy': bool(nic and nic['phy'] and phy == nic['phy'])})
relevant = [b for b in blocks if b['selected_phy']]
blocked = any(b[k] == '1' for b in relevant for k in ('soft', 'hard'))
complete = relevant and all(b[k] in ('0', '1') for b in relevant for k in ('soft', 'hard'))
check('rfkill', 'warning' if blocked else 'ok' if complete else 'unknown', 'Radio blocks',
[f"{b['name']}: soft={b['soft']}, hard={b['hard']}, selected PHY={b['selected_phy']}" for b in blocks] or ['No readable WLAN rfkill entries.'],
'The selected PHY is blocked. Review the PC radio switch/airplane mode; a hard block may need a hardware switch.' if blocked else
'Only a matching PHY establishes this adapter’s rfkill state. Unrelated or unavailable entries do not rule out a radio block.')
if nic:
iw = r.command(['iw', 'dev', nic['name'], 'info'])
radio = radio_info(iw['text'])
frequencies = []
phy = nic['phy']
if phy and re.fullmatch(r'phy\d+', phy):
capabilities = r.command(['iw', 'phy', phy, 'info'])
frequencies = supported_frequencies(capabilities['text'])
else:
capabilities = {'detail': 'No PHY identifier available'}
check('channel', 'info' if radio else 'unknown', 'Current channel and band',
[iw['detail'], json.dumps(radio) if radio else 'No current channel reported.',
capabilities['detail'], f'Enabled frequencies reported by iw (MHz): {frequencies or "unavailable"}'],
'This is local radio evidence, not proof of the Steam streaming link or supported regions. '
'If no channel is reported the radio may be idle. Do not force a band, channel or regulatory country.')
nm = r.command(['nmcli', '-g', 'GENERAL.STATE,GENERAL.NM-MANAGED,GENERAL.DRIVER', 'device', 'show', nic['name']])
# These fields contain no SSIDs, addresses or credentials.
fields = nm['text'].strip().splitlines()[:3] if nm['status'] == 'ok' else []
nm_evidence = [f'{label}: {value}' for label, value in zip(
('NetworkManager state', 'NetworkManager managed', 'NetworkManager driver'), fields)]
unmanaged = len(fields) > 1 and fields[1].lower() == 'no'
else:
nm = {'detail': 'No correlated wireless interface; per-interface ownership unavailable'}
nm_evidence = []
unmanaged = False
check('channel', 'unknown', 'Current channel and band', [], 'Select one correlated network interface to check channel/band.' if len(correlated) > 1 else 'No correlated wireless interface exists; channel/band cannot be determined.')
processes = set()
proc_entries = r.entries(r.proc)
for entry in proc_entries:
if entry.name.isdigit():
comm = r.read(entry / 'comm')
if comm in ('NetworkManager', 'iwd', 'wpa_supplicant'):
processes.add(comm)
check('managers', 'info' if proc_entries else 'unknown', 'Network manager coexistence',
[f'Visible processes: {", ".join(sorted(processes)) or "none"}', nm['detail'], *nm_evidence],
'NetworkManager commonly uses iwd or wpa_supplicant as its Wi-Fi backend; coexistence alone is not a conflict. '
+ ('Both iwd and wpa_supplicant are visible. Check locally which service owns the selected interface before attributing a conflict; do not stop either automatically.' if {'iwd', 'wpa_supplicant'} <= processes else
'Per-interface ownership by iwd/wpa_supplicant is unverified. Process absence in a restricted /proc view is inconclusive.')
+ (' NetworkManager reports this interface unmanaged. Steam or another service may own it; unmanaged alone is not a fault. Do not force management on.' if unmanaged else ''))
if r.gaps:
check('reads', 'unknown', 'Some local evidence is unavailable', r.gaps[:40],
'Review unavailable paths locally. Permission failures or disappearing devices are not evidence of an adapter fault; do not elevate this doctor.')
check('limits', 'info', 'What this report cannot establish', [],
'No physical adapter or Frame link test runs here. Firmware, USB stability under load, Steam support and the reported Ubuntu/Kubuntu 26.04 cause remain unverified. Review the report before sharing it.')
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('--device', help='observed selector, e.g. net:wlan0 or usb:1-2; omitted lists candidates')
parser.add_argument('--json', action='store_true', help='print structured local evidence; review before sharing')
args = parser.parse_args(argv)
try:
data = diagnose(args.device)
except ValueError as exc:
parser.error(str(exc))
if args.json:
print(json.dumps(data, indent=2, ensure_ascii=False))
else:
print(data['scope'] + '\n' + data['identity'])
for d in data['usb']:
print(f" {d['id']} {d['vid']}:{d['pid']} {d['product'] or 'unnamed USB device'}")
for d in data['interfaces']:
print(f" {d['id']} USB={d['usb'] or 'not correlated'} driver={d['driver'] or 'unknown'}")
for c in data['checks']:
print(f"\n[{c['status'].upper()}] {c['title']}")
for line in c['evidence']:
print(' ' + line)
print(' ' + c['suggestion'])
return 0 if data['supported'] else 2
if __name__ == '__main__':
raise SystemExit(main())
+136 -20
View File
@@ -1270,6 +1270,7 @@
<div class="sub sync-note" id="clipsSyncNote" hidden></div>
<div class="hint">Clips and background recordings from Steam's game recording in the headset. Export joins one into a single <code>.mp4</code> in <code>~/Videos/SteamFrame</code>, however long it is, without re-encoding it. Recordings you make with <b>Record</b> go there too.</div>
</section>
</div>
<div class="page" data-page="games">
@@ -1430,8 +1431,24 @@
<div class="page" data-page="tools">
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div class="grid-3" id="toolWorkspace">
<section class="panel" id="wireless" hidden>
<h2>Wireless Adapter doctor</h2>
<p class="sub">Read-only snapshot of this Windows PC. Works without a headset connection. Nothing on the Frame is read or changed.</p>
<button class="action" id="wirelessRead">Read PC adapters</button>
<p id="wirelessStatus" role="status" aria-live="polite">Choose Read PC adapters to start. No automatic polling.</p>
<div id="wirelessResult"></div>
<div class="card" style="margin-top:16px"><h3>Before changing an adapter</h3>
<p>Multiple Microsoft Wi-Fi Direct Virtual Adapter instances can be normal. A number such as #2, a disconnected link or a non-present device does not establish which instance is extra or safe to disable. This tool cannot reliably identify the Steam dongle's primary or active instance, so it offers no disable action.</p>
<p>Disabling the wrong instance can interrupt Steam streaming, PC Wi-Fi, Mobile Hotspot or wireless displays. Keep the exact PnP instance IDs and compare snapshots before and after a drop with Steam Support. Do not disable an instance based on this list alone.</p>
</div>
<div class="card" style="margin-top:16px"><h3>Check possible 2.4 GHz interference</h3>
<p>A Razer or other 2.4 GHz audio dongle can share spectrum with Wi-Fi. This inventory cannot measure interference, detect the audio dongle's channel or prove what caused a dropout or PC shutdown.</p>
<p>Try wired headphones briefly and compare. Space USB radio dongles apart with an extension lead; if your PC uses 2.4 GHz Wi-Fi, compare Ethernet or an existing 5/6 GHz network. Change one thing at a time and keep notes. No network or security settings are changed here.</p>
</div>
</section>
<section class="panel" id="powerReport">
<h2>Power &amp; heat report</h2>
<p class="sub">Observe battery, CPU, temperatures and available sensors without changing settings. One observation at a time, every five seconds, for at most five minutes.</p>
@@ -1446,6 +1463,23 @@
<p class="hint">Open the saved HTML in your browser; it works offline and includes JSON/CSV downloads. Unknown readings are never treated as zero. Rails overlap; do not add them. Sensor accuracy is unverified. Reports include sensor paths and OS builds: review before sharing.</p>
</section>
<section class="panel" id="steamPipewire" hidden>
<h2>Steam desktop sharing <span class="tag">Linux PC only</span></h2>
<p>Help for Steam's own <code>steam -pipewire</code> desktop-in-headset flow on Wayland.
Frame Control's Linux “PC in the headset” feature remains disabled. This helper needs no headset connection.</p>
<p>Save your games and exit Steam using <b>Steam → Exit</b> before launching.
This launch does not save a setting or restart an existing Steam session.</p>
<div class="row"><button id="steamPipewireLaunch" disabled>Launch Steam with PipeWire</button>
<button id="steamPipewireReset" disabled>Reset screen-share choice…</button></div>
<p class="sub">Reset opens KDE's Application Permissions so you can revoke a verified Steam screen-sharing
session yourself. Frame Control does not reset permissions automatically. Other desktops get guidance only.</p>
<p id="steamPipewireGuidance">KDE System Settings → Applications → Application Permissions → Steam Linux Runtime 3.0
→ Screen sharing → Manage 1 Session → Revoke. Verify the session belongs to Steam. Never use Revoke All.
If the entry is missing or ambiguous, stop.</p>
<p id="steamPipewireStatus" role="status" aria-live="polite"></p>
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/steam-pipewire.md" target="_blank" rel="noopener">Setup, missing KDE module and limitations →</a></p>
</section>
<section class="panel" id="vrTools">
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
<div class="stats" id="vrMetrics"></div>
@@ -1498,8 +1532,7 @@
<div class="shelf-head"><h2>Keyboard and trackpad</h2><span class="sub" id="padState"></span><span class="spacer"></span>
<button class="small" id="padOn">Turn on</button>
</div>
<p class="sub" id="padIntro">Use this computer's keyboard and mouse in apps on the Frame, such as Chromium or the
desktop's Linux apps.</p>
<p class="sub" id="padIntro">Use this computer's keyboard and mouse in the panel you're using in the headset.</p>
<div id="padBody" hidden>
<div class="pad-area" id="padArea" tabindex="0" role="application" aria-label="Trackpad for the Frame">
<div class="pad-hint" id="padHint"></div>
@@ -1522,10 +1555,9 @@
<button data-pad-key="12" title="Enter">Enter</button>
</span>
</div>
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
else to install. <a href="#" data-about>Licences</a></div>
<div class="hint">Types and points in whichever panel has focus in the headset, through Valve's own input path: nothing to
install and no internet needed. Accents and emoji go through KDE Connect, which comes with Frame Control and starts
the first time you type one (it copies Valve's build for the Frame, 3.6 MB, into your home folder there). <a href="#" data-about>Licences</a></div>
</div>
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
@@ -1810,7 +1842,7 @@
</div>
<div class="page" data-page="settings">
<div class="workspace-heading"><div><a href="#settings" id="settingsBack" class="workspace-link" hidden>← All settings</a><h1 id="settingsTitle">Settings</h1><p id="settingsSubtitle">Connection, headset controls and Frame Control preferences.</p></div></div>
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices &amp; connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display &amp; reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#autoSync" id="syncLink"><span>Copy to this computer</span><small>New screenshots and clips, automatically</small></a><a href="#privacy"><span>Privacy &amp; updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote &amp; power</span><small>Remote access, sleep and restart</small></a></div>
<div id="settingsDirectory" class="quick-routes"><a id="wirelessDoctorSettings" href="/wireless-doctor" hidden><span>Linux Wireless Adapter doctor</span><small>Read-only checks on this PC</small></a><a href="#devices"><span>Devices &amp; connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display &amp; reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#autoSync" id="syncLink"><span>Copy to this computer</span><small>New screenshots and clips, automatically</small></a><a href="#privacy"><span>Privacy &amp; updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote &amp; power</span><small>Remote access, sleep and restart</small></a></div>
<div id="settingsWorkspace" hidden></div>
</div>
@@ -1827,6 +1859,7 @@
<div class="list" id="devList"><div class="sub">Loading…</div></div>
<div class="hint">Frame Control talks to one headset at a time. Each can be reached at several addresses;
it tries them all at once and uses the best one that answers on the network you're on.</div>
<p id="wirelessDoctorLink" hidden><a href="/wireless-doctor">Linux Wireless Adapter doctor ↗</a><br><span class="sub">Read-only PC checks; works without a connected Frame.</span></p>
<h3 style="margin-top:22px">This computer's network</h3>
<div id="netNow" class="sub">Checking…</div>
<div class="row" style="margin-top:8px; flex-wrap:nowrap">
@@ -2135,6 +2168,11 @@ $("bottombar").onclick = () => {
const HOST = { computer: "computer" };
function applyHostWording(h) {
Object.assign(HOST, h);
for (const id of ["wirelessDoctorSettings", "wirelessDoctorLink"]) {
$(id).hidden = !!h.mobile || h.os !== "Linux";
const link = $(id).tagName === "A" ? $(id) : $(id).querySelector("a");
link.href = "/wireless-doctor?key=" + encodeURIComponent(UI_KEY);
}
window.frameControlCenter?.host(h);
$("desktopBatteryComfort").hidden = !!h.mobile || !window.frameApp?.notify;
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
@@ -5589,6 +5627,45 @@ document.querySelectorAll('[data-power-format]').forEach(b => b.onclick = async
finally { b.disabled = !powerState?.id; }
});
// ---- Read-only PC wireless doctor ----
let wirelessSeq = 0;
const wirelessKnown = value => value === true ? 'Yes' : value === false ? 'No' : 'Unknown';
function renderWireless(r) {
const unknown = value => value == null ? 'Unknown' : esc(value);
const states = (value, known) => known[value] || (value == null ? 'Unknown' : `Other (${esc(value)})`);
return `<p class="hint">${r.multipleWifiDirect ? `${r.wifiDirectInstances.length} distinct Wi-Fi Direct PnP instances found. Multiple instances do not prove a fault or identify a safe disable target.` : 'No duplicate Wi-Fi Direct instances identified in the available inventory. Missing data does not prove there are none.'}</p>
<p>${r.overlap === 'possible' ? '2.4 GHz use reported: spectrum overlap with a 2.4 GHz headset is possible; interference is unconfirmed.' : '2.4 GHz overlap is unknown. Other radios, the Steam dongle and audio channels may be unreported, even when PC Wi-Fi uses 5/6 GHz.'}</p>
<p class="sub">Sources: ${Object.entries(r.sources).map(([key,value]) => `${esc(key)}: ${esc(value)}`).join(' · ')}. Unavailable can mean access denied, missing commands or unsupported Windows features. Channel/band parsing needs English netsh labels and an exact interface GUID. Leave location and security permissions unchanged.</p>
${r.devices.map(d => `<div class="card" style="margin-top:12px;overflow-wrap:anywhere"><h3>${esc(d.name)}</h3>
<p class="sub">${esc(d.interface)}${d.wifiDirect ? ` · Wi-Fi Direct (${esc(d.identityEvidence)})` : d.wifi === true ? ' · PC Wi-Fi' : d.wifi === false ? ' · Other network device' : ' · Network device (type unknown)'} · Hardware interface: ${wirelessKnown(d.hardware)}</p>
<p><strong>PnP instance:</strong> <code style="user-select:text">${esc(d.id)}</code></p>
<p>Present: ${wirelessKnown(d.present)} · PnP status: ${unknown(d.pnpStatus)} · Problem code: ${unknown(d.problemCode)}${d.problemCode === 22 ? ' (disabled)' : ''}</p>
<p>Operational: ${states(d.operational,{1:'Up',2:'Down',3:'Testing',4:'Unknown',5:'Dormant',6:'Not present',7:'Lower layer down'})} · Media: ${states(d.media,{1:'Connected',2:'Disconnected'})} · Default route configured: ${wirelessKnown(d.defaultRoute)}</p>
<p>Reported channel: ${unknown(d.channel)} · Band: ${d.band ? `${esc(d.band)} GHz` : 'Unknown'}</p>
</div>`).join('') || '<p>No readable network devices. This does not prove no adapters are installed.</p>'}
<p class="sub">Snapshot: ${esc(r.observedAt)}. Queries run sequentially; state can change during or after a read. Up/connected and configured routes do not establish which adapter Steam uses. A PC Wi-Fi channel is not the Steam dongle channel; virtual and proprietary radio channels often remain unknown. SSIDs and MAC addresses are omitted. PnP IDs may identify this PC: review before sharing.</p>`;
}
$('wirelessRead').onclick = async () => {
const seq = ++wirelessSeq;
$('wirelessRead').disabled = true;
$('wirelessResult').textContent = '';
$('wirelessStatus').textContent = 'Reading this PC’s adapters…';
try {
const r = await api('/api/host/wireless');
if (seq !== wirelessSeq) return;
if (!['ok','partial'].includes(r.state)) {
$('wirelessStatus').textContent = r.message || 'PC wireless inventory unavailable.';
return;
}
$('wirelessResult').innerHTML = renderWireless(r);
$('wirelessStatus').textContent = r.state === 'partial' ? 'Partial PC snapshot. Some sources are unavailable. No settings changed.' : 'PC snapshot received. Unknown channel/band fields may remain. No settings changed.';
} catch (e) {
if (seq === wirelessSeq) $('wirelessStatus').textContent = `PC wireless inventory unavailable: ${e.message}`;
} finally {
if (seq === wirelessSeq) $('wirelessRead').disabled = false;
}
};
// ---- Read-only game storage ----
let storageSeq = 0;
const storageState = s => ({'permission-denied':'Permission denied', missing:'Missing or disconnected',
@@ -5891,15 +5968,49 @@ $("macview").onclick = async e => {
setTimeout(loadMacView, 800);
};
// Local Steam helper: never start an action on navigation or a status read.
let steamPipewireBusy = false, steamPipewireRead = 0;
async function loadSteamPipewire() {
if (steamPipewireBusy) return;
const read = ++steamPipewireRead;
try {
const s = await api('/api/steam-pipewire');
if (read !== steamPipewireRead) return;
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = !s.available;
$('steamPipewireStatus').textContent = s.reason || '';
if (s.guidance) $('steamPipewireGuidance').textContent = s.guidance;
} catch (e) {
if (read !== steamPipewireRead) return;
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
$('steamPipewireStatus').textContent = e.message;
}
}
async function steamPipewireAction(action) {
if (steamPipewireBusy) return;
steamPipewireBusy = true;
steamPipewireRead++;
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
try {
const r = await api('/api/steam-pipewire', {action, confirmed: true});
$('steamPipewireStatus').textContent = r.message;
} catch (e) { $('steamPipewireStatus').textContent = e.message; }
finally {
steamPipewireBusy = false;
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = false;
}
}
$('steamPipewireLaunch').onclick = () => steamPipewireAction('launch');
$('steamPipewireReset').onclick = () => steamPipewireAction('permissions');
// ---- shared destinations and intentional secondary workspaces ----
const PAGES = ["apps", "games", "tools", "settings"];
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", powerReport:"tools", backup:"tools"};
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
steamPipewire:"tools", vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools"};
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", powerReport:"Power & heat report", backup:"Backup & recovery"};
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery"};
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
let page = "apps";
// Reuse the existing live lists and form handlers in their new destinations.
@@ -6003,6 +6114,7 @@ function showPage(e) {
const host = $(kind === 'tools' ? 'toolWorkspace' : 'settingsWorkspace');
if (activeTool && $(activeTool).parentNode !== host) host.append($(activeTool));
}
if (tool === 'steamPipewire') loadSteamPipewire();
if (tool === 'macview') loadMacView();
if (tool === 'panels') loadPanelSwitcher();
if (tool === 'backup') loadBackups();
@@ -6088,11 +6200,11 @@ const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=c
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
// ---- keyboard and trackpad: events go to KDE Connect on the Frame (see ui/frame_input_agent.py) ----
// ---- keyboard and trackpad: events go to the Frame through gamescope (ui/frame_touch.py); accents and emoji through KDE Connect ----
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
const PAD_STATES = {
off: "", starting: "Connecting…", installing: "Setting up KDE Connect on the Frame (first time only)…",
pairing: "Pairing with KDE Connect…", ready: "On",
off: "", starting: "Connecting…", installing: "Setting up (first time only)…",
pairing: "Pairing with KDE Connect…", waiting: "Waiting for queued text…", ready: "On",
};
// KDE Connect's specialKey numbers (plugins/mousepad in its source).
const PAD_SPECIAL = { Backspace: 1, Tab: 2, ArrowLeft: 4, ArrowUp: 5, ArrowRight: 6, ArrowDown: 7, PageUp: 8,
@@ -6111,14 +6223,14 @@ function padShow(status) {
$("padArea").classList.toggle("off", !ready);
$("padBody").hidden = pad.state === "off";
$("padIntro").hidden = pad.state !== "off";
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame."
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach the Frame."
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
: "Click here to use your mouse and keyboard on the Frame (Esc to stop), or drag like a trackpad.";
clearTimeout(pad.poll);
if (ready && pad.queue.length) padFlush();
if (busy) {
if (busy || status.queued) {
const gen = devGen;
pad.poll = setTimeout(async () => {
const status = await api('/api/input').catch(e => ({state:'error', message:e.message}));
@@ -6141,7 +6253,7 @@ function padSend(event) {
// While it reconnects, keys and clicks wait in the queue; pointer moves would be stale.
// A button release is always kept, so a drag never leaves the button held on the Frame.
const room = pad.queue.length < 200 || event.singlerelease;
const reconnecting = ["starting", "pairing", "installing"].includes(pad.state) && room;
const reconnecting = ["starting", "pairing", "installing", "waiting"].includes(pad.state) && room;
// Even after an error: a button pressed on the Frame must be let go once it's back.
const release = event.singlerelease && pad.state !== "off";
if (pad.state !== "ready" && !(reconnecting && !isMove(event)) && !release) return;
@@ -6174,7 +6286,7 @@ async function padFlush() {
const status = await api("/api/input", { events });
if (gen !== devGen) return;
if (!status.sent) padKeep(events);
if (status.state !== "ready") padShow(status);
if (status.state !== "ready" || status.queued) padShow(status);
} catch (e) {
if (gen !== devGen) return;
padKeep(events);
@@ -7234,6 +7346,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>`;
@@ -7311,8 +7424,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>`);
@@ -7407,6 +7521,8 @@ api("/api/host").then(h => {
window.frameControlCenter?.host(h);
$("macToolLink").hidden = h.mobile || h.os !== "macOS";
$("pcToolLink").hidden = h.mobile || h.os !== "Windows";
$("wirelessToolGroup").hidden = h.mobile || h.os !== "Windows";
$("steamPipewireLink").hidden = h.mobile || h.os !== "Linux";
if (!h.mobile && h.os === "Windows") TOOL_TITLES.macview = "PC in the headset";
$("backupToolLink").hidden = !!h.mobile; // backups are folders on a computer
if (h.mobile) return;
+279 -12
View File
@@ -60,6 +60,7 @@ import frame_host # noqa: E402
import frame_link # noqa: E402
import frame_macview # noqa: E402
import frame_pcview # noqa: E402
import frame_steam_pipewire # noqa: E402
import frame_media # noqa: E402
import frame_panels # noqa: E402
import frame_quest # noqa: E402
@@ -69,9 +70,12 @@ import frame_report # noqa: E402
import frame_store # noqa: E402
import frame_telemetry # noqa: E402
import frame_titles # noqa: E402
import frame_touch # noqa: E402
import frame_webinstall # noqa: E402
import frame_wireless # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
import frame_wireless_doctor # noqa: E402
import frame_compat_db # noqa: E402
frame_host.trust_bundled_cas()
@@ -111,7 +115,9 @@ frame_android.SSH_OPTS = SSH[1:]
_power = frame_power.Observation()
_route_lock = threading.Lock()
# Pad delivery reserves the route before its own locks; InputAgent.start()
# captures that same route synchronously, so this lock must be reentrant.
_route_lock = threading.RLock()
def route(alias, host_opts):
@@ -123,13 +129,10 @@ def route(alias, host_opts):
moved = alias != FRAME
if moved:
_power.stop('Headset switched; received samples retained for the previous headset')
reset_pad = globals().get("reset_pad_route")
if reset_pad:
reset_pad() # cancel queued input and both agents before publishing the new route
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
# Invalidate launches before publishing the new route. send/stop share
# a writer lock, so an old process cannot receive events after this.
for name in ("_input", "_touch"):
agent = globals().get(name)
if agent:
agent.stop()
FRAME = frame_android.FRAME = alias
HOST_OPTS = list(host_opts)
MUX[:] = [*MUX_BASE, *HOST_OPTS]
@@ -148,7 +151,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
# install's clean-up) to the other headset.
_work_lock = threading.Lock()
_work = [0]
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
NOT_HEADSET_WORK = {"/api/steam-pipewire", "/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
@contextlib.contextmanager
@@ -1499,12 +1502,244 @@ def licenses():
return notices
# KDE Connect's specialKey numbers -> Linux key codes (KEY_BACKSPACE, KEY_TAB, ...).
PAD_KEYS = {1: 14, 2: 15, 4: 105, 5: 103, 6: 106, 7: 108, 8: 104, 9: 109, 10: 102, 11: 107, 12: 28, 13: 111, 14: 1,
**{21 + i: code for i, code in enumerate([59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88])}}
PAD_MODS = (("ctrl", 29), ("alt", 56), ("shift", 42), ("super", 125))
PAD_CLICKS = {"singleclick": "left", "rightclick": "right", "middleclick": "middle"}
PAD_NOTCH = 15 # scroll units for one wheel notch
def typed_in_kde(events):
"""Whether these events must go the KDE Connect way: they hold an accent or emoji, or
earlier ones are queued, being sent or still being typed there (typing must stay in
order, so it can't be split between the two ways)."""
with _kde_lock:
waiting = bool(_kde_queue) or time.time() < _kde_until
return waiting or any(ch not in frame_touch.ASCII for e in events for ch in e.get("key", ""))
def pad_events(events, kde=False):
"""The trackpad's and key row's events as (gamescope events, KDE Connect events).
Gamescope's own input reaches every panel and needs nothing installed, but types only
US-keyboard characters. With kde, the keyboard goes to KDE Connect as it was sent, so
text stays in order; the pointer always goes through gamescope.
"""
ascii_keys = frame_touch.ASCII
out, slow = [], []
def tap(code, mods):
down = [{"key": c, "down": True} for c in mods]
out.extend(down + [{"key": code, "down": True}, {"key": code, "down": False}]
+ [{"key": c, "down": False} for c in reversed(mods)])
for e in events:
mods = [code for name, code in PAD_MODS if e.get(name)]
if "dx" in e or "dy" in e:
if not e.get("scroll"):
out.append({k: e[k] for k in ("dx", "dy") if k in e})
if e.get("scroll"):
out.append({"scroll": [0, -(e.get("dy") or 0) * PAD_NOTCH]})
for flag, button in PAD_CLICKS.items():
if e.get(flag):
out += [{"button": button, "down": True}, {"button": button, "down": False}]
if e.get("doubleclick"):
out += [{"button": "left", "down": d} for d in (True, False, True, False)]
if e.get("singlehold"):
out.append({"button": "left", "down": True})
if e.get("singlerelease"):
out.append({"button": "left", "down": False})
if kde and ("key" in e or "specialKey" in e):
slow.append({k: e[k] for k in ("key", "specialKey", "ctrl", "alt", "shift", "super") if k in e})
continue
if "specialKey" in e and e["specialKey"] in PAD_KEYS:
tap(PAD_KEYS[e["specialKey"]], mods)
if "key" in e:
for ch in e["key"]:
if ch not in ascii_keys:
continue # only reached when kde is wrong; the caller checks typed_in_kde first
elif mods:
code, shifted = ascii_keys[ch]
tap(code, mods + ([42] if shifted and 42 not in mods else []))
else:
if out and "text" in out[-1] and len(out[-1]["text"]) < 100:
out[-1]["text"] += ch
else:
out.append({"text": ch})
return out, slow
def remote_input(body):
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent."""
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent.
"sent" in the answer means the whole batch is taken (the page keeps it and tries again
if not), so the keyboard's share is queued only once that's so.
"""
events = body.get("events", [])
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
return _input.send([input_event(e) for e in events])
events = [input_event(e) for e in events]
# Serialize admission with other requests and the sender. Keep route -> pad
# order: either agent may synchronously capture its route during send().
with _route_lock, _pad_send_lock:
touch, slow = pad_events(events, typed_in_kde(events))
with _kde_lock:
blocked = slow and (_kde_error or len(_kde_queue) + len(slow) > KDE_QUEUE_LIMIT)
if blocked:
# Refuse the WHOLE batch before sending its clicks/moves. The page
# retains it and polls, so a retry cannot duplicate pointer events.
return pad_status({"state": "waiting", "sent": False})
status = _touch.send(touch)
if touch and not status.get("sent"):
return status
if touch:
_note_typed(touch)
if slow:
_queue_for_kde(slow)
# Poll at least once after accepting text, even if its worker finished
# already, so an asynchronous error is visible without another keypress.
return pad_status({**status, "sent": True}, pending=bool(slow))
# Typing KDE Connect is to do: it starts the first time it's needed, so this waits for it.
# Nothing on the Frame says when typed text has landed, so each way holds the other back
# for as long as what it was given should take (a short margin on a time estimate).
_kde_queue = []
_kde_lock = threading.Lock()
_pad_send_lock = threading.Lock()
_kde_worker = [None]
_kde_error = None
_kde_until = 0.0 # keys typed through KDE Connect should have landed by then
_typed_until = 0.0 # when gamescope's agent will have typed everything it's been given
_kde_route = object()
_kde_cancel = threading.Event()
KDE_QUEUE_LIMIT = 200
KDE_WAIT = 90 # seconds to wait before pausing queued text for an explicit retry
TYPING_SETTLE = 0.6 # seconds past the estimate, once
def reset_pad_route():
"""Discard input for the headset being left, including batches waiting for an agent."""
global _kde_route, _kde_cancel, _kde_until, _typed_until, _kde_error
with _route_lock, _pad_send_lock, _kde_lock:
_kde_cancel.set()
_kde_cancel = threading.Event()
_kde_route = object()
_kde_queue.clear()
_kde_worker[0] = None
_kde_error = None
_kde_until = _typed_until = 0.0
_input.stop()
_touch.stop()
def typing_seconds(touch):
"""How long gamescope's agent takes over these: it sleeps 8 ms after every key transition."""
transitions = 0
for e in touch:
if "text" in e:
transitions += sum(2 + 2 * frame_touch.ASCII[ch][1] for ch in e["text"] if ch in frame_touch.ASCII)
elif "key" in e:
transitions += 1
return transitions * 0.008
def _note_typed(touch):
global _typed_until
seconds = typing_seconds(touch)
if seconds:
with _kde_lock: # the end of the work, which queues up; the margin is added once, where it's checked
_typed_until = max(_typed_until, time.time()) + seconds
def pad_status(status, pending=False):
with _kde_lock:
if _kde_error:
return {**status, "state": "error", "message": _kde_error}
if (_kde_queue or pending) and status.get("state") != "error":
return {**status, "queued": len(_kde_queue) or 1}
return status
def start_pad():
with _route_lock, _pad_send_lock:
status = _touch.send([])
with _kde_lock:
if _kde_queue and _kde_worker[0] is None:
_start_kde_locked() # explicit Try again retries retained text
return pad_status(status)
def _start_kde_locked():
global _kde_error
_kde_error = None
try:
worker = threading.Thread(target=_drain_kde, args=(_kde_route, _kde_cancel), daemon=True)
_kde_worker[0] = worker
worker.start()
except Exception:
_kde_worker[0] = None
_kde_error = "Couldn't start queued typing. Text is retained; try again."
def _queue_for_kde(events):
with _kde_lock:
if len(_kde_queue) + len(events) > KDE_QUEUE_LIMIT:
return False
_kde_queue.extend(events)
if _kde_worker[0] is None and not _kde_error:
_start_kde_locked()
return True
def _drain_kde(route, cancel):
"""One sender, in order. In-flight text stays in the bounded queue until sent.
Errors retain it for an explicit retry; only a headset switch cancels it."""
global _kde_until, _kde_error
deadline = time.time() + KDE_WAIT
try:
while True:
with _kde_lock:
if route is not _kde_route:
return
batch = _kde_queue[:INPUT_BATCH_LIMIT]
if not batch:
_kde_worker[0] = None
return
wait = _typed_until + TYPING_SETTLE - time.time()
_kde_until = time.time() + 3600 # held while in flight
if wait > 0 and cancel.wait(wait):
return
# Match route()/reset_pad_route()'s lock order. send() may start an
# agent and take _route_lock again to capture its immutable route.
with _route_lock, _pad_send_lock:
with _kde_lock:
if route is not _kde_route:
return
status = _input.send(batch)
with _kde_lock:
if route is not _kde_route:
return
if status.get("sent"):
del _kde_queue[:len(batch)]
_kde_until = time.time() + len(batch) * 0.03 + TYPING_SETTLE
deadline = time.time() + KDE_WAIT
continue
_kde_until = 0.0
if status.get("state") == "error" or time.time() > deadline:
_kde_error = "Queued typing didn't reach the input service. Text is retained; try again."
_kde_worker[0] = None
return
if cancel.wait(0.4):
return
except Exception:
# A stale worker must never retire the new route's worker or alter its
# queue/timing. Retain unsent text and let the page report/retry failure.
with _kde_lock:
if route is _kde_route:
_kde_until = 0.0
_kde_worker[0] = None
_kde_error = "Queued typing failed. Text is retained; try again."
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
@@ -2844,6 +3079,22 @@ def push_media(path):
"id": token + "/" + name}
# Local Steam helper: no headset route, SSH, or PC capture helper.
def steam_pipewire_state():
if LOCAL or PRIVATE:
return {"available": False, "reason": "Open this helper in Frame Control on your Linux PC."}
return frame_steam_pipewire.status()
def steam_pipewire_action(body):
if LOCAL or PRIVATE:
raise Failure("Open this helper in Frame Control on your Linux PC.", 403)
try:
return frame_steam_pipewire.action(body)
except frame_steam_pipewire.HelperError as exc:
raise Failure(str(exc), 400) from exc
# ---- Mac in the headset (frame_macview.py), PC in the headset (frame_pcview.py) ----
# The tunnel gets its own connection: the shared master's options would win
@@ -3033,6 +3284,7 @@ def power_action(body):
POST = {
"/api/steam-pipewire": steam_pipewire_action,
"/api/power": power_action,
"/api/android/backup": android_backup, "/api/backup": backup_action,
"/api/vr": vr,
@@ -3243,10 +3495,23 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/agent/approval":
token = (parse_qs(url.query).get("confirmation") or [""])[0]
self.send_json(frame_agent.approvals.inspect(token))
elif path == "/wireless-doctor":
self.send_bytes((HERE / "wireless-doctor.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/wireless-doctor":
# PC-only and independent of LINK/working(): never start or wait for SSH.
args = parse_qs(url.query)
self.send_json(frame_wireless_doctor.diagnose(
(args.get("device") or [None])[0], local=LOCAL))
elif path == "/api/steam-pipewire":
self.send_json(steam_pipewire_state())
elif path == "/api/host":
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
"computer": "Mac" if frame_host.MAC else "PC"})
elif path == "/api/host/wireless":
if LOCAL:
raise Failure("Wireless Adapter doctor runs on a Windows PC, not a headset", 400)
self.send_json(frame_wireless.diagnose())
elif path == "/api/connection":
self.send_json(connection_state())
elif path == "/api/connection/events":
@@ -3302,7 +3567,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/panels":
self.send_json(panels())
elif path in ("/api/touch", "/api/input"):
agent = _touch if path == "/api/touch" else _input
agent = _touch # both controls use gamescope; KDE Connect starts only for Unicode
if parse_qs(url.query).get("start") == ["1"]:
# Validate and reserve before start() captures its immutable
# route and schedules a worker. Status-only polls stay free.
@@ -3310,9 +3575,11 @@ class Handler(BaseHTTPRequestHandler):
if LINK and not meant:
raise Failure("Control start needs the intended X-Frame-Device headset", 400)
with working(meant):
result = agent.send([])
result = start_pad() if path == "/api/input" else agent.send([])
else:
result = dict(agent.status)
if path == "/api/input":
result = pad_status(result)
self.send_json(result)
elif path == "/api/job":
self.send_json(job_status(url.query))
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Linux Wireless Adapter doctor · Frame Control</title>
<style>
:root{color-scheme:dark;--bg:#101820;--card:#1b2838;--text:#e4edf5;--sub:#acbccb;--line:#3a5065;--blue:#66c0f4;--warn:#f0c67a}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.55 system-ui,sans-serif}main{max-width:960px;margin:auto;padding:32px 24px 64px}a{color:var(--blue)}h1{font-size:30px;line-height:1.2;margin:20px 0 12px}h2{font-size:19px;margin:0 0 10px}p{margin:8px 0 16px}.sub{color:var(--sub)}.badge{display:inline-block;font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--blue);margin-bottom:8px}section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:22px;margin:18px 0}.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}button,select{font:inherit;min-height:44px;border:1px solid var(--line);border-radius:5px;padding:9px 14px;background:#263f54;color:var(--text)}button{cursor:pointer}button.primary{background:#205b81;border-color:var(--blue)}button:disabled{opacity:.5;cursor:default}select{max-width:100%;width:100%}label{display:block;margin:16px 0 6px}pre{font:13px/1.6 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;background:#111d29;padding:14px;border-radius:5px}.status{font-size:12px;font-weight:700;text-transform:uppercase;margin-right:10px;color:var(--blue)}.warning .status{color:var(--warn)}.unknown .status{color:var(--sub)}#message{min-height:24px}summary{cursor:pointer;min-height:40px;overflow-wrap:anywhere}:focus-visible{outline:2px solid var(--blue);outline-offset:4px}[hidden]{display:none!important}@media(max-width:540px){main{padding:20px 16px}h1{font-size:25px}section{padding:16px}.row button{flex:1}}
</style>
</head>
<body><main>
<a id="back" href="/#devices">← Devices &amp; connection</a>
<header><h1>Linux Wireless Adapter doctor</h1><span class="badge">This PC · Read-only · On demand</span>
<p>Find what Linux can see before choosing a fix. This doctor reads local USB, driver, radio and network manager evidence. It does not connect to the Frame or change your system.</p></header>
<section aria-labelledby="chooseTitle"><h2 id="chooseTitle">1. Find the adapter</h2>
<p class="sub">The Steam Frame adapter’s hardware ID is unverified here. Nothing is selected automatically. All USB devices are listed so you can select one even when no wireless interface appears.</p>
<button id="inventory" class="primary">Read PC device inventory</button>
<div id="selection" hidden><label for="device">Select the device you want to check</label><select id="device"><option value="">Choose an observed device…</option></select>
<p class="sub">Compare the list before and after reconnecting the adapter when safe. A USB name or ID alone does not confirm that it is Valve’s adapter.</p>
<button id="diagnose" class="primary" disabled>Check selected device</button></div>
<p id="message" role="status" aria-live="polite">No checks have run.</p></section>
<div id="results" aria-live="polite"></div>
<section id="reportSection" hidden aria-labelledby="reportTitle"><h2 id="reportTitle">Local evidence report</h2>
<p class="sub">Review before sharing. The report includes observed USB IDs and product labels, interface names, driver names and local permission metadata. It excludes Wi-Fi names, MAC/IP addresses, USB serial numbers, credentials and raw command output.</p>
<div class="row"><button id="download">Save JSON report</button><button id="copy">Copy JSON report</button></div>
<details><summary>Review JSON</summary><pre id="json"></pre></details></section>
<p class="sub">No repairs, root prompts, driver installs, security changes or network configuration changes. A clean report does not prove Steam streaming support or resolve the Ubuntu/Kubuntu 26.04 complaint.</p>
</main>
<script>
'use strict';
const $ = id => document.getElementById(id);
const key = new URLSearchParams(location.search).get('key') || '1';
$('back').href = '/?key=' + encodeURIComponent(key) + '#devices';
let report = null;
const make = (tag, text) => { const el = document.createElement(tag); el.textContent = text; return el; };
function render(data) {
report = data;
$('json').textContent = JSON.stringify(data, null, 2);
$('reportSection').hidden = false;
$('results').replaceChildren();
for (const c of data.checks) {
const box = document.createElement('section'); box.className = c.status;
const title = make('h2', c.title); const status = make('span', c.status + ' · '); status.className = 'status'; title.prepend(status);
box.append(title, make('p', c.suggestion));
if (c.evidence.length) { const d = document.createElement('details'); d.append(make('summary', 'Show evidence'), make('pre', c.evidence.join('\n'))); box.append(d); }
$('results').append(box);
}
}
async function run(device) {
$('inventory').disabled = $('diagnose').disabled = $('device').disabled = true;
$('message').textContent = device ? 'Reading selected device evidence…' : 'Reading PC inventory…';
// Clear previous results first: a removed device or failed read must not leave a healthy old report.
report = null; $('results').replaceChildren(); $('reportSection').hidden = true;
try {
const response = await fetch('/api/wireless-doctor' + (device ? '?device=' + encodeURIComponent(device) : ''), {headers:{'X-Frame-UI':key}});
const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Could not read local evidence');
render(data);
const select = $('device'); select.replaceChildren(make('option', 'Choose an observed device…')); select.firstChild.value = '';
for (const d of [...data.interfaces, ...data.usb]) {
const text = d.name ? `${d.id} · driver ${d.driver || 'unknown'} · ${d.usb || 'USB not correlated'}` : `${d.id} · ${d.vid || '?'}:${d.pid || '?'} · ${d.product || 'unnamed USB device'}`;
const option = make('option', text); option.value = d.id; select.append(option);
}
select.value = device || ''; $('selection').hidden = !data.supported || select.options.length < 2;
$('message').textContent = !data.supported ? 'Available only on a Linux PC.' : device ? 'Checks finished. Review evidence and limits below.' : 'Inventory read. Select the intended device to continue.';
} catch (e) { $('message').textContent = e.message; }
finally { $('inventory').disabled = $('device').disabled = false; $('diagnose').disabled = !$('device').value; }
}
$('inventory').onclick = () => run();
$('device').onchange = () => {
report = null; $('results').replaceChildren(); $('reportSection').hidden = true;
$('diagnose').disabled = !$('device').value;
$('message').textContent = $('device').value ? 'Selection changed. Check this device for fresh evidence.' : 'Choose the intended device.';
};
$('diagnose').onclick = () => run($('device').value);
$('download').onclick = () => {
if (!report) return;
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2) + '\n'], {type:'application/json'}));
const a = make('a', ''); a.href = url; a.download = 'linux-wireless-doctor.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
};
$('copy').onclick = async () => {
if (!report) return;
try { await navigator.clipboard.writeText(JSON.stringify(report, null, 2)); $('message').textContent = 'Report copied. Review it before sharing.'; }
catch { $('message').textContent = 'Clipboard unavailable. Use Save JSON report or select the JSON below.'; }
};
</script></body></html>