Compare commits

..
Author SHA1 Message Date
SAPHID 8f718273a5 Document Linux PC-host feasibility blocker on lxso2 2026-10-24 09:10:05 +00:00
Alex SouthwellandSAPHID 4e456480ae Document real 100-second Steam clip export (#154)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:05:12 +00:00
Alex SouthwellandSAPHID 241f538f99 Bump version to 0.4.17 (#153)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:43:00 +00:00
Alex SouthwellandSAPHID 21dacfcbe7 Distinguish unreadable panel discovery from an empty list (#152)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:34:56 +00:00
Alex SouthwellandSAPHID eb181d8996 Add opt-in desktop low-battery comfort alerts (#150)
* Add opt-in desktop low-battery comfort alerts

* Fix desktop battery alert test harness and Windows encoding

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:22:56 +00:00
Alex SouthwellandSAPHID 827ddcf14f Clarify desktop installation and first headset connection (#151)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:08:47 +00:00
Alex SouthwellandSAPHID 7c3894e630 docs: record real Frame clip Export button check (#149)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:04:06 +00:00
19 changed files with 718 additions and 17 deletions

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
## Unreleased
- Distinguish an unreadable gamescope panel list from a confirmed empty list in
Desktop Capture and Control (issue #73). Failed discovery now asks users to
check SteamVR and report the exact message and SteamOS version, rather than
claiming no panel is open. This does not establish a new capture fix.
- Add an opt-in desktop low-battery alert without starting a family session.
Existing status reads confirm low charge and drain; recovery hysteresis and a
ten-minute cooldown limit repeat alerts. Off on page open/headset switch;
pauses with hidden-page status refreshes. No extra polling or headset changes.
- Clarify first-time desktop downloads, installation and opening on the website
and README, including CPU choices, unsigned-app warnings, pairing fallback
and the source-only iOS build. Keep section navigation available on mobile.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.16",
"version": "0.4.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.16",
"version": "0.4.17",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.16",
"version": "0.4.17",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -0,0 +1,38 @@
{
"date_utc": "2026-10-24T07:54:37Z",
"frame_control_tag": "v0.4.16",
"steamos_version": "0.5.6",
"steamos_build": "20261015.6141207",
"host": "lxso2",
"interface": "source UI served by a temporary private Frame Control server, opened in T3 Code's desktop browser",
"action": "Clicked the visible Export button for Steam's retained real saved clip in Capture & Live",
"clip_id": "10230640/clips/clip_13009719723623972864_20261024_072115",
"api_observed": {
"post_clips_export_http": 200,
"get_job_http": 200,
"get_clips_http": 200
},
"ui_result": "Saved clip_13009719723623972864_20261024_072115.mp4 to ~/Videos/SteamFrame; button changed to Export again",
"output": {
"path": "~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4",
"bytes": 159829,
"sha256": "f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1",
"matches_previously_fully_decoded_export": true,
"duration_seconds": 10.288532,
"video": "H.264 1920x1080",
"audio": "AAC stereo"
},
"cleanup": {
"temporary_server_stopped": true,
"shared_device_lock_released": true,
"battery_percent_after": 99,
"desktop_mode_active_after": false,
"test_title_created": false,
"steam_source_clip_retained": true
},
"limits": [
"The test used the source UI, not a packaged desktop installer.",
"The retained source clip was nearly black; visible gameplay capture remains unverified.",
"Perceptual audio/video sync, long clips, trims and automatic copying were not tested."
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"date_utc": "2026-10-24",
"version": "v0.4.17",
"steam_os_version": "0.5.6",
"steam_os_build": "20261015.6141207",
"result": "PASS: the real Steam-saved clip exported beyond 60 seconds",
"method": "Steam manual recording of a reserved test title, then Frame Control's production frame_clips.export_clip path; no packaged GUI button click",
"source": {
"kind": "saved Steam clip",
"duration_seconds": 100.801,
"chunks": 68,
"gaps": 0
},
"export": {
"format": "MP4",
"bytes": 1377849,
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
"duration_seconds_ffprobe": 100.833335,
"video": "H.264, 1920x1080, 6049 frames",
"audio": "AAC, 48000 Hz, stereo",
"full_ffmpeg_decode": "passed without errors"
},
"cleanup": "Restored Steam recording mode to its prior off setting; stopped and removed only the reserved title and its processes; no recording or running Steam apps remained; Game Mode active, battery 100%, shared test lock released",
"limits": [
"This exercised the production export code path, not a packaged GUI Export click.",
"Visible gameplay quality, perceptual audio/video sync, trims and longer recordings were not checked.",
"The newly saved Steam source clip was retained; no user recording was deleted."
]
}
@@ -0,0 +1,95 @@
{
"date": "2026-10-24",
"command": "scripts/frame-power-sample.sh --count 2 --interval 1",
"exitCode": 0,
"stopped": null,
"lockPolicy": "Existing sampler checked local and remote /tmp/frame-test.lock before reading; no lock acquired, bypassed or removed. Read-only run only.",
"samples": [
{
"time": 1792828506.4317346,
"osBuild": "20261015.6141207",
"elapsedSeconds": 0.057539,
"battery": {
"battery.percent": {
"value": 99,
"unit": "%",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.status": {
"value": "Not charging",
"unit": "state",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.watts": {
"value": -0.06,
"unit": "W",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.tempC": {
"value": 24.0,
"unit": "C",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
}
}
},
{
"time": 1792828507.4357972,
"osBuild": "20261015.6141207",
"elapsedSeconds": 1.061612,
"battery": {
"battery.percent": {
"value": 99,
"unit": "%",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.status": {
"value": "Not charging",
"unit": "state",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.watts": {
"value": -0.05,
"unit": "W",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.tempC": {
"value": 24.0,
"unit": "C",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
}
}
}
],
"limitations": [
"No real low-charge or thermal condition induced.",
"No native notification delivery verified.",
"No worn-headset or native package tests.",
"Read-only parser/transport check; no background monitor or power-saving claim."
],
"browserCheck": {
"page": "Production ui/index.html served statically; API calls intentionally unavailable",
"viewport": {
"width": 1280,
"height": 800
},
"bridge": "Synthetic desktop notification bridge; no native OS notification sent",
"result": "Initially unchecked and visible only after desktop host/bridge setup. Checkbox enabled through browser. Synthetic 12%, Discharging, -4 W samples at 0/30/60 seconds produced one bridge request, an in-page status and a toast. No horizontal overflow.",
"apiLimit": "Static server returned expected 404/501 API errors; not an end-to-end live desktop connection test."
},
"offlineValidation": [
"test_desktop_battery_comfort.py: 2 tests, production JS with monotonic fake clock and notification bridge",
"test_comfort*.py: 25 tests",
"test_battery.py: 7 tests",
"test_power*.py: 36 tests (loopback HTTP test run outside socket-restricted sandbox)",
"test_ui_workspace.py: 1 Python wrapper / Node workspace suite",
"test_ui_review.py: 1 Python wrapper / 9 Node regression tests, including switch reset and retained cooldown"
]
}
@@ -0,0 +1,114 @@
# Linux PC host feasibility on lxso2, 2026-10-24
**Blocked on this host's current desktop environment. No feature enabled and
no feature PR opened.** Investigation for [issue #23](https://github.com/saphid/frame-control/issues/23),
starting at `4e45648` (main after #154). This concerns flat desktop panels,
not Linux PC VR streaming.
## Decision
The smallest useful candidate is one explicitly selected **whole screen**,
captured through the ScreenCast portal's restricted PipeWire connection,
encoded locally with GStreamer/x264, and sent to the existing viewer through
the shared SSH controller. Input, individual windows and virtual displays
would remain unsupported until separately verified. This candidate was **not
implemented**: lxso2 currently cannot exercise the real capture permission path.
Development packages could unblock compilation, but would not create a desktop,
portal backend or consented screen. Synthetic encoding, an Xvfb screen grab or
using the Frame as its own host would not verify Linux PC desktop capture.
No host packages or services were changed.
## Evidence
Host: `lxso2`, Linux x86-64, kernel `6.8.0-139-generic`.
- `DISPLAY`, `WAYLAND_DISPLAY` and `XDG_SESSION_TYPE` were unset. The user bus
was reachable at `/run/user/1000/bus`.
- `/run/user/1000/pipewire-0` and `/run/user/1000/wayland-0` did not exist.
No processes matched Xorg, Xwayland, Xvfb, GNOME Shell, KWin Wayland, Weston,
PipeWire, WirePlumber or xdg-desktop-portal at the process check.
- D-Bus `ListNames` returned D-Bus, systemd and anonymous connections, with no
`org.freedesktop.portal.Desktop`. `ListActivatableNames` also had no desktop
portal. These calls queried D-Bus itself without activating capture services.
- `dpkg-query` found no `xdg-desktop-portal*` or `pipewire*` packages.
`libgstreamer1.0-0:amd64` was installed at `1.24.2-1ubuntu0.1`, but
`gstreamer1.0-tools` was uninstalled. `gst-launch-1.0` and `gst-inspect-1.0`
were absent from PATH. Xvfb was installed, but supplies no portal by itself.
- `pkg-config --modversion` failed for `gstreamer-1.0`,
`gstreamer-app-1.0`, `gstreamer-video-1.0` and `libpipewire-0.3`.
`gio-unix-2.0` reported `2.80.0`.
- `desktop/bundle/pc-host.so` did not exist.
`python3 desktop/build.py --with-x264 --out /tmp/frame-linux-pc-feasibility`
exited **1** before compilation: required GStreamer pkg-config modules were
missing. No native build or encoder runtime success is claimed.
Reproduce the non-activating portal checks in the intended desktop user's
session, using that session's actual runtime directory and bus:
```sh
gdbus call --session --dest org.freedesktop.DBus \
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListNames
gdbus call --session --dest org.freedesktop.DBus \
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListActivatableNames
pkg-config --modversion gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0 gio-unix-2.0
```
## Existing implementation reviewed
- `ui/frame_pcview.py` selects PCView only on Windows and rejects Linux even
when enabled. Linux installers do not carry its native bundle.
- `desktop/portal.c` creates a **RemoteDesktop** session, requests pointer and
keyboard devices, and selects monitors and windows (`types=3`). It is not
ScreenCast-only or screen-only. Its restricted fd/node and fresh-connection
handling are reusable.
- `ui/frame_pc_agent.py` calls the selection a shared window or screen and
picks VA-API when the element is present, falling back to x264. A bounded
x264 slice needs explicit encoder policy and unsupported-input behavior;
exposing the existing Linux path alone would not meet that scope.
- `app/main.js` starts Python with the inherited environment; it does not
supply a compositor, portal backend or capture permission.
- `.github/workflows/pc-host.yml` installs native dependencies and tests
synthetic x264/protocol behavior with fake capture consent/input. This does
not establish interactive Linux desktop capture.
- Issue #23 and its six existing comments distinguish prior synthetic
helper/viewer results from untested Linux desktop capture and consent.
## Verification and limits
`python3 -m unittest discover -s tests -p 'test_pc*.py' -v` exited **0**:
27 tests ran successfully, with one additional native-class setup skip
(`native PC libraries not built on this host`). Coverage includes the compiled
C controller trace, Python adapters, grants and default-off/Windows-only gates.
It does not prove native GStreamer capture or local-client streaming.
No fake/local native stream, consent prompt, real desktop capture, Frame viewer
run, benchmark, input or window mode was tested in this investigation. No SSH
connection to the Frame was needed; no lock was acquired and no headset install,
launch, stop or settings change was attempted. Issue #113 was left alone.
No issue comments or other public messages were sent.
## Concrete unblock and next slice
1. Supply an interactive Linux desktop, physical or VM, with a working
ScreenCast portal/backend, PipeWire and accessible consent dialog. Installing
the portal frontend alone is insufficient. A VM can establish software
capture/x264 behavior while physical-host behavior remains unverified.
2. Install the native dependencies listed in `pc-host.yml` in that environment
and build a local `--with-x264` bundle. Resolve x264 distribution
licensing/source obligations before shipping it: the current build labels
that option test-only and the Windows installer omits it.
3. Implement a ScreenCast-only session with monitor-only selection, no
RemoteDesktop/device request, disabled input in protocol and viewer, hidden
window/virtual-display modes, and explicit x264 selection. Preserve the
default-off gate and existing Mac/Windows paths.
4. Verify consent acceptance/refusal/cancellation, Stop/reopen, connection loss
and portal closure with a local client decoding actual captured content.
Run the shared benchmark and record results without inferring hardware or
physical laser behavior.
5. Only after local capture works, acquire the shared Frame lock once and
follow [the headset procedure](../testing.md#headset-smoke-test) for a brief
viewer test. If occupied, leave the lock alone and defer device testing.
These are prerequisites and proposed scope, not a ready feature. The Linux
host request in #23 remains open.
@@ -0,0 +1,65 @@
# Panel discovery investigation, 2026-10-24
Base: v0.4.16 (`040581a`). Scope: issue #73, Desktop Capture and Control.
## Evidence and change
Issue #73 reports SteamOS 0.4.5 with its desktop visibly running, without a
Frame Control version or steps. The maintainer's 2026-10-12 comment identifies
the duplicate `loadPanels` UI function fixed in 0.4.3 and requests the exact
remaining message and SteamOS build if it persists. There is no reporter
follow-up. At this base, the functions are distinct and the existing Node
regression checks their uniqueness and the Desktop GET request.
Read-only source inspection found a remaining diagnostic ambiguity:
`read_props()` returns `{}` for failed/timed-out X queries and missing root
properties. `panels()` treated that like a confirmed empty focusable-window
list. With no detected Plasma process it claimed no panel was open; with Plasma
it claimed SteamOS was not listing the desktop. Neither claim follows from an
unreadable list.
The change checks for the presence of `GAMESCOPE_FOCUSABLE_WINDOWS` before
those claims. An absent list now reports unknown panel state, suggests checking
SteamVR and retrying/Headset view, and asks for the exact message and SteamOS
version if persistent. Missing-tool and whole-run deadline explanations retain
precedence. No new probes, retries, timeouts, input or capture routes are added.
## Real-device boundary
A preliminary read-only SSH inspection returned SteamOS 0.5.6, BUILD_ID
`20261015.6141207`, battery 99%, discharging, and X0/X1 socket names. It made
no headset changes. The shared lock was absent at that moment.
The subsequent atomic `mkdir /tmp/frame-test.lock` failed with `File exists`.
The guarded command exited with status 3 before the session, X-property or
panel-helper probes ran. The other task's lock was left alone. No application
was installed, started, stopped or focused; no headset settings changed.
That implementation probe was blocked by the occupied shared device. The
coordinator later supplied a separate narrow read-only observation on SteamOS
0.5.6, BUILD_ID `20261015.6141207`, at battery 99%, with the shared lock absent
and both `xprop` and `xwininfo` installed:
- In **Game Mode**, `DISPLAY=:0 xprop -root GAMESCOPE_FOCUSABLE_WINDOWS
GAMESCOPE_FOCUSED_WINDOW` returned both properties **present but empty**.
- On `DISPLAY=:1`, both properties were reported **not found**.
This observation distinguishes a confirmed empty list on `:0` from absent
properties on `:1`. It does **not** reproduce #73's Desktop Mode report on
SteamOS 0.4.5, and it does **not** test this PR's code on the headset. It is a
coordinator-supplied observation, separate from the blocked implementation probe
above; no new headset probe was run for this follow-up.
Remaining limit: there is no reproduction establishing a further
capture/discovery fix for the reported Desktop Mode/build combination. This PR
improves a demonstrated diagnostic error; it must not close #73 as a verified
new capture fix.
## Offline verification
Focused Python regressions distinguish absent, focus-only, failed/timed-out X
queries, confirmed empty and desktop-running lists. Existing tests cover
missing tools, alternate displays, successful discovery and the 12-second
deadline. The production page's Node behavior test verifies that both Capture
and Control preserve the unreadable-list advice. These use fake X/HTTP/DOM
boundaries and do not prove real SteamOS 0.4.5 capture or physical input.
+39
View File
@@ -6,6 +6,45 @@ This is Frame Control code using Python, Steam and SteamVR already on the Frame.
![Family and comfort controls in the desktop app](img/comfort-desktop.png)
## Desktop low-battery alert without a session
In the desktop app, open **Tools → Family & comfort** and check **Alert on this
computer at 15% or less**. This separate option does not start a session, install
a worker or change the headset. It starts off whenever the page opens and turns
off when you switch headsets. The choice is only for the current open page.
It consumes the battery parser already used by normal status refreshes (also
shared by the read-only power sampler). It adds no SSH calls, timers, sensor
reads, third-party push or heat thresholds. Normal automatic status refreshes
pause while the page is hidden, so this is **not a background or safety monitor**.
Keep the app visible and connected. Use **Enable / test notifications** to check
system delivery; operating-system settings may block notifications.
Two fresh readings, at least 20 seconds and at most 90 seconds apart, must show
15% or less, driver state `Discharging`, and signed battery power below −0.5 W.
This avoids alerting on plugged-in idle readings, which can report discharging.
Missing, invalid, stale or interrupted readings break confirmation. Disconnect,
a failed status request, hiding the page or toggling the option also breaks
confirmation. Unknown readings never mean zero charge.
An alert appears in the page and requests a native desktop notification once
per low-charge episode. Two readings at 20% or more rearm it, and at least ten
minutes must pass between alerts (including across headset switches). A single
recovery spike cannot rearm it. Toggling off/on does not clear the episode latch.
Notification failure stays visible in the page and does not retry on every
refresh. While an active family session has battery alerts enabled, that session
handles battery alerts instead. Multiple app windows have independent state;
this does not coordinate notifications between computers or windows.
A read-only check on 2026-10-24, build **20261015.6141207**, received two samples
at 99%, `Not charging`, approximately −0.06 W and 24 °C. No low-battery condition
was induced, no notification was sent to the real headset, and no headset state
was changed. The sampler's local and remote smoke-lock checks allowed the run;
no lock was acquired or removed. Deterministic tests exercise low charge,
recovery, missing readings, idle drain, timing and notification denial. See
[recorded evidence](evidence/desktop-battery-comfort-2026-10-24.json). Native OS
notification display, worn use and macOS/Windows packaging were not verified.
## Sessions
Set a limit of 1–240 minutes, optional break and check-in intervals, then
+6
View File
@@ -107,6 +107,12 @@ The Linux adapter from #38 (desktop portal, PipeWire, VA-API) is in the tree,
because it shares `capture.c` and the controller, and CI still builds and tests
it. The app never offers it and the Linux installers don't include it.
The [2026-10-24 lxso2 feasibility check](evidence/linux-pc-host-lxso2-2026-10-24.md)
found no running or activatable desktop portal, no PipeWire socket and missing
native build dependencies. Linux support remains disabled. The evidence records
the build failure and prerequisites for a screen-only, view-only x264 slice;
Linux capture, input and window modes have not been verified there.
## Measuring
The Mac benchmark accepts the PC host:
+24 -6
View File
@@ -162,16 +162,34 @@ was released, and the battery remained at 99%. The Steam source clip was kept.
The inspected frame is nearly black in both Steam's source and the export, so
this establishes file creation and faithful export, **not visible gameplay
capture**. The literal desktop **Export** button was not clicked. Remaining
checks include:
capture**. On v0.4.16, the literal desktop **Export** button was also clicked
against that retained real Steam clip in the source UI on lxso2. The job
completed and saved `~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4`;
its 159,829 bytes and SHA-256 matched the previously decoded export exactly.
The temporary server stopped, the shared device lock was released, and the
headset remained in Game Mode at 99% battery. See
[`docs/evidence/clip-gui-export-2026-10-24.json`](evidence/clip-gui-export-2026-10-24.json)
for the narrow proof. This did not exercise the packaged desktop installer.
On 2026-10-24, the exact v0.4.17 tag exported a second real Steam-saved clip
through `frame_clips.export_clip`, the same production path used by **Export**.
Steam listed the clip at **100.801 seconds**, with 68 chunks and no gaps. The
copied MP4 was **100.833 seconds** by `ffprobe` and decoded completely with
`ffmpeg`; it was not cut at 60 seconds. The test restored Steam's recording
mode, removed its reserved title and processes, and left Game Mode active at
100% battery. See [the long-clip evidence](evidence/clip-long-2026-10-24.json).
This did not click **Export** in the packaged desktop app or verify visible
gameplay quality or perceptual audio/video sync.
Remaining checks include:
- Perceptual audio/video sync and audible content. Frame Control's separate
**Record** path stamps camera and audio inputs with the wall clock because
they otherwise count from different clocks.
- Long saved clips, trims starting partway through a Steam recording, and the
desktop **Export** button. One real saved clip's
directory layout was checked; the parser remains deliberately loose for
other layouts.
- Clips longer than the 100-second test, trims starting partway through a Steam
recording, and the packaged desktop app's **Export** button. Two real saved
clips' directory layouts were checked; the parser remains deliberately loose
for other layouts.
- How much x264 at 1080p30 costs the Frame while a game is running.
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
finished saving a clip, and how a once-a-minute listing feels on the Frame.
+10
View File
@@ -7,6 +7,9 @@ This covers three directions, plus input:
- **C. iPhone → Frame**: mirror the phone inside the headset.
- **PC VR from Linux**: [feasibility and options](linux-vr-streaming.md),
including Valve's streaming and USB support. No Linux host tested yet.
- **Linux desktop → Frame panels**: [PC-host feasibility on lxso2](evidence/linux-pc-host-lxso2-2026-10-24.md).
Blocked on missing desktop portal/PipeWire and native build dependencies;
Linux desktop capture remains disabled and unverified.
- **Input**: type and point in the Frame from the Mac or iPhone.
- **Live view and Control**: watch a panel flat and tap on it to use it.
@@ -285,6 +288,13 @@ How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
[#73](https://github.com/saphid/frame-control/issues/73) (SteamOS 0.4.5, desktop
open but not found). The likely cause there was the page itself: a second
`loadPanels` (the panel switcher's) replaced the Desktop view's, fixed in #84.
If the `GAMESCOPE_FOCUSABLE_WINDOWS` property cannot be read, discovery now
reports that the panel list is unreadable and whether a panel is open is
unknown. A failed X connection or missing property is not evidence that the
desktop is closed. Check that SteamVR is running, retry or use Headset view;
if it persists, report the exact message and SteamOS version. The confirmed
empty-list advice is retained when the property is present but empty.
[2026-10-24 investigation and verification limits](evidence/panel-discovery-2026-10-24.md).
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
text box in the panel and the text field typed into it; a trackpad move went
exactly (+40, +25).
+48 -5
View File
@@ -23,7 +23,8 @@ function fixture() {
const page = element('controlCenter');
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
return {document:{getElementById:element},element,cards,buttons};
const listeners = {};
return {document:{hidden:false,getElementById:element,addEventListener(name,callback){listeners[name]=callback;}},listeners,element,cards,buttons};
}
function mounted(options = {}) {
const f = fixture(), calls = [];
@@ -39,8 +40,10 @@ const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
// rendering helpers, timers and transport are stubbed.
function sharedPage({productionApi=false} = {}) {
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
const f=fixture(), requests=[], writes=[], notifications=[], host=deferred(), job=deferred();
let now=0;
const context={document:f.document,FrameControlCenter:cc,performance:{now:()=>now},
window:{frameApp:{setUpConnection(){},notify:async(message,request)=>{notifications.push({message,request});}}},
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
battery(){},setVolume(){},renderHome(){},gb:String,dur:String,esc:String,meter(){},toast(){},
runJob(title,id){context.installing.add(id);return job.promise;},
@@ -52,7 +55,11 @@ function sharedPage({productionApi=false} = {}) {
watchConnection(){context.watched=true;}};
vm.createContext(context);
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1, comfortActive = false; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
// Load the real alert state machine and DOM wiring used by render, refresh,
// setOnline and onConnection; do not stub away their lifecycle behavior.
const batteryStart=pageSource.indexOf('class DesktopBatteryAlert'),batteryEnd=pageSource.indexOf('// ---- battery ----',batteryStart);
vm.runInContext(pageSource.slice(batteryStart,batteryEnd),context);
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
@@ -80,12 +87,48 @@ function sharedPage({productionApi=false} = {}) {
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
return {...f,context,requests,writes,notifications,host,setNow(value){now=value;},refresh:()=>vm.runInContext('refresh()',context),
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
}
const response = data => ({ok:true,status:200,json:async()=>data});
test('production refresh confirms desktop alerts and failures interrupt confirmation', async () => {
const f=sharedPage({productionApi:true});
const low=sample();low.battery={percent:15,status:'Discharging',watts:-4};
const read=async(now,data=low)=>{
f.setNow(now);const pending=f.refresh();
f.requests.at(-1).resolve(response(data));await pending;
};
await read(0);await read(30000);
assert.equal(f.notifications.length,0,'desktop alert starts off');
f.element('desktopBatteryAlert').checked=true;
f.element('desktopBatteryAlert').onchange();
await read(60000);
const failed=f.refresh();f.requests.at(-1).reject(Error('Status transport failed'));await failed;
await read(90000);
assert.equal(f.notifications.length,0,'failed status interrupts low-battery confirmation');
await read(120000);
assert.equal(f.notifications.length,1);
assert.match(f.notifications[0].message,/15%.*draining/);
assert.equal(f.notifications[0].request,false,'refresh never requests notification permission');
await read(150000);
assert.equal(f.notifications.length,1,'same episode stays latched');
assert.equal(f.requests.length,7,'only explicit existing status reads are used');
});
test('production headset switch clears desktop opt-in and pending confirmation', async () => {
const f=sharedPage({productionApi:true});
f.element('desktopBatteryAlert').checked=true;
const low=sample();low.battery={percent:10,status:'Discharging',watts:-3};
const pending=f.refresh();f.requests[0].resolve(response(low));await pending;
assert.equal(vm.runInContext('desktopBattery.pending.condition',f.context),'low');
f.setNow(30000);f.switchHeadset();
assert.equal(f.element('desktopBatteryAlert').checked,false);
assert.equal(vm.runInContext('desktopBattery.pending',f.context),null);
assert.match(f.element('desktopBatteryStatus').textContent,/Headset changed/);
f.requests[1].resolve(response(low));await flush();
assert.equal(f.notifications.length,0,'new headset cannot inherit the prior opt-in');
});
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
const f=sharedPage({productionApi:true});
+9
View File
@@ -40,6 +40,15 @@ function page(answer) {
if (!p.noPanelWhy().startsWith("The headset's desktop is running")) fail("why for capture: " + p.noPanelWhy());
if (!p.ctrlTarget().why.startsWith("The headset's desktop is running")) fail("why for control: " + p.ctrlTarget().why);
}
// An unreadable list is unknown: both Capture and Control retain that distinction.
{
const why = "Frame Control couldn't read gamescope's panel list, so it can't tell whether an app panel is open. " +
"Check that SteamVR is running in the headset, then try again or use the Headset view. " +
"If this persists, report your SteamOS version and this exact message.";
const p = page(() => ({ panels: [], focus: null, why }));
await p.loadPanels();
if (p.noPanelWhy() !== why || p.ctrlTarget().why !== why) fail("unreadable list reason lost");
}
// An older headset script without "why": the generic advice.
{
const p = page(() => ({ panels: [], focus: null }));
+105
View File
@@ -0,0 +1,105 @@
"""Exercise the shipped desktop alert and its UI with a monotonic fake clock."""
import pathlib
import shutil
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page JavaScript')
class DesktopBatteryComfort(unittest.TestCase):
def test_alert_confirmation_and_lifecycle(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
code = page.split('// ---- Desktop-only low-battery comfort:')[1].split('// ---- battery ----')[0]
code = code[code.index('class DesktopBatteryAlert'):]
js = r'''
const assert = require('node:assert/strict');
const elements = new Map();
const $ = id => {
if (!elements.has(id)) elements.set(id, {checked:false, textContent:''});
return elements.get(id);
};
let now = 0, calls = [], toasts = [], devGen = 1, comfortActive = false;
const performance = {now:()=>now};
const listeners = {};
const document = {hidden:false, addEventListener:(name,fn)=>listeners[name]=fn};
const HOST = {mobile:false};
const window = {frameApp:{notify:(message,request)=>{calls.push({message,request});return Promise.resolve();}}};
const toast = message => toasts.push(message);
''' + code + r'''
(async () => {
const low = {percent:15,status:'Discharging',watts:-4};
const high = {...low,percent:20};
const a = new DesktopBatteryAlert();
assert.equal(a.sample(low,0),false);
assert.equal(a.sample(low,1000),false); // rapid manual refreshes cannot confirm
assert.equal(a.sample(low,30000),true);
for (let t=60000;t<=900000;t+=30000) assert.equal(a.sample(low,t),false);
assert.equal(a.sample(high,930000),false);
assert.equal(a.sample(low,960000),false); // a recovery spike did not rearm
assert.equal(a.sample(low,990000),false);
a.sample(high,1020000); a.sample(high,1050000);
assert.equal(a.sample(low,1080000),false);
assert.equal(a.sample(low,1110000),true);
const c = new DesktopBatteryAlert();
c.sample(low,0); assert.equal(c.sample(low,30000),true);
c.sample(high,60000); c.sample(high,90000);
c.sample(low,120000); assert.equal(c.sample(low,150000),false); // ten minute cap
assert.equal(c.sample(low,631000),false); // long gap requires fresh confirmation
assert.equal(c.sample(low,661000),true);
for (const bad of [null,{}, {...low,percent:null}, {...low,percent:NaN}, {...low,percent:-1},
{...low,percent:101},{...low,percent:'10'}, {...low,status:'Unknown'},
{...low,status:'Charging'}, {...low,status:'Full'}, {...low,status:'Not charging'},
{...low,watts:0},{...low,watts:-0.5},{...low,watts:null},{...low,watts:NaN}]) {
const b = new DesktopBatteryAlert();
b.sample(low,0); assert.equal(b.sample(bad,30000),false);
assert.equal(b.sample(low,60000),false); // unknown interrupts confirmation
}
const b = new DesktopBatteryAlert();
b.sample(low,0); b.invalidate(); assert.equal(b.sample(low,30000),false);
assert.equal(b.sample(low,121000),false); // stale gap
assert.equal(b.sample(low,120000),false); // clock rollback
desktopBatterySample(low); now=30000; desktopBatterySample(low);
assert.equal(calls.length,0); // opt-in starts unchecked
$('desktopBatteryAlert').checked=true; $('desktopBatteryAlert').onchange();
now=60000;desktopBatterySample(low);now=90000;desktopBatterySample(low);
assert.equal(calls.length,1); assert.equal(calls[0].request,false);
assert.match(calls[0].message,/15%.*draining/);
$('desktopBatteryAlert').checked=false;$('desktopBatteryAlert').onchange();
$('desktopBatteryAlert').checked=true;$('desktopBatteryAlert').onchange();
now=120000;desktopBatterySample(low);now=150000;desktopBatterySample(low);
assert.equal(calls.length,1); // toggling does not clear episode latch
desktopBattery.latched=false;desktopBattery.lastAlert=-Infinity;desktopBattery.invalidate();
now=180000;desktopBatterySample(low);document.hidden=true;listeners.visibilitychange();
document.hidden=false;now=210000;desktopBatterySample(low);assert.equal(calls.length,1);
now=240000;desktopBatterySample(low);assert.equal(calls.length,2);
desktopBattery.latched=false;desktopBattery.lastAlert=-Infinity;desktopBattery.invalidate();
comfortActive=true;$('batteryAlert').checked=true;
now=270000;desktopBatterySample(low);now=300000;desktopBatterySample(low);
assert.equal(calls.length,2); // existing session owns battery alerts
comfortActive=false;HOST.mobile=true;
now=330000;desktopBatterySample(low);now=360000;desktopBatterySample(low);
assert.equal(calls.length,2);
HOST.mobile=false;
window.frameApp.notify=()=>Promise.reject(Error('denied'));
now=390000;desktopBatterySample(low);now=420000;desktopBatterySample(low);
await new Promise(setImmediate);
assert.match($('desktopBatteryStatus').textContent,/not confirmed/);
now=450000;desktopBatterySample(low);
assert.equal(toasts.length,3); // delivery failure does not retry on refresh
})().catch(e=>{console.error(e);process.exitCode=1;});
'''
result = subprocess.run(['node', '-e', js], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_existing_status_path_and_switch_reset(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
self.assertIn('desktopBatterySample(s.battery);', page)
switch = page.split('if (link.device !== null && devId !== link.device)')[1].split('devGen++;')[0]
self.assertIn('$("desktopBatteryAlert").checked = false;', switch)
refresh = page.split('function refresh()')[1].split('async function refreshAfterAudio')[0]
self.assertIn('desktopBattery.invalidate();', refresh)
block = page.split('// ---- Desktop-only low-battery comfort:')[1].split('// ---- battery ----')[0]
self.assertNotIn('setTimeout', block)
self.assertNotIn('api(', block)
+37
View File
@@ -355,6 +355,8 @@ class FindingPanels(unittest.TestCase):
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, out, "")
self.assertEqual(self.real_read_props(":0"), {"GAMESCOPE_FOCUSABLE_WINDOWS": [1, 0, 2, 3, 769, 4],
"GAMESCOPE_FOCUSED_WINDOW": [0]})
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, "GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \n", "")
self.assertEqual(self.real_read_props(":0"), {"GAMESCOPE_FOCUSABLE_WINDOWS": []})
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, "GAMESCOPE_FOCUSED_WINDOW: not found.\n", "")
self.assertEqual(self.real_read_props(":0"), {})
@@ -392,17 +394,52 @@ class FindingPanels(unittest.TestCase):
self.assertIn("Headset view", out["why"])
def test_desktop_running_but_not_listed(self):
self.props[(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")] = []
self.t.desktop_running = lambda: True
out = self.t.panels()
self.assertEqual(out["panels"], [])
self.assertIn("desktop is running", out["why"])
def test_nothing_open(self):
self.props[(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")] = []
out = self.t.panels()
self.assertEqual(out, {"focus": None, "focus_display": None, "panels": [],
"why": "No app panel is open in the headset. Open the desktop or an app in "
"the headset, then try again."})
def test_absent_list_is_unknown_even_when_desktop_is_running(self):
for desktop in (False, True):
with self.subTest(desktop=desktop):
self.t.desktop_running = lambda: desktop
self.t._ROOT.clear()
out = self.t.panels()
self.assertEqual(out["panels"], [])
self.assertIn("couldn't read gamescope's panel list", out["why"])
self.assertIn("can't tell whether", out["why"])
self.assertIn("Headset view", out["why"])
self.assertNotIn("No app panel is open", out["why"])
def test_focus_property_without_panel_list_is_unknown(self):
self.props[(":0", "GAMESCOPE_FOCUSED_WINDOW")] = [0]
self.assertIn("couldn't read gamescope's panel list", self.t.panels()["why"])
def test_failed_xprop_does_not_claim_no_panels_are_open(self):
t = self.t
t.read_props = self.real_read_props
saved = t.subprocess.run
self.addCleanup(setattr, t.subprocess, "run", saved)
for failure in ("refused", "timeout"):
with self.subTest(failure=failure):
def run(*args, **kwargs):
if failure == "timeout":
raise subprocess.TimeoutExpired(args[0], kwargs["timeout"])
return subprocess.CompletedProcess(args[0], 1, "", "Can't open display")
t.subprocess.run = run
t._ROOT.clear()
out = t.panels()
self.assertIn("couldn't read gamescope's panel list", out["why"])
self.assertNotIn("No app panel is open", out["why"])
def test_missing_x_tools_are_named(self):
self.plasma_on(":0")
self.t.shutil.which = lambda tool: None if tool == "xwininfo" else "/usr/bin/" + tool
+9
View File
@@ -10,6 +10,8 @@ function connectionFixture() {
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},resetClips(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
c.window.connDevice='a';
c.document.addEventListener=()=>{};
f.run(pick(html,'class DesktopBatteryAlert {','// ---- battery ----'));
f.run('const UI_KEY="test";'+pick(html,'const HEADSET_FREE =','// What a panel shows'));
f.run(pick(html,'function onConnection(s) {','// Read the event stream'));
return f;
@@ -41,8 +43,15 @@ test('a device switch discards completed install results and the open detail',()
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.el('desktopBatteryAlert').checked=true;
f.run('desktopBattery.pending={condition:"low",at:0};desktopBattery.latched=true;desktopBattery.lastAlert=42;');
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(f.el('desktopBatteryAlert').checked,false);
assert.equal(f.run('desktopBattery.pending'),null);
assert.equal(f.run('desktopBattery.latched'),false);
assert.equal(f.run('desktopBattery.lastAlert'),42);
assert.match(f.el('desktopBatteryStatus').textContent,/Off.*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()=>{
+8 -3
View File
@@ -271,7 +271,7 @@ def panels(limit=None):
out["why"] = ("The headset took too long to list its panels (its X tools didn't answer in time). "
+ ("Some may be missing. " if found else "") + "Try again, or use the Headset view.")
elif not found:
out["why"] = why_none(unfound)
out["why"] = why_none(unfound, props)
return out
@@ -295,13 +295,18 @@ def desktop_running():
return False
def why_none(listed):
def why_none(listed, props):
"""Why panels() found nothing, in words the person at the computer can act on.
listed: how many of the windows gamescope lists aren't X11 windows on any display."""
listed: how many of the windows gamescope lists aren't X11 windows on any display.
props: the root properties actually read; an absent list is not an empty list."""
missing = [tool for tool in ("xprop", "xwininfo") if not shutil.which(tool)]
if missing:
return (f"This SteamOS build has no {' or '.join(missing)}, which Frame Control uses to find the "
"headset's panels. Use the Headset view instead, and please report your SteamOS version.")
if "GAMESCOPE_FOCUSABLE_WINDOWS" not in props:
return ("Frame Control couldn't read gamescope's panel list, so it can't tell whether an app panel is open. "
"Check that SteamVR is running in the headset, then try again or use the Headset view. "
"If this persists, report your SteamOS version and this exact message.")
if listed:
# gamescope lists windows, but none is an X11 window on any display: a Wayland
# window (a desktop drawn straight to gamescope, say) can't be found or captured this way.
+69
View File
@@ -1465,6 +1465,12 @@
</details>
</section><section class="panel" id="comfort" style="margin:20px 0;padding:16px">
<div class="shelf-head"><h2>Family and comfort</h2><span class="count">Session timer and reminders</span></div>
<div id="desktopBatteryComfort" hidden>
<h3>Desktop low-battery alert</h3>
<label><input id="desktopBatteryAlert" type="checkbox"> Alert on this computer at 15% or less</label>
<p class="hint">No session timer needed. Off when this page opens or you switch headsets. Uses normal status refreshes while the page is visible; no extra polling or headset changes. Two readings must confirm low charge and battery drain. One alert until charge recovers to 20%, with at least ten minutes between alerts.</p>
<p id="desktopBatteryStatus" class="hint" role="status">Off.</p>
</div>
<form id="comfortForm">
<div class="row" style="flex-wrap:wrap;gap:12px">
<label>Session limit (minutes) <input id="sessionMinutes" type="number" min="1" max="240" value="30" required style="width:75px"></label>
@@ -2130,6 +2136,7 @@ const HOST = { computer: "computer" };
function applyHostWording(h) {
Object.assign(HOST, h);
window.frameControlCenter?.host(h);
$("desktopBatteryComfort").hidden = !!h.mobile || !window.frameApp?.notify;
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
document.body.classList.toggle("mobile", !!h.mobile);
$("shotsFolder").hidden = !!h.mobile;
@@ -2240,6 +2247,7 @@ function setOnline(ok, why) {
if (HOST.mobile) $('mobileConnectionStatus').textContent = ok ? 'Connected to the Frame serving this session.' : 'The Frame is disconnected. Retry or check its address in connection setup.';
$('offline').hidden = ok;
if (!ok) {
desktopBattery.invalidate();
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
$("battChip").hidden = true; closeDevPop();
@@ -2276,6 +2284,7 @@ function refresh() {
setConn(true, state.hostname);
setOnline(true);
} catch (e) {
desktopBattery.invalidate();
renderPerformance(null);
if (!e.stale) stateRead = {valid: false, error: e};
renderHome();
@@ -2456,6 +2465,60 @@ $("castBtn").onclick = () => {
$("viewer").requestFullscreen?.().catch(() => {});
};
// ---- Desktop-only low-battery comfort: consumes existing status, never polls ----
class DesktopBatteryAlert {
constructor() {
this.pending = null;
this.latched = false;
this.lastAlert = -Infinity;
}
invalidate() { this.pending = null; }
sample(b, now) {
// Require actual drain as well as driver state: plugged-in idle can report Discharging.
const valid = Number.isFinite(b?.percent) && b.percent >= 0 && b.percent <= 100 &&
["Charging", "Discharging", "Full", "Not charging"].includes(b.status);
const low = valid && b.percent <= 15 && b.status === "Discharging" &&
Number.isFinite(b.watts) && b.watts < -0.5;
const recovered = valid && b.percent >= 20;
const condition = low ? "low" : recovered ? "recovered" : null;
if (!condition) { this.invalidate(); return false; }
if (!this.pending || this.pending.condition !== condition || now - this.pending.at > 90000 || now < this.pending.at) {
this.pending = {condition, at: now};
return false;
}
if (now - this.pending.at < 20000) return false;
if (recovered) this.latched = false;
if (!low || this.latched || now - this.lastAlert < 600000) return false;
this.latched = true;
this.lastAlert = now; // latch even when system delivery fails; never retry on each refresh
return true;
}
}
const desktopBattery = new DesktopBatteryAlert();
$("desktopBatteryAlert").onchange = () => {
desktopBattery.invalidate();
$("desktopBatteryStatus").textContent = $("desktopBatteryAlert").checked
? "On. Waiting for two fresh status readings confirming low charge and battery drain."
: "Off.";
};
function desktopBatterySample(b) {
if (!$("desktopBatteryAlert").checked || HOST.mobile || !window.frameApp?.notify || document.hidden ||
(comfortActive && $("batteryAlert").checked)) {
desktopBattery.invalidate();
return;
}
if (!desktopBattery.sample(b, performance.now())) return;
const message = `Frame battery is low (${b.percent}%) and draining. Consider charging or taking a break.`;
$("desktopBatteryStatus").textContent = message;
toast(message);
// Desktop bridge only: never prompt or send a phone/browser push from a status refresh.
const generation = devGen;
window.frameApp.notify(message, false).catch(() => {
if (generation !== devGen || !$("desktopBatteryAlert").checked) return;
$("desktopBatteryStatus").textContent = message + " System notification delivery was not confirmed. Check system settings or use Enable / test notifications below.";
});
}
document.addEventListener("visibilitychange", () => desktopBattery.invalidate());
// ---- battery ----
function battery(b, power) {
const pct = b?.percent;
@@ -2511,6 +2574,7 @@ function render(s) {
renderHome(s);
window.frameControlCenter?.status(s, stateReceivedAt, stateRead);
battery(s.battery, s.power);
desktopBatterySample(s.battery);
$("devPopTitle").textContent = $("devPopTitle").title = window.connName || "Headset";
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
@@ -6898,6 +6962,11 @@ function onConnection(s) {
stateReceivedAt = null;
stateRead = {valid: false, error: null};
online = null;
$("desktopBatteryAlert").checked = false;
desktopBattery.invalidate();
// Keep the cooldown across switches, but a new headset can start its own charge episode.
desktopBattery.latched = false;
$("desktopBatteryStatus").textContent = "Off. Headset changed; enable for this headset if wanted.";
devGen++; // answers still on their way are about the other headset
padReset(); ctrlReset();
sourceState.jobs.clear(); sourceState.detail = null; sourceState.detailRequest++;