Merge remote-tracking branch 'origin/main' into feat/backup-recovery-kit

# Conflicts:
#	ui/index.html
This commit is contained in:
SAPHID committed 2026-10-24 00:13:26 +00:00
commit 310e778d00
24 files changed
+2005 -57

No files matched your search

+2 -2
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">
@@ -70,7 +70,7 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
<td valign="top">
**📸 Screenshots and recordings**<br>
Browse the shots you take in the headset and save them to your Pictures folder. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
**⌨️ Type on the headset**<br>
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
+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
+4
View File
@@ -15,6 +15,10 @@ depends on SSH working through the `frame` alias from `scripts/connect.sh`.
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
Screenshots and Steam clips have their own buttons on **Tools → Capture &
Live**, and **Settings → Copy to this computer** copies new ones here
automatically: see [recording.md](recording.md#copying-new-ones-automatically).
## Clipboard
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
+13
View File
@@ -63,6 +63,13 @@ counts them while they run.
- **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
@@ -142,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,
+9
View File
@@ -287,3 +287,12 @@ then every 6 hours: the latest release's `update.json` on GitHub, or
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
Those requests carry no id. To stop it, set
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
## Screenshots and clips
Copying screenshots and Steam clips to your computer, by hand or with
**Settings → Copy to this computer**, goes straight from the headset to your
computer over the same SSH connection as everything else. Nothing is uploaded
anywhere. What automatic copying has copied is listed, by file id, in
`auto-sync.json` in Frame Control's data folder; see
[recording.md](recording.md#copying-new-ones-automatically).
+53 -9
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.
@@ -74,10 +80,42 @@ The listing doesn't rely on the folder names: any folder under `clips/` or
missing its init segment or its chunks is left out. Missing chunk numbers are
shown as "pieces missing"; the rest still exports.
## Copying new ones automatically
**Settings → Copy to this computer** copies new screenshots and Steam clips
here on their own. It's off until you turn it on, with a switch each for
screenshots and Steam clips. While a headset is connected, Frame Control looks
about once a minute and copies what's new with the same code as **Save** and
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
`~/Videos/SteamFrame`.
- **Only what's new.** It copies what you take after turning it on, so the
shots and clips already on the headset don't all arrive at once. **Copy
everything now** copies everything that isn't on this computer yet, once.
- **Deleted stays deleted.** What it has copied is remembered, per headset, in
`auto-sync.json` in Frame Control's data folder, so a file you delete here
isn't copied again (**Copy everything now** would bring it back).
- **Saved clips only.** Steam's background recording is a rolling buffer, not
something you chose to keep, so it isn't copied; export it by hand. A clip
Steam wrote to in the last 30 seconds is still being saved and waits for the
next look.
- **It gives way.** It waits while an install, an export, a recording or
anything else is running on the headset, and stops between files if one
starts. While a copy is running, switching headsets waits for it, as it does
for an export. When the headset can't be reached it waits longer each time,
up to 15 minutes, and starts over once it answers.
- **When this computer is full** (under 512 MB free, or a copy runs out of
space) it pauses and says so in Settings and above the lists. Free some space
and press **Resume**.
- **With several headsets** it copies from the one you're connected to.
Settings shows when it last checked, how many files it last copied and when,
and the last problem it had. The screenshot and clip lists say when it's on.
## For developers
`ui/frame_clips.py` holds the logic, so other features (such as syncing clips
automatically) can reuse it:
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
copying, in `ui/frame_autosync.py`, does):
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
computer's side; the server passes how to reach the Frame.
@@ -96,11 +134,15 @@ The API, with the same guards as every other request:
| `GET /api/clips` | Steam's recordings on the Frame, each with `saved` |
| `POST /api/clips/export {"id"}` | Starts an export; poll `/api/job` |
| `POST /api/open {"what": "videos"}` | Opens `~/Videos/SteamFrame` |
| `GET /api/autosync` | Automatic copying: `screenshots`, `clips`, `since` (the watermark), `checked`, `copied`, `copied_time`, `error`, `paused`, `waiting` (clips still being written) |
| `POST /api/autosync {"action": "settings", "screenshots", "clips"}` | Turn either on or off; turning it on sets `since` to now |
| `POST /api/autosync {"action": "now"\|"resume"}` | Copy everything not here yet, once; or resume after running out of space |
They aren't available when the server runs on the Frame itself (the iPhone
app), since they save to a computer.
Tests: `tests/test_clips.py`. They run the recording's shell under bash with a
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
copying (with a fake headset). They run the recording's shell under bash with a
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
recording with Steam's file names and export it with the real `ffmpeg`.
@@ -118,3 +160,5 @@ None of this has run on a Steam Frame yet. In particular:
whether a clip's segments start mid-recording. The parser is deliberately
loose about the layout.
- How much x264 at 1080p30 costs the Frame while a game is running.
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
finished saving a clip, and how a once-a-minute listing feels on the Frame.
+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)
+13 -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,
+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'));
});
+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))
+291
View File
@@ -0,0 +1,291 @@
"""Copying new screenshots and clips to the computer on their own (ui/frame_autosync.py).
The headset is a fake: lists of shots and clips, and save/export functions that
record what they were asked to copy. No SSH, no headset.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import errno
import json
import shutil
import sys
import tempfile
import threading
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import frame_autosync as fa # noqa: E402
NOW = 1_800_000_000.0
GB = 1024**3
def shot(n, t, saved=False):
return {"id": f"1/620/20261020{n:06d}_1.jpg", "time": t, "saved": saved}
def clip(n, t, idle=600, kind="clip", saved=False):
return {"id": f"1/clips/clip_620_{n}/video/bg", "kind": kind, "time": t, "idle": idle, "saved": saved}
class FakeFrame:
def __init__(self):
self.shots, self.clips = [], []
self.saved, self.exported, self.calls = [], [], []
self.device, self.busy, self.down = "frame-a", False, False
self.save_error = self.export_error = None
def list_shots(self):
self.calls.append("list_shots")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
return {"shots": list(self.shots)}
def list_clips(self):
self.calls.append("list_clips")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
return {"clips": list(self.clips)}
def save_shots(self, device, ids):
self.calls.append("save_shots")
if self.save_error:
raise self.save_error
self.saved.append((device, list(ids)))
for s in self.shots:
if s["id"] in ids:
s["saved"] = True
def export_clip(self, device, clip_id):
self.calls.append("export_clip")
if self.export_error:
raise self.export_error
self.exported.append((device, clip_id))
for c in self.clips:
if c["id"] == clip_id:
c["saved"] = True
class AutoSyncTest(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.frame = FakeFrame()
self.now = [NOW]
self.free = [100 * GB]
self.sync = self.make()
def make(self):
f = self.frame
return fa.AutoSync(f.list_shots, f.save_shots, f.list_clips, f.export_clip,
lambda: f.device, lambda: f.busy,
offline=lambda e: "The Frame isn't answering." if "timed out" in str(e) else None,
shots_folder=self.dir / "Pictures", clips_folder=self.dir / "Videos",
path=self.dir / "auto-sync.json", clock=lambda: self.now[0],
space=lambda folder: self.free[0])
def test_off_by_default_and_does_nothing(self):
self.frame.shots = [shot(1, NOW + 10)]
self.frame.clips = [clip(1, NOW + 10)]
s = self.sync.state()
self.assertFalse(s["screenshots"] or s["clips"])
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertEqual(self.frame.calls, []) # not even a listing
self.assertFalse((self.dir / "auto-sync.json").exists())
def test_settings_persist(self):
self.sync.update({"screenshots": True})
again = self.make().state()
self.assertTrue(again["screenshots"])
self.assertFalse(again["clips"])
self.assertEqual(again["since"], NOW)
saved = json.loads((self.dir / "auto-sync.json").read_text())
self.assertEqual((saved["screenshots"], saved["clips"]), (True, False))
def test_only_what_is_newer_than_turning_it_on(self):
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW + 5), shot(3, NOW + 50, saved=True)]
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW + 5)]
self.sync.update({"screenshots": True, "clips": True})
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertEqual(self.frame.saved, [("frame-a", [shot(2, 0)["id"]])])
self.assertEqual(self.frame.exported, [("frame-a", clip(2, 0)["id"])])
s = self.sync.state()
self.assertEqual((s["copied"], s["copied_time"], s["checked"], s["error"]), (2, NOW, NOW, None))
def test_watermark_moves_when_turned_off_and_on_again(self):
self.sync.update({"screenshots": True})
self.now[0] += 1000
self.sync.update({"clips": True}) # already on: the watermark stays
self.assertEqual(self.sync.state()["since"], NOW)
self.sync.update({"screenshots": False, "clips": False})
self.sync.update({"screenshots": True})
self.assertEqual(self.sync.state()["since"], NOW + 1000)
def test_copy_everything_now_once(self):
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW - 60)]
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW - 60, kind="background")]
self.sync.copy_all() # works with the toggles off too
self.assertTrue(self.sync.state()["pending"])
self.sync.tick()
self.assertEqual(self.frame.saved, [("frame-a", [shot(1, 0)["id"], shot(2, 0)["id"]])])
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])]) # never the background recording
self.assertFalse(self.sync.state()["pending"])
self.frame.calls.clear()
self.sync.tick()
self.assertEqual(self.frame.calls, []) # off again afterwards
def test_not_copied_again_after_deleting_it_here(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5)]
self.frame.clips = [clip(1, NOW + 5)]
self.sync.tick()
for s in self.frame.shots + self.frame.clips:
s["saved"] = False # the person deleted the files
self.frame.saved.clear()
self.frame.exported.clear()
self.make().tick() # remembered across restarts too
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
def test_saved_by_hand_counts_as_copied(self):
self.sync.update({"screenshots": True})
self.frame.shots = [shot(1, NOW + 5, saved=True)]
self.sync.tick()
self.frame.shots[0]["saved"] = False
self.sync.tick()
self.assertEqual(self.frame.saved, [])
def test_remembered_per_headset(self):
self.sync.update({"screenshots": True})
self.frame.shots = [shot(1, NOW + 5)]
self.sync.tick()
self.frame.device = "frame-b"
self.frame.shots = [shot(1, NOW + 5)] # the same name on another headset
self.sync.tick()
self.assertEqual([d for d, _ in self.frame.saved], ["frame-a", "frame-b"])
def test_clip_still_being_recorded_waits(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5, idle=4), clip(2, NOW + 6, idle=None)]
self.sync.tick()
self.assertEqual(self.frame.exported, [])
self.assertEqual(self.sync.state()["waiting"], 2)
self.frame.clips[0]["idle"] = fa.SETTLE + 1
self.sync.tick()
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])])
self.assertEqual(self.sync.state()["waiting"], 1)
def test_no_headset_no_work(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.device = None
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertEqual(self.frame.calls, [])
def test_waits_for_other_work(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5)]
self.frame.busy = True
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual(self.frame.calls, [])
self.frame.busy = False
self.sync.tick()
self.assertEqual(len(self.frame.saved), 1)
def test_stops_between_items_when_other_work_starts(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5), clip(2, NOW + 6)]
export = self.frame.export_clip
def then_busy(device, clip_id):
export(device, clip_id)
self.frame.busy = True # an install started meanwhile
self.frame.export_clip = then_busy
sync = self.make()
self.assertEqual(sync.tick(), fa.BUSY_WAIT)
self.assertEqual(len(self.frame.exported), 1)
def test_backs_off_while_unreachable(self):
self.sync.update({"screenshots": True})
self.frame.down = True
waits = [self.sync.tick() for _ in range(6)]
self.assertEqual(waits[:4], [120, 240, 480, 900])
self.assertEqual(waits[-1], fa.MAX_BACKOFF)
self.assertEqual(self.sync.state()["error"], "The Frame isn't answering.")
self.frame.down = False
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertIsNone(self.sync.state()["error"])
self.frame.down = True
self.assertEqual(self.sync.tick(), 120) # starts over after a good look
def test_pauses_when_this_computer_is_nearly_full(self):
self.sync.update({"screenshots": True})
self.frame.shots = [shot(1, NOW + 5)]
self.free[0] = 10 * 1024**2
self.sync.tick()
s = self.sync.state()
self.assertIn("out of space", s["paused"])
self.assertEqual(self.frame.saved, [])
self.free[0] = 100 * GB
self.frame.calls.clear()
self.sync.tick()
self.assertEqual(self.frame.calls, []) # stays paused, also after a restart
self.assertTrue(self.make().state()["paused"])
self.sync.resume()
self.sync.tick()
self.assertEqual(len(self.frame.saved), 1)
self.assertIsNone(self.sync.state()["paused"])
def test_pauses_when_a_copy_runs_out_of_space(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("Not enough free space on this computer: it needs 900 MB")
self.sync.tick()
self.assertIn("ran out of space", self.sync.state()["paused"])
self.sync.resume()
self.frame.export_error = None
self.frame.shots = [shot(1, NOW + 5)]
self.frame.save_error = OSError(errno.ENOSPC, "No space left on device")
self.sync.tick()
self.assertTrue(self.sync.state()["paused"])
def test_space_on_the_frame_is_not_a_pause(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("Not enough free space on the Frame to export it: it needs 900 MB")
self.sync.tick()
s = self.sync.state()
self.assertIsNone(s["paused"])
self.assertIn("Not enough free space on the Frame", s["error"])
def test_an_item_that_keeps_failing_is_left_alone(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("ffmpeg couldn't join the recording")
for _ in range(fa.TRIES + 2):
self.sync.tick()
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
def test_worker_stops(self):
stop = threading.Event()
t = threading.Thread(target=self.sync.run, args=(stop,), daemon=True)
t.start()
stop.set()
self.sync.update({}) # wakes it
t.join(5)
self.assertFalse(t.is_alive())
class Server(unittest.TestCase):
def test_route_and_settings(self):
import server
self.assertIn("/api/autosync", server.POST)
with self.assertRaises(server.Failure):
server.auto_sync({"action": "explode"})
state = server.auto_sync({"action": "settings", "screenshots": "yes"}) # not a bool: ignored
self.assertFalse(state["screenshots"])
if server.LINK is None:
with self.assertRaises(server.Failure):
server.auto_sync({"action": "now"}) # nothing connected
if __name__ == "__main__":
unittest.main()
+38 -6
View File
@@ -149,6 +149,9 @@ class Sessions(unittest.TestCase):
bg = by["123/video/bg_1172470_20261021_090000"]
self.assertEqual((bg["kind"], bg["appid"], bg["streams"], bg["duration"]), ("background", "1172470", 1, None))
self.assertIsNone(by["123/video/odd-name"]["appid"])
# How long since Steam last wrote to it: auto-sync waits for a clip still being recorded.
self.assertLess(clip["idle"], 60)
self.assertGreater(by["123/video/odd-name"]["idle"], 10**8)
self.assertEqual(clips.list_recordings(self.dir / "missing"), [])
def test_listing_marks_what_is_already_here(self):
@@ -332,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"]
@@ -344,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:
@@ -436,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$")
@@ -454,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
@@ -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); },
};
}
+285
View File
@@ -0,0 +1,285 @@
"""Copy new screenshots and Steam clips from the headset to this computer on their own.
Off until it's turned on in Settings. Then a worker in the server looks about once a
minute, while a headset is connected and nothing else is running on it, and copies
what's new with the same code as Save and Export: screenshots to ~/Pictures/SteamFrame,
saved Steam clips to ~/Videos/SteamFrame.
- Only what's newer than the moment it was turned on, so turning it on doesn't copy
the whole back-catalogue; Copy everything now does that, once.
- What it has copied is remembered per headset (auto-sync.json in the app's data
folder), so a file deleted from this computer isn't copied again.
- A clip Steam is still writing (a chunk changed in the last SETTLE seconds) waits
for the next look. Steam's background recording is never copied: it's a rolling
buffer, not something anyone chose to keep.
- When this computer runs out of space it pauses and says so, until Resume.
- When the headset can't be reached it waits longer each time, up to MAX_BACKOFF.
The server supplies how to reach the headset, so this can be tested without one:
list_shots() -> {"shots": [{"id", "time", "saved"}...]}
save_shots(device, ids) copies those screenshots here
list_clips() -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
export_clip(device, id) exports one clip here
device() -> the connected headset's id, or None
busy() -> True while other work runs on the headset (installs, jobs, a recording)
offline(error) -> a plain message if the error means the headset couldn't be reached
"""
import errno
import json
import os
import shutil
import threading
import time
from pathlib import Path
import frame_host
INTERVAL = 60 # seconds between looks
BUSY_WAIT = 15 # something else is running: look again soon
MAX_BACKOFF = 15 * 60 # the longest wait while the headset can't be reached
SETTLE = 30 # a clip written to within this many seconds is still being recorded
MIN_FREE = 512 * 1024**2 # pause below this much free space on this computer
KEEP = 5000 # copied ids remembered per headset and kind
BATCH = 100 # screenshots per copy
TRIES = 3 # an item that fails this often is left until the app restarts
KINDS = ("shots", "clips")
class DiskFull(Exception):
pass
def free_space(folder):
"""Free bytes on the disk `folder` is (or will be) on."""
p = Path(folder)
while not p.exists() and p != p.parent:
p = p.parent
return shutil.disk_usage(p).free
def disk_full(error):
"""Whether an error means this computer ran out of space (not the headset)."""
if isinstance(error, OSError) and error.errno == errno.ENOSPC:
return True
text = str(error)
return "No space left on device" in text or "free space on this computer" in text
def _defaults():
return {"screenshots": False, "clips": False, "since": None, "synced": {}, "paused": None,
"checked": None, "copied": 0, "copied_time": None, "error": None, "waiting": 0}
class AutoSync:
def __init__(self, list_shots, save_shots, list_clips, export_clip, device, busy, offline=lambda e: None,
shots_folder=None, clips_folder=None, path=None, clock=time.time, space=free_space):
self.list_shots, self.save_shots = list_shots, save_shots
self.list_clips, self.export_clip = list_clips, export_clip
self.device, self.busy, self.offline = device, busy, offline
self.shots_folder = Path(shots_folder or Path.home() / "Pictures" / "SteamFrame")
self.clips_folder = Path(clips_folder or Path.home() / "Videos" / "SteamFrame")
self.path = Path(path or frame_host.data_dir("auto-sync.json"))
self.clock, self.space = clock, space
self._lock = threading.RLock()
self._wake = threading.Event()
self._all = False # Copy everything now, waiting for the next pass
self._running = False
self._backoff = 0
self._tries = {} # (device, id) -> failed attempts this run
self._errors = [] # this pass's, for the page
self._s = self._load()
# ---- settings and what the page shows ----
def _load(self):
s = _defaults()
try:
saved = json.loads(self.path.read_text())
if isinstance(saved, dict):
s.update({k: v for k, v in saved.items() if k in s})
except (OSError, ValueError):
pass
s["screenshots"], s["clips"] = bool(s["screenshots"]), bool(s["clips"])
if not isinstance(s["synced"], dict):
s["synced"] = {}
return s
def _save(self):
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self._s, indent=1))
os.replace(tmp, self.path)
except OSError:
pass # the settings still apply until the app restarts
def enabled(self):
with self._lock:
return self._s["screenshots"] or self._s["clips"]
def state(self):
with self._lock:
s = self._s
return {k: s[k] for k in ("screenshots", "clips", "since", "paused", "checked", "copied",
"copied_time", "error", "waiting")} | {
"pending": self._all, "running": self._running,
"folders": {"shots": str(self.shots_folder), "clips": str(self.clips_folder)}}
def update(self, changes):
"""{"screenshots": bool, "clips": bool}: turning it on starts the watermark now."""
with self._lock:
was = self.enabled()
for key in ("screenshots", "clips"):
if key in changes:
self._s[key] = bool(changes[key])
if self.enabled() and (not was or self._s["since"] is None):
self._s["since"] = self.clock()
if not self.enabled():
self._s["error"], self._s["waiting"] = None, 0
self._save()
self._wake.set()
return self.state()
def copy_all(self):
"""Copy everything on the headset that isn't on this computer, once (also resumes)."""
with self._lock:
self._all, self._s["paused"] = True, None
self._save()
self._wake.set()
return self.state()
def resume(self):
with self._lock:
self._s["paused"], self._s["error"] = None, None
self._save()
self._backoff = 0
self._wake.set()
return self.state()
# ---- the worker ----
def run(self, stop):
"""Until `stop` (a threading.Event) is set."""
while not stop.is_set():
delay = self.tick()
self._wake.wait(delay)
self._wake.clear()
def tick(self):
"""One look. -> seconds until the next."""
with self._lock:
want_all = self._all
shots = self._s["screenshots"] or want_all
clips = self._s["clips"] or want_all
if not (shots or clips) or self._s["paused"]:
return INTERVAL
since = None if want_all else self._s["since"] or 0
device = self.device()
if not device:
return INTERVAL # nothing connected: nothing to ask
if self.busy():
return BUSY_WAIT
with self._lock:
self._running = True
self._errors = []
try:
copied, waiting, done = self._pass(device, shots, clips, since)
except DiskFull as e:
with self._lock:
self._s.update(paused=str(e), error=str(e))
self._save()
return INTERVAL
except Exception as e:
friendly = self.offline(e)
self._backoff = min(max(self._backoff * 2, INTERVAL * 2), MAX_BACKOFF)
with self._lock:
self._s["error"] = friendly or str(e)
self._save()
return self._backoff
finally:
with self._lock:
self._running = False
self._backoff = 0
with self._lock:
if copied:
self._s.update(copied=copied, copied_time=self.clock())
self._s.update(checked=self.clock(), waiting=waiting)
if done:
self._s["error"] = self._errors[0] if self._errors else None
if want_all:
self._all = False
self._save()
return INTERVAL if done else BUSY_WAIT
def _synced(self, device, kind):
with self._lock:
return set(self._s["synced"].get(device, {}).get(kind, []))
def _mark(self, device, kind, ids):
with self._lock:
per = self._s["synced"].setdefault(device, {})
have = per.get(kind, [])
new = [i for i in ids if i not in set(have)]
if new:
per[kind] = (have + new)[-KEEP:]
self._save()
def _room(self, folder):
if self.space(folder) < MIN_FREE:
raise DiskFull(f"Paused: this computer is nearly out of space ({folder} has under "
f"{MIN_FREE // 1024**2} MB free). Free some space, then press Resume.")
def _failed(self, device, item, error):
if disk_full(error):
raise DiskFull("Paused: this computer ran out of space while copying. Free some space, "
"then press Resume.")
if self.offline(error):
raise error
key = (device, item)
self._tries[key] = self._tries.get(key, 0) + 1
self._errors.append(f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}")
def _pass(self, device, shots, clips, since):
"""-> (copied, clips still being written, finished without stopping for other work)."""
copied = waiting = 0
if shots:
listing = self.list_shots()["shots"]
done = self._synced(device, "shots")
self._mark(device, "shots", [s["id"] for s in listing if s.get("saved")])
todo = [s["id"] for s in sorted(listing, key=lambda s: s["time"])
if not s.get("saved") and (since is None or (s["time"] >= since and s["id"] not in done))
and self._tries.get((device, s["id"]), 0) < TRIES]
for i in range(0, len(todo), BATCH):
if self.busy():
return copied, waiting, False
self._room(self.shots_folder)
batch = todo[i:i + BATCH]
try:
self.save_shots(device, batch)
except Exception as e:
for item in batch:
self._failed(device, item, e)
continue
self._mark(device, "shots", batch)
copied += len(batch)
if clips:
listing = [c for c in self.list_clips()["clips"] if c.get("kind") == "clip"]
done = self._synced(device, "clips")
self._mark(device, "clips", [c["id"] for c in listing if c.get("saved")])
for c in sorted(listing, key=lambda c: c["time"]):
if c.get("saved") or self._tries.get((device, c["id"]), 0) >= TRIES:
continue
if since is not None and (c["time"] < since or c["id"] in done):
continue
if c.get("idle") is None or c["idle"] < SETTLE:
waiting += 1 # still being written: copied on a later look
continue
if self.busy():
return copied, waiting, False
self._room(self.clips_folder)
try:
self.export_clip(device, c["id"])
except Exception as e:
self._failed(device, c["id"], e)
continue
self._mark(device, "clips", [c["id"]])
copied += 1
return copied, waiting, True
+19 -9
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)
@@ -412,7 +419,8 @@ def describe(userdata, clip_id):
pass
if duration is not None:
break
appid, when = None, max(s.st_mtime for s in stats)
newest = max(s.st_mtime for s in stats)
appid, when = None, newest
for p in parts[2:]:
m = STAMP.search(p)
if m:
@@ -426,7 +434,9 @@ def describe(userdata, clip_id):
"size": sum(s.st_size for s in stats), "streams": len(streams),
"chunks": sum(len(s) - 1 for s in streams.values()),
"gaps": sum(chunk_gaps(s) for s in streams.values()), "duration": duration,
"file": export_name(clip_id)}
"file": export_name(clip_id),
# Seconds since Steam last wrote to it, by the Frame's clock: still recording if small.
"idle": round(max(0.0, time.time() - newest), 1)}
def list_recordings(userdata):
+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()
+118 -7
View File
@@ -388,6 +388,7 @@
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
.popt b { font-weight: 600; color: var(--text); }
.sync-note { margin: 10px 0 0; }
.popt .sub { grid-column: 2; line-height: 1.45; }
.sentlog { max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 3px; padding: 10px;
font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
@@ -1247,6 +1248,7 @@
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
</div>
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
<div class="sub sync-note" id="shotsSyncNote" hidden></div>
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to <code>~/Pictures/SteamFrame</code>. Right-click for more.</div>
</section>
<section id="clips">
@@ -1255,6 +1257,7 @@
<button class="small" id="videosFolder" title="Open the SteamFrame folder in your Videos">Show folder</button>
</div>
<div class="list" id="clipList"><div class="sub">Loading…</div></div>
<div class="sub sync-note" id="clipsSyncNote" hidden></div>
<div class="hint">Clips and background recordings from Steam's game recording in the headset. Export joins one into a single <code>.mp4</code> in <code>~/Videos/SteamFrame</code>, however long it is, without re-encoding it. Recordings you make with <b>Record</b> go there too.</div>
</section>
</div>
@@ -1263,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>
@@ -1612,6 +1620,21 @@
<div id="steamGridStatus" class="hint" role="status"></div>
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh artwork for installed apps and titles</button>
</section>
<section class="panel" id="autoSync">
<div class="shelf-head"><h2>Copy to this computer</h2></div>
<p class="sub">Copy new screenshots and clips to this computer automatically, about once a minute while a headset is connected.</p>
<label class="popt"><input type="checkbox" id="syncShots"><b>Screenshots</b>
<span class="sub">Into <code>~/Pictures/SteamFrame</code>, as Save does.</span></label>
<label class="popt"><input type="checkbox" id="syncClips"><b>Steam clips</b>
<span class="sub">Clips you save in Steam, as <code>.mp4</code> files in <code>~/Videos/SteamFrame</code>, once Steam has finished
writing them. Steam's background recording isn't copied.</span></label>
<div class="row"><button class="small" id="syncNow">Copy everything now</button>
<button class="small action" id="syncResume" hidden>Resume</button></div>
<div class="hint" id="syncStatus" role="status"></div>
<div class="hint">Only what you take after turning it on is copied; Copy everything now copies the rest, once. A file you
delete from this computer isn't copied again. It copies from the headset you're connected to, and waits while an
install, an export or a recording is running.</div>
</section>
<section class="panel" id="power">
<div class="shelf-head"><h2>Remote access</h2></div>
<div class="actions">
@@ -1734,7 +1757,7 @@
</div>
<div class="page" data-page="settings">
<div class="workspace-heading"><div><a href="#settings" id="settingsBack" class="workspace-link" hidden>← All settings</a><h1 id="settingsTitle">Settings</h1><p id="settingsSubtitle">Connection, headset controls and Frame Control preferences.</p></div></div>
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices &amp; connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display &amp; reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#privacy"><span>Privacy &amp; updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote &amp; power</span><small>Remote access, sleep and restart</small></a></div>
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices &amp; connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display &amp; reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#autoSync" id="syncLink"><span>Copy to this computer</span><small>New screenshots and clips, automatically</small></a><a href="#privacy"><span>Privacy &amp; updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote &amp; power</span><small>Remote access, sleep and restart</small></a></div>
<div id="settingsWorkspace" hidden></div>
</div>
@@ -2062,6 +2085,7 @@ function applyHostWording(h) {
document.body.classList.toggle("mobile", !!h.mobile);
$("shotsFolder").hidden = !!h.mobile;
$("recBtn").hidden = $("clips").hidden = !!h.mobile; // recordings are saved to a computer
$("syncLink").hidden = !!h.mobile; // and so are automatic copies
$("quest").hidden = !!h.mobile;
$("metaLib").hidden = !!h.mobile; // the Quest plugs into a computer; the phone app's server runs on the Frame
if (!h.mobile) $("shotsFolder").textContent = $("videosFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
@@ -2471,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.
@@ -3263,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();
});
@@ -3271,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);
@@ -3848,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>`;
@@ -3860,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();
@@ -3868,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" };
@@ -5180,6 +5251,45 @@ $("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/ap
// window is visible and the Frame is reachable, and again on coming back to it.
setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS);
// ---- Copying new screenshots and clips here on their own (ui/frame_autosync.py) ----
const autoSync = { s: null, copied: null };
const syncWhen = t => new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
function renderAutoSync(s) {
autoSync.s = s;
$("syncShots").checked = s.screenshots; $("syncClips").checked = s.clips;
$("syncResume").hidden = !s.paused;
const on = s.screenshots || s.clips, lines = [];
if (s.paused) lines.push(s.paused);
else if (s.error) lines.push(`Last problem: ${s.error}`);
if (s.running || s.pending) lines.push("Copying…");
if (s.copied_time) lines.push(`Last copied ${s.copied} file${s.copied === 1 ? "" : "s"} on ${syncWhen(s.copied_time)}.`);
if (on && s.checked) lines.push(`Last checked ${syncWhen(s.checked)}.`);
if (on && s.waiting) lines.push(`${s.waiting} clip${s.waiting === 1 ? " is" : "s are"} still being written; copied once Steam finishes.`);
if (on && s.since) lines.push(`Copying what's new since ${syncWhen(s.since)}.`);
$("syncStatus").textContent = lines.length ? lines.join(" ") : "Off.";
for (const [id, key, what] of [["shotsSyncNote", "screenshots", "New screenshots"], ["clipsSyncNote", "clips", "New Steam clips"]]) {
$(id).hidden = !s[key] || !!HOST.mobile;
$(id).innerHTML = s.paused ? `${esc(s.paused)} <a href="#autoSync">Settings</a>`
: `${what} are copied to this ${esc(HOST.computer)} automatically${s.copied_time ? `; last copy ${esc(syncWhen(s.copied_time))}` : ""}. <a href="#autoSync">Change</a>`;
}
// Something new arrived here: show it as saved.
if (autoSync.copied !== null && s.copied_time !== autoSync.copied) { loadShots(true); if (!HOST.mobile) loadClips(); }
autoSync.copied = s.copied_time;
}
async function loadAutoSync() {
try { renderAutoSync(await api("/api/autosync")); } catch {} // not on the iPhone app's server
}
async function setAutoSync(body, label) {
try { renderAutoSync(await api("/api/autosync", body)); }
catch (e) { toast(`${label} failed: ${e.message}`, true); loadAutoSync(); }
}
$("syncShots").onchange = e => setAutoSync({ action: "settings", screenshots: e.target.checked }, "Saving");
$("syncClips").onchange = e => setAutoSync({ action: "settings", clips: e.target.checked }, "Saving");
$("syncNow").onclick = async e => { const r = await act("Copy everything now", () => api("/api/autosync", { action: "now" }), e.currentTarget); if (r) renderAutoSync(r); };
$("syncResume").onclick = async e => { const r = await act("Resume copying", () => api("/api/autosync", { action: "resume" }), e.currentTarget); if (r) renderAutoSync(r); };
loadAutoSync();
setInterval(() => { const s = autoSync.s; if (!document.hidden && s && (s.screenshots || s.clips || s.pending || s.running)) loadAutoSync(); }, 20000);
// ---- Recording the headset view, and Steam's own clips (server.py, frame_clips.py) ----
// A recording runs on the Frame until Stop; the server then copies the .mp4 here
// and the page follows it by polling /api/record.
@@ -5548,8 +5658,8 @@ const PAGES = ["apps", "games", "tools", "settings"];
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", backup:"tools"};
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork",
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", backup:"Backup & recovery"};
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
@@ -5637,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();
@@ -6635,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(); });
@@ -7060,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);
+98 -4
View File
@@ -51,6 +51,7 @@ from apk_sources import fdroid as apk_fdroid # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_clips # noqa: E402
import frame_autosync # noqa: E402
import frame_devices # noqa: E402
import frame_steamgriddb
import frame_comfort # noqa: E402
@@ -819,6 +820,54 @@ def export_clip(body):
return start_job(f"Export {name}", work)
# Copying new screenshots and clips here on their own (frame_autosync.py). Each copy
# counts as work on the headset it was listed on, so a switch can't send it elsewhere.
def _sync_device():
if not LINK or LINK.snapshot().get("phase") != "connected":
return None
return LINK.active_device().get("id")
def _sync_shots(device, ids):
with working(device):
return save_shots({"ids": ids})
def _sync_clip(device, clip_id):
with working(device):
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
fetch_file)
def _sync_list(listing):
def run():
with working(_sync_device()):
return listing()
return run
_stop_sync = threading.Event()
autosync = frame_autosync.AutoSync(_sync_list(list_shots), _sync_shots, _sync_list(list_clips), _sync_clip,
_sync_device, lambda: _work[0] > 0, offline=lambda e: unreachable(str(e)),
shots_folder=SHOTS_DIR, clips_folder=frame_clips.VIDEOS_DIR)
def auto_sync(body):
"""{"action": "settings", "screenshots", "clips"} | {"action": "now"} | {"action": "resume"}."""
_clips_here()
action = body.get("action")
if action == "settings":
return autosync.update({k: body[k] for k in ("screenshots", "clips") if isinstance(body.get(k), bool)})
if action == "now":
if not _sync_device():
raise Failure("Connect a headset first", 409)
return dict(autosync.copy_all(), message="Copying everything that isn't on this computer yet…")
if action == "resume":
return dict(autosync.resume(), message="Copying again")
raise Failure("action must be settings, now or resume", 400)
def launch(body):
appid = str(body.get("appid", ""))
if not APPID.match(appid):
@@ -827,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.
@@ -845,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")
@@ -2915,9 +3001,9 @@ POST = {
"/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/record": record, "/api/clips/export": export_clip, "/api/autosync": auto_sync,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
@@ -3200,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":
@@ -3215,6 +3303,9 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(recorder.status())
elif path == "/api/clips":
self.send_json(list_clips())
elif path == "/api/autosync":
_clips_here()
self.send_json(autosync.state())
elif path == "/api/stream":
self.stream_video(url.query)
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["panel"]:
@@ -3505,6 +3596,8 @@ def main():
frame_report.link = LINK # its connection summary goes in every report's diagnostics
frame_report.start_ssh_check() # ...with which ssh this is, found once in the background
LINK.start()
if not PRIVATE: # one worker per user, in the app's server
threading.Thread(target=autosync.run, args=(_stop_sync,), daemon=True).start()
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof:
@@ -3527,6 +3620,7 @@ def main():
# mid-cleanup would abort it and leave the SSH master running.
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
_stop_sync.set()
webinstall_shutdown()
macview.shutdown() # close the headset's viewers before the agent goes
# The master was started with -N, so it stays up until told to exit.