mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 18:01:56 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7110c1f4 |
No files matched your search
@@ -41,10 +41,6 @@ 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
|
||||
@@ -74,21 +70,6 @@ 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
|
||||
|
||||
@@ -2,27 +2,6 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -44,9 +44,6 @@ 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).
|
||||
|
||||
@@ -76,7 +73,7 @@ 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 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).
|
||||
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)).
|
||||
|
||||
**⌨️ 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.
|
||||
@@ -264,7 +261,6 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [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 |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.20",
|
||||
"version": "0.4.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.20",
|
||||
"version": "0.4.19",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.20",
|
||||
"version": "0.4.19",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 188 KiB |
@@ -1,22 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,74 +0,0 @@
|
||||
# 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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"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.
|
Before Width: | Height: | Size: 214 KiB |
@@ -1,24 +0,0 @@
|
||||
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
|
||||
@@ -58,11 +58,6 @@ 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,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB |
@@ -1,118 +0,0 @@
|
||||
# Linux Wireless Adapter doctor
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -1,9 +1,5 @@
|
||||
# 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
|
||||
|
||||
+1
-16
@@ -1,6 +1,6 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
|
||||
Frame Control can record what the headset shows, 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,21 +47,6 @@ 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
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# 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.
|
||||
@@ -53,13 +53,8 @@ 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).
|
||||
path change. The dominant-eye setting is not read yet; recording metadata
|
||||
must not claim an eye until its source and effect on `/dev/video99` are verified.
|
||||
|
||||
Sources: [SteamOS 3.9 news](https://store.steampowered.com/news/app/1675200),
|
||||
[Frame OS news](https://steamcommunity.com/app/4165890/allnews/), and the
|
||||
|
||||
@@ -483,19 +483,3 @@ 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).
|
||||
@@ -1,101 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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');
|
||||
@@ -1,82 +0,0 @@
|
||||
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"/);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,233 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,287 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,49 +0,0 @@
|
||||
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','<img','<script','<unsafe>']) 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);
|
||||
}
|
||||
});
|
||||
@@ -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(){},
|
||||
loadSteamPipewire(){calls.push(['steam-pipewire']);},loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))};
|
||||
vm.createContext(context);
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"""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}
|
||||
@@ -1,188 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,269 +0,0 @@
|
||||
"""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())
|
||||
+6
-121
@@ -1431,24 +1431,8 @@
|
||||
|
||||
<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="#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 & 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 & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & 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="#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 & 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 & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & 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 & 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>
|
||||
@@ -1463,23 +1447,6 @@
|
||||
<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>
|
||||
@@ -1842,7 +1809,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 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 & 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 & 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 & updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote & power</span><small>Remote access, sleep and restart</small></a></div>
|
||||
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices & 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 & 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 & updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote & power</span><small>Remote access, sleep and restart</small></a></div>
|
||||
<div id="settingsWorkspace" hidden></div>
|
||||
</div>
|
||||
|
||||
@@ -1859,7 +1826,6 @@
|
||||
<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">
|
||||
@@ -2168,11 +2134,6 @@ $("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.
|
||||
@@ -5627,45 +5588,6 @@ 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',
|
||||
@@ -5968,49 +5890,15 @@ $("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",
|
||||
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",
|
||||
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",
|
||||
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", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", 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.
|
||||
@@ -6114,7 +6002,6 @@ 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();
|
||||
@@ -7521,8 +7408,6 @@ 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;
|
||||
|
||||
+1
-34
@@ -60,7 +60,6 @@ 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
|
||||
@@ -72,10 +71,8 @@ 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()
|
||||
@@ -151,7 +148,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/steam-pipewire", "/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -3079,22 +3076,6 @@ 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
|
||||
@@ -3284,7 +3265,6 @@ 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,
|
||||
@@ -3495,23 +3475,10 @@ 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":
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<!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 & 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>
|
||||
Reference in new issue
Block a user