mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 12:01:40 +02:00
Merge origin/main into feat/windows-pc-in-headset
ui/index.html: main added the Back up tool link beside the Mac one; both links and both host checks are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
361bcf8a1b
13 files changed
+1786
-5
No files matched your search
@@ -89,6 +89,14 @@ Install desktop apps like Moonlight or VLC, and set each Android app's resolutio
|
||||
**⚡ One-click tools**<br>
|
||||
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
**💾 Backup and recovery**<br>
|
||||
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Backup and recovery
|
||||
|
||||
**Tools → Backup & recovery** copies what a factory reset would lose from the
|
||||
Steam Frame to your computer, puts it back afterwards, and gets the headset out
|
||||
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
|
||||
app or the Frame itself.
|
||||
|
||||
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
|
||||
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
|
||||
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
|
||||
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)).
|
||||
|
||||
## Back up
|
||||
|
||||
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
|
||||
(all three by default), then **Back up now**. **See what's included** lists each
|
||||
part and its size first. Backing up only reads the headset.
|
||||
|
||||
Each backup is a folder named after the time it started:
|
||||
|
||||
```
|
||||
~/Documents/Frame Control Backups/2026-10-23-142530/
|
||||
manifest.json
|
||||
saves/saves-1234.tar.gz one per game
|
||||
saves/saves-linux.tar.gz
|
||||
settings/settings-steam.tar.gz
|
||||
settings/settings-system.tar.gz
|
||||
android/org.example.game.tar.gz one per Android app
|
||||
```
|
||||
|
||||
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
|
||||
file with its size and SHA-256, and each archive's SHA-256. It also lists what
|
||||
was skipped and why. A backup is written under a hidden `.…partial` name and
|
||||
renamed when complete, so a cut-off backup never shows up as one.
|
||||
|
||||
### What's saved
|
||||
|
||||
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|
||||
|---|---|---|
|
||||
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
|
||||
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
|
||||
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
|
||||
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
|
||||
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
|
||||
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
|
||||
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
|
||||
|
||||
"Kept for reference" files are in the backup but not put back automatically:
|
||||
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
|
||||
point at apps that aren't installed after a reset. Open the backup folder to
|
||||
copy one by hand if you need it.
|
||||
|
||||
### What's never copied
|
||||
|
||||
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
|
||||
`registry.vdf` and `ssfn*` files.
|
||||
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
|
||||
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
|
||||
(`secrets.env`). These are refused by name wherever they turn up, including
|
||||
inside a game's folder, and a restore refuses them too.
|
||||
- Games, Proton and the Steam runtimes: reinstall them from Steam.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
|
||||
Android apps that are open are skipped (the helper needs the app closed); close
|
||||
them and back up again.
|
||||
|
||||
## Restore after a factory reset
|
||||
|
||||
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
|
||||
Steam with the same account.
|
||||
2. Reinstall your games, and **start each once** so Proton creates its folders.
|
||||
3. Reinstall your Android apps from Frame Control.
|
||||
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
|
||||
each part against the headset and says what it would do: how many files it
|
||||
replaces and how many are new.
|
||||
5. Tick the parts to restore and **Restore selected**. It asks before replacing
|
||||
anything.
|
||||
|
||||
Parts that can't go back yet are skipped, not failed, with the reason:
|
||||
|
||||
| Reason | What to do |
|
||||
|---|---|
|
||||
| Install the game first | Install it from Steam, then restore |
|
||||
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
|
||||
| Sign in to Steam first | Controller layouts go under your account's folder |
|
||||
| Install the app first / close the app first | Android app data needs the app installed and closed |
|
||||
| The backup file doesn't match its checksum | The archive changed since it was made |
|
||||
|
||||
A restore writes only the files in the parts you chose, and only inside that
|
||||
part's folders: it never deletes a file, and a save archive can't write outside
|
||||
its game. Before replacing a file it copies the current one to
|
||||
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
|
||||
already identical are left alone. Android app data goes through the existing
|
||||
`restore-data` helper, which keeps the previous data folder beside it.
|
||||
|
||||
## Stuck in Desktop Mode
|
||||
|
||||
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
|
||||
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)).
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
If the button can't help, or the headset isn't reachable:
|
||||
|
||||
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
|
||||
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
|
||||
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`.
|
||||
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
|
||||
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
|
||||
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
|
||||
`Erase User Data` is a factory reset.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
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.
|
||||
@@ -53,6 +53,11 @@ counts them while they run.
|
||||
`~/Videos/SteamFrame`, and **Steam clips**: export clips and background
|
||||
recordings from Steam's game recording at any length, without re-encoding.
|
||||
See [recording.md](recording.md). Not tested on a headset yet.
|
||||
- **Backup & recovery** (Tools): copy game saves, settings and Android app data
|
||||
to `~/Documents/Frame Control Backups/<time>/` with a checksummed manifest,
|
||||
restore chosen parts after a factory reset, and **Return to Game Mode** when
|
||||
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
|
||||
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
|
||||
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||
|
||||
@@ -96,5 +96,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
|
||||
- Backing up saves and settings, restoring after a reset, leaving Desktop Mode: [backup-and-recovery.md](backup-and-recovery.md)
|
||||
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
@@ -4,6 +4,9 @@ Where to get the Steam Frame's operating system, what's inside it, and how to
|
||||
run it for testing without the headset. For recovering a Frame that won't boot,
|
||||
see the boot menu and boot-loop entries in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
|
||||
To keep your game saves, settings and Android app data through a factory reset
|
||||
(`Erase User Data`) or a re-image, and to get out of Desktop Mode, see
|
||||
[backup-and-recovery.md](backup-and-recovery.md).
|
||||
|
||||
## Downloads
|
||||
|
||||
|
||||
@@ -346,3 +346,14 @@ Chromium rendering and the same helper through HTTP. Close the switcher after
|
||||
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
|
||||
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
|
||||
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
|
||||
|
||||
## Backup and recovery kit
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script `ui/frame_backup_remote.py`
|
||||
for real against a temporary folder laid out like the Frame's home, with only
|
||||
SSH faked: path selection and the secrets it must never copy, the manifest and
|
||||
checksums, restore checks after a reset, restore writing only its own files,
|
||||
unsafe archives and backup names, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
|
||||
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
|
||||
No backup or restore has been run on a real headset yet.
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Backup and recovery kit through the server against the fake Frame: a Proton game's save
|
||||
goes to this computer and back, Steam's sign-in token never leaves the headset."""
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import harness
|
||||
from harness import api, finished, ok, ssh
|
||||
|
||||
harness.require()
|
||||
|
||||
SAVE = '.local/share/Steam/steamapps/compatdata/620980/pfx/drive_c/users/steamuser/AppData/LocalLow/Beat Games/Beat Saber/PlayerData.dat'
|
||||
|
||||
|
||||
class Backup(harness.FrameTestCase):
|
||||
def test_saves_round_trip_and_secrets_stay(self):
|
||||
ssh(f"mkdir -p ~/'{Path(SAVE).parent}' && printf 'level 7' > ~/'{SAVE}' && "
|
||||
"mkdir -p ~/.steam && printf SECRET > ~/.steam/steam.token")
|
||||
job = finished(ok('POST', '/api/backup', {'action': 'backup', 'kinds': ['saves', 'settings']}), 120)
|
||||
self.assertIsNone(job['error'], job)
|
||||
folder = Path(job['result']['path'])
|
||||
self.addCleanup(shutil.rmtree, str(folder), True)
|
||||
manifest = json.loads((folder / 'manifest.json').read_text())
|
||||
self.assertEqual(manifest['headset']['build'], '20260922.6101926')
|
||||
saves = next(p for p in manifest['parts'] if p['id'] == 'saves:620980')
|
||||
self.assertEqual(saves['label'], 'Beat Saber saves')
|
||||
self.assertEqual([f['path'] for f in saves['files']], [SAVE])
|
||||
for part in manifest['parts']:
|
||||
with tarfile.open(str(folder / part['archive'])) as tar:
|
||||
self.assertFalse([m.name for m in tar if 'steam.token' in m.name])
|
||||
self.assertEqual(ssh('cat ~/.steam/steam.token'), 'SECRET') # read-only on the headset
|
||||
|
||||
ssh(f"rm ~/'{SAVE}'")
|
||||
name = folder.name
|
||||
plan = {p['id']: p for p in ok('POST', '/api/backup', {'action': 'plan', 'name': name})['parts']}
|
||||
self.assertEqual((plan['saves:620980']['status'], plan['saves:620980']['new']), ('ok', 1))
|
||||
status, out, _ = api('POST', '/api/backup', {'action': 'restore', 'name': name, 'parts': ['saves:620980']})
|
||||
self.assertEqual(status, 400, out) # not confirmed
|
||||
job = finished(ok('POST', '/api/backup', {'action': 'restore', 'name': name, 'parts': ['saves:620980'],
|
||||
'confirm': True}), 120)
|
||||
self.assertIsNone(job['error'], job)
|
||||
self.assertEqual(ssh(f"cat ~/'{SAVE}'"), 'level 7')
|
||||
|
||||
def test_bad_names_and_session(self):
|
||||
status, out, _ = api('POST', '/api/backup', {'action': 'plan', 'name': '../../etc'})
|
||||
self.assertEqual(status, 400, out)
|
||||
state = ok('POST', '/api/backup', {'action': 'session'})
|
||||
self.assertIn(state['mode'], ('game', 'desktop', 'unknown'))
|
||||
self.assertIsNone(state['selector']) # the fake has no session switch command
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,450 @@
|
||||
"""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')
|
||||
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')
|
||||
|
||||
|
||||
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': 'unknown', 'selector': 'steamos-session-select'}), \
|
||||
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()
|
||||
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()
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Backup and recovery kit: copy game saves, settings and Android app data off the Frame,
|
||||
put them back after a factory reset, and get out of Desktop Mode. Python stdlib only.
|
||||
|
||||
A backup is a folder in ~/Documents/Frame Control Backups/<YYYY-MM-DD-HHMMSS>/ on this
|
||||
computer: one tar.gz per part (saves/, settings/, android/) and manifest.json listing every
|
||||
file with its size and SHA-256, the archives' checksums and the headset's BUILD_ID. What the
|
||||
parts hold, and what is never copied, is decided on the headset by frame_backup_remote.py.
|
||||
Backing up only reads the headset. See docs/backup-and-recovery.md.
|
||||
|
||||
CLI: python3 ui/frame_backup.py backup | list | plan NAME | restore NAME PART... | session | game-mode
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
import frame_android as android
|
||||
import frame_host
|
||||
|
||||
REMOTE = Path(__file__).resolve().parent / 'frame_backup_remote.py'
|
||||
NAME = re.compile(r'^\d{4}-\d{2}-\d{2}-\d{6}$')
|
||||
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')
|
||||
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',
|
||||
'no_account': 'Sign in to Steam on the headset first',
|
||||
'reference': 'Kept for reference only; copy by hand if you need it',
|
||||
'refused': 'Not restored: the path is not safe to write',
|
||||
'running': 'Close the app first',
|
||||
'damaged': "The backup file doesn't match its checksum",
|
||||
'missing': 'The backup file is missing',
|
||||
'empty': 'Nothing to restore',
|
||||
'unknown': 'Not something this version of Frame Control can restore',
|
||||
}
|
||||
|
||||
|
||||
class BackupError(android.FrameError):
|
||||
pass
|
||||
|
||||
|
||||
def backup_root():
|
||||
return Path(os.environ.get('FRAME_BACKUP_DIR') or Path.home() / 'Documents' / 'Frame Control Backups')
|
||||
|
||||
|
||||
def backup_path(name):
|
||||
"""The folder for a backup name; refuses anything that isn't one of ours."""
|
||||
if not isinstance(name, str) or not NAME.fullmatch(name):
|
||||
raise BackupError('not a backup name')
|
||||
root = backup_root().resolve()
|
||||
path = (root / name).resolve()
|
||||
if path.parent != root:
|
||||
raise BackupError('not a backup name')
|
||||
return path
|
||||
|
||||
|
||||
def archive_name(part):
|
||||
kind, _, rest = part.partition(':')
|
||||
return '%s/%s.tar.gz' % (kind, rest if kind == 'android' else part.replace(':', '-'))
|
||||
|
||||
|
||||
def remote_command(cmd, *args):
|
||||
return shlex.join(['python3', '-c', REMOTE.read_text(), cmd, *args])
|
||||
|
||||
|
||||
def _ssh(command, src=None, dst=None, timeout=1800):
|
||||
try:
|
||||
# src: bytes, or a file to stream (an archive can be larger than memory).
|
||||
feed = {'input': src} if isinstance(src, bytes) else {
|
||||
'stdin': src if src is not None else subprocess.DEVNULL}
|
||||
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command], **feed,
|
||||
stdout=dst if dst is not None else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise BackupError('the headset took too long to answer')
|
||||
except OSError as error:
|
||||
raise BackupError('could not reach the headset: ' + str(error))
|
||||
if result.returncode:
|
||||
raise BackupError(result.stderr.decode(errors='replace').strip()[-600:] or
|
||||
'the headset command failed')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def remote_json(cmd, *args, src=None, timeout=120):
|
||||
out = _ssh(remote_command(cmd, *args), src=src, timeout=timeout)
|
||||
try:
|
||||
return json.loads(out)
|
||||
except (ValueError, TypeError):
|
||||
raise BackupError('unexpected answer from the headset')
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(str(path), 'rb') as src:
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def inspect_archive(path):
|
||||
"""Every file in one of our part archives, with its size and SHA-256; refuses anything else."""
|
||||
files, names = [], set()
|
||||
try:
|
||||
with tarfile.open(str(path), 'r:gz') as archive:
|
||||
for member in archive:
|
||||
name = member.name
|
||||
parts = name.split('/')
|
||||
if (not member.isfile() or name.startswith('/') or '\\' in name or name in names
|
||||
or any(p in ('', '.', '..') for p in parts)):
|
||||
raise BackupError('backup archive has an unsafe entry: %r' % name[:200])
|
||||
names.add(name)
|
||||
digest = hashlib.sha256()
|
||||
src = archive.extractfile(member)
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
files.append({'path': name, 'bytes': member.size, 'sha256': digest.hexdigest()})
|
||||
except (OSError, EOFError, tarfile.TarError) as error:
|
||||
raise BackupError('could not read the backup archive: ' + str(error))
|
||||
return files
|
||||
|
||||
|
||||
def preview():
|
||||
"""What a backup would hold, before making one."""
|
||||
plan = remote_json('plan')
|
||||
apps, error = _android_apps()
|
||||
return {'build': plan.get('build'), 'version': plan.get('version'), 'parts': plan.get('parts', []),
|
||||
'android': apps, 'android_error': error, 'excluded': plan.get('excluded', []), 'folder': str(backup_root())}
|
||||
|
||||
|
||||
def _android_apps():
|
||||
"""(installed Android apps, None) or ([], why they couldn't be listed)."""
|
||||
try:
|
||||
return [{'package': a['package'], 'label': a.get('label') or a['package'],
|
||||
'running': bool(a.get('running'))} for a in android.list_apps()], None
|
||||
except android.FrameError as error:
|
||||
return [], str(error)
|
||||
|
||||
|
||||
def backup(report=lambda stage, percent=None: None, kinds=KINDS, headset=None):
|
||||
kinds = [k for k in KINDS if k in kinds]
|
||||
if not kinds:
|
||||
raise BackupError('choose something to back up')
|
||||
report('Reading what is on the headset', 1)
|
||||
plan = remote_json('plan')
|
||||
parts = [p for p in plan.get('parts', []) if p.get('kind') in kinds and PART.fullmatch(str(p.get('id')))]
|
||||
apps, error = _android_apps() if 'android' in kinds else ([], None)
|
||||
root = backup_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
name = time.strftime('%Y-%m-%d-%H%M%S')
|
||||
final = backup_path(name)
|
||||
if final.exists():
|
||||
raise BackupError('a backup was started this second already; try again')
|
||||
# Built under a hidden name and renamed when complete, so a cut-off backup is never listed.
|
||||
staging = root / ('.' + name + '.partial')
|
||||
staging.mkdir()
|
||||
entries = []
|
||||
skipped = [{'id': 'android', 'label': 'Android apps', 'reason': error}] if error else []
|
||||
total = len(parts) + len(apps) or 1
|
||||
try:
|
||||
for i, part in enumerate(parts):
|
||||
report('Copying ' + part['label'], 2 + 90 * i / total)
|
||||
rel = archive_name(part['id'])
|
||||
dest = staging / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(str(dest), 'wb') as dst:
|
||||
_ssh(remote_command('pack', part['id']), dst=dst)
|
||||
files = inspect_archive(dest)
|
||||
entries.append({'id': part['id'], 'kind': part['kind'], 'label': part['label'],
|
||||
'appid': part.get('appid'), 'archive': rel, 'sha256': _sha256(dest),
|
||||
'archive_bytes': dest.stat().st_size, 'files': files,
|
||||
'bytes': sum(f['bytes'] for f in files)})
|
||||
skipped += [{'id': part['id'], 'label': part['label'], 'path': s['path'], 'reason': s['reason']}
|
||||
for s in part.get('skipped', [])]
|
||||
for j, app in enumerate(apps):
|
||||
report('Copying ' + app['label'] + ' (Android app data)', 2 + 90 * (len(parts) + j) / total)
|
||||
part_id = 'android:' + app['package']
|
||||
if app['running']:
|
||||
skipped.append({'id': part_id, 'label': app['label'], 'reason': 'It was open; close it and back up again'})
|
||||
continue
|
||||
rel = archive_name(part_id)
|
||||
dest = staging / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
result = android.backup_data(app['package'], dest)
|
||||
except android.FrameError as error:
|
||||
skipped.append({'id': part_id, 'label': app['label'], 'reason': str(error)})
|
||||
continue
|
||||
entries.append({'id': part_id, 'kind': 'android', 'label': app['label'] + ' (Android app data)',
|
||||
'package': app['package'], 'instance': result.get('instance'), 'archive': rel,
|
||||
'sha256': result['sha256'], 'archive_bytes': dest.stat().st_size,
|
||||
'files': result.get('files'), 'bytes': result.get('bytes')})
|
||||
report('Writing the manifest', 95)
|
||||
manifest = {'format': FORMAT, 'app': 'Frame Control', 'name': name,
|
||||
'created': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
'headset': {'name': headset or android.FRAME, 'build': plan.get('build'),
|
||||
'version': plan.get('version')},
|
||||
'parts': entries, 'skipped': skipped, 'excluded': plan.get('excluded', [])}
|
||||
(staging / 'manifest.json').write_text(json.dumps(manifest, indent=1))
|
||||
os.rename(str(staging), str(final))
|
||||
except BaseException:
|
||||
shutil.rmtree(str(staging), ignore_errors=True)
|
||||
raise
|
||||
size = sum(e['archive_bytes'] for e in entries)
|
||||
message = 'Backed up %d part%s (%s) to %s' % (len(entries), '' if len(entries) == 1 else 's',
|
||||
human(size), final)
|
||||
if skipped:
|
||||
message += '; %d skipped' % len(skipped)
|
||||
return {'message': message, 'name': name, 'path': str(final), 'parts': len(entries),
|
||||
'bytes': size, 'skipped': skipped}
|
||||
|
||||
|
||||
def human(n):
|
||||
for unit in ('bytes', 'KB', 'MB', 'GB'):
|
||||
if n < 1000 or unit == 'GB':
|
||||
return ('%d %s' % (n, unit)) if unit == 'bytes' else '%.1f %s' % (n, unit)
|
||||
n /= 1000.0
|
||||
|
||||
|
||||
def read_manifest(name):
|
||||
path = backup_path(name)
|
||||
try:
|
||||
manifest = json.loads((path / 'manifest.json').read_text())
|
||||
except (OSError, ValueError):
|
||||
raise BackupError('that backup has no readable manifest')
|
||||
if not isinstance(manifest, dict) or manifest.get('format') != FORMAT or \
|
||||
not isinstance(manifest.get('parts'), list):
|
||||
raise BackupError('that backup was made by a newer or unknown version')
|
||||
for part in manifest['parts']:
|
||||
if (not isinstance(part, dict) or not PART.fullmatch(str(part.get('id'))) or
|
||||
not ARCHIVE.fullmatch(str(part.get('archive'))) or part['archive'] != archive_name(part['id'])):
|
||||
raise BackupError('that backup manifest lists something unexpected')
|
||||
return path, manifest
|
||||
|
||||
|
||||
def list_backups():
|
||||
root = backup_root()
|
||||
out = []
|
||||
try:
|
||||
names = sorted((p.name for p in root.iterdir() if NAME.fullmatch(p.name) and p.is_dir()), reverse=True)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
try:
|
||||
_, m = read_manifest(name)
|
||||
except BackupError as error:
|
||||
out.append({'name': name, 'error': str(error)})
|
||||
continue
|
||||
out.append({'name': name, 'created': m.get('created'), 'build': (m.get('headset') or {}).get('build'),
|
||||
'headset': (m.get('headset') or {}).get('name'), 'parts': len(m['parts']),
|
||||
'bytes': sum(p.get('archive_bytes') or 0 for p in m['parts'])})
|
||||
return {'backups': out, 'folder': str(root)}
|
||||
|
||||
|
||||
def restore_plan(name):
|
||||
"""Each part of a backup and whether it can go back now; reads only."""
|
||||
path, manifest = read_manifest(name)
|
||||
plans, check = {}, []
|
||||
for part in manifest['parts']:
|
||||
archive = path / part['archive']
|
||||
state = None
|
||||
if not archive.is_file():
|
||||
state = 'missing'
|
||||
elif _sha256(archive) != part.get('sha256'):
|
||||
state = 'damaged'
|
||||
plans[part['id']] = {'id': part['id'], 'kind': part.get('kind'), 'label': part.get('label'),
|
||||
'bytes': part.get('bytes'), 'status': state}
|
||||
if state is None and part['kind'] != 'android':
|
||||
check.append({'id': part['id'], 'files': [f['path'] for f in part.get('files') or []]})
|
||||
if check:
|
||||
answer = remote_json('check', src=json.dumps({'parts': check}).encode())
|
||||
for result in answer.get('parts', []):
|
||||
plan = plans.get(result.get('id'))
|
||||
if plan:
|
||||
plan.update(status=result.get('status'), replace=result.get('replace', 0),
|
||||
new=result.get('new', 0), held=result.get('held', {}))
|
||||
android_parts = [p for p in plans.values() if p['kind'] == 'android' and p['status'] is None]
|
||||
if android_parts:
|
||||
installed = {a['package']: a for a in android.list_apps()}
|
||||
for plan in android_parts:
|
||||
app = installed.get(plan['id'].split(':', 1)[1])
|
||||
plan['status'] = 'not_installed' if not app else 'running' if app.get('running') else 'ok'
|
||||
if plan['status'] == 'not_installed':
|
||||
plan['reason'] = 'Install the app first, then restore its data'
|
||||
elif plan['status'] == 'ok':
|
||||
plan['replace'] = 1 # the app's whole private data folder, kept beside it
|
||||
for plan in plans.values():
|
||||
plan.setdefault('reason', REASONS.get(plan['status']) if plan['status'] != 'ok' else None)
|
||||
return {'name': name, 'headset': manifest.get('headset'), 'parts': list(plans.values())}
|
||||
|
||||
|
||||
def restore(name, ids, confirmed=False, report=lambda stage, percent=None: None):
|
||||
"""Restore the chosen parts that can go back now; the rest are skipped, not failed."""
|
||||
if not isinstance(ids, list) or not ids or not all(isinstance(i, str) and PART.fullmatch(i) for i in ids):
|
||||
raise BackupError('choose what to restore')
|
||||
if confirmed is not True:
|
||||
raise BackupError('confirm the restore first: it replaces files on the headset')
|
||||
path, manifest = read_manifest(name)
|
||||
by_id = {p['id']: p for p in manifest['parts']}
|
||||
plan = {p['id']: p for p in restore_plan(name)['parts']}
|
||||
stamp = time.strftime('%Y%m%d-%H%M%S')
|
||||
done, skipped = [], []
|
||||
for i, part_id in enumerate(ids):
|
||||
part, state = by_id.get(part_id), plan.get(part_id)
|
||||
if not part or not state:
|
||||
skipped.append({'id': part_id, 'reason': 'Not in this backup'})
|
||||
continue
|
||||
if state['status'] != 'ok':
|
||||
skipped.append({'id': part_id, 'label': state['label'], 'reason': state.get('reason') or state['status']})
|
||||
continue
|
||||
report('Restoring ' + state['label'], 5 + 90 * i / len(ids))
|
||||
archive = path / part['archive']
|
||||
try:
|
||||
if part['kind'] == 'android':
|
||||
result = android.restore_data(part_id.split(':', 1)[1], str(archive))
|
||||
else:
|
||||
with open(str(archive), 'rb') as src:
|
||||
result = remote_json('restore', part_id, stamp, src=src, timeout=1800)
|
||||
except android.FrameError as error:
|
||||
skipped.append({'id': part_id, 'label': state['label'], 'reason': str(error)})
|
||||
continue
|
||||
done.append(dict(result, id=part_id, label=state['label']))
|
||||
message = 'Restored %d part%s' % (len(done), '' if len(done) == 1 else 's')
|
||||
if skipped:
|
||||
message += ', skipped %d' % len(skipped)
|
||||
return {'message': message, 'restored': done, 'skipped': skipped}
|
||||
|
||||
|
||||
# ---- Game Mode ---------------------------------------------------------------
|
||||
|
||||
def session_status():
|
||||
return remote_json('session', timeout=30)
|
||||
|
||||
|
||||
def session_command(selector):
|
||||
"""The switch to Game Mode, exactly as Valve's devkit tools run it."""
|
||||
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'])
|
||||
|
||||
|
||||
def return_to_game_mode(confirmed=False):
|
||||
if confirmed is not True:
|
||||
raise BackupError('confirm first: switching closes the desktop and any apps open in it')
|
||||
state = session_status()
|
||||
if state.get('mode') == 'game':
|
||||
return {'message': 'The headset is already in Game Mode. The desktop you see is a panel: '
|
||||
'close it from the Steam dashboard.', 'state': state, 'switched': False}
|
||||
if state.get('mode') != 'desktop':
|
||||
# Only a desktop session seen running is switched: the switch restarts the session,
|
||||
# which would close whatever is running in Game Mode.
|
||||
raise BackupError("couldn't tell that the headset is in Desktop Mode, so nothing was changed. "
|
||||
'Use the manual steps in the guide if it is stuck.')
|
||||
command = session_command(state.get('selector'))
|
||||
try:
|
||||
# The session restarts under it, so the command may not get to answer.
|
||||
_ssh(command, timeout=60)
|
||||
except BackupError as error:
|
||||
if 'took too long' not in str(error):
|
||||
raise BackupError('the switch was refused: %s. Use the manual steps in the guide.' % error)
|
||||
return {'message': 'Asked the headset to return to Game Mode; it restarts its session in a few seconds',
|
||||
'state': state, 'switched': True, 'command': command}
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
try:
|
||||
if cmd == 'backup':
|
||||
r = backup(lambda stage, percent=None: print(stage, file=sys.stderr))
|
||||
elif cmd == 'list':
|
||||
r = list_backups()
|
||||
elif cmd == 'plan' and len(args) == 1:
|
||||
r = restore_plan(args[0])
|
||||
elif cmd == 'restore' and len(args) >= 2:
|
||||
r = restore(args[0], args[1:], confirmed=True)
|
||||
elif cmd == 'session':
|
||||
r = session_status()
|
||||
elif cmd == 'game-mode':
|
||||
r = return_to_game_mode(confirmed=True)
|
||||
else:
|
||||
print(__doc__.strip().splitlines()[-1], file=sys.stderr)
|
||||
return 2
|
||||
except android.FrameError as error:
|
||||
print('error:', error, file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(r, indent=1))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,442 @@
|
||||
"""Backup and restore on the Frame: what to save, packing it and putting it back. Stdlib only.
|
||||
|
||||
Runs on the headset as `python3 -c <this file> <command> ...` (ui/frame_backup.py sends it),
|
||||
so stdin is free for the archive or file list. Commands:
|
||||
|
||||
plan JSON: the headset's build and every part worth saving, with sizes
|
||||
pack PART a tar.gz of that part's files on stdout (reads only)
|
||||
check stdin JSON {"parts": [{"id", "files": [paths]}]}: what a restore would do
|
||||
restore PART STAMP stdin tar.gz: write that part's restorable files, keeping replaced ones
|
||||
session JSON: Game Mode or Desktop Mode, and the switch command on this image
|
||||
|
||||
Paths are relative to the home folder, with '/' separators, everywhere: in archives, in the
|
||||
manifest and in `check`. Nothing here deletes a file. `restore` replaces only files named in
|
||||
its archive, and first copies each one it replaces under PREVIOUS/<stamp>/.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
# FRAME_BACKUP_HOME: the tests point this at a folder laid out like the Frame's home.
|
||||
HOME = Path(os.environ.get('FRAME_BACKUP_HOME') or Path.home())
|
||||
STEAM = '.local/share/Steam'
|
||||
COMPAT = STEAM + '/steamapps/compatdata'
|
||||
PREFIX_USER = 'pfx/drive_c/users/steamuser'
|
||||
# Where Windows games keep saves inside a Proton prefix. Steam Cloud covers many games, not all.
|
||||
SAVE_DIRS = ('AppData/Roaming', 'AppData/Local', 'AppData/LocalLow', 'Documents', 'Saved Games')
|
||||
# Wine's and Windows' own folders inside those: caches, not saves.
|
||||
SAVE_SKIP = ('AppData/Local/Temp', 'AppData/Local/Microsoft', 'AppData/Roaming/Microsoft',
|
||||
'AppData/Local/D3DSCache', 'AppData/Local/NVIDIA', 'AppData/Local/CrashDumps',
|
||||
'AppData/LocalLow/Microsoft')
|
||||
# Native Linux games: the engines' standard save folders.
|
||||
LINUX_SAVES = ('.config/unity3d', '.local/share/godot/app_userdata')
|
||||
# Steam settings. Controller layouts go back; the rest is kept so you can look things up.
|
||||
STEAM_RESTORE = ('241100/remote/controller_config',) # under userdata/<account>/
|
||||
STEAM_REFERENCE = ('config/localconfig.vdf', 'config/shortcuts.vdf', 'config/grid')
|
||||
STEAM_CONTROLLER = STEAM + '/steamapps/common/Steam Controller Configs' # <account>/config/
|
||||
# Desktop settings worth keeping. Steam rewrites steamvr.vrsettings while SteamVR runs, so
|
||||
# it's only for reference.
|
||||
SYSTEM_RESTORE = ('.config/kdeglobals', '.config/kwinrc', '.config/kxkbrc', '.config/konsolerc',
|
||||
'.config/dolphinrc')
|
||||
SYSTEM_REFERENCE = ('.config/openvr/config/steamvr.vrsettings',)
|
||||
PREVIOUS = '.local/share/frame-control/restore-previous'
|
||||
MAX_FILE = 2 * 1024 ** 3 # a bigger "save" is a cache or a video; listed as skipped
|
||||
MAX_PART = 20 * 1024 ** 3
|
||||
MAX_FILES = 100000
|
||||
SHORTCUT_IDS = 2 ** 31 # non-Steam shortcuts and Lepton instances use ids above this
|
||||
|
||||
# Never copied, whatever folder they turn up in. Checked on every path, packing and restoring.
|
||||
SECRET_NAMES = {'steam.token', 'loginusers.vdf', 'registry.vdf', 'config.vdf', 'secrets.env',
|
||||
'authorized_keys', 'known_hosts', 'credentials', 'credentials.json'}
|
||||
SECRET_DIRS = {'.ssh', '.gnupg', '.pki', 'keyrings', 'kwalletd', 'cliproxyapi', 'tailscale'}
|
||||
SECRET_SUFFIXES = ('.token', '.pem', '.key', '.kwl')
|
||||
EXCLUDED = [
|
||||
'Steam sign-in: steam.token, config/config.vdf, loginusers.vdf, registry.vdf, ssfn files',
|
||||
'Keys and passwords: ~/.ssh, ~/.gnupg, keyrings and KWallet, *.token/*.key/*.pem files, '
|
||||
'Tailscale and proxy settings (secrets.env)',
|
||||
'Games themselves, Proton and runtimes (reinstall them from Steam), shader caches, Steam '
|
||||
'Cloud folders under userdata/<account>/<appid>, screenshots (Tools → Capture & Live saves them)',
|
||||
'Android apps themselves and their shared storage (/sdcard); only private app data is saved',
|
||||
'Symbolic links, and single files over 2 GiB',
|
||||
]
|
||||
|
||||
|
||||
def is_secret(rel):
|
||||
parts = PurePosixPath(rel).parts
|
||||
name = parts[-1].lower() if parts else ''
|
||||
return (name in SECRET_NAMES or name.startswith('ssfn') or name.startswith('id_rsa') or
|
||||
name.startswith('id_ed25519') or name.startswith('id_ecdsa') or
|
||||
name.endswith(SECRET_SUFFIXES) or any(p.lower() in SECRET_DIRS for p in parts[:-1]))
|
||||
|
||||
|
||||
def safe_rel(rel):
|
||||
"""A relative '/' path with no '..', '.', empty or absolute parts."""
|
||||
if not isinstance(rel, str) or not rel or '\\' in rel or '\0' in rel or rel.startswith('/'):
|
||||
return False
|
||||
parts = rel.split('/')
|
||||
return all(p not in ('', '.', '..') for p in parts) and not re.match(r'^[A-Za-z]:', rel)
|
||||
|
||||
|
||||
def _under(rel, root):
|
||||
return rel == root or rel.startswith(root + '/')
|
||||
|
||||
|
||||
def _walk(rel_root, skip=()):
|
||||
"""Regular files under HOME/rel_root, never following links: [(rel, size)], [skipped]."""
|
||||
files, skipped = [], []
|
||||
base = HOME / rel_root
|
||||
if base.is_symlink() or not base.is_dir():
|
||||
if base.is_file() and not base.is_symlink():
|
||||
return [(rel_root, base.stat().st_size)], skipped
|
||||
return files, skipped
|
||||
for top, dirs, names in os.walk(str(base)):
|
||||
rel_top = PurePosixPath(rel_root, *Path(top).relative_to(base).parts).as_posix()
|
||||
dirs[:] = sorted(d for d in dirs if not os.path.islink(os.path.join(top, d)) and
|
||||
not any(_under(rel_top + '/' + d, s) for s in skip) and
|
||||
not is_secret(rel_top + '/' + d + '/x'))
|
||||
for name in sorted(names):
|
||||
path, rel = os.path.join(top, name), rel_top + '/' + name
|
||||
if os.path.islink(path) or is_secret(rel):
|
||||
continue
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
if st.st_size > MAX_FILE:
|
||||
skipped.append({'path': rel, 'reason': 'over 2 GiB'})
|
||||
continue
|
||||
files.append((rel, st.st_size))
|
||||
return files, skipped
|
||||
|
||||
|
||||
def _manifest_field(text, key):
|
||||
m = re.search(r'"%s"\s+"([^"]*)"' % key, text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def game_name(appid):
|
||||
try:
|
||||
text = (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).read_text(errors='replace')
|
||||
except OSError:
|
||||
return None
|
||||
return _manifest_field(text, 'name')
|
||||
|
||||
|
||||
def accounts():
|
||||
root = HOME / STEAM / 'userdata'
|
||||
try:
|
||||
return sorted(p.name for p in root.iterdir() if p.name.isdigit() and p.is_dir()
|
||||
and not p.is_symlink() and p.name != '0')
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _prefixes():
|
||||
try:
|
||||
return sorted((p.name for p in (HOME / COMPAT).iterdir()
|
||||
if p.name.isdigit() and (p / PREFIX_USER).is_dir()), key=int)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def part_roots(part):
|
||||
"""(roots to save, roots that a restore may write) for a part id, or None if unknown."""
|
||||
if part.startswith('saves:') and part[6:].isdigit():
|
||||
user = '%s/%s/%s' % (COMPAT, part[6:], PREFIX_USER)
|
||||
roots = tuple(user + '/' + d for d in SAVE_DIRS)
|
||||
return roots, roots
|
||||
if part == 'saves:linux':
|
||||
return LINUX_SAVES, LINUX_SAVES
|
||||
if part == 'settings:steam':
|
||||
save, restore = [], []
|
||||
for acct in accounts():
|
||||
base = '%s/userdata/%s/' % (STEAM, acct)
|
||||
restore += [base + r for r in STEAM_RESTORE] + ['%s/%s/config' % (STEAM_CONTROLLER, acct)]
|
||||
save += [base + r for r in STEAM_REFERENCE]
|
||||
return tuple(save + restore), tuple(restore)
|
||||
if part == 'settings:system':
|
||||
return SYSTEM_RESTORE + SYSTEM_REFERENCE, SYSTEM_RESTORE
|
||||
return None
|
||||
|
||||
|
||||
def restore_roots(part, rel):
|
||||
"""The restore roots for rel within part; the Steam ones don't depend on today's accounts."""
|
||||
if part == 'settings:steam':
|
||||
m = re.match(r'^(?:%s/userdata/(\d+)/|%s/(\d+)/)' % (re.escape(STEAM), re.escape(STEAM_CONTROLLER)), rel)
|
||||
acct = m and (m.group(1) or m.group(2))
|
||||
if not acct:
|
||||
return ()
|
||||
base = '%s/userdata/%s/' % (STEAM, acct)
|
||||
return tuple(base + r for r in STEAM_RESTORE) + ('%s/%s/config' % (STEAM_CONTROLLER, acct),)
|
||||
roots = part_roots(part)
|
||||
return roots[1] if roots else ()
|
||||
|
||||
|
||||
def part_files(part):
|
||||
roots = part_roots(part)
|
||||
if roots is None:
|
||||
raise ValueError('unknown backup part %r' % part)
|
||||
skip = ()
|
||||
if part.startswith('saves:') and part != 'saves:linux':
|
||||
skip = tuple('%s/%s/%s/%s' % (COMPAT, part[6:], PREFIX_USER, s) for s in SAVE_SKIP)
|
||||
files, skipped = [], []
|
||||
for root in roots[0]:
|
||||
f, s = _walk(root, skip)
|
||||
files += f
|
||||
skipped += s
|
||||
return files, skipped
|
||||
|
||||
|
||||
def part_label(part):
|
||||
if part == 'saves:linux':
|
||||
return 'Linux game saves (Unity and Godot games)'
|
||||
if part == 'settings:steam':
|
||||
return 'Steam settings and controller layouts'
|
||||
if part == 'settings:system':
|
||||
return 'Desktop and SteamVR settings'
|
||||
appid = part[6:]
|
||||
name = game_name(appid)
|
||||
if name:
|
||||
return name + ' saves'
|
||||
return ('Non-Steam game %s saves' if int(appid) >= SHORTCUT_IDS else 'Game %s saves') % appid
|
||||
|
||||
|
||||
def plan():
|
||||
parts = []
|
||||
ids = ['saves:' + a for a in _prefixes()] + ['saves:linux', 'settings:steam', 'settings:system']
|
||||
for part in ids:
|
||||
files, skipped = part_files(part)
|
||||
if not files:
|
||||
continue
|
||||
parts.append({'id': part, 'kind': part.split(':')[0], 'label': part_label(part),
|
||||
'files': len(files), 'bytes': sum(s for _, s in files), 'skipped': skipped,
|
||||
'appid': part[6:] if part[6:].isdigit() else None,
|
||||
'restorable': sum(1 for rel, _ in files
|
||||
if any(_under(rel, r) for r in restore_roots(part, rel)))})
|
||||
return {'build': os_release().get('BUILD_ID'), 'version': os_release().get('VERSION_ID'),
|
||||
'parts': parts, 'excluded': EXCLUDED}
|
||||
|
||||
|
||||
def os_release():
|
||||
out = {}
|
||||
try:
|
||||
for line in Path(os.environ.get('FRAME_BACKUP_OS_RELEASE') or '/etc/os-release').read_text().splitlines():
|
||||
k, _, v = line.partition('=')
|
||||
out[k.strip()] = v.strip().strip('"')
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def pack(part, output):
|
||||
files, _ = part_files(part)
|
||||
if len(files) > MAX_FILES or sum(s for _, s in files) > MAX_PART:
|
||||
raise ValueError('this part is too big to back up (over 100,000 files or 20 GiB)')
|
||||
with tarfile.open(fileobj=output, mode='w|gz') as archive:
|
||||
for rel, _ in files:
|
||||
path = HOME / rel
|
||||
try:
|
||||
with open(str(path), 'rb') as src:
|
||||
st = os.fstat(src.fileno())
|
||||
member = tarfile.TarInfo(rel)
|
||||
member.size, member.mtime, member.mode = st.st_size, int(st.st_mtime), st.st_mode & 0o755
|
||||
archive.addfile(member, src)
|
||||
except OSError:
|
||||
continue # gone since the walk (a game wrote it); the manifest lists what's in the archive
|
||||
|
||||
|
||||
def target_status(part, rel):
|
||||
"""None if rel may be restored now, else why not (a short code)."""
|
||||
if is_secret(rel) or not safe_rel(rel):
|
||||
return 'refused'
|
||||
if not any(_under(rel, r) for r in restore_roots(part, rel)):
|
||||
return 'reference'
|
||||
if part.startswith('saves:') and part != 'saves:linux':
|
||||
appid = part[6:]
|
||||
if int(appid) < SHORTCUT_IDS and \
|
||||
not (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).is_file():
|
||||
return 'not_installed'
|
||||
if not (HOME / COMPAT / appid / PREFIX_USER).is_dir():
|
||||
return 'no_prefix'
|
||||
if part == 'settings:steam':
|
||||
acct = re.search(r'/(\d+)/', rel[len(STEAM):])
|
||||
if not acct or not (HOME / STEAM / 'userdata' / acct.group(1)).is_dir():
|
||||
return 'no_account'
|
||||
# A link anywhere on the way could send the write somewhere else.
|
||||
path = HOME
|
||||
for p in rel.split('/')[:-1]:
|
||||
path = path / p
|
||||
if path.is_symlink() or (path.exists() and not path.is_dir()):
|
||||
return 'refused'
|
||||
if (HOME / rel).is_symlink() or ((HOME / rel).exists() and not (HOME / rel).is_file()):
|
||||
return 'refused'
|
||||
return None
|
||||
|
||||
|
||||
def check(request):
|
||||
out = []
|
||||
for item in request.get('parts', []):
|
||||
part, files = item.get('id'), item.get('files') or []
|
||||
if not isinstance(part, str) or part_roots(part) is None:
|
||||
out.append({'id': part, 'status': 'unknown'})
|
||||
continue
|
||||
counts = {}
|
||||
replace = new = 0
|
||||
for rel in files:
|
||||
why = target_status(part, rel)
|
||||
if why:
|
||||
counts[why] = counts.get(why, 0) + 1
|
||||
elif (HOME / rel).exists():
|
||||
replace += 1
|
||||
else:
|
||||
new += 1
|
||||
status = 'ok' if replace + new else next(
|
||||
(c for c in ('not_installed', 'no_prefix', 'no_account', 'reference', 'refused') if c in counts), 'empty')
|
||||
out.append({'id': part, 'status': status, 'replace': replace, 'new': new, 'held': counts})
|
||||
return {'parts': out}
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(str(path), 'rb') as src:
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def restore(part, stamp, source):
|
||||
if part_roots(part) is None:
|
||||
raise ValueError('unknown backup part %r' % part)
|
||||
if not re.fullmatch(r'\d{8}-\d{6}', stamp):
|
||||
raise ValueError('bad restore stamp')
|
||||
# Spool first: everything is checked before anything is written.
|
||||
spool_dir = HOME / '.cache/frame-control'
|
||||
spool_dir.mkdir(parents=True, exist_ok=True)
|
||||
written, replaced, held = [], [], {}
|
||||
with tempfile.TemporaryDirectory(prefix='restore-', dir=str(spool_dir)) as tmp:
|
||||
spool = Path(tmp) / 'part.tar.gz'
|
||||
with open(str(spool), 'wb') as dst:
|
||||
shutil.copyfileobj(source, dst, 1024 * 1024)
|
||||
todo, names, total = [], set(), 0
|
||||
with tarfile.open(str(spool), 'r:gz') as archive:
|
||||
for member in archive:
|
||||
if not member.isfile() or not safe_rel(member.name) or member.name in names:
|
||||
raise ValueError('backup has an unsafe, duplicate or non-file entry: %r' % member.name[:200])
|
||||
names.add(member.name)
|
||||
total += member.size
|
||||
if len(names) > MAX_FILES or total > MAX_PART:
|
||||
raise ValueError('backup part is too big')
|
||||
why = target_status(part, member.name)
|
||||
if why:
|
||||
held[why] = held.get(why, 0) + 1
|
||||
else:
|
||||
todo.append(member.name)
|
||||
previous = HOME / PREVIOUS / stamp
|
||||
keep = set(todo)
|
||||
with tarfile.open(str(spool), 'r:gz') as archive:
|
||||
for member in archive:
|
||||
if member.name not in keep:
|
||||
continue
|
||||
target = HOME / member.name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
part_file = target.parent / ('.frame-restore-%d.part' % os.getpid())
|
||||
with archive.extractfile(member) as src, open(str(part_file), 'wb') as dst:
|
||||
shutil.copyfileobj(src, dst, 1024 * 1024)
|
||||
os.chmod(str(part_file), (member.mode & 0o755) | 0o600)
|
||||
os.utime(str(part_file), (member.mtime, member.mtime))
|
||||
if target.exists():
|
||||
if _sha256(target) == _sha256(part_file):
|
||||
os.unlink(str(part_file))
|
||||
held['same'] = held.get('same', 0) + 1
|
||||
continue
|
||||
keep_copy = previous / member.name
|
||||
keep_copy.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(target), str(keep_copy))
|
||||
replaced.append(member.name)
|
||||
os.replace(str(part_file), str(target))
|
||||
written.append(member.name)
|
||||
return {'id': part, 'written': len(written), 'replaced': len(replaced), 'held': held,
|
||||
'previous': (PREVIOUS + '/' + stamp) if replaced else None}
|
||||
|
||||
|
||||
# ---- Game Mode and Desktop Mode --------------------------------------------
|
||||
#
|
||||
# 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.
|
||||
|
||||
SDDM_CONFS = ('/etc/sddm.conf.d/zz-steamos-autologin.conf', '/etc/sddm.conf.d/steamos.conf')
|
||||
|
||||
|
||||
def _running(name, full=False):
|
||||
try:
|
||||
return subprocess.call(['pgrep', '-f' if full else '-x', name], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL) == 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def session():
|
||||
configured, conf = None, None
|
||||
for path in SDDM_CONFS:
|
||||
try:
|
||||
text = Path(path).read_text(errors='replace')
|
||||
except OSError:
|
||||
continue
|
||||
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)
|
||||
# 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:
|
||||
mode = 'game'
|
||||
elif plasma or (configured and 'plasma' in configured):
|
||||
mode = 'desktop'
|
||||
else:
|
||||
mode = 'unknown'
|
||||
return {'mode': mode, 'gamescope': gamescope, 'plasma': plasma, 'nested_desktop': nested,
|
||||
'configured': configured, 'config_file': conf, 'selector': selector}
|
||||
|
||||
|
||||
def main(argv):
|
||||
cmd, args = (argv[0] if argv else ''), argv[1:]
|
||||
try:
|
||||
if cmd == 'plan':
|
||||
print(json.dumps(plan()))
|
||||
elif cmd == 'pack' and len(args) == 1:
|
||||
out = sys.stdout.buffer
|
||||
pack(args[0], out)
|
||||
out.flush()
|
||||
elif cmd == 'check':
|
||||
print(json.dumps(check(json.load(sys.stdin))))
|
||||
elif cmd == 'restore' and len(args) == 2:
|
||||
print(json.dumps(restore(args[0], args[1], sys.stdin.buffer)))
|
||||
elif cmd == 'session':
|
||||
print(json.dumps(session()))
|
||||
else:
|
||||
sys.stderr.write('usage: plan | pack PART | check | restore PART STAMP | session\n')
|
||||
return 2
|
||||
except (OSError, ValueError, tarfile.TarError) as error:
|
||||
sys.stderr.write(str(error) + '\n')
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+172
-3
@@ -1420,7 +1420,7 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's windows and screens</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's windows and screens</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
@@ -1667,6 +1667,41 @@
|
||||
Saved spatial layouts are not available yet.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="backup" hidden>
|
||||
<div class="shelf-head"><h2>Back up the Frame</h2><span class="spacer"></span>
|
||||
<button class="small" id="backupFolder">Open backups folder</button></div>
|
||||
<p class="sub">Copies game saves, settings and Android app data from the headset to a dated folder on this computer.
|
||||
It only reads the headset. Games themselves aren't copied: reinstall them from Steam.</p>
|
||||
<div class="chips" id="backupKinds">
|
||||
<label><input type="checkbox" value="saves" checked> Game saves</label>
|
||||
<label><input type="checkbox" value="settings" checked> Steam and desktop settings</label>
|
||||
<label><input type="checkbox" value="android" checked> Android app data</label>
|
||||
</div>
|
||||
<div class="row" style="margin-top:12px"><button class="small" id="backupPreview">See what's included</button>
|
||||
<button class="small action" id="backupStart">Back up now</button></div>
|
||||
<div class="sub" id="backupProgress" role="status" aria-live="polite"></div>
|
||||
<div class="list" id="backupSummary"></div>
|
||||
<div class="hint">Never copied: Steam sign-in files (steam.token, config.vdf, loginusers.vdf), SSH keys,
|
||||
keyrings and other passwords. Backups aren't encrypted, and game folders can hold launcher logins, so keep them private.
|
||||
Close Android apps first: open ones are skipped.</div>
|
||||
|
||||
<div class="shelf-head" style="margin-top:20px"><h2>Restore</h2><span class="spacer"></span>
|
||||
<button class="small" id="backupRefresh">Refresh</button></div>
|
||||
<div class="list" id="backupList"><div class="sub">Open this tool to see your backups.</div></div>
|
||||
<div id="restorePlan"></div>
|
||||
<div class="hint">After a factory reset, sign in to Steam, reinstall your games and start each once, and reinstall
|
||||
your Android apps. Then restore. Parts that can't go back yet are skipped, not failed. Files a restore replaces are
|
||||
kept on the headset in ~/.local/share/frame-control/restore-previous.</div>
|
||||
|
||||
<div class="shelf-head" style="margin-top:20px"><h2>Stuck in Desktop Mode?</h2><span class="spacer"></span>
|
||||
<button class="small" id="sessionCheck">Check</button></div>
|
||||
<div class="sub" id="sessionState">Check which mode the headset is in.</div>
|
||||
<div class="row" style="margin-top:12px"><button class="small" id="sessionGame" disabled>Return to Game Mode</button></div>
|
||||
<div class="hint">Uses the same switch as Valve's developer tools (<code>steamos-session-select gamescope</code>).
|
||||
It closes the desktop and anything open in it. If the headset can't be reached, hold Power for 10 seconds to
|
||||
restart it; <a href="https://github.com/saphid/frame-control/blob/main/docs/backup-and-recovery.md#stuck-in-desktop-mode" target="_blank">more ways back</a>.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="macview" hidden>
|
||||
<div class="shelf-head"><h2 id="mvTitle">Mac in the headset</h2><span class="tag" id="mvTag" hidden>Experimental</span><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="mvRefresh">Refresh</button></div>
|
||||
@@ -5390,6 +5425,138 @@ $("panelList").onclick = e => {
|
||||
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
|
||||
};
|
||||
|
||||
// ---- Backup and recovery kit (ui/frame_backup.py) ----
|
||||
const BACKUP_KIND = {saves: "Game saves", settings: "Settings", android: "Android app data"};
|
||||
function sizeText(n) {
|
||||
if (n == null) return "";
|
||||
for (const unit of ["bytes", "KB", "MB", "GB"]) {
|
||||
if (n < 1000 || unit === "GB") return unit === "bytes" ? `${n} bytes` : `${n.toFixed(1)} ${unit}`;
|
||||
n /= 1000;
|
||||
}
|
||||
}
|
||||
const backupKinds = () => [...$("backupKinds").querySelectorAll("input:checked")].map(i => i.value);
|
||||
let backupSeq = 0, backupBusy = false;
|
||||
async function loadBackups() {
|
||||
const seq = ++backupSeq;
|
||||
try {
|
||||
const r = await api("/api/backup", {action: "list"});
|
||||
if (seq !== backupSeq) return;
|
||||
$("backupList").innerHTML = r.backups.length ? r.backups.map(b => `<div class="item"><div class="grow">
|
||||
<div class="t">${esc(b.name)}</div><div class="s">${b.error ? esc(b.error) :
|
||||
esc([b.headset, b.build && `SteamOS build ${b.build}`, `${b.parts} part${b.parts === 1 ? "" : "s"}`, sizeText(b.bytes)].filter(Boolean).join(" · "))}</div></div>
|
||||
${b.error ? "" : `<button class="small" data-backup-plan="${esc(b.name)}">Restore…</button>`}</div>`).join("")
|
||||
: `<div class="sub">No backups in ${esc(r.folder)} yet.</div>`;
|
||||
} catch (e) { if (seq === backupSeq) failed($("backupList"), e); }
|
||||
}
|
||||
function backupPartRow(p, restore) {
|
||||
const detail = restore
|
||||
? (p.status === "ok" ? [p.replace ? `replaces ${p.replace} file${p.replace === 1 ? "" : "s"}` : "", p.new ? `${p.new} new` : "",
|
||||
p.held && p.held.reference ? `${p.held.reference} kept for reference` : ""].filter(Boolean).join(", ") || "ready"
|
||||
: p.reason || p.status)
|
||||
: [`${p.files} file${p.files === 1 ? "" : "s"}`, sizeText(p.bytes), p.skipped && p.skipped.length ? `${p.skipped.length} too big, skipped` : ""].filter(Boolean).join(" · ");
|
||||
const box = restore ? `<input type="checkbox" data-restore-part="${esc(p.id)}" ${p.status === "ok" ? "checked" : "disabled"} aria-label="Restore ${esc(p.label)}"> ` : "";
|
||||
return `<div class="item"><div class="grow"><div class="t">${box}${esc(p.label)}</div><div class="s">${esc(detail)}</div></div></div>`;
|
||||
}
|
||||
$("backupPreview").onclick = e => act("Check what a backup includes", async () => {
|
||||
const r = await api("/api/backup", {action: "preview"}), kinds = backupKinds();
|
||||
const rows = r.parts.filter(p => kinds.includes(p.kind)).map(p => backupPartRow(p, false));
|
||||
if (kinds.includes("android")) rows.push(...r.android.map(a => `<div class="item"><div class="grow"><div class="t">${esc(a.label)} (Android app data)</div>
|
||||
<div class="s">${a.running ? "Open now: close it or it's skipped" : "Saved while closed"}</div></div></div>`));
|
||||
if (r.android_error && kinds.includes("android")) rows.push(`<div class="sub">Android apps couldn't be listed: ${esc(r.android_error)}</div>`);
|
||||
$("backupSummary").innerHTML = (rows.join("") || '<div class="sub">Nothing to back up for that choice.</div>') +
|
||||
`<details><summary>Never copied</summary><ul>${r.excluded.map(x => `<li>${esc(x)}</li>`).join("")}</ul></details>`;
|
||||
return {message: `SteamOS build ${r.build || "unknown"}: ${rows.length} part${rows.length === 1 ? "" : "s"} to back up`};
|
||||
}, e.currentTarget);
|
||||
// Like runJob, without counting as an install in the header.
|
||||
async function backupJob(label, start) {
|
||||
log(label + "…");
|
||||
try {
|
||||
const { job } = await start();
|
||||
for (let misses = 0; ;) {
|
||||
await new Promise(ok => setTimeout(ok, 1500));
|
||||
let j;
|
||||
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
||||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||||
backupProgress(j);
|
||||
if (!j.done) continue;
|
||||
if (j.error) throw new Error(j.error);
|
||||
log(j.message, "ok"); toast(j.message);
|
||||
return j.result || {};
|
||||
}
|
||||
} catch (e) {
|
||||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||||
}
|
||||
}
|
||||
function backupProgress(j) {
|
||||
$("backupProgress").textContent = j.done ? "" : `${j.stage || "Starting"}${j.percent != null ? ` · ${Math.round(j.percent)}%` : ""}`;
|
||||
}
|
||||
$("backupStart").onclick = async e => {
|
||||
const kinds = backupKinds();
|
||||
if (!kinds.length) return toast("Choose something to back up", true);
|
||||
if (backupBusy) return;
|
||||
backupBusy = true; e.currentTarget.disabled = true;
|
||||
try {
|
||||
const r = await backupJob("Back up the Frame", () => api("/api/backup", {action: "backup", kinds}));
|
||||
if (r) {
|
||||
$("backupSummary").innerHTML = `<div class="sub">${esc(r.message)}</div>` + (r.skipped || []).map(s =>
|
||||
`<div class="item"><div class="grow"><div class="t">${esc(s.label || s.id)}${s.path ? `: ${esc(s.path)}` : ""}</div>
|
||||
<div class="s">Skipped: ${esc(s.reason)}</div></div></div>`).join("");
|
||||
loadBackups();
|
||||
}
|
||||
} finally { backupBusy = false; $("backupStart").disabled = false; $("backupProgress").textContent = ""; }
|
||||
};
|
||||
$("backupFolder").onclick = e => act("Open backups folder", () => api("/api/backup", {action: "open"}), e.currentTarget);
|
||||
$("backupRefresh").onclick = loadBackups;
|
||||
let restoreName = null;
|
||||
$("backupList").onclick = async e => {
|
||||
const b = e.target.closest("[data-backup-plan]"); if (!b) return;
|
||||
restoreName = b.dataset.backupPlan;
|
||||
$("restorePlan").innerHTML = '<div class="sub">Checking what can go back…</div>';
|
||||
try {
|
||||
const r = await api("/api/backup", {action: "plan", name: restoreName});
|
||||
if (r.name !== restoreName) return;
|
||||
$("restorePlan").innerHTML = `<h3>Restore ${esc(r.name)}</h3>` + r.parts.map(p => backupPartRow(p, true)).join("") +
|
||||
`<div class="row" style="margin-top:12px"><button class="small danger" id="restoreStart">Restore selected</button>
|
||||
<button class="small" data-backup-open="${esc(r.name)}">Show files</button></div>`;
|
||||
$("restorePlan").plan = r;
|
||||
} catch (err) { failed($("restorePlan"), err); }
|
||||
};
|
||||
$("restorePlan").onclick = async e => {
|
||||
const open = e.target.closest("[data-backup-open]");
|
||||
if (open) return act("Show backup files", () => api("/api/backup", {action: "open", name: open.dataset.backupOpen}), open);
|
||||
if (!e.target.closest("#restoreStart") || backupBusy) return;
|
||||
const plan = $("restorePlan").plan;
|
||||
const parts = [...$("restorePlan").querySelectorAll("[data-restore-part]:checked")].map(i => i.dataset.restorePart);
|
||||
if (!plan || !parts.length) return toast("Choose something to restore", true);
|
||||
const chosen = plan.parts.filter(p => parts.includes(p.id));
|
||||
const replacing = chosen.reduce((n, p) => n + (p.replace || 0), 0);
|
||||
if (!confirm(`Restore ${chosen.length} part${chosen.length === 1 ? "" : "s"} from ${plan.name}?\n\n` +
|
||||
`${replacing ? `This replaces ${replacing} file${replacing === 1 ? "" : "s"} or app data folder${replacing === 1 ? "" : "s"} on the headset. ` : ""}` +
|
||||
"Replaced files are kept on the headset. Close the games and apps you're restoring first.")) return;
|
||||
backupBusy = true;
|
||||
try {
|
||||
const r = await backupJob("Restore from backup", () => api("/api/backup", {action: "restore", name: plan.name, parts, confirm: true}));
|
||||
if (r) $("restorePlan").innerHTML = `<div class="sub">${esc(r.message)}</div>` + (r.skipped || []).map(s =>
|
||||
`<div class="item"><div class="grow"><div class="t">${esc(s.label || s.id)}</div><div class="s">Skipped: ${esc(s.reason)}</div></div></div>`).join("");
|
||||
} finally { backupBusy = false; $("backupProgress").textContent = ""; }
|
||||
};
|
||||
const SESSION_TEXT = {game: "Game Mode: the headset is running normally. A desktop you see is a panel; close it from the Steam dashboard.",
|
||||
desktop: "Desktop Mode: the headset started the full desktop instead of Game Mode.",
|
||||
unknown: "Couldn't tell which mode the headset is in."};
|
||||
$("sessionCheck").onclick = async e => {
|
||||
e.currentTarget.disabled = true;
|
||||
try {
|
||||
const s = await api("/api/backup", {action: "session"});
|
||||
$("sessionState").textContent = SESSION_TEXT[s.mode] + (s.selector ? "" : " This SteamOS build has no session switch command; use the manual steps.");
|
||||
$("sessionGame").disabled = s.mode !== "desktop" || !s.selector;
|
||||
} catch (err) { failed($("sessionState"), err); }
|
||||
finally { $("sessionCheck").disabled = false; }
|
||||
};
|
||||
$("sessionGame").onclick = e => {
|
||||
if (!confirm("Return the headset to Game Mode?\n\nThis closes the desktop and any apps open in it, then starts Steam's Game Mode.")) return;
|
||||
act("Return to Game Mode", () => api("/api/backup", {action: "gamemode", confirm: true}), e.currentTarget);
|
||||
};
|
||||
|
||||
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
|
||||
let mvSeq = 0, mvTimer = 0, mvPcAsked = false;
|
||||
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
|
||||
@@ -5532,10 +5699,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools"};
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", backup:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", backup:"Backup & recovery"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
let page = "apps";
|
||||
// Reuse the existing live lists and form handlers in their new destinations.
|
||||
@@ -5641,6 +5808,7 @@ function showPage(e) {
|
||||
}
|
||||
if (tool === 'macview') loadMacView();
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'backup') loadBackups();
|
||||
if (tool === 'androidSettings') {
|
||||
$('repNew').hidden = false; $('androidSettings').prepend($('repNew')); loadDisplays();
|
||||
}
|
||||
@@ -7035,6 +7203,7 @@ api("/api/host").then(h => {
|
||||
$("macToolLink").hidden = h.mobile || h.os !== "macOS";
|
||||
$("pcToolLink").hidden = h.mobile || h.os !== "Windows";
|
||||
if (!h.mobile && h.os === "Windows") TOOL_TITLES.macview = "PC in the headset";
|
||||
$("backupToolLink").hidden = !!h.mobile; // backups are folders on a computer
|
||||
if (h.mobile) return;
|
||||
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
|
||||
watchConnection();
|
||||
|
||||
+69
-1
@@ -45,6 +45,7 @@ import frame_agent # noqa: E402
|
||||
import frame_assistant # noqa: E402
|
||||
import frame_android # noqa: E402
|
||||
import frame_android_import # noqa: E402
|
||||
import frame_backup # noqa: E402
|
||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
from apk_sources import fdroid as apk_fdroid # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
@@ -2696,6 +2697,73 @@ def panels_action(body):
|
||||
return result
|
||||
|
||||
|
||||
# ---- Backup and recovery kit (frame_backup.py) ------------------------------
|
||||
#
|
||||
# Backups are folders on this computer, so they run from the computer app only. One backup
|
||||
# or restore at a time: both are long jobs the page follows through /api/job.
|
||||
|
||||
_backup_lock = threading.Lock()
|
||||
|
||||
|
||||
def backup_action(body):
|
||||
action = body.get("action")
|
||||
if LOCAL:
|
||||
raise Failure("Backups are made from Frame Control on your computer", 400)
|
||||
if action in ("plan", "restore") or (action == "open" and body.get("name") is not None):
|
||||
try:
|
||||
frame_backup.backup_path(body.get("name")) # refused before anything reads or runs
|
||||
except frame_backup.BackupError as e:
|
||||
raise Failure(str(e), 400)
|
||||
if action in ("gamemode", "restore") and body.get("confirm") is not True:
|
||||
raise Failure("Confirm first: this changes the headset", 400)
|
||||
if action == "list":
|
||||
return frame_backup.list_backups()
|
||||
if action == "preview":
|
||||
return frame_backup.preview()
|
||||
if action == "plan":
|
||||
return frame_backup.restore_plan(body.get("name"))
|
||||
if action == "session":
|
||||
return frame_backup.session_status()
|
||||
if action == "gamemode":
|
||||
return frame_backup.return_to_game_mode(confirmed=True)
|
||||
if action == "open":
|
||||
folder = frame_backup.backup_root()
|
||||
if body.get("name") is not None:
|
||||
folder = frame_backup.backup_path(body.get("name"))
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
frame_host.open_path(folder)
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
return {"message": f"Opened {folder} in {frame_host.FILE_MANAGER}"}
|
||||
if action == "backup":
|
||||
kinds = body.get("kinds", list(frame_backup.KINDS))
|
||||
if not isinstance(kinds, list) or not kinds or any(k not in frame_backup.KINDS for k in kinds):
|
||||
raise Failure("Choose what to back up", 400)
|
||||
name = agent_target()["name"]
|
||||
work = lambda report: frame_backup.backup(report, kinds, headset=name) # noqa: E731
|
||||
label = "Back up the Frame"
|
||||
elif action == "restore":
|
||||
name, ids = body.get("name"), body.get("parts")
|
||||
if not isinstance(ids, list) or not ids or not all(
|
||||
isinstance(i, str) and frame_backup.PART.fullmatch(i) for i in ids):
|
||||
raise Failure("Choose what to restore", 400)
|
||||
work = lambda report: frame_backup.restore(name, ids, True, report) # noqa: E731
|
||||
label = "Restore from backup"
|
||||
else:
|
||||
raise Failure("Unknown backup action", 400)
|
||||
|
||||
def exclusive(report):
|
||||
if not _backup_lock.acquire(blocking=False):
|
||||
raise Failure("A backup or restore is already running", 409)
|
||||
try:
|
||||
return work(report)
|
||||
finally:
|
||||
_backup_lock.release()
|
||||
ensure_master()
|
||||
return start_job(label, exclusive, progress=True)
|
||||
|
||||
|
||||
# ---- Our Frame-side media player -----------------------------------------
|
||||
|
||||
_MEDIA_LOCK = threading.Lock()
|
||||
@@ -2934,7 +3002,7 @@ def source_manage(body):
|
||||
|
||||
|
||||
POST = {
|
||||
"/api/android/backup": android_backup,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
|
||||
Reference in new issue
Block a user