mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 16:02:38 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b3ae813f | ||
|
|
8bc3d63126 | ||
|
|
427b66f8bc | ||
|
|
b87e915c89 | ||
|
|
c690e691a3 |
No files matched your search
@@ -41,6 +41,10 @@ jobs:
|
||||
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Linux Wireless Adapter doctor (read-only PC smoke)
|
||||
run: |
|
||||
python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json"
|
||||
python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1'
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: App tests
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 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,6 +44,9 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
For PC-side USB, driver, radio and network manager evidence, use the
|
||||
[Linux Wireless Adapter doctor](docs/linux-wireless-adapter-doctor.md).
|
||||
|
||||
For repeatable read-only battery, power-rail, temperature and fan measurements,
|
||||
use the [power and heat sampler](docs/power-and-heat.md).
|
||||
|
||||
@@ -260,6 +263,7 @@ 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.18",
|
||||
"version": "0.4.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.18",
|
||||
"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.18",
|
||||
"version": "0.4.19",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,118 @@
|
||||
# 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,5 +1,9 @@
|
||||
# PC in the headset (Experimental)
|
||||
|
||||
For Steam's own Linux Wayland desktop-sharing flow, see the separate
|
||||
[Steam desktop sharing helper](steam-pipewire.md). It does not enable this
|
||||
feature on Linux.
|
||||
|
||||
**Tools → PC in the headset**, on Windows only. It's the Windows version of
|
||||
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Individual windows are available only
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Steam desktop sharing on a Linux PC
|
||||
|
||||
**Tools → Steam desktop sharing** helps with Steam's own `steam -pipewire`
|
||||
desktop-in-headset flow on a local Wayland PC. It needs no Frame connection.
|
||||
It is separate from Frame Control's [PC in the headset](pc-in-headset.md),
|
||||
which remains disabled on Linux. Steam and the desktop portal own the capture,
|
||||
screen picker and headset flow; this helper does not provide a stream.
|
||||
|
||||
## Launch Steam with PipeWire
|
||||
|
||||
Save your game and use **Steam → Exit** first. Then click **Launch Steam with
|
||||
PipeWire**. Frame Control checks this user's local processes, finds `steam` on
|
||||
PATH, and requests `steam -pipewire`. This flag applies to that launch only.
|
||||
No Steam settings, login files or startup entries are changed. An existing
|
||||
Steam session is never stopped, signalled or silently switched.
|
||||
|
||||
If Steam or a Steam launcher/helper is running, the button explains that Steam
|
||||
must be exited first. If the process inventory cannot be read, it refuses to
|
||||
launch. It also refuses root, SSH, headless, X11 and Flatpak-sandboxed Frame
|
||||
Control sessions. Run Frame Control as your normal user in the local Wayland
|
||||
desktop with its session bus.
|
||||
|
||||
For a Flatpak-only Steam install without a `steam` command on PATH, the helper
|
||||
provides a manual command instead of choosing an installation for you. After
|
||||
exiting Steam, run `flatpak run com.valvesoftware.Steam -pipewire` in a local
|
||||
terminal. Flatpak Steam processes are included in the existing-session check.
|
||||
|
||||
A successful request is **not confirmation of a picker or stream**. Steam may
|
||||
exit or fail after the initial check, and another launcher can race the local
|
||||
process check. Do not start Steam elsewhere at the same time. If nothing opens,
|
||||
run the command yourself in a terminal to see Steam's error.
|
||||
|
||||
## Reset screen-share choice
|
||||
|
||||
Click **Reset screen-share choice…** to request KDE's **Application Permissions**
|
||||
page. This button does **not** revoke anything or report a completed reset.
|
||||
Frame Control cannot reliably map a Steam runtime display name to a
|
||||
Steam-specific portal grant or restore token across backends. It never reads,
|
||||
edits or clears the portal permission store or Steam tokens.
|
||||
|
||||
On KDE:
|
||||
|
||||
1. Open **System Settings → Applications → Application Permissions** if the
|
||||
button cannot open it.
|
||||
2. Find the entry for Steam. The Ubuntu 26.04 KDE report showed **Steam Linux
|
||||
Runtime 3.0**. That display name alone is not proof that every session there
|
||||
is safe to remove; verify the session belongs to your Steam desktop share.
|
||||
3. Under **Screen sharing**, choose **Manage 1 Session** (the count may differ).
|
||||
4. Choose **Revoke** for that verified session only. **Do not use Revoke All**
|
||||
or change another application's grants. If ownership is ambiguous, stop.
|
||||
5. Retry Steam's desktop sharing flow. The reporter saw the portal picker again
|
||||
after revoking that session. Results can differ by Steam and portal version.
|
||||
|
||||
The reported entry sometimes appeared intermittently. If it is missing, do not
|
||||
clear other entries or the whole portal store. Check again when Steam's sharing
|
||||
session is present. If **Application Permissions** itself is missing on
|
||||
Ubuntu/Kubuntu, check your distribution's `kde-config-flatpak` package: the
|
||||
reporter needed that package and a reboot before the page appeared. Frame
|
||||
Control does not install packages, reboot, request root or change security
|
||||
settings. Older KDE modules may lack this page; other desktops receive guidance
|
||||
only, with no automatic reset or guessed permission target.
|
||||
|
||||
The launcher uses `kcmshell6 --list` to verify the exact module, then
|
||||
`kcmshell6 kcm_app-permissions`. It does not substitute the older Flatpak-only
|
||||
page. A request to open the page does not prove that a window became visible.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- User-supplied report and screenshot description:
|
||||
[ExpressGinga81's thread and replies](https://x.com/ExpressGinga81/status/2113242533530911058).
|
||||
The report describes Ubuntu 26.04 KDE, the initial picker appearing once,
|
||||
the Steam Linux Runtime 3.0 entry, and Revoke making the picker reappear.
|
||||
This is reported behavior, not a live test performed for this feature.
|
||||
- KDE upstream [`flatpak-kcm`](https://github.com/KDE/flatpak-kcm):
|
||||
`CMakeLists.txt` and `kcm_app-permissions.json` identify Application
|
||||
Permissions; `ui/SessionList.qml` distinguishes Revoke from Revoke All.
|
||||
- Automated tests mock Steam, processes and KDE commands. HTTP tests verify
|
||||
request guards, no actions on GET, and no headset work. Existing CI discovers
|
||||
these tests on Linux, macOS and Windows; they do not need a desktop portal.
|
||||
- lxso2 has no interactive Wayland/PipeWire portal for a chooser test.
|
||||
**No live chooser, revoke, Steam launch or headset display is verified here.**
|
||||
No Frame access was performed for this task, including during the hold before
|
||||
2026-10-24 13:00 UTC. The helper never invokes SSH or Frame controls.
|
||||
|
||||
## Announcement
|
||||
|
||||
Linux Wayland users can now find **Steam desktop sharing** under Tools to
|
||||
request Steam with `-pipewire` after exiting Steam, or open KDE's Application
|
||||
Permissions to revoke only a verified Steam screen-sharing session and try the
|
||||
picker again. Frame Control does not clear permissions automatically. This is
|
||||
help for Steam's own desktop-sharing flow; Frame Control's Linux capture
|
||||
feature remains disabled. Live picker behavior still needs desktop validation.
|
||||
@@ -0,0 +1,70 @@
|
||||
// Run the real doctor page script with a minimal DOM; no browser or hardware I/O.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
class Element {
|
||||
constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.value = ''; this.hidden = false; this.disabled = false; this.textContent = ''; }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
prepend(...nodes) { this.children.unshift(...nodes); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
get firstChild() { return this.children[0]; }
|
||||
get options() { return this.children; }
|
||||
}
|
||||
const ids = ['back','inventory','diagnose','device','message','results','json','reportSection','selection','download','copy'];
|
||||
const elements = Object.fromEntries(ids.map(id => [id,new Element(id === 'device' ? 'select' : 'div')]));
|
||||
const requests = [];
|
||||
let pending;
|
||||
const context = vm.createContext({
|
||||
document:{getElementById:id=>elements[id],createElement:tag=>new Element(tag)},
|
||||
URLSearchParams,location:{search:'?key=test-key'},
|
||||
fetch:(path,options)=>{ requests.push({path,options}); return new Promise(resolve=>{pending=resolve;}); },
|
||||
navigator:{clipboard:{writeText:async()=>{throw Error('clipboard denied');}}},
|
||||
});
|
||||
const source = readFileSync(new URL('../../ui/wireless-doctor.html',import.meta.url),'utf8');
|
||||
vm.runInContext(source.match(/<script>([\s\S]*)<\/script>/)[1],context);
|
||||
assert.equal(requests.length,0,'opening the page must not probe');
|
||||
assert.equal(elements.back.href,'/?key=test-key#devices');
|
||||
const fixture = {
|
||||
schema:1,supported:true,selected:null,
|
||||
usb:[{id:'usb:1-2',vid:'1234',pid:'5678',product:'<img src=x onerror=alert(1)>'}],
|
||||
interfaces:[{id:'net:wlan0',name:'wlan0',usb:'usb:1-2',driver:'synthetic'}],
|
||||
checks:[{id:'presence',title:'Presence',status:'ok',suggestion:'Select hardware',evidence:['Synthetic evidence']}]
|
||||
};
|
||||
let promise=elements.inventory.onclick();
|
||||
assert.equal(elements.device.disabled,true,'selection must stay fixed during a read');
|
||||
assert.equal(requests[0].path,'/api/wireless-doctor');
|
||||
assert.equal(requests[0].options.headers['X-Frame-UI'],'test-key');
|
||||
pending({ok:true,json:async()=>fixture}); await promise;
|
||||
assert.equal(elements.device.value,'','no automatic hardware choice');
|
||||
assert.equal(elements.diagnose.disabled,true);
|
||||
assert.equal(elements.device.options.length,3);
|
||||
assert.match(elements.device.options[2].textContent,/<img src=x/,'hardware labels must stay plain text');
|
||||
assert.equal(elements.device.options[2].children.length,0);
|
||||
assert.equal(elements.reportSection.hidden,false);
|
||||
assert.equal(elements.results.children.length,1);
|
||||
elements.device.value='net:wlan0'; elements.device.onchange();
|
||||
assert.equal(elements.diagnose.disabled,false);
|
||||
assert.equal(elements.results.children.length,0,'changing hardware clears the previous result');
|
||||
assert.equal(elements.reportSection.hidden,true,'cannot export an old report for a new selection');
|
||||
promise=elements.diagnose.onclick();
|
||||
assert.equal(requests[1].path,'/api/wireless-doctor?device=net%3Awlan0');
|
||||
assert.equal(elements.device.disabled,true);
|
||||
pending({ok:true,json:async()=>({...fixture,selected:'net:wlan0'})}); await promise;
|
||||
assert.equal(elements.device.value,'net:wlan0');
|
||||
assert.equal(elements.device.disabled,false);
|
||||
await elements.copy.onclick();
|
||||
assert.match(elements.message.textContent,/Clipboard unavailable/);
|
||||
promise=elements.diagnose.onclick();
|
||||
assert.equal(elements.results.children.length,0,'clear a healthy snapshot before retrying');
|
||||
assert.equal(elements.reportSection.hidden,true);
|
||||
pending({ok:false,json:async()=>({error:'Device is no longer present. Refresh and select an observed device.'})}); await promise;
|
||||
assert.match(elements.message.textContent,/no longer present/);
|
||||
assert.equal(elements.results.children.length,0);
|
||||
assert.equal(elements.reportSection.hidden,true,'failed requests cannot export stale success');
|
||||
assert.equal(elements.inventory.disabled,false,'can refresh after failure');
|
||||
promise=elements.inventory.onclick();
|
||||
pending({ok:true,json:async()=>({...fixture,supported:false,usb:[],interfaces:[]})}); await promise;
|
||||
assert.equal(elements.selection.hidden,true);
|
||||
assert.equal(elements.diagnose.disabled,true);
|
||||
assert.match(elements.message.textContent,/only on a Linux PC/);
|
||||
console.log('doctor UI: on-demand reads, explicit selection, safe labels, stale report clearing and failure recovery passed');
|
||||
@@ -0,0 +1,82 @@
|
||||
const {test} = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const html = fs.readFileSync(path.join(__dirname, '../ui/index.html'), 'utf8');
|
||||
const code = html.split('// Local Steam helper: never start')[1].split('// ---- shared destinations')[0];
|
||||
|
||||
function fixture() {
|
||||
const elements = new Map(), requests = [];
|
||||
const $ = id => {
|
||||
if (!elements.has(id)) elements.set(id, {disabled:true, textContent:''});
|
||||
return elements.get(id);
|
||||
};
|
||||
const ctx = {$, api:(url, body) => new Promise((resolve, reject) => requests.push({url, body, resolve, reject}))};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext('// never start' + code, ctx);
|
||||
return {ctx, $, requests};
|
||||
}
|
||||
|
||||
test('navigation reads status; only clicks request launch or permission UI', async () => {
|
||||
const {ctx, $, requests} = fixture();
|
||||
assert.equal(requests.length, 0);
|
||||
const load = ctx.loadSteamPipewire();
|
||||
assert.equal(requests[0].url, '/api/steam-pipewire');
|
||||
assert.equal(requests[0].body, undefined);
|
||||
requests[0].resolve({available:true, guidance:'Verify the Steam session'});
|
||||
await load;
|
||||
assert.equal($('steamPipewireLaunch').disabled, false);
|
||||
assert.equal($('steamPipewireGuidance').textContent, 'Verify the Steam session');
|
||||
const launch = $('steamPipewireLaunch').onclick();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(requests[1].body)), {action:'launch', confirmed:true});
|
||||
assert.equal($('steamPipewireReset').disabled, true);
|
||||
await $('steamPipewireReset').onclick(); // repeated action suppressed
|
||||
assert.equal(requests.length, 2);
|
||||
requests[1].reject(new Error('Exit Steam first'));
|
||||
await launch;
|
||||
assert.equal($('steamPipewireStatus').textContent, 'Exit Steam first');
|
||||
const reset = $('steamPipewireReset').onclick();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(requests[2].body)), {action:'permissions', confirmed:true});
|
||||
requests[2].resolve({reset:false, opened:true, message:'No permission was changed'});
|
||||
await reset;
|
||||
assert.equal($('steamPipewireStatus').textContent, 'No permission was changed');
|
||||
});
|
||||
|
||||
test('unsupported status and read failure keep actions disabled', async () => {
|
||||
const {ctx, $, requests} = fixture();
|
||||
const load = ctx.loadSteamPipewire();
|
||||
requests[0].resolve({available:false, reason:'Local Wayland required'});
|
||||
await load;
|
||||
assert.equal($('steamPipewireLaunch').disabled, true);
|
||||
assert.equal($('steamPipewireReset').disabled, true);
|
||||
assert.equal($('steamPipewireStatus').textContent, 'Local Wayland required');
|
||||
const retry = ctx.loadSteamPipewire();
|
||||
requests[1].reject(new Error('Unavailable'));
|
||||
await retry;
|
||||
assert.equal($('steamPipewireLaunch').disabled, true);
|
||||
});
|
||||
|
||||
test('stale status cannot overwrite an action result', async () => {
|
||||
const {ctx, $, requests} = fixture();
|
||||
const load = ctx.loadSteamPipewire();
|
||||
const reset = ctx.steamPipewireAction('permissions');
|
||||
requests[1].resolve({message:'No permission was changed'});
|
||||
await reset;
|
||||
requests[0].resolve({available:true});
|
||||
await load;
|
||||
assert.equal($('steamPipewireStatus').textContent, 'No permission was changed');
|
||||
});
|
||||
|
||||
|
||||
test('Tools navigation opens the separate Steam panel without starting PC capture', () => {
|
||||
const {workspaceFixture} = require('./workspace_fixture.cjs');
|
||||
const f = workspaceFixture();
|
||||
f.navigate('#steamPipewire');
|
||||
assert.equal(f.activePage, 'tools');
|
||||
assert.equal(f.el('steamPipewire').hidden, false);
|
||||
assert.equal(f.el('macview').hidden, true);
|
||||
assert.ok(f.calls.some(call => call[0] === 'steam-pipewire'));
|
||||
assert.ok(!f.calls.some(call => call[0] === 'macview'));
|
||||
assert.match(html, /\$\("steamPipewireLink"\)\.hidden = h.mobile \|\| h.os !== "Linux"/);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""PC-only Steam helper tests. All app launches and KDE calls are mocked.
|
||||
No portal, interactive Wayland session, Steam installation or Frame is used.
|
||||
"""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_steam_pipewire as helper
|
||||
import server
|
||||
|
||||
ENV = {'XDG_SESSION_TYPE': 'wayland', 'WAYLAND_DISPLAY': 'wayland-0',
|
||||
'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/mock/session-bus', 'XDG_CURRENT_DESKTOP': 'KDE'}
|
||||
|
||||
|
||||
class HelperTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.stack = contextlib.ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
patches = [mock.patch.dict(os.environ, ENV, clear=True),
|
||||
mock.patch.object(helper.sys, 'platform', 'linux'),
|
||||
mock.patch.object(helper.os, 'geteuid', return_value=1000, create=True),
|
||||
mock.patch.object(helper.Path, 'exists', return_value=False),
|
||||
mock.patch.object(helper, '_launch', None),
|
||||
mock.patch.object(helper, '_steam_running', return_value=False),
|
||||
mock.patch.object(helper.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n),
|
||||
mock.patch.object(helper.subprocess, 'Popen'),
|
||||
mock.patch.object(helper.subprocess, 'run')]
|
||||
started = [self.stack.enter_context(p) for p in patches]
|
||||
self.running, self.which, self.spawn, self.run = started[-4:]
|
||||
self.spawn.return_value.poll.return_value = None
|
||||
self.spawn.return_value.wait.side_effect = subprocess.TimeoutExpired('app', 1)
|
||||
self.run.return_value = subprocess.CompletedProcess([], 0, 'kcm_app-permissions - Application Permissions\n', '')
|
||||
|
||||
def action(self, action):
|
||||
return helper.action({'action': action, 'confirmed': True})
|
||||
|
||||
def test_status_is_read_only(self):
|
||||
self.assertTrue(helper.status()['available'])
|
||||
self.spawn.assert_not_called()
|
||||
self.run.assert_not_called()
|
||||
self.running.assert_not_called()
|
||||
|
||||
def test_platform_and_local_session_gates(self):
|
||||
for platform in ('win32', 'darwin'):
|
||||
with self.subTest(platform=platform), mock.patch.object(helper.sys, 'platform', platform):
|
||||
self.assertFalse(helper.status()['available'])
|
||||
with self.assertRaises(helper.HelperError):
|
||||
self.action('launch')
|
||||
for change in ({'FRAME_LOCAL': '1'}, {'XDG_SESSION_TYPE': 'x11'}, {'WAYLAND_DISPLAY': ''},
|
||||
{'DBUS_SESSION_BUS_ADDRESS': ''}, {'SSH_CONNECTION': 'remote'}, {'SSH_CLIENT': 'remote'}):
|
||||
with self.subTest(change=change), mock.patch.dict(os.environ, change):
|
||||
for action in ('launch', 'permissions'):
|
||||
with self.assertRaises(helper.HelperError):
|
||||
self.action(action)
|
||||
self.spawn.assert_not_called()
|
||||
self.run.assert_not_called()
|
||||
|
||||
def test_root_and_sandbox_refused(self):
|
||||
with mock.patch.object(helper.os, 'geteuid', return_value=0):
|
||||
self.assertIn('not root', helper.status()['reason'])
|
||||
with mock.patch.object(helper.Path, 'exists', return_value=True):
|
||||
with self.assertRaisesRegex(helper.HelperError, 'sandbox'):
|
||||
self.action('launch')
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
def test_only_explicit_supported_actions(self):
|
||||
for body in ({}, {'action': 'launch'}, {'action': 'permissions', 'confirmed': 'true'},
|
||||
{'action': 'delete-all', 'confirmed': True}):
|
||||
with self.subTest(body=body), self.assertRaises(helper.HelperError):
|
||||
helper.action(body)
|
||||
self.spawn.assert_not_called()
|
||||
self.run.assert_not_called()
|
||||
|
||||
def test_launch_exact_command_and_duplicate_guard(self):
|
||||
result = self.action('launch')
|
||||
self.assertTrue(result['launched'])
|
||||
self.assertIn('does not confirm', result['message'])
|
||||
self.spawn.assert_called_once_with(['/usr/bin/steam', '-pipewire'], stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
with self.assertRaisesRegex(helper.HelperError, 'already running or starting'):
|
||||
self.action('launch')
|
||||
self.assertEqual(self.spawn.call_count, 1)
|
||||
self.run.assert_not_called()
|
||||
|
||||
def test_running_and_unknown_steam_fail_closed(self):
|
||||
self.running.return_value = True
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Steam > Exit'):
|
||||
self.action('launch')
|
||||
self.running.side_effect = helper.HelperError('Cannot verify')
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Cannot verify'):
|
||||
self.action('launch')
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
def test_missing_launcher_does_not_guess_flatpak(self):
|
||||
self.which.side_effect = None
|
||||
self.which.return_value = None
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Flatpak-only'):
|
||||
self.action('launch')
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
def test_launch_error_is_not_success(self):
|
||||
self.spawn.side_effect = OSError('missing')
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Could not start'):
|
||||
self.action('launch')
|
||||
|
||||
def test_immediate_application_failure_is_not_success(self):
|
||||
self.spawn.return_value.wait.side_effect = None
|
||||
self.spawn.return_value.wait.return_value = 1
|
||||
for action in ('launch', 'permissions'):
|
||||
with self.subTest(action=action), mock.patch.object(helper, '_launch', None):
|
||||
with self.assertRaisesRegex(helper.HelperError, 'exited with an error'):
|
||||
self.action(action)
|
||||
|
||||
def test_permissions_opens_only_verified_exact_module_never_resets(self):
|
||||
result = self.action('permissions')
|
||||
self.assertTrue(result['opened'])
|
||||
self.assertFalse(result['reset'])
|
||||
self.assertIn('No permission was changed', result['message'])
|
||||
self.run.assert_called_once_with(['/usr/bin/kcmshell6', '--list'], capture_output=True,
|
||||
text=True, timeout=5, stdin=subprocess.DEVNULL)
|
||||
self.assertEqual(self.spawn.call_args.args[0], ['/usr/bin/kcmshell6', 'kcm_app-permissions'])
|
||||
self.running.assert_not_called()
|
||||
|
||||
def test_wrong_module_missing_tool_and_other_desktops_only_give_guidance(self):
|
||||
for listing in ('kcm_flatpak - Flatpak Permissions', 'evil_kcm_app-permissions - nope', ''):
|
||||
self.run.return_value.stdout = listing
|
||||
result = self.action('permissions')
|
||||
self.assertFalse(result['reset'])
|
||||
self.assertFalse(result['opened'])
|
||||
self.which.side_effect = None
|
||||
self.which.return_value = None
|
||||
self.assertFalse(self.action('permissions')['opened'])
|
||||
self.which.reset_mock()
|
||||
with mock.patch.dict(os.environ, {'XDG_CURRENT_DESKTOP': 'GNOME'}):
|
||||
self.assertFalse(self.action('permissions')['opened'])
|
||||
self.which.assert_not_called()
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
def test_module_listing_error_or_timeout_is_guidance_not_reset(self):
|
||||
for error in (OSError('missing'), subprocess.TimeoutExpired('kcm', 5)):
|
||||
self.run.side_effect = error
|
||||
result = self.action('permissions')
|
||||
self.assertFalse(result['opened'])
|
||||
self.assertFalse(result['reset'])
|
||||
self.assertIn('kde-config-flatpak', result['message'])
|
||||
self.spawn.assert_not_called()
|
||||
|
||||
|
||||
class ProcessTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.stack = contextlib.ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
self.tmp = self.stack.enter_context(tempfile.TemporaryDirectory())
|
||||
self.proc = Path(self.tmp)
|
||||
# Match the temp files' owner even when tests run as root or on Windows.
|
||||
self.stack.enter_context(mock.patch.object(helper.os, 'geteuid', return_value=self.proc.stat().st_uid, create=True))
|
||||
self.add('1', 'python', b'python\0server.py\0')
|
||||
|
||||
def add(self, pid, comm, cmdline):
|
||||
p = self.proc / pid
|
||||
p.mkdir()
|
||||
(p / 'comm').write_text(comm)
|
||||
(p / 'cmdline').write_bytes(cmdline)
|
||||
|
||||
def test_native_launcher_webhelper_and_flatpak_detected(self):
|
||||
for comm, cmd in [('steam', b'/x/steam\0'), ('bash', b'bash\0/x/steam.sh\0'),
|
||||
('steamwebhelper', b'/x/steamwebhelper\0'),
|
||||
('flatpak', b'flatpak\0run\0com.valvesoftware.Steam\0')]:
|
||||
with self.subTest(comm=comm):
|
||||
(self.proc / '1' / 'comm').write_text(comm)
|
||||
(self.proc / '1' / 'cmdline').write_bytes(cmd)
|
||||
self.assertTrue(helper._steam_running(self.proc))
|
||||
|
||||
def test_unrelated_names_are_not_steam(self):
|
||||
self.add('2', 'steamdeck-tools', b'/bin/steam-tools\0')
|
||||
self.assertFalse(helper._steam_running(self.proc))
|
||||
|
||||
def test_other_user_steam_is_not_this_users_instance(self):
|
||||
self.add('2', 'steam', b'steam\0')
|
||||
with mock.patch.object(helper.os, 'geteuid', return_value=self.proc.stat().st_uid + 1):
|
||||
self.assertFalse(helper._steam_running(self.proc))
|
||||
|
||||
def test_exited_process_is_skipped(self):
|
||||
(self.proc / '2').mkdir()
|
||||
self.assertFalse(helper._steam_running(self.proc))
|
||||
|
||||
def test_unreadable_or_missing_inventory_fails_closed(self):
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Cannot verify'):
|
||||
helper._steam_running(self.proc / 'missing')
|
||||
with mock.patch.object(helper.Path, 'read_bytes', side_effect=PermissionError('denied')):
|
||||
with self.assertRaisesRegex(helper.HelperError, 'Cannot verify'):
|
||||
helper._steam_running(self.proc)
|
||||
|
||||
|
||||
class RouteTests(unittest.TestCase):
|
||||
def test_headset_and_private_servers_never_call_helper(self):
|
||||
for local, private in ((True, False), (False, True)):
|
||||
with mock.patch.object(server, 'LOCAL', local), mock.patch.object(server, 'PRIVATE', private), \
|
||||
mock.patch.object(helper, 'action') as action, mock.patch.object(helper, 'status') as status:
|
||||
self.assertFalse(server.steam_pipewire_state()['available'])
|
||||
with self.assertRaises(server.Failure):
|
||||
server.steam_pipewire_action({'action': 'launch', 'confirmed': True})
|
||||
action.assert_not_called()
|
||||
status.assert_not_called()
|
||||
|
||||
def test_http_guards_get_read_only_and_post_bypasses_headset_work(self):
|
||||
with ThreadingHTTPServer(('127.0.0.1', 0), server.Handler) as httpd:
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
def request(method, body=None, headers=None):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', httpd.server_port)
|
||||
conn.request(method, '/api/steam-pipewire', json.dumps(body) if body else None, headers or {})
|
||||
reply = conn.getresponse()
|
||||
code, data = reply.status, json.loads(reply.read())
|
||||
conn.close()
|
||||
return code, data
|
||||
with mock.patch.object(server, 'LOCAL', False), mock.patch.object(server, 'PRIVATE', False), \
|
||||
mock.patch.object(server, 'working', side_effect=AssertionError('headset work')), \
|
||||
mock.patch.object(server, 'ssh', side_effect=AssertionError('Frame access')), \
|
||||
mock.patch.object(helper, 'action', return_value={'reset': False}) as action, \
|
||||
mock.patch.object(helper, 'status', return_value={'available': True}) as status:
|
||||
self.assertEqual(request('POST', {'action': 'launch'})[0], 403)
|
||||
self.assertEqual(request('GET')[0], 403)
|
||||
action.assert_not_called()
|
||||
headers = {'X-Frame-UI': server.UI_KEY, 'X-Frame-Device': 'unrelated-device'}
|
||||
self.assertEqual(request('GET', headers=headers), (200, {'available': True}))
|
||||
action.assert_not_called()
|
||||
status.assert_called_once()
|
||||
body = {'action': 'permissions', 'confirmed': True}
|
||||
self.assertEqual(request('POST', body, headers), (200, {'reset': False}))
|
||||
action.assert_called_once_with(body)
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
class PageTests(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page handlers')
|
||||
def test_production_page_handlers(self):
|
||||
result = subprocess.run(['node', '--test', str(ROOT / 'tests/steam_pipewire_ui.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Synthetic sysfs fixtures and loopback HTTP; never require an adapter or a Frame."""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_wireless_doctor as wd
|
||||
|
||||
|
||||
class Parsers(unittest.TestCase):
|
||||
def test_channel_without_leaking_identifiers(self):
|
||||
for frequency, band, channel in [(2412, '2.4 GHz', 1), (5180, '5 GHz', 36), (5975, '6 GHz', 5)]:
|
||||
text = f'Interface wlan0\n addr aa:bb:cc:dd:ee:ff\n ssid private\n channel {channel} ({frequency} MHz), width: 80 MHz'
|
||||
self.assertEqual(wd.radio_info(text), dict(channel=channel, frequency_mhz=frequency, band=band))
|
||||
self.assertIsNone(wd.radio_info('Interface wlan0\n type managed\n'))
|
||||
self.assertEqual(wd.supported_frequencies('* 2412 MHz [1] (20.0 dBm)\n* 5975 MHz [5] (disabled)\n* 5180 MHz [36] (23.0 dBm)'), [2412, 5180])
|
||||
|
||||
def test_non_linux_and_headset_mode_do_not_read_or_run(self):
|
||||
for kwargs in ({'system': 'darwin'}, {'system': 'win32'}, {'system': 'linux', 'local': True}):
|
||||
with patch.object(wd, 'Reader', side_effect=AssertionError('must not probe')):
|
||||
self.assertFalse(wd.diagnose(**kwargs)['supported'])
|
||||
with patch.dict(os.environ, {'FRAME_LOCAL': '1'}), patch.object(wd, 'Reader') as reader:
|
||||
self.assertFalse(wd.diagnose(system='linux')['supported'])
|
||||
reader.assert_not_called()
|
||||
|
||||
def test_command_failure_is_not_empty_success(self):
|
||||
reader = wd.Reader()
|
||||
for error, status in [(FileNotFoundError(), 'missing'), (PermissionError(), 'unavailable'),
|
||||
(subprocess.TimeoutExpired('iw', 3), 'timeout')]:
|
||||
with patch.object(wd.subprocess, 'run', side_effect=error):
|
||||
self.assertEqual(reader.command(['iw', 'dev', 'wlan0', 'info'])['status'], status)
|
||||
with patch.object(wd.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, '', 'Operation not permitted')) as run:
|
||||
result = reader.command(['iw', 'dev', 'wlan0', 'info'])
|
||||
self.assertEqual(result['status'], 'denied')
|
||||
self.assertEqual(result['text'], '')
|
||||
self.assertEqual(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
|
||||
self.assertEqual(run.call_args.kwargs['timeout'], 3)
|
||||
self.assertNotIn('shell', run.call_args.kwargs)
|
||||
|
||||
def test_read_failure_and_empty_inventory_remain_unknown(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
reader = wd.Reader(root, root, root)
|
||||
report = wd.diagnose(reader=reader, system='linux', local=False)
|
||||
checks = {c['id']: c for c in report['checks']}
|
||||
self.assertEqual(checks['presence']['status'], 'unknown')
|
||||
self.assertIn('reads', checks)
|
||||
with patch.object(Path, 'open', side_effect=PermissionError()):
|
||||
self.assertIsNone(reader.read(Path('/synthetic/denied')))
|
||||
self.assertIn('PermissionError', reader.gaps[-1])
|
||||
|
||||
|
||||
class Page(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node is needed for real page script checks')
|
||||
def test_on_demand_selection_and_failure_recovery(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent / 'page/wireless_doctor.mjs')],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
class FixtureReader(wd.Reader):
|
||||
"""Invented USB 1234:5678; it is deliberately not a claimed Valve ID."""
|
||||
def __init__(self, root):
|
||||
super().__init__(root / 'sys', root / 'dev', root / 'proc')
|
||||
self.commands = []
|
||||
self.responses = {}
|
||||
|
||||
def command(self, argv):
|
||||
self.commands.append(argv)
|
||||
return self.responses.get(argv[0], {'status': 'missing', 'detail': argv[0] + ' is not installed', 'text': ''})
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux', 'Linux sysfs symlink fixture')
|
||||
class LinuxEvidence(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.r = FixtureReader(self.root)
|
||||
self.usb = self.r.sysfs / 'devices/pci0000:00/usb1/1-2'
|
||||
for name, content in {'idVendor': '1234', 'idProduct': '5678', 'product': 'Synthetic adapter', 'busnum': '1', 'devnum': '2'}.items():
|
||||
self.write(self.usb / name, content)
|
||||
bus = self.r.sysfs / 'bus/usb/devices'
|
||||
bus.mkdir(parents=True)
|
||||
(bus / '1-2').symlink_to(self.usb, target_is_directory=True)
|
||||
(self.r.sysfs / 'class/net').mkdir(parents=True)
|
||||
(self.r.sysfs / 'class/rfkill').mkdir(parents=True)
|
||||
self.r.proc.mkdir(parents=True)
|
||||
self.write(self.r.dev / 'bus/usb/001/002', '')
|
||||
|
||||
def write(self, path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
|
||||
def nic(self, name='wlan0', index=0, driver=True):
|
||||
device = self.usb / f'1-2:1.{index}'
|
||||
device.mkdir(parents=True, exist_ok=True)
|
||||
net = self.r.sysfs / 'class/net' / name
|
||||
net.mkdir()
|
||||
(net / 'device').symlink_to(device, target_is_directory=True)
|
||||
(net / 'wireless').mkdir()
|
||||
self.write(net / 'operstate', 'down')
|
||||
phy = self.r.sysfs / f'devices/ieee80211/phy{index}'
|
||||
phy.mkdir(parents=True)
|
||||
(net / 'phy80211').symlink_to(phy, target_is_directory=True)
|
||||
if driver:
|
||||
binding = self.r.sysfs / 'bus/usb/drivers/synthetic_driver'
|
||||
binding.mkdir(parents=True, exist_ok=True)
|
||||
module = self.r.sysfs / 'module/synthetic_module'
|
||||
module.mkdir(parents=True, exist_ok=True)
|
||||
if not (binding / 'module').exists():
|
||||
(binding / 'module').symlink_to(module, target_is_directory=True)
|
||||
(device / 'driver').symlink_to(binding, target_is_directory=True)
|
||||
return net
|
||||
|
||||
def report(self, device='usb:1-2'):
|
||||
return wd.diagnose(device, reader=self.r, system='linux', local=False)
|
||||
|
||||
def checks(self, report):
|
||||
return {c['id']: c for c in report['checks']}
|
||||
|
||||
def test_inventory_does_not_select_or_execute_commands(self):
|
||||
self.nic()
|
||||
report = self.report(None)
|
||||
self.assertIsNone(report['selected'])
|
||||
self.assertEqual(report['usb'][0]['interfaces'], ['net:wlan0'])
|
||||
self.assertEqual(report['interfaces'][0]['usb'], 'usb:1-2')
|
||||
self.assertIn('Unverified', report['identity'])
|
||||
self.assertEqual(self.r.commands, [])
|
||||
|
||||
def test_nearest_usb_ancestor_wins_over_upstream_hub(self):
|
||||
hub = self.usb.parent / '1-1'
|
||||
hub.mkdir()
|
||||
old = self.usb
|
||||
self.usb = hub / old.name
|
||||
old.rename(self.usb)
|
||||
bus = self.r.sysfs / 'bus/usb/devices'
|
||||
(bus / '1-2').unlink()
|
||||
(bus / '1-2').symlink_to(self.usb, target_is_directory=True)
|
||||
for name, content in {'idVendor': '4321', 'idProduct': '8765', 'busnum': '1', 'devnum': '1'}.items():
|
||||
self.write(hub / name, content)
|
||||
(bus / '1-1').symlink_to(hub, target_is_directory=True)
|
||||
self.nic()
|
||||
report = self.report(None)
|
||||
self.assertEqual(report['interfaces'][0]['usb'], 'usb:1-2')
|
||||
upstream_hub = next(d for d in report['usb'] if d['port'] == '1-1')
|
||||
self.assertEqual(upstream_hub['interfaces'], [])
|
||||
|
||||
def test_unbound_usb_is_selectable_without_guessing_driver(self):
|
||||
report = self.report()
|
||||
checks = self.checks(report)
|
||||
self.assertEqual(checks['binding']['status'], 'unknown')
|
||||
self.assertIn('No wireless interface', checks['binding']['evidence'][0])
|
||||
self.assertEqual(checks['channel']['status'], 'unknown')
|
||||
self.assertNotIn('iw', [a[0] for a in self.r.commands])
|
||||
self.assertIn('1234:5678', json.dumps(report))
|
||||
|
||||
def test_selected_radio_and_all_commands_are_read_only(self):
|
||||
self.nic()
|
||||
for key, value in {'type': 'wlan', 'name': 'phy0', 'soft': '1', 'hard': '0'}.items():
|
||||
self.write(self.r.sysfs / 'class/rfkill/rfkill0' / key, value)
|
||||
for pid, name in enumerate(('NetworkManager', 'iwd', 'wpa_supplicant'), 1):
|
||||
self.write(self.r.proc / str(pid) / 'comm', name)
|
||||
self.r.responses = {
|
||||
'udevadm': dict(status='ok', detail='udevadm completed', text='ID_NET_DRIVER=synthetic_driver\nID_SERIAL=secret_serial\nTAGS=:uaccess:\n'),
|
||||
'iw': dict(status='ok', detail='iw completed', text='channel 5 (5975 MHz)\naddr aa:bb:cc:dd:ee:ff\nssid private\n* 5975 MHz [5] (23.0 dBm)'),
|
||||
'nmcli': dict(status='ok', detail='nmcli completed', text='30 (disconnected)\nyes\nsynthetic_driver\n')}
|
||||
report = self.report('net:wlan0')
|
||||
checks = self.checks(report)
|
||||
self.assertEqual(checks['binding']['status'], 'ok')
|
||||
self.assertIn('module=synthetic_module', checks['binding']['evidence'][0])
|
||||
self.assertEqual(checks['rfkill']['status'], 'warning')
|
||||
self.assertIn('6 GHz', json.dumps(checks['channel']))
|
||||
self.assertIn('coexistence alone is not a conflict', checks['managers']['suggestion'])
|
||||
self.assertIn('Both iwd and wpa_supplicant', checks['managers']['suggestion'])
|
||||
for secret in ('secret_serial', 'aa:bb:cc:dd:ee:ff', 'ssid private'):
|
||||
self.assertNotIn(secret, json.dumps(report))
|
||||
self.assertEqual(self.r.commands, [
|
||||
['udevadm', 'info', '--query=property', '--path=' + str(self.r.sysfs / 'class/net/wlan0')],
|
||||
['iw', 'dev', 'wlan0', 'info'], ['iw', 'phy', 'phy0', 'info'],
|
||||
['nmcli', '-g', 'GENERAL.STATE,GENERAL.NM-MANAGED,GENERAL.DRIVER', 'device', 'show', 'wlan0']])
|
||||
|
||||
def test_unrelated_rfkill_and_missing_tools_are_not_healthy(self):
|
||||
self.nic(driver=False)
|
||||
for key, value in {'type': 'wlan', 'name': 'phy7', 'soft': '1', 'hard': '1'}.items():
|
||||
self.write(self.r.sysfs / 'class/rfkill/rfkill7' / key, value)
|
||||
checks = self.checks(self.report())
|
||||
self.assertEqual(checks['binding']['status'], 'unknown')
|
||||
self.assertEqual(checks['rfkill']['status'], 'unknown')
|
||||
self.assertEqual(checks['permissions']['status'], 'unknown')
|
||||
self.assertEqual(checks['channel']['status'], 'unknown')
|
||||
self.r.responses['iw'] = dict(status='denied', detail='iw exited 1 (permission denied)', text='')
|
||||
self.assertIn('permission denied', json.dumps(self.checks(self.report())['channel']))
|
||||
|
||||
def test_unmanaged_interface_does_not_trigger_a_network_repair(self):
|
||||
self.nic()
|
||||
self.r.responses['nmcli'] = dict(status='ok', detail='nmcli completed', text='10 (unmanaged)\nno\nsynthetic_driver\n')
|
||||
check = self.checks(self.report())['managers']
|
||||
self.assertIn('NetworkManager managed: no', check['evidence'])
|
||||
self.assertIn('unmanaged alone is not a fault', check['suggestion'])
|
||||
self.assertIn('Do not force management on', check['suggestion'])
|
||||
|
||||
def test_multiple_interfaces_require_explicit_interface(self):
|
||||
self.nic()
|
||||
self.nic('wlan1', 1)
|
||||
checks = self.checks(self.report())
|
||||
self.assertIn('interface-selection', checks)
|
||||
self.assertEqual(checks['channel']['status'], 'unknown')
|
||||
self.assertNotIn('iw', [a[0] for a in self.r.commands])
|
||||
self.assertNotIn('interface-selection', self.checks(self.report('net:wlan1')))
|
||||
|
||||
def test_disappeared_or_injected_selector_is_rejected_before_commands(self):
|
||||
for selector in ('net:missing', 'usb:../../etc', 'net:;sudo reboot', '', ['wlan0']):
|
||||
with self.assertRaises(ValueError):
|
||||
self.report(selector)
|
||||
self.assertEqual(self.r.commands, [])
|
||||
|
||||
def test_cli_json_and_exit_contract(self):
|
||||
with patch.object(wd, 'diagnose', return_value=self.report(None)), contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
self.assertEqual(wd.main(['--json']), 0)
|
||||
self.assertEqual(json.loads(out.getvalue())['schema'], 1)
|
||||
with patch.object(wd, 'diagnose', return_value={'supported': False}), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(wd.main(['--json']), 2)
|
||||
|
||||
|
||||
class HttpBoundary(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import server
|
||||
cls.server_module = server
|
||||
# Serve the real handler only: main() would start unrelated headset workers.
|
||||
cls.httpd = server.LoopbackServer(('127.0.0.1', 0), server.Handler)
|
||||
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.httpd.shutdown()
|
||||
cls.httpd.server_close()
|
||||
cls.thread.join()
|
||||
|
||||
def request(self, path, key=True):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', self.httpd.server_address[1], timeout=10)
|
||||
headers = {'X-Frame-UI': self.server_module.UI_KEY} if key else {}
|
||||
conn.request('GET', path, headers=headers)
|
||||
response = conn.getresponse()
|
||||
result = response.status, response.read()
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
def test_auth_and_headset_independence(self):
|
||||
with patch.object(self.server_module, 'ssh', side_effect=AssertionError('no SSH')), \
|
||||
patch.object(self.server_module, 'working', side_effect=AssertionError('no headset lock')), \
|
||||
patch.object(wd, 'diagnose', return_value={'supported': True, 'checks': []}) as probe:
|
||||
self.assertEqual(self.request('/api/wireless-doctor', key=False)[0], 403)
|
||||
probe.assert_not_called()
|
||||
code, data = self.request('/api/wireless-doctor?device=net%3Awlan0')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertTrue(json.loads(data)['supported'])
|
||||
probe.assert_called_once_with('net:wlan0', local=self.server_module.LOCAL)
|
||||
|
||||
def test_local_mode_refuses_before_collection(self):
|
||||
with patch.object(self.server_module, 'LOCAL', True), patch.object(wd, 'Reader', side_effect=AssertionError('no probes')):
|
||||
code, body = self.request('/api/wireless-doctor')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertFalse(json.loads(body)['supported'])
|
||||
|
||||
def test_stale_selection_is_bad_request_and_page_is_packaged(self):
|
||||
with patch.object(wd, 'diagnose', side_effect=ValueError('Device is no longer present')):
|
||||
self.assertEqual(self.request('/api/wireless-doctor?device=net:gone')[0], 400)
|
||||
code, page = self.request('/wireless-doctor')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn(b'No checks have run', page)
|
||||
package = json.loads((Path(__file__).resolve().parent.parent / 'app/package.json').read_text())
|
||||
filters = next(r['filter'] for r in package['build']['extraResources'] if r['from'] == '../ui')
|
||||
self.assertIn('*.py', filters)
|
||||
self.assertIn('*.html', filters)
|
||||
@@ -25,7 +25,7 @@ function workspaceFixture() {
|
||||
const context={URL,document:{querySelectorAll:s=>s==='.page'?pages:s==='#nav a'?links:controls[s]||[],title:'',body:{addEventListener(k,fn){listeners['body-'+k]=fn;}}},
|
||||
window:win,location,scroller:()=>({get scrollTop(){return win.scrollY;},scrollTo:o=>win.scrollTo(o)}),scrollToY:y=>win.scrollTo({top:y}),requestAnimationFrame:fn=>fn(),$:el,live:false,toggleLive(on){this.live=on;calls.push(['live',on]);},
|
||||
loadAndroid(){calls.push(['android']);},closeDevPop(){},metaLib:{connected:false},renderMetaApps(){},
|
||||
loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
|
||||
loadSteamPipewire(){calls.push(['steam-pipewire']);},loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
|
||||
gm:{src:'owned',dl:null,byId:new Map(),shown:0,shownBySource:{owned:0,store:0}},setGameSource(src){context.gm.src=src;},state:null,stateRead:{valid:false},online:null,devGen:0,androidApps:[],jobsRunning:0,viewGen:0,
|
||||
esc:s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))};
|
||||
vm.createContext(context);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Local Linux helper for Steam's own desktop sharing; never a Frame client.
|
||||
|
||||
No portal grants/tokens are read or written. KDE owns the manual Revoke UI.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
MODULE = 'kcm_app-permissions'
|
||||
GUIDANCE = (
|
||||
'In KDE System Settings > Applications > Application Permissions, find the Steam entry '
|
||||
'(reported as Steam Linux Runtime 3.0). Under Screen sharing, choose Manage 1 Session '
|
||||
'(the count may differ), verify it belongs to Steam, then Revoke only that session. '
|
||||
'Do not use Revoke All or change another app. Retry Steam desktop sharing to choose a screen. '
|
||||
'If the entry is missing or ambiguous, stop; it can appear intermittently. '
|
||||
'If Application Permissions is missing on Ubuntu/Kubuntu, check whether your distribution '
|
||||
'provides kde-config-flatpak; the reporter needed that package and a reboot. '
|
||||
'Frame Control does not install packages or change permissions.'
|
||||
)
|
||||
_lock = threading.Lock()
|
||||
_launch = None
|
||||
|
||||
|
||||
class HelperError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def unavailable():
|
||||
if sys.platform != 'linux' or os.environ.get('FRAME_LOCAL') == '1':
|
||||
return 'This helper runs on your Linux PC, not the headset.'
|
||||
if os.geteuid() == 0:
|
||||
return 'Open Frame Control as your normal desktop user, not root.'
|
||||
if Path('/.flatpak-info').exists():
|
||||
return 'This sandbox cannot verify host Steam processes. Use a terminal on your PC.'
|
||||
if os.environ.get('SSH_CONNECTION') or os.environ.get('SSH_CLIENT'):
|
||||
return 'Open Frame Control in your local Wayland desktop session, not over SSH.'
|
||||
if (os.environ.get('XDG_SESSION_TYPE', '').lower() != 'wayland'
|
||||
or not os.environ.get('WAYLAND_DISPLAY') or not os.environ.get('DBUS_SESSION_BUS_ADDRESS')):
|
||||
return 'A local Wayland desktop with its session bus is required. X11 and headless sessions are unsupported.'
|
||||
return ''
|
||||
|
||||
|
||||
def status():
|
||||
reason = unavailable()
|
||||
return {'available': not reason, 'reason': reason, 'guidance': GUIDANCE}
|
||||
|
||||
|
||||
def _steam_running(proc=Path('/proc')):
|
||||
"""Fail closed if our process inventory cannot be read. No command execution.
|
||||
|
||||
Include launch scripts and Flatpak Steam, not just the final Steam binary.
|
||||
Other users' processes are irrelevant to this user's Steam instance.
|
||||
"""
|
||||
try:
|
||||
entries = list(proc.iterdir())
|
||||
if not any(p.name.isdecimal() for p in entries):
|
||||
raise OSError('empty process inventory')
|
||||
for p in entries:
|
||||
if not p.name.isdecimal():
|
||||
continue
|
||||
try:
|
||||
if p.stat().st_uid != os.geteuid():
|
||||
continue
|
||||
comm = (p / 'comm').read_text().strip().lower()
|
||||
args = (p / 'cmdline').read_bytes().decode('utf-8', 'replace').split('\0')
|
||||
except FileNotFoundError:
|
||||
continue # a process exited while scanning
|
||||
names = {Path(a).name.lower() for a in args if a}
|
||||
if (comm in {'steam', 'steamwebhelper', 'steam.sh'}
|
||||
or names.intersection({'steam', 'steam.sh', 'steamwebhelper', 'com.valvesoftware.steam'})):
|
||||
return True
|
||||
return False
|
||||
except OSError as exc:
|
||||
raise HelperError('Cannot verify whether Steam is running. Exit Steam and launch '
|
||||
'steam -pipewire yourself from a local terminal.') from exc
|
||||
|
||||
|
||||
def _spawn(argv):
|
||||
try:
|
||||
return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
except OSError as exc:
|
||||
raise HelperError('Could not start the local application. Check it is installed and opens '
|
||||
'from your desktop. No permission was changed.') from exc
|
||||
|
||||
|
||||
def _check_start(child):
|
||||
try:
|
||||
code = child.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
return
|
||||
if code:
|
||||
raise HelperError('The local application exited with an error. Try opening it from your '
|
||||
'desktop to see its error. No permission was changed.')
|
||||
|
||||
|
||||
def action(body):
|
||||
global _launch
|
||||
kind = body.get('action')
|
||||
if kind not in ('launch', 'permissions'):
|
||||
raise HelperError('Unknown Steam helper action.')
|
||||
if body.get('confirmed') is not True:
|
||||
raise HelperError('Use the Steam helper button to request this action explicitly.')
|
||||
reason = unavailable()
|
||||
if reason:
|
||||
raise HelperError(reason)
|
||||
with _lock:
|
||||
if kind == 'launch':
|
||||
if (_launch is not None and _launch.poll() is None) or _steam_running():
|
||||
raise HelperError('Steam is already running or starting. Exit Steam using Steam > Exit '
|
||||
'(after saving games), then click Launch Steam with PipeWire again. '
|
||||
'Frame Control will not stop it or change an existing session.')
|
||||
steam = shutil.which('steam')
|
||||
if not steam:
|
||||
raise HelperError('No steam launcher was found on PATH. For a Flatpak-only install, '
|
||||
'exit Steam and run flatpak run com.valvesoftware.Steam -pipewire '
|
||||
'yourself from a local terminal. Nothing was launched.')
|
||||
_launch = _spawn([steam, '-pipewire'])
|
||||
_check_start(_launch)
|
||||
return {'launched': True, 'message': 'Requested Steam with -pipewire for this launch only. '
|
||||
'Use Steam\'s desktop-in-headset flow; Steam and your desktop portal control the picker. '
|
||||
'This does not confirm screen sharing started.'}
|
||||
|
||||
# There is no portable verified mapping from Steam's runtime display name
|
||||
# to a portal restore token. Never guess an app ID or delete a grant.
|
||||
desktop = os.environ.get('XDG_CURRENT_DESKTOP', '').upper().split(':')
|
||||
kcm = shutil.which('kcmshell6') if 'KDE' in desktop else None
|
||||
if not kcm:
|
||||
return {'reset': False, 'opened': False, 'message': 'No permission was changed. '
|
||||
'KDE Application Permissions could not be opened automatically. ' + GUIDANCE}
|
||||
try:
|
||||
listing = subprocess.run([kcm, '--list'], capture_output=True, text=True, timeout=5,
|
||||
stdin=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
listing = None
|
||||
if (listing is None or listing.returncode
|
||||
or not re.search(r'^\s*' + re.escape(MODULE) + r'\s+-', listing.stdout, re.M)):
|
||||
return {'reset': False, 'opened': False, 'message': 'No permission was changed. '
|
||||
'The KDE Application Permissions module is unavailable. ' + GUIDANCE}
|
||||
child = _spawn([kcm, MODULE])
|
||||
_check_start(child)
|
||||
return {'reset': False, 'opened': True, 'message': 'Requested KDE Application Permissions. '
|
||||
'No permission was changed by Frame Control. ' + GUIDANCE}
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Read-only Linux PC Wireless Adapter doctor. No SSH, scans or repairs.
|
||||
|
||||
Run on the PC: python3 ui/frame_wireless_doctor.py [--device net:wlan0] [--json]
|
||||
USB IDs are observations, never a claim that a device is Valve's adapter.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
class Reader:
|
||||
"""Bounded local reads; failures remain evidence rather than healthy defaults."""
|
||||
def __init__(self, sysfs=Path('/sys'), dev=Path('/dev'), proc=Path('/proc')):
|
||||
self.sysfs, self.dev, self.proc = Path(sysfs), Path(dev), Path(proc)
|
||||
self.gaps = []
|
||||
|
||||
def read(self, path):
|
||||
try:
|
||||
with path.open(errors='replace') as source:
|
||||
return source.read(4096).strip()
|
||||
except OSError as exc:
|
||||
self.gaps.append(f'{path}: {type(exc).__name__}')
|
||||
return None
|
||||
|
||||
def entries(self, path):
|
||||
try:
|
||||
return sorted(path.iterdir())
|
||||
except OSError as exc:
|
||||
self.gaps.append(f'{path}: {type(exc).__name__}')
|
||||
return []
|
||||
|
||||
def link(self, path):
|
||||
try:
|
||||
return path.resolve(strict=True).name
|
||||
except OSError as exc:
|
||||
if not isinstance(exc, FileNotFoundError):
|
||||
self.gaps.append(f'{path}: {type(exc).__name__}')
|
||||
return None
|
||||
|
||||
def command(self, argv):
|
||||
try:
|
||||
result = subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True,
|
||||
text=True, errors='replace', timeout=3,
|
||||
env={**os.environ, 'LC_ALL': 'C'})
|
||||
except FileNotFoundError:
|
||||
return {'status': 'missing', 'detail': f'{argv[0]} is not installed', 'text': ''}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {'status': 'timeout', 'detail': f'{argv[0]} timed out', 'text': ''}
|
||||
except OSError as exc:
|
||||
return {'status': 'unavailable', 'detail': f'{argv[0]}: {type(exc).__name__}', 'text': ''}
|
||||
if result.returncode:
|
||||
# Do not export command output that may contain SSIDs, MACs or other identifiers.
|
||||
denied = bool(re.search(r'permission|not permitted|access denied', result.stderr, re.I))
|
||||
return {'status': 'denied' if denied else 'unavailable',
|
||||
'detail': f'{argv[0]} exited {result.returncode}' + (' (permission denied)' if denied else ''),
|
||||
'text': ''}
|
||||
return {'status': 'ok', 'detail': f'{argv[0]} completed', 'text': result.stdout[:131072]}
|
||||
|
||||
|
||||
def inventory(reader):
|
||||
usb = []
|
||||
for path in reader.entries(reader.sysfs / 'bus/usb/devices'):
|
||||
# USB interface entries have no idVendor; inspect them under their parent below.
|
||||
if ':' in path.name or not (path / 'idVendor').exists():
|
||||
continue
|
||||
vid, pid = reader.read(path / 'idVendor'), reader.read(path / 'idProduct')
|
||||
bindings = []
|
||||
for child in reader.entries(path):
|
||||
if ':' in child.name:
|
||||
bindings.append({'interface': child.name, 'driver': reader.link(child / 'driver'),
|
||||
'module': reader.link(child / 'driver/module')})
|
||||
usb.append({'id': 'usb:' + path.name, 'port': path.name, 'vid': vid, 'pid': pid,
|
||||
'product': reader.read(path / 'product') if (path / 'product').exists() else None,
|
||||
'bus': reader.read(path / 'busnum'), 'number': reader.read(path / 'devnum'),
|
||||
'bindings': bindings, 'interfaces': []})
|
||||
interfaces = []
|
||||
for path in reader.entries(reader.sysfs / 'class/net'):
|
||||
if not ((path / 'wireless').exists() or (path / 'phy80211').exists()):
|
||||
continue
|
||||
try:
|
||||
device_path = (path / 'device').resolve(strict=True)
|
||||
ancestors = [device_path, *device_path.parents]
|
||||
except OSError:
|
||||
ancestors = []
|
||||
# Nearest USB ancestor: a downstream adapter must not be attributed to its hub.
|
||||
by_port = {d['port']: d for d in usb}
|
||||
parent = next((by_port[p.name] for p in ancestors if p.name in by_port), None)
|
||||
item = {'id': 'net:' + path.name, 'name': path.name,
|
||||
'usb': parent['id'] if parent else None, 'phy': reader.link(path / 'phy80211'),
|
||||
'driver': reader.link(path / 'device/driver'),
|
||||
'module': reader.link(path / 'device/driver/module'),
|
||||
'state': reader.read(path / 'operstate')}
|
||||
interfaces.append(item)
|
||||
if parent:
|
||||
parent['interfaces'].append(item['id'])
|
||||
return usb, interfaces
|
||||
|
||||
|
||||
def radio_info(text):
|
||||
"""Parse only channel facts; omit interface MAC, SSID and link identifiers."""
|
||||
match = re.search(r'channel (\d+) \((\d+) MHz\)', text)
|
||||
if not match:
|
||||
return None
|
||||
frequency = int(match[2])
|
||||
band = '2.4 GHz' if 2400 <= frequency < 2500 else '5 GHz' if 4900 <= frequency < 5925 else '6 GHz' if 5925 <= frequency <= 7125 else 'other'
|
||||
return {'channel': int(match[1]), 'frequency_mhz': frequency, 'band': band}
|
||||
|
||||
|
||||
def supported_frequencies(text):
|
||||
return sorted(set(int(n) for n in re.findall(r'\* (\d+) MHz \[\d+\](?![^\n]*disabled)', text)))
|
||||
|
||||
|
||||
def diagnose(device=None, *, reader=None, system=None, local=None):
|
||||
"""Collect only on explicit request. The server calls this outside headset work."""
|
||||
report = {'schema': 1, 'captured_at': datetime.now(timezone.utc).isoformat(), 'scope': 'Linux PC only; read-only', 'supported': False,
|
||||
'identity': 'Unverified: select hardware by its observed USB port or network interface. '
|
||||
'No trusted Steam Frame Wireless Adapter VID/PID is assumed.',
|
||||
'selected': device, 'usb': [], 'interfaces': [], 'checks': []}
|
||||
def check(key, status, title, evidence, suggestion):
|
||||
report['checks'].append(dict(id=key, status=status, title=title,
|
||||
evidence=evidence, suggestion=suggestion))
|
||||
if (system or sys.platform) != 'linux' or (os.environ.get('FRAME_LOCAL') == '1' if local is None else local):
|
||||
check('platform', 'unknown', 'Linux PC required', [],
|
||||
'Run this doctor on the Linux PC, not on the Frame or the phone server.')
|
||||
return report
|
||||
report.update(supported=True, kernel=platform.release())
|
||||
r = reader or Reader()
|
||||
usb, interfaces = inventory(r)
|
||||
report.update(usb=usb, interfaces=interfaces)
|
||||
items = usb + interfaces
|
||||
selected = next((d for d in items if d['id'] == device), None)
|
||||
if device is not None and selected is None:
|
||||
raise ValueError('Device is no longer present. Refresh and select an observed device.')
|
||||
check('presence', 'ok' if items else 'unknown', 'USB and wireless interface presence',
|
||||
[f'{len(usb)} USB devices; {len(interfaces)} wireless interfaces visible in sysfs'],
|
||||
'Select the intended adapter. USB presence alone does not establish wireless support.' if items else
|
||||
'No candidates are visible. Check the PC USB connection and whether a VM/container hides hardware; this does not prove the adapter is absent.')
|
||||
if selected is None:
|
||||
check('selection', 'unknown', 'Hardware identity needs selection', [],
|
||||
'Compare the inventory before and after reconnecting the adapter when safe. Select its USB port even if no network interface exists.')
|
||||
if r.gaps:
|
||||
check('reads', 'unknown', 'Some local evidence is unavailable', r.gaps,
|
||||
'Review unavailable paths locally; do not infer healthy hardware from missing evidence.')
|
||||
return report
|
||||
correlated = [d for d in interfaces if d['usb'] == device]
|
||||
nic = selected if device.startswith('net:') else correlated[0] if len(correlated) == 1 else None
|
||||
if len(correlated) > 1:
|
||||
check('interface-selection', 'unknown', 'More than one wireless interface',
|
||||
[d['id'] for d in correlated], 'Select a network interface for per-interface radio and ownership checks.')
|
||||
parent = selected if device.startswith('usb:') else next((d for d in usb if d['id'] == selected['usb']), None)
|
||||
names = [d['name'] for d in interfaces if parent and d['usb'] == parent['id']]
|
||||
check('binding', 'ok' if nic and nic['driver'] else 'unknown', 'Driver and module binding',
|
||||
([f"{nic['name']}: driver={nic['driver'] or 'unavailable'}, module={nic['module'] or 'unavailable (possibly built in)'}, state={nic['state'] or 'unknown'}"] if nic else ['Multiple wireless interfaces are correlated; select one explicitly.' if len(correlated) > 1 else 'No wireless interface is correlated with this USB device.']) +
|
||||
([f"USB {parent['port']} observed ID {parent['vid']}:{parent['pid']}; wireless interfaces: {', '.join(names) or 'none'}"] +
|
||||
[f"{b['interface']}: driver={b['driver'] or 'unbound'}, module={b['module'] or 'unavailable'}" for b in parent['bindings']] if parent else []),
|
||||
'A bound driver does not prove streaming support. A missing module link can mean a built-in driver.' if nic and nic['driver'] else
|
||||
'Check this observed device ID against your distribution/kernel and vendor documentation. No driver package or cause can be inferred; do not install a guessed driver or disable Secure Boot.')
|
||||
path = r.sysfs / ('class/net/' + nic['name'] if nic else 'bus/usb/devices/' + parent['port'])
|
||||
udev = r.command(['udevadm', 'info', '--query=property', '--path=' + str(path)])
|
||||
properties = [line for line in udev['text'].splitlines() if line.split('=', 1)[0] in
|
||||
('ID_NET_DRIVER', 'ID_USB_DRIVER', 'ID_VENDOR_ID', 'ID_MODEL_ID', 'TAGS', 'CURRENT_TAGS')]
|
||||
access = []
|
||||
if parent and (parent['bus'] or '').isdigit() and (parent['number'] or '').isdigit():
|
||||
node = r.dev / f"bus/usb/{int(parent['bus']):03d}/{int(parent['number']):03d}"
|
||||
try:
|
||||
st = node.stat()
|
||||
access.append(f'{node}: mode={stat.S_IMODE(st.st_mode):04o}, uid={st.st_uid}, gid={st.st_gid}, '
|
||||
f'user read={os.access(node, os.R_OK)}, write={os.access(node, os.W_OK)}')
|
||||
except OSError as exc:
|
||||
access.append(f'{node}: {type(exc).__name__}')
|
||||
check('permissions', 'info' if udev['status'] == 'ok' else 'unknown', 'Permissions and udev evidence',
|
||||
[udev['detail'], *properties, *access],
|
||||
'USB node access is only an observation; kernel networking may not require direct USB access. '
|
||||
'If Steam reports access denied for this exact device, compare the vendor/distribution udev guidance. Do not use broad chmod rules or run Steam as root.')
|
||||
blocks = []
|
||||
for entry in r.entries(r.sysfs / 'class/rfkill'):
|
||||
if r.read(entry / 'type') != 'wlan':
|
||||
continue
|
||||
phy = r.read(entry / 'name')
|
||||
blocks.append({'name': phy, 'soft': r.read(entry / 'soft'), 'hard': r.read(entry / 'hard'),
|
||||
'selected_phy': bool(nic and nic['phy'] and phy == nic['phy'])})
|
||||
relevant = [b for b in blocks if b['selected_phy']]
|
||||
blocked = any(b[k] == '1' for b in relevant for k in ('soft', 'hard'))
|
||||
complete = relevant and all(b[k] in ('0', '1') for b in relevant for k in ('soft', 'hard'))
|
||||
check('rfkill', 'warning' if blocked else 'ok' if complete else 'unknown', 'Radio blocks',
|
||||
[f"{b['name']}: soft={b['soft']}, hard={b['hard']}, selected PHY={b['selected_phy']}" for b in blocks] or ['No readable WLAN rfkill entries.'],
|
||||
'The selected PHY is blocked. Review the PC radio switch/airplane mode; a hard block may need a hardware switch.' if blocked else
|
||||
'Only a matching PHY establishes this adapter’s rfkill state. Unrelated or unavailable entries do not rule out a radio block.')
|
||||
if nic:
|
||||
iw = r.command(['iw', 'dev', nic['name'], 'info'])
|
||||
radio = radio_info(iw['text'])
|
||||
frequencies = []
|
||||
phy = nic['phy']
|
||||
if phy and re.fullmatch(r'phy\d+', phy):
|
||||
capabilities = r.command(['iw', 'phy', phy, 'info'])
|
||||
frequencies = supported_frequencies(capabilities['text'])
|
||||
else:
|
||||
capabilities = {'detail': 'No PHY identifier available'}
|
||||
check('channel', 'info' if radio else 'unknown', 'Current channel and band',
|
||||
[iw['detail'], json.dumps(radio) if radio else 'No current channel reported.',
|
||||
capabilities['detail'], f'Enabled frequencies reported by iw (MHz): {frequencies or "unavailable"}'],
|
||||
'This is local radio evidence, not proof of the Steam streaming link or supported regions. '
|
||||
'If no channel is reported the radio may be idle. Do not force a band, channel or regulatory country.')
|
||||
nm = r.command(['nmcli', '-g', 'GENERAL.STATE,GENERAL.NM-MANAGED,GENERAL.DRIVER', 'device', 'show', nic['name']])
|
||||
# These fields contain no SSIDs, addresses or credentials.
|
||||
fields = nm['text'].strip().splitlines()[:3] if nm['status'] == 'ok' else []
|
||||
nm_evidence = [f'{label}: {value}' for label, value in zip(
|
||||
('NetworkManager state', 'NetworkManager managed', 'NetworkManager driver'), fields)]
|
||||
unmanaged = len(fields) > 1 and fields[1].lower() == 'no'
|
||||
else:
|
||||
nm = {'detail': 'No correlated wireless interface; per-interface ownership unavailable'}
|
||||
nm_evidence = []
|
||||
unmanaged = False
|
||||
check('channel', 'unknown', 'Current channel and band', [], 'Select one correlated network interface to check channel/band.' if len(correlated) > 1 else 'No correlated wireless interface exists; channel/band cannot be determined.')
|
||||
processes = set()
|
||||
proc_entries = r.entries(r.proc)
|
||||
for entry in proc_entries:
|
||||
if entry.name.isdigit():
|
||||
comm = r.read(entry / 'comm')
|
||||
if comm in ('NetworkManager', 'iwd', 'wpa_supplicant'):
|
||||
processes.add(comm)
|
||||
check('managers', 'info' if proc_entries else 'unknown', 'Network manager coexistence',
|
||||
[f'Visible processes: {", ".join(sorted(processes)) or "none"}', nm['detail'], *nm_evidence],
|
||||
'NetworkManager commonly uses iwd or wpa_supplicant as its Wi-Fi backend; coexistence alone is not a conflict. '
|
||||
+ ('Both iwd and wpa_supplicant are visible. Check locally which service owns the selected interface before attributing a conflict; do not stop either automatically.' if {'iwd', 'wpa_supplicant'} <= processes else
|
||||
'Per-interface ownership by iwd/wpa_supplicant is unverified. Process absence in a restricted /proc view is inconclusive.')
|
||||
+ (' NetworkManager reports this interface unmanaged. Steam or another service may own it; unmanaged alone is not a fault. Do not force management on.' if unmanaged else ''))
|
||||
if r.gaps:
|
||||
check('reads', 'unknown', 'Some local evidence is unavailable', r.gaps[:40],
|
||||
'Review unavailable paths locally. Permission failures or disappearing devices are not evidence of an adapter fault; do not elevate this doctor.')
|
||||
check('limits', 'info', 'What this report cannot establish', [],
|
||||
'No physical adapter or Frame link test runs here. Firmware, USB stability under load, Steam support and the reported Ubuntu/Kubuntu 26.04 cause remain unverified. Review the report before sharing it.')
|
||||
return report
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument('--device', help='observed selector, e.g. net:wlan0 or usb:1-2; omitted lists candidates')
|
||||
parser.add_argument('--json', action='store_true', help='print structured local evidence; review before sharing')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
data = diagnose(args.device)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
if args.json:
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(data['scope'] + '\n' + data['identity'])
|
||||
for d in data['usb']:
|
||||
print(f" {d['id']} {d['vid']}:{d['pid']} {d['product'] or 'unnamed USB device'}")
|
||||
for d in data['interfaces']:
|
||||
print(f" {d['id']} USB={d['usb'] or 'not correlated'} driver={d['driver'] or 'unknown'}")
|
||||
for c in data['checks']:
|
||||
print(f"\n[{c['status'].upper()}] {c['title']}")
|
||||
for line in c['evidence']:
|
||||
print(' ' + line)
|
||||
print(' ' + c['suggestion'])
|
||||
return 0 if data['supported'] else 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
+63
-4
@@ -1431,7 +1431,7 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & 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="#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"><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="powerReport">
|
||||
<h2>Power & heat report</h2>
|
||||
@@ -1447,6 +1447,23 @@
|
||||
<p class="hint">Open the saved HTML in your browser; it works offline and includes JSON/CSV downloads. Unknown readings are never treated as zero. Rails overlap; do not add them. Sensor accuracy is unverified. Reports include sensor paths and OS builds: review before sharing.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="steamPipewire" hidden>
|
||||
<h2>Steam desktop sharing <span class="tag">Linux PC only</span></h2>
|
||||
<p>Help for Steam's own <code>steam -pipewire</code> desktop-in-headset flow on Wayland.
|
||||
Frame Control's Linux “PC in the headset” feature remains disabled. This helper needs no headset connection.</p>
|
||||
<p>Save your games and exit Steam using <b>Steam → Exit</b> before launching.
|
||||
This launch does not save a setting or restart an existing Steam session.</p>
|
||||
<div class="row"><button id="steamPipewireLaunch" disabled>Launch Steam with PipeWire</button>
|
||||
<button id="steamPipewireReset" disabled>Reset screen-share choice…</button></div>
|
||||
<p class="sub">Reset opens KDE's Application Permissions so you can revoke a verified Steam screen-sharing
|
||||
session yourself. Frame Control does not reset permissions automatically. Other desktops get guidance only.</p>
|
||||
<p id="steamPipewireGuidance">KDE System Settings → Applications → Application Permissions → Steam Linux Runtime 3.0
|
||||
→ Screen sharing → Manage 1 Session → Revoke. Verify the session belongs to Steam. Never use Revoke All.
|
||||
If the entry is missing or ambiguous, stop.</p>
|
||||
<p id="steamPipewireStatus" role="status" aria-live="polite"></p>
|
||||
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/steam-pipewire.md" target="_blank" rel="noopener">Setup, missing KDE module and limitations →</a></p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
<div class="stats" id="vrMetrics"></div>
|
||||
@@ -1809,7 +1826,7 @@
|
||||
</div>
|
||||
<div class="page" data-page="settings">
|
||||
<div class="workspace-heading"><div><a href="#settings" id="settingsBack" class="workspace-link" hidden>← All settings</a><h1 id="settingsTitle">Settings</h1><p id="settingsSubtitle">Connection, headset controls and Frame Control preferences.</p></div></div>
|
||||
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices & 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 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="settingsWorkspace" hidden></div>
|
||||
</div>
|
||||
|
||||
@@ -1826,6 +1843,7 @@
|
||||
<div class="list" id="devList"><div class="sub">Loading…</div></div>
|
||||
<div class="hint">Frame Control talks to one headset at a time. Each can be reached at several addresses;
|
||||
it tries them all at once and uses the best one that answers on the network you're on.</div>
|
||||
<p id="wirelessDoctorLink" hidden><a href="/wireless-doctor">Linux Wireless Adapter doctor ↗</a><br><span class="sub">Read-only PC checks; works without a connected Frame.</span></p>
|
||||
<h3 style="margin-top:22px">This computer's network</h3>
|
||||
<div id="netNow" class="sub">Checking…</div>
|
||||
<div class="row" style="margin-top:8px; flex-wrap:nowrap">
|
||||
@@ -2134,6 +2152,11 @@ $("bottombar").onclick = () => {
|
||||
const HOST = { computer: "computer" };
|
||||
function applyHostWording(h) {
|
||||
Object.assign(HOST, h);
|
||||
for (const id of ["wirelessDoctorSettings", "wirelessDoctorLink"]) {
|
||||
$(id).hidden = !!h.mobile || h.os !== "Linux";
|
||||
const link = $(id).tagName === "A" ? $(id) : $(id).querySelector("a");
|
||||
link.href = "/wireless-doctor?key=" + encodeURIComponent(UI_KEY);
|
||||
}
|
||||
window.frameControlCenter?.host(h);
|
||||
$("desktopBatteryComfort").hidden = !!h.mobile || !window.frameApp?.notify;
|
||||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||||
@@ -5890,13 +5913,47 @@ $("macview").onclick = async e => {
|
||||
setTimeout(loadMacView, 800);
|
||||
};
|
||||
|
||||
// Local Steam helper: never start an action on navigation or a status read.
|
||||
let steamPipewireBusy = false, steamPipewireRead = 0;
|
||||
async function loadSteamPipewire() {
|
||||
if (steamPipewireBusy) return;
|
||||
const read = ++steamPipewireRead;
|
||||
try {
|
||||
const s = await api('/api/steam-pipewire');
|
||||
if (read !== steamPipewireRead) return;
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = !s.available;
|
||||
$('steamPipewireStatus').textContent = s.reason || '';
|
||||
if (s.guidance) $('steamPipewireGuidance').textContent = s.guidance;
|
||||
} catch (e) {
|
||||
if (read !== steamPipewireRead) return;
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
|
||||
$('steamPipewireStatus').textContent = e.message;
|
||||
}
|
||||
}
|
||||
async function steamPipewireAction(action) {
|
||||
if (steamPipewireBusy) return;
|
||||
steamPipewireBusy = true;
|
||||
steamPipewireRead++;
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
|
||||
try {
|
||||
const r = await api('/api/steam-pipewire', {action, confirmed: true});
|
||||
$('steamPipewireStatus').textContent = r.message;
|
||||
} catch (e) { $('steamPipewireStatus').textContent = e.message; }
|
||||
finally {
|
||||
steamPipewireBusy = false;
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = false;
|
||||
}
|
||||
}
|
||||
$('steamPipewireLaunch').onclick = () => steamPipewireAction('launch');
|
||||
$('steamPipewireReset').onclick = () => steamPipewireAction('permissions');
|
||||
|
||||
// ---- shared destinations and intentional secondary workspaces ----
|
||||
const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
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", powerReport:"tools", backup:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", powerReport:"Power & heat report", backup:"Backup & recovery"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
@@ -6002,6 +6059,7 @@ function showPage(e) {
|
||||
const host = $(kind === 'tools' ? 'toolWorkspace' : 'settingsWorkspace');
|
||||
if (activeTool && $(activeTool).parentNode !== host) host.append($(activeTool));
|
||||
}
|
||||
if (tool === 'steamPipewire') loadSteamPipewire();
|
||||
if (tool === 'macview') loadMacView();
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'backup') loadBackups();
|
||||
@@ -7406,6 +7464,7 @@ api("/api/host").then(h => {
|
||||
window.frameControlCenter?.host(h);
|
||||
$("macToolLink").hidden = h.mobile || h.os !== "macOS";
|
||||
$("pcToolLink").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;
|
||||
|
||||
+29
-1
@@ -60,6 +60,7 @@ import frame_host # noqa: E402
|
||||
import frame_link # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
import frame_pcview # noqa: E402
|
||||
import frame_steam_pipewire # noqa: E402
|
||||
import frame_media # noqa: E402
|
||||
import frame_panels # noqa: E402
|
||||
import frame_quest # noqa: E402
|
||||
@@ -73,6 +74,7 @@ import frame_touch # noqa: E402
|
||||
import frame_webinstall # 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()
|
||||
@@ -148,7 +150,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
# install's clean-up) to the other headset.
|
||||
_work_lock = threading.Lock()
|
||||
_work = [0]
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
|
||||
NOT_HEADSET_WORK = {"/api/steam-pipewire", "/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -3076,6 +3078,22 @@ def push_media(path):
|
||||
"id": token + "/" + name}
|
||||
|
||||
|
||||
# Local Steam helper: no headset route, SSH, or PC capture helper.
|
||||
def steam_pipewire_state():
|
||||
if LOCAL or PRIVATE:
|
||||
return {"available": False, "reason": "Open this helper in Frame Control on your Linux PC."}
|
||||
return frame_steam_pipewire.status()
|
||||
|
||||
|
||||
def steam_pipewire_action(body):
|
||||
if LOCAL or PRIVATE:
|
||||
raise Failure("Open this helper in Frame Control on your Linux PC.", 403)
|
||||
try:
|
||||
return frame_steam_pipewire.action(body)
|
||||
except frame_steam_pipewire.HelperError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
# ---- Mac in the headset (frame_macview.py), PC in the headset (frame_pcview.py) ----
|
||||
|
||||
# The tunnel gets its own connection: the shared master's options would win
|
||||
@@ -3265,6 +3283,7 @@ def power_action(body):
|
||||
|
||||
|
||||
POST = {
|
||||
"/api/steam-pipewire": steam_pipewire_action,
|
||||
"/api/power": power_action,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/vr": vr,
|
||||
@@ -3475,6 +3494,15 @@ 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,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Linux Wireless Adapter doctor · Frame Control</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#101820;--card:#1b2838;--text:#e4edf5;--sub:#acbccb;--line:#3a5065;--blue:#66c0f4;--warn:#f0c67a}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.55 system-ui,sans-serif}main{max-width:960px;margin:auto;padding:32px 24px 64px}a{color:var(--blue)}h1{font-size:30px;line-height:1.2;margin:20px 0 12px}h2{font-size:19px;margin:0 0 10px}p{margin:8px 0 16px}.sub{color:var(--sub)}.badge{display:inline-block;font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--blue);margin-bottom:8px}section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:22px;margin:18px 0}.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}button,select{font:inherit;min-height:44px;border:1px solid var(--line);border-radius:5px;padding:9px 14px;background:#263f54;color:var(--text)}button{cursor:pointer}button.primary{background:#205b81;border-color:var(--blue)}button:disabled{opacity:.5;cursor:default}select{max-width:100%;width:100%}label{display:block;margin:16px 0 6px}pre{font:13px/1.6 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;background:#111d29;padding:14px;border-radius:5px}.status{font-size:12px;font-weight:700;text-transform:uppercase;margin-right:10px;color:var(--blue)}.warning .status{color:var(--warn)}.unknown .status{color:var(--sub)}#message{min-height:24px}summary{cursor:pointer;min-height:40px;overflow-wrap:anywhere}:focus-visible{outline:2px solid var(--blue);outline-offset:4px}[hidden]{display:none!important}@media(max-width:540px){main{padding:20px 16px}h1{font-size:25px}section{padding:16px}.row button{flex:1}}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<a id="back" href="/#devices">← Devices & 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