Compare commits

...
6 changed files with 131 additions and 3 deletions

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
## 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;
@@ -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.
+7
View File
@@ -285,6 +285,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).
+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 }));
+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
+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.