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