Compare commits

..
Author SHA1 Message Date
SAPHIDandClaude Opus 5.5 2b838ede9e Backup docs: what was tested on a real Frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:16:38 +00:00
SAPHID 310e778d00 Merge remote-tracking branch 'origin/main' into feat/backup-recovery-kit
# Conflicts:
#	ui/index.html
2026-10-24 00:13:26 +00:00
SAPHIDandClaude Opus 5.5 a909beb39e Return to Game Mode: recognise gamescope-wl, and switch only when Desktop Mode is seen
On a Frame (build 20261015.6141207) gamescope runs as gamescope-wl, so the session
check said unknown, and the button would have restarted a headset that was in
Game Mode. Game Mode now also counts gamescope-wl and start-gamescope-session, and
the switch runs only when Plasma is running without gamescope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:13:17 +00:00
Alex Southwell e2601965bd Merge pull request #120 from saphid/fix/record-stop
Recording: Stop actually stops ffmpeg on the Frame
2026-10-24 00:11:10 +00:00
SAPHID 96502e6e4d Merge remote-tracking branch 'origin/main' into fix/record-stop 2026-10-24 00:03:53 +00:00
Alex Southwell d5f26efc1a Merge pull request #119 from saphid/feat/auto-sync
Copy new screenshots and Steam clips to the computer automatically
2026-10-24 00:03:43 +00:00
SAPHIDandClaude Opus 5.5 eac9557286 Recording: stop ffmpeg with q on a FIFO, not SIGTERM, which SteamOS's ffmpeg ignores
On a Frame (SteamOS 0.5.6, build 20261015.6141207) Stop left ffmpeg recording:
ffmpeg n7.0 there catches SIGTERM and SIGINT and carries on. The recording shell
now gives ffmpeg a FIFO as stdin, types q into it on Stop or a dropped channel, and
SIGKILLs it if it's still there 10 s later. Tested end to end on that Frame: an
8 s 1080p H.264 + AAC .mp4 saved 1.3 s after Stop, nothing left on the Frame.

Also records the read-only charge-limit checks run on the same Frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:02:39 +00:00
Alex Southwell 4aa9f18fd1 Merge pull request #117 from saphid/feat/force-quit
Force quit a running game from Games and the Control Center
2026-10-23 23:47:42 +00:00
SAPHIDandClaude Opus 5.5 fa40e30f3c Force quit never lists or quits SteamVR: on the Frame it's the headset view, not a game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:38:14 +00:00
SAPHIDandClaude Opus 5.5 d7a887b98c Force quit's unit tests run on Windows too: no SIGKILL or getuid there
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:29:10 +00:00
SAPHIDandClaude Opus 5.5 30cbe1c901 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>
2026-10-23 23:28:35 +00:00
SAPHIDandClaude Opus 5.5 a5980d8f98 Force quit a running game from Games and the Control Center
Running now, at the top of Games, lists what's running on the Frame (Steam
games, sideloaded titles, non-Steam shortcuts, Android apps), with Force quit
on each, on a game's library tile and in a sideloaded title's menu. The
Control Center gets a Force quit a game card. Each asks first: unsaved
progress will be lost. Nothing running says so.

ui/frame_running.py finds games by Steam's `reaper SteamLaunch AppId=<id>`
process and its tree, never by name. It asks Steam to quit
(SteamClient.Apps.TerminateApp), then sends SIGTERM and SIGKILL to the tree,
skipping init, the SSH session, other users and Steam, gamescope, SteamVR,
sshd and the session. Android apps also get the Apps tab's container stop.

The fake Frame runs games under a stand-in reaper and lists them in
SteamUIStore.RunningApps, so tests/e2e/test_running.py force quits real
process trees. Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:17:59 +00:00
28 changed files with 3035 additions and 52 deletions

No files matched your search

