diff --git a/CHANGELOG.md b/CHANGELOG.md index 463cd24..782ba8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + - Treat headset aliases and other CSV metadata as plain text in power and heat exports, so spreadsheet formula prefixes cannot run when a report is opened. diff --git a/docs/evidence/panel-discovery-2026-10-24.md b/docs/evidence/panel-discovery-2026-10-24.md new file mode 100644 index 0000000..6d7d43c --- /dev/null +++ b/docs/evidence/panel-discovery-2026-10-24.md @@ -0,0 +1,50 @@ +# 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. + +Blocker: the shared device was occupied, and its current build differs from +the reported 0.4.5 build. There is no safe reproduction establishing a further +capture/discovery fix for the original report. 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. diff --git a/docs/streaming.md b/docs/streaming.md index 7f96a75..e0f1e18 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -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). diff --git a/tests/page/desk_panels.mjs b/tests/page/desk_panels.mjs index 5ddf353..dcf4910 100644 --- a/tests/page/desk_panels.mjs +++ b/tests/page/desk_panels.mjs @@ -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 })); diff --git a/tests/test_touch.py b/tests/test_touch.py index 5d0c7c6..b501e91 100644 --- a/tests/test_touch.py +++ b/tests/test_touch.py @@ -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 diff --git a/ui/frame_touch.py b/ui/frame_touch.py index 8f1be7d..427314c 100644 --- a/ui/frame_touch.py +++ b/ui/frame_touch.py @@ -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.