mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 08:01:36 +02:00
Tools → Backup & recovery copies game saves (Proton prefixes' AppData, Documents and Saved Games, Unity and Godot saves), Steam controller layouts, desktop settings and Android app data (the existing backup-data helper) to ~/Documents/Frame Control Backups/<time>/ with a manifest of every file's size and SHA-256 and the headset's BUILD_ID. Backing up only reads the headset; sign-in files, keys and keyrings are refused by name wherever they are. Restore checks each part against the headset first and skips, rather than fails, games not installed yet, prefixes Proton hasn't made, a missing Steam account and closed or missing Android apps. It asks before replacing anything, writes only inside each part's folders and keeps every replaced file under ~/.local/share/frame-control/restore-previous/. Return to Game Mode reads the SDDM session config and runs the switch Valve's devkit tools use (steamos-session-select / holo-session-select gamescope), only when the headset isn't already in Game Mode and only after confirmation. Not tested on a headset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
24 KiB
Python
440 lines
24 KiB
Python
"""Backup and recovery kit (ui/frame_backup.py, ui/frame_backup_remote.py) against a folder laid
|
|
out like the Frame's home. The headset-side script runs for real, locally; only SSH is faked."""
|
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / 'ui'))
|
|
import frame_android as android # noqa: E402
|
|
import frame_backup as backup # noqa: E402
|
|
import frame_backup_remote as remote # noqa: E402
|
|
import server # noqa: E402
|
|
|
|
STEAM = '.local/share/Steam'
|
|
USER = STEAM + '/steamapps/compatdata/%s/pfx/drive_c/users/steamuser'
|
|
SECRETS = ['.steam/steam.token', STEAM + '/config/config.vdf', STEAM + '/config/loginusers.vdf',
|
|
STEAM + '/ssfn123456', '.ssh/id_ed25519', '.ssh/authorized_keys',
|
|
USER % 1234 + '/AppData/Roaming/Launcher/session.token',
|
|
USER % 1234 + '/AppData/Roaming/Launcher/.ssh/id_rsa',
|
|
'.config/cliproxyapi/secrets.env', '.local/share/keyrings/login.keyring']
|
|
|
|
|
|
def write(home, rel, data=b'x'):
|
|
path = Path(home) / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data if isinstance(data, bytes) else data.encode())
|
|
return path
|
|
|
|
|
|
def manifest(home, appid, name):
|
|
write(home, '%s/steamapps/appmanifest_%s.acf' % (STEAM, appid),
|
|
'"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n}\n' % (appid, name))
|
|
|
|
|
|
def frame_home(home):
|
|
"""A Frame before a reset: one Proton game, a Linux game, settings and secrets."""
|
|
manifest(home, 1234, 'Test Game')
|
|
write(home, USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat', b'save one')
|
|
write(home, USER % 1234 + '/Documents/My Games/Game/profile.sav', b'profile')
|
|
write(home, USER % 1234 + '/AppData/Local/Temp/junk.tmp', b'junk')
|
|
write(home, USER % 1234 + '/AppData/Local/Microsoft/Windows/x.db', b'wine')
|
|
write(home, USER % 999 + '/AppData/Local/Temp/only-cache', b'c') # no saves: no part
|
|
write(home, STEAM + '/steamapps/compatdata/2800000001/internal/org.example/x', b'android')
|
|
write(home, STEAM + '/userdata/555/config/localconfig.vdf', b'local')
|
|
write(home, STEAM + '/userdata/555/config/shortcuts.vdf', b'shortcuts')
|
|
write(home, STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', b'layout')
|
|
write(home, STEAM + '/userdata/555/1234/remote/cloud.sav', b'cloud') # Steam Cloud's own
|
|
write(home, STEAM + '/userdata/555/760/remote/1234/screenshots/a.jpg', b'shot')
|
|
write(home, '.config/kdeglobals', b'[General]')
|
|
write(home, '.config/openvr/config/steamvr.vrsettings', b'{}')
|
|
write(home, '.config/unity3d/Company/LinuxGame/prefs', b'prefs')
|
|
write(home, '.bashrc', b'# mine')
|
|
for rel in SECRETS:
|
|
write(home, rel, b'SECRET')
|
|
|
|
|
|
class Remote(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.home = Path(self.tmp.name)
|
|
patcher = patch.object(remote, 'HOME', self.home)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
self.addCleanup(self.tmp.cleanup)
|
|
|
|
def files(self, part):
|
|
return [rel for rel, _ in remote.part_files(part)[0]]
|
|
|
|
def test_plan_picks_saves_and_settings_and_never_secrets(self):
|
|
frame_home(self.home)
|
|
plan = remote.plan()
|
|
ids = [p['id'] for p in plan['parts']]
|
|
self.assertEqual(ids, ['saves:1234', 'saves:linux', 'settings:steam', 'settings:system'])
|
|
self.assertEqual(plan['parts'][0]['label'], 'Test Game saves')
|
|
saves = self.files('saves:1234')
|
|
self.assertEqual(sorted(saves), [USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat',
|
|
USER % 1234 + '/Documents/My Games/Game/profile.sav'])
|
|
steam = self.files('settings:steam')
|
|
self.assertIn(STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', steam)
|
|
self.assertIn(STEAM + '/userdata/555/config/localconfig.vdf', steam)
|
|
self.assertFalse([f for f in steam if '/1234/remote/' in f or '/760/' in f])
|
|
everything = sum((self.files(i) for i in ids), [])
|
|
for rel in SECRETS + ['.bashrc']:
|
|
self.assertNotIn(rel, everything)
|
|
self.assertFalse([f for f in everything if remote.is_secret(f)])
|
|
self.assertTrue(any('steam.token' in x for x in plan['excluded']))
|
|
# The controller layout goes back; localconfig.vdf and shortcuts.vdf are for reference.
|
|
steam_part = plan['parts'][2]
|
|
self.assertEqual((steam_part['files'], steam_part['restorable']), (3, 1))
|
|
|
|
def test_secret_names(self):
|
|
for rel in SECRETS + ['a/b/ID_RSA.pub', 'x/kwalletd/k', 'x/cert.PEM']:
|
|
self.assertTrue(remote.is_secret(rel), rel)
|
|
for rel in ['saves/game.sav', 'Documents/tokens-collected.txt', 'config/localconfig.vdf']:
|
|
self.assertFalse(remote.is_secret(rel), rel)
|
|
|
|
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
|
def test_links_are_never_followed(self):
|
|
frame_home(self.home)
|
|
outside = write(self.home, 'elsewhere/private.txt', b'p')
|
|
base = self.home / (USER % 1234) / 'Documents'
|
|
os.symlink(str(outside.parent), str(base / 'linked-dir'))
|
|
os.symlink(str(outside), str(base / 'linked-file'))
|
|
self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f])
|
|
|
|
def test_big_files_are_listed_as_skipped(self):
|
|
frame_home(self.home)
|
|
with patch.object(remote, 'MAX_FILE', 3):
|
|
files, skipped = remote.part_files('saves:1234')
|
|
self.assertEqual(files, [])
|
|
self.assertEqual({s['reason'] for s in skipped}, {'over 2 GiB'})
|
|
|
|
def test_pack_holds_exactly_the_planned_files(self):
|
|
frame_home(self.home)
|
|
out = io.BytesIO()
|
|
remote.pack('saves:1234', out)
|
|
out.seek(0)
|
|
with tarfile.open(fileobj=out, mode='r:gz') as archive:
|
|
names = sorted(m.name for m in archive)
|
|
self.assertEqual(names, sorted(self.files('saves:1234')))
|
|
with self.assertRaises(ValueError):
|
|
remote.pack('saves:../../etc', io.BytesIO())
|
|
|
|
def archive(self, members):
|
|
out = io.BytesIO()
|
|
with tarfile.open(fileobj=out, mode='w:gz') as tar:
|
|
for name, data in members:
|
|
info = tarfile.TarInfo(name)
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
out.seek(0)
|
|
return out
|
|
|
|
def test_check_after_a_reset(self):
|
|
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
|
|
ask = {'parts': [{'id': 'saves:1234', 'files': [save]},
|
|
{'id': 'settings:steam', 'files': [STEAM + '/userdata/555/241100/remote/controller_config/1/a.vdf']},
|
|
{'id': 'settings:system', 'files': ['.config/openvr/config/steamvr.vrsettings']},
|
|
{'id': 'saves:../x', 'files': []}]}
|
|
states = lambda: [p['status'] for p in remote.check(ask)['parts']] # noqa: E731
|
|
self.assertEqual(states(), ['not_installed', 'no_account', 'reference', 'unknown'])
|
|
manifest(self.home, 1234, 'Test Game')
|
|
(self.home / STEAM / 'userdata/555').mkdir(parents=True)
|
|
self.assertEqual(states()[:2], ['no_prefix', 'ok'])
|
|
(self.home / (USER % 1234)).mkdir(parents=True)
|
|
result = remote.check(ask)['parts'][0]
|
|
self.assertEqual((result['status'], result['new'], result['replace']), ('ok', 1, 0))
|
|
write(self.home, save, b'newer')
|
|
self.assertEqual(remote.check(ask)['parts'][0]['replace'], 1)
|
|
|
|
def test_restore_replaces_only_its_files_and_keeps_the_old_ones(self):
|
|
manifest(self.home, 1234, 'Test Game')
|
|
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
|
|
write(self.home, save, b'played since')
|
|
other = write(self.home, USER % 1234 + '/AppData/Roaming/Studio/Game/other.dat', b'untouched')
|
|
bashrc = write(self.home, '.bashrc', b'# mine')
|
|
source = self.archive([(save, b'from backup'), (USER % 1234 + '/Documents/new.sav', b'new'),
|
|
('.bashrc', b'# replaced?')])
|
|
result = remote.restore('saves:1234', '20261023-120000', source)
|
|
self.assertEqual((result['written'], result['replaced']), (2, 1))
|
|
self.assertEqual(result['held'], {'reference': 1})
|
|
self.assertEqual((self.home / save).read_bytes(), b'from backup')
|
|
self.assertEqual(other.read_bytes(), b'untouched')
|
|
self.assertEqual(bashrc.read_bytes(), b'# mine')
|
|
kept = self.home / remote.PREVIOUS / '20261023-120000' / save
|
|
self.assertEqual(kept.read_bytes(), b'played since')
|
|
self.assertFalse([p for p in (self.home / save).parent.iterdir() if p.name.endswith('.part')])
|
|
|
|
def test_unsafe_archives_write_nothing(self):
|
|
manifest(self.home, 1234, 'Test Game')
|
|
(self.home / (USER % 1234)).mkdir(parents=True)
|
|
good = (USER % 1234 + '/Documents/a.sav', b'a')
|
|
for bad in ('../escape', '/etc/passwd', USER % 1234 + '/Documents/../../../../../../x',
|
|
USER % 1234 + '/Documents/a.sav'):
|
|
with self.assertRaises(ValueError, msg=bad):
|
|
remote.restore('saves:1234', '20261023-120000', self.archive([good, (bad, b'evil')]))
|
|
self.assertFalse((self.home / good[0]).exists(), bad)
|
|
link = io.BytesIO()
|
|
with tarfile.open(fileobj=link, mode='w:gz') as tar:
|
|
info = tarfile.TarInfo(USER % 1234 + '/Documents/l')
|
|
info.type, info.linkname = tarfile.SYMTYPE, '/etc/passwd'
|
|
tar.addfile(info)
|
|
link.seek(0)
|
|
with self.assertRaises(ValueError):
|
|
remote.restore('saves:1234', '20261023-120000', link)
|
|
with self.assertRaises(ValueError):
|
|
remote.restore('saves:1234', '../stamp', self.archive([good]))
|
|
|
|
def test_restore_refuses_secrets_and_other_games(self):
|
|
manifest(self.home, 1234, 'Test Game')
|
|
(self.home / (USER % 1234)).mkdir(parents=True)
|
|
result = remote.restore('saves:1234', '20261023-120000', self.archive([
|
|
(USER % 1234 + '/AppData/Roaming/L/auth.token', b't'), (USER % 5678 + '/Documents/x.sav', b'x')]))
|
|
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')
|
|
|
|
|
|
def fake_ssh(home, os_release):
|
|
"""Runs the headset-side command locally, as the Frame's python3 would."""
|
|
def run(command, src=None, dst=None, timeout=None):
|
|
argv = shlex.split(command)
|
|
assert argv[:2] == ['python3', '-c'], argv
|
|
data = src if isinstance(src, bytes) else src.read() if src is not None else b''
|
|
env = dict(os.environ, FRAME_BACKUP_HOME=str(home), FRAME_BACKUP_OS_RELEASE=str(os_release))
|
|
r = subprocess.run([sys.executable, *argv[1:]], input=data, capture_output=True, env=env)
|
|
if r.returncode:
|
|
raise backup.BackupError(r.stderr.decode(errors='replace'))
|
|
if dst is not None:
|
|
dst.write(r.stdout)
|
|
return None
|
|
return r.stdout
|
|
return run
|
|
|
|
|
|
class Local(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
base = Path(self.tmp.name)
|
|
self.home, self.store = base / 'frame', base / 'backups'
|
|
self.release = write(base, 'os-release', 'ID=steamos\nVERSION_ID=0.4.1\nBUILD_ID=20260925.6191901\n')
|
|
frame_home(self.home)
|
|
for p in (patch.dict(os.environ, FRAME_BACKUP_DIR=str(self.store)),
|
|
patch.object(backup, '_ssh', side_effect=fake_ssh(self.home, self.release))):
|
|
p.start()
|
|
self.addCleanup(p.stop)
|
|
|
|
def android_backup(self, package, destination):
|
|
write(Path(destination).parent, Path(destination).name, b'android archive ' + package.encode())
|
|
return {'package': package, 'instance': android.instance_id(package), 'files': 3, 'bytes': 42,
|
|
'path': str(destination), 'sha256': backup._sha256(destination)}
|
|
|
|
def make(self):
|
|
apps = [{'package': 'org.example.open', 'label': 'Open app', 'running': True},
|
|
{'package': 'org.example.game', 'label': 'Game', 'running': False}]
|
|
with patch.object(android, 'list_apps', return_value=apps), \
|
|
patch.object(android, 'backup_data', side_effect=self.android_backup) as data, \
|
|
patch.object(backup.time, 'strftime', side_effect=lambda f: '2026-10-23-120000' if f == '%Y-%m-%d-%H%M%S'
|
|
else '2026-10-23T12:00:00+0100'):
|
|
result = backup.backup(headset='My Frame')
|
|
data.assert_called_once()
|
|
return result
|
|
|
|
def test_backup_writes_a_manifest_with_checksums(self):
|
|
result = self.make()
|
|
folder = backup.backup_path('2026-10-23-120000') # resolved: macOS's temp folder is a link
|
|
self.assertEqual(result['path'], str(folder))
|
|
self.assertEqual(sorted(p.name for p in self.store.iterdir()), ['2026-10-23-120000']) # no .partial left
|
|
m = json.loads((folder / 'manifest.json').read_text())
|
|
self.assertEqual(m['headset'], {'name': 'My Frame', 'build': '20260925.6191901', 'version': '0.4.1'})
|
|
self.assertEqual([p['id'] for p in m['parts']],
|
|
['saves:1234', 'saves:linux', 'settings:steam', 'settings:system', 'android:org.example.game'])
|
|
for part in m['parts']:
|
|
self.assertEqual(backup._sha256(folder / part['archive']), part['sha256'])
|
|
saves = m['parts'][0]
|
|
self.assertEqual(saves['archive'], 'saves/saves-1234.tar.gz')
|
|
self.assertEqual(sum(f['bytes'] for f in saves['files']), saves['bytes'])
|
|
self.assertEqual(saves['files'][0]['sha256'], __import__('hashlib').sha256(b'save one').hexdigest())
|
|
self.assertEqual([s['id'] for s in m['skipped']], ['android:org.example.open'])
|
|
for part in m['parts'][:-1]:
|
|
with tarfile.open(str(folder / part['archive'])) as tar:
|
|
for member in tar:
|
|
self.assertFalse(remote.is_secret(member.name), member.name)
|
|
self.assertNotEqual(tar.extractfile(member).read(), b'SECRET', member.name)
|
|
|
|
def test_a_failed_backup_leaves_nothing_behind(self):
|
|
with patch.object(backup, 'inspect_archive', side_effect=backup.BackupError('cut off')), \
|
|
patch.object(android, 'list_apps', return_value=[]):
|
|
with self.assertRaises(backup.BackupError):
|
|
backup.backup(kinds=['saves'])
|
|
self.assertEqual(list(self.store.iterdir()), [])
|
|
|
|
def test_backup_names_cannot_leave_the_folder(self):
|
|
for name in ('../2026-10-23-120000', '2026-10-23-120000/..', '..', '/etc', 'C:\\x', '',
|
|
'2026-10-23-120000/../../x', '2026-10-23-12000', None, 5):
|
|
with self.assertRaises(backup.BackupError, msg=repr(name)):
|
|
backup.backup_path(name)
|
|
self.assertEqual(backup.backup_path('2026-10-23-120000'), (self.store / '2026-10-23-120000').resolve())
|
|
|
|
def test_manifest_entries_cannot_point_outside_the_backup(self):
|
|
self.make()
|
|
path = self.store / '2026-10-23-120000' / 'manifest.json'
|
|
m = json.loads(path.read_text())
|
|
for archive in ('../../secret.tar.gz', '/etc/passwd', 'saves/saves-5678.tar.gz'):
|
|
m['parts'][0]['archive'] = archive
|
|
path.write_text(json.dumps(m))
|
|
with self.assertRaises(backup.BackupError, msg=archive):
|
|
backup.read_manifest('2026-10-23-120000')
|
|
|
|
def reset(self):
|
|
"""A factory reset: the home folder is empty again."""
|
|
import shutil
|
|
shutil.rmtree(str(self.home))
|
|
self.home.mkdir()
|
|
|
|
def test_restore_after_a_reset_skips_what_is_not_back_yet(self):
|
|
self.make()
|
|
self.reset()
|
|
manifest(self.home, 1234, 'Test Game')
|
|
(self.home / (USER % 1234)).mkdir(parents=True) # started once
|
|
with patch.object(android, 'list_apps', return_value=[]):
|
|
plan = {p['id']: p for p in backup.restore_plan('2026-10-23-120000')['parts']}
|
|
self.assertEqual(plan['saves:1234']['status'], 'ok')
|
|
self.assertEqual(plan['settings:steam']['status'], 'no_account')
|
|
self.assertEqual(plan['android:org.example.game']['status'], 'not_installed')
|
|
with self.assertRaises(backup.BackupError):
|
|
backup.restore('2026-10-23-120000', ['saves:1234']) # not confirmed
|
|
self.assertFalse((self.home / (USER % 1234) / 'AppData').exists())
|
|
result = backup.restore('2026-10-23-120000', ['saves:1234', 'settings:steam', 'android:org.example.game'],
|
|
confirmed=True)
|
|
self.assertEqual([r['id'] for r in result['restored']], ['saves:1234'])
|
|
self.assertEqual({s['id'] for s in result['skipped']}, {'settings:steam', 'android:org.example.game'})
|
|
self.assertEqual((self.home / (USER % 1234) / 'AppData/Roaming/Studio/Game/save1.dat').read_bytes(), b'save one')
|
|
|
|
def test_android_data_goes_back_through_the_existing_helper(self):
|
|
self.make()
|
|
apps = [{'package': 'org.example.game', 'label': 'Game', 'running': False}]
|
|
with patch.object(android, 'list_apps', return_value=apps), \
|
|
patch.object(android, 'restore_data', return_value={'restored': True}) as restore:
|
|
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
|
|
restore.assert_called_once_with('org.example.game', str(backup.backup_path('2026-10-23-120000') /
|
|
'android' / 'org.example.game.tar.gz'))
|
|
self.assertEqual(len(result['restored']), 1)
|
|
apps[0]['running'] = True
|
|
with patch.object(android, 'list_apps', return_value=apps), patch.object(android, 'restore_data') as restore:
|
|
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
|
|
restore.assert_not_called()
|
|
self.assertEqual(result['skipped'][0]['reason'], 'Close the app first')
|
|
|
|
def test_a_damaged_archive_is_skipped(self):
|
|
self.make()
|
|
with open(str(self.store / '2026-10-23-120000/saves/saves-1234.tar.gz'), 'ab') as f:
|
|
f.write(b'tampered')
|
|
with patch.object(android, 'list_apps', return_value=[]):
|
|
result = backup.restore('2026-10-23-120000', ['saves:1234'], confirmed=True)
|
|
self.assertEqual(result['restored'], [])
|
|
self.assertIn('checksum', result['skipped'][0]['reason'])
|
|
|
|
def test_list_backups(self):
|
|
self.make()
|
|
(self.store / '.2026-10-23-130000.partial').mkdir()
|
|
(self.store / 'notes').mkdir()
|
|
listed = backup.list_backups()['backups']
|
|
self.assertEqual([b['name'] for b in listed], ['2026-10-23-120000'])
|
|
self.assertEqual(listed[0]['build'], '20260925.6191901')
|
|
|
|
|
|
class Ssh(unittest.TestCase):
|
|
def test_bytes_go_as_input_and_files_as_stdin(self):
|
|
done = subprocess.CompletedProcess([], 0, b'{}', b'')
|
|
with patch.object(backup.frame_host, 'run_ssh', return_value=done) as run:
|
|
backup._ssh('true', src=b'{"parts": []}')
|
|
self.assertEqual(run.call_args.kwargs['input'], b'{"parts": []}')
|
|
self.assertNotIn('stdin', run.call_args.kwargs)
|
|
with tempfile.TemporaryFile() as f:
|
|
backup._ssh('true', src=f)
|
|
self.assertIs(run.call_args.kwargs['stdin'], f)
|
|
backup._ssh('true')
|
|
self.assertIs(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
|
|
self.assertEqual(run.call_args.args[0][-1], 'true')
|
|
with patch.object(backup.frame_host, 'run_ssh',
|
|
return_value=subprocess.CompletedProcess([], 1, b'', b'Permission denied')):
|
|
with self.assertRaisesRegex(backup.BackupError, 'Permission denied'):
|
|
backup._ssh('true')
|
|
|
|
|
|
class GameMode(unittest.TestCase):
|
|
def test_command_is_only_ever_the_session_switch(self):
|
|
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'):
|
|
with self.assertRaises(backup.BackupError):
|
|
backup.session_command(bad)
|
|
|
|
def test_switch_needs_confirmation_and_desktop_mode(self):
|
|
with patch.object(backup, '_ssh') as ssh:
|
|
with self.assertRaises(backup.BackupError):
|
|
backup.return_to_game_mode()
|
|
ssh.assert_not_called()
|
|
with patch.object(backup, 'session_status', return_value={'mode': 'game', 'selector': 'steamos-session-select'}), \
|
|
patch.object(backup, '_ssh') as ssh:
|
|
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
|
|
ssh.assert_not_called()
|
|
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': 'steamos-session-select'}), \
|
|
patch.object(backup, '_ssh') as ssh:
|
|
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
|
|
ssh.assert_called_once_with('steamos-session-select gamescope', timeout=60)
|
|
ssh.side_effect = backup.BackupError('the headset took too long to answer') # the session restarted
|
|
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
|
|
ssh.side_effect = backup.BackupError('Interactive authentication required')
|
|
with self.assertRaisesRegex(backup.BackupError, 'manual steps'):
|
|
backup.return_to_game_mode(confirmed=True)
|
|
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': None}), \
|
|
patch.object(backup, '_ssh') as ssh:
|
|
with self.assertRaises(backup.BackupError):
|
|
backup.return_to_game_mode(confirmed=True)
|
|
ssh.assert_not_called()
|
|
|
|
|
|
class Endpoint(unittest.TestCase):
|
|
def test_guards_run_before_the_headset(self):
|
|
with patch.object(backup, '_ssh') as ssh, patch.object(server, 'start_job') as job:
|
|
for body in ({'action': 'plan', 'name': '../x'}, {'action': 'open', 'name': '..'},
|
|
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:1234']},
|
|
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:x'], 'confirm': True},
|
|
{'action': 'gamemode'}, {'action': 'backup', 'kinds': ['ssh-keys']}, {'action': 'nope'}):
|
|
with self.assertRaises(server.Failure, msg=body) as caught:
|
|
server.backup_action(body)
|
|
self.assertEqual(caught.exception.status, 400)
|
|
ssh.assert_not_called()
|
|
job.assert_not_called()
|
|
with patch.object(server, 'LOCAL', True):
|
|
with self.assertRaises(server.Failure):
|
|
server.backup_action({'action': 'list'})
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|