diff --git a/CHANGELOG.md b/CHANGELOG.md index 4232919..044367f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ original headset. Shared sampler/report modules ship with the app; no power or thermal controls are added. Download before replacing a report or quitting. +- 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, diff --git a/docs/backup-and-recovery.md b/docs/backup-and-recovery.md index d420c08..1a5f076 100644 --- a/docs/backup-and-recovery.md +++ b/docs/backup-and-recovery.md @@ -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`. diff --git a/tests/test_backup.py b/tests/test_backup.py index 7667572..c966576 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -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) diff --git a/ui/frame_backup.py b/ui/frame_backup.py index 374738c..029bb81 100644 --- a/ui/frame_backup.py +++ b/ui/frame_backup.py @@ -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): diff --git a/ui/frame_backup_remote.py b/ui/frame_backup_remote.py index de829a5..b8df5b2 100644 --- a/ui/frame_backup_remote.py +++ b/ui/frame_backup_remote.py @@ -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 `: 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):