Compare commits

...
5 changed files with 202 additions and 63 deletions

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Fix Return to Game Mode misidentifying full Plasma Desktop Mode when its VR
overlay also runs gamescope (issue #142). Session targets now determine the
mode; conflicting or incomplete evidence stays unknown. Use SteamOS's
`steamosctl` switch when available, with older selector commands retained.
- Export read-only power and heat observations to an offline accessible HTML
report with JSON/CSV downloads. `--html FILE` limits sessions to ten minutes;
Ctrl+C saves received samples with an explicit incomplete state. Units,
+44 -31
View File
@@ -12,15 +12,21 @@ with no sign-in files or keys in the archives. Restoring one game's saves on top
of identical files wrote nothing and left nothing behind. The session check
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
headset: a restore after a real factory reset, a restore that replaces files,
Android data restore, and the switch out of a real Desktop Mode session. Unit
tests and the fake Frame cover those (see [Verification](#verification)).
On 2026-10-24 a read-only session check on the same build again reported Game
Mode (`gamescope=true`, `plasma=false`, `nested_desktop=false`) and the
`steamos-session-select` command was present. Opening the nested desktop for a
clipboard test did not enter full Desktop Mode. A full session switch was not
attempted because it restarts the headset session and a reliable return path
was not established; the **Return to Game Mode** branch for a real full
Desktop Mode session remains unverified.
Android data restore. Unit tests and the fake Frame cover those (see
[Verification](#verification)).
On 2026-10-24, the real-device reproduction in [issue #142](https://github.com/saphid/frame-control/issues/142)
confirmed full Plasma Desktop Mode on the same build: the Plasma session target
was active and the Game Mode target inactive. Desktop Mode also ran gamescope
for `steamvr-plasma.service`, so the old process check incorrectly reported
Game Mode. SteamOS's `steamosctl switch-to-game-mode` restored Game Mode and
reversed the target states; battery stayed at 99%, and the rollback timer and
shared lock were cleaned up. After #143 passed CI, the corrected code was
tested in another full Plasma session under a three-minute rollback timer.
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
the button. Game Mode became active and a second call made no switch. Battery
remained at 99%; the timer and lock were removed. The literal desktop button
was not clicked in this test, and older SteamOS builds remain fixture-tested.
## Back up
@@ -118,29 +124,28 @@ normally runs *inside* it as one panel (`steamos-nested-desktop`, see
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
is what you see, you're already in Game Mode: close it from the Steam dashboard.
Full Desktop Mode is a different session. SteamOS picks the session through
SDDM's autologin file, `/etc/sddm.conf.d/zz-steamos-autologin.conf` (or the
default `/etc/sddm.conf.d/steamos.conf`), and switches with
`steamos-session-select` (`holo-session-select` on newer images). This is what
Valve's own devkit tools on the Frame read and run
([`steamos-get-status`](../frame/devkit-utils/steamos-get-status),
[`steamos-delete`](../frame/devkit-utils/steamos-delete)).
Full Desktop Mode is a different session. **Check** reads the main user's
systemd session targets over SSH: `gamescope-session.target` and
`plasma-workspace-wayland.target`. Game Mode requires the former active and the
latter inactive; Desktop Mode requires the reverse. This takes precedence over
process names: full Desktop Mode can run gamescope for its VR overlay, and
Game Mode can run a nested Plasma panel. Check changes nothing.
**Check** reads that file and whether gamescope and Plasma are running; it
changes nothing. On the Frame gamescope's process is `gamescope-wl`, started by
`start-gamescope-session`, and either counts as Game Mode. Only when Plasma is
running without gamescope (Desktop Mode) does **Return to Game Mode** run
exactly `steamos-session-select gamescope` (or `holo-session-select
gamescope`) over SSH, after asking. It closes the desktop and anything open in
it. Frame Control never edits the SDDM files itself, and runs nothing when the
headset is in Game Mode, when it can't tell which mode it's in, or when the
image has neither command.
Conflicting, transitional, failed or unreadable targets produce **unknown**.
On older images where both targets are explicitly absent, the fallback accepts
only gamescope without Plasma/nested desktop as Game Mode, or Plasma without
gamescope/nested desktop as Desktop Mode. Mixed processes remain unknown.
The SDDM autologin file is still reported as a login preference; it cannot
establish which session is running.
**Uncertain:** whether the Frame offers a full Desktop Mode session at all
(only the nested desktop has been seen on BUILD_ID 20260922.6101926 and
20260925.6191901), and whether `steamos-session-select` there works without
the Developer Mode password. If the switch is refused, Frame Control says so and
leaves the headset as it was.
After confirmation, **Return to Game Mode** runs exactly
`steamosctl switch-to-game-mode` when available, or the older
`holo-session-select gamescope` / `steamos-session-select gamescope` command.
It closes the desktop and anything open in it. Frame Control never edits the
SDDM files itself, and runs no switch when the headset is in Game Mode, when
its mode is unknown, or when no supported switch command is installed. A
refused command reports an error; a request being sent does not verify that
the new session has started.
### Manual fallback
@@ -151,7 +156,9 @@ If the button can't help, or the headset isn't reachable:
Desktop Mode session, so a restart brings Game Mode back. That the Frame
behaves the same is **inferred**, not tested.
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
`ssh frame steamos-session-select gamescope`.
`ssh frame steamosctl switch-to-game-mode` (verified on SteamOS 0.5.6).
Older images may instead provide `steamos-session-select gamescope` or
`holo-session-select gamescope`.
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
button on top of Power. Choose `Current`, or `Previous` to start the other
SteamOS slot; both keep your data. Details and the further options
@@ -172,3 +179,9 @@ restore replaces only its own files and keeps the old ones, unsafe archives
names that try to leave the backups folder, and the exact Game Mode command.
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
the server, deletes it and restores it. Neither involves a real headset.
Isolated session tests in the same suite reproduce full Plasma plus gamescope,
Game Mode with and without the nested desktop, conflicting/transitional targets,
missing targets on older builds, failed/timed-out systemd reads, selector
compatibility, and the confirmed Return action with SSH mocked. Run them with
`python3 -m unittest discover -s tests -p test_backup.py -v`.
+104 -18
View File
@@ -5,6 +5,7 @@ import io
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
@@ -203,23 +204,106 @@ class Remote(unittest.TestCase):
self.assertEqual(result['written'], 0)
self.assertEqual(result['held'], {'refused': 1, 'reference': 1})
def test_session_reads_the_mode(self):
conf = write(self.home, 'zz-steamos-autologin.conf', '[Autologin]\nSession=plasma-steamos-wayland-oneshot.desktop\n')
running = set()
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n == 'steamos-session-select' else None):
state = remote.session()
self.assertEqual((state['mode'], state['selector']), ('desktop', 'steamos-session-select'))
self.assertEqual(state['configured'], 'plasma-steamos-wayland-oneshot.desktop')
running.update({'gamescope', 'plasmashell'}) # the nested desktop panel
self.assertEqual(remote.session()['mode'], 'game')
running.clear()
running.add('gamescope-wl') # gamescope's process name on the Frame, with no desktop panel open
self.assertEqual(remote.session()['mode'], 'game')
running.clear()
running.add('start-gamescope-session')
self.assertEqual(remote.session()['mode'], 'game')
class Session(unittest.TestCase):
def state(self, targets, running=(), configured=None):
with tempfile.TemporaryDirectory() as folder:
conf = write(folder, 'sddm.conf', 'Session=%s\n' % (configured or 'gamescope.desktop'))
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
patch.object(remote, '_session_target', side_effect=targets), \
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
patch.object(remote.shutil, 'which', return_value='/usr/bin/steamosctl'):
return remote.session()
def test_full_plasma_with_vr_gamescope_is_desktop(self):
# Issue #142, SteamOS 0.5.6: steamvr-plasma also starts nested scripts.
state = self.state(['inactive', 'active'],
{'gamescope-wl', 'plasmashell', 'steamos-nested-desktop'},
configured='gamescope.desktop')
self.assertEqual(state['mode'], 'desktop')
self.assertTrue(state['gamescope'])
self.assertTrue(state['nested_desktop'])
self.assertEqual(state['selector'], 'steamosctl')
self.assertEqual(state['session_targets']['plasma-workspace-wayland.target'], 'active')
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.assert_called_once_with('steamosctl switch-to-game-mode', timeout=60)
def test_game_target_wins_with_or_without_nested_desktop(self):
for running in ((), {'gamescope-wl'}, {'gamescope', 'plasmashell', 'steamos-nested-desktop'}):
state = self.state(['active', 'inactive'], running, configured='plasma.desktop')
self.assertEqual(state['mode'], 'game')
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.assert_not_called()
def test_conflicting_incomplete_and_transitional_targets_block_return(self):
for targets in (['active', 'active'], ['inactive', 'inactive'], ['active', 'unknown'],
['not-found', 'active'], ['unknown', 'unknown'], ['inactive', 'activating'],
['deactivating', 'active'], ['failed', 'active']):
with self.subTest(targets=targets):
state = self.state(targets, {'gamescope', 'plasmashell'}, 'plasma.desktop')
self.assertEqual(state['mode'], 'unknown')
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
with self.assertRaisesRegex(backup.BackupError, 'nothing was changed'):
backup.return_to_game_mode(confirmed=True)
ssh.assert_not_called()
def test_older_builds_use_only_unambiguous_running_processes(self):
cases = [((), 'unknown'), ({'plasmashell'}, 'desktop'),
({'gamescope'}, 'game'), ({'gamescope-wl'}, 'game'),
({'start-gamescope-session'}, 'game'),
({'gamescope', 'plasmashell'}, 'unknown'),
({'gamescope', 'steamos-nested-desktop'}, 'unknown')]
for running, expected in cases:
with self.subTest(running=running):
state = self.state(['not-found', 'not-found'], running, 'plasma.desktop')
self.assertEqual(state['mode'], expected)
self.assertEqual(state['mode_source'], 'legacy-processes')
def test_target_probe_parses_systemd_and_uses_main_user_bus(self):
cases = [('LoadState=loaded\nActiveState=active\n', 0, 'active'),
('ActiveState=inactive\nLoadState=loaded\n', 0, 'inactive'),
('LoadState=not-found\nActiveState=inactive\n', 0, 'not-found'),
('LoadState=error\nActiveState=active\n', 0, 'unknown'),
('garbage', 0, 'unknown'), ('LoadState=loaded\nActiveState=active', 1, 'unknown')]
for stdout, code, expected in cases:
with self.subTest(stdout=stdout, code=code), \
patch.object(remote.os, 'getuid', return_value=1000, create=True), \
patch.object(remote.subprocess, 'run', return_value=subprocess.CompletedProcess([], code, stdout)) as run:
self.assertEqual(remote._session_target('gamescope-session.target'), expected)
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'show',
'gamescope-session.target', '--property=LoadState', '--property=ActiveState'])
self.assertEqual(run.call_args.kwargs['timeout'], 5)
self.assertEqual(run.call_args.kwargs['env']['DBUS_SESSION_BUS_ADDRESS'], 'unix:path=/run/user/1000/bus')
self.assertEqual(run.call_args.kwargs['env']['XDG_RUNTIME_DIR'], '/run/user/1000')
for error in (OSError('no systemctl'), subprocess.TimeoutExpired('systemctl', 5)):
with patch.object(remote.os, 'getuid', return_value=1000, create=True), \
patch.object(remote.subprocess, 'run', side_effect=error):
self.assertEqual(remote._session_target('gamescope-session.target'), 'unknown')
def test_selector_preference_and_older_builds(self):
for available, expected in [({'steamosctl', 'steamos-session-select'}, 'steamosctl'),
({'holo-session-select', 'steamos-session-select'}, 'holo-session-select'),
({'steamos-session-select'}, 'steamos-session-select'), (set(), None)]:
with patch.object(remote, 'SDDM_CONFS', ()), \
patch.object(remote, '_session_target', return_value='unknown'), \
patch.object(remote, '_running', return_value=False), \
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n in available else None):
self.assertEqual(remote.session()['selector'], expected)
def test_script_probe_does_not_match_names_in_transmitted_python_source(self):
with patch.object(remote.subprocess, 'call', return_value=1) as call:
self.assertFalse(remote._running('steamos-nested-desktop', full=True))
pattern = call.call_args.args[0][-1]
for command in ('/usr/bin/steamos-nested-desktop',
'/bin/sh /usr/bin/steamos-nested-desktop',
'bash steamos-nested-desktop --flag'):
self.assertIsNotNone(re.search(pattern, command), command)
for command in ('python3 -c ' + backup.REMOTE.read_text(),
'/bin/sh -c pgrep -f steamos-nested-desktop',
'/usr/bin/steamos-nested-desktop-other'):
self.assertIsNone(re.search(pattern, command), command)
def fake_ssh(home, os_release):
@@ -393,9 +477,11 @@ class Ssh(unittest.TestCase):
class GameMode(unittest.TestCase):
def test_command_is_only_ever_the_session_switch(self):
self.assertEqual(backup.session_command('steamosctl'), 'steamosctl switch-to-game-mode')
self.assertEqual(backup.session_command('steamos-session-select'), 'steamos-session-select gamescope')
self.assertEqual(backup.session_command('holo-session-select'), 'holo-session-select gamescope')
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot'):
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot',
'steamosctl switch-to-desktop-mode', 'steamosctl; reboot'):
with self.assertRaises(backup.BackupError):
backup.session_command(bad)
+4 -5
View File
@@ -30,9 +30,8 @@ ARCHIVE = re.compile(r'^(saves|settings|android)/[A-Za-z0-9._-]{1,200}\.tar\.gz$
PART = re.compile(r'^(saves:(\d{1,10}|linux)|settings:(steam|system)|android:[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+)$')
KINDS = ('saves', 'settings', 'android')
FORMAT = 1
# The switch Valve's own devkit tools use (frame/devkit-utils/steamos-delete). Nothing else
# is ever run as a session command.
SELECTORS = ('holo-session-select', 'steamos-session-select')
# Fixed SteamOS switches only; never execute a command supplied by remote status.
SELECTORS = ('steamosctl', 'holo-session-select', 'steamos-session-select')
REASONS = {
'not_installed': 'Install the game first, then restore its saves',
'no_prefix': 'Start the game once so Proton sets up its folders, then restore',
@@ -344,10 +343,10 @@ def session_status():
def session_command(selector):
"""The switch to Game Mode, exactly as Valve's devkit tools run it."""
"""Allow only fixed Game Mode commands, including older devkit selectors."""
if selector not in SELECTORS:
raise BackupError('this SteamOS build has no session switch command; use the steps in the guide')
return shlex.join([selector, 'gamescope'])
return shlex.join([selector, 'switch-to-game-mode' if selector == 'steamosctl' else 'gamescope'])
def return_to_game_mode(confirmed=False):
+45 -9
View File
@@ -373,15 +373,40 @@ def restore(part, stamp, source):
# The Frame normally runs gamescope (Game Mode) with Plasma nested inside it as one panel
# (steamos-nested-desktop). A full Desktop Mode session is SDDM autologin into Plasma
# instead. Valve's own devkit tools (frame/devkit-utils: steamos-get-status, steamos-delete)
# read the session from these files and switch with holo-session-select or
# steamos-session-select, so that's what this reads and suggests.
# read the login preference from these files. It is not the running session.
SDDM_CONFS = ('/etc/sddm.conf.d/zz-steamos-autologin.conf', '/etc/sddm.conf.d/steamos.conf')
SESSION_TARGETS = ('gamescope-session.target', 'plasma-workspace-wayland.target')
def _session_target(unit):
"""Read the main user's manager, not a nested Plasma session bus over SSH."""
runtime = '/run/user/%d' % os.getuid()
env = dict(os.environ, XDG_RUNTIME_DIR=runtime,
DBUS_SESSION_BUS_ADDRESS='unix:path=%s/bus' % runtime)
try:
result = subprocess.run(['systemctl', '--user', 'show', unit,
'--property=LoadState', '--property=ActiveState'],
capture_output=True, text=True, timeout=5, env=env)
except (OSError, subprocess.TimeoutExpired):
return 'unknown'
if result.returncode:
return 'unknown'
properties = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
if properties.get('LoadState') == 'not-found':
return 'not-found'
if properties.get('LoadState') == 'loaded':
return properties.get('ActiveState', 'unknown')
return 'unknown'
def _running(name, full=False):
# This module is sent as `python3 -c <source>`: unanchored pgrep -f would
# match the script names inside our own source, even when no script runs.
pattern = (r'^([^ ]*/)?((ba|da|z)?sh +([^ ]*/)?)?%s( |$)' % re.escape(name)
if full else name)
try:
return subprocess.call(['pgrep', '-f' if full else '-x', name], stdout=subprocess.DEVNULL,
return subprocess.call(['pgrep', '-f' if full else '-x', pattern], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) == 0
except OSError:
return False
@@ -397,21 +422,32 @@ def session():
m = re.search(r'^\s*Session\s*=\s*(\S+)', text, re.M)
conf, configured = path, (m.group(1) if m else None)
break
selector = next((s for s in ('holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
selector = next((s for s in ('steamosctl', 'holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
# On the Frame gamescope's process is named gamescope-wl (seen on build 20261015.6141207),
# under the start-gamescope-session script.
gamescope = (_running('gamescope') or _running('gamescope-wl') or
_running('start-gamescope-session', full=True))
plasma = _running('plasmashell')
nested = _running('steamos-nested-desktop', full=True) # a script: its process name is the shell's
if gamescope:
targets = {unit: _session_target(unit) for unit in SESSION_TARGETS}
game, desktop = (targets[unit] for unit in SESSION_TARGETS)
mode = 'unknown'
source = 'session-targets'
if (game, desktop) == ('active', 'inactive'):
mode = 'game'
elif plasma or (configured and 'plasma' in configured):
elif (game, desktop) == ('inactive', 'active'):
mode = 'desktop'
else:
mode = 'unknown'
elif game == desktop == 'not-found':
# Older images have neither target. A compositor plus Plasma is ambiguous:
# Desktop Mode's steamvr-plasma.service also runs gamescope and nested scripts.
source = 'legacy-processes'
if gamescope and not plasma and not nested:
mode = 'game'
elif plasma and not gamescope and not nested:
mode = 'desktop'
return {'mode': mode, 'gamescope': gamescope, 'plasma': plasma, 'nested_desktop': nested,
'configured': configured, 'config_file': conf, 'selector': selector}
'configured': configured, 'config_file': conf, 'selector': selector,
'session_targets': targets, 'mode_source': source}
def main(argv):