+9 -1
View File
@@ -50,7 +50,7 @@ Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and w
<td valign="top">
**🎮 Steam games**<br>
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
</td>
<td valign="top">
@@ -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>
+167
View File
@@ -0,0 +1,167 @@
# Backup and recovery
**Tools → Backup & recovery** copies what a factory reset would lose from the
Steam Frame to your computer, puts it back afterwards, and gets the headset out
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
app or the Frame itself.
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
with no sign-in files or keys in the archives. Restoring one game's saves on top
of identical files wrote nothing and left nothing behind. The session check
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
headset: a restore after a real factory reset, a restore that replaces files,
Android data restore, and the switch out of a real Desktop Mode session. Unit
tests and the fake Frame cover those (see [Verification](#verification)).
## Back up
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
(all three by default), then **Back up now**. **See what's included** lists each
part and its size first. Backing up only reads the headset.
Each backup is a folder named after the time it started:
```
~/Documents/Frame Control Backups/2026-10-23-142530/
manifest.json
saves/saves-1234.tar.gz one per game
saves/saves-linux.tar.gz
settings/settings-steam.tar.gz
settings/settings-system.tar.gz
android/org.example.game.tar.gz one per Android app
```
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
file with its size and SHA-256, and each archive's SHA-256. It also lists what
was skipped and why. A backup is written under a hidden `.…partial` name and
renamed when complete, so a cut-off backup never shows up as one.
### What's saved
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|---|---|---|
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
"Kept for reference" files are in the backup but not put back automatically:
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
point at apps that aren't installed after a reset. Open the backup folder to
copy one by hand if you need it.
### What's never copied
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
`registry.vdf` and `ssfn*` files.
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
(`secrets.env`). These are refused by name wherever they turn up, including
inside a game's folder, and a restore refuses them too.
- Games, Proton and the Steam runtimes: reinstall them from Steam.
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
screenshots (**Tools → Capture & Live** saves those to Pictures).
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
- Symbolic links (never followed), and single files over 2 GiB.
Backups aren't encrypted. Game folders can still hold a launcher's login, and
Android app data can hold an app's, so keep backups private.
Android apps that are open are skipped (the helper needs the app closed); close
them and back up again.
## Restore after a factory reset
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
Steam with the same account.
2. Reinstall your games, and **start each once** so Proton creates its folders.
3. Reinstall your Android apps from Frame Control.
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
each part against the headset and says what it would do: how many files it
replaces and how many are new.
5. Tick the parts to restore and **Restore selected**. It asks before replacing
anything.
Parts that can't go back yet are skipped, not failed, with the reason:
| Reason | What to do |
|---|---|
| Install the game first | Install it from Steam, then restore |
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
| Sign in to Steam first | Controller layouts go under your account's folder |
| Install the app first / close the app first | Android app data needs the app installed and closed |
| The backup file doesn't match its checksum | The archive changed since it was made |
A restore writes only the files in the parts you chose, and only inside that
part's folders: it never deletes a file, and a save archive can't write outside
its game. Before replacing a file it copies the current one to
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
already identical are left alone. Android app data goes through the existing
`restore-data` helper, which keeps the previous data folder beside it.
## Stuck in Desktop Mode
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
is what you see, you're already in Game Mode: close it from the Steam dashboard.
Full Desktop Mode is a different session. SteamOS picks the session through
SDDM's autologin file, `/etc/sddm.conf.d/zz-steamos-autologin.conf` (or the
default `/etc/sddm.conf.d/steamos.conf`), and switches with
`steamos-session-select` (`holo-session-select` on newer images). This is what
Valve's own devkit tools on the Frame read and run
([`steamos-get-status`](../frame/devkit-utils/steamos-get-status),
[`steamos-delete`](../frame/devkit-utils/steamos-delete)).
**Check** reads that file and whether gamescope and Plasma are running; it
changes nothing. On the Frame gamescope's process is `gamescope-wl`, started by
`start-gamescope-session`, and either counts as Game Mode. Only when Plasma is
running without gamescope (Desktop Mode) does **Return to Game Mode** run
exactly `steamos-session-select gamescope` (or `holo-session-select
gamescope`) over SSH, after asking. It closes the desktop and anything open in
it. Frame Control never edits the SDDM files itself, and runs nothing when the
headset is in Game Mode, when it can't tell which mode it's in, or when the
image has neither command.
**Uncertain:** whether the Frame offers a full Desktop Mode session at all
(only the nested desktop has been seen on BUILD_ID 20260922.6101926 and
20260925.6191901), and whether `steamos-session-select` there works without
the Developer Mode password. If the switch is refused, Frame Control says so and
leaves the headset as it was.
### Manual fallback
If the button can't help, or the headset isn't reachable:
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
Desktop Mode session, so a restart brings Game Mode back. That the Frame
behaves the same is **inferred**, not tested.
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
`ssh frame steamos-session-select gamescope`.
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
button on top of Power. Choose `Current`, or `Previous` to start the other
SteamOS slot; both keep your data. Details and the further options
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
`Erase User Data` is a factory reset.
## Verification
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
for real against a folder laid out like the Frame's home, with only SSH faked:
which paths are picked, that sign-in files and keys never are (also inside game
folders), links never followed, the manifest and its checksums, restore checks
after a reset (game not installed, no Proton folders yet, not signed in), that a
restore replaces only its own files and keeps the old ones, unsafe archives
(`..`, absolute paths, links, other games' folders) writing nothing, backup
names that try to leave the backups folder, and the exact Game Mode command.
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
the server, deletes it and restores it. Neither involves a real headset.
+14 -5
View File
@@ -3,11 +3,20 @@
**Question:** can Frame Control set an 80% battery charge limit on the Frame
over SSH or adb, safely?
**Verdict: don't build it yet; it needs a headset check.** Valve's supported
mechanism (the one Steam's own charge-limit setting uses on the Deck) is not
configured for the Frame. The only control the Frame seems to have is a
vendor charger attribute that Valve's own charging daemon also manages.
Research as of 2026-10-23. **Not tested on a headset.**
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
charge-limit setting uses on the Deck) is not configured for the Frame. The
only control the Frame has is a vendor charger attribute that Valve's own
charging daemon also manages. Research as of 2026-10-23, confirmed with the
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
## How Steam does it on other devices
+18
View File
@@ -53,11 +53,23 @@ 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,
the desktop, Lepton and xrdp are running.
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
- **Force quit** a game that has stopped responding: Steam games, sideloaded
titles, non-Steam shortcuts and Android apps. **Running now** at the top of
Games lists what's running; each game's own **Force quit…** (on its library
tile, or in a sideloaded title's ⋯ menu) and the Control Center's **Force quit
a game** card do the same. Each asks first, because unsaved progress is lost.
Steam is asked to quit the game, then its processes get SIGTERM and SIGKILL;
see [steam-games.md](steam-games.md#force-quitting-a-running-game).
- **Get games**: every game you own with its Steam Frame rating (Verified,
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
with live progress. Search the Steam store with prices and Frame ratings; Buy
@@ -137,6 +149,12 @@ GPU time is render time, not GPU utilisation. The temperature is the hottest
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
not a guessed universal temperature threshold.
**Force quit a game** lists what's running only when you choose **Check what's
running**, and says so when nothing is. Each **Force quit…** names the game and
warns that unsaved progress will be lost before anything is sent; it uses the
selected headset, refuses a second quit while one is running, and drops the
list when you switch headsets.
**Audio** controls the default output sink through the existing `/api/volume`
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
and **Unmute** send explicit mute states. The UI captures the selected headset,
+1
View File
@@ -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)
+12 -6
View File
@@ -8,10 +8,13 @@ Both are on **Tools → Capture & Live**. Everything is saved to
Nothing needs installing on the computer or on the Frame: the work is done by
the `ffmpeg` that SteamOS ships.
**Not tested on a headset yet.** This is built from what the code already does
on the Frame (the live view, the media player) and from how Steam is reported
to store its recordings. The parts we're least sure of are listed under
[What hasn't been checked](#what-hasnt-been-checked). Please report what you see.
**Recording is tested on a headset; Steam clip export isn't yet.** On a Frame
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
second, and nothing was left running or stored on the Frame. That Frame had no
Steam recordings, so the clip list came back empty and export has only been
tested with files in Steam's format made by `ffmpeg`. The parts we're least sure
of are listed under [What hasn't been checked](#what-hasnt-been-checked).
## Record
@@ -36,8 +39,11 @@ the Frame, converted to `.mp4` there (without re-encoding) and copied to
- **Where it waits on the Frame**: `~/.cache/frame-control/video/`, not `/tmp`,
which is a RAM disk on SteamOS. It's deleted once copied.
- **If the connection drops or the app quits** while recording, the Frame
stops `ffmpeg` cleanly when the SSH channel closes, as the live view does. A
recording that was never copied is deleted when the next one starts.
stops `ffmpeg` cleanly when the SSH channel closes: it types `q` into
`ffmpeg`'s input, as at its keyboard, and kills it if it's still there 10
seconds later. Not a signal: SteamOS's `ffmpeg` 7.0 catches SIGTERM and
SIGINT and carries on recording. A recording that was never copied is
deleted when the next one starts.
Recording and **Live** can run together; both read the same device.
+3
View File
@@ -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
+2 -1
View File
@@ -30,7 +30,8 @@ it.) A dialog shows:
Install copies it to the Frame and registers it with Steam; progress shows in
the bar and the activity log. **Sideloaded titles** lists what's installed,
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
with Launch, Remove and, for a game that has stopped responding, **Force
quit…** in its ⋯ menu ([how](steam-games.md#force-quitting-a-running-game)). **Copy to ~/Downloads instead** keeps the old
behaviour for a zip that isn't a game.
From a terminal:
+56
View File
@@ -101,6 +101,62 @@ Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
returns nothing without `cc`, so Frame Control takes the country from
`SteamClient.User.GetIPCountry()` on the Frame.
## Force quitting a running game
**Running now**, at the top of Games, lists what's running on the Frame: Steam
games, sideloaded titles, non-Steam shortcuts and Android apps. A game's library
tile, a sideloaded title's ⋯ menu and the Control Center's **Force quit a game**
card have **Force quit…** too. Each asks first ("Unsaved progress will be
lost"); from a game's own menu it checks that the game is running and says so if
it isn't. SteamVR (app 250820, and its beta 1009850) is never listed or quit:
on the Frame it shows the headset view, so quitting it isn't a way out of a game.
`ui/frame_running.py` does the work on the Frame, piped over SSH with
`frame_steam.py` as its DevTools client:
```sh
scp ui/frame_steam.py ui/frame_running.py frame:/tmp/
ssh frame 'python3 /tmp/frame_running.py list' # {"running": [{appid, gameid, name, kind, ...}]}
ssh frame 'python3 /tmp/frame_running.py quit 620' # {"name", "method", "message"}
```
1. **What's running.** Steam starts every game, shortcut and devkit title under
its `reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper
stays the parent of the game's processes. So a running game is one of your
own processes whose program is named `reaper`, with `SteamLaunch` and an
`AppId=` before `--`; the game is that reaper's process tree. Steam's own list
(`SteamUIStore.RunningApps`) and Android apps whose Lepton container is up
(`podman ps`) are added. Names come from `appStore`, else the app manifest,
the Android app's `meta.json` or the devkit title's folder.
2. **Steam first.** `SteamClient.Apps.TerminateApp(<game id>, false)`, the
call Steam's own Stop makes for Android apps ([vr-apks.md](vr-apks.md)). The
game id is the appid, or `(appid << 32) | 0x02000000` for a non-Steam
shortcut. An Android app's container is also stopped, as the Apps tab's Stop
does (`podman stop -t 5 lepton-steamlaunch-<instance>`).
3. **Then signals.** If the game's processes are still there after 5 seconds,
they get SIGTERM, deepest first; whatever is left 3 seconds later gets
SIGKILL, the reaper last, so nothing it holds is orphaned unsignalled. A pid
is only signalled while its start time still matches, so a reused pid is safe.
Nothing is found by name. These are never signalled, even inside a game's tree:
init, the helper itself and every process above it (the SSH session), other
users' processes, and anything named `steam`, `steamwebhelper`, `gamescope`,
`gamescope-wl`, `gamescopereaper`, `gamescope-session`, `sshd`, `sshd-session`,
`ssh-agent`, `systemd`, `dbus-daemon`, `dbus-broker`, `pipewire`,
`pipewire-pulse`, `wireplumber`, `plasmashell`, `kwin_wayland`, `kwin_x11`,
`Xwayland`, `sddm`, `vrserver`, `vrcompositor`, `vrmonitor`, `vrdashboard`,
`vrwebhelper` or `vrstartup`, with everything under them.
**Not tested on a headset.** The reaper command line is how Steam launches games
on Linux desktops and the Steam Deck, **inferred** for the Frame.
`SteamUIStore.RunningApps` comes from Steam's client code as plugin loaders use
it and hasn't been seen on the Frame's build; without it, the reapers alone
decide. `TerminateApp` with a shortcut's game id is the call Steam's Stop was
seen making for an Android app (docs/vr-apks.md); for Steam games it is
**inferred**. Unit tests (`tests/test_force_quit.py`) cover the parsing, the
process selection and the escalation; the fake Frame launches its games under a
stand-in `reaper` so `tests/e2e/test_running.py` force quits a real process tree.
## Not yet checked
- Free-to-play games: `steam://install` should stop at state 3 (free license)
+24 -2
View File
@@ -76,8 +76,8 @@ its guards want.
| `sshd` with key and password logins, `rsync`, `python3` | Real |
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
| Valve's devkit-utils, copied over by Frame Control itself | Real |
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs. Games it really runs start under a stand-in `reaper SteamLaunch AppId=<id> -- <command>` (bash with that command line), so force quit finds a real process tree | Fake |
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF. `SteamUIStore.RunningApps` lists launches that haven't exited; `TerminateApp` ends only launches that never ran a process, so a running program needs Frame Control's signals | The JS engine is real; the objects are fake |
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
@@ -267,6 +267,17 @@ connect it to the Frame and follow Remote Desktop through to the Frame's
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
`frame-key` left `authorized_keys` byte-for-byte as it was.
## Force quit
`tests/test_force_quit.py` feeds `ui/frame_running.py` made-up process tables:
the `reaper SteamLaunch AppId=` parsing, naming from Steam, manifests, Android
`meta.json` and devkit folders, and a Frame's worth of Steam, gamescope, SteamVR,
sshd and session processes that must never be signalled. Processes that ignore
SIGTERM, survive SIGKILL or exit and have their pid reused check the escalation.
The Control Center's confirmation, duplicate and headset-switch handling run in
`tests/control_center.cjs`. `tests/e2e/test_running.py` force quits real process
trees on the fake Frame. None of it has run on a headset.
## Owned media player
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
@@ -314,3 +325,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.
+65 -1
View File
@@ -68,7 +68,7 @@ function sharedPage({productionApi=false} = {}) {
vm.runInContext(`const UI_KEY = 'test-only';
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
const disp = {}, gm = {seq:0}, cat = {}, wi = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0};
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, page = 'control';
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, runningGames = [], runningSeq = 0, page = 'control';
const androidPending = new Set();`,context);
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
@@ -449,3 +449,67 @@ test('every Control Center workflow reaches its intentional workspace', () => {
}
assert.equal(f.run('PAGES.length'),4);
});
// Force quit: an explicit check, a confirmation naming the game, and the captured headset.
function quitPage({confirmAnswer=true, running=[{appid:620,name:'Portal <2>',kind:'steam'}], quit} = {}) {
const asked=[], calls=[];
const f=mounted({confirm:text=>{asked.push(text);return confirmAnswer;},
api:async(path,body,device)=>{calls.push({path,body,device});
if(body===undefined)return {running};
return quit ? quit(body) : {message:'Quit Portal 2',method:'steam'};}});
return Object.assign(f,{asked,apiCalls:calls,click:appid=>f.element('ccRunningList').onclick({target:{closest:()=>({dataset:{ccQuit:String(appid)}})}})});
}
test('force quit lists what is running and says so when nothing is', async t => {
const f=quitPage({running:[]});t.after(()=>f.ui.destroy());
await f.element('ccRunningCheck').onclick();
assert.deepEqual(f.apiCalls,[{path:'/api/running',body:undefined,device:'frame-a'}]);
assert.equal(f.element('ccRunningResult').textContent,'Nothing is running on the Frame.');
assert.equal(f.element('ccRunningList').innerHTML,'');
const g=quitPage();t.after(()=>g.ui.destroy());
await g.element('ccRunningCheck').onclick();
assert.match(g.element('ccRunningList').innerHTML,/Portal &lt;2&gt;<small>Steam game<\/small>.*data-cc-quit="620"/);
assert.match(g.element('ccRunningResult').textContent,/^1 running/);
});
test('force quit confirms with the unsaved-progress warning before any request', async t => {
const f=quitPage({confirmAnswer:false});t.after(()=>f.ui.destroy());
await f.element('ccRunningCheck').onclick();
f.click(620);await flush();
assert.deepEqual(f.asked,['Force quit Portal <2>? Unsaved progress will be lost.']);
assert.equal(f.apiCalls.length,1,'declining sends nothing');
const g=quitPage();t.after(()=>g.ui.destroy());
await g.element('ccRunningCheck').onclick();
g.click(620);await flush();
assert.deepEqual(g.apiCalls[1],{path:'/api/running',body:{action:'quit',appid:'620'},device:'frame-a'});
assert.equal(g.element('ccRunningResult').textContent,'Quit Portal 2');
assert.equal(g.element('ccRunningList').innerHTML,'');
g.click(999);await flush();
assert.equal(g.apiCalls.length,2,'an id that was not listed is ignored');
});
test('force quit shows errors, refuses duplicates and offline use, and ignores a switched headset', async t => {
const pending=deferred();
const f=quitPage({quit:()=>pending.promise});t.after(()=>f.ui.destroy());
await f.element('ccRunningCheck').onclick();
f.click(620);f.click(620);await flush();
assert.equal(f.asked.length,1);
assert.match(f.element('ccRunningList').innerHTML,/disabled/);
pending.reject(Error("That game isn't running any more"));await flush();
assert.equal(f.element('ccRunningResult').textContent,"Couldn’t force quit Portal <2>: That game isn't running any more");
assert.doesNotMatch(f.element('ccRunningList').innerHTML,/disabled/);
// A switch while quitting: the old answer is dropped and the list cleared.
const late=deferred(),g=quitPage({quit:()=>late.promise});t.after(()=>g.ui.destroy());
await g.element('ccRunningCheck').onclick();g.click(620);await flush();
g.context={online:true,generation:2,device:'frame-b'};g.ui.reset();
late.resolve({message:'Quit Portal 2'});await flush();
assert.equal(g.element('ccRunningResult').textContent,'');
assert.equal(g.element('ccRunningList').innerHTML,'');
const h=quitPage();t.after(()=>h.ui.destroy());h.context={online:false,generation:1,device:'frame-a'};
await h.element('ccRunningCheck').onclick();
assert.equal(h.apiCalls.length,0);
assert.match(h.element('ccRunningResult').textContent,/Connect to the Frame/);
const controller=cc.createController({api:async()=>assert.fail('no request'),getContext:()=>({online:true,generation:1})});
await assert.rejects(controller.forceQuit({appid:'620; reboot'}),/Choose a running game/);
});
test('the Control Center finds force quit by what a stuck game looks like', () => {
const item=cc.catalog.find(i=>i.id==='forcequit');
for(const q of ['frozen game','hung','not responding','kill','stuck app'])assert.ok(cc.matches(item,q,'all'),q);
assert.ok(cc.matches(item,'','apps'));
});
+55
View File
@@ -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()
+82
View File
@@ -0,0 +1,82 @@
"""Force quit (ui/frame_running.py) through the server's HTTP API, against games the
fake Steam client really runs under its stand-in reaper (fakesteam.REAPER)."""
import os
import unittest
import harness
from harness import api, install_title, launches, ok, ssh, state, wait_for
harness.require()
class Running(harness.FrameTestCase):
def script_title(self, name, body):
folder = self.path(name)
os.makedirs(folder)
with open(os.path.join(folder, 'run.sh'), 'w') as f:
f.write('#!/bin/sh\n' + body)
os.chmod(os.path.join(folder, 'run.sh'), 0o755)
plan, job = install_title(folder)
self.assertIsNone(job['error'], job)
return plan['id'], plan['name']
def running(self, match):
return next((g for g in ok('GET', '/api/running')['running'] if match(g)), None)
def launch_title(self, gid):
ok('POST', '/api/titles', {'action': 'launch', 'id': gid})
game = wait_for(lambda: self.running(lambda g: g.get('title') == gid), 20, f'{gid} to be running')
# The fake's reset doesn't stop programs: don't leave one running for the next test.
self.addCleanup(ssh, f"pkill -KILL -f '[d]evkit-game/{gid}/' || true", check=False)
return game
def assert_steam_alive(self, pid):
self.assertEqual(ssh(f'kill -0 {pid} && echo alive').strip(), 'alive')
def test_nothing_running(self):
self.assertEqual(ok('GET', '/api/running'), {'running': [], 'message': 'Nothing is running on the Frame'})
status, out, _ = api('POST', '/api/running', {'action': 'quit', 'appid': '620'})
self.assertEqual(status, 409, out)
def test_sigterm_quits_a_sideloaded_game(self):
gid, name = self.script_title('Hang Game', 'sleep 600 &\nwait\n')
steam = ssh('cat ~/.steam/steam.pid').strip()
game = self.launch_title(gid)
self.assertEqual((game['kind'], game['name']), ('devkit', name))
self.assertGreaterEqual(game['processes'], 3) # reaper, run.sh, sleep
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
self.assertEqual(out['method'], 'term', out)
self.assertEqual(out['message'], f'Force quit {name}')
# Steam was asked first, with the shortcut's game id.
self.assertEqual(state()['steam']['last_terminate'], game['gameid'])
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
self.assert_steam_alive(steam)
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
10, 'the reaper to report the exit')
self.assertIsNotNone(done['exit'])
def test_sigkill_when_it_ignores_sigterm(self):
gid, name = self.script_title('Stuck Game', "trap '' TERM\nsleep 600 &\nwait\n")
steam = ssh('cat ~/.steam/steam.pid').strip()
game = self.launch_title(gid)
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
self.assertEqual(out['method'], 'kill', out)
self.assertEqual(out['message'], f'Force quit {name} (it had to be killed)')
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
self.assert_steam_alive(steam)
def test_steam_game_quits_through_steam(self):
ok('POST', '/api/launch', {'appid': 620980})
game = wait_for(lambda: self.running(lambda g: g['appid'] == 620980), 20, 'the Steam game to be running')
self.assertEqual((game['kind'], game['gameid']), ('steam', '620980'))
out = ok('POST', '/api/running', {'action': 'quit', 'appid': '620980'})
self.assertEqual(out['method'], 'steam', out)
self.assertEqual(state()['steam']['last_terminate'], '620980')
self.assertEqual(launches('rungameid')[-1]['exit'], 'terminated')
self.assertEqual(ok('GET', '/api/running')['running'], [])
if __name__ == '__main__':
unittest.main()
@@ -79,6 +79,12 @@ function build(steam) {
return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null;
},
},
// Guess: the running list as Steam's client code and plugin loaders read it
// (SteamUIStore.RunningApps of app overviews); not seen on the Frame. fakesteam
// passes the ids of launches that haven't exited.
SteamUIStore: {
get RunningApps() { return (steam.running || []).map(id => allApps().find(a => a.appid === id) || { appid: id }); },
},
appStore: {
get allApps() { return allApps(); },
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
@@ -109,7 +115,8 @@ function build(steam) {
if (s?.artwork) delete s.artwork[type];
},
// Container fallback in frame_android.stop performs the simulated stop.
TerminateApp(gameid) { steam.last_terminate = gameid; },
// fakesteam ends pid-less launches for the ids in `terminated`.
TerminateApp(gameid) { steam.last_terminate = gameid; (steam.terminated ||= []).push(String(gameid)); },
RemoveShortcut(id) {
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
delete steam.compat_tools[String(id)];
@@ -47,6 +47,12 @@ COMPAT_LOG = fs.STEAM_ROOT + '/logs/compat_log.txt'
DEVTOOLS_PORT = 8080
TARGET_ID = 'F0CA11ED5EA4ED0000000000000000AB'
GAME_LOGS = '/var/log/fakeframe'
# Steam starts a game under its reaper, `reaper SteamLaunch AppId=<id> -- <command>`,
# which stays the game's parent until it ends (guess for the Frame: that's how Steam
# launches on Linux desktops and the Steam Deck; docs/steam-games.md). The fake's
# reaper is bash with that command line: it waits for the game and exits with its status.
REAPER = fs.STEAM_ROOT + '/ubuntu12_32/reaper'
REAPER_SH = 'while [ "$1" != "--" ]; do shift; done; shift; "$@" & wait $!'
# The 403 text /register returned while Steam wasn't on "Pair new host"
# (docs/ssh.md, verified 2026-09-26). approve-ssh-key passes the Steam
@@ -105,11 +111,24 @@ class JS:
self._start()
self.next += 1
with fs.update() as state:
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression,
'awaitPromise': await_promise, 'steam': state['steam']}) + '\n')
# What Steam counts as running: launches that started and haven't exited.
running = sorted({l['appid'] for l in state['launches']
if l.get('started') and l.get('appid') and l.get('exit') is None})
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression, 'awaitPromise': await_promise,
'steam': dict(state['steam'], running=running)}) + '\n')
self.proc.stdin.flush()
reply = json.loads(self.proc.stdout.readline())
state['steam'] = reply['steam']
steam = reply['steam']
steam.pop('running', None)
for gid in map(int, steam.pop('terminated', [])):
appid = gid >> 32 if gid >= 1 << 32 else gid
# Guess: TerminateApp ends a launch the fake didn't really run (a Steam
# game, a Proton title). A real process it leaves alone, so Frame
# Control's own fallback (SIGTERM, SIGKILL) is what the e2e tests see.
for l in state['launches']:
if l.get('appid') == appid and l.get('exit') is None and not l.get('pid'):
l['exit'] = 'terminated'
state['steam'] = steam
return reply['result']
@@ -253,8 +272,10 @@ def add_launch(record):
os.makedirs(GAME_LOGS, exist_ok=True)
with open(f'{GAME_LOGS}/{label}.log', 'ab') as log:
try:
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
argv = [REAPER, '-c', REAPER_SH, 'reaper', 'SteamLaunch', f"AppId={record['appid']}", '--', path]
proc = subprocess.Popen(argv, executable='/bin/bash', cwd=cwd if os.path.isdir(cwd) else HOME,
env={**os.environ, **env}, stdin=subprocess.DEVNULL, stdout=log, stderr=log,
start_new_session=True)
record['pid'] = proc.pid
except OSError as e:
record.update(pid=None, exec_error=str(e))
+450
View File
@@ -0,0 +1,450 @@
"""Backup and recovery kit (ui/frame_backup.py, ui/frame_backup_remote.py) against a folder laid
out like the Frame's home. The headset-side script runs for real, locally; only SSH is faked."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_android as android # noqa: E402
import frame_backup as backup # noqa: E402
import frame_backup_remote as remote # noqa: E402
import server # noqa: E402
STEAM = '.local/share/Steam'
USER = STEAM + '/steamapps/compatdata/%s/pfx/drive_c/users/steamuser'
SECRETS = ['.steam/steam.token', STEAM + '/config/config.vdf', STEAM + '/config/loginusers.vdf',
STEAM + '/ssfn123456', '.ssh/id_ed25519', '.ssh/authorized_keys',
USER % 1234 + '/AppData/Roaming/Launcher/session.token',
USER % 1234 + '/AppData/Roaming/Launcher/.ssh/id_rsa',
'.config/cliproxyapi/secrets.env', '.local/share/keyrings/login.keyring']
def write(home, rel, data=b'x'):
path = Path(home) / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data if isinstance(data, bytes) else data.encode())
return path
def manifest(home, appid, name):
write(home, '%s/steamapps/appmanifest_%s.acf' % (STEAM, appid),
'"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n}\n' % (appid, name))
def frame_home(home):
"""A Frame before a reset: one Proton game, a Linux game, settings and secrets."""
manifest(home, 1234, 'Test Game')
write(home, USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat', b'save one')
write(home, USER % 1234 + '/Documents/My Games/Game/profile.sav', b'profile')
write(home, USER % 1234 + '/AppData/Local/Temp/junk.tmp', b'junk')
write(home, USER % 1234 + '/AppData/Local/Microsoft/Windows/x.db', b'wine')
write(home, USER % 999 + '/AppData/Local/Temp/only-cache', b'c') # no saves: no part
write(home, STEAM + '/steamapps/compatdata/2800000001/internal/org.example/x', b'android')
write(home, STEAM + '/userdata/555/config/localconfig.vdf', b'local')
write(home, STEAM + '/userdata/555/config/shortcuts.vdf', b'shortcuts')
write(home, STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', b'layout')
write(home, STEAM + '/userdata/555/1234/remote/cloud.sav', b'cloud') # Steam Cloud's own
write(home, STEAM + '/userdata/555/760/remote/1234/screenshots/a.jpg', b'shot')
write(home, '.config/kdeglobals', b'[General]')
write(home, '.config/openvr/config/steamvr.vrsettings', b'{}')
write(home, '.config/unity3d/Company/LinuxGame/prefs', b'prefs')
write(home, '.bashrc', b'# mine')
for rel in SECRETS:
write(home, rel, b'SECRET')
class Remote(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.home = Path(self.tmp.name)
patcher = patch.object(remote, 'HOME', self.home)
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(self.tmp.cleanup)
def files(self, part):
return [rel for rel, _ in remote.part_files(part)[0]]
def test_plan_picks_saves_and_settings_and_never_secrets(self):
frame_home(self.home)
plan = remote.plan()
ids = [p['id'] for p in plan['parts']]
self.assertEqual(ids, ['saves:1234', 'saves:linux', 'settings:steam', 'settings:system'])
self.assertEqual(plan['parts'][0]['label'], 'Test Game saves')
saves = self.files('saves:1234')
self.assertEqual(sorted(saves), [USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat',
USER % 1234 + '/Documents/My Games/Game/profile.sav'])
steam = self.files('settings:steam')
self.assertIn(STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', steam)
self.assertIn(STEAM + '/userdata/555/config/localconfig.vdf', steam)
self.assertFalse([f for f in steam if '/1234/remote/' in f or '/760/' in f])
everything = sum((self.files(i) for i in ids), [])
for rel in SECRETS + ['.bashrc']:
self.assertNotIn(rel, everything)
self.assertFalse([f for f in everything if remote.is_secret(f)])
self.assertTrue(any('steam.token' in x for x in plan['excluded']))
# The controller layout goes back; localconfig.vdf and shortcuts.vdf are for reference.
steam_part = plan['parts'][2]
self.assertEqual((steam_part['files'], steam_part['restorable']), (3, 1))
def test_secret_names(self):
for rel in SECRETS + ['a/b/ID_RSA.pub', 'x/kwalletd/k', 'x/cert.PEM']:
self.assertTrue(remote.is_secret(rel), rel)
for rel in ['saves/game.sav', 'Documents/tokens-collected.txt', 'config/localconfig.vdf']:
self.assertFalse(remote.is_secret(rel), rel)
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
def test_links_are_never_followed(self):
frame_home(self.home)
outside = write(self.home, 'elsewhere/private.txt', b'p')
base = self.home / (USER % 1234) / 'Documents'
os.symlink(str(outside.parent), str(base / 'linked-dir'))
os.symlink(str(outside), str(base / 'linked-file'))
self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f])
def test_big_files_are_listed_as_skipped(self):
frame_home(self.home)
with patch.object(remote, 'MAX_FILE', 3):
files, skipped = remote.part_files('saves:1234')
self.assertEqual(files, [])
self.assertEqual({s['reason'] for s in skipped}, {'over 2 GiB'})
def test_pack_holds_exactly_the_planned_files(self):
frame_home(self.home)
out = io.BytesIO()
remote.pack('saves:1234', out)
out.seek(0)
with tarfile.open(fileobj=out, mode='r:gz') as archive:
names = sorted(m.name for m in archive)
self.assertEqual(names, sorted(self.files('saves:1234')))
with self.assertRaises(ValueError):
remote.pack('saves:../../etc', io.BytesIO())
def archive(self, members):
out = io.BytesIO()
with tarfile.open(fileobj=out, mode='w:gz') as tar:
for name, data in members:
info = tarfile.TarInfo(name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
out.seek(0)
return out
def test_check_after_a_reset(self):
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
ask = {'parts': [{'id': 'saves:1234', 'files': [save]},
{'id': 'settings:steam', 'files': [STEAM + '/userdata/555/241100/remote/controller_config/1/a.vdf']},
{'id': 'settings:system', 'files': ['.config/openvr/config/steamvr.vrsettings']},
{'id': 'saves:../x', 'files': []}]}
states = lambda: [p['status'] for p in remote.check(ask)['parts']] # noqa: E731
self.assertEqual(states(), ['not_installed', 'no_account', 'reference', 'unknown'])
manifest(self.home, 1234, 'Test Game')
(self.home / STEAM / 'userdata/555').mkdir(parents=True)
self.assertEqual(states()[:2], ['no_prefix', 'ok'])
(self.home / (USER % 1234)).mkdir(parents=True)
result = remote.check(ask)['parts'][0]
self.assertEqual((result['status'], result['new'], result['replace']), ('ok', 1, 0))
write(self.home, save, b'newer')
self.assertEqual(remote.check(ask)['parts'][0]['replace'], 1)
def test_restore_replaces_only_its_files_and_keeps_the_old_ones(self):
manifest(self.home, 1234, 'Test Game')
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
write(self.home, save, b'played since')
other = write(self.home, USER % 1234 + '/AppData/Roaming/Studio/Game/other.dat', b'untouched')
bashrc = write(self.home, '.bashrc', b'# mine')
source = self.archive([(save, b'from backup'), (USER % 1234 + '/Documents/new.sav', b'new'),
('.bashrc', b'# replaced?')])
result = remote.restore('saves:1234', '20261023-120000', source)
self.assertEqual((result['written'], result['replaced']), (2, 1))
self.assertEqual(result['held'], {'reference': 1})
self.assertEqual((self.home / save).read_bytes(), b'from backup')
self.assertEqual(other.read_bytes(), b'untouched')
self.assertEqual(bashrc.read_bytes(), b'# mine')
kept = self.home / remote.PREVIOUS / '20261023-120000' / save
self.assertEqual(kept.read_bytes(), b'played since')
self.assertFalse([p for p in (self.home / save).parent.iterdir() if p.name.endswith('.part')])
def test_unsafe_archives_write_nothing(self):
manifest(self.home, 1234, 'Test Game')
(self.home / (USER % 1234)).mkdir(parents=True)
good = (USER % 1234 + '/Documents/a.sav', b'a')
for bad in ('../escape', '/etc/passwd', USER % 1234 + '/Documents/../../../../../../x',
USER % 1234 + '/Documents/a.sav'):
with self.assertRaises(ValueError, msg=bad):
remote.restore('saves:1234', '20261023-120000', self.archive([good, (bad, b'evil')]))
self.assertFalse((self.home / good[0]).exists(), bad)
link = io.BytesIO()
with tarfile.open(fileobj=link, mode='w:gz') as tar:
info = tarfile.TarInfo(USER % 1234 + '/Documents/l')
info.type, info.linkname = tarfile.SYMTYPE, '/etc/passwd'
tar.addfile(info)
link.seek(0)
with self.assertRaises(ValueError):
remote.restore('saves:1234', '20261023-120000', link)
with self.assertRaises(ValueError):
remote.restore('saves:1234', '../stamp', self.archive([good]))
def test_restore_refuses_secrets_and_other_games(self):
manifest(self.home, 1234, 'Test Game')
(self.home / (USER % 1234)).mkdir(parents=True)
result = remote.restore('saves:1234', '20261023-120000', self.archive([
(USER % 1234 + '/AppData/Roaming/L/auth.token', b't'), (USER % 5678 + '/Documents/x.sav', b'x')]))
self.assertEqual(result['written'], 0)
self.assertEqual(result['held'], {'refused': 1, 'reference': 1})
def test_session_reads_the_mode(self):
conf = write(self.home, 'zz-steamos-autologin.conf', '[Autologin]\nSession=plasma-steamos-wayland-oneshot.desktop\n')
running = set()
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n == 'steamos-session-select' else None):
state = remote.session()
self.assertEqual((state['mode'], state['selector']), ('desktop', 'steamos-session-select'))
self.assertEqual(state['configured'], 'plasma-steamos-wayland-oneshot.desktop')
running.update({'gamescope', 'plasmashell'}) # the nested desktop panel
self.assertEqual(remote.session()['mode'], 'game')
running.clear()
running.add('gamescope-wl') # gamescope's process name on the Frame, with no desktop panel open
self.assertEqual(remote.session()['mode'], 'game')
running.clear()
running.add('start-gamescope-session')
self.assertEqual(remote.session()['mode'], 'game')
def fake_ssh(home, os_release):
"""Runs the headset-side command locally, as the Frame's python3 would."""
def run(command, src=None, dst=None, timeout=None):
argv = shlex.split(command)
assert argv[:2] == ['python3', '-c'], argv
data = src if isinstance(src, bytes) else src.read() if src is not None else b''
env = dict(os.environ, FRAME_BACKUP_HOME=str(home), FRAME_BACKUP_OS_RELEASE=str(os_release))
r = subprocess.run([sys.executable, *argv[1:]], input=data, capture_output=True, env=env)
if r.returncode:
raise backup.BackupError(r.stderr.decode(errors='replace'))
if dst is not None:
dst.write(r.stdout)
return None
return r.stdout
return run
class Local(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
base = Path(self.tmp.name)
self.home, self.store = base / 'frame', base / 'backups'
self.release = write(base, 'os-release', 'ID=steamos\nVERSION_ID=0.4.1\nBUILD_ID=20260925.6191901\n')
frame_home(self.home)
for p in (patch.dict(os.environ, FRAME_BACKUP_DIR=str(self.store)),
patch.object(backup, '_ssh', side_effect=fake_ssh(self.home, self.release))):
p.start()
self.addCleanup(p.stop)
def android_backup(self, package, destination):
write(Path(destination).parent, Path(destination).name, b'android archive ' + package.encode())
return {'package': package, 'instance': android.instance_id(package), 'files': 3, 'bytes': 42,
'path': str(destination), 'sha256': backup._sha256(destination)}
def make(self):
apps = [{'package': 'org.example.open', 'label': 'Open app', 'running': True},
{'package': 'org.example.game', 'label': 'Game', 'running': False}]
with patch.object(android, 'list_apps', return_value=apps), \
patch.object(android, 'backup_data', side_effect=self.android_backup) as data, \
patch.object(backup.time, 'strftime', side_effect=lambda f: '2026-10-23-120000' if f == '%Y-%m-%d-%H%M%S'
else '2026-10-23T12:00:00+0100'):
result = backup.backup(headset='My Frame')
data.assert_called_once()
return result
def test_backup_writes_a_manifest_with_checksums(self):
result = self.make()
folder = backup.backup_path('2026-10-23-120000') # resolved: macOS's temp folder is a link
self.assertEqual(result['path'], str(folder))
self.assertEqual(sorted(p.name for p in self.store.iterdir()), ['2026-10-23-120000']) # no .partial left
m = json.loads((folder / 'manifest.json').read_text())
self.assertEqual(m['headset'], {'name': 'My Frame', 'build': '20260925.6191901', 'version': '0.4.1'})
self.assertEqual([p['id'] for p in m['parts']],
['saves:1234', 'saves:linux', 'settings:steam', 'settings:system', 'android:org.example.game'])
for part in m['parts']:
self.assertEqual(backup._sha256(folder / part['archive']), part['sha256'])
saves = m['parts'][0]
self.assertEqual(saves['archive'], 'saves/saves-1234.tar.gz')
self.assertEqual(sum(f['bytes'] for f in saves['files']), saves['bytes'])
self.assertEqual(saves['files'][0]['sha256'], __import__('hashlib').sha256(b'save one').hexdigest())
self.assertEqual([s['id'] for s in m['skipped']], ['android:org.example.open'])
for part in m['parts'][:-1]:
with tarfile.open(str(folder / part['archive'])) as tar:
for member in tar:
self.assertFalse(remote.is_secret(member.name), member.name)
self.assertNotEqual(tar.extractfile(member).read(), b'SECRET', member.name)
def test_a_failed_backup_leaves_nothing_behind(self):
with patch.object(backup, 'inspect_archive', side_effect=backup.BackupError('cut off')), \
patch.object(android, 'list_apps', return_value=[]):
with self.assertRaises(backup.BackupError):
backup.backup(kinds=['saves'])
self.assertEqual(list(self.store.iterdir()), [])
def test_backup_names_cannot_leave_the_folder(self):
for name in ('../2026-10-23-120000', '2026-10-23-120000/..', '..', '/etc', 'C:\\x', '',
'2026-10-23-120000/../../x', '2026-10-23-12000', None, 5):
with self.assertRaises(backup.BackupError, msg=repr(name)):
backup.backup_path(name)
self.assertEqual(backup.backup_path('2026-10-23-120000'), (self.store / '2026-10-23-120000').resolve())
def test_manifest_entries_cannot_point_outside_the_backup(self):
self.make()
path = self.store / '2026-10-23-120000' / 'manifest.json'
m = json.loads(path.read_text())
for archive in ('../../secret.tar.gz', '/etc/passwd', 'saves/saves-5678.tar.gz'):
m['parts'][0]['archive'] = archive
path.write_text(json.dumps(m))
with self.assertRaises(backup.BackupError, msg=archive):
backup.read_manifest('2026-10-23-120000')
def reset(self):
"""A factory reset: the home folder is empty again."""
import shutil
shutil.rmtree(str(self.home))
self.home.mkdir()
def test_restore_after_a_reset_skips_what_is_not_back_yet(self):
self.make()
self.reset()
manifest(self.home, 1234, 'Test Game')
(self.home / (USER % 1234)).mkdir(parents=True) # started once
with patch.object(android, 'list_apps', return_value=[]):
plan = {p['id']: p for p in backup.restore_plan('2026-10-23-120000')['parts']}
self.assertEqual(plan['saves:1234']['status'], 'ok')
self.assertEqual(plan['settings:steam']['status'], 'no_account')
self.assertEqual(plan['android:org.example.game']['status'], 'not_installed')
with self.assertRaises(backup.BackupError):
backup.restore('2026-10-23-120000', ['saves:1234']) # not confirmed
self.assertFalse((self.home / (USER % 1234) / 'AppData').exists())
result = backup.restore('2026-10-23-120000', ['saves:1234', 'settings:steam', 'android:org.example.game'],
confirmed=True)
self.assertEqual([r['id'] for r in result['restored']], ['saves:1234'])
self.assertEqual({s['id'] for s in result['skipped']}, {'settings:steam', 'android:org.example.game'})
self.assertEqual((self.home / (USER % 1234) / 'AppData/Roaming/Studio/Game/save1.dat').read_bytes(), b'save one')
def test_android_data_goes_back_through_the_existing_helper(self):
self.make()
apps = [{'package': 'org.example.game', 'label': 'Game', 'running': False}]
with patch.object(android, 'list_apps', return_value=apps), \
patch.object(android, 'restore_data', return_value={'restored': True}) as restore:
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
restore.assert_called_once_with('org.example.game', str(backup.backup_path('2026-10-23-120000') /
'android' / 'org.example.game.tar.gz'))
self.assertEqual(len(result['restored']), 1)
apps[0]['running'] = True
with patch.object(android, 'list_apps', return_value=apps), patch.object(android, 'restore_data') as restore:
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
restore.assert_not_called()
self.assertEqual(result['skipped'][0]['reason'], 'Close the app first')
def test_a_damaged_archive_is_skipped(self):
self.make()
with open(str(self.store / '2026-10-23-120000/saves/saves-1234.tar.gz'), 'ab') as f:
f.write(b'tampered')
with patch.object(android, 'list_apps', return_value=[]):
result = backup.restore('2026-10-23-120000', ['saves:1234'], confirmed=True)
self.assertEqual(result['restored'], [])
self.assertIn('checksum', result['skipped'][0]['reason'])
def test_list_backups(self):
self.make()
(self.store / '.2026-10-23-130000.partial').mkdir()
(self.store / 'notes').mkdir()
listed = backup.list_backups()['backups']
self.assertEqual([b['name'] for b in listed], ['2026-10-23-120000'])
self.assertEqual(listed[0]['build'], '20260925.6191901')
class Ssh(unittest.TestCase):
def test_bytes_go_as_input_and_files_as_stdin(self):
done = subprocess.CompletedProcess([], 0, b'{}', b'')
with patch.object(backup.frame_host, 'run_ssh', return_value=done) as run:
backup._ssh('true', src=b'{"parts": []}')
self.assertEqual(run.call_args.kwargs['input'], b'{"parts": []}')
self.assertNotIn('stdin', run.call_args.kwargs)
with tempfile.TemporaryFile() as f:
backup._ssh('true', src=f)
self.assertIs(run.call_args.kwargs['stdin'], f)
backup._ssh('true')
self.assertIs(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
self.assertEqual(run.call_args.args[0][-1], 'true')
with patch.object(backup.frame_host, 'run_ssh',
return_value=subprocess.CompletedProcess([], 1, b'', b'Permission denied')):
with self.assertRaisesRegex(backup.BackupError, 'Permission denied'):
backup._ssh('true')
class GameMode(unittest.TestCase):
def test_command_is_only_ever_the_session_switch(self):
self.assertEqual(backup.session_command('steamos-session-select'), 'steamos-session-select gamescope')
self.assertEqual(backup.session_command('holo-session-select'), 'holo-session-select gamescope')
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot'):
with self.assertRaises(backup.BackupError):
backup.session_command(bad)
def test_switch_needs_confirmation_and_desktop_mode(self):
with patch.object(backup, '_ssh') as ssh:
with self.assertRaises(backup.BackupError):
backup.return_to_game_mode()
ssh.assert_not_called()
with patch.object(backup, 'session_status', return_value={'mode': 'game', 'selector': 'steamos-session-select'}), \
patch.object(backup, '_ssh') as ssh:
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.assert_not_called()
with patch.object(backup, 'session_status', return_value={'mode': 'unknown', 'selector': 'steamos-session-select'}), \
patch.object(backup, '_ssh') as ssh:
with self.assertRaisesRegex(backup.BackupError, 'nothing was changed'):
backup.return_to_game_mode(confirmed=True)
ssh.assert_not_called()
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': 'steamos-session-select'}), \
patch.object(backup, '_ssh') as ssh:
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.assert_called_once_with('steamos-session-select gamescope', timeout=60)
ssh.side_effect = backup.BackupError('the headset took too long to answer') # the session restarted
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.side_effect = backup.BackupError('Interactive authentication required')
with self.assertRaisesRegex(backup.BackupError, 'manual steps'):
backup.return_to_game_mode(confirmed=True)
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': None}), \
patch.object(backup, '_ssh') as ssh:
with self.assertRaises(backup.BackupError):
backup.return_to_game_mode(confirmed=True)
ssh.assert_not_called()
class Endpoint(unittest.TestCase):
def test_guards_run_before_the_headset(self):
with patch.object(backup, '_ssh') as ssh, patch.object(server, 'start_job') as job:
for body in ({'action': 'plan', 'name': '../x'}, {'action': 'open', 'name': '..'},
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:1234']},
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:x'], 'confirm': True},
{'action': 'gamemode'}, {'action': 'backup', 'kinds': ['ssh-keys']}, {'action': 'nope'}):
with self.assertRaises(server.Failure, msg=body) as caught:
server.backup_action(body)
self.assertEqual(caught.exception.status, 400)
ssh.assert_not_called()
job.assert_not_called()
with patch.object(server, 'LOCAL', True):
with self.assertRaises(server.Failure):
server.backup_action({'action': 'list'})
if __name__ == '__main__':
unittest.main()
+35 -6
View File
@@ -335,8 +335,10 @@ class Commands(unittest.TestCase):
# A stand-in for ffmpeg on the Frame: records how it was run, writes its output file,
# and finishes the file when asked to stop (SIGTERM), as ffmpeg does. FAKE_FFMPEG_RUN
# makes the recording end by itself after that many seconds (the time limit).
# and finishes the file when q arrives on stdin. Like SteamOS's ffmpeg n7.0 (seen on a
# Frame), it catches SIGTERM and SIGINT and carries on, exiting only after more than
# three. FAKE_FFMPEG_RUN makes the recording end by itself after that many seconds (the
# time limit); FAKE_FFMPEG_DEAF makes it ignore q too, so only SIGKILL ends it.
FAKE_FFMPEG = r'''#!/usr/bin/env python3
import json, os, signal, sys, time
home = os.environ["HOME"]
@@ -347,8 +349,22 @@ if "-c" in sys.argv and sys.argv[sys.argv.index("-c") + 1] == "copy": # the re
src = sys.argv[sys.argv.index("-i") + 1]
open(out, "wb").write(open(src, "rb").read() + b"+mp4")
sys.exit(0)
stop = []
signal.signal(signal.SIGTERM, lambda *a: stop.append("TERM"))
import threading
stop, signals = [], []
def caught(*a):
signals.append(1)
if len(signals) > 3:
os._exit(255) # "Received > 3 system signals, hard exiting"
signal.signal(signal.SIGTERM, caught)
signal.signal(signal.SIGINT, caught)
def keys():
while True:
ch = os.read(0, 1)
if not ch:
return
if ch == b"q" and not os.environ.get("FAKE_FFMPEG_DEAF"):
stop.append("q")
threading.Thread(target=keys, daemon=True).start()
f = open(out, "wb")
f.write(b"v"); f.flush()
with open(os.path.join(home, "ffmpeg.pid.tmp"), "w") as p:
@@ -439,7 +455,7 @@ class Recording(unittest.TestCase):
self.assertEqual(rec.stop()["state"], "stopping")
done = self.wait_for(rec, ("saved", "failed"))
self.assertEqual(done["state"], "saved", done)
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "TERM") # asked to finish, not killed
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q") # asked to finish, not killed
saved = Path(done["file"])
self.assertEqual(saved.parent, self.here)
self.assertRegex(saved.name, r"^rec-\d{8}-\d{6}\.mp4$")
@@ -457,7 +473,20 @@ class Recording(unittest.TestCase):
pid = int((self.frame / "ffmpeg.pid").read_text())
self.channels[0].stdin.close()
self.wait_for(rec, ("saved", "failed"))
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "TERM")
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q")
with self.assertRaises(ProcessLookupError):
for _ in range(100):
os.kill(pid, 0)
time.sleep(0.05)
def test_an_ffmpeg_that_ignores_q_is_killed_not_left_recording(self):
self.env["FAKE_FFMPEG_DEAF"] = "1"
rec = self.recorder()
rec.start()
pid = int((self.frame / "ffmpeg.pid").read_text())
rec.stop()
self.wait_for(rec, ("saved", "failed"), timeout=30)
self.assertFalse((self.frame / "ffmpeg-ended").exists()) # it never finished by itself
with self.assertRaises(ProcessLookupError):
for _ in range(100):
os.kill(pid, 0)
+398
View File
@@ -0,0 +1,398 @@
"""Force quit (ui/frame_running.py and /api/running) without a headset: running-game
detection, which processes may be signalled, SIGTERM-then-SIGKILL, and the Android path.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_running # noqa: E402
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
ME, OTHER = 1000, 0
HOME = frame_running.HOME
REAPER = f"{HOME}/.local/share/Steam/ubuntu12_32/reaper"
def proc(ppid, comm, argv=None, uid=ME, start=None, state="S"):
return {"ppid": ppid, "comm": comm, "argv": argv if argv is not None else [comm], "uid": uid,
"start": start or 1000 + ppid, "state": state}
def frame():
"""A Frame's processes, as Steam on Linux launches games: Steam's reaper with
AppId= is the parent of the game (Portal 2 here, appid 620)."""
return {
1: proc(0, "systemd", uid=OTHER),
200: proc(1, "sshd", uid=OTHER),
201: proc(200, "sshd-session"),
202: proc(201, "bash"),
203: proc(202, "python3", ["python3", "-", "quit", "620"]), # this helper
300: proc(1, "systemd", ["/usr/lib/systemd/systemd", "--user"]),
301: proc(300, "gamescope-session"),
302: proc(301, "gamescope"),
303: proc(302, "steam", [f"{HOME}/.local/share/Steam/ubuntu12_32/steam", "-cef-enable-debugging"]),
304: proc(303, "steamwebhelper"),
305: proc(300, "vrserver"),
306: proc(300, "plasmashell"),
307: proc(300, "pipewire"),
400: proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=620", "--", "steam-launch-wrapper", "--", "portal2.sh"]),
401: proc(400, "steam-launch-wr", ["steam-launch-wrapper", "--", "portal2.sh"]),
402: proc(401, "portal2.sh", ["/bin/sh", "portal2.sh"]),
403: proc(402, "portal2_linux", ["./portal2_linux", "-game", "portal2"]),
404: proc(403, "steam", ["steam"]), # a child named like Steam: left alone
405: proc(404, "crashhandler"), # and so is anything under it
# Another game, and processes that only look like launches.
500: proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=999", "--", "other"]),
501: proc(500, "other"),
600: proc(1, "reaper", [REAPER, "SteamLaunch", "AppId=620", "--", "x"], uid=OTHER),
601: proc(300, "bash", ["bash", "-c", "echo reaper SteamLaunch AppId=620"]),
602: proc(300, "fakereaper", ["/tmp/notreaper", "SteamLaunch", "AppId=620"]),
}
class Parsing(unittest.TestCase):
def test_launch_appid(self):
cases = [
([REAPER, "SteamLaunch", "AppId=620", "--", "game"], 620),
(["reaper", "SteamLaunch", "AppId=3346865537", "--", "lepton-app.sh"], 3346865537),
([REAPER, "AppId=620", "--", "game"], None), # no SteamLaunch
([REAPER, "SteamLaunch", "--", "game", "AppId=620"], None), # the game's own arguments
(["/usr/bin/steam", "SteamLaunch", "AppId=620"], None), # not the reaper
(["/tmp/reaper-ish", "SteamLaunch", "AppId=620"], None),
([REAPER, "SteamLaunch", "AppId=62x0"], None),
([REAPER, "SteamLaunch", "AppId=12345678901"], None), # more than 10 digits
([], None),
]
for argv, want in cases:
with self.subTest(argv=argv):
self.assertEqual(frame_running.launch_appid(argv), want)
def test_reapers_are_this_users_live_launches(self):
procs = frame()
procs[700] = proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=777"], state="Z")
self.assertEqual(frame_running.reapers(procs, ME), {620: [400], 999: [500]})
def test_steamvr_is_never_a_game(self):
procs = frame()
procs[710] = proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=250820", "--", "vrstartup"])
procs[711] = proc(710, "vrcompositor-launcher")
self.assertNotIn(250820, frame_running.reapers(procs, ME))
found = frame_running.running(procs, ME, steam=lambda ids: ([250820], {}),
android=dict, containers=set)
self.assertNotIn(250820, [a["appid"] for a in found])
def test_gameid(self):
self.assertEqual(frame_running.gameid(620), "620")
# A non-Steam shortcut: (appid << 32) | 0x02000000, as steam_shortcuts.py stop sends.
self.assertEqual(frame_running.gameid(3346865537), str((3346865537 << 32) | 0x02000000))
def test_snapshot_reads_proc(self):
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(f"{tmp}/42")
os.makedirs(f"{tmp}/self")
fields = " ".join(["S", "7"] + ["0"] * 17 + ["123456"])
Path(f"{tmp}/42/stat").write_text(f"42 (my (game) x) {fields} 0 0\n")
Path(f"{tmp}/42/cmdline").write_bytes(b"reaper\0SteamLaunch\0AppId=5\0--\0g\0")
os.makedirs(f"{tmp}/43") # vanished between listdir and read
got = frame_running.snapshot(tmp)
self.assertEqual(list(got), [42])
self.assertEqual(got[42]["comm"], "my (game) x")
self.assertEqual((got[42]["ppid"], got[42]["start"], got[42]["state"]), (7, 123456, "S"))
self.assertEqual(got[42]["argv"], ["reaper", "SteamLaunch", "AppId=5", "--", "g"])
class Detection(unittest.TestCase):
def run_list(self, procs, steam=(None, {}), android=None, containers=()):
return frame_running.running(procs, ME, steam=lambda ids: steam, android=lambda: android or {},
containers=lambda: set(containers))
def test_steam_game_named_by_steam(self):
got = self.run_list(frame(), steam=([620], {620: "Portal 2", 999: "Other"}))
self.assertEqual([(a["appid"], a["kind"], a["name"]) for a in got],
[(620, "steam", "Portal 2"), (999, "steam", "Other")])
self.assertEqual(got[0]["gameid"], "620")
self.assertEqual(got[0]["processes"], 6)
def test_without_steam_the_manifest_names_it(self):
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(f"{tmp}/steamapps")
Path(f"{tmp}/steamapps/appmanifest_620.acf").write_text('"AppState"\n{\n\t"appid"\t"620"\n\t"name"\t"Portal 2"\n}')
with mock.patch.object(frame_running, "STEAM", tmp):
got = self.run_list(frame())
self.assertEqual([(a["appid"], a["name"]) for a in got], [(620, "Portal 2"), (999, "App 999")])
def test_nothing_running(self):
procs = {pid: p for pid, p in frame().items() if pid < 400}
self.assertEqual(self.run_list(procs), [])
def test_steam_only_listing_counts(self):
# Steam says it's running but no reaper was found: listed, with no processes.
got = self.run_list({1: proc(0, "systemd", uid=OTHER)}, steam=([620], {620: "Portal 2"}))
self.assertEqual(got, [{"appid": 620, "gameid": "620", "processes": 0, "kind": "steam", "name": "Portal 2"}])
def test_devkit_title_and_shortcut(self):
sid, other = 0x80001234, 0x80005678
procs = {1: proc(0, "systemd", uid=OTHER),
10: proc(1, "reaper", [REAPER, "SteamLaunch", f"AppId={sid}", "--", f'"{HOME}/devkit-game/Cool_Game/run.sh"']),
11: proc(10, "run.sh", ["/bin/sh", f"{HOME}/devkit-game/Cool_Game/run.sh"]),
20: proc(1, "reaper", [REAPER, "SteamLaunch", f"AppId={other}", "--", "/usr/bin/flatpak", "run", "x"])}
got = self.run_list(procs)
self.assertEqual([(a["kind"], a.get("title"), a["name"]) for a in got],
[("devkit", "Cool_Game", "Cool_Game"), ("shortcut", None, f"Shortcut {other}")])
self.assertEqual(got[0]["gameid"], str((sid << 32) | 0x02000000))
def test_android_app_from_its_container(self):
meta = {"package": "org.example.game", "label": "Example", "shortcut": 3346865537, "instance": 2812345678}
apps = {3346865537: meta, 2812345678: meta}
got = self.run_list({1: proc(0, "systemd", uid=OTHER)}, android=apps,
containers={"lepton-steamlaunch-2812345678"})
self.assertEqual(got, [{"appid": 3346865537, "gameid": str((3346865537 << 32) | 0x02000000), "processes": 0,
"kind": "android", "package": "org.example.game", "name": "Example"}])
# Its container stopped: not running.
self.assertEqual(self.run_list({1: proc(0, "systemd", uid=OTHER)}, android=apps, containers=()), [])
def test_android_meta_reads_shortcut_and_instance(self):
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(f"{tmp}/org.example.game")
Path(f"{tmp}/org.example.game/meta.json").write_text(json.dumps(
{"package": "org.example.game", "shortcut": 3346865537, "instance": 2812345678}))
os.makedirs(f"{tmp}/broken")
Path(f"{tmp}/broken/meta.json").write_text("{")
with mock.patch.object(frame_running, "ANDROID", tmp):
self.assertEqual(sorted(frame_running.android_apps()), [2812345678, 3346865537])
class World:
"""Processes that react to signals: kill() removes them, unless they ignore it."""
def __init__(self, procs, ignore=(), unkillable=()):
self.procs, self.ignore, self.unkillable = dict(procs), set(ignore), set(unkillable)
self.sent, self.now = [], 0.0
def scan(self):
return dict(self.procs)
def kill(self, pid, sig):
if pid not in self.procs:
raise ProcessLookupError
self.sent.append((pid, sig))
if pid in self.unkillable or (sig == frame_running.SIGTERM and pid in self.ignore):
return
self.procs.pop(pid)
# A reaper exits once its last child has.
for rid, p in list(self.procs.items()):
if p["comm"] == "reaper" and not any(c["ppid"] == rid for c in self.procs.values()):
self.procs.pop(rid)
def sleep(self, seconds):
self.now += seconds
def escalate(self, appid=620):
return frame_running.escalate(appid, me=203, uid=ME, scan=self.scan, kill=self.kill,
sleep=self.sleep, clock=lambda: self.now)
class Escalation(unittest.TestCase):
GAME = {401, 402, 403}
def assert_only_game(self, world, allowed=GAME | {400}):
signalled = {pid for pid, _ in world.sent}
self.assertTrue(signalled, "nothing was signalled")
self.assertLessEqual(signalled, allowed)
protected = frame_running.protected(frame(), 203, ME)
for pid in (1, 200, 201, 202, 203, 300, 301, 302, 303, 304, 305, 306, 307, 404, 405, 500, 501, 600, 601, 602):
self.assertNotIn(pid, signalled)
self.assertTrue({1, 201, 202, 203, 302, 303, 304, 305, 306, 307, 404, 600}.issubset(protected))
def test_sigterm_ends_it(self):
world = World(frame())
self.assertEqual(world.escalate(), "term")
self.assert_only_game(world)
self.assertEqual({pid for pid, sig in world.sent}, self.GAME)
self.assertTrue(all(sig == frame_running.SIGTERM for _, sig in world.sent))
# Deepest first, and the reaper isn't signalled while its children are alive.
self.assertEqual([pid for pid, _ in world.sent], [403, 402, 401])
self.assertNotIn(400, world.procs)
self.assertIn(404, world.procs) # the "steam" child under the game is never touched
def test_sigkill_after_a_grace_period(self):
world = World(frame(), ignore={403})
self.assertEqual(world.escalate(), "kill")
self.assert_only_game(world)
self.assertIn((403, frame_running.SIGTERM), world.sent)
self.assertIn((403, frame_running.SIGKILL), world.sent)
self.assertGreaterEqual(world.now, frame_running.TERM_WAIT)
kills = [pid for pid, sig in world.sent if sig == frame_running.SIGKILL]
self.assertEqual(kills[0], 403)
def test_survivor_is_an_error(self):
world = World(frame(), unkillable={403})
with self.assertRaisesRegex(frame_running.Fail, "still running after SIGKILL"):
world.escalate()
self.assert_only_game(world)
def test_reused_pid_is_not_signalled(self):
world = World(frame(), ignore={402, 403})
real_scan = world.scan
def scan():
procs = real_scan()
if world.sent and 402 in procs: # 402 exits and its pid goes to an unrelated program
procs[402] = proc(300, "editor", start=99999)
world.procs[402] = procs[402]
return procs
world.scan = scan
world.escalate()
self.assertNotIn((402, frame_running.SIGKILL), world.sent)
def test_nothing_left(self):
world = World({pid: p for pid, p in frame().items() if pid < 400})
self.assertEqual(world.escalate(), "gone")
self.assertEqual(world.sent, [])
def test_lingering_reaper_alone(self):
procs = {pid: p for pid, p in frame().items() if pid not in self.GAME and pid not in (404, 405)}
world = World(procs)
self.assertEqual(world.escalate(), "term")
self.assertEqual(world.sent, [(400, frame_running.SIGTERM)])
def test_protected_never_signalled_even_as_the_root(self):
# A reaper whose only children are protected: nothing to signal but the reaper itself.
procs = frame()
procs[402]["comm"] = "gamescope"
world = World(procs)
world.escalate()
self.assertNotIn(402, {pid for pid, _ in world.sent})
self.assertNotIn(403, {pid for pid, _ in world.sent})
class Quit(unittest.TestCase):
def setUp(self):
self.page = mock.MagicMock()
patches = [mock.patch.object(frame_running, "Page", return_value=self.page),
mock.patch.object(frame_running, "running", return_value=[
{"appid": 620, "gameid": "620", "name": "Portal 2", "kind": "steam", "processes": 4}]),
mock.patch.object(frame_running.os, "getuid", return_value=ME, create=True)] # none on Windows
for p in patches:
p.start()
self.addCleanup(p.stop)
def test_steam_terminate_first(self):
scans = iter([frame(), {}])
self.page.eval.return_value = {"running": [], "names": {}}
with mock.patch.object(frame_running, "snapshot", side_effect=lambda: next(scans)), \
mock.patch.object(frame_running, "escalate") as escalate:
out = frame_running.quit_game(620, sleep=lambda s: None)
self.assertEqual(out["method"], "steam")
self.page.eval.assert_any_call('SteamClient.Apps.TerminateApp("620", false)')
escalate.assert_not_called()
def test_signals_when_steam_leaves_it_running(self):
now = [0.0]
with mock.patch.object(frame_running, "snapshot", return_value=frame()), \
mock.patch.object(frame_running, "escalate", return_value="kill") as escalate:
out = frame_running.quit_game(620, sleep=lambda s: now.__setitem__(0, now[0] + s), clock=lambda: now[0])
escalate.assert_called_once_with(620)
self.assertGreaterEqual(now[0], frame_running.STEAM_WAIT)
self.assertEqual(out["message"], "Force quit Portal 2 (it had to be killed)")
def test_without_steam_goes_straight_to_signals(self):
frame_running.Page.side_effect = frame_running.Fail("no SharedJSContext page")
with mock.patch.object(frame_running, "snapshot", return_value=frame()), \
mock.patch.object(frame_running, "escalate", return_value="term") as escalate:
out = frame_running.quit_game(620, sleep=self.fail)
escalate.assert_called_once_with(620)
self.assertEqual(out["method"], "term")
def test_not_running(self):
frame_running.running.return_value = []
with mock.patch.object(frame_running, "escalate") as escalate:
self.assertEqual(frame_running.quit_game(620)["method"], "gone")
escalate.assert_not_called()
self.page.eval.assert_not_called()
class Script(unittest.TestCase):
def test_server_sends_it_with_frame_steam_inside(self):
import server
with mock.patch.object(server, "ssh", return_value='{"running": []}') as ssh:
self.assertEqual(server.running_games(), {"running": [], "message": "Nothing is running on the Frame"})
source = ssh.call_args.kwargs["stdin"]
self.assertEqual(ssh.call_args.args[0], "python3 - list")
# The same script runs as `python3 -`: frame_steam resolves, and bad usage is one JSON error.
r = subprocess.run([sys.executable, "-I", "-", "bogus"], input=source, capture_output=True, text=True,
cwd=tempfile.gettempdir(), timeout=30)
self.assertEqual(r.returncode, 1, r.stderr)
self.assertEqual(json.loads(r.stdout), {"error": "usage: list | quit APPID"})
class Server(unittest.TestCase):
def setUp(self):
import server
self.server = server
def quit(self, listed, quit_result=None, stop_error=None):
calls = []
def frame(*args, timeout=40):
calls.append(args)
return {"running": listed} if args == ("list",) else quit_result
with mock.patch.object(self.server, "running_frame", side_effect=frame), \
mock.patch.object(self.server, "ensure_master"), \
mock.patch.object(self.server.frame_android, "stop", side_effect=stop_error) as stop:
out = self.server.force_quit({"action": "quit", "appid": "3346865537"})
return out, calls, stop
def test_android_app_stops_its_container_first(self):
app = {"appid": 3346865537, "kind": "android", "package": "org.example.game", "name": "Example"}
out, calls, stop = self.quit([app], {"name": "Example", "method": "gone", "message": "That game isn't running"})
stop.assert_called_once_with("org.example.game")
self.assertEqual(calls, [("list",), ("quit", "3346865537")])
self.assertEqual(out["message"], "Stopped Example")
def test_android_stop_failure_falls_back_to_processes(self):
app = {"appid": 3346865537, "kind": "android", "package": "org.example.game", "name": "Example"}
err = self.server.frame_android.FrameError("podman failed")
out, calls, _ = self.quit([app], {"name": "Example", "method": "term", "message": "Force quit Example"}, err)
self.assertEqual(calls[-1], ("quit", "3346865537"))
self.assertEqual(out["message"], "Force quit Example")
def test_other_games_skip_the_android_path(self):
app = {"appid": 3346865537, "kind": "devkit", "title": "Cool_Game", "name": "Cool_Game"}
out, _, stop = self.quit([app], {"name": "Cool_Game", "method": "steam", "message": "Quit Cool_Game"})
stop.assert_not_called()
self.assertEqual(out["message"], "Quit Cool_Game")
def test_not_running_is_a_conflict(self):
with self.assertRaises(self.server.Failure) as cm:
self.quit([])
self.assertEqual(cm.exception.status, 409)
class Routes(test_server.ServerGuards):
"""Validation runs before any SSH."""
def test_input_validation(self):
for body in ({"action": "quit", "appid": "620; reboot"}, {"action": "quit", "appid": ""},
{"action": "kill", "appid": "620"}, {"appid": "620"}, {"action": "quit", "appid": "12345678901"}):
status, payload = self.post("/api/running", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
def test_routes_need_custom_header(self):
self.assertEqual(self.request("GET", "/api/running")[0], 403)
self.assertEqual(self.request("POST", "/api/running", {"action": "quit", "appid": "620"})[0], 403)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -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'''
+1 -1
View File
@@ -6,7 +6,7 @@ const pick = (s,a,b) => s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)));
const tick = async () => { for (let i=0;i<16;i++) await Promise.resolve(); };
function connectionFixture() {
const f=workspaceFixture(), c=f.context;
Object.assign(c,{URLSearchParams,Date,clearTimeout,link:{s:{phase:'connected'},device:'a',phase:'connected',rev:0},disp:{list:[],seq:0},cat:{apps:null},wi:{},devGen:1,androidSeq:0,androidPending:new Set(),titlesSeq:0,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
Object.assign(c,{URLSearchParams,Date,clearTimeout,link:{s:{phase:'connected'},device:'a',phase:'connected',rev:0},disp:{list:[],seq:0},cat:{apps:null},wi:{},devGen:1,androidSeq:0,androidPending:new Set(),titlesSeq:0,runningGames:[],runningSeq:0,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},resetClips(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
c.window.connDevice='a';
+2
View File
@@ -47,3 +47,5 @@
@media (max-width:900px) { .cc-workspace { grid-template-columns:minmax(0,1fr); }.cc-sidebar { position:static; }.cc-sidebar > p,.cc-status { display:none; }.cc-categories { flex-direction:row; overflow-x:auto; padding-bottom:6px; }.cc-categories button { flex:none; white-space:nowrap; }.cc-category-count { margin-left:2px; }.cc-categories button[aria-pressed=true] { box-shadow:inset 0 -2px var(--blue); }.cc-summary { gap:10px; }.cc-metric { padding:15px; } }
@media (max-width:640px) { .cc { gap:20px; }.cc-hero { padding:22px 20px; }.cc-hero p { font-size:12px; }.cc-hero-mark { width:72px; height:55px; }.cc-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-metric > strong { font-size:24px; }.cc-topline { gap:8px; }.cc-sample { order:3; width:100%; }.cc-device-facts { padding:14px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-cards { grid-template-columns:minmax(0,1fr); gap:12px; }.cc-search-row { gap:8px; }.cc-results { font-size:10px; }.cc-card { padding:20px; }.cc-card h2 { font-size:18px; } }
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-links a { min-height:44px; }.cc-audio-actions button,.cc-topline button { min-height:44px; }.cc-search button { min-width:36px; min-height:44px; } }
.cc-running { list-style:none; margin:12px 0 0; padding:0; display:grid; gap:6px; }.cc-running:empty { display:none; }.cc-running li { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:8px 10px; border-radius:6px; background:#ffffff0a; }.cc-running span { min-width:0; overflow-wrap:anywhere; }.cc-running small { display:block; font-size:11px; color:var(--muted); }.cc-running button { border-radius:5px; flex:none; }
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-running button { min-height:44px; } }
+73 -4
View File
@@ -54,6 +54,9 @@
{id:'steam', category:'apps', icon:'game', title:'Steam library', kind:'flow', label:'Existing controls',
description:'Browse owned games and Frame ratings, install with progress, or launch an installed game. Purchases and license prompts use Steam.',
keywords:'games download install store owned compatibility rating verified proton', links:[['#library','Installed games'],['#getgames','Library & store']]},
{id:'forcequit', category:'apps', icon:'power', title:'Force quit a game', kind:'control', label:'Available here',
description:'See what’s running on the Frame and quit a game or app that has stopped responding. Steam is asked first; if the game stays open, its own processes are stopped. Unsaved progress is lost.',
keywords:'hang hung frozen freeze crash stuck kill close stop end task running not responding exit', special:'forcequit'},
{id:'android', category:'apps', icon:'apps', title:'Android apps', kind:'flow', label:'Existing controls',
description:'Discover apps, manage Lepton instances, and launch, stop, test or remove installed apps. View compatibility reports before installing.',
keywords:'apk fdroid lepton application sideload sources repository reports', links:[['#appStore','Discover apps'],['#androidLibrary','Manage installed apps']]},
@@ -131,6 +134,8 @@
categories.find(([id])=>id === item.category)?.[1]].join(' ').toLowerCase();
return (category === 'all' || item.category === category) && query.toLowerCase().trim().split(/\s+/).every(word => text.includes(word));
}
const esc = value => String(value).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
const KINDS = {steam:'Steam game', devkit:'Sideloaded game', shortcut:'Non-Steam shortcut', android:'Android app'};
const number = (value, suffix = '', digits = 0) => Number.isFinite(value) ? value.toLocaleString(undefined, {maximumFractionDigits:digits}) + suffix : 'Unavailable';
const gib = value => Number.isFinite(value) ? number(value / 1073741824, ' GiB', 1) : 'Unavailable';
const duration = seconds => Number.isFinite(seconds) && seconds >= 0 ? seconds < 3600 ? `${Math.floor(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.floor(seconds % 3600 / 60)} min` : 'Unavailable';
@@ -162,9 +167,30 @@
// Strict validation and captured device identity apply even if a stale enabled
// button is clicked during a disconnect, headset switch, or pending request.
function createController({api, getContext}) {
let busy = false;
let busy = false, quitting = false;
const connected = message => {
const context = getContext();
if (context.online !== true) throw new Error(message);
return context;
};
return {
get busy() { return busy; },
get quitting() { return quitting; },
async running() {
const context = connected('Connect to the Frame to see what’s running.');
const result = await api('/api/running', undefined, context.device);
return getContext().generation === context.generation ? result : null;
},
async forceQuit(app) {
if (quitting) throw new Error('A force quit is still in progress.');
if (!/^\d{1,10}$/.test(String(app?.appid))) throw new Error('Choose a running game to quit.');
const context = connected('Connect to the Frame before quitting a game.');
quitting = true;
try {
const result = await api('/api/running', {action:'quit', appid:String(app.appid)}, context.device);
return getContext().generation === context.generation ? result : null;
} finally { quitting = false; }
},
async audio(change) {
if (busy) throw new Error('An audio change is still in progress.');
const context = getContext();
@@ -182,16 +208,17 @@
},
};
}
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform}) {
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform, confirm = text => globalThis.confirm(text)}) {
const get = id => document.getElementById(id), page = get('controlCenter');
let category = 'all', sample = null, receivedAt = null, available = false, error = '', dirty = false, sampleGeneration = null, audioPending = false;
const metric = (id, label) => `<div><dt>${label}</dt><dd data-cc-metric="${id}">Unavailable</dd></div>`;
const perf = `<dl class="cc-readings">${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.</p><p class="cc-note" data-cc-metric="thermal">Trip data unavailable</p>`;
const audio = `<div class="cc-audio"><label for="ccVolume">Output volume <output id="ccVolumeValue" for="ccVolume">—</output></label><input id="ccVolume" type="range" min="0" max="100" step="1" value="0" disabled><div class="cc-audio-actions"><button class="action small" id="ccApplyVolume" disabled>Apply volume</button><button class="small" id="ccMute" disabled>Mute</button><span id="ccAudioReadback" class="cc-note">Waiting for an audio reading</span></div><p class="cc-note" id="ccAudioResult" role="status"></p></div>`;
const health = `<dl class="cc-readings">${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
const forcequit = `<div class="cc-quit"><div class="cc-audio-actions"><button class="small" type="button" id="ccRunningCheck">Check what’s running</button></div><ul class="cc-running" id="ccRunningList"></ul><p class="cc-note" id="ccRunningResult" role="status"></p></div>`;
const storage = `<dl class="cc-readings">${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>`<button type="button" data-cc-category="${id}" aria-pressed="${id === 'all'}">${icon(symbol)}<span>${label}</span><span class="cc-category-count"></span></button>`).join('');
const specials = {audio, performance:perf, health, storage};
const specials = {audio, performance:perf, health, storage, forcequit};
get('ccCards').innerHTML = catalog.map(item=>`<section class="cc-card" data-cc-card="${item.id}"><div class="cc-card-head"><span class="cc-icon">${icon(item.icon)}</span><span class="cc-kind cc-kind-${item.kind}">${item.label}</span></div><h2>${item.title}</h2><p>${item.description}</p>${specials[item.special] || ''}${item.guidance ? `<div class="cc-guidance">${item.guidance}</div>` : ''}${item.links ? `<div class="cc-links">${item.links.map(([href,label,desktop])=>`<a href="${href}"${desktop ? ' class="desk-only"' : ''}${item.platform === 'mac' ? ' data-cc-mac-link' : ''}>${label}<span aria-hidden="true">↗</span></a>`).join('')}</div>` : ''}</section>`).join('');
// Host-only features stay searchable with a reason; only their unsupported
// action disappears. The host API, rather than viewport/UA, decides availability.
@@ -287,6 +314,48 @@
get('ccAudioResult').textContent = `Couldn’t apply audio: ${e.message}`;
} finally { audioPending = false; renderSample(); }
}
// Force quit: an explicit check, then a confirmation for each game. A headset
// switch drops the list, so a stale entry can't quit the other Frame's game.
let runningApps = [];
function renderRunning(message) {
get('ccRunningList').innerHTML = runningApps.map(app=>`<li><span>${esc(app.name)}<small>${KINDS[app.kind] || 'Running'}</small></span><button class="small danger" type="button" data-cc-quit="${esc(app.appid)}"${controller.quitting ? ' disabled' : ''}>Force quit…</button></li>`).join('');
if (message !== undefined) get('ccRunningResult').textContent = message;
}
async function checkRunning() {
const generation = getContext().generation;
get('ccRunningCheck').disabled = true;
get('ccRunningResult').textContent = 'Checking…';
try {
const result = await controller.running();
if (!result || generation !== getContext().generation) return;
runningApps = Array.isArray(result.running) ? result.running : [];
renderRunning(runningApps.length ? `${runningApps.length} running. Force quit only a game that has stopped responding.` : 'Nothing is running on the Frame.');
} catch (e) {
if (generation !== getContext().generation) return;
runningApps = []; renderRunning(`Couldn’t check what’s running: ${e.message}`);
} finally { get('ccRunningCheck').disabled = false; }
}
async function forceQuit(appid) {
const app = runningApps.find(item=>String(item.appid) === String(appid));
if (!app || controller.quitting) return;
if (!confirm(`Force quit ${app.name}? Unsaved progress will be lost.`)) return;
const generation = getContext().generation, pending = controller.forceQuit(app);
renderRunning(`Quitting ${app.name}…`);
try {
const result = await pending;
if (!result || generation !== getContext().generation) return;
runningApps = runningApps.filter(item=>item !== app);
renderRunning(result.message || `Quit ${app.name}`);
} catch (e) {
if (generation !== getContext().generation) return;
renderRunning(`Couldn’t force quit ${app.name}: ${e.message}`);
} finally { if (generation === getContext().generation) renderRunning(); }
}
get('ccRunningCheck').onclick = checkRunning;
get('ccRunningList').onclick = event => {
const button = event.target.closest('[data-cc-quit]');
if (button) forceQuit(button.dataset.ccQuit);
};
get('ccVolume').oninput = () => { dirty = true; get('ccVolumeValue').textContent = `${get('ccVolume').value}%`; };
get('ccApplyVolume').onclick = () => changeAudio({level:Number(get('ccVolume').value) / 100});
get('ccMute').onclick = () => changeAudio({muted:!sample?.volume?.muted});
@@ -312,7 +381,7 @@
},
unavailable(reason) { if (reason?.stale) return; available = false; error = `No live sample${reason?.message ? ' · ' + reason.message : ''}`; renderSample(); },
connection(ok) { if (!ok) { available = false; renderSample(); } else audioAvailability(); },
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; renderSample(); },
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; runningApps = []; renderRunning(''); renderSample(); },
destroy() { clearInterval(timer); },
};
}
+402
View File
@@ -0,0 +1,402 @@
"""Backup and recovery kit: copy game saves, settings and Android app data off the Frame,
put them back after a factory reset, and get out of Desktop Mode. Python stdlib only.
A backup is a folder in ~/Documents/Frame Control Backups/<YYYY-MM-DD-HHMMSS>/ on this
computer: one tar.gz per part (saves/, settings/, android/) and manifest.json listing every
file with its size and SHA-256, the archives' checksums and the headset's BUILD_ID. What the
parts hold, and what is never copied, is decided on the headset by frame_backup_remote.py.
Backing up only reads the headset. See docs/backup-and-recovery.md.
CLI: python3 ui/frame_backup.py backup | list | plan NAME | restore NAME PART... | session | game-mode
"""
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import sys
import tarfile
import time
import frame_android as android
import frame_host
REMOTE = Path(__file__).resolve().parent / 'frame_backup_remote.py'
NAME = re.compile(r'^\d{4}-\d{2}-\d{2}-\d{6}$')
ARCHIVE = re.compile(r'^(saves|settings|android)/[A-Za-z0-9._-]{1,200}\.tar\.gz$')
PART = re.compile(r'^(saves:(\d{1,10}|linux)|settings:(steam|system)|android:[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+)$')
KINDS = ('saves', 'settings', 'android')
FORMAT = 1
# The switch Valve's own devkit tools use (frame/devkit-utils/steamos-delete). Nothing else
# is ever run as a session command.
SELECTORS = ('holo-session-select', 'steamos-session-select')
REASONS = {
'not_installed': 'Install the game first, then restore its saves',
'no_prefix': 'Start the game once so Proton sets up its folders, then restore',
'no_account': 'Sign in to Steam on the headset first',
'reference': 'Kept for reference only; copy by hand if you need it',
'refused': 'Not restored: the path is not safe to write',
'running': 'Close the app first',
'damaged': "The backup file doesn't match its checksum",
'missing': 'The backup file is missing',
'empty': 'Nothing to restore',
'unknown': 'Not something this version of Frame Control can restore',
}
class BackupError(android.FrameError):
pass
def backup_root():
return Path(os.environ.get('FRAME_BACKUP_DIR') or Path.home() / 'Documents' / 'Frame Control Backups')
def backup_path(name):
"""The folder for a backup name; refuses anything that isn't one of ours."""
if not isinstance(name, str) or not NAME.fullmatch(name):
raise BackupError('not a backup name')
root = backup_root().resolve()
path = (root / name).resolve()
if path.parent != root:
raise BackupError('not a backup name')
return path
def archive_name(part):
kind, _, rest = part.partition(':')
return '%s/%s.tar.gz' % (kind, rest if kind == 'android' else part.replace(':', '-'))
def remote_command(cmd, *args):
return shlex.join(['python3', '-c', REMOTE.read_text(), cmd, *args])
def _ssh(command, src=None, dst=None, timeout=1800):
try:
# src: bytes, or a file to stream (an archive can be larger than memory).
feed = {'input': src} if isinstance(src, bytes) else {
'stdin': src if src is not None else subprocess.DEVNULL}
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command], **feed,
stdout=dst if dst is not None else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout)
except subprocess.TimeoutExpired:
raise BackupError('the headset took too long to answer')
except OSError as error:
raise BackupError('could not reach the headset: ' + str(error))
if result.returncode:
raise BackupError(result.stderr.decode(errors='replace').strip()[-600:] or
'the headset command failed')
return result.stdout
def remote_json(cmd, *args, src=None, timeout=120):
out = _ssh(remote_command(cmd, *args), src=src, timeout=timeout)
try:
return json.loads(out)
except (ValueError, TypeError):
raise BackupError('unexpected answer from the headset')
def _sha256(path):
digest = hashlib.sha256()
with open(str(path), 'rb') as src:
for chunk in iter(lambda: src.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def inspect_archive(path):
"""Every file in one of our part archives, with its size and SHA-256; refuses anything else."""
files, names = [], set()
try:
with tarfile.open(str(path), 'r:gz') as archive:
for member in archive:
name = member.name
parts = name.split('/')
if (not member.isfile() or name.startswith('/') or '\\' in name or name in names
or any(p in ('', '.', '..') for p in parts)):
raise BackupError('backup archive has an unsafe entry: %r' % name[:200])
names.add(name)
digest = hashlib.sha256()
src = archive.extractfile(member)
for chunk in iter(lambda: src.read(1024 * 1024), b''):
digest.update(chunk)
files.append({'path': name, 'bytes': member.size, 'sha256': digest.hexdigest()})
except (OSError, EOFError, tarfile.TarError) as error:
raise BackupError('could not read the backup archive: ' + str(error))
return files
def preview():
"""What a backup would hold, before making one."""
plan = remote_json('plan')
apps, error = _android_apps()
return {'build': plan.get('build'), 'version': plan.get('version'), 'parts': plan.get('parts', []),
'android': apps, 'android_error': error, 'excluded': plan.get('excluded', []), 'folder': str(backup_root())}
def _android_apps():
"""(installed Android apps, None) or ([], why they couldn't be listed)."""
try:
return [{'package': a['package'], 'label': a.get('label') or a['package'],
'running': bool(a.get('running'))} for a in android.list_apps()], None
except android.FrameError as error:
return [], str(error)
def backup(report=lambda stage, percent=None: None, kinds=KINDS, headset=None):
kinds = [k for k in KINDS if k in kinds]
if not kinds:
raise BackupError('choose something to back up')
report('Reading what is on the headset', 1)
plan = remote_json('plan')
parts = [p for p in plan.get('parts', []) if p.get('kind') in kinds and PART.fullmatch(str(p.get('id')))]
apps, error = _android_apps() if 'android' in kinds else ([], None)
root = backup_root()
root.mkdir(parents=True, exist_ok=True)
name = time.strftime('%Y-%m-%d-%H%M%S')
final = backup_path(name)
if final.exists():
raise BackupError('a backup was started this second already; try again')
# Built under a hidden name and renamed when complete, so a cut-off backup is never listed.
staging = root / ('.' + name + '.partial')
staging.mkdir()
entries = []
skipped = [{'id': 'android', 'label': 'Android apps', 'reason': error}] if error else []
total = len(parts) + len(apps) or 1
try:
for i, part in enumerate(parts):
report('Copying ' + part['label'], 2 + 90 * i / total)
rel = archive_name(part['id'])
dest = staging / rel
dest.parent.mkdir(parents=True, exist_ok=True)
with open(str(dest), 'wb') as dst:
_ssh(remote_command('pack', part['id']), dst=dst)
files = inspect_archive(dest)
entries.append({'id': part['id'], 'kind': part['kind'], 'label': part['label'],
'appid': part.get('appid'), 'archive': rel, 'sha256': _sha256(dest),
'archive_bytes': dest.stat().st_size, 'files': files,
'bytes': sum(f['bytes'] for f in files)})
skipped += [{'id': part['id'], 'label': part['label'], 'path': s['path'], 'reason': s['reason']}
for s in part.get('skipped', [])]
for j, app in enumerate(apps):
report('Copying ' + app['label'] + ' (Android app data)', 2 + 90 * (len(parts) + j) / total)
part_id = 'android:' + app['package']
if app['running']:
skipped.append({'id': part_id, 'label': app['label'], 'reason': 'It was open; close it and back up again'})
continue
rel = archive_name(part_id)
dest = staging / rel
dest.parent.mkdir(parents=True, exist_ok=True)
try:
result = android.backup_data(app['package'], dest)
except android.FrameError as error:
skipped.append({'id': part_id, 'label': app['label'], 'reason': str(error)})
continue
entries.append({'id': part_id, 'kind': 'android', 'label': app['label'] + ' (Android app data)',
'package': app['package'], 'instance': result.get('instance'), 'archive': rel,
'sha256': result['sha256'], 'archive_bytes': dest.stat().st_size,
'files': result.get('files'), 'bytes': result.get('bytes')})
report('Writing the manifest', 95)
manifest = {'format': FORMAT, 'app': 'Frame Control', 'name': name,
'created': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
'headset': {'name': headset or android.FRAME, 'build': plan.get('build'),
'version': plan.get('version')},
'parts': entries, 'skipped': skipped, 'excluded': plan.get('excluded', [])}
(staging / 'manifest.json').write_text(json.dumps(manifest, indent=1))
os.rename(str(staging), str(final))
except BaseException:
shutil.rmtree(str(staging), ignore_errors=True)
raise
size = sum(e['archive_bytes'] for e in entries)
message = 'Backed up %d part%s (%s) to %s' % (len(entries), '' if len(entries) == 1 else 's',
human(size), final)
if skipped:
message += '; %d skipped' % len(skipped)
return {'message': message, 'name': name, 'path': str(final), 'parts': len(entries),
'bytes': size, 'skipped': skipped}
def human(n):
for unit in ('bytes', 'KB', 'MB', 'GB'):
if n < 1000 or unit == 'GB':
return ('%d %s' % (n, unit)) if unit == 'bytes' else '%.1f %s' % (n, unit)
n /= 1000.0
def read_manifest(name):
path = backup_path(name)
try:
manifest = json.loads((path / 'manifest.json').read_text())
except (OSError, ValueError):
raise BackupError('that backup has no readable manifest')
if not isinstance(manifest, dict) or manifest.get('format') != FORMAT or \
not isinstance(manifest.get('parts'), list):
raise BackupError('that backup was made by a newer or unknown version')
for part in manifest['parts']:
if (not isinstance(part, dict) or not PART.fullmatch(str(part.get('id'))) or
not ARCHIVE.fullmatch(str(part.get('archive'))) or part['archive'] != archive_name(part['id'])):
raise BackupError('that backup manifest lists something unexpected')
return path, manifest
def list_backups():
root = backup_root()
out = []
try:
names = sorted((p.name for p in root.iterdir() if NAME.fullmatch(p.name) and p.is_dir()), reverse=True)
except OSError:
names = []
for name in names:
try:
_, m = read_manifest(name)
except BackupError as error:
out.append({'name': name, 'error': str(error)})
continue
out.append({'name': name, 'created': m.get('created'), 'build': (m.get('headset') or {}).get('build'),
'headset': (m.get('headset') or {}).get('name'), 'parts': len(m['parts']),
'bytes': sum(p.get('archive_bytes') or 0 for p in m['parts'])})
return {'backups': out, 'folder': str(root)}
def restore_plan(name):
"""Each part of a backup and whether it can go back now; reads only."""
path, manifest = read_manifest(name)
plans, check = {}, []
for part in manifest['parts']:
archive = path / part['archive']
state = None
if not archive.is_file():
state = 'missing'
elif _sha256(archive) != part.get('sha256'):
state = 'damaged'
plans[part['id']] = {'id': part['id'], 'kind': part.get('kind'), 'label': part.get('label'),
'bytes': part.get('bytes'), 'status': state}
if state is None and part['kind'] != 'android':
check.append({'id': part['id'], 'files': [f['path'] for f in part.get('files') or []]})
if check:
answer = remote_json('check', src=json.dumps({'parts': check}).encode())
for result in answer.get('parts', []):
plan = plans.get(result.get('id'))
if plan:
plan.update(status=result.get('status'), replace=result.get('replace', 0),
new=result.get('new', 0), held=result.get('held', {}))
android_parts = [p for p in plans.values() if p['kind'] == 'android' and p['status'] is None]
if android_parts:
installed = {a['package']: a for a in android.list_apps()}
for plan in android_parts:
app = installed.get(plan['id'].split(':', 1)[1])
plan['status'] = 'not_installed' if not app else 'running' if app.get('running') else 'ok'
if plan['status'] == 'not_installed':
plan['reason'] = 'Install the app first, then restore its data'
elif plan['status'] == 'ok':
plan['replace'] = 1 # the app's whole private data folder, kept beside it
for plan in plans.values():
plan.setdefault('reason', REASONS.get(plan['status']) if plan['status'] != 'ok' else None)
return {'name': name, 'headset': manifest.get('headset'), 'parts': list(plans.values())}
def restore(name, ids, confirmed=False, report=lambda stage, percent=None: None):
"""Restore the chosen parts that can go back now; the rest are skipped, not failed."""
if not isinstance(ids, list) or not ids or not all(isinstance(i, str) and PART.fullmatch(i) for i in ids):
raise BackupError('choose what to restore')
if confirmed is not True:
raise BackupError('confirm the restore first: it replaces files on the headset')
path, manifest = read_manifest(name)
by_id = {p['id']: p for p in manifest['parts']}
plan = {p['id']: p for p in restore_plan(name)['parts']}
stamp = time.strftime('%Y%m%d-%H%M%S')
done, skipped = [], []
for i, part_id in enumerate(ids):
part, state = by_id.get(part_id), plan.get(part_id)
if not part or not state:
skipped.append({'id': part_id, 'reason': 'Not in this backup'})
continue
if state['status'] != 'ok':
skipped.append({'id': part_id, 'label': state['label'], 'reason': state.get('reason') or state['status']})
continue
report('Restoring ' + state['label'], 5 + 90 * i / len(ids))
archive = path / part['archive']
try:
if part['kind'] == 'android':
result = android.restore_data(part_id.split(':', 1)[1], str(archive))
else:
with open(str(archive), 'rb') as src:
result = remote_json('restore', part_id, stamp, src=src, timeout=1800)
except android.FrameError as error:
skipped.append({'id': part_id, 'label': state['label'], 'reason': str(error)})
continue
done.append(dict(result, id=part_id, label=state['label']))
message = 'Restored %d part%s' % (len(done), '' if len(done) == 1 else 's')
if skipped:
message += ', skipped %d' % len(skipped)
return {'message': message, 'restored': done, 'skipped': skipped}
# ---- Game Mode ---------------------------------------------------------------
def session_status():
return remote_json('session', timeout=30)
def session_command(selector):
"""The switch to Game Mode, exactly as Valve's devkit tools run it."""
if selector not in SELECTORS:
raise BackupError('this SteamOS build has no session switch command; use the steps in the guide')
return shlex.join([selector, 'gamescope'])
def return_to_game_mode(confirmed=False):
if confirmed is not True:
raise BackupError('confirm first: switching closes the desktop and any apps open in it')
state = session_status()
if state.get('mode') == 'game':
return {'message': 'The headset is already in Game Mode. The desktop you see is a panel: '
'close it from the Steam dashboard.', 'state': state, 'switched': False}
if state.get('mode') != 'desktop':
# Only a desktop session seen running is switched: the switch restarts the session,
# which would close whatever is running in Game Mode.
raise BackupError("couldn't tell that the headset is in Desktop Mode, so nothing was changed. "
'Use the manual steps in the guide if it is stuck.')
command = session_command(state.get('selector'))
try:
# The session restarts under it, so the command may not get to answer.
_ssh(command, timeout=60)
except BackupError as error:
if 'took too long' not in str(error):
raise BackupError('the switch was refused: %s. Use the manual steps in the guide.' % error)
return {'message': 'Asked the headset to return to Game Mode; it restarts its session in a few seconds',
'state': state, 'switched': True, 'command': command}
def main():
cmd, *args = sys.argv[1:] or ['help']
try:
if cmd == 'backup':
r = backup(lambda stage, percent=None: print(stage, file=sys.stderr))
elif cmd == 'list':
r = list_backups()
elif cmd == 'plan' and len(args) == 1:
r = restore_plan(args[0])
elif cmd == 'restore' and len(args) >= 2:
r = restore(args[0], args[1:], confirmed=True)
elif cmd == 'session':
r = session_status()
elif cmd == 'game-mode':
r = return_to_game_mode(confirmed=True)
else:
print(__doc__.strip().splitlines()[-1], file=sys.stderr)
return 2
except android.FrameError as error:
print('error:', error, file=sys.stderr)
return 1
print(json.dumps(r, indent=1))
return 0
if __name__ == '__main__':
sys.exit(main())
+442
View File
@@ -0,0 +1,442 @@
"""Backup and restore on the Frame: what to save, packing it and putting it back. Stdlib only.
Runs on the headset as `python3 -c <this file> <command> ...` (ui/frame_backup.py sends it),
so stdin is free for the archive or file list. Commands:
plan JSON: the headset's build and every part worth saving, with sizes
pack PART a tar.gz of that part's files on stdout (reads only)
check stdin JSON {"parts": [{"id", "files": [paths]}]}: what a restore would do
restore PART STAMP stdin tar.gz: write that part's restorable files, keeping replaced ones
session JSON: Game Mode or Desktop Mode, and the switch command on this image
Paths are relative to the home folder, with '/' separators, everywhere: in archives, in the
manifest and in `check`. Nothing here deletes a file. `restore` replaces only files named in
its archive, and first copies each one it replaces under PREVIOUS/<stamp>/.
"""
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
# FRAME_BACKUP_HOME: the tests point this at a folder laid out like the Frame's home.
HOME = Path(os.environ.get('FRAME_BACKUP_HOME') or Path.home())
STEAM = '.local/share/Steam'
COMPAT = STEAM + '/steamapps/compatdata'
PREFIX_USER = 'pfx/drive_c/users/steamuser'
# Where Windows games keep saves inside a Proton prefix. Steam Cloud covers many games, not all.
SAVE_DIRS = ('AppData/Roaming', 'AppData/Local', 'AppData/LocalLow', 'Documents', 'Saved Games')
# Wine's and Windows' own folders inside those: caches, not saves.
SAVE_SKIP = ('AppData/Local/Temp', 'AppData/Local/Microsoft', 'AppData/Roaming/Microsoft',
'AppData/Local/D3DSCache', 'AppData/Local/NVIDIA', 'AppData/Local/CrashDumps',
'AppData/LocalLow/Microsoft')
# Native Linux games: the engines' standard save folders.
LINUX_SAVES = ('.config/unity3d', '.local/share/godot/app_userdata')
# Steam settings. Controller layouts go back; the rest is kept so you can look things up.
STEAM_RESTORE = ('241100/remote/controller_config',) # under userdata/<account>/
STEAM_REFERENCE = ('config/localconfig.vdf', 'config/shortcuts.vdf', 'config/grid')
STEAM_CONTROLLER = STEAM + '/steamapps/common/Steam Controller Configs' # <account>/config/
# Desktop settings worth keeping. Steam rewrites steamvr.vrsettings while SteamVR runs, so
# it's only for reference.
SYSTEM_RESTORE = ('.config/kdeglobals', '.config/kwinrc', '.config/kxkbrc', '.config/konsolerc',
'.config/dolphinrc')
SYSTEM_REFERENCE = ('.config/openvr/config/steamvr.vrsettings',)
PREVIOUS = '.local/share/frame-control/restore-previous'
MAX_FILE = 2 * 1024 ** 3 # a bigger "save" is a cache or a video; listed as skipped
MAX_PART = 20 * 1024 ** 3
MAX_FILES = 100000
SHORTCUT_IDS = 2 ** 31 # non-Steam shortcuts and Lepton instances use ids above this
# Never copied, whatever folder they turn up in. Checked on every path, packing and restoring.
SECRET_NAMES = {'steam.token', 'loginusers.vdf', 'registry.vdf', 'config.vdf', 'secrets.env',
'authorized_keys', 'known_hosts', 'credentials', 'credentials.json'}
SECRET_DIRS = {'.ssh', '.gnupg', '.pki', 'keyrings', 'kwalletd', 'cliproxyapi', 'tailscale'}
SECRET_SUFFIXES = ('.token', '.pem', '.key', '.kwl')
EXCLUDED = [
'Steam sign-in: steam.token, config/config.vdf, loginusers.vdf, registry.vdf, ssfn files',
'Keys and passwords: ~/.ssh, ~/.gnupg, keyrings and KWallet, *.token/*.key/*.pem files, '
'Tailscale and proxy settings (secrets.env)',
'Games themselves, Proton and runtimes (reinstall them from Steam), shader caches, Steam '
'Cloud folders under userdata/<account>/<appid>, screenshots (Tools → Capture & Live saves them)',
'Android apps themselves and their shared storage (/sdcard); only private app data is saved',
'Symbolic links, and single files over 2 GiB',
]
def is_secret(rel):
parts = PurePosixPath(rel).parts
name = parts[-1].lower() if parts else ''
return (name in SECRET_NAMES or name.startswith('ssfn') or name.startswith('id_rsa') or
name.startswith('id_ed25519') or name.startswith('id_ecdsa') or
name.endswith(SECRET_SUFFIXES) or any(p.lower() in SECRET_DIRS for p in parts[:-1]))
def safe_rel(rel):
"""A relative '/' path with no '..', '.', empty or absolute parts."""
if not isinstance(rel, str) or not rel or '\\' in rel or '\0' in rel or rel.startswith('/'):
return False
parts = rel.split('/')
return all(p not in ('', '.', '..') for p in parts) and not re.match(r'^[A-Za-z]:', rel)
def _under(rel, root):
return rel == root or rel.startswith(root + '/')
def _walk(rel_root, skip=()):
"""Regular files under HOME/rel_root, never following links: [(rel, size)], [skipped]."""
files, skipped = [], []
base = HOME / rel_root
if base.is_symlink() or not base.is_dir():
if base.is_file() and not base.is_symlink():
return [(rel_root, base.stat().st_size)], skipped
return files, skipped
for top, dirs, names in os.walk(str(base)):
rel_top = PurePosixPath(rel_root, *Path(top).relative_to(base).parts).as_posix()
dirs[:] = sorted(d for d in dirs if not os.path.islink(os.path.join(top, d)) and
not any(_under(rel_top + '/' + d, s) for s in skip) and
not is_secret(rel_top + '/' + d + '/x'))
for name in sorted(names):
path, rel = os.path.join(top, name), rel_top + '/' + name
if os.path.islink(path) or is_secret(rel):
continue
try:
st = os.lstat(path)
except OSError:
continue
if not os.path.isfile(path):
continue
if st.st_size > MAX_FILE:
skipped.append({'path': rel, 'reason': 'over 2 GiB'})
continue
files.append((rel, st.st_size))
return files, skipped
def _manifest_field(text, key):
m = re.search(r'"%s"\s+"([^"]*)"' % key, text)
return m.group(1) if m else None
def game_name(appid):
try:
text = (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).read_text(errors='replace')
except OSError:
return None
return _manifest_field(text, 'name')
def accounts():
root = HOME / STEAM / 'userdata'
try:
return sorted(p.name for p in root.iterdir() if p.name.isdigit() and p.is_dir()
and not p.is_symlink() and p.name != '0')
except OSError:
return []
def _prefixes():
try:
return sorted((p.name for p in (HOME / COMPAT).iterdir()
if p.name.isdigit() and (p / PREFIX_USER).is_dir()), key=int)
except OSError:
return []
def part_roots(part):
"""(roots to save, roots that a restore may write) for a part id, or None if unknown."""
if part.startswith('saves:') and part[6:].isdigit():
user = '%s/%s/%s' % (COMPAT, part[6:], PREFIX_USER)
roots = tuple(user + '/' + d for d in SAVE_DIRS)
return roots, roots
if part == 'saves:linux':
return LINUX_SAVES, LINUX_SAVES
if part == 'settings:steam':
save, restore = [], []
for acct in accounts():
base = '%s/userdata/%s/' % (STEAM, acct)
restore += [base + r for r in STEAM_RESTORE] + ['%s/%s/config' % (STEAM_CONTROLLER, acct)]
save += [base + r for r in STEAM_REFERENCE]
return tuple(save + restore), tuple(restore)
if part == 'settings:system':
return SYSTEM_RESTORE + SYSTEM_REFERENCE, SYSTEM_RESTORE
return None
def restore_roots(part, rel):
"""The restore roots for rel within part; the Steam ones don't depend on today's accounts."""
if part == 'settings:steam':
m = re.match(r'^(?:%s/userdata/(\d+)/|%s/(\d+)/)' % (re.escape(STEAM), re.escape(STEAM_CONTROLLER)), rel)
acct = m and (m.group(1) or m.group(2))
if not acct:
return ()
base = '%s/userdata/%s/' % (STEAM, acct)
return tuple(base + r for r in STEAM_RESTORE) + ('%s/%s/config' % (STEAM_CONTROLLER, acct),)
roots = part_roots(part)
return roots[1] if roots else ()
def part_files(part):
roots = part_roots(part)
if roots is None:
raise ValueError('unknown backup part %r' % part)
skip = ()
if part.startswith('saves:') and part != 'saves:linux':
skip = tuple('%s/%s/%s/%s' % (COMPAT, part[6:], PREFIX_USER, s) for s in SAVE_SKIP)
files, skipped = [], []
for root in roots[0]:
f, s = _walk(root, skip)
files += f
skipped += s
return files, skipped
def part_label(part):
if part == 'saves:linux':
return 'Linux game saves (Unity and Godot games)'
if part == 'settings:steam':
return 'Steam settings and controller layouts'
if part == 'settings:system':
return 'Desktop and SteamVR settings'
appid = part[6:]
name = game_name(appid)
if name:
return name + ' saves'
return ('Non-Steam game %s saves' if int(appid) >= SHORTCUT_IDS else 'Game %s saves') % appid
def plan():
parts = []
ids = ['saves:' + a for a in _prefixes()] + ['saves:linux', 'settings:steam', 'settings:system']
for part in ids:
files, skipped = part_files(part)
if not files:
continue
parts.append({'id': part, 'kind': part.split(':')[0], 'label': part_label(part),
'files': len(files), 'bytes': sum(s for _, s in files), 'skipped': skipped,
'appid': part[6:] if part[6:].isdigit() else None,
'restorable': sum(1 for rel, _ in files
if any(_under(rel, r) for r in restore_roots(part, rel)))})
return {'build': os_release().get('BUILD_ID'), 'version': os_release().get('VERSION_ID'),
'parts': parts, 'excluded': EXCLUDED}
def os_release():
out = {}
try:
for line in Path(os.environ.get('FRAME_BACKUP_OS_RELEASE') or '/etc/os-release').read_text().splitlines():
k, _, v = line.partition('=')
out[k.strip()] = v.strip().strip('"')
except OSError:
pass
return out
def pack(part, output):
files, _ = part_files(part)
if len(files) > MAX_FILES or sum(s for _, s in files) > MAX_PART:
raise ValueError('this part is too big to back up (over 100,000 files or 20 GiB)')
with tarfile.open(fileobj=output, mode='w|gz') as archive:
for rel, _ in files:
path = HOME / rel
try:
with open(str(path), 'rb') as src:
st = os.fstat(src.fileno())
member = tarfile.TarInfo(rel)
member.size, member.mtime, member.mode = st.st_size, int(st.st_mtime), st.st_mode & 0o755
archive.addfile(member, src)
except OSError:
continue # gone since the walk (a game wrote it); the manifest lists what's in the archive
def target_status(part, rel):
"""None if rel may be restored now, else why not (a short code)."""
if is_secret(rel) or not safe_rel(rel):
return 'refused'
if not any(_under(rel, r) for r in restore_roots(part, rel)):
return 'reference'
if part.startswith('saves:') and part != 'saves:linux':
appid = part[6:]
if int(appid) < SHORTCUT_IDS and \
not (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).is_file():
return 'not_installed'
if not (HOME / COMPAT / appid / PREFIX_USER).is_dir():
return 'no_prefix'
if part == 'settings:steam':
acct = re.search(r'/(\d+)/', rel[len(STEAM):])
if not acct or not (HOME / STEAM / 'userdata' / acct.group(1)).is_dir():
return 'no_account'
# A link anywhere on the way could send the write somewhere else.
path = HOME
for p in rel.split('/')[:-1]:
path = path / p
if path.is_symlink() or (path.exists() and not path.is_dir()):
return 'refused'
if (HOME / rel).is_symlink() or ((HOME / rel).exists() and not (HOME / rel).is_file()):
return 'refused'
return None
def check(request):
out = []
for item in request.get('parts', []):
part, files = item.get('id'), item.get('files') or []
if not isinstance(part, str) or part_roots(part) is None:
out.append({'id': part, 'status': 'unknown'})
continue
counts = {}
replace = new = 0
for rel in files:
why = target_status(part, rel)
if why:
counts[why] = counts.get(why, 0) + 1
elif (HOME / rel).exists():
replace += 1
else:
new += 1
status = 'ok' if replace + new else next(
(c for c in ('not_installed', 'no_prefix', 'no_account', 'reference', 'refused') if c in counts), 'empty')
out.append({'id': part, 'status': status, 'replace': replace, 'new': new, 'held': counts})
return {'parts': out}
def _sha256(path):
digest = hashlib.sha256()
with open(str(path), 'rb') as src:
for chunk in iter(lambda: src.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def restore(part, stamp, source):
if part_roots(part) is None:
raise ValueError('unknown backup part %r' % part)
if not re.fullmatch(r'\d{8}-\d{6}', stamp):
raise ValueError('bad restore stamp')
# Spool first: everything is checked before anything is written.
spool_dir = HOME / '.cache/frame-control'
spool_dir.mkdir(parents=True, exist_ok=True)
written, replaced, held = [], [], {}
with tempfile.TemporaryDirectory(prefix='restore-', dir=str(spool_dir)) as tmp:
spool = Path(tmp) / 'part.tar.gz'
with open(str(spool), 'wb') as dst:
shutil.copyfileobj(source, dst, 1024 * 1024)
todo, names, total = [], set(), 0
with tarfile.open(str(spool), 'r:gz') as archive:
for member in archive:
if not member.isfile() or not safe_rel(member.name) or member.name in names:
raise ValueError('backup has an unsafe, duplicate or non-file entry: %r' % member.name[:200])
names.add(member.name)
total += member.size
if len(names) > MAX_FILES or total > MAX_PART:
raise ValueError('backup part is too big')
why = target_status(part, member.name)
if why:
held[why] = held.get(why, 0) + 1
else:
todo.append(member.name)
previous = HOME / PREVIOUS / stamp
keep = set(todo)
with tarfile.open(str(spool), 'r:gz') as archive:
for member in archive:
if member.name not in keep:
continue
target = HOME / member.name
target.parent.mkdir(parents=True, exist_ok=True)
part_file = target.parent / ('.frame-restore-%d.part' % os.getpid())
with archive.extractfile(member) as src, open(str(part_file), 'wb') as dst:
shutil.copyfileobj(src, dst, 1024 * 1024)
os.chmod(str(part_file), (member.mode & 0o755) | 0o600)
os.utime(str(part_file), (member.mtime, member.mtime))
if target.exists():
if _sha256(target) == _sha256(part_file):
os.unlink(str(part_file))
held['same'] = held.get('same', 0) + 1
continue
keep_copy = previous / member.name
keep_copy.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(target), str(keep_copy))
replaced.append(member.name)
os.replace(str(part_file), str(target))
written.append(member.name)
return {'id': part, 'written': len(written), 'replaced': len(replaced), 'held': held,
'previous': (PREVIOUS + '/' + stamp) if replaced else None}
# ---- Game Mode and Desktop Mode --------------------------------------------
#
# The Frame normally runs gamescope (Game Mode) with Plasma nested inside it as one panel
# (steamos-nested-desktop). A full Desktop Mode session is SDDM autologin into Plasma
# instead. Valve's own devkit tools (frame/devkit-utils: steamos-get-status, steamos-delete)
# read the session from these files and switch with holo-session-select or
# steamos-session-select, so that's what this reads and suggests.
SDDM_CONFS = ('/etc/sddm.conf.d/zz-steamos-autologin.conf', '/etc/sddm.conf.d/steamos.conf')
def _running(name, full=False):
try:
return subprocess.call(['pgrep', '-f' if full else '-x', name], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) == 0
except OSError:
return False
def session():
configured, conf = None, None
for path in SDDM_CONFS:
try:
text = Path(path).read_text(errors='replace')
except OSError:
continue
m = re.search(r'^\s*Session\s*=\s*(\S+)', text, re.M)
conf, configured = path, (m.group(1) if m else None)
break
selector = next((s for s in ('holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
# On the Frame gamescope's process is named gamescope-wl (seen on build 20261015.6141207),
# under the start-gamescope-session script.
gamescope = (_running('gamescope') or _running('gamescope-wl') or
_running('start-gamescope-session', full=True))
plasma = _running('plasmashell')
nested = _running('steamos-nested-desktop', full=True) # a script: its process name is the shell's
if gamescope:
mode = 'game'
elif plasma or (configured and 'plasma' in configured):
mode = 'desktop'
else:
mode = 'unknown'
return {'mode': mode, 'gamescope': gamescope, 'plasma': plasma, 'nested_desktop': nested,
'configured': configured, 'config_file': conf, 'selector': selector}
def main(argv):
cmd, args = (argv[0] if argv else ''), argv[1:]
try:
if cmd == 'plan':
print(json.dumps(plan()))
elif cmd == 'pack' and len(args) == 1:
out = sys.stdout.buffer
pack(args[0], out)
out.flush()
elif cmd == 'check':
print(json.dumps(check(json.load(sys.stdin))))
elif cmd == 'restore' and len(args) == 2:
print(json.dumps(restore(args[0], args[1], sys.stdin.buffer)))
elif cmd == 'session':
print(json.dumps(session()))
else:
sys.stderr.write('usage: plan | pack PART | check | restore PART STAMP | session\n')
return 2
except (OSError, ValueError, tarfile.TarError) as error:
sys.stderr.write(str(error) + '\n')
return 1
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+14 -7
View File
@@ -98,10 +98,11 @@ def record_command(name, audio, max_seconds=MAX_SECONDS):
ffmpeg runs in the background while the shell watches its stdin, which is the SSH
channel: when that closes (Stop closed it, or the computer's ssh went away) the
shell sends ffmpeg SIGTERM, which makes it finish the file properly (as q or
Ctrl-C would; SIGINT itself is ignored by a background job of a script). A recording
that ends by itself (the time limit, an error) ends the wait too. The shell prints
"started" once ffmpeg is running and "exit N" when it has gone."""
shell types q into ffmpeg's stdin, a FIFO, which makes it finish the file properly.
Not a signal: SteamOS's ffmpeg (n7.0) catches SIGTERM and SIGINT and records on
(seen on a Frame, build 20261015.6141207). If it's still there 10 s later it's
killed. A recording that ends by itself (the time limit, an error) ends the wait
too. The shell prints "started" once ffmpeg is running and "exit N" when it has gone."""
if not REC_NAME.fullmatch(name):
raise ValueError(f"bad recording name {name!r}")
if not 1 <= max_seconds <= MAX_SECONDS:
@@ -115,12 +116,18 @@ def record_command(name, audio, max_seconds=MAX_SECONDS):
inputs += f" {clock} -f pulse -i {AUDIO_SOURCE}"
maps += " -map 1:a:0"
codecs += f" -af aresample=async=1 -c:a aac -b:a {AUDIO_RATE}k"
# ffmpeg's stdin is a FIFO the shell holds open on fd 3 (opening it waits for both
# ends, so ffmpeg is started first); it's unlinked once both ends are open.
script = (f"export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; d=\"$HOME/{WORK}\"; "
f"ffmpeg -hide_banner -loglevel error -nostdin -y {inputs} {maps} {codecs} -t {max_seconds} "
f"\"$d/{name}.mkv\" </dev/null >/dev/null 2>\"$d/{name}.log\" & p=$!; "
f"trap '' PIPE; c=\"$d/{name}.ctl\"; rm -f \"$c\"; mkfifo \"$c\" || exit 1; "
f"ffmpeg -hide_banner -loglevel error -y {inputs} {maps} {codecs} -t {max_seconds} "
f"\"$d/{name}.mkv\" <\"$c\" >/dev/null 2>\"$d/{name}.log\" & p=$!; "
f"exec 3>\"$c\"; rm -f \"$c\"; "
f"echo started; "
f"while kill -0 $p 2>/dev/null; do read -r -t 1 _; r=$?; [ $r -eq 0 ] || [ $r -gt 128 ] || break; done; "
f"kill $p 2>/dev/null; wait $p; echo \"exit $?\"")
f"kill -0 $p 2>/dev/null && printf q >&3 2>/dev/null; exec 3>&-; "
f"i=0; while kill -0 $p 2>/dev/null && [ $i -lt 40 ]; do sleep 0.25; i=$((i+1)); done; "
f"kill -0 $p 2>/dev/null && kill -KILL $p 2>/dev/null; wait $p; echo \"exit $?\"")
return "bash -c " + shlex.quote(script)
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Runs ON the Frame (piped over SSH, with frame_steam.py): what game is running,
and force quit it.
Steam starts every game, non-Steam shortcut and devkit title under its own
`reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper stays
the parent of the game's processes. That's how Steam starts games on Linux
desktops and the Steam Deck; on the Frame it is **inferred, not yet seen**. So a
running game is a reaper with an AppId, and the game is that reaper's process
tree. Nothing is matched by name, so Steam, gamescope, SteamVR, sshd or the
session can't be picked by mistake, and the names in PROTECTED are skipped
even inside a game's tree.
Quitting asks Steam first (SteamClient.Apps.TerminateApp, what Steam's own Stop
uses for Android apps, docs/vr-apks.md). If the game's processes are still
there a few seconds later, they get SIGTERM, then SIGKILL.
Usage: python3 - list # {"running": [{appid, gameid, name, kind, ...}]}
python3 - quit APPID # {"name", "method": "steam"|"term"|"kill"|"gone", "message"}
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
"""
import glob
import json
import os
import re
import signal
import subprocess
import sys
import time
from frame_steam import Fail, Page
# The Frame is Linux; "/" also keeps the unit tests' paths the same on Windows.
HOME = os.path.expanduser("~")
STEAM = HOME + "/.local/share/Steam"
DEVKIT = HOME + "/devkit-game"
ANDROID = HOME + "/Applications/Android" # frame_android.APPS_DIR
SIGTERM, SIGKILL = signal.SIGTERM, getattr(signal, "SIGKILL", 9) # Windows (unit tests) has no SIGKILL
SHORTCUT_BIT = 0x80000000 # non-Steam shortcut ids have the top bit set
APPID_ARG = re.compile(r"AppId=(\d{1,10})")
# Never signalled, even when found under a game's reaper: Steam, the
# compositors, SteamVR, the session, audio, sshd and init.
PROTECTED = frozenset({
"steam", "steamwebhelper", "gamescope", "gamescope-wl", "gamescopereaper", "gamescope-session",
"sshd", "sshd-session", "ssh-agent", "systemd", "init", "dbus-daemon", "dbus-broker", "dbus-broker-lau",
"pipewire", "pipewire-pulse", "wireplumber", "plasmashell", "kwin_wayland", "kwin_x11", "Xwayland",
"sddm", "vrserver", "vrcompositor", "vrmonitor", "vrdashboard", "vrwebhelper", "vrstartup",
})
# Steam launches SteamVR under a reaper too, but on the Frame it's the headset's view,
# not a game: it's never listed or quit (SteamVR and SteamVR Beta).
NOT_GAMES = frozenset({250820, 1009850})
STEAM_WAIT = 5 # seconds for Steam's own terminate to end the game
TERM_WAIT = 3 # then SIGTERM, and this long before SIGKILL
KILL_WAIT = 2
RUNNING_JS = """((ids) => {
const store = globalThis.SteamUIStore;
const running = Array.isArray(store?.RunningApps) ? store.RunningApps.map(a => a.appid) : null;
const names = {};
for (const id of new Set([...ids, ...(running || [])])) {
const a = appStore.GetAppOverviewByAppID(id);
if (a) names[id] = a.display_name;
}
return {running, names};
})(%s)"""
def read(path, mode="r"):
try:
with open(path, mode) as f:
return f.read()
except OSError:
return None
def snapshot(proc="/proc"):
"""pid -> {ppid, start, state, uid, comm, argv} for every process that can be read."""
out = {}
for d in os.listdir(proc):
if not d.isdigit():
continue
stat = read(f"{proc}/{d}/stat")
try:
# comm is in parentheses and may hold spaces or ')': split at the last ')'.
comm = stat[stat.index("(") + 1:stat.rindex(")")]
fields = stat[stat.rindex(")") + 2:].split()
uid = os.stat(f"{proc}/{d}").st_uid
except (AttributeError, ValueError, OSError):
continue
argv = (read(f"{proc}/{d}/cmdline", "rb") or b"").split(b"\0")
out[int(d)] = {"ppid": int(fields[1]), "state": fields[0], "start": int(fields[19]), "uid": uid,
"comm": comm, "argv": [a.decode(errors="replace") for a in argv if a]}
return out
def launch_appid(argv):
"""The AppId of `reaper SteamLaunch AppId=<id> -- ...`, or None for any other process."""
if not argv or os.path.basename(argv[0]) != "reaper" or "SteamLaunch" not in argv:
return None
for arg in argv[1:]:
if arg == "--":
break
m = APPID_ARG.fullmatch(arg)
if m:
return int(m.group(1))
return None
def reapers(procs, uid):
"""appid -> [reaper pid], for this user's game launches."""
out = {}
for pid, p in procs.items():
appid = launch_appid(p["argv"])
if appid and appid not in NOT_GAMES and p["uid"] == uid and p["state"] != "Z":
out.setdefault(appid, []).append(pid)
return out
def children(procs):
kids = {}
for pid, p in procs.items():
kids.setdefault(p["ppid"], []).append(pid)
return kids
def protected(procs, me, uid):
"""Pids never to signal: init, this helper and its parents (the SSH session),
other users' processes and anything named in PROTECTED."""
keep, pid = {0, 1}, me
while pid > 1 and pid not in keep:
keep.add(pid)
pid = procs.get(pid, {}).get("ppid", 0)
for pid, p in procs.items():
argv0 = os.path.basename(p["argv"][0]) if p["argv"] else ""
if p["uid"] != uid or p["comm"] in PROTECTED or argv0 in PROTECTED:
keep.add(pid)
return keep
def tree(procs, roots, keep):
"""The roots and their descendants, deepest first, leaving out kept processes
and everything under them."""
kids, out, level = children(procs), [], [r for r in roots if r not in keep]
while level:
out = level + out
level = [c for pid in level for c in kids.get(pid, ()) if c not in keep and procs[c]["state"] != "Z"]
return out
def alive(procs, pid, start):
p = procs.get(pid)
return bool(p) and p["start"] == start and p["state"] != "Z"
def escalate(appid, me=None, uid=None, scan=snapshot, kill=os.kill, sleep=time.sleep, clock=time.monotonic):
"""SIGTERM the game's processes, then SIGKILL what's left. The reaper itself
is only killed last, so its orphans can't escape to init before they're
signalled. Returns "term", "kill" or "gone"; raises Fail if any survive."""
me = os.getpid() if me is None else me
uid = os.getuid() if uid is None else uid
seen = {} # pid -> start time, so a reused pid is never signalled
def targets(include_roots):
procs = scan()
keep = protected(procs, me, uid)
roots = reapers(procs, uid).get(appid, [])
for pid in tree(procs, roots, keep):
seen.setdefault(pid, procs[pid]["start"])
return procs, [pid for pid, start in seen.items() if alive(procs, pid, start) and pid not in keep
and (include_roots or pid not in roots)]
def wait(seconds, include_roots):
end = clock() + seconds
while True:
procs, left = targets(include_roots)
if not left or clock() >= end:
return left
sleep(0.2)
method = "gone"
for sig, seconds, include_roots in ((SIGTERM, TERM_WAIT, False), (SIGKILL, KILL_WAIT, True)):
_, left = targets(include_roots)
if not left:
# The reaper exits when its last child does; a lingering one is all that's left.
_, left = targets(True)
if not left:
return method
for pid in left:
try:
kill(pid, sig)
except ProcessLookupError:
pass
except PermissionError:
continue
method = "term" if sig == SIGTERM else "kill"
if not wait(seconds, True):
return method
raise Fail(f"{len(targets(True)[1])} of its processes are still running after SIGKILL")
def gameid(appid):
"""The game id Steam's Apps API takes: the appid, or (appid << 32) | 0x02000000
for a non-Steam shortcut (docs/apks.md)."""
return str((appid << 32) | 0x02000000 if appid >= SHORTCUT_BIT else appid)
def android_apps():
"""Shortcut or instance id -> Frame Control's Android app meta."""
out = {}
for path in glob.glob(os.path.join(ANDROID, "*/meta.json")):
try:
m = json.loads(read(path) or "null")
except ValueError:
continue
if isinstance(m, dict) and m.get("package"):
for key in ("shortcut", "instance"):
if str(m.get(key) or "").isdigit():
out[int(m[key])] = m
return out
def lepton_containers():
try:
r = subprocess.run(["podman", "ps", "--format", "{{.Names}}"], capture_output=True, text=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
return set()
return set(r.stdout.split())
def manifest_name(appid):
m = re.search(r'"name"\s+"([^"]*)"', read(os.path.join(STEAM, f"steamapps/appmanifest_{appid}.acf")) or "")
return m.group(1) if m else None
def devkit_title(procs, pids):
"""A devkit title's id from its processes' arguments (~/devkit-game/<id>/...)."""
prefix = DEVKIT + "/"
for pid in pids:
for arg in procs[pid]["argv"]:
arg = arg.strip('"')
if arg.startswith(prefix) and "/" in arg[len(prefix):]:
return arg[len(prefix):].split("/", 1)[0]
return None
def steam_view(ids, page=None):
"""Steam's own running list and app names, or (None, {}) without Steam."""
try:
r = (page or Page()).eval(RUNNING_JS % json.dumps(sorted(ids)))
except (Fail, OSError):
return None, {}
return r.get("running"), {int(k): v for k, v in (r.get("names") or {}).items()}
def running(procs=None, uid=None, steam=steam_view, android=android_apps, containers=lepton_containers):
"""Every game running now: reapers on this Frame, Steam's running list, and
Android apps whose Lepton container is up."""
procs = snapshot() if procs is None else procs
uid = os.getuid() if uid is None else uid
found = reapers(procs, uid)
apps = android()
up = containers() if apps else set()
for m in apps.values():
if f"lepton-steamlaunch-{m.get('instance')}" in up and str(m.get("shortcut") or "").isdigit():
found.setdefault(int(m["shortcut"]), [])
listed, names = steam(set(found))
for appid in listed or ():
if int(appid) not in NOT_GAMES:
found.setdefault(int(appid), [])
out = []
for appid, roots in sorted(found.items()):
pids = tree(procs, roots, set())
app = {"appid": appid, "gameid": gameid(appid), "processes": len(pids)}
meta = apps.get(appid)
title = devkit_title(procs, pids)
if meta:
app.update(kind="android", package=meta["package"], name=names.get(appid) or meta.get("label") or meta["package"])
elif title:
app.update(kind="devkit", title=title, name=names.get(appid) or title)
elif appid >= SHORTCUT_BIT:
app.update(kind="shortcut", name=names.get(appid) or f"Shortcut {appid}")
else:
app.update(kind="steam", name=names.get(appid) or manifest_name(appid) or f"App {appid}")
out.append(app)
return out
def quit_game(appid, sleep=time.sleep, clock=time.monotonic):
apps = {a["appid"]: a for a in running()}
app = apps.get(appid)
if not app:
return {"name": None, "method": "gone", "message": "That game isn't running"}
name = app["name"]
try:
page = Page()
page.eval(f"SteamClient.Apps.TerminateApp({json.dumps(app['gameid'])}, false)")
except (Fail, OSError):
page = None # Steam isn't answering: the processes are all that's left to stop
end = clock() + (STEAM_WAIT if page else 0)
while True:
procs = snapshot()
left = reapers(procs, os.getuid()).get(appid) or (page and appid in (steam_view({appid}, page)[0] or ()))
if not left:
return {"name": name, "method": "steam" if page else "gone", "message": f"Quit {name}"}
if clock() >= end:
break
sleep(0.5)
if not reapers(procs, os.getuid()).get(appid):
# Steam still lists it, but there's no process of it to stop.
raise Fail(f"Steam didn't quit {name}, and none of its processes were found on the Frame")
method = escalate(appid)
return {"name": name, "method": method,
"message": f"Force quit {name}" + (" (it had to be killed)" if method == "kill" else "")}
def main():
try:
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "list":
out = {"running": running()}
elif cmd == "quit" and len(sys.argv) == 3 and sys.argv[2].isdigit() and len(sys.argv[2]) <= 10:
out = quit_game(int(sys.argv[2]))
else:
raise Fail("usage: list | quit APPID")
except (Fail, OSError) as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
except Exception as e: # keep the one-JSON-object contract for the server
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
sys.exit(1)
print(json.dumps(out))
if __name__ == "__main__":
main()
+229 -7
View File
@@ -1266,6 +1266,11 @@
<div class="workspace-heading"><div><span class="eyebrow">Play on your Frame</span><h1>Games</h1><p>Your Steam library and games you add yourself.</p></div><button class="action add-install" data-open-install>+ Add / Install</button></div>
<div class="library-controls"><div class="scope-tabs" id="libraryScope" aria-label="Game library"><button data-scope="installed" aria-pressed="true">Installed</button><button data-scope="owned" aria-pressed="false">Owned</button><button data-scope="store" aria-pressed="false">Store</button></div>
<div class="scope-tabs" id="librarySources" aria-label="Installed content source"><button data-library-source="all" aria-pressed="true">All</button><button data-library-source="steam" aria-pressed="false">Steam</button><button data-library-source="sideloaded" aria-pressed="false">Sideloaded</button></div></div>
<section class="panel now-running" id="nowRunning" aria-labelledby="nowRunningTitle" hidden>
<div class="shelf-head"><h2 id="nowRunningTitle">Running now</h2><span class="spacer"></span><button class="small" id="runningCheck">Check again</button></div>
<div class="list" id="runningList"></div>
<div class="hint">Force quit a game that has stopped responding. Steam is asked to quit it first; unsaved progress is lost.</div>
</section>
<div id="libraryOwned"></div><div id="steamDiscover" hidden></div>
<section id="library">
<div class="shelf-head"><h2>Steam games</h2><span class="count" id="gameCount"></span></div>
@@ -1415,7 +1420,7 @@
<div class="page" data-page="tools">
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; 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 &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; 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>
@@ -1662,6 +1667,41 @@
Saved spatial layouts are not available yet.</div>
</section>
<section class="panel" id="backup" hidden>
<div class="shelf-head"><h2>Back up the Frame</h2><span class="spacer"></span>
<button class="small" id="backupFolder">Open backups folder</button></div>
<p class="sub">Copies game saves, settings and Android app data from the headset to a dated folder on this computer.
It only reads the headset. Games themselves aren't copied: reinstall them from Steam.</p>
<div class="chips" id="backupKinds">
<label><input type="checkbox" value="saves" checked> Game saves</label>
<label><input type="checkbox" value="settings" checked> Steam and desktop settings</label>
<label><input type="checkbox" value="android" checked> Android app data</label>
</div>
<div class="row" style="margin-top:12px"><button class="small" id="backupPreview">See what's included</button>
<button class="small action" id="backupStart">Back up now</button></div>
<div class="sub" id="backupProgress" role="status" aria-live="polite"></div>
<div class="list" id="backupSummary"></div>
<div class="hint">Never copied: Steam sign-in files (steam.token, config.vdf, loginusers.vdf), SSH keys,
keyrings and other passwords. Backups aren't encrypted, and game folders can hold launcher logins, so keep them private.
Close Android apps first: open ones are skipped.</div>
<div class="shelf-head" style="margin-top:20px"><h2>Restore</h2><span class="spacer"></span>
<button class="small" id="backupRefresh">Refresh</button></div>
<div class="list" id="backupList"><div class="sub">Open this tool to see your backups.</div></div>
<div id="restorePlan"></div>
<div class="hint">After a factory reset, sign in to Steam, reinstall your games and start each once, and reinstall
your Android apps. Then restore. Parts that can't go back yet are skipped, not failed. Files a restore replaces are
kept on the headset in ~/.local/share/frame-control/restore-previous.</div>
<div class="shelf-head" style="margin-top:20px"><h2>Stuck in Desktop Mode?</h2><span class="spacer"></span>
<button class="small" id="sessionCheck">Check</button></div>
<div class="sub" id="sessionState">Check which mode the headset is in.</div>
<div class="row" style="margin-top:12px"><button class="small" id="sessionGame" disabled>Return to Game Mode</button></div>
<div class="hint">Uses the same switch as Valve's developer tools (<code>steamos-session-select gamescope</code>).
It closes the desktop and anything open in it. If the headset can't be reached, hold Power for 10 seconds to
restart it; <a href="https://github.com/saphid/frame-control/blob/main/docs/backup-and-recovery.md#stuck-in-desktop-mode" target="_blank">more ways back</a>.</div>
</section>
<section class="panel" id="macview" hidden>
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
<button class="small" id="mvRefresh">Refresh</button></div>
@@ -2455,6 +2495,7 @@ function render(s) {
<div class="name">${esc(g.name)}</div>
<div class="size">${gb(g.size)}</div>
<button class="play small" data-launch="${g.appid}" data-name="${esc(g.name)}">▶ Play</button>
<button class="small danger" data-force-quit-app="${g.appid}" data-name="${esc(g.name)}" title="Quit it if it has stopped responding">Force quit…</button>
</div>
</div>`).join("") : `<div class="sub">No games installed.</div>`;
// Tall library art, falling back to the wide header, then to the name card.
@@ -3247,7 +3288,7 @@ $("ctrlKeys").addEventListener("click", e => {
});
// ---- controls ----
$("refreshAll").onclick = () => { refresh(); loadTitles(); loadAndroid(); }; // titles too: the Frame may have been asleep at start
$("refreshAll").onclick = () => { refresh(); loadTitles(); loadAndroid(); loadRunning().catch(() => {}); }; // titles too: the Frame may have been asleep at start
document.addEventListener("keydown", e => {
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) $("refreshAll").onclick();
});
@@ -3255,6 +3296,7 @@ document.body.addEventListener("click", async (e) => {
const b = e.target.closest("button");
if (!b) return;
if (b.dataset.launch) act(`Launch ${b.dataset.name}`, () => api("/api/launch", { appid: b.dataset.launch }), b);
else if (b.dataset.forceQuitApp) forceQuitFrom(g => String(g.appid) === b.dataset.forceQuitApp, b.dataset.name, b);
else if (b.dataset.uninstall) {
if (confirm(`Remove ${b.dataset.name} from the Frame?`))
act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh);
@@ -3832,6 +3874,7 @@ async function loadTitles() {
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">Play</button>
<details class="lib-more"><summary aria-label="More for ${esc(t.id)}">⋯</summary><div class="lib-menu">
${t.art_missing ? `<button data-title="refresh-art" data-id="${esc(t.id)}">Add artwork<small>Its Steam library tile has none yet</small></button>` : ""}
<button class="danger" data-title="force-quit" data-id="${esc(t.id)}">Force quit…<small>If it has stopped responding</small></button>
<button class="danger" data-title="remove" data-id="${esc(t.id)}">Remove…<small>Deletes the game and its files</small></button>
</div></details>
</div>`).join("") : `<div class="sub">None yet.</div>`;
@@ -3844,6 +3887,8 @@ $("titleList").onclick = async e => {
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
loadTitles();
} else if (b.dataset.title === "force-quit") {
forceQuitFrom(g => g.kind === "devkit" && g.title === id, id, b);
} else if (b.dataset.title === "refresh-art") {
await runJob(`Add Steam artwork for ${id}`, "art-" + id, () => api("/api/titles", { action: "refresh-art", id }));
loadTitles();
@@ -3852,6 +3897,48 @@ $("titleList").onclick = async e => {
loadTitles();
$("titleAdd").onclick = () => $("fileInput").click();
// ---- what's running, and force quit (frame_running.py on the Frame) ----
let runningGames = [], runningSeq = 0;
async function loadRunning() {
const seq = ++runningSeq;
try {
const list = (await api("/api/running")).running;
if (seq === runningSeq) runningGames = list;
} catch (e) {
if (seq === runningSeq && !e.stale) runningGames = [];
throw e;
} finally { if (seq === runningSeq) renderRunning(); }
return seq === runningSeq;
}
const RUNNING_KIND = { steam: "Steam game", devkit: "Sideloaded game", shortcut: "Non-Steam shortcut", android: "Android app" };
function renderRunning() {
$("nowRunning").hidden = !runningGames.length;
$("runningList").innerHTML = runningGames.map(g => `
<div class="item"><div class="grow"><div class="t">${esc(g.name)}</div><div class="s">${RUNNING_KIND[g.kind] || "Running"} · running on the Frame</div></div>
<button class="small danger" data-force-quit="${esc(g.appid)}">Force quit…</button></div>`).join("");
}
async function forceQuit(appid, btn) {
const g = runningGames.find(x => String(x.appid) === String(appid));
if (!g) return;
if (!confirm(`Force quit ${g.name}? Unsaved progress will be lost.`)) return;
await act(`Force quit ${g.name}`, () => api("/api/running", { action: "quit", appid: String(g.appid) }), btn);
loadRunning().catch(() => {});
if (g.kind === "android") loadAndroid();
}
// From a game's own menu: check it's running first, so a stopped game says so.
async function forceQuitFrom(match, name, btn) {
if (btn) btn.disabled = true;
try { if (!await loadRunning()) return; }
catch (e) { if (!e.stale) toast(`Couldn't check what's running: ${e.message}`, true); return; }
finally { if (btn) btn.disabled = false; }
const g = runningGames.find(match);
if (!g) { log(`${name} isn't running`); toast(`${name} isn't running`); return; }
forceQuit(g.appid, btn);
}
$("runningList").onclick = e => { const b = e.target.closest("[data-force-quit]"); if (b) forceQuit(b.dataset.forceQuit, b); };
$("runningCheck").onclick = () => loadRunning().then(() => { if (!runningGames.length) toast("Nothing is running on the Frame"); },
e => { if (!e.stale) toast(`Couldn't check what's running: ${e.message}`, true); });
// ---- Steam games: install owned games, find and buy on the store ----
const FLABEL = { 3: "Frame Verified", 2: "Playable", 1: "Unsupported", 0: "Unknown" };
@@ -5326,6 +5413,138 @@ $("panelList").onclick = e => {
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
};
// ---- Backup and recovery kit (ui/frame_backup.py) ----
const BACKUP_KIND = {saves: "Game saves", settings: "Settings", android: "Android app data"};
function sizeText(n) {
if (n == null) return "";
for (const unit of ["bytes", "KB", "MB", "GB"]) {
if (n < 1000 || unit === "GB") return unit === "bytes" ? `${n} bytes` : `${n.toFixed(1)} ${unit}`;
n /= 1000;
}
}
const backupKinds = () => [...$("backupKinds").querySelectorAll("input:checked")].map(i => i.value);
let backupSeq = 0, backupBusy = false;
async function loadBackups() {
const seq = ++backupSeq;
try {
const r = await api("/api/backup", {action: "list"});
if (seq !== backupSeq) return;
$("backupList").innerHTML = r.backups.length ? r.backups.map(b => `<div class="item"><div class="grow">
<div class="t">${esc(b.name)}</div><div class="s">${b.error ? esc(b.error) :
esc([b.headset, b.build && `SteamOS build ${b.build}`, `${b.parts} part${b.parts === 1 ? "" : "s"}`, sizeText(b.bytes)].filter(Boolean).join(" · "))}</div></div>
${b.error ? "" : `<button class="small" data-backup-plan="${esc(b.name)}">Restore…</button>`}</div>`).join("")
: `<div class="sub">No backups in ${esc(r.folder)} yet.</div>`;
} catch (e) { if (seq === backupSeq) failed($("backupList"), e); }
}
function backupPartRow(p, restore) {
const detail = restore
? (p.status === "ok" ? [p.replace ? `replaces ${p.replace} file${p.replace === 1 ? "" : "s"}` : "", p.new ? `${p.new} new` : "",
p.held && p.held.reference ? `${p.held.reference} kept for reference` : ""].filter(Boolean).join(", ") || "ready"
: p.reason || p.status)
: [`${p.files} file${p.files === 1 ? "" : "s"}`, sizeText(p.bytes), p.skipped && p.skipped.length ? `${p.skipped.length} too big, skipped` : ""].filter(Boolean).join(" · ");
const box = restore ? `<input type="checkbox" data-restore-part="${esc(p.id)}" ${p.status === "ok" ? "checked" : "disabled"} aria-label="Restore ${esc(p.label)}"> ` : "";
return `<div class="item"><div class="grow"><div class="t">${box}${esc(p.label)}</div><div class="s">${esc(detail)}</div></div></div>`;
}
$("backupPreview").onclick = e => act("Check what a backup includes", async () => {
const r = await api("/api/backup", {action: "preview"}), kinds = backupKinds();
const rows = r.parts.filter(p => kinds.includes(p.kind)).map(p => backupPartRow(p, false));
if (kinds.includes("android")) rows.push(...r.android.map(a => `<div class="item"><div class="grow"><div class="t">${esc(a.label)} (Android app data)</div>
<div class="s">${a.running ? "Open now: close it or it's skipped" : "Saved while closed"}</div></div></div>`));
if (r.android_error && kinds.includes("android")) rows.push(`<div class="sub">Android apps couldn't be listed: ${esc(r.android_error)}</div>`);
$("backupSummary").innerHTML = (rows.join("") || '<div class="sub">Nothing to back up for that choice.</div>') +
`<details><summary>Never copied</summary><ul>${r.excluded.map(x => `<li>${esc(x)}</li>`).join("")}</ul></details>`;
return {message: `SteamOS build ${r.build || "unknown"}: ${rows.length} part${rows.length === 1 ? "" : "s"} to back up`};
}, e.currentTarget);
// Like runJob, without counting as an install in the header.
async function backupJob(label, start) {
log(label + "…");
try {
const { job } = await start();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
backupProgress(j);
if (!j.done) continue;
if (j.error) throw new Error(j.error);
log(j.message, "ok"); toast(j.message);
return j.result || {};
}
} catch (e) {
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
}
}
function backupProgress(j) {
$("backupProgress").textContent = j.done ? "" : `${j.stage || "Starting"}${j.percent != null ? ` · ${Math.round(j.percent)}%` : ""}`;
}
$("backupStart").onclick = async e => {
const kinds = backupKinds();
if (!kinds.length) return toast("Choose something to back up", true);
if (backupBusy) return;
backupBusy = true; e.currentTarget.disabled = true;
try {
const r = await backupJob("Back up the Frame", () => api("/api/backup", {action: "backup", kinds}));
if (r) {
$("backupSummary").innerHTML = `<div class="sub">${esc(r.message)}</div>` + (r.skipped || []).map(s =>
`<div class="item"><div class="grow"><div class="t">${esc(s.label || s.id)}${s.path ? `: ${esc(s.path)}` : ""}</div>
<div class="s">Skipped: ${esc(s.reason)}</div></div></div>`).join("");
loadBackups();
}
} finally { backupBusy = false; $("backupStart").disabled = false; $("backupProgress").textContent = ""; }
};
$("backupFolder").onclick = e => act("Open backups folder", () => api("/api/backup", {action: "open"}), e.currentTarget);
$("backupRefresh").onclick = loadBackups;
let restoreName = null;
$("backupList").onclick = async e => {
const b = e.target.closest("[data-backup-plan]"); if (!b) return;
restoreName = b.dataset.backupPlan;
$("restorePlan").innerHTML = '<div class="sub">Checking what can go back…</div>';
try {
const r = await api("/api/backup", {action: "plan", name: restoreName});
if (r.name !== restoreName) return;
$("restorePlan").innerHTML = `<h3>Restore ${esc(r.name)}</h3>` + r.parts.map(p => backupPartRow(p, true)).join("") +
`<div class="row" style="margin-top:12px"><button class="small danger" id="restoreStart">Restore selected</button>
<button class="small" data-backup-open="${esc(r.name)}">Show files</button></div>`;
$("restorePlan").plan = r;
} catch (err) { failed($("restorePlan"), err); }
};
$("restorePlan").onclick = async e => {
const open = e.target.closest("[data-backup-open]");
if (open) return act("Show backup files", () => api("/api/backup", {action: "open", name: open.dataset.backupOpen}), open);
if (!e.target.closest("#restoreStart") || backupBusy) return;
const plan = $("restorePlan").plan;
const parts = [...$("restorePlan").querySelectorAll("[data-restore-part]:checked")].map(i => i.dataset.restorePart);
if (!plan || !parts.length) return toast("Choose something to restore", true);
const chosen = plan.parts.filter(p => parts.includes(p.id));
const replacing = chosen.reduce((n, p) => n + (p.replace || 0), 0);
if (!confirm(`Restore ${chosen.length} part${chosen.length === 1 ? "" : "s"} from ${plan.name}?\n\n` +
`${replacing ? `This replaces ${replacing} file${replacing === 1 ? "" : "s"} or app data folder${replacing === 1 ? "" : "s"} on the headset. ` : ""}` +
"Replaced files are kept on the headset. Close the games and apps you're restoring first.")) return;
backupBusy = true;
try {
const r = await backupJob("Restore from backup", () => api("/api/backup", {action: "restore", name: plan.name, parts, confirm: true}));
if (r) $("restorePlan").innerHTML = `<div class="sub">${esc(r.message)}</div>` + (r.skipped || []).map(s =>
`<div class="item"><div class="grow"><div class="t">${esc(s.label || s.id)}</div><div class="s">Skipped: ${esc(s.reason)}</div></div></div>`).join("");
} finally { backupBusy = false; $("backupProgress").textContent = ""; }
};
const SESSION_TEXT = {game: "Game Mode: the headset is running normally. A desktop you see is a panel; close it from the Steam dashboard.",
desktop: "Desktop Mode: the headset started the full desktop instead of Game Mode.",
unknown: "Couldn't tell which mode the headset is in."};
$("sessionCheck").onclick = async e => {
e.currentTarget.disabled = true;
try {
const s = await api("/api/backup", {action: "session"});
$("sessionState").textContent = SESSION_TEXT[s.mode] + (s.selector ? "" : " This SteamOS build has no session switch command; use the manual steps.");
$("sessionGame").disabled = s.mode !== "desktop" || !s.selector;
} catch (err) { failed($("sessionState"), err); }
finally { $("sessionCheck").disabled = false; }
};
$("sessionGame").onclick = e => {
if (!confirm("Return the headset to Game Mode?\n\nThis closes the desktop and any apps open in it, then starts Steam's Game Mode.")) return;
act("Return to Game Mode", () => api("/api/backup", {action: "gamemode", confirm: true}), e.currentTarget);
};
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
let mvSeq = 0, mvTimer = 0;
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
@@ -5439,10 +5658,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools"};
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", backup:"tools"};
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates"};
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", backup:"Backup & recovery"};
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
let page = "apps";
// Reuse the existing live lists and form handlers in their new destinations.
@@ -5528,7 +5747,7 @@ function showPage(e) {
if (page === 'games') selectLibrary(workspace.source, workspace.scope, false);
// Another computer may have added or removed a title: phones have no Refresh button. Only
// on navigating here (e: the hashchange); at startup the first load is already on its way.
if (e && page === 'games' && previousPage !== 'games') loadTitles();
if (e && page === 'games' && previousPage !== 'games') { loadTitles(); loadRunning().catch(() => {}); }
if (page === 'apps') {
selectApps(workspace.apps, false);
if ((previousPage !== 'apps' || id === 'androidLibrary') && workspace.apps === 'installed') loadAndroid();
@@ -5548,6 +5767,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();
}
@@ -6525,7 +6745,8 @@ function onConnection(s) {
gm.storeDraft = ""; gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.shownBySource = {owned:0, store:0}; gm.seq++;
androidApps = []; androidSeq++; androidPending.clear(); $('installedDetail').close(); resetShots(); resetClips();
renderHome();
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
titlesSeq++; disp.seq++; runningSeq++; // answers to loads already on their way are ignored
runningGames = []; $("nowRunning").hidden = true; $("runningList").innerHTML = "";
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
// Confirmations still open were checked against the other headset.
["titleDlg", "apkAltDlg", "pwDlg", "repDlg"].forEach(id => { if ($(id) && $(id).open) $(id).close(); });
@@ -6939,6 +7160,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();
@@ -6949,7 +7171,7 @@ api("/api/host").then(h => {
<script>
window.frameControlCenter = FrameControlCenter.mount({document, api, refresh, refreshAudio: refreshAfterAudio,
getContext: () => ({sample: state, online, generation: devGen, device: window.connDevice}),
openConnection: () => $("conn").click(), platform: window.frameApp?.platform || ""});
openConnection: () => $("conn").click(), platform: window.frameApp?.platform || "", confirm: text => confirm(text)});
window.frameControlCenter.host(HOST);
if (state) window.frameControlCenter.status(state, stateReceivedAt, stateRead);
else if (stateRead.error) window.frameControlCenter.unavailable(stateRead.error);
+111 -4
View File
@@ -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
@@ -875,12 +876,12 @@ def launch(body):
return {"message": f"Launching {appid}"}
def steam_frame(*args, timeout=40, script="frame_steam.py"):
def steam_frame(*args, timeout=40, script="frame_steam.py", source=None):
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
helper with the same JSON contract, on the Frame and return its JSON."""
try:
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
stdin=(HERE / script).read_text(), timeout=timeout)
stdin=source or (HERE / script).read_text(), timeout=timeout)
except Failure as e:
# The helper prints {"error": ...} on stdout when it fails, but ssh()
# reports stderr instead if there was any, so look in both.
@@ -893,6 +894,43 @@ def steam_frame(*args, timeout=40, script="frame_steam.py"):
return json.loads(out)
def running_frame(*args, timeout=40):
"""frame_running.py on the Frame, with frame_steam.py (its DevTools client) as a module."""
source = ("import sys, types\nm = types.ModuleType('frame_steam'); sys.modules['frame_steam'] = m\n"
f"exec({(HERE / 'frame_steam.py').read_text()!r}, m.__dict__)\n" + (HERE / "frame_running.py").read_text())
return steam_frame(*args, timeout=timeout, source=source)
def running_games():
games = running_frame("list")["running"]
return {"running": games, "message": None if games else "Nothing is running on the Frame"}
def force_quit(body):
"""Quit a running game: Steam's own terminate, then its processes (frame_running.py).
An Android app's Lepton container is stopped the way the Apps tab's Stop does."""
appid = str(body.get("appid", ""))
if body.get("action") != "quit":
raise Failure("action must be quit", 400)
if not APPID.match(appid):
raise Failure("bad appid", 400)
app = next((a for a in running_frame("list")["running"] if str(a.get("appid")) == appid), None)
if not app:
raise Failure("That game isn't running any more", 409)
stopped = False
if app.get("kind") == "android" and app.get("package"):
ensure_master()
try:
frame_android.stop(app["package"])
stopped = True
except frame_android.FrameError:
pass # the process fallback below still runs
res = running_frame("quit", appid, timeout=60)
if res.get("method") == "gone":
res["message"] = f"{'Stopped' if stopped else 'Quit'} {app['name']}"
return dict(res, appid=int(appid), name=app["name"])
def steam(body):
"""Steam games: install an owned game, or open its store page in the headset."""
appid, action = str(body.get("appid", "")), body.get("action")
@@ -2658,6 +2696,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()
@@ -2890,13 +2995,13 @@ 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,
"/api/input": remote_input, "/api/touch": remote_touch,
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/running": force_quit, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/record": record, "/api/clips/export": export_clip, "/api/autosync": auto_sync,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
@@ -3181,6 +3286,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
elif path == "/api/status":
self.send_json(status({}))
elif path == "/api/running":
self.send_json(running_games())
elif path == "/api/steam/owned":
self.send_json(steam_frame("owned"))
elif path == "/api/steam/search":