mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 06:01:39 +02:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb125b59c7 | ||
|
|
08cb267ce4 | ||
|
|
0119544c3e | ||
|
|
2a9e7dcabf | ||
|
|
5a9099dddb | ||
|
|
ec4086db69 | ||
|
|
5a7c3541f8 | ||
|
|
525040162e | ||
|
|
21e771d16e | ||
|
|
2cd095a8c4 | ||
|
|
bb9236b2b7 | ||
|
|
1bc090d00d | ||
|
|
63ba25ffdd | ||
|
|
98e6e90d84 | ||
|
|
7c061846bd | ||
|
|
a91d55151f | ||
|
|
7f25e87b42 | ||
|
|
ff245953ac | ||
|
|
46665d51c0 | ||
|
|
67615e85c3 | ||
|
|
9e3c3cd31d | ||
|
|
537754ebce | ||
|
|
573a9e2730 | ||
|
|
8b91b58336 | ||
|
|
12c60ec024 | ||
|
|
60261f093f | ||
|
|
3120a3cacf |
No files matched your search
@@ -0,0 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Keep the full host-key recovery explanation readable in Devices, including
|
||||
long addresses, and remove doubled punctuation from the connection tooltip
|
||||
(issue #87). Refresh stays within the header and switching headsets selects
|
||||
the new headset's details.
|
||||
|
||||
- Fix experimental PC screen capture on Windows builds whose streaming libraries
|
||||
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
|
||||
windows are hidden with an explanation instead of failing when shown. A Windows
|
||||
VM streamed a real desktop to the Frame; physical input remains unverified.
|
||||
|
||||
- Add read-only battery health in Tools: charging state, temperature, cycle count,
|
||||
and available capacity with explicit unknown states. The parser was checked
|
||||
on a real Frame; the connected desktop UI was tested with synthetic readings.
|
||||
|
||||
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
|
||||
library/game install-size breakdown, with unknown sizes, partial inventories
|
||||
and permission/missing-drive states. Includes instructions for Steam's own
|
||||
Move UI; Frame Control does not move or delete game files. The read-only
|
||||
parser was checked on a real Frame; the connected UI and game moves were not.
|
||||
|
||||
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
|
||||
with verified SSH identity, private summaries and actionable next steps
|
||||
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
|
||||
manual; restart-loop evidence stops deeper diagnostics. The real Frame
|
||||
verified the SSH connection and safety stop; deeper checks were skipped.
|
||||
- Fixed headset smoke cleanup reporting success while a smoke-title process
|
||||
could remain running after its files were removed. Cleanup now stops and
|
||||
rechecks processes tied to exact reserved test-title paths, uses PID handles
|
||||
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
@@ -226,7 +226,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 |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: your PC's windows and screens as panels in the Frame. Not yet tried on a real Windows desktop or headset |
|
||||
| [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 |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
@@ -275,3 +275,5 @@ into a draft release, which reaches users once published. See
|
||||
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||
|
||||
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.11",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.11",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -22,6 +22,15 @@ FC_API int fc_has_element(const char *name) {
|
||||
GstElementFactory *f=gst_element_factory_find(name);
|
||||
if(!f)return 0;gst_object_unref(f);return 1;
|
||||
}
|
||||
FC_API int fc_has_property(const char *element, const char *property) {
|
||||
/* Probe the same initialized GStreamer instance that capture uses. The
|
||||
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
|
||||
GstElement *source=gst_element_factory_make(element,NULL);
|
||||
if(!source)return 0;
|
||||
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
|
||||
gst_object_unref(source);
|
||||
return found;
|
||||
}
|
||||
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
|
||||
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
|
||||
if(!b)return GST_PAD_PROBE_OK;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Battery and health monitoring
|
||||
|
||||
Open **Control Center → Battery & thermal health**, or expand **System
|
||||
information**, to read the Frame's charge, charging state, signed battery
|
||||
power, battery temperature and driver-reported health. Optional readings show
|
||||
charge cycles, full charge capacity, design capacity and full / design capacity.
|
||||
The existing battery chip also shows charging state and temperature.
|
||||
|
||||
These are read-only readings from the Linux host's `/sys/class/power_supply`,
|
||||
not Android's container battery view. The probe prefers the documented Frame
|
||||
fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral
|
||||
(`scope=Device`) and absent batteries, and otherwise uses a deterministic
|
||||
battery fallback. The UI shows the selected source path.
|
||||
|
||||
| Reading | Linux attribute | Interpretation |
|
||||
|---|---|---|
|
||||
| Charge | `capacity` | Integer 0–100 percent; zero is a real reading |
|
||||
| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable |
|
||||
| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame |
|
||||
| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings |
|
||||
| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage |
|
||||
| Charge cycles | `cycle_count` | Driver-reported count, including zero |
|
||||
| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh |
|
||||
| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime |
|
||||
| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state |
|
||||
|
||||
Unavailable means the field is absent, unreadable or invalid. One missing
|
||||
sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second
|
||||
to 7 days and battery temperatures outside −273.15 to 200 °C are rejected
|
||||
as invalid readings; these bounds are not thermal warning thresholds.
|
||||
The ratio may exceed 100% because full capacity is a gauge estimate, so the app
|
||||
does not clamp it or invent a wear warning. The app does not estimate health
|
||||
from charge percentage or assign temperature thresholds. Existing thermal
|
||||
alerts use the kernel's own hot/critical trip points.
|
||||
|
||||
A connected charger does not necessarily mean charging: the Frame can still
|
||||
drain under load or sit idle near full charge. A 100% capacity reading alone
|
||||
does not prove the driver reports Full. Missing status is explicitly unknown;
|
||||
near-zero measured battery power with a charger is shown as not charging.
|
||||
Failed, stale or disconnected status samples clear Control Center readings.
|
||||
|
||||
**No charge controls are provided.** Frame's SteamOS charge-limit backend is
|
||||
unsupported; see [charge-limit research](charge-limit.md). Monitoring never
|
||||
writes to power supplies, pauses charging, changes current or sets a limit.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_battery.py` uses temporary sysfs-like files for conversion,
|
||||
malformed/missing values, permission failures, source selection and partial
|
||||
sensor support. `tests/control_center.cjs` exercises charging states, optional
|
||||
metrics and failed/stale/disconnected sample handling. These tests do not
|
||||
connect to a headset. On 2026-10-24, the PR's uninstalled parser was also
|
||||
piped over SSH to a real Frame running SteamOS 0.5.6 (build
|
||||
`20261015.6141207`). Its read-only sysfs result selected
|
||||
`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C,
|
||||
Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity
|
||||
(108.1% fuel-gauge estimate). The Control Center UI was not run against this
|
||||
headset; Windows and macOS were not tested. Optional fields are not claimed to
|
||||
be available on every firmware build.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Battery monitoring now selects the Frame fuel gauge predictably, handles
|
||||
missing or invalid readings, and shows optional cycle count, full/design
|
||||
capacity and estimated capacity health in Control Center. Unknown charging
|
||||
status stays explicit. Monitoring is read-only; charge limits remain
|
||||
unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not.
|
||||
@@ -18,6 +18,9 @@ read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207)
|
||||
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
|
||||
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
|
||||
|
||||
For safe read-only readings in Control Center, see [battery and health
|
||||
monitoring](battery-health.md). It does not provide charging controls.
|
||||
|
||||
## How Steam does it on other devices
|
||||
|
||||
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
|
||||
|
||||
@@ -97,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||
|
||||
## The connection, stage by stage
|
||||
|
||||
When a connection fails, the header pill keeps its compact offline state and
|
||||
retry countdown; its tooltip and connection dialog contain the full reason.
|
||||
Devices shows a short reason in the headset row (with the full reason in its
|
||||
tooltip) and a wrapping recovery explanation in the selected headset's details.
|
||||
**Use this headset** also selects that headset's details.
|
||||
|
||||
The connector runs in the server (`frame_link.Link`) and moves through:
|
||||
|
||||
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Windows VM and real Frame test, 2026-10-24
|
||||
|
||||
The v0.4.10 installer could enumerate windows/screens and encode a synthetic
|
||||
Media Foundation stream, but every desktop capture failed at pipeline parsing:
|
||||
`no property "capture-api" in element "d3d11screencapturesrc"`.
|
||||
MSYS2's GStreamer 1.28.8 package disables WGC. After the fix, the same installed
|
||||
bundle captured the actual Windows desktop with Desktop Duplication and sent
|
||||
H.264 to a real Frame: 219 sent frame records, 215 decoded and drawn before the
|
||||
snapshot. Individual windows are explicitly unavailable with this bundle.
|
||||
|
||||
The base was `e9cbb8fd5555f5cfceeba1babadf2d7d35dcd3be` (`v0.4.10`, origin/main,
|
||||
including merged #121), in isolated branch `t3/test-windows-vm-headset`.
|
||||
|
||||
## Evidence
|
||||
|
||||
- [Capture before the fix](windows-vm-2026-10-24/capture-before.json): five
|
||||
synthetic H.264 frames and five JPEG frames; zero desktop/window frames,
|
||||
with the missing-property error for both codecs.
|
||||
- [Actual bundled properties](windows-vm-2026-10-24/plugin-properties.json):
|
||||
`monitor-handle` exists; `capture-api`, `window-handle`, `show-border` do not.
|
||||
[MSYS2 package build](https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-gst-plugins-bad/PKGBUILD)
|
||||
sets `-Dd3d11-wgc=disabled`. Upstream
|
||||
[installs those properties conditionally](https://github.com/GStreamer/gstreamer/blob/main/subprojects/gst-plugins-bad/sys/d3d11/gstd3d11screencapturesrc.cpp).
|
||||
- [Capture after the fix](windows-vm-2026-10-24/capture-after.json): the real
|
||||
display produces five H.264 and five JPEG frames. Window sources are hidden
|
||||
with an explicit reason, and the display selects `capture_api=dxgi`.
|
||||
- [Raw locked headset session](windows-vm-2026-10-24/headset-session.json):
|
||||
Windows helper, Media Foundation encoder, reverse SSH tunnel, production
|
||||
panel launcher and shared viewer. The image below was captured from that
|
||||
viewer's own X window on the Frame, rather than the VM's console.
|
||||
- [Windows tests](windows-vm-2026-10-24/windows-tests.txt) and
|
||||
[guest system details](windows-vm-2026-10-24/windows-system.txt).
|
||||
|
||||

|
||||
|
||||
## Host and VM prerequisites
|
||||
|
||||
lxso2: Intel Core i7-8700, 12 logical CPUs, VT-x/KVM; Docker 29.1.3 and Compose
|
||||
2.40.3. Before starting: 31 GiB RAM, 15 GiB available; 147 GiB free disk.
|
||||
Swap was already full. No other VM or service workload was started or stopped.
|
||||
Docker listed no Windows VM, and ports 2222/8006/13389 were unused.
|
||||
|
||||
The execution sandbox hid KVM and denied Docker/network access. Checks outside
|
||||
it found `/dev/kvm`, `/dev/net/tun` and the Docker daemon. Direct user access to
|
||||
KVM was denied, but a disposable Docker container with `--device /dev/kvm`
|
||||
opened it successfully; no host permissions changed.
|
||||
|
||||
Created only `frame-winvm-priority3`, labelled
|
||||
`frame-control.task=priority3-windows-test`: 4 vCPUs, 8 GiB guest RAM, 32 GiB
|
||||
growable disk, no restart, 150-second stop timeout. SSH 2222 and console 8006
|
||||
bind only 127.0.0.1; RDP is unpublished. No GPU passthrough. Image:
|
||||
`dockurr/windows@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea`
|
||||
(Windows for Docker v6.06, QEMU v11.1.1).
|
||||
|
||||
The container downloaded Windows 11 from Microsoft, with no activation changes.
|
||||
It took about 16 minutes to reach the first desktop. Guest build 26200.8037,
|
||||
Red Hat VirtIO GPU DOD driver 100.103.104.30200. The old WindowsProductName
|
||||
registry field still says Windows 10 Pro; the actual desktop/setup is Windows
|
||||
11. After installing the app, the 32 GiB disk had about 9.3 GB free.
|
||||
|
||||
Official `Frame-Control-Setup-x64.exe` v0.4.10 SHA-256:
|
||||
`30165bc5aa39f941cacc697d195dc5c30b01ad0b9ffb472da8a18a5220549202`.
|
||||
Its installer exited 0. The VM stream used an intermediate Python/UI-only
|
||||
fix with the released DLL bundle and bundled Python 3.12.14. A later CI failure
|
||||
showed that probing GStreamer through a separately loaded DLL could see a
|
||||
different, uninitialized instance. The final fix probes optional properties
|
||||
through the capture DLL itself. Windows CI checks that native change; the VM
|
||||
stream was not repeated with the final DLL.
|
||||
|
||||
OEM setup added OpenSSH Server, PowerShell and a dedicated public login key;
|
||||
password authentication is disabled. Its config needed an explicit
|
||||
`AuthorizedKeysFile C:/ProgramData/ssh/administrators_authorized_keys` before
|
||||
key login worked. The default informational share was replaced with a private
|
||||
writable staging share inside this container. These were test-VM setup steps,
|
||||
not app defects. Host setup files and the stopped VM disk are retained under
|
||||
`/tmp/frame-winvm-priority3/`; no private keys are committed or printed.
|
||||
The Frame test key was generated inside Windows and its private key stayed
|
||||
there. Only its public key was temporarily authorized on the Frame. After
|
||||
testing, the guest key and scheduled task were removed; `docker stop -t 150`
|
||||
shut down the dedicated VM. Inspection confirmed `exited`, restart `no`
|
||||
(container exit 143; logs show the ACPI shutdown signal, waiting for Windows,
|
||||
and `Shutdown completed!`). The disk is retained
|
||||
for reuse; the VM is not running.
|
||||
|
||||
## Checks
|
||||
|
||||
- Baseline PC-focused suite: 24 tests passed, one native class skipped.
|
||||
- After fix: full Linux unittest suite **1260 tests, OK, 15 skipped**, 144.366 s;
|
||||
focused final PC suite **27 tests, OK, one native class skipped**. The C
|
||||
controller's frozen 600-state trace ran on Linux.
|
||||
- Actual Windows installer bundle with the intermediate Python/UI fix:
|
||||
**33 tests, OK, four skipped** (no C compiler, Linux PipeWire test, two
|
||||
CI-only x264 tests). Its GObject property probe, host/encoder check and
|
||||
x264-absence check ran. The final native probe is checked in the PR's
|
||||
Windows CI build, not by this VM run.
|
||||
- Browser check with the real production HTML/JavaScript and mocked Windows API
|
||||
responses: DXGI whole-screen Show remains available, the limitation appears
|
||||
once, WGC window sources retain Show, off hides controls, and the limitation
|
||||
text is escaped. This used a static asset server, without headset API access.
|
||||
- `node --test app/test/*.test.js`: passed, three test files.
|
||||
- `node --test site/test/*.test.mjs`: passed, one test file.
|
||||
- The first full Linux run inside the sandbox failed (11 failures, 139 errors),
|
||||
including loopback socket `PermissionError`. The successful full runs were
|
||||
outside that socket restriction.
|
||||
- An initial guest test archive omitted `apk-catalog`, causing one import
|
||||
error; the complete archive produced the 33-test passing Windows run.
|
||||
|
||||
Raw local logs: `/tmp/frame-windows-{pc,app,site}-tests.log`,
|
||||
`/tmp/frame-windows-full-tests-unrestricted.log`,
|
||||
`/tmp/frame-windows-full-fixed-tests.log`, and
|
||||
`/tmp/frame-winvm-priority3/`. The committed artifacts above preserve the
|
||||
capture, Windows test and headset evidence independently of those local files.
|
||||
|
||||
## Shared Frame session and cleanup
|
||||
|
||||
Read-only checks first: SteamOS 0.5.6, BUILD_ID `20261015.6141207`, battery 99%,
|
||||
Not charging (another supply reports Full). The lock was free.
|
||||
|
||||
Both attempts acquired `/tmp/frame-test.lock` atomically before any key or
|
||||
viewer changes, and removed only the lock acquired by this task. The first,
|
||||
35-second attempt failed because the test wrapper sent CRLF script input and
|
||||
left SSH stdin open. Production already sends script bytes and closes stdin;
|
||||
correcting the wrapper resolved both. Its key was removed and lock released.
|
||||
|
||||
The successful session held the lock **03:09:36–03:10:05 UTC (29 seconds)**.
|
||||
It used a dedicated `pc-view-priority3` profile and port range 47960–47964,
|
||||
leaving existing viewers/profiles alone. The app's Windows `PCView` captured
|
||||
`display:65537`, 1280×800, Light preset (30 fps ceiling), and opened panel
|
||||
`valve.steam.desktopgame.2001396898`. The viewer reported H.264 software decode,
|
||||
218 received frames, 215 decoded/drawn before the snapshot (219 sent records).
|
||||
|
||||
This mostly idle, roughly ten-second sample reported 19 fps and content delay
|
||||
p50/p95 51.6/75.2 ms. These are observations, not a performance-target pass.
|
||||
|
||||
Cleanup stopped the helper and tunnel, closed the own viewer, removed its two
|
||||
possible profile paths and log, then removed the exact public-key line. The
|
||||
`authorized_keys` SHA-256 was identical before/after. A follow-up login from
|
||||
Windows with that key failed with exit 255. Separate checks found no task-owned
|
||||
viewer processes, the profile absent, and the shared lock released. Battery
|
||||
remained 99%; the sampled gamescope focus properties matched before/after.
|
||||
No reboot, global settings changes, game launch or Steam/SteamVR stop occurred.
|
||||
|
||||
## Limits
|
||||
|
||||
This verifies a Windows VM's desktop capture, Media Foundation encoding and
|
||||
real Frame viewer decode/rendering. It does not verify physical-PC hardware
|
||||
encoding, WGC or individual-window capture, mixed DPI, multiple displays,
|
||||
physical laser alignment/clicks, keyboard layouts, resize/minimise, busy-scene
|
||||
performance, bitrate/encoder restarts, or worn-headset comfort. No input was
|
||||
injected during the real Frame session. The installed app’s Tools navigation was not exercised; its streaming
|
||||
controller, tunnel and viewer were exercised directly. No independent review was performed.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windowCaptureReason": "Individual windows are unavailable in this build. Choose a whole screen instead.",
|
||||
"windows": [],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"capture_api": "dxgi"
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 65
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 113339
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"src": "window:131836",
|
||||
"id": 131836,
|
||||
"title": "Administrator: C:\\WINDOWS\\system32\\cmd.exe - powershell -noprofile -executionpolicy bypass -file \\\\host.lan\\data\\bootstrap.ps1",
|
||||
"app": "Windows",
|
||||
"w": 1115,
|
||||
"h": 628,
|
||||
"x": 51,
|
||||
"y": 52
|
||||
},
|
||||
{
|
||||
"src": "window:327926",
|
||||
"id": 327926,
|
||||
"title": "Administrator: Install",
|
||||
"app": "Windows",
|
||||
"w": 979,
|
||||
"h": 512,
|
||||
"x": 85,
|
||||
"y": 78
|
||||
},
|
||||
{
|
||||
"src": "window:65856",
|
||||
"id": 65856,
|
||||
"title": "Program Manager",
|
||||
"app": "Windows",
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"gstreamer": "1.28.8",
|
||||
"plugins": [
|
||||
"coreelements",
|
||||
"videotestsrc",
|
||||
"videoconvertscale",
|
||||
"app",
|
||||
"jpeg",
|
||||
"videoparsersbad",
|
||||
"d3d11",
|
||||
"mediafoundation"
|
||||
],
|
||||
"packages": [
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad-libs 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gstreamer 1.28.8-1"
|
||||
],
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"blocksize": "Size in bytes to read per buffer (-1 = default)",
|
||||
"num-buffers": "Number of buffers to output before sending EOS (-1 = unlimited)",
|
||||
"typefind": "Run typefind before negotiating (deprecated, non-functional)",
|
||||
"do-timestamp": "Apply current stream time to buffers",
|
||||
"automatic-eos": "Automatically EOS when the segment is done",
|
||||
"monitor-index": "Zero-based index for monitor to capture (-1 = primary monitor)",
|
||||
"monitor-handle": "A HMONITOR handle of monitor to capture",
|
||||
"show-cursor": "Whether to show mouse cursor",
|
||||
"crop-x": "Horizontal coordinate of top left corner for the screen capture area",
|
||||
"crop-y": "Vertical coordinate of top left corner for the screen capture area",
|
||||
"crop-width": "Width of screen capture area (0 = maximum)",
|
||||
"crop-height": "Height of screen capture area (0 = maximum)"
|
||||
},
|
||||
"d3d12screencapturesrc": null,
|
||||
"mfh264enc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"qos": "Handle Quality-of-Service events from downstream",
|
||||
"min-force-key-unit-interval": "Minimum interval between force-keyunit requests in nanoseconds",
|
||||
"bitrate": "Bitrate in kbit/sec",
|
||||
"rc-mode": "Rate Control Mode",
|
||||
"adaptive-mode": "Adaptive Mode",
|
||||
"vbv-buffer-size": "VBV(HRD) Buffer Size in bytes (0 = MFT default)",
|
||||
"max-bitrate": "The maximum bitrate applied when rc-mode is \"pcvbr\" in kbit/sec",
|
||||
"quality-vs-speed": "Quality and speed tradeoff, [0, 33]: Low complexity, [34, 66]: Medium complexity, [67, 100]: High complexity",
|
||||
"cabac": "Enable CABAC entropy coding",
|
||||
"sps-id": "The SPS id to use",
|
||||
"pps-id": "The PPS id to use",
|
||||
"bframes": "The maximum number of consecutive B frames",
|
||||
"gop-size": "The number of pictures from one GOP header to the next. Depending on GPU vendor implementation, zero gop-size might produce only one keyframe at the beginning (-1 for automatic)",
|
||||
"threads": "The number of worker threads used by a encoder, (0 = MFT default)",
|
||||
"content-type": "Indicates the type of video content",
|
||||
"qp": "QP applied when rc-mode is \"qvbr\"",
|
||||
"low-latency": "Enable low latency encoding",
|
||||
"min-qp": "The minimum allowed QP applied to all rc-mode",
|
||||
"max-qp": "The maximum allowed QP applied to all rc-mode",
|
||||
"qp-i": "QP applied to I frames",
|
||||
"qp-p": "QP applied to P frames",
|
||||
"qp-b": "QP applied to B frames",
|
||||
"ref": "The number of reference frames",
|
||||
"d3d11-aware": "Whether device can support Direct3D11 interop"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
WindowsProductName WindowsVersion OsBuildNumber
|
||||
------------------ -------------- -------------
|
||||
Windows 10 Pro 2009 26200
|
||||
|
||||
|
||||
|
||||
Name DriverVersion Status
|
||||
---- ------------- ------
|
||||
Red Hat VirtIO GPU DOD controller 100.103.104.30200 OK
|
||||
|
||||
|
||||
|
||||
Used Free
|
||||
---- ----
|
||||
23951253504 9314816000
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
python.exe : test_original_mac_congestion_and_recovery
|
||||
(test_pc_controller.ControllerTrace.test_original_mac_congestion_and_recovery) ... skipped 'C
|
||||
compiler not installed; native build jobs cover this trace'
|
||||
At line:12 char:1
|
||||
+ & $python -B -m unittest discover -s tests -p 'test_pc*.py' -v 2>&1 | ...
|
||||
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
+ CategoryInfo : NotSpecified: (test_original_m...ver this trace':String) [], Remote
|
||||
Exception
|
||||
+ FullyQualifiedErrorId : NativeCommandError
|
||||
|
||||
test_controller_is_inert_after_close
|
||||
(test_pcview.AdapterTests.test_controller_is_inert_after_close) ... ok
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties (test_pcview.AdapterTests.
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties) ... ok
|
||||
test_idle_source_is_not_a_stall (test_pcview.AdapterTests.test_idle_source_is_not_a_stall) ... ok
|
||||
test_library_path_prepend_adds_no_empty_entry
|
||||
(test_pcview.AdapterTests.test_library_path_prepend_adds_no_empty_entry) ... ok
|
||||
test_linux_uses_only_portal_fd_and_node
|
||||
(test_pcview.AdapterTests.test_linux_uses_only_portal_fd_and_node) ... ok
|
||||
test_malformed_viewer_input_is_dropped_not_fatal
|
||||
(test_pcview.AdapterTests.test_malformed_viewer_input_is_dropped_not_fatal) ... ok
|
||||
test_pc_card_receives_window_capture_limitation
|
||||
(test_pcview.AdapterTests.test_pc_card_receives_window_capture_limitation) ... ok
|
||||
test_pc_reuses_launcher_and_namespaces_panel_ids
|
||||
(test_pcview.AdapterTests.test_pc_reuses_launcher_and_namespaces_panel_ids) ... ok
|
||||
test_portal_input_clamps_and_releases_held_state
|
||||
(test_pcview.AdapterTests.test_portal_input_clamps_and_releases_held_state) ... ok
|
||||
test_refused_windows_focus_never_clicks_the_covering_app
|
||||
(test_pcview.AdapterTests.test_refused_windows_focus_never_clicks_the_covering_app) ... ok
|
||||
test_session_end_releases_native_input_only_once
|
||||
(test_pcview.AdapterTests.test_session_end_releases_native_input_only_once) ... ok
|
||||
test_stats_keep_capture_time_and_bound_records
|
||||
(test_pcview.AdapterTests.test_stats_keep_capture_time_and_bound_records) ... ok
|
||||
test_windows_release_and_negative_monitor_origin
|
||||
(test_pcview.AdapterTests.test_windows_release_and_negative_monitor_origin) ... ok
|
||||
test_windows_sources_follow_actual_wgc_capabilities
|
||||
(test_pcview.AdapterTests.test_windows_sources_follow_actual_wgc_capabilities) ... ok
|
||||
test_windows_uses_wgc_and_media_foundation
|
||||
(test_pcview.AdapterTests.test_windows_uses_wgc_and_media_foundation) ... ok
|
||||
test_never_offered_off_windows_even_when_turned_on
|
||||
(test_pcview.GatingTests.test_never_offered_off_windows_even_when_turned_on) ... ok
|
||||
test_off_by_default_starts_nothing (test_pcview.GatingTests.test_off_by_default_starts_nothing)
|
||||
... ok
|
||||
test_only_windows_gets_the_pc_host (test_pcview.GatingTests.test_only_windows_gets_the_pc_host)
|
||||
... ok
|
||||
test_server_enable_action (test_pcview.GatingTests.test_server_enable_action) ... ok
|
||||
test_turning_off_stops_streams_and_the_helper
|
||||
(test_pcview.GatingTests.test_turning_off_stops_streams_and_the_helper) ... ok
|
||||
test_turning_on_checks_for_the_bundled_libraries
|
||||
(test_pcview.GatingTests.test_turning_on_checks_for_the_bundled_libraries) ... ok
|
||||
test_expiry_and_stop_before_redeem (test_pcview.GrantsTests.test_expiry_and_stop_before_redeem)
|
||||
... ok
|
||||
test_source_bound_retry_ack_and_stop
|
||||
(test_pcview.GrantsTests.test_source_bound_retry_ack_and_stop) ... ok
|
||||
test_agent_reports_its_host_and_encoder
|
||||
(test_pcview.NativeTests.test_agent_reports_its_host_and_encoder) ... ok
|
||||
test_auth_and_real_h264_stats (test_pcview.NativeTests.test_auth_and_real_h264_stats) ... skipped
|
||||
'x264 is only in CI test bundles, not the installer'
|
||||
test_bundled_pipewire_context_needs_no_system_modules
|
||||
(test_pcview.NativeTests.test_bundled_pipewire_context_needs_no_system_modules) ... skipped
|
||||
'Linux PipeWire client libraries'
|
||||
test_installer_bundle_leaves_out_gpl_x264
|
||||
(test_pcview.NativeTests.test_installer_bundle_leaves_out_gpl_x264) ... ok
|
||||
test_last_raw_picture_survives_a_closed_gate
|
||||
(test_pcview.NativeTests.test_last_raw_picture_survives_a_closed_gate) ... skipped 'x264 is only
|
||||
in CI test bundles, not the installer'
|
||||
test_native_property_probe_checks_the_instantiated_element
|
||||
(test_pcview.NativeTests.test_native_property_probe_checks_the_instantiated_element) ... ok
|
||||
test_card_says_experimental_and_what_is_unverified
|
||||
(test_pcview.PageTests.test_card_says_experimental_and_what_is_unverified) ... ok
|
||||
test_opt_in_is_remembered_with_the_client_preferences
|
||||
(test_pcview.PageTests.test_opt_in_is_remembered_with_the_client_preferences) ... ok
|
||||
test_tool_link_is_windows_only_and_marked_experimental
|
||||
(test_pcview.PageTests.test_tool_link_is_windows_only_and_marked_experimental) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 33 tests in 1.684s
|
||||
|
||||
OK (skipped=4)
|
||||
@@ -0,0 +1,121 @@
|
||||
# Frame doctor
|
||||
|
||||
Run `scripts/frame-doctor.sh` from a checkout on macOS or Linux to diagnose
|
||||
first-time setup, pairing and common Frame failures without applying repairs.
|
||||
It needs Bash, Python 3 and OpenSSH on the computer, and Python 3 on the Frame.
|
||||
Windows users can run `python scripts/frame_doctor.py` with Python and OpenSSH
|
||||
installed; the desktop app does not bundle this command.
|
||||
|
||||
```sh
|
||||
scripts/frame-doctor.sh
|
||||
scripts/frame-doctor.sh --alias bedroom --lan frame.local
|
||||
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
|
||||
```
|
||||
|
||||
Use only addresses belonging to your intended headset. The command tries the
|
||||
existing `frame` SSH alias first (Tailscale if setup configured it that way),
|
||||
then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb`
|
||||
override those defaults. It reports the successful path without printing its
|
||||
address. It neither discovers other devices nor enables Tailscale.
|
||||
|
||||
The existing alias supplies the user, port, keys and known-hosts file. LAN and
|
||||
USB overrides preserve its `HostKeyAlias`, or its configured hostname if no
|
||||
alias was set. A first connection therefore needs trusted setup first. The
|
||||
doctor never accepts a new host key, deletes a saved key, generates keys or
|
||||
sends a pairing request. It uses your trusted OpenSSH configuration; OpenSSH
|
||||
can evaluate `Match exec` conditions in that configuration. Proxy commands,
|
||||
jumps, local commands, forwarding and connection sharing are disabled for
|
||||
these direct diagnostic connections.
|
||||
|
||||
## Reading the result
|
||||
|
||||
Each of the eleven runbook sections has one line: `OK`, `WARN`, `BROKEN` or
|
||||
`UNKNOWN`. `OK` means only that the stated check passed, not that the whole
|
||||
feature works. Optional features that are off may produce a warning.
|
||||
|
||||
| Section | Read-only evidence and limits |
|
||||
|---|---|
|
||||
| 1. Boot and crash history | Previous-boot kernel-oops signatures; one day of coredump history. An oops alone does not prove an unclean reset. |
|
||||
| 2. Wi-Fi | NetworkManager's wlan0 state, without SSIDs or connection profiles. |
|
||||
| 3. Displays and SteamVR | DSI timeout signatures in the recent kernel journal; no display, compositor or game is launched. |
|
||||
| 4. Steam and storage | Free space in home and `devkit-game`, including separate mounts; missing Steam install marker. No CRC scan or restart. |
|
||||
| 5. Idle sleep and keep-awake | Existing keep-awake unit state; never takes ownership or changes idle timers. |
|
||||
| 6. Remote wake | WoWLAN state, without arming it or testing suspend. Wake with the power button. |
|
||||
| 7. Lepton | Recent port-clash, binder and data-clearing signatures; no Android/container commands. |
|
||||
| 8. Chromium XR | Crash history; no browser profile or environment inspection. |
|
||||
| 9. KDE Connect | Process presence; absence is normal when sharing is off. |
|
||||
| 10. Streaming and capture | ffmpeg crash history; no capture or encoder is started. |
|
||||
| 11. Panels | Always `UNKNOWN`: placement requires the user's visual confirmation. |
|
||||
|
||||
Journal samples are capped at 2,000 entries; current-boot samples cover the
|
||||
last ten minutes. This can miss older or high-volume failures. Empty,
|
||||
inaccessible or permission-limited journals are `UNKNOWN`, never zero errors.
|
||||
Commands time out individually, and each SSH probe is bounded to 90 seconds.
|
||||
|
||||
Exit codes: **0** if all reported checks are OK; **1** for warnings, broken or
|
||||
unknown checks after connecting; **2** if no authenticated SSH path exists or
|
||||
local SSH setup is unavailable. Panel verification currently means a connected
|
||||
run normally exits 1. Automation should read the labelled checks rather than
|
||||
treating a warning as proof the headset is unusable.
|
||||
|
||||
## First-time setup and pairing
|
||||
|
||||
- **No SSH response:** wake the headset with its power button. Check the cable
|
||||
or Steam Settings → Internet, then enable Developer Mode. Sleep and network
|
||||
failure can look alike; a timeout cannot distinguish them.
|
||||
- **Key rejected:** open **Set Up Connection** in Frame Control. In the headset,
|
||||
open Steam Settings → Developer → **Pair new host**, then approve setup's
|
||||
request. The doctor only checks whether devkit port 32000 responds; that
|
||||
does not prove pairing mode is open or the service is healthy.
|
||||
- **Unknown or changed identity:** verify the intended headset through setup
|
||||
or Devices → **Check its key**. Do not delete `known_hosts` to bypass this.
|
||||
- **No devkit response:** check Developer Mode and the address. See
|
||||
[SSH setup](ssh.md) for the manual fallback. The doctor cannot turn SSH on.
|
||||
|
||||
No password is requested. Enter any password required by the separate setup
|
||||
flow only in its local terminal; never paste it into an issue or report.
|
||||
|
||||
## Restart safety and recovery
|
||||
|
||||
Before deeper checks, and again afterwards, the doctor samples Steam and
|
||||
SteamVR `NRestarts` and health-check tracker sizes two seconds apart. Rising
|
||||
counters stop diagnostics immediately. Nonempty health-check trackers also stop
|
||||
deeper checks even when stable, since a short sample could miss a slower loop.
|
||||
Missing counters or counters that reset stop deeper checks as `UNKNOWN`. Stable
|
||||
counters with empty trackers still cannot rule out a slower restart loop.
|
||||
|
||||
The [historical runbook from PR #33](https://github.com/saphid/frame-control/blob/cce3d940302054ab5246c92d740afaefc4e58d38/docs/frame-doctor.md)
|
||||
is the source for these section numbers and observed signatures (September
|
||||
2026 builds). Its section 4 describes freezing and verifying health-check
|
||||
trackers before prolonged recovery during a live loop. That changes headset
|
||||
state, so this read-only implementation stops and directs you there instead.
|
||||
It does not implement issue #34's automatic fixes, before/after repair runs,
|
||||
or full recovery automation. Review build-specific recovery instructions with
|
||||
the person using the headset; a repair can remove data.
|
||||
|
||||
Never reload `ath12k`, suspend remotely, kill gamescope to diagnose a loop,
|
||||
reset or force power off during extraction/repair, or write passwords to disk.
|
||||
The doctor never restarts services, signals processes, repairs Steam, changes
|
||||
WoWLAN/standby settings, takes keep-awake ownership, installs anything or writes
|
||||
a report. Nothing needs restoring after its checks. Manual/device tests must
|
||||
follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test).
|
||||
|
||||
## Privacy and verification
|
||||
|
||||
Only fixed diagnostic summaries leave the remote probe. Raw journals, process
|
||||
arguments, environment variables, SSH errors, account IDs, network addresses,
|
||||
SSIDs, keys and tokens are not printed. No telemetry or HTTP request is sent;
|
||||
the only pairing-service probe is a TCP connection. SSH produces normal access
|
||||
logs. Review summaries before sharing them; feature availability can still be
|
||||
personal information. Redirecting stdout saves a report only if you choose it.
|
||||
|
||||
Unit tests use mocked boundaries. `scripts/e2e.sh test_doctor` covers asleep,
|
||||
disk-full, SSH/devkit-off and missing-key faults against the fake Frame. Its
|
||||
stable restart counters are explicitly simulated, not proof about SteamOS.
|
||||
On 2026-10-24 the script ran read-only against a real Frame, SteamOS build
|
||||
`20261015.6141207`. It verified the configured SSH alias and key login,
|
||||
then reported `WARN Restart safety`: nonempty health-check trackers. It
|
||||
correctly skipped all eleven deeper sections and exited 1. No raw logs,
|
||||
addresses or keys were printed, and no repair or restart was attempted.
|
||||
This verifies the safety stop on that device; the deeper checks are not
|
||||
claimed as headset-tested.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Game storage
|
||||
|
||||
Open **Tools → Game storage** for a read-only snapshot of the headset's user
|
||||
filesystem and Steam libraries. Refresh after an installation, download or move.
|
||||
The page never moves, deletes or modifies content, starts Steam, requests root,
|
||||
or recursively scans game files. It works through the existing SSH connection.
|
||||
|
||||
## What the numbers mean
|
||||
|
||||
- **Total, free and used** come from `statvfs` on the headset user's home and
|
||||
each library path. Free is space available to that user; reserved blocks mean
|
||||
used + free can be less than total. These are filesystem sizes, not advertised
|
||||
drive capacity, and do not include separate system partitions.
|
||||
- Libraries come from `~/.local/share/Steam/steamapps/libraryfolders.vdf` plus
|
||||
the default Steam library. Modern nested and older numbered-path records are
|
||||
accepted; canonical duplicate paths are shown once. No drive is mounted.
|
||||
- Two paths can share one filesystem. Their capacity must **not** be added.
|
||||
An unmounted drive whose mount directory still exists can report the parent
|
||||
filesystem's capacity: the page labels this as the filesystem containing the
|
||||
path, rather than claiming the removable drive is connected.
|
||||
- Game sizes are Steam's `SizeOnDisk` records in `appmanifest_<appid>.acf`, sorted
|
||||
largest first, with unknown sizes last. A missing, negative or invalid size
|
||||
is **Unknown**, while an explicit zero remains zero. Records may be stale,
|
||||
including during downloads or moves; they do not measure allocated blocks.
|
||||
- Games/apps and runtimes/tools have separate subtotals of **known recorded**
|
||||
sizes. Runtime classification uses names (Proton, Steam Linux Runtime,
|
||||
Steamworks Common, FEX and Lepton Development), so it is only a convenience.
|
||||
Additional apps or runtimes may fall in the game/app group.
|
||||
- Saves, Proton prefixes, shaders, workshops, downloads, Android apps,
|
||||
sideloaded games, media and OS files are **unmeasured other usage**. The page
|
||||
does not subtract manifest totals from filesystem usage to invent a size for
|
||||
them. Recorded content is not an exhaustive storage breakdown.
|
||||
|
||||
Permission denied, missing/disconnected paths, unavailable filesystem reads,
|
||||
invalid metadata and partial inventories are reported separately. A failed read
|
||||
is never proof of an empty library. If library discovery fails, other libraries
|
||||
may be missing from the view. Refresh clears the old results immediately; a
|
||||
headset switch clears the snapshot and ignores late replies from the old device.
|
||||
|
||||
## Moving a game safely
|
||||
|
||||
Use Steam's own **Settings → Storage** on the headset (or in desktop Steam):
|
||||
choose the source drive, select the games and choose **Move**, then pick a
|
||||
Steam-supported destination. A second Steam library must already be available.
|
||||
Steam may withhold Move or a destination when it cannot perform the operation.
|
||||
Let Steam finish, follow Steam's errors/recovery prompts, then Refresh here.
|
||||
Do not manually delete files to make room or interrupt an in-progress move.
|
||||
|
||||
[Valve's moving guide](https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move)
|
||||
was read in a browser on 2026-10-24 and documents this UI. This is an instructional
|
||||
handoff: the page does not send a URI or silently open a UI on the headset.
|
||||
The guide's separate Windows instructions for moving the entire Steam client
|
||||
are outside this feature's scope.
|
||||
|
||||
### Why Frame Control does not automate moving
|
||||
|
||||
The repository's Valve devkit tools register and launch developer titles; no
|
||||
supported game-move operation is provided there. The existing Steam CEF bridge
|
||||
uses internal client APIs, which are not a verified supported contract for a
|
||||
move transaction. [Valve's public ISteamApps reference](https://partner.steamgames.com/doc/api/ISteamApps) provides install-directory
|
||||
and installed-depot queries but no documented move transaction with completion,
|
||||
cancellation, rollback and error guarantees. The public support guide describes
|
||||
the user-operated Storage UI, rather than such an automation interface.
|
||||
|
||||
Consequently this feature adds no move endpoint or guessed internal Steam call,
|
||||
manifest rewrite, copy/delete fallback, or custom rollback. Automating a move
|
||||
requires a supported interface and verified behaviour for full disks, disconnects,
|
||||
running games, downloads, cancellation and Steam restart first.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
`tests/test_storage.py` runs the actual probe against temporary Steam metadata
|
||||
and filesystem/error fixtures. It covers multiple libraries, duplicate paths,
|
||||
zero/unknown/invalid sizes, corrupt manifests, unreadable directories and files,
|
||||
missing discovery/drives, reserved capacity and read-only file preservation.
|
||||
A loopback HTTP test executes the real guarded endpoint with SSH mocked.
|
||||
`tests/storage.cjs` runs the production renderer, navigation and refresh handlers
|
||||
with DOM/transport boundaries faked, including escaping, offline errors,
|
||||
out-of-order refreshes and headset switches. It runs through unittest discovery.
|
||||
|
||||
The production storage component was rendered with synthetic data at 390px and
|
||||
900px widths; both fit without horizontal overflow. This checks component
|
||||
layout, not the full connected application or headset browser.
|
||||
|
||||
On 2026-10-24 the uninstalled storage parser was piped read-only over SSH
|
||||
to a real Frame running SteamOS build `20261015.6141207`. It reported
|
||||
710.4 GiB free on the user filesystem, one available Steam library, 29
|
||||
readable app manifests, zero unknown sizes and zero parse/permission issues.
|
||||
No game names or raw metadata were printed in the test report. The
|
||||
connected UI, removable media, Steam's Move UI and an actual game move were
|
||||
not tested. UI and error-state fixtures remain synthetic.
|
||||
+31
-13
@@ -1,15 +1,19 @@
|
||||
# PC in the headset (Experimental)
|
||||
|
||||
**Tools → PC in the headset**, on Windows only. It's the Windows version of
|
||||
[Mac in the headset](mac-in-headset.md): a window or a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Place it with the dashboard's Float
|
||||
[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
|
||||
with streaming libraries built with Windows Graphics Capture (WGC); the shipped
|
||||
MSYS2 libraries disable WGC, so this build explains that limitation and offers
|
||||
whole screens. Place the panel with the dashboard's Float
|
||||
in World, Move and Size, point and click with the laser, scroll with the
|
||||
thumbstick, and type on the PC's keyboard.
|
||||
|
||||
**It's experimental and not yet tried on a real Windows desktop or with a
|
||||
headset.** It builds and passes its protocol tests on Windows in CI, but desktop
|
||||
capture, Media Foundation encoding and laser input have never run on a real
|
||||
PC. Expect it not to work yet. Reports are welcome through the
|
||||
**It's experimental.** A Windows 11 VM has captured its real desktop with
|
||||
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
|
||||
hardware encoding and physical laser input remain unverified. See
|
||||
[the dated evidence](evidence/windows-vm-2026-10-24.md) for results and limits.
|
||||
Reports are welcome through the
|
||||
[feedback form](https://frame-control.pages.dev/feedback/).
|
||||
|
||||
## Turning it on
|
||||
@@ -31,17 +35,18 @@ streaming app. GStreamer is bundled as ordinary libraries.
|
||||
|
||||
| | Windows |
|
||||
|---|---|
|
||||
| Capture | Windows.Graphics.Capture, through GStreamer's `d3d11screencapturesrc capture-api=wgc`, by window (HWND) or screen (HMONITOR) |
|
||||
| Capture | GStreamer's `d3d11screencapturesrc`: Desktop Duplication for whole screens in the shipped build; Windows.Graphics.Capture by window (HWND) or screen (HMONITOR) when that implementation is present |
|
||||
| Encoding | Media Foundation's H.264 encoder (`mfh264enc`), low latency, no B-frames. Whether it uses the GPU depends on the PC; **not verified** |
|
||||
| Input | `SendInput`, mapped through the source's bounds, with per-monitor DPI awareness |
|
||||
|
||||
API choices are documented, not device-verified:
|
||||
API references:
|
||||
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
|
||||
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
|
||||
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html).
|
||||
|
||||
It needs Windows 10 or 11 with Windows.Graphics.Capture and a Media Foundation
|
||||
H.264 encoder (Windows "N" editions need the Media Feature Pack). Clicks and
|
||||
It needs Windows 10 or 11 with Desktop Duplication and a Media Foundation
|
||||
H.264 encoder (Windows "N" editions need the Media Feature Pack). WGC-enabled
|
||||
builds can also capture individual windows. Clicks and
|
||||
keys can't reach administrator apps or the sign-in screen, which an ordinary
|
||||
process can't drive. If Windows won't bring the chosen window to the front,
|
||||
input is refused rather than sent to the app covering it: bring the window
|
||||
@@ -77,6 +82,13 @@ UCRT64 packages. `libraries.json` records the GStreamer version, the plugins,
|
||||
each copied DLL and the exact package versions; licence texts are in
|
||||
`licenses/`.
|
||||
|
||||
MSYS2's `gst-plugins-bad` build disables `d3d11-wgc`. The presence of
|
||||
`d3d11screencapturesrc` alone does not prove WGC support: its `capture-api`,
|
||||
`window-handle` and `show-border` properties are conditional. The helper queries
|
||||
those properties on the instantiated element, advertises only supported
|
||||
sources, and omits all three properties for the DXGI whole-screen pipeline.
|
||||
It never substitutes a screen crop for an unavailable window capture.
|
||||
|
||||
The x264 encoder is **not** in the installer. It's GPL, and Windows encodes
|
||||
with Media Foundation, so only CI's test builds include it
|
||||
(`desktop/build.py --with-x264`). The macOS and Linux installers are unchanged.
|
||||
@@ -127,11 +139,17 @@ python scripts/macview-bench.py run --pc --scenario capture --source window:1234
|
||||
[earlier probe](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
|
||||
- **Verified, #38, on a Mac:** the C controller matched all 600 states of the
|
||||
Swift controller's congestion and recovery trace.
|
||||
- **Not verified:** anything on a real Windows desktop. Windows.Graphics.Capture,
|
||||
Media Foundation (hardware or software), encoder restarts when the bitrate
|
||||
- **Verified, 2026-10-24:** Windows 11 VM build 26200.8037 on lxso2, using
|
||||
the v0.4.10 installer with this fix, captured its actual desktop and streamed
|
||||
H.264 to Frame BUILD_ID `20261015.6141207`: 219 sent frame records, 215 decoded
|
||||
and drawn before the snapshot. The own-panel screenshot shows the Windows
|
||||
desktop. This short, mostly idle run does not establish performance targets.
|
||||
[Evidence, raw traces and cleanup](evidence/windows-vm-2026-10-24.md).
|
||||
- **Not verified:** Windows.Graphics.Capture, physical-PC capture, hardware
|
||||
Media Foundation encoding, encoder restarts when the bitrate
|
||||
changes, mixed-DPI and multi-monitor input alignment, window resize and
|
||||
minimise, non-US keyboard layouts, physical laser input, and how it performs
|
||||
in a worn headset. No numbers are claimed for these. Not tested with a
|
||||
headset from this branch.
|
||||
worn headset. See the dated VM evidence for the separate automated checks.
|
||||
- **Not reviewed independently.** #38 tried an independent review that never
|
||||
completed.
|
||||
@@ -7,6 +7,10 @@ run on Linux. On Windows, use the app.
|
||||
|
||||
## Minimum typing on the headset
|
||||
|
||||
If setup or pairing fails, run `scripts/frame-doctor.sh` from this checkout.
|
||||
It checks existing SSH access without requesting passwords, pairing or applying
|
||||
repairs. See the [Frame doctor guide](frame-doctor.md) for results and next steps.
|
||||
|
||||
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
|
||||
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
|
||||
thing you type on the headset is a password you choose.
|
||||
|
||||
+103
-8
@@ -44,6 +44,20 @@ them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
## The fake Frame
|
||||
|
||||
Frame doctor has focused offline tests in `tests/test_frame_doctor.py` and
|
||||
fake-Frame fault tests in `tests/e2e/test_doctor.py`:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_frame_doctor.py -v
|
||||
scripts/e2e.sh test_doctor
|
||||
```
|
||||
|
||||
They cover privacy filtering, host-key preservation, restart safety and missing
|
||||
journal evidence, plus asleep, full-title-storage, SSH/devkit-off and missing-key
|
||||
faults. The fake's doctor-only `systemctl show` responses assume stable restart
|
||||
counters and inactive keep-awake; unsupported queries fail. These tests do not
|
||||
exercise a real SteamOS user manager or touch a headset.
|
||||
|
||||
`tests/fakeframe/` builds a container that stands in for the headset, and a
|
||||
second one for the computer Frame Control runs on. `scripts/e2e.sh` builds
|
||||
both, starts them with `docker compose`, runs `tests/e2e` in the host
|
||||
@@ -164,6 +178,43 @@ does). A cleanup that fails counts as a failed step. Results go to
|
||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||
isn't reachable.
|
||||
|
||||
Smoke process cleanup runs before and after title removal and during the
|
||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
|
||||
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
|
||||
an exact title directory in `/proc` cwd/exe or an individual absolute command
|
||||
argument; similarly named folders and shell command substrings do not count.
|
||||
Deleted cwd/exe paths still count, so a process left after file removal can be
|
||||
found. It sends SIGTERM, waits up to three seconds, then uses SIGKILL with
|
||||
another three-second bound, rescanning for survivors. PID handles prevent PID
|
||||
reuse from signalling another process. Linux Python 3.9+ with pidfd support
|
||||
is required; missing support, signal errors and survivors fail the
|
||||
cleanup step. Stopped PIDs are recorded in the results JSON.
|
||||
If the initial leftover cleanup fails, the run skips installs and pairing and
|
||||
records the failure instead of overwriting an active test title.
|
||||
|
||||
This deliberately does not stop shared Wine/Proton services by name or prefix,
|
||||
Steam, SteamVR, process groups or arbitrary descendants. A process that drops
|
||||
all title-path evidence cannot safely be attributed and is outside this
|
||||
check. Unreadable `/proc` fields provide no ownership evidence; other readable
|
||||
fields are still checked, and a wholly unattributable process is left alone.
|
||||
Real Frame verification, 2026-10-24, SteamOS build `20261015.6141207`: the
|
||||
fixed Python smoke entry point passed all 13 steps under the shared device lock.
|
||||
The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
|
||||
remove step stopped five test-owned wrapper/Proton PIDs before removing the
|
||||
title; the following process sweep found none. The final cleanup found no
|
||||
test titles or processes, the test desktop was closed, and the battery
|
||||
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
|
||||
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
|
||||
the same entry point instead.
|
||||
The local regression suite runs real Linux processes in temporary fake title
|
||||
directories (including deleted folders and a TERM-resistant wrapper); it checks
|
||||
exit, escalation and survival of unrelated processes without SSH or a headset:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
|
||||
```
|
||||
|
||||
`--pair` asks the devkit service to pair a new RSA key, which needs someone
|
||||
in the headset to open **Settings → Developer → Pair new host** and approve
|
||||
it; the key is checked and then taken out of `authorized_keys` again.
|
||||
@@ -204,15 +255,19 @@ tests the installer's own bundle with its bundled Python and checks x264 isn't
|
||||
in it. `FRAME_PC_REQUIRE_NATIVE=1` and `FRAME_PC_REQUIRE_X264=1` make a missing
|
||||
bundle or encoder fail CI instead of skipping.
|
||||
|
||||
None of this captures a desktop, exercises Media Foundation or a GPU, or
|
||||
checks laser alignment. The [Windows test VM](#windows-test-vm) is the next
|
||||
step for that. See [what's verified](pc-in-headset.md#whats-verified).
|
||||
These automated CI checks do not capture a desktop, exercise Media Foundation
|
||||
or a GPU, or check laser alignment. A [Windows test VM](#windows-test-vm) run
|
||||
on 2026-10-24 captured its actual desktop with Desktop Duplication, encoded
|
||||
with Media Foundation and streamed to a real Frame. It found MSYS2 disables
|
||||
WGC: an element name alone does not prove its optional capture properties
|
||||
exist. Native property-probe tests and source-selection regressions now cover
|
||||
that boundary. [Dated evidence and limits](evidence/windows-vm-2026-10-24.md);
|
||||
[what's verified](pc-in-headset.md#whats-verified).
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
depends on the Windows desktop (Remote Desktop, the installer, the bundled
|
||||
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
|
||||
Windows CI runs the unit tests and checks installer contents. Desktop capture,
|
||||
installer launch and interactive behavior need a Windows machine. This is a Windows 11 VM in a
|
||||
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
|
||||
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
|
||||
|
||||
@@ -232,7 +287,14 @@ Setting it up, once, on the Linux machine:
|
||||
`~/.ssh/id_ed25519_winvm` on the Mac) in
|
||||
`C:\ProgramData\ssh\administrators_authorized_keys`.
|
||||
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
|
||||
and the tests.
|
||||
and the tests. Inspect free memory/disk, KVM access and existing VMs before
|
||||
starting; leave other VMs alone. The first install can take tens of minutes
|
||||
(the 2026-10-24 run took about 16 minutes to reach the desktop), rather than
|
||||
the 1–2 minute startup of an already installed guest.
|
||||
- For PC streaming, check the actual bundled capture properties. MSYS2's
|
||||
GStreamer build disables WGC: this app falls back to Desktop Duplication for
|
||||
whole screens and explains why individual windows are unavailable. A VM
|
||||
capture test does not prove physical GPU encoding or laser alignment.
|
||||
|
||||
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
|
||||
|
||||
@@ -356,4 +418,37 @@ checksums, restore checks after a reset, restore writing only its own files,
|
||||
unsafe archives and backup names, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
|
||||
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
|
||||
No backup or restore has been run on a real headset yet.
|
||||
A full backup and an identical-file Proton save restore were checked on a
|
||||
real Frame running build `20261015.6141207`; an after-reset restore and
|
||||
Android data restore remain unverified. See the linked device evidence.
|
||||
|
||||
## Game storage
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_storage.py -v` exercises the
|
||||
read-only metadata probe, guarded HTTP endpoint and Node renderer/navigation
|
||||
regressions. All headset reads are temporary fixtures or mocked SSH. See
|
||||
[game storage](game-storage.md#verification-and-limits) for coverage and device
|
||||
verification limits. This suite does not move games or contact a headset.
|
||||
|
||||
## Battery monitoring
|
||||
|
||||
`tests/test_battery.py` covers real file parsing with temporary sysfs-like
|
||||
files, source preference, missing/invalid sensors and permission errors.
|
||||
`tests/control_center.cjs` covers charging states and optional health fields,
|
||||
alongside existing stale/offline handling. No headset is used. See
|
||||
[battery monitoring](battery-health.md) for sources, units and limitations.
|
||||
|
||||
## Host-key recovery presentation
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
|
||||
connection/banner/selection functions in Node and checks the recovery CSS guard.
|
||||
`test_link.py` covers connection failures and short probe reasons with fake SSH
|
||||
and local sockets. Neither suite uses a real headset.
|
||||
|
||||
Browser verification on 2026-10-24 used the current page's markup, styles and
|
||||
recovery renderers with synthetic changed-key state and two headsets at
|
||||
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
|
||||
rule, which the original source-only assertion missed. Check computed wrapping,
|
||||
the complete explanation, tooltip punctuation, Refresh bounds and selection
|
||||
after **Use this headset** when changing these styles. This fixture does not
|
||||
exercise Electron integration or a real SSH identity change.
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Read-only setup and health diagnostics; see docs/frame-doctor.md.
|
||||
set -euo pipefail
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
exec python3 "$root/scripts/frame_doctor.py" "$@"
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Read-only doctor. Also sent over SSH on stdin; never installed on the Frame.
|
||||
|
||||
Only fixed summaries leave the probe: no raw journals, addresses, account IDs,
|
||||
process arguments, environment, key material or HTTP response bodies.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
SECTIONS = ('Boot and crash history', 'Wi-Fi', 'Displays and SteamVR',
|
||||
'Steam and storage', 'Idle sleep and keep-awake', 'Remote wake',
|
||||
'Lepton (Android)', 'Chromium XR', 'KDE Connect',
|
||||
'Streaming and capture', 'Panels')
|
||||
|
||||
|
||||
def run(args, timeout=4, **kwargs):
|
||||
try:
|
||||
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, **kwargs)
|
||||
except (OSError, UnicodeError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def command(*args):
|
||||
r = run(list(args))
|
||||
return r.stdout if r is not None and r.returncode == 0 else None
|
||||
|
||||
|
||||
def journal(*args):
|
||||
# Even exit 0 can mean the account cannot see the system journal.
|
||||
r = run(['journalctl', '--no-pager', '-q', '-n', '2000', *args])
|
||||
if r is None or r.returncode or r.stderr.strip() or not r.stdout.strip():
|
||||
return None
|
||||
return r.stdout
|
||||
|
||||
|
||||
def snapshot():
|
||||
values = []
|
||||
for unit in ('steam', 'steamvr'):
|
||||
s = command('systemctl', '--user', 'show', unit + '.service',
|
||||
'-p', 'NRestarts', '--value')
|
||||
if s is None or not s.strip().isdigit():
|
||||
return None
|
||||
values.append(int(s.strip()))
|
||||
p = Path('/run/user') / str(os.getuid()) / (unit + '-short-session-tracker')
|
||||
try:
|
||||
values.append(p.stat().st_size)
|
||||
except FileNotFoundError:
|
||||
values.append(0)
|
||||
except OSError:
|
||||
return None
|
||||
return values
|
||||
|
||||
|
||||
def guard():
|
||||
first = snapshot()
|
||||
time.sleep(2)
|
||||
second = snapshot()
|
||||
if first is None or second is None:
|
||||
return 'unknown'
|
||||
if any(b > a for a, b in zip(first, second)):
|
||||
return 'loop'
|
||||
if first != second:
|
||||
return 'unknown' # counters reset or tracker replaced during the sample
|
||||
if second[1] or second[3]:
|
||||
return 'risk' # avoid a long run even if a short sample missed the loop
|
||||
return 'stable'
|
||||
|
||||
|
||||
def signal_check(text, pattern, problem, healthy):
|
||||
if text is None:
|
||||
return ['UNKNOWN', 'Evidence unavailable; consult the runbook locally.']
|
||||
return ['WARN', problem] if re.search(pattern, text, re.I) else ['OK', healthy]
|
||||
|
||||
|
||||
def storage():
|
||||
# devkit-game may be a separate mount (including in the fake Frame).
|
||||
try:
|
||||
paths = [Path.home()]
|
||||
games = Path.home() / 'devkit-game'
|
||||
if games.exists():
|
||||
paths.append(games)
|
||||
free = min(shutil.disk_usage(p).free for p in paths)
|
||||
if free < 16 * 1024**2:
|
||||
return ['BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.']
|
||||
if free < 256 * 1024**2:
|
||||
return ['WARN', 'Less than 256 MiB free; review storage before large installs.']
|
||||
return ['OK', 'Home and title storage have at least 256 MiB free.']
|
||||
except OSError:
|
||||
return ['UNKNOWN', 'Storage could not be read.']
|
||||
|
||||
|
||||
def probe():
|
||||
state = guard()
|
||||
if state != 'stable':
|
||||
return {'guard': state, 'checks': []}
|
||||
kernel = journal('-b', '-k', '--since=-10min')
|
||||
recent = journal('-b', '--since=-10min')
|
||||
previous = journal('-b', '-1', '-k')
|
||||
crashes = command('coredumpctl', 'list', '--no-pager', '--since=-1d')
|
||||
checks = []
|
||||
boot = signal_check(previous, r'Unable to handle kernel|Internal error|Kernel panic',
|
||||
'Previous boot has a kernel oops; this alone does not prove an unclean reset.',
|
||||
'No kernel-oops signature in the sampled previous boot.')
|
||||
if crashes is None and boot[0] != 'WARN':
|
||||
boot = ['UNKNOWN', 'Crash history unavailable; inspect boot history locally.']
|
||||
elif crashes is not None and boot[0] != 'WARN' and re.search(r'SIG(SEGV|ABRT)|\b(SEGV|ABRT)\b', crashes):
|
||||
boot = ['WARN', 'Crash records exist in the last day; inspect locally using the runbook.']
|
||||
checks.append(boot)
|
||||
wifi = command('nmcli', '-t', '-f', 'DEVICE,TYPE,STATE', 'device')
|
||||
checks.append(signal_check(wifi if wifi and 'wlan0:wifi:' in wifi else None, r'wlan0:wifi:(unavailable|disconnected|failed)',
|
||||
'Wi-Fi is unavailable or disconnected; check Steam Settings > Internet. Never reload ath12k.',
|
||||
'No unavailable/disconnected wlan0 reported; other interfaces may differ.'))
|
||||
checks.append(signal_check(kernel, r'wait for video done',
|
||||
'Display timeouts observed; follow display recovery before touching Steam.',
|
||||
'No display-timeout signature in recent kernel sample.'))
|
||||
if checks[-1][0] != 'WARN' and recent is not None and re.search(r'left-over process|steamvr.service: Failed|!modifiers.empty', recent):
|
||||
checks[-1] = ['WARN', 'SteamVR/session failure signature observed; inspect locally before restarting anything.']
|
||||
disk = storage()
|
||||
if disk[0] == 'OK' and not (Path.home() / '.local/share/Steam/.install-complete').exists():
|
||||
disk = ['WARN', 'Steam install marker is missing; do not restart Steam for diagnosis. Consult the runbook.']
|
||||
checks.append(disk)
|
||||
awake = command('systemctl', '--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value')
|
||||
checks.append(['UNKNOWN', 'Keep-awake state unavailable.'] if awake is None or not awake.strip() else
|
||||
['WARN', 'Keep-awake is active; leave it with its owner.'] if awake.strip() == 'active' else
|
||||
['OK', 'Keep-awake is inactive; diagnostics do not inhibit sleep.'])
|
||||
wow = command('iw', 'phy', 'phy0', 'wowlan', 'show')
|
||||
checks.append(['UNKNOWN', 'WoWLAN state unavailable; remote wake is not verified.'] if wow is None else
|
||||
['OK', 'WoWLAN is disabled; wake the headset with its power button.'] if 'WoWLAN is disabled' in wow else
|
||||
['WARN', 'WoWLAN may be armed; review the runbook. Remote wake is not reliable.'])
|
||||
checks.append(signal_check(recent, r'Listen failed for HOST TCP|binder_user_error|Clearing baked app data',
|
||||
'Android failure signature observed; inspect Lepton locally before retrying.',
|
||||
'No known Android failure signature in recent journal sample.'))
|
||||
checks.append(signal_check(crashes, r'chromium|/chrome\b',
|
||||
'Chromium crash history found; update/relaunch the panel through its normal UI.',
|
||||
'No Chromium crash signature in available crash history.'))
|
||||
kde = run(['pgrep', '-f', 'frame-control/kdeconnect/root/usr/lib/kdeconnectd'])
|
||||
checks.append(['UNKNOWN', 'KDE Connect process check unavailable.'] if kde is None or kde.returncode not in (0, 1) else
|
||||
['OK', 'KDE Connect process found.'] if kde.returncode == 0 else
|
||||
['WARN', 'KDE Connect is not running; normal if keyboard/trackpad sharing is off.'])
|
||||
checks.append(signal_check(crashes, r'ffmpeg',
|
||||
'Capture crash history found; consult software-encoder guidance in the runbook.',
|
||||
'No ffmpeg crash signature in available crash history.'))
|
||||
checks.append(['UNKNOWN', 'Panel placement needs visual confirmation; open one panel at a time.'])
|
||||
# A short sample is not proof that Steam is healthy. Recheck after collecting.
|
||||
after = guard()
|
||||
return {'guard': after, 'checks': checks if after == 'stable' else []}
|
||||
|
||||
|
||||
SSH_OPTIONS = ['BatchMode=yes', 'NumberOfPasswordPrompts=0', 'StrictHostKeyChecking=yes',
|
||||
'UpdateHostKeys=no', 'CheckHostIP=no', 'ConnectTimeout=3', 'ConnectionAttempts=1',
|
||||
'ControlMaster=no', 'ControlPath=none', 'ClearAllForwardings=yes',
|
||||
'PermitLocalCommand=no', 'RemoteCommand=none', 'RequestTTY=no',
|
||||
'ProxyCommand=none', 'ProxyJump=none', 'ForwardAgent=no', 'ForwardX11=no']
|
||||
|
||||
|
||||
def ssh_args(alias, host=None, identity=None):
|
||||
args = ['ssh']
|
||||
for opt in SSH_OPTIONS:
|
||||
args += ['-o', opt]
|
||||
if host:
|
||||
args += ['-o', 'HostName=' + host, '-o', 'HostKeyAlias=' + identity]
|
||||
return args + [alias]
|
||||
|
||||
|
||||
def failure(text):
|
||||
# Never echo SSH stderr: it can contain addresses, paths or remote text.
|
||||
if 'REMOTE HOST IDENTIFICATION HAS CHANGED' in text:
|
||||
return 'SSH identity changed; use Devices > Check its key after verifying the headset. Do not delete known_hosts.'
|
||||
if 'Host key verification failed' in text:
|
||||
return 'SSH identity is untrusted; verify it through Set Up Connection or Devices > Check its key.'
|
||||
if 'Permission denied' in text:
|
||||
return "SSH key rejected; use Set Up Connection, then Developer > Pair new host in the headset."
|
||||
return 'No SSH response; wake the headset, check the network/cable and enable Developer Mode.'
|
||||
|
||||
|
||||
def port_open(host, port):
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def report(data):
|
||||
state = data.get('guard')
|
||||
if state != 'stable':
|
||||
status = 'BROKEN' if state == 'loop' else 'WARN' if state == 'risk' else 'UNKNOWN'
|
||||
message = {'loop': 'Counters are rising. Stop diagnostics; use the runbook health-check recovery procedure.',
|
||||
'risk': 'Health-check trackers contain failures. Deeper checks skipped; review the runbook before recovery.'}
|
||||
print(status + ' Restart safety: ' + message.get(state, 'Cannot verify restart counters; deeper checks skipped for safety.'))
|
||||
for i, label in enumerate(SECTIONS, 1):
|
||||
print(f'UNKNOWN {i}. {label}: Skipped until restart safety is verified.')
|
||||
return 1
|
||||
checks = data.get('checks')
|
||||
# Treat the remote endpoint as untrusted. Only accept pairs from our own
|
||||
# fixed vocabulary by matching against the production probe's messages.
|
||||
if not isinstance(checks, list) or len(checks) != len(SECTIONS):
|
||||
print('UNKNOWN Probe: Incomplete diagnostic response; no raw output displayed.')
|
||||
return 1
|
||||
for check in checks:
|
||||
if (not isinstance(check, list) or len(check) != 2 or check[0] not in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')
|
||||
or not isinstance(check[1], str) or check[1] not in MESSAGES):
|
||||
print('UNKNOWN Probe: Unexpected diagnostic response; no raw output displayed.')
|
||||
return 1
|
||||
print('OK Restart safety: No rising counters during short samples; this is not a health guarantee.')
|
||||
for i, (label, (status, message)) in enumerate(zip(SECTIONS, checks), 1):
|
||||
print(f'{status} {i}. {label}: {message}')
|
||||
return int(any(c[0] != 'OK' for c in checks))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Read-only Frame setup diagnostics. Never pairs, repairs or asks for a password.')
|
||||
parser.add_argument('--alias', default='frame', help='Existing trusted SSH alias (default: frame)')
|
||||
parser.add_argument('--lan', default='frame.local', help='LAN fallback host/IP')
|
||||
parser.add_argument('--usb', default='10.86.200.233', help='USB fallback IP; use only with the intended headset connected')
|
||||
args = parser.parse_args(argv)
|
||||
for value in (args.alias, args.lan, args.usb):
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,252}', value):
|
||||
parser.error('Use a plain alias, hostname or IP address.')
|
||||
print('Frame doctor: read-only; no passwords, pairing, repairs or raw logs. No report is saved.')
|
||||
if not shutil.which('ssh'):
|
||||
print('BROKEN Setup: Install an OpenSSH client, then run Set Up Connection.')
|
||||
return 2
|
||||
config = run(ssh_args(args.alias)[:-1] + ['-G', args.alias])
|
||||
if config is None or config.returncode:
|
||||
print('UNKNOWN Setup: SSH configuration could not be read; check it locally.')
|
||||
return 2
|
||||
settings = dict(line.split(' ', 1) for line in config.stdout.splitlines() if ' ' in line)
|
||||
identity = settings.get('hostkeyalias') or settings.get('hostname', args.alias)
|
||||
source = Path(__file__).read_text()
|
||||
for label, host in (('configured alias (Tailscale if configured)', None), ('LAN', args.lan), ('USB-C', args.usb)):
|
||||
result = run(ssh_args(args.alias, host, identity) + ['python3 -B - --probe'], timeout=90, input=source)
|
||||
if result is not None and result.returncode == 0:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
print('UNKNOWN Probe: Invalid response; check Python 3 availability on the headset. No raw output displayed.')
|
||||
return 1
|
||||
print('OK Connection: ' + label + '; verified SSH identity and key login.')
|
||||
return report(data)
|
||||
if result is not None and result.returncode != 255:
|
||||
print('UNKNOWN Probe: SSH connected but diagnostics failed; check Python 3 on the headset.')
|
||||
return 1
|
||||
reason = failure(result.stderr if result is not None else '')
|
||||
print('WARN Connection (' + label + '): ' + reason)
|
||||
# No /register call: an open devkit port cannot prove pairing mode is enabled.
|
||||
hosts = (settings.get('hostname', args.alias), args.lan, args.usb)
|
||||
if any(port_open(host, 32000) for host in hosts):
|
||||
print('WARN Pairing: Devkit port responds. Use Set Up Connection and Developer > Pair new host; no pairing request was sent.')
|
||||
else:
|
||||
print('UNKNOWN Pairing: Devkit port did not respond. Needs your hands: wake headset, enable Developer Mode and check its address.')
|
||||
for i, label in enumerate(SECTIONS, 1):
|
||||
print(f'UNKNOWN {i}. {label}: No authenticated SSH path.')
|
||||
return 2
|
||||
|
||||
|
||||
# Derive the output allowlist from literal strings, not remote data. This module
|
||||
# has no dynamic diagnostic messages. AST stays stdlib and runs only locally.
|
||||
def messages():
|
||||
import ast
|
||||
tree = ast.parse(Path(__file__).read_text())
|
||||
return {node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str)}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[1:] == ['--probe']:
|
||||
print(json.dumps(probe()))
|
||||
else:
|
||||
MESSAGES = messages()
|
||||
sys.exit(main())
|
||||
@@ -165,6 +165,50 @@ test('metrics use real fields, preserve zero, and distinguish unavailable values
|
||||
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
|
||||
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
|
||||
});
|
||||
test('battery health distinguishes charging states and optional sensors', () => {
|
||||
for (const status of [null, 'Unknown', 'Unexpected']) {
|
||||
const m = cc.metrics({battery:{percent:100,status,timeToEmpty:3600}});
|
||||
assert.equal(m.batteryDetail,'Charging state unavailable');
|
||||
assert.equal(m.estimate,'Unavailable');
|
||||
}
|
||||
assert.equal(cc.metrics({battery:{status:'Not charging'}}).batteryDetail,'Not charging');
|
||||
assert.equal(cc.metrics({battery:{status:'Full'}}).batteryDetail,'Fully charged');
|
||||
assert.equal(cc.metrics({battery:{status:'Charging',timeToFull:3600}}).estimate,'1 h 0 min to full');
|
||||
assert.equal(cc.metrics({battery:{status:'Discharging',timeToEmpty:600}}).estimate,'10 min remaining');
|
||||
const m = cc.metrics({battery:{cycleCount:0,fullMah:4500,designMah:5000,capacityHealthPercent:90,
|
||||
source:'/sys/class/power_supply/max1720x_bat_7-36',health:'Unknown',tempC:0}});
|
||||
assert.equal(m.cycles,'0'); assert.equal(m.capacityHealth,'90%');
|
||||
assert.equal(m.fullCapacity,'4,500 mAh'); assert.equal(m.batteryTemp,'0 °C');
|
||||
assert.equal(m.batteryHealth,'Unavailable'); assert.match(m.batterySource,/max1720x/);
|
||||
for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource','batteryHealth','batteryTemp'])
|
||||
assert.equal(cc.metrics(null)[key],'Unavailable');
|
||||
});
|
||||
test('battery chip preserves unknown status and does not infer Full at 100%', () => {
|
||||
const f=fixture();
|
||||
const start=pageSource.indexOf('function battery(b, power)');
|
||||
const end=pageSource.indexOf('// hidePopover', start);
|
||||
const context={$:f.element,dur:String}; vm.createContext(context);
|
||||
vm.runInContext(pageSource.slice(start,end),context);
|
||||
context.battery({percent:100,status:'Unknown'},{watts:20});
|
||||
assert.equal(f.element('battState').textContent,'Charging state unavailable');
|
||||
context.battery({percent:50,status:'Not charging'},null);
|
||||
assert.equal(f.element('battState').textContent,'Not charging');
|
||||
context.battery({percent:50,status:'Charging',watts:2,tempC:0},null);
|
||||
assert.match(f.element('battState').textContent,/Charging/);
|
||||
assert.match(f.element('battDetail').textContent,/battery 0°C/);
|
||||
context.battery(null,null);
|
||||
assert.equal(f.element('battState').textContent,'Battery unknown');
|
||||
});
|
||||
test('new health readings clear after a failed status read', () => {
|
||||
const f=mounted();
|
||||
f.ui.status({...sample(),battery:{...sample().battery,cycleCount:42,capacityHealthPercent:92,
|
||||
fullMah:4600,designMah:5000,source:'/sys/class/power_supply/max1720x_bat_7-36'}});
|
||||
assert.equal(f.element('metric-capacityHealth').textContent,'92%');
|
||||
f.ui.unavailable(Error('Read failed'));
|
||||
for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource'])
|
||||
assert.equal(f.element('metric-'+key).textContent,'Unavailable');
|
||||
f.ui.destroy();
|
||||
});
|
||||
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
|
||||
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Real doctor/SSH against fake-Frame faults; never a real headset."""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import harness
|
||||
from harness import ROOT, ctl
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
class Doctor(harness.FrameTestCase):
|
||||
def doctor(self):
|
||||
return subprocess.run([sys.executable, str(ROOT / 'scripts/frame_doctor.py'),
|
||||
'--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
|
||||
def test_disk_full(self):
|
||||
before = self.doctor()
|
||||
self.assertNotIn('BROKEN 4. Steam and storage', before.stdout)
|
||||
ctl('disk-full', 'on')
|
||||
r = self.doctor()
|
||||
self.assertEqual(r.returncode, 1, r.stdout + r.stderr)
|
||||
self.assertIn('BROKEN 4. Steam and storage: Less than 16 MiB free', r.stdout)
|
||||
|
||||
def test_unreachable_asleep(self):
|
||||
ctl('sleep', 'on')
|
||||
r = self.doctor()
|
||||
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
|
||||
self.assertIn('No authenticated SSH path', r.stdout)
|
||||
|
||||
def test_no_ssh_and_no_devkit(self):
|
||||
ctl('sshd', 'off')
|
||||
ctl('devkit-service', 'off')
|
||||
r = self.doctor()
|
||||
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
|
||||
self.assertIn('Devkit port did not respond', r.stdout)
|
||||
|
||||
def test_no_keys_but_devkit_available(self):
|
||||
ctl('keys', 'none')
|
||||
r = self.doctor()
|
||||
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
|
||||
self.assertIn('SSH key rejected', r.stdout)
|
||||
self.assertIn('no pairing request was sent', r.stdout)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Doctor's read-only systemd queries in a container without a user manager.
|
||||
|
||||
Stable NRestarts=0 and inactive keep-awake are harness assumptions, not observed
|
||||
device behavior. Real Frame query forms come from the historical doctor runbook
|
||||
(2026-09-28, BUILD_ID 20260925.6191901). No service control is emulated here.
|
||||
"""
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
if args in (['--user', 'show', 'steam.service', '-p', 'NRestarts', '--value'],
|
||||
['--user', 'show', 'steamvr.service', '-p', 'NRestarts', '--value']):
|
||||
print('0')
|
||||
elif args == ['--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value']:
|
||||
print('inactive')
|
||||
else:
|
||||
sys.exit(1)
|
||||
+36
-17
@@ -13,11 +13,12 @@ BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
|
||||
(someone has to open Settings > Developer > Pair new host and approve it in
|
||||
the headset), checked, and taken out of authorized_keys again.
|
||||
|
||||
Everything it installs is removed again, also when a step fails: the titles
|
||||
(named fc_smoke_*, and leftovers of an interrupted run first), their Steam
|
||||
Everything it installs is removed again, also when a step fails: the reserved
|
||||
fc_smoke_{arm64,x86_64,exe} titles (and legacy leftovers first), their Steam
|
||||
shortcuts, the paired key, and ~/devkit-utils if it wasn't there before (if it
|
||||
was, it stays, synced to this checkout as Frame Control always does). A
|
||||
cleanup that fails is a failed step.
|
||||
cleanup that fails is a failed step. Test-owned processes are stopped and
|
||||
rechecked around deletion; shared Proton services are never stopped by name.
|
||||
|
||||
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
||||
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
|
||||
@@ -45,11 +46,17 @@ import frame_android # noqa: E402
|
||||
import frame_connect # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
import tiny_programs # noqa: E402
|
||||
import stop_processes # noqa: E402
|
||||
|
||||
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
|
||||
PREFIX = 'fc_smoke_'
|
||||
# Earlier runs named titles fc-smoke*, which Steam refused to register but left on disk.
|
||||
OLD_PREFIX = 'fc-smoke'
|
||||
|
||||
|
||||
def stop_titles(ids):
|
||||
"""Run the same bounded, PID-safe helper used by the local process tests."""
|
||||
return json.loads(frame_android.ssh(
|
||||
'python3 - ' + ' '.join(shlex.quote(gid) for gid in sorted(ids)),
|
||||
input=(HERE / 'stop_processes.py').read_text(), timeout=20))
|
||||
|
||||
|
||||
class Smoke:
|
||||
@@ -172,14 +179,17 @@ def title_cycle(smoke, kind, folder):
|
||||
return detail
|
||||
|
||||
def remove():
|
||||
stopped = stop_titles({gid})
|
||||
frame_titles.remove(gid)
|
||||
# A wrapper could have launched a child just as deletion completed.
|
||||
after = stop_titles({gid})
|
||||
left = ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip()
|
||||
if left:
|
||||
raise AssertionError(f'left behind: {left}')
|
||||
if gid in {t['id'] for t in frame_titles.list_titles()}:
|
||||
raise AssertionError(f'{gid} is still listed')
|
||||
smoke.installed.discard(gid)
|
||||
return {'removed': gid}
|
||||
return {'removed': gid, 'process_cleanup': [stopped, after]}
|
||||
|
||||
if smoke.step(f'{kind}: install', install)['ok']:
|
||||
smoke.step(f'{kind}: launch', launch)
|
||||
@@ -187,15 +197,21 @@ def title_cycle(smoke, kind, folder):
|
||||
|
||||
|
||||
def cleanup(smoke):
|
||||
"""Remove this run's titles and any fc_smoke_* an interrupted run left: the folder,
|
||||
its json files, and (through steamos-delete) Steam's shortcut. Raises if anything stays."""
|
||||
"""Stop reserved smoke processes and remove their titles and shortcuts.
|
||||
|
||||
Legacy fc-smoke-* IDs are included; arbitrary similarly named titles are
|
||||
never removed. Raises if any owned process, file or shortcut stays.
|
||||
"""
|
||||
problems = []
|
||||
try:
|
||||
ids = {t['id'] for t in frame_titles.list_titles() if t['id'].startswith((PREFIX, OLD_PREFIX))}
|
||||
ids = {t['id'] for t in frame_titles.list_titles() if t['id'] in stop_processes.IDS}
|
||||
except frame_android.FrameError as e:
|
||||
ids = set()
|
||||
problems.append(f'could not list titles: {e}')
|
||||
ids |= smoke.installed
|
||||
# Scan every reserved ID, even when a prior run removed its folder and
|
||||
# shortcut but left Proton running. Stop before removing any more files.
|
||||
stopped = stop_titles(stop_processes.IDS)
|
||||
gone = set()
|
||||
for gid in sorted(ids):
|
||||
try:
|
||||
@@ -226,9 +242,10 @@ def cleanup(smoke):
|
||||
smoke.installed.discard(gid)
|
||||
except (frame_android.FrameError, AssertionError) as e:
|
||||
problems.append(f"syncing Steam's shortcuts: {e}")
|
||||
after = stop_titles(stop_processes.IDS)
|
||||
if problems:
|
||||
raise AssertionError('; '.join(problems))
|
||||
return {'removed': sorted(ids)}
|
||||
return {'removed': sorted(ids), 'process_cleanup': [stopped, after]}
|
||||
|
||||
|
||||
# Runs on the Frame: drop the lines holding one key from authorized_keys, writing a
|
||||
@@ -307,15 +324,17 @@ def main():
|
||||
# Frame Control copies Valve's devkit tools to ~/devkit-utils on the first install
|
||||
# (as Valve's client does). If they weren't there before, they go again at the end.
|
||||
had_utils = ssh('test -d ~/devkit-utils && echo yes || true').strip() == 'yes'
|
||||
smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)
|
||||
ready = smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)['ok']
|
||||
folder = tempfile.mkdtemp(prefix='frame-smoke-')
|
||||
try:
|
||||
smoke.step('devkit service: properties.json', properties)
|
||||
smoke.step('status', status, build)
|
||||
for kind in ('arm64', 'x86_64', 'exe'):
|
||||
title_cycle(smoke, kind, folder)
|
||||
if args.pair:
|
||||
smoke.step('devkit pairing (throwaway key)', pair, folder)
|
||||
# Do not overwrite a leftover title when safe process cleanup failed.
|
||||
if ready:
|
||||
smoke.step('devkit service: properties.json', properties)
|
||||
smoke.step('status', status, build)
|
||||
for kind in ('arm64', 'x86_64', 'exe'):
|
||||
title_cycle(smoke, kind, folder)
|
||||
if args.pair:
|
||||
smoke.step('devkit pairing (throwaway key)', pair, folder)
|
||||
finally:
|
||||
smoke.step('cleanup: everything this run installed', cleanup, smoke)
|
||||
if not had_utils:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Linux-only smoke cleanup helper, sent over SSH before deleting test files.
|
||||
|
||||
Only exact, reserved smoke title directories establish ownership. Never signal
|
||||
by process name, process group, Steam app id, or a shared Wine/Proton prefix.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
IDS = frozenset(prefix + kind for prefix in ('fc_smoke_', 'fc-smoke-')
|
||||
for kind in ('arm64', 'x86_64', 'exe'))
|
||||
|
||||
|
||||
def owned(pid, roots):
|
||||
"""Require our uid and an exact cwd/exe or individual absolute argv path.
|
||||
|
||||
/proc retains a deleted cwd/exe with ' (deleted)' appended. This also lets
|
||||
the final sweep catch an older run whose title was already removed.
|
||||
"""
|
||||
proc = Path('/proc') / str(pid)
|
||||
try:
|
||||
if pid == os.getpid() or proc.stat().st_uid != os.getuid():
|
||||
return False
|
||||
state = (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
|
||||
if state == 'Z':
|
||||
return False
|
||||
paths = []
|
||||
for key in ('cwd', 'exe'):
|
||||
try:
|
||||
paths.append(os.readlink(proc / key).removesuffix(' (deleted)'))
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
try:
|
||||
paths += os.fsdecode((proc / 'cmdline').read_bytes()).split('\0')
|
||||
except PermissionError:
|
||||
pass
|
||||
return any(path == root or path.startswith(root + '/')
|
||||
for path in paths for root in roots)
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
return False
|
||||
|
||||
|
||||
def candidates(roots):
|
||||
return sorted(int(p.name) for p in Path('/proc').iterdir()
|
||||
if p.name.isdigit() and owned(int(p.name), roots))
|
||||
|
||||
|
||||
def stop(ids, grace=3):
|
||||
if not ids or not set(ids) <= IDS:
|
||||
raise ValueError('only reserved smoke title IDs may be stopped')
|
||||
if not hasattr(os, 'pidfd_open') or not hasattr(signal, 'pidfd_send_signal'):
|
||||
raise RuntimeError('safe smoke cleanup requires Linux Python with pidfd support')
|
||||
roots = [str(Path.home() / 'devkit-game' / gid) for gid in ids]
|
||||
stopped = set()
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
# Re-scan while waiting: wrappers can spawn a child during shutdown.
|
||||
deadline = time.monotonic() + grace
|
||||
while True:
|
||||
pids = candidates(roots)
|
||||
if not pids:
|
||||
return {'stopped_pids': sorted(stopped)}
|
||||
for pid in pids:
|
||||
try:
|
||||
fd = os.pidfd_open(pid)
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
try:
|
||||
# Open first, then revalidate ownership. The handle cannot
|
||||
# signal an unrelated process if this numeric PID is reused.
|
||||
if owned(pid, roots):
|
||||
try:
|
||||
signal.pidfd_send_signal(fd, sig)
|
||||
stopped.add(pid)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
finally:
|
||||
os.close(fd)
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
left = candidates(roots)
|
||||
if left:
|
||||
raise RuntimeError(f'smoke processes survived cleanup: {left}')
|
||||
return {'stopped_pids': sorted(stopped)}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(stop(sys.argv[1:])))
|
||||
@@ -0,0 +1,50 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||
function fixture() {
|
||||
const f=workspaceFixture();
|
||||
const start=html.indexOf('// ---- Read-only game storage ----');
|
||||
f.run(html.slice(start,html.indexOf('// ---- Backup and recovery kit',start)));
|
||||
const size=html.indexOf('function sizeText(');
|
||||
f.run(html.slice(size,html.indexOf('const backupKinds',size)));
|
||||
return f;
|
||||
}
|
||||
const sample={home:{state:'ok',total:1000,free:200,used:700},discovery:'permission-denied',libraries:[{
|
||||
path:'<img onerror=alert(1)>',capacity:{state:'missing'},state:'partial',gameBytes:10,toolBytes:0,
|
||||
recordedBytes:10,unknownSizes:1,issues:[{file:'bad.acf',state:'invalid-metadata'}],
|
||||
apps:[{appid:'1',name:'<script>bad</script>',bytes:null,kind:'game'},{appid:'2',name:'Zero',bytes:0,kind:'game'}]}]};
|
||||
test('navigation reaches storage and refresh runs once per visit',()=>{
|
||||
const f=fixture();let calls=0;f.context.loadStorage=()=>calls++;
|
||||
f.navigate('#storage');assert.equal(f.activePage,'tools');assert.equal(f.el('storage').hidden,false);
|
||||
assert.equal(f.el('storage').parentNode,f.el('toolWorkspace'));assert.equal(calls,1);
|
||||
});
|
||||
test('unknown, shared capacity, partial discovery and untrusted names remain explicit',()=>{
|
||||
const f=fixture();f.context.sample=sample;const out=f.run('renderStorage(sample)');
|
||||
assert.match(out,/Permission denied/);assert.match(out,/Missing or disconnected/);
|
||||
assert.match(out,/Unknown/);assert.match(out,/0 bytes/);assert.match(out,/Partial inventory/);
|
||||
assert.match(out,/must not be added/);assert.match(out,/Other usage is unmeasured/);
|
||||
assert.match(out,/<script>/);assert.doesNotMatch(out,/<script>|<img/);
|
||||
});
|
||||
test('refresh clears old data, ignores earlier requests and shows offline failure',async()=>{
|
||||
const f=fixture(),pending=[];
|
||||
f.context.api=()=>new Promise((resolve,reject)=>pending.push({resolve,reject}));
|
||||
f.el('storageResult').innerHTML='old data';
|
||||
const first=f.run('loadStorage()'),second=f.run('loadStorage()');
|
||||
assert.match(f.el('storageResult').textContent,/Reading/);
|
||||
pending[1].resolve(sample);await second;
|
||||
const current=f.el('storageResult').innerHTML;
|
||||
pending[0].resolve({...sample,libraries:[]});await first;
|
||||
assert.equal(f.el('storageResult').innerHTML,current);
|
||||
const last=f.run('loadStorage()');pending[2].reject(new Error('Offline'));await last;
|
||||
assert.match(f.el('storageResult').textContent,/Storage unavailable: Offline/);
|
||||
});
|
||||
test('a response or error from another headset cannot update the view',async()=>{
|
||||
for(const fail of [false,true]) {
|
||||
const f=fixture();let resolve,reject;
|
||||
f.context.api=()=>new Promise((yes,no)=>{resolve=yes;reject=no;});
|
||||
const request=f.run('loadStorage()');f.context.devGen++;
|
||||
f.el('storageResult').textContent='New headset';
|
||||
fail?reject(new Error('Old headset')):resolve(sample);await request;
|
||||
assert.equal(f.el('storageResult').textContent,'New headset');assert.equal(f.el('storageResult').innerHTML,'');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Read-only sysfs battery parsing; fixture values, no real headset access."""
|
||||
import sandbox # noqa: F401
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_status as status
|
||||
|
||||
|
||||
class Battery(unittest.TestCase):
|
||||
def probe(self, fields):
|
||||
# Real files exercise missing files, whitespace and integer conversion.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
node = Path(tmp) / 'max1720x_bat_7-36'
|
||||
node.mkdir()
|
||||
for name, value in dict(type='Battery', **fields).items():
|
||||
(node / name).write_text(str(value) + '\n')
|
||||
with mock.patch.object(status.glob, 'glob', return_value=[str(node)]):
|
||||
result = status.battery()
|
||||
if result:
|
||||
result.pop('source')
|
||||
return result
|
||||
|
||||
def test_frame_units_and_optional_health(self):
|
||||
b = self.probe(dict(capacity=73, status='Charging', voltage_now=3800000,
|
||||
current_now=1000000, temp=325, health='Good',
|
||||
charge_full=4600000, charge_full_design=5000000,
|
||||
cycle_count=42, time_to_full_now=3600))
|
||||
self.assertEqual(b, dict(percent=73, status='Charging', watts=3.8,
|
||||
tempC=32.5, health='Good', timeToFull=3600,
|
||||
timeToEmpty=None, cycleCount=42, fullMah=4600,
|
||||
designMah=5000, capacityHealthPercent=92))
|
||||
|
||||
def test_zero_negative_current_and_temperature(self):
|
||||
b = self.probe(dict(capacity=0, cycle_count=0, temp=-50,
|
||||
voltage_now=3800000, current_now=-1000000))
|
||||
self.assertEqual((b['percent'], b['cycleCount'], b['tempC'], b['watts']),
|
||||
(0, 0, -5, -3.8))
|
||||
|
||||
def test_missing_optional_sensors_stay_null(self):
|
||||
b = self.probe(dict(capacity=50, status='Discharging'))
|
||||
self.assertEqual(b['percent'], 50)
|
||||
self.assertTrue(all(value is None for key, value in b.items()
|
||||
if key not in ('percent', 'status')))
|
||||
|
||||
def test_invalid_values_do_not_break_other_readings(self):
|
||||
for invalid in ('abc', '', '-1', '101', '12.5'):
|
||||
b = self.probe(dict(capacity=invalid, status='Charging', temp='bad',
|
||||
charge_full=4500000, charge_full_design=0,
|
||||
time_to_full_now=-1, time_to_empty_now=4294967295,
|
||||
cycle_count=-1, voltage_now='bad', current_now=100))
|
||||
self.assertIsNone(b['percent'])
|
||||
for key in ('watts', 'tempC', 'capacityHealthPercent', 'timeToFull', 'timeToEmpty', 'cycleCount'):
|
||||
self.assertIsNone(b[key], key)
|
||||
self.assertEqual(b['status'], 'Charging')
|
||||
|
||||
def test_no_battery_and_unreadable_type(self):
|
||||
with mock.patch.object(status.glob, 'glob', return_value=[]):
|
||||
self.assertIsNone(status.battery())
|
||||
with mock.patch.object(status.glob, 'glob', return_value=['/missing']), \
|
||||
mock.patch('builtins.open', side_effect=PermissionError):
|
||||
self.assertIsNone(status.battery())
|
||||
|
||||
def test_prefers_frame_and_excludes_peripheral_or_absent_batteries(self):
|
||||
paths = ['/sys/class/power_supply/aaa', '/sys/class/power_supply/max1720x_bat_7-36']
|
||||
def read(path):
|
||||
return {'type': 'Battery', 'capacity': '80'}.get(path.rsplit('/', 1)[-1])
|
||||
with mock.patch.object(status.glob, 'glob', return_value=paths), \
|
||||
mock.patch.object(status, 'read', side_effect=read):
|
||||
self.assertEqual(status.battery()['source'], paths[1])
|
||||
self.assertIsNone(self.probe(dict(scope='Device', capacity=99)))
|
||||
self.assertIsNone(self.probe(dict(present=0, capacity=99)))
|
||||
|
||||
def test_permission_failure_affects_only_one_sensor(self):
|
||||
actual_open = open
|
||||
def guarded(path, *args, **kwargs):
|
||||
if str(path).endswith('/temp'):
|
||||
raise PermissionError('unreadable')
|
||||
return actual_open(path, *args, **kwargs)
|
||||
with mock.patch('builtins.open', side_effect=guarded):
|
||||
b = self.probe(dict(capacity=45, temp=300))
|
||||
self.assertEqual(b['percent'], 45)
|
||||
self.assertIsNone(b['tempC'])
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Doctor tests never contact a headset or the user's SSH configuration."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('frame_doctor', ROOT / 'scripts/frame_doctor.py')
|
||||
doctor = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(doctor)
|
||||
doctor.MESSAGES = doctor.messages()
|
||||
|
||||
|
||||
def result(out='', code=0, err=''):
|
||||
return subprocess.CompletedProcess([], code, out, err)
|
||||
|
||||
|
||||
class FrameDoctor(unittest.TestCase):
|
||||
def main(self, responses, **kwargs):
|
||||
config = result('hostname private-tailnet.ts.net\nhostkeyalias pinned-frame\n')
|
||||
with patch.object(doctor.shutil, 'which', return_value='/usr/bin/ssh'), \
|
||||
patch.object(doctor, 'run', side_effect=[config, *responses]) as run, \
|
||||
patch.object(doctor, 'port_open', return_value=kwargs.get('devkit', False)), \
|
||||
patch('sys.stdout', new_callable=io.StringIO) as output:
|
||||
code = doctor.main([])
|
||||
return code, output.getvalue(), run
|
||||
|
||||
def test_unreachable_is_actionable_without_exposing_stderr(self):
|
||||
code, text, _ = self.main([result(code=255, err='secret-token 192.0.2.5')] * 3)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn('wake headset', text)
|
||||
self.assertEqual(text.count('No authenticated SSH path.'), 11)
|
||||
self.assertNotIn('secret-token', text)
|
||||
self.assertNotIn('192.0.2.5', text)
|
||||
self.assertNotIn('private-tailnet', text)
|
||||
|
||||
def test_no_keys_and_devkit_available_never_registers(self):
|
||||
code, text, run = self.main([result(code=255, err='Permission denied')] * 3, devkit=True)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn('no pairing request was sent', text)
|
||||
self.assertIn('Pair new host', text)
|
||||
self.assertFalse(any('/register' in str(c.args[0]) for c in run.call_args_list))
|
||||
|
||||
def test_fallback_preserves_identity_and_disables_mutating_ssh_options(self):
|
||||
_, text, run = self.main([result(code=255), result(json.dumps({'guard': 'loop', 'checks': []}))])
|
||||
args = run.call_args_list[2].args[0]
|
||||
for flag in ('HostKeyAlias=pinned-frame', 'HostName=frame.local', 'BatchMode=yes',
|
||||
'StrictHostKeyChecking=yes', 'UpdateHostKeys=no', 'PermitLocalCommand=no',
|
||||
'ClearAllForwardings=yes', 'ControlPath=none'):
|
||||
self.assertIn(flag, args)
|
||||
self.assertIn('OK Connection: LAN', text)
|
||||
self.assertIn('Counters are rising', text)
|
||||
|
||||
def test_hostkey_failure_does_not_suggest_deleting_identity(self):
|
||||
_, text, _ = self.main([result(code=255, err='REMOTE HOST IDENTIFICATION HAS CHANGED')] * 3)
|
||||
self.assertIn('Do not delete known_hosts', text)
|
||||
|
||||
def test_invalid_probe_response_never_prints_remote_data(self):
|
||||
for payload in ('a secret token', json.dumps({'guard': 'stable', 'checks': [['OK', 'secret']] * 11})):
|
||||
code, text, _ = self.main([result(payload)])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertNotIn('secret', text)
|
||||
|
||||
def test_guard_stops_before_any_deeper_checks(self):
|
||||
for state in ('loop', 'unknown', 'risk'):
|
||||
with patch.object(doctor, 'guard', return_value=state), patch.object(doctor, 'journal') as journal:
|
||||
self.assertEqual(doctor.probe(), {'guard': state, 'checks': []})
|
||||
journal.assert_not_called()
|
||||
|
||||
def test_rising_and_reset_counters_and_missing_data(self):
|
||||
for snapshots, expected in (([[0, 0, 0, 0], [1, 0, 0, 0]], 'loop'),
|
||||
([[1, 0, 0, 0], [0, 0, 0, 0]], 'unknown'),
|
||||
([None, None], 'unknown'),
|
||||
([[0, 4, 0, 0]] * 2, 'risk'),
|
||||
([[0, 0, 0, 0]] * 2, 'stable')):
|
||||
with patch.object(doctor, 'snapshot', side_effect=snapshots), patch.object(doctor.time, 'sleep'):
|
||||
self.assertEqual(doctor.guard(), expected)
|
||||
|
||||
def test_journal_permission_denied_and_empty_are_unknown(self):
|
||||
for response in (result('partial', err='not seeing messages from other users'), result(), result(code=1)):
|
||||
with patch.object(doctor, 'run', return_value=response):
|
||||
self.assertIsNone(doctor.journal('-b'))
|
||||
self.assertEqual(doctor.signal_check(None, 'bad', 'problem', 'healthy')[0], 'UNKNOWN')
|
||||
|
||||
def test_disk_full_reports_broken_and_never_fixes(self):
|
||||
with patch.object(doctor.shutil, 'disk_usage', return_value=Mock(free=0)):
|
||||
check = doctor.storage()
|
||||
with patch('sys.stdout', new_callable=io.StringIO) as output:
|
||||
code = doctor.report({'guard': 'stable', 'checks': [check] * 11})
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('BROKEN 4. Steam and storage', output.getvalue())
|
||||
|
||||
def test_probe_only_emits_approved_messages_and_rechecks_guard(self):
|
||||
with patch.object(doctor, 'guard', side_effect=['stable', 'stable']) as guard, \
|
||||
patch.object(doctor, 'journal', return_value=None), \
|
||||
patch.object(doctor, 'command', return_value=None), \
|
||||
patch.object(doctor, 'run', return_value=None), \
|
||||
patch.object(doctor, 'storage', return_value=['UNKNOWN', 'Storage could not be read.']):
|
||||
data = doctor.probe()
|
||||
self.assertEqual(guard.call_count, 2)
|
||||
self.assertEqual(len(data['checks']), 11)
|
||||
with patch('sys.stdout', new_callable=io.StringIO):
|
||||
self.assertEqual(doctor.report(data), 1)
|
||||
|
||||
def test_help_shell_entry_point_is_offline(self):
|
||||
# This Bash wrapper is for macOS/Linux; Windows uses the Python CLI.
|
||||
import os, shutil
|
||||
if os.name == 'nt':
|
||||
self.skipTest('POSIX shell wrapper')
|
||||
if not shutil.which('bash'):
|
||||
self.skipTest('bash unavailable')
|
||||
r = subprocess.run(['bash', str(ROOT / 'scripts/frame-doctor.sh'), '--help'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertIn('Never pairs', r.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -64,6 +64,7 @@ class LinkUI(unittest.TestCase):
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
|
||||
assert.ok($('conn').title.includes(changed.error.message));
|
||||
assert.ok(!$('conn').title.includes('..'), $('conn').title);
|
||||
// Without a short reason: the stage that failed.
|
||||
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
||||
renderPill();
|
||||
@@ -120,7 +121,9 @@ class LinkUI(unittest.TestCase):
|
||||
class HeaderLayout(unittest.TestCase):
|
||||
def test_the_devices_tab_explanation_wraps(self):
|
||||
# It was cut to one line with no way to read the rest.
|
||||
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
|
||||
# More specific than the later .item .s rule: the old equal-specificity
|
||||
# rule passed this test while the browser still clipped the explanation.
|
||||
self.assertIn(".item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }", PAGE)
|
||||
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
|
||||
|
||||
def test_the_header_buttons_dont_give_way(self):
|
||||
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
+63
-1
@@ -141,7 +141,7 @@ class PageTests(unittest.TestCase):
|
||||
def test_card_says_experimental_and_what_is_unverified(self):
|
||||
self.assertIn('<span class="tag" id="mvTag" hidden>Experimental</span>', self.page)
|
||||
self.assertIn('<input type="checkbox" id="mvPcOn">', self.page)
|
||||
self.assertIn("hasn't been tried on a real\n Windows desktop or with a headset yet", self.page)
|
||||
self.assertIn('Tested in a Windows VM; physical PCs, hardware encoding and laser clicks remain unverified.', self.page)
|
||||
self.assertIn('docs/pc-in-headset.md', self.page)
|
||||
|
||||
def test_opt_in_is_remembered_with_the_client_preferences(self):
|
||||
@@ -151,6 +151,55 @@ class PageTests(unittest.TestCase):
|
||||
|
||||
|
||||
class AdapterTests(unittest.TestCase):
|
||||
def test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties(self):
|
||||
p = capture.pipeline(dict(src='display:65537', capture_api='dxgi'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
self.assertIn('monitor-handle=65537', p)
|
||||
self.assertNotIn('capture-api=', p)
|
||||
self.assertNotIn('show-border=', p)
|
||||
self.assertIn('mfh264enc', p)
|
||||
for source in (dict(src='window:42', capture_api='dxgi'),
|
||||
dict(src='display:42', capture_api='dxgi ! fakesink')):
|
||||
with self.assertRaises(ValueError):
|
||||
capture.pipeline(source, 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
|
||||
def test_windows_sources_follow_actual_wgc_capabilities(self):
|
||||
for wgc in (False, True):
|
||||
with self.subTest(wgc=wgc):
|
||||
native, windows = mock.Mock(), mock.Mock()
|
||||
native.has_property.return_value = wgc
|
||||
windows.sources.side_effect = lambda: ([dict(src='window:12', w=800, h=600)],
|
||||
[dict(src='display:4', w=1920, h=1080)])
|
||||
with mock.patch.object(agent.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(agent, 'Windows', return_value=windows):
|
||||
host = agent.Agent(native, 'test', ROOT/'ui/mac-view.html')
|
||||
wins, displays = host.lists()
|
||||
self.assertEqual(len(wins), int(wgc))
|
||||
self.assertEqual(displays[0]['capture_api'], 'wgc' if wgc else 'dxgi')
|
||||
self.assertEqual(host.source('display:4')['capture_api'], displays[0]['capture_api'])
|
||||
if wgc:
|
||||
self.assertEqual(host.source('window:12')['capture_api'], 'wgc')
|
||||
self.assertEqual(host.window_capture_reason, '')
|
||||
else:
|
||||
with self.assertRaisesRegex(ValueError, 'Individual windows are unavailable'):
|
||||
host.source('window:12')
|
||||
self.assertIn('whole screen', host.window_capture_reason)
|
||||
# Synthetic encoder/protocol checks remain available without WGC.
|
||||
self.assertEqual(host.source('test')['src'], 'test')
|
||||
|
||||
def test_pc_card_receives_window_capture_limitation(self):
|
||||
view = frame_pcview.PCView([], lambda *a: None, 'frame')
|
||||
view.enabled = True
|
||||
view.call = mock.Mock(side_effect=lambda path: {
|
||||
'/status': dict(screen=True, accessibility=True, windowCaptureReason=agent.WINDOW_CAPTURE_UNAVAILABLE),
|
||||
'/windows': dict(windows=[]), '/displays': dict(displays=[dict(src='display:4')])}[path])
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(frame_pcview, 'LIBRARY', mock.Mock(is_file=lambda: True)):
|
||||
state = view.state()
|
||||
self.assertTrue(state['available'])
|
||||
self.assertEqual(state['windowCaptureReason'], agent.WINDOW_CAPTURE_UNAVAILABLE)
|
||||
self.assertEqual(state['windows'], [])
|
||||
self.assertEqual(state['displays'], [dict(src='display:4')])
|
||||
|
||||
def test_windows_uses_wgc_and_media_foundation(self):
|
||||
for kind in ('window', 'display'):
|
||||
pipeline = capture.pipeline(dict(src=kind+':42'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
@@ -366,6 +415,19 @@ class NativeTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self.request('/close?k='+self.token, 'POST')
|
||||
|
||||
def test_native_property_probe_checks_the_instantiated_element(self):
|
||||
code = """
|
||||
from frame_pc_capture import Native
|
||||
n = Native()
|
||||
assert n.has_property('fakesink', 'sync')
|
||||
assert not n.has_property('fakesink', 'not-a-gstreamer-property')
|
||||
assert not n.has_property('not-a-gstreamer-element', 'sync')
|
||||
"""
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['PYTHONPATH'] = str(ROOT/'ui')
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux'), 'Linux PipeWire client libraries')
|
||||
def test_bundled_pipewire_context_needs_no_system_modules(self):
|
||||
# No portal request, remote connection or desktop capture: create and
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Smoke cleanup against real local Linux processes, never a headset or SSH."""
|
||||
import sandbox # noqa: F401
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
SMOKE = Path(__file__).resolve().parent / 'smoke'
|
||||
sys.path.insert(0, str(SMOKE))
|
||||
import stop_processes # noqa: E402
|
||||
import frame_smoke # noqa: E402
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux' and hasattr(os, 'pidfd_open'), 'Linux pidfds required')
|
||||
class RealProcesses(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
self.title = self.home / 'devkit-game' / 'fc_smoke_exe'
|
||||
self.title.mkdir(parents=True)
|
||||
self.children = []
|
||||
self.patcher = mock.patch.object(stop_processes.Path, 'home', return_value=self.home)
|
||||
self.patcher.start()
|
||||
self.addCleanup(self.patcher.stop)
|
||||
self.addCleanup(self.reap)
|
||||
|
||||
def reap(self):
|
||||
for child in self.children:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait(timeout=5)
|
||||
|
||||
def start(self, cwd, *args, ignore=False):
|
||||
code = ('import signal,time; '
|
||||
+ ('signal.signal(signal.SIGTERM, signal.SIG_IGN); ' if ignore else '')
|
||||
+ 'print("ready", flush=True); time.sleep(60)')
|
||||
child = subprocess.Popen([sys.executable, '-c', code, *args], cwd=cwd,
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
self.children.append(child)
|
||||
self.addCleanup(child.stdout.close)
|
||||
self.assertEqual(child.stdout.readline().strip(), 'ready')
|
||||
return child
|
||||
|
||||
def test_deleted_title_process_is_stopped_but_similar_names_survive(self):
|
||||
owned = self.start(self.title)
|
||||
neighbour = self.title.with_name('fc_smoke_exe_other')
|
||||
neighbour.mkdir()
|
||||
other = self.start(neighbour, 'fc_smoke_exe', 'wine', 'proton')
|
||||
shutil.rmtree(self.title)
|
||||
# Reproduces the old success condition: no title remains, yet it runs.
|
||||
self.assertFalse(self.title.exists())
|
||||
self.assertIsNone(owned.poll())
|
||||
def local_target(command, *, input, timeout):
|
||||
return subprocess.run(shlex.split(command), input=input, text=True,
|
||||
capture_output=True, check=True, timeout=timeout,
|
||||
env={**os.environ, 'HOME': str(self.home)}).stdout
|
||||
|
||||
# Exercise the production SSH command and stdin payload against a local
|
||||
# fake target, including the helper's CLI and JSON result contract.
|
||||
with mock.patch.object(frame_smoke.frame_android, 'ssh', side_effect=local_target):
|
||||
result = frame_smoke.stop_titles({'fc_smoke_exe'})
|
||||
self.assertIn(owned.pid, result['stopped_pids'])
|
||||
self.assertEqual(owned.wait(timeout=5), -signal.SIGTERM)
|
||||
self.assertIsNone(other.poll())
|
||||
|
||||
def test_proton_style_absolute_argument_escalates_without_stopping_shared_wine(self):
|
||||
owned = self.start(self.home, str(self.title / 'fc-smoke-exe.exe'), ignore=True)
|
||||
shared = self.start(self.home, 'wineserver', '-p')
|
||||
quoted = self.start(self.home, 'echo ' + str(self.title / 'fc-smoke-exe.exe'))
|
||||
result = stop_processes.stop(['fc_smoke_exe'], grace=0.2)
|
||||
self.assertIn(owned.pid, result['stopped_pids'])
|
||||
self.assertEqual(owned.wait(timeout=5), -signal.SIGKILL)
|
||||
self.assertIsNone(shared.poll())
|
||||
self.assertIsNone(quoted.poll())
|
||||
self.assertEqual(stop_processes.stop(['fc_smoke_exe']), {'stopped_pids': []})
|
||||
|
||||
def test_unreadable_cwd_does_not_abort_scan_or_hide_readable_argv(self):
|
||||
owned = self.start(self.home, str(self.title / 'fc-smoke-exe.exe'))
|
||||
other = self.start(self.home, 'wineserver')
|
||||
with mock.patch.object(stop_processes.os, 'readlink', side_effect=PermissionError):
|
||||
self.assertTrue(stop_processes.owned(owned.pid, [str(self.title)]))
|
||||
self.assertFalse(stop_processes.owned(other.pid, [str(self.title)]))
|
||||
result = stop_processes.stop(['fc_smoke_exe'], grace=0.2)
|
||||
self.assertIn(owned.pid, result['stopped_pids'])
|
||||
self.assertEqual(owned.wait(timeout=5), -signal.SIGTERM)
|
||||
self.assertIsNone(other.poll())
|
||||
|
||||
|
||||
class Safety(unittest.TestCase):
|
||||
def test_non_test_ids_rejected_before_scanning(self):
|
||||
for ids in ([], ['portal2'], ['fc_smoke_exe/../portal2'], ['fc_smoke_custom']):
|
||||
with self.subTest(ids=ids), self.assertRaises(ValueError):
|
||||
stop_processes.stop(ids)
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux', 'Linux signalling helper')
|
||||
def test_lost_ownership_after_open_never_signalled(self):
|
||||
with mock.patch.object(stop_processes, 'candidates', side_effect=[[123], [], []]), \
|
||||
mock.patch.object(stop_processes.os, 'pidfd_open', return_value=9, create=True), \
|
||||
mock.patch.object(stop_processes.os, 'close'), \
|
||||
mock.patch.object(stop_processes, 'owned', return_value=False), \
|
||||
mock.patch.object(stop_processes.signal, 'pidfd_send_signal', create=True) as send:
|
||||
self.assertEqual(stop_processes.stop(['fc_smoke_exe'], grace=0), {'stopped_pids': []})
|
||||
send.assert_not_called()
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux', 'Linux signalling helper')
|
||||
def test_survivor_is_a_failure(self):
|
||||
with mock.patch.object(stop_processes, 'candidates', return_value=[123]), \
|
||||
mock.patch.object(stop_processes.os, 'pidfd_open', return_value=9, create=True), \
|
||||
mock.patch.object(stop_processes.os, 'close'), \
|
||||
mock.patch.object(stop_processes, 'owned', return_value=True), \
|
||||
mock.patch.object(stop_processes.signal, 'pidfd_send_signal', create=True):
|
||||
with self.assertRaisesRegex(RuntimeError, 'survived cleanup'):
|
||||
stop_processes.stop(['fc_smoke_exe'], grace=0)
|
||||
|
||||
def test_cleanup_scans_even_when_title_already_removed(self):
|
||||
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[]), \
|
||||
mock.patch.object(frame_smoke, 'ssh') as ssh, \
|
||||
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': [42]}) as stop:
|
||||
result = frame_smoke.cleanup(frame_smoke.Smoke())
|
||||
self.assertEqual(stop.call_args_list, [mock.call(stop_processes.IDS)] * 2)
|
||||
self.assertEqual(result['process_cleanup'][0]['stopped_pids'], [42])
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_stop_failure_prevents_file_removal(self):
|
||||
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[{'id': 'fc_smoke_exe'}]), \
|
||||
mock.patch.object(frame_smoke, 'ssh') as ssh, \
|
||||
mock.patch.object(frame_smoke, 'stop_titles', side_effect=RuntimeError('still running')):
|
||||
with self.assertRaisesRegex(RuntimeError, 'still running'):
|
||||
frame_smoke.cleanup(frame_smoke.Smoke())
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_failed_initial_cleanup_prevents_install_and_records_failure(self):
|
||||
with tempfile.TemporaryDirectory() as folder, \
|
||||
mock.patch.object(frame_smoke, 'RESULTS', Path(folder)), \
|
||||
mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
|
||||
mock.patch.object(frame_smoke.subprocess, 'run', return_value=mock.Mock(returncode=0)), \
|
||||
mock.patch.object(frame_smoke.tempfile, 'mkdtemp', return_value=folder), \
|
||||
mock.patch.object(frame_smoke, 'os_release', return_value={
|
||||
'BUILD_ID': 'fake', 'VERSION_ID': 'fake', 'VARIANT_ID': 'fake'}), \
|
||||
mock.patch.object(frame_smoke, 'ssh', return_value='yes'), \
|
||||
mock.patch.object(frame_smoke, 'cleanup', side_effect=RuntimeError('cannot stop')), \
|
||||
mock.patch.object(frame_smoke, 'title_cycle') as cycle, \
|
||||
mock.patch('builtins.print'):
|
||||
self.assertEqual(frame_smoke.main(), 1)
|
||||
cycle.assert_not_called()
|
||||
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Storage inventory uses only local temporary metadata; no headset connection."""
|
||||
import sandbox # noqa: F401
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_storage as storage
|
||||
import server
|
||||
|
||||
|
||||
class Storage(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
self.steam = self.home / '.local/share/Steam'
|
||||
(self.steam / 'steamapps').mkdir(parents=True)
|
||||
|
||||
def write(self, relative, text):
|
||||
path = self.steam / 'steamapps' / relative
|
||||
path.write_text(text)
|
||||
return path
|
||||
|
||||
def app(self, appid, name, size=None):
|
||||
return self.write(f'appmanifest_{appid}.acf', '"AppState" { "appid" "%s" "name" "%s" %s }' %
|
||||
(appid, name, '' if size is None else '"SizeOnDisk" "%s"' % size))
|
||||
|
||||
def inventory(self):
|
||||
return storage.inventory(self.home, self.steam)
|
||||
|
||||
def test_multilibrary_dedup_sizes_and_read_only(self):
|
||||
extra = self.home / 'other library'
|
||||
(extra / 'steamapps').mkdir(parents=True)
|
||||
self.write('libraryfolders.vdf', '"libraryfolders" { "0" { "path" "%s" } "1" { "path" "%s" } "2" "%s" }' %
|
||||
(self.steam, extra, extra))
|
||||
self.app(1, 'Game', '400')
|
||||
self.app(2, 'Proton 10', '100')
|
||||
self.app(3, 'Unknown')
|
||||
self.app(4, 'Zero', '0')
|
||||
self.app(5, 'Bad', '-10')
|
||||
(extra / 'steamapps/appmanifest_9.acf').write_text('"AppState" { "appid" "9" "name" "Other" "SizeOnDisk" "200" }')
|
||||
before = {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()}
|
||||
result = self.inventory()
|
||||
self.assertEqual(result['discovery'], 'ok')
|
||||
self.assertEqual(len(result['libraries']), 2)
|
||||
lib = result['libraries'][0]
|
||||
self.assertEqual((lib['gameBytes'], lib['toolBytes'], lib['recordedBytes'], lib['unknownSizes']), (400, 100, 500, 2))
|
||||
self.assertEqual([a['bytes'] for a in lib['apps']], [400, 100, 0, None, None])
|
||||
self.assertEqual(result['libraries'][1]['recordedBytes'], 200)
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()})
|
||||
|
||||
def test_partial_and_corrupt_metadata_never_become_empty_success(self):
|
||||
self.write('libraryfolders.vdf', '"libraryfolders" {')
|
||||
self.app(1, 'Good', '12')
|
||||
self.write('appmanifest_2.acf', '"AppState" { "appid" "2"')
|
||||
self.write('appmanifest_3.acf', '"AppState" { "appid" "9" }')
|
||||
r = self.inventory()
|
||||
self.assertEqual(r['discovery'], 'invalid-metadata')
|
||||
lib = r['libraries'][0]
|
||||
self.assertEqual((lib['state'], lib['recordedBytes'], len(lib['issues'])), ('partial', 12, 2))
|
||||
|
||||
def test_permission_denied_discovery_listing_manifest_and_capacity(self):
|
||||
original = storage.read_vdf
|
||||
self.app(1, 'Game', '10')
|
||||
def read(path):
|
||||
if str(path).endswith('appmanifest_1.acf') or str(path).endswith('libraryfolders.vdf'):
|
||||
raise PermissionError('denied')
|
||||
return original(path)
|
||||
with patch.object(storage, 'read_vdf', side_effect=read), patch.object(storage.os, 'statvfs', side_effect=PermissionError(), create=True):
|
||||
r = self.inventory()
|
||||
self.assertEqual(r['discovery'], 'permission-denied')
|
||||
self.assertEqual(r['home']['state'], 'permission-denied')
|
||||
self.assertIsNone(r['home']['free'])
|
||||
self.assertEqual(r['libraries'][0]['issues'][0]['state'], 'permission-denied')
|
||||
with patch.object(storage.os, 'scandir', side_effect=PermissionError()):
|
||||
self.assertEqual(self.inventory()['libraries'][0]['state'], 'permission-denied')
|
||||
|
||||
def test_disconnected_library_and_missing_discovery(self):
|
||||
r = self.inventory()
|
||||
self.assertEqual(r['discovery'], 'missing')
|
||||
missing = self.home / 'disconnected'
|
||||
self.write('libraryfolders.vdf', '"libraryfolders" { "1" { "path" "%s" } }' % missing)
|
||||
lib = self.inventory()['libraries'][1]
|
||||
self.assertEqual(lib['state'], 'missing')
|
||||
self.assertIsNone(lib['capacity']['total'])
|
||||
|
||||
def test_reserved_capacity_and_shared_filesystem(self):
|
||||
stat = SimpleNamespace(f_blocks=100, f_frsize=1024, f_bavail=10, f_bfree=20)
|
||||
with patch.object(storage.os, 'statvfs', return_value=stat, create=True):
|
||||
r = self.inventory()
|
||||
self.assertEqual((r['home']['total'], r['home']['free'], r['home']['used']), (102400, 10240, 81920))
|
||||
self.assertEqual(r['home']['filesystem'], r['libraries'][0]['capacity']['filesystem'])
|
||||
|
||||
def test_keyvalues_comments_escaping_and_limits(self):
|
||||
path = self.write('parse.vdf', '// comment\n"root" { "name" "A \\"quote\\"" "nested" { "size" "42" } }')
|
||||
self.assertEqual(storage.read_vdf(path)['root']['name'], 'A "quote"')
|
||||
path.write_text('"root" { "name" "}" }')
|
||||
self.assertEqual(storage.read_vdf(path)['root']['name'], '}')
|
||||
for bad in ['"a" {', '"a" "1" "a" "2"', '"a" }', 'garbage', '"a" {"b" "c"}}']:
|
||||
path.write_text(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
storage.read_vdf(path)
|
||||
path.write_text('x' * (storage.MAX_TEXT + 1))
|
||||
with self.assertRaises(ValueError):
|
||||
storage.read_vdf(path)
|
||||
|
||||
def test_http_probe_is_guarded_and_read_only(self):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with patch.object(server, 'ssh', return_value=json.dumps(self.inventory())) as ssh:
|
||||
conn = http.client.HTTPConnection(*httpd.server_address)
|
||||
conn.request('GET', '/api/storage')
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 403)
|
||||
response.read()
|
||||
ssh.assert_not_called()
|
||||
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY})
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn('libraries', json.loads(response.read()))
|
||||
self.assertEqual(ssh.call_args.args, ('python3 -',))
|
||||
self.assertEqual(ssh.call_args.kwargs['stdin'], (ROOT / 'ui/frame_storage.py').read_text())
|
||||
with patch.object(server, 'LINK', SimpleNamespace(active_device=lambda: {'id': 'new-frame'})):
|
||||
ssh.reset_mock()
|
||||
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY, 'X-Frame-Device': 'old-frame'})
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 409)
|
||||
response.read()
|
||||
ssh.assert_not_called()
|
||||
conn.close()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
thread.join()
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node required')
|
||||
def test_ui_behavior(self):
|
||||
r = subprocess.run(['node', '--test', str(ROOT / 'tests/storage.cjs')], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
@@ -40,7 +40,9 @@ test('a device switch discards completed install results and the open detail',()
|
||||
const f=connectionFixture(),c=f.context;
|
||||
Object.assign(c,{pad:{state:'off',queue:[]},padReset(){},sourceState:{jobs:new Map([['test:one',{done:true,result:{package:'app.on.a',game_data:true}}]]),detail:{app:{}},detailRequest:0}});
|
||||
f.el('appDetail').open=true;
|
||||
f.el('storageResult').textContent='Old headset storage';
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.match(f.el('storageResult').textContent,/Headset changed/);
|
||||
assert.equal(c.sourceState.jobs.size,0);assert.equal(c.sourceState.detail,null);assert.equal(f.el('appDetail').open,false);
|
||||
});
|
||||
test('only a preview matching current diagnostics consent can be sent or copied',async()=>{
|
||||
|
||||
+11
-7
@@ -16,8 +16,8 @@
|
||||
description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.',
|
||||
keywords:'sound speakers headphones loud quiet', special:'audio'},
|
||||
{id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics',
|
||||
description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.',
|
||||
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
|
||||
description:'Inspect charge, signed battery power, charger readings, battery temperature, health and optional capacity / cycle readings. Heat warnings use the kernel’s own hot and critical trip points.',
|
||||
keywords:'battery charging charger charge time estimate remaining power watts usb health cycles capacity wear heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
|
||||
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
|
||||
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
|
||||
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#linuxApps','Linux apps'],['#power','SFTP & remote tools']]},
|
||||
@@ -144,9 +144,9 @@
|
||||
const capacity = b.percent;
|
||||
const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5;
|
||||
const charging = b.status === 'Charging';
|
||||
const full = b.status === 'Full' || (!!s.power && capacity >= 100);
|
||||
const full = b.status === 'Full';
|
||||
const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' :
|
||||
full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery';
|
||||
full ? 'Fully charged' : charging ? 'Charging' : b.status === 'Not charging' || idle ? (s.power ? 'Plugged in · not charging' : 'Not charging') : b.status === 'Discharging' ? (s.power ? 'Plugged in · on battery' : 'On battery') : 'Charging state unavailable';
|
||||
return {
|
||||
battery:number(capacity, '%'), batteryDetail:batteryState,
|
||||
storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable',
|
||||
@@ -155,8 +155,11 @@
|
||||
rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available),
|
||||
fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS',
|
||||
power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable',
|
||||
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable',
|
||||
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
|
||||
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health && b.health !== 'Unknown' ? b.health : 'Unavailable',
|
||||
batterySource:b.source || 'Unavailable', cycles:number(b.cycleCount),
|
||||
fullCapacity:number(b.fullMah, ' mAh', 1), designCapacity:number(b.designMah, ' mAh', 1),
|
||||
capacityHealth:number(b.capacityHealthPercent, '%', 1),
|
||||
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : b.status === 'Discharging' && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
|
||||
wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable',
|
||||
uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable',
|
||||
temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1),
|
||||
@@ -214,7 +217,8 @@
|
||||
const metric = (id, label) => `<div><dt>${label}</dt><dd data-cc-metric="${id}">Unavailable</dd></div>`;
|
||||
const perf = `<dl class="cc-readings">${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.</p><p class="cc-note" data-cc-metric="thermal">Trip data unavailable</p>`;
|
||||
const audio = `<div class="cc-audio"><label for="ccVolume">Output volume <output id="ccVolumeValue" for="ccVolume">—</output></label><input id="ccVolume" type="range" min="0" max="100" step="1" value="0" disabled><div class="cc-audio-actions"><button class="action small" id="ccApplyVolume" disabled>Apply volume</button><button class="small" id="ccMute" disabled>Mute</button><span id="ccAudioReadback" class="cc-note">Waiting for an audio reading</span></div><p class="cc-note" id="ccAudioResult" role="status"></p></div>`;
|
||||
const health = `<dl class="cc-readings">${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
const batteryNote = 'Read-only. Unavailable means not reported, unreadable or invalid. Health is the driver’s status; full / design capacity is a fuel-gauge estimate, not a diagnosis. Charge limits are unsupported on Frame.';
|
||||
const health = `<dl class="cc-readings">${[['battery','Charge'],['batteryDetail','Charging state'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health'],['cycles','Charge cycles'],['fullCapacity','Full charge capacity'],['designCapacity','Design capacity'],['capacityHealth','Full / design capacity'],['batterySource','Battery source']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">${batteryNote}</p>`;
|
||||
const forcequit = `<div class="cc-quit"><div class="cc-audio-actions"><button class="small" type="button" id="ccRunningCheck">Check what’s running</button></div><ul class="cc-running" id="ccRunningList"></ul><p class="cc-note" id="ccRunningResult" role="status"></p></div>`;
|
||||
const storage = `<dl class="cc-readings">${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>`<button type="button" data-cc-category="${id}" aria-pressed="${id === 'all'}">${icon(symbol)}<span>${label}</span><span class="cc-category-count"></span></button>`).join('');
|
||||
|
||||
@@ -460,6 +460,7 @@ class MacView:
|
||||
windows = self.call("/windows").get("windows", []) if status.get("screen") else []
|
||||
displays = self.call("/displays").get("displays", [])
|
||||
return {"available": True, "host": self.host, "selecting": status.get("selecting", False),
|
||||
**({"windowCaptureReason": status["windowCaptureReason"]} if status.get("windowCaptureReason") else {}),
|
||||
"selectionError": status.get("selectionError", ""), "screen": status.get("screen", False),
|
||||
"accessibility": status.get("accessibility", False), "streams": status.get("streams", []),
|
||||
"windows": windows, "displays": displays, "tunnel": self.tunnel_up(),
|
||||
|
||||
@@ -29,6 +29,7 @@ from frame_stream_stats import Stats
|
||||
|
||||
# Viewer fields are untrusted JSON: float('x'), int(None), m['t'] missing.
|
||||
MALFORMED = (ValueError, TypeError, KeyError, OverflowError)
|
||||
WINDOW_CAPTURE_UNAVAILABLE = 'Individual windows are unavailable in this build. Choose a whole screen instead.'
|
||||
|
||||
|
||||
class Grants:
|
||||
@@ -386,6 +387,9 @@ class Agent:
|
||||
self.sessions, self.sources = {}, {}
|
||||
self.next_id, self.selecting, self.selection_error = 1, False, ''
|
||||
self.windows = Windows() if sys.platform == 'win32' else None
|
||||
self.wgc = bool(self.windows) and all(native.has_property('d3d11screencapturesrc', p)
|
||||
for p in ('capture-api', 'window-handle', 'show-border'))
|
||||
self.window_capture_reason = WINDOW_CAPTURE_UNAVAILABLE if self.windows and not self.wgc else ''
|
||||
self.encoder = 'mfh264enc' if self.windows else 'vah264enc' if native.has('vah264enc') else 'x264enc'
|
||||
self.shutting_down = False
|
||||
self.selection_generation = 0
|
||||
@@ -393,6 +397,10 @@ class Agent:
|
||||
def lists(self):
|
||||
if self.windows:
|
||||
windows, displays = self.windows.sources()
|
||||
if not self.wgc:
|
||||
windows = []
|
||||
for source in windows + displays:
|
||||
source['capture_api'] = 'wgc' if self.wgc else 'dxgi'
|
||||
self.sources = {s['src']: s for s in windows + displays}
|
||||
return windows, displays
|
||||
return [{k: v for k, v in s.items() if k not in ('portal', 'fd', 'node')} for s in self.sources.values()], []
|
||||
@@ -400,6 +408,8 @@ class Agent:
|
||||
def source(self, src):
|
||||
if src == 'test':
|
||||
return dict(src='test', title='Test pattern', w=1280, h=720)
|
||||
if self.window_capture_reason and isinstance(src, str) and src.startswith('window:'):
|
||||
raise ValueError(self.window_capture_reason)
|
||||
self.lists()
|
||||
if src not in self.sources:
|
||||
raise ValueError('Choose a window or screen on this computer first')
|
||||
@@ -498,6 +508,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
windows, displays = agent.lists()
|
||||
if method == 'GET' and url.path == '/status':
|
||||
data = dict(version=1, host='windows' if agent.windows else 'linux', screen=True,
|
||||
windowCaptureReason=agent.window_capture_reason,
|
||||
accessibility=True, selecting=agent.selecting, selectionError=agent.selection_error,
|
||||
encoder=agent.encoder, finished=[], streams=[dict(id=i, src=s.src,
|
||||
title=s.source.get('title', ''), stats=s.stats.summary(), controller=s.controller.state())
|
||||
|
||||
+11
-1
@@ -38,6 +38,7 @@ class Native:
|
||||
signatures = {
|
||||
'fc_now': (C.c_int64, []), 'fc_gst_init': (None, []),
|
||||
'fc_has_element': (C.c_int, [C.c_char_p]),
|
||||
'fc_has_property': (C.c_int, [C.c_char_p, C.c_char_p]),
|
||||
'fc_new': (C.c_void_p, [C.c_int, C.c_int]), 'fc_free': (None, [C.c_void_p]),
|
||||
'fc_ceiling': (None, [C.c_void_p, C.c_int]),
|
||||
'fc_gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
|
||||
@@ -69,6 +70,10 @@ class Native:
|
||||
def has(self, name):
|
||||
return bool(self.lib.fc_has_element(name.encode()))
|
||||
|
||||
def has_property(self, element, name):
|
||||
"""Check optional element properties in the initialized capture DLL."""
|
||||
return bool(self.lib.fc_has_property(element.encode(), name.encode()))
|
||||
|
||||
def now(self):
|
||||
return self.lib.fc_now()
|
||||
|
||||
@@ -133,8 +138,13 @@ def pipeline(source, platform, encoder, w, h, fps, bitrate, codec='h264'):
|
||||
kind, ident = source['src'].split(':')
|
||||
if kind not in ('window', 'display') or not re.fullmatch(r'[0-9]+', ident):
|
||||
raise ValueError('Invalid Windows capture source')
|
||||
api = source.get('capture_api', 'wgc')
|
||||
if api not in ('wgc', 'dxgi') or (api == 'dxgi' and kind != 'display'):
|
||||
raise ValueError('This build can only capture whole screens without Windows Graphics Capture')
|
||||
prop = 'window-handle' if kind == 'window' else 'monitor-handle'
|
||||
capture = 'd3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true %s=%d' % (prop, int(ident))
|
||||
# DXGI-only builds don't expose capture-api or show-border at all.
|
||||
mode = ' capture-api=wgc show-border=true' if api == 'wgc' else ''
|
||||
capture = 'd3d11screencapturesrc%s show-cursor=true %s=%d' % (mode, prop, int(ident))
|
||||
else:
|
||||
capture = 'pipewiresrc fd=%d path=%d do-timestamp=true' % (source['fd'], source['node'])
|
||||
# The source gate runs before conversion/encoding. There is no leaky queue
|
||||
|
||||
+29
-8
@@ -45,19 +45,40 @@ def num(path, scale=1.0):
|
||||
|
||||
|
||||
def battery():
|
||||
for d in glob.glob("/sys/class/power_supply/*"):
|
||||
# Prefer the documented Frame fuel gauge over controller/peripheral batteries.
|
||||
supplies = sorted(glob.glob("/sys/class/power_supply/*"),
|
||||
key=lambda d: (os.path.basename(d) != "max1720x_bat_7-36", d))
|
||||
for d in supplies:
|
||||
if read(d + "/type") != "Battery":
|
||||
continue
|
||||
cap = read(d + "/capacity")
|
||||
if read(d + "/scope") == "Device" or read(d + "/present") == "0":
|
||||
continue
|
||||
|
||||
def value(name, scale=1, minimum=None, maximum=None):
|
||||
n = num(d + "/" + name, scale)
|
||||
if n is None or not math.isfinite(n):
|
||||
return None
|
||||
if (minimum is not None and n < minimum) or (maximum is not None and n > maximum):
|
||||
return None
|
||||
return n
|
||||
|
||||
# max1720x reports microvolts/microamps; current is positive while charging.
|
||||
volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6)
|
||||
return {"percent": int(cap) if cap and cap.isdigit() else None,
|
||||
volts, amps = value("voltage_now", 1e-6, 0), value("current_now", 1e-6)
|
||||
full = value("charge_full", 1e-3, 0)
|
||||
design = value("charge_full_design", 1e-3, 0)
|
||||
return {"source": d,
|
||||
"percent": value("capacity", minimum=0, maximum=100),
|
||||
"status": read(d + "/status"),
|
||||
"watts": round(volts * amps, 2) if volts is not None and amps is not None else None,
|
||||
"timeToFull": num(d + "/time_to_full_now"),
|
||||
"timeToEmpty": num(d + "/time_to_empty_now"),
|
||||
"tempC": num(d + "/temp", 0.1),
|
||||
"health": read(d + "/health")}
|
||||
# Reject negative/unsigned sentinel estimates and implausible temperatures.
|
||||
"timeToFull": value("time_to_full_now", minimum=1, maximum=604800),
|
||||
"timeToEmpty": value("time_to_empty_now", minimum=1, maximum=604800),
|
||||
"tempC": value("temp", 0.1, -273.15, 200),
|
||||
"health": read(d + "/health"),
|
||||
"cycleCount": value("cycle_count", minimum=0),
|
||||
"fullMah": full if full else None,
|
||||
"designMah": design if design else None,
|
||||
"capacityHealthPercent": round(full / design * 100, 1) if full and design else None}
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Read-only headset storage inventory, piped over SSH as python3 -.
|
||||
|
||||
No recursive size scans, Steam IPC, subprocesses or writes. Manifest sizes are
|
||||
Steam's recorded content sizes, not filesystem allocation measurements.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
|
||||
HOME = Path.home()
|
||||
STEAM = HOME / '.local/share/Steam'
|
||||
MAX_TEXT = 2 * 1024 * 1024
|
||||
TOOLS = re.compile(r'^(Steam Linux Runtime|Proton|Steamworks Common|FEX$|Lepton Development$)')
|
||||
|
||||
|
||||
def state(error):
|
||||
if isinstance(error, PermissionError):
|
||||
return 'permission-denied'
|
||||
if isinstance(error, FileNotFoundError):
|
||||
return 'missing'
|
||||
return 'unavailable'
|
||||
|
||||
|
||||
def read_vdf(path):
|
||||
"""Small strict KeyValues reader; reject truncated/malformed input explicitly."""
|
||||
with open(path, encoding='utf-8', errors='strict') as stream:
|
||||
text = stream.read(MAX_TEXT + 1)
|
||||
if len(text) > MAX_TEXT:
|
||||
raise ValueError('metadata too large')
|
||||
tokens = []
|
||||
pos = 0
|
||||
pattern = re.compile(r'\s+|//[^\n]*|"((?:[^"\\]|\\.)*)"|([{}])')
|
||||
while pos < len(text):
|
||||
match = pattern.match(text, pos)
|
||||
if not match:
|
||||
raise ValueError('invalid KeyValues')
|
||||
pos = match.end()
|
||||
if match.group(1) is not None:
|
||||
tokens.append(('text', re.sub(r'\\(["\\])', r'\1', match.group(1))))
|
||||
elif match.group(2):
|
||||
tokens.append(('brace', match.group(2)))
|
||||
index = 0
|
||||
|
||||
def pairs(depth=0):
|
||||
nonlocal index
|
||||
if depth > 20:
|
||||
raise ValueError('metadata too deep')
|
||||
result = {}
|
||||
while index < len(tokens):
|
||||
key_type, key = tokens[index]
|
||||
index += 1
|
||||
if key_type == 'brace' and key == '}':
|
||||
if not depth:
|
||||
raise ValueError('unexpected brace')
|
||||
return result
|
||||
if key_type == 'brace' or index >= len(tokens):
|
||||
raise ValueError('missing value')
|
||||
value_type, value = tokens[index]
|
||||
index += 1
|
||||
if value_type == 'brace' and value == '{':
|
||||
value = pairs(depth + 1)
|
||||
elif value_type == 'brace':
|
||||
raise ValueError('missing value')
|
||||
if key.lower() in result:
|
||||
raise ValueError('duplicate key')
|
||||
result[key.lower()] = value
|
||||
if depth:
|
||||
raise ValueError('unclosed object')
|
||||
return result
|
||||
return pairs()
|
||||
|
||||
|
||||
def capacity(path):
|
||||
try:
|
||||
s = os.statvfs(path)
|
||||
return {'state': 'ok', 'total': s.f_blocks * s.f_frsize,
|
||||
'free': s.f_bavail * s.f_frsize,
|
||||
'used': (s.f_blocks - s.f_bfree) * s.f_frsize,
|
||||
'filesystem': str(os.stat(path).st_dev)}
|
||||
except (OSError, AttributeError) as error:
|
||||
return {'state': state(error), 'total': None, 'free': None, 'used': None,
|
||||
'filesystem': None}
|
||||
|
||||
|
||||
def inventory(home=None, steam=None):
|
||||
home, steam = Path(home or HOME), Path(steam or STEAM)
|
||||
paths = [steam]
|
||||
discovery = 'ok'
|
||||
try:
|
||||
folders = read_vdf(steam / 'steamapps/libraryfolders.vdf').get('libraryfolders')
|
||||
if not isinstance(folders, dict):
|
||||
raise ValueError('missing libraryfolders')
|
||||
for key, value in folders.items():
|
||||
if not key.isdigit():
|
||||
continue
|
||||
path = value.get('path') if isinstance(value, dict) else value
|
||||
if not isinstance(path, str) or not os.path.isabs(path) or '\x00' in path:
|
||||
raise ValueError('invalid library path')
|
||||
paths.append(Path(path))
|
||||
except OSError as error:
|
||||
discovery = state(error)
|
||||
except (ValueError, UnicodeError):
|
||||
discovery = 'invalid-metadata'
|
||||
libraries, seen = [], set()
|
||||
for path in paths:
|
||||
canonical = os.path.realpath(path)
|
||||
if canonical in seen:
|
||||
continue
|
||||
seen.add(canonical)
|
||||
lib = {'path': str(path), 'capacity': capacity(path), 'state': 'ok',
|
||||
'apps': [], 'issues': [], 'recordedBytes': 0, 'unknownSizes': 0,
|
||||
'gameBytes': 0, 'toolBytes': 0}
|
||||
try:
|
||||
with os.scandir(path / 'steamapps') as entries:
|
||||
manifests = sorted(entry.path for entry in entries
|
||||
if re.fullmatch(r'appmanifest_\d+\.acf', entry.name))
|
||||
for manifest in manifests:
|
||||
try:
|
||||
fields = read_vdf(manifest).get('appstate')
|
||||
if not isinstance(fields, dict):
|
||||
raise ValueError('missing AppState')
|
||||
appid = fields.get('appid', '')
|
||||
if not isinstance(appid, str) or not appid.isdigit() or Path(manifest).name != f'appmanifest_{appid}.acf':
|
||||
raise ValueError('invalid appid')
|
||||
name = fields.get('name') or appid
|
||||
if not isinstance(name, str):
|
||||
raise ValueError('invalid name')
|
||||
raw = fields.get('sizeondisk')
|
||||
size = int(raw) if isinstance(raw, str) and raw.isascii() and raw.isdigit() and len(raw) <= 18 else None
|
||||
kind = 'tool' if TOOLS.match(name) else 'game'
|
||||
lib['apps'].append({'appid': appid, 'name': name, 'bytes': size, 'kind': kind})
|
||||
if size is None:
|
||||
lib['unknownSizes'] += 1
|
||||
else:
|
||||
lib['recordedBytes'] += size
|
||||
lib['toolBytes' if kind == 'tool' else 'gameBytes'] += size
|
||||
except (OSError, ValueError, UnicodeError) as error:
|
||||
lib['issues'].append({'file': Path(manifest).name,
|
||||
'state': state(error) if isinstance(error, OSError) else 'invalid-metadata'})
|
||||
lib['apps'].sort(key=lambda app: (app['bytes'] is None, -(app['bytes'] or 0), app['name'].lower()))
|
||||
if lib['issues']:
|
||||
lib['state'] = 'partial'
|
||||
except OSError as error:
|
||||
lib['state'] = state(error)
|
||||
libraries.append(lib)
|
||||
return {'time': time.time(), 'home': capacity(home), 'discovery': discovery, 'libraries': libraries}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(inventory()))
|
||||
+68
-13
@@ -175,7 +175,8 @@
|
||||
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||
padding: 8px 8px; font: inherit; font-size: 13px; }
|
||||
.addr .t { overflow-wrap: anywhere; }
|
||||
.addr .s, .dev-problem .s { white-space: normal; }
|
||||
.addr .s { white-space: normal; }
|
||||
.item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }
|
||||
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
|
||||
.addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; }
|
||||
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } }
|
||||
@@ -1018,6 +1019,9 @@
|
||||
#appsLinux .row { flex-wrap:wrap!important; }
|
||||
}
|
||||
|
||||
#storage .card { padding:16px; border:1px solid rgba(255,255,255,.1); border-radius:8px; margin-top:16px; }
|
||||
#storage .grow, #storage .sub { overflow-wrap:anywhere; }
|
||||
#storage .item > span { flex-shrink:0; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/control-center.css">
|
||||
</head>
|
||||
@@ -1124,7 +1128,10 @@
|
||||
<div><dt>Battery power · signed</dt><dd data-cc-metric="power">Unavailable</dd></div><div><dt>Charger</dt><dd data-cc-metric="charger">Unavailable</dd></div>
|
||||
<div><dt>Battery temperature</dt><dd data-cc-metric="batteryTemp">Unavailable</dd></div><div><dt>Battery health</dt><dd data-cc-metric="batteryHealth">Unavailable</dd></div>
|
||||
<div><dt>Battery estimate</dt><dd data-cc-metric="estimate">Unavailable</dd></div>
|
||||
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.</p>
|
||||
<div><dt>Charge cycles</dt><dd data-cc-metric="cycles">Unavailable</dd></div><div><dt>Full charge capacity</dt><dd data-cc-metric="fullCapacity">Unavailable</dd></div>
|
||||
<div><dt>Design capacity</dt><dd data-cc-metric="designCapacity">Unavailable</dd></div><div><dt>Full / design capacity</dt><dd data-cc-metric="capacityHealth">Unavailable</dd></div>
|
||||
<div><dt>Battery source</dt><dd data-cc-metric="batterySource">Unavailable</dd></div>
|
||||
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Unavailable sensors are not reported, unreadable or invalid. Health is the driver’s status; full / design capacity is a fuel-gauge estimate, not a diagnosis. Charge limits are unsupported on Frame.</p>
|
||||
<div class="cc-services" id="ccServices"></div>
|
||||
</details>
|
||||
</section>
|
||||
@@ -1420,7 +1427,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 windows and screens</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#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="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>
|
||||
@@ -1667,6 +1674,17 @@
|
||||
Saved spatial layouts are not available yet.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="storage" hidden>
|
||||
<div class="shelf-head"><h2>Game storage</h2><span class="spacer"></span><button class="small" id="storageRefresh">Refresh</button></div>
|
||||
<p class="sub">Read-only snapshot. Steam's recorded install sizes may be stale and exclude saves, shaders, downloads, Android and sideloaded content. No files are scanned recursively or changed.</p>
|
||||
<div id="storageResult" role="status" aria-live="polite">Open this tool to read headset storage.</div>
|
||||
<div class="card" style="margin-top:16px"><h3>Move a game with Steam</h3>
|
||||
<p>On the headset, open Steam → Settings → Storage, choose the source drive, select the game, then choose Move and a destination. If Storage is unavailable in the headset UI, use desktop Steam’s Settings → Storage.</p>
|
||||
<p class="sub">Steam offers destinations it supports. Let Steam finish and handle any errors, then Refresh here. Frame Control cannot verify a supported move API or its recovery behaviour, so it never moves or deletes game files.</p>
|
||||
<a href="https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move" target="_blank" rel="noopener">Steam's storage and moving guide ↗</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="backup" hidden>
|
||||
<div class="shelf-head"><h2>Back up the Frame</h2><span class="spacer"></span>
|
||||
<button class="small" id="backupFolder">Open backups folder</button></div>
|
||||
@@ -1706,9 +1724,9 @@
|
||||
<div class="shelf-head"><h2 id="mvTitle">Mac in the headset</h2><span class="tag" id="mvTag" hidden>Experimental</span><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="mvRefresh">Refresh</button></div>
|
||||
<div id="mvPc" hidden>
|
||||
<p class="hint" style="color:var(--warn)">Experimental. Shows this PC's windows and screens as panels in the
|
||||
headset, with laser and keyboard input. It builds and passes its tests on Windows in CI, but hasn't been tried on a real
|
||||
Windows desktop or with a headset yet: capture, hardware encoding and laser clicks may not work. Clicks and keys
|
||||
<p class="hint" style="color:var(--warn)">Experimental. Shows a whole PC screen as a panel in the
|
||||
headset, with laser and keyboard input. Individual windows are offered only when this build supports them.
|
||||
Tested in a Windows VM; physical PCs, hardware encoding and laser clicks remain unverified. Clicks and keys
|
||||
can't reach administrator apps or the sign-in screen.
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/pc-in-headset.md" target="_blank" rel="noopener noreferrer">What's verified</a></p>
|
||||
<label class="mv-opt"><input type="checkbox" id="mvPcOn"> Turn on PC in the headset
|
||||
@@ -1981,6 +1999,8 @@
|
||||
<input type="text" id="caLabel" maxlength="60" placeholder="Label, e.g. Home" aria-label="Label for the new address">
|
||||
<button class="small" type="submit">Add</button></form>
|
||||
<details class="conn-more" id="connMore"><summary>Connection steps</summary>
|
||||
<p class="sub">Trouble setting up or pairing? <a href="https://github.com/saphid/frame-control/blob/main/docs/frame-doctor.md" target="_blank" rel="noopener noreferrer">Read-only diagnostic guide</a>
|
||||
explains how to check a connection without sharing passwords or keys.</p>
|
||||
<ol class="stages" id="connStages"></ol>
|
||||
<h3>This computer</h3>
|
||||
<dl class="facts" id="connNet"></dl>
|
||||
@@ -2422,12 +2442,11 @@ $("castBtn").onclick = () => {
|
||||
// ---- battery ----
|
||||
function battery(b, power) {
|
||||
const pct = b?.percent;
|
||||
const full = b?.status === "Full" || (!!power && pct >= 100);
|
||||
const full = b?.status === "Full";
|
||||
const charging = !full && b?.status === "Charging";
|
||||
// A weak charger can lose to the load: plugged in but still draining.
|
||||
// Topped up on a charger it reports Discharging at ~0 W: that's idle, not draining.
|
||||
const idle = !!power && b?.watts != null && Math.abs(b.watts) < 0.5;
|
||||
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
|
||||
const low = !charging && !full && pct != null && pct <= 15;
|
||||
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
|
||||
|
||||
@@ -2437,7 +2456,8 @@ function battery(b, power) {
|
||||
else if (charging) {
|
||||
label = b.watts > 0 ? `Charging · ${b.watts.toFixed(1)} W` : "Charging"; cls = "charging";
|
||||
if (b.timeToFull > 0) detail.push(`Full in ${dur(b.timeToFull)}`);
|
||||
} else if (!draining) { label = "Plugged in, not charging"; cls = "draining"; }
|
||||
} else if (b.status === "Not charging" || idle) { label = "Not charging"; cls = "draining"; }
|
||||
else if (b.status !== "Discharging") { label = "Charging state unavailable"; cls = "draining"; }
|
||||
else {
|
||||
label = low ? "Battery low" : power ? "Plugged in, still draining" : "On battery";
|
||||
cls = low ? "low" : "draining";
|
||||
@@ -5425,6 +5445,39 @@ $("panelList").onclick = e => {
|
||||
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
|
||||
};
|
||||
|
||||
// ---- Read-only game storage ----
|
||||
let storageSeq = 0;
|
||||
const storageState = s => ({'permission-denied':'Permission denied', missing:'Missing or disconnected',
|
||||
unavailable:'Unavailable', 'invalid-metadata':'Invalid Steam metadata', partial:'Partial inventory', ok:'Available'}[s] || 'Unknown');
|
||||
const storageBytes = n => typeof n === 'number' && Number.isFinite(n) && n >= 0 ? sizeText(n) : 'Unknown';
|
||||
function storageCapacity(c) {
|
||||
if (!c || c.state !== 'ok') return esc(storageState(c?.state));
|
||||
return `${storageBytes(c.free)} free / ${storageBytes(c.total)} total · ${storageBytes(c.used)} used`;
|
||||
}
|
||||
function renderStorage(r) {
|
||||
return `<div class="card"><h3>Headset user filesystem</h3><p>${storageCapacity(r.home)}</p>
|
||||
<p class="sub">Filesystem space available to your user; excludes separate system partitions. Reserved space can make used + free less than total.</p></div>` +
|
||||
(r.discovery !== 'ok' ? `<p class="sub">Library discovery: ${esc(storageState(r.discovery))}. Additional libraries may be absent from this view.</p>` : '') +
|
||||
r.libraries.map(lib => `<div class="card" style="margin-top:16px"><h3 style="overflow-wrap:anywhere">${esc(lib.path)}</h3>
|
||||
<p>${storageCapacity(lib.capacity)}</p><p class="sub">Filesystem containing this library path; paths on the same filesystem share this space. Capacities must not be added together.</p>
|
||||
<p>${esc(storageState(lib.state))} · Known games/apps: ${storageBytes(lib.gameBytes)} · Known runtimes / tools (by name): ${storageBytes(lib.toolBytes)}</p>
|
||||
<p class="sub">Known recorded content: ${storageBytes(lib.recordedBytes)} · ${lib.unknownSizes} unknown sizes · ${lib.issues.length} unreadable / invalid manifests. Other usage is unmeasured.</p>
|
||||
<div class="list">${lib.apps.map(app => `<div class="item"><div class="grow">${esc(app.name)}<div class="sub">Steam ${esc(app.appid)} · ${app.kind === 'tool' ? 'Runtime / tool' : 'Game / app'}</div></div><span>${storageBytes(app.bytes)}</span></div>`).join('') || '<p class="sub">No readable Steam manifests found; this does not prove the library is empty.</p>'}</div>
|
||||
${lib.issues.map(i => `<p class="sub">${esc(i.file)}: ${esc(storageState(i.state))}</p>`).join('')}</div>`).join('') +
|
||||
`<p class="sub">Snapshot received ${esc(new Date().toLocaleTimeString())}. Refresh after installs, downloads or moves.</p>`;
|
||||
}
|
||||
async function loadStorage() {
|
||||
const seq = ++storageSeq, gen = devGen;
|
||||
$('storageResult').textContent = 'Reading headset storage…';
|
||||
try {
|
||||
const result = await api('/api/storage');
|
||||
if (seq === storageSeq && gen === devGen) $('storageResult').innerHTML = renderStorage(result);
|
||||
} catch (error) {
|
||||
if (seq === storageSeq && gen === devGen) $('storageResult').textContent = `Storage unavailable: ${error.message}`;
|
||||
}
|
||||
}
|
||||
$('storageRefresh').onclick = loadStorage;
|
||||
|
||||
// ---- Backup and recovery kit (ui/frame_backup.py) ----
|
||||
const BACKUP_KIND = {saves: "Game saves", settings: "Settings", android: "Android app data"};
|
||||
function sizeText(n) {
|
||||
@@ -5622,7 +5675,7 @@ async function loadMacView(restart) {
|
||||
$("mvWinCount").textContent = wins.length ? `${wins.length}` : "";
|
||||
$("mvWindows").innerHTML = !s.screen ? `<div class="sub">Windows appear here once Screen Recording is allowed.</div>`
|
||||
: wins.length ? wins.map(w => mvRow(w.src, w.title || w.app, `${w.title ? w.app + " · " : ""}${w.w}×${w.h}`, w.w, w.h, liveAs(w.src), byStream(liveAs(w.src)))).join("")
|
||||
: `<div class="sub">No windows open on this ${pc ? "PC" : "Mac's current desktop"}.</div>`;
|
||||
: `<div class="sub">${pc && s.windowCaptureReason ? esc(s.windowCaptureReason) : `No windows open on this ${pc ? "PC" : "Mac's current desktop"}.`}</div>`;
|
||||
// Keep the numbers fresh while something is in the headset and the card is on screen.
|
||||
clearTimeout(mvTimer);
|
||||
const tick = () => { mvTimer = document.hidden || !box.offsetParent ? setTimeout(tick, 3000) : (loadMacView(), 0); };
|
||||
@@ -5699,10 +5752,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", backup:"tools"};
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", backup:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", backup:"Backup & recovery"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", backup:"Backup & recovery"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
let page = "apps";
|
||||
// Reuse the existing live lists and form handlers in their new destinations.
|
||||
@@ -5809,6 +5862,7 @@ function showPage(e) {
|
||||
if (tool === 'macview') loadMacView();
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'backup') loadBackups();
|
||||
if (tool === 'storage') loadStorage();
|
||||
if (tool === 'androidSettings') {
|
||||
$('repNew').hidden = false; $('androidSettings').prepend($('repNew')); loadDisplays();
|
||||
}
|
||||
@@ -6565,7 +6619,7 @@ function renderPill() {
|
||||
}
|
||||
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(word)}` +
|
||||
`${via ? `<span class="via"> · ${esc(via)}</span>` : ""}</span></span>`;
|
||||
$("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`;
|
||||
$("conn").title = `${dev}: ${detail.replace(/[.\s]+$/, "")}. Click to see or change its addresses.`;
|
||||
return true;
|
||||
}
|
||||
window.connPill = renderPill;
|
||||
@@ -6787,6 +6841,7 @@ function onConnection(s) {
|
||||
androidApps = []; androidSeq++; androidPending.clear(); $('installedDetail').close(); resetShots(); resetClips();
|
||||
renderHome();
|
||||
titlesSeq++; disp.seq++; runningSeq++; // answers to loads already on their way are ignored
|
||||
$('storageResult').textContent = 'Headset changed. Open Game storage or Refresh to read this headset.';
|
||||
runningGames = []; $("nowRunning").hidden = true; $("runningList").innerHTML = "";
|
||||
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
||||
// Confirmations still open were checked against the other headset.
|
||||
|
||||
@@ -3293,6 +3293,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||
elif path == "/api/status":
|
||||
self.send_json(status({}))
|
||||
elif path == "/api/storage":
|
||||
with working(self.headers.get("X-Frame-Device")):
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_storage.py").read_text(), timeout=20)))
|
||||
elif path == "/api/running":
|
||||
self.send_json(running_games())
|
||||
elif path == "/api/steam/owned":
|
||||
|
||||
Reference in new issue
Block a user