mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 09:01:40 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96502e6e4d | ||
|
|
d5f26efc1a | ||
|
|
eac9557286 | ||
|
|
4aa9f18fd1 | ||
|
|
fa40e30f3c | ||
|
|
d7a887b98c | ||
|
|
a5980d8f98 |
No files matched your search
@@ -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">
|
||||
|
||||
+14
-5
@@ -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
|
||||
|
||||
|
||||
@@ -58,6 +58,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
|
||||
@@ -137,6 +144,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,
|
||||
|
||||
+12
-6
@@ -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.
|
||||
|
||||
|
||||
+2
-1
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 <2><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'));
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
+35
-6
@@ -335,8 +335,10 @@ class Commands(unittest.TestCase):
|
||||
|
||||
|
||||
# A stand-in for ffmpeg on the Frame: records how it was run, writes its output file,
|
||||
# and finishes the file when asked to stop (SIGTERM), as ffmpeg does. FAKE_FFMPEG_RUN
|
||||
# makes the recording end by itself after that many seconds (the time limit).
|
||||
# and finishes the file when q arrives on stdin. Like SteamOS's ffmpeg n7.0 (seen on a
|
||||
# Frame), it catches SIGTERM and SIGINT and carries on, exiting only after more than
|
||||
# three. FAKE_FFMPEG_RUN makes the recording end by itself after that many seconds (the
|
||||
# time limit); FAKE_FFMPEG_DEAF makes it ignore q too, so only SIGKILL ends it.
|
||||
FAKE_FFMPEG = r'''#!/usr/bin/env python3
|
||||
import json, os, signal, sys, time
|
||||
home = os.environ["HOME"]
|
||||
@@ -347,8 +349,22 @@ if "-c" in sys.argv and sys.argv[sys.argv.index("-c") + 1] == "copy": # the re
|
||||
src = sys.argv[sys.argv.index("-i") + 1]
|
||||
open(out, "wb").write(open(src, "rb").read() + b"+mp4")
|
||||
sys.exit(0)
|
||||
stop = []
|
||||
signal.signal(signal.SIGTERM, lambda *a: stop.append("TERM"))
|
||||
import threading
|
||||
stop, signals = [], []
|
||||
def caught(*a):
|
||||
signals.append(1)
|
||||
if len(signals) > 3:
|
||||
os._exit(255) # "Received > 3 system signals, hard exiting"
|
||||
signal.signal(signal.SIGTERM, caught)
|
||||
signal.signal(signal.SIGINT, caught)
|
||||
def keys():
|
||||
while True:
|
||||
ch = os.read(0, 1)
|
||||
if not ch:
|
||||
return
|
||||
if ch == b"q" and not os.environ.get("FAKE_FFMPEG_DEAF"):
|
||||
stop.append("q")
|
||||
threading.Thread(target=keys, daemon=True).start()
|
||||
f = open(out, "wb")
|
||||
f.write(b"v"); f.flush()
|
||||
with open(os.path.join(home, "ffmpeg.pid.tmp"), "w") as p:
|
||||
@@ -439,7 +455,7 @@ class Recording(unittest.TestCase):
|
||||
self.assertEqual(rec.stop()["state"], "stopping")
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual(done["state"], "saved", done)
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "TERM") # asked to finish, not killed
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q") # asked to finish, not killed
|
||||
saved = Path(done["file"])
|
||||
self.assertEqual(saved.parent, self.here)
|
||||
self.assertRegex(saved.name, r"^rec-\d{8}-\d{6}\.mp4$")
|
||||
@@ -457,7 +473,20 @@ class Recording(unittest.TestCase):
|
||||
pid = int((self.frame / "ffmpeg.pid").read_text())
|
||||
self.channels[0].stdin.close()
|
||||
self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "TERM")
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q")
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
for _ in range(100):
|
||||
os.kill(pid, 0)
|
||||
time.sleep(0.05)
|
||||
|
||||
def test_an_ffmpeg_that_ignores_q_is_killed_not_left_recording(self):
|
||||
self.env["FAKE_FFMPEG_DEAF"] = "1"
|
||||
rec = self.recorder()
|
||||
rec.start()
|
||||
pid = int((self.frame / "ffmpeg.pid").read_text())
|
||||
rec.stop()
|
||||
self.wait_for(rec, ("saved", "failed"), timeout=30)
|
||||
self.assertFalse((self.frame / "ffmpeg-ended").exists()) # it never finished by itself
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
for _ in range(100):
|
||||
os.kill(pid, 0)
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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); },
|
||||
};
|
||||
}
|
||||
|
||||
+14
-7
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
+57
-4
@@ -1266,6 +1266,11 @@
|
||||
<div class="workspace-heading"><div><span class="eyebrow">Play on your Frame</span><h1>Games</h1><p>Your Steam library and games you add yourself.</p></div><button class="action add-install" data-open-install>+ Add / Install</button></div>
|
||||
<div class="library-controls"><div class="scope-tabs" id="libraryScope" aria-label="Game library"><button data-scope="installed" aria-pressed="true">Installed</button><button data-scope="owned" aria-pressed="false">Owned</button><button data-scope="store" aria-pressed="false">Store</button></div>
|
||||
<div class="scope-tabs" id="librarySources" aria-label="Installed content source"><button data-library-source="all" aria-pressed="true">All</button><button data-library-source="steam" aria-pressed="false">Steam</button><button data-library-source="sideloaded" aria-pressed="false">Sideloaded</button></div></div>
|
||||
<section class="panel now-running" id="nowRunning" aria-labelledby="nowRunningTitle" hidden>
|
||||
<div class="shelf-head"><h2 id="nowRunningTitle">Running now</h2><span class="spacer"></span><button class="small" id="runningCheck">Check again</button></div>
|
||||
<div class="list" id="runningList"></div>
|
||||
<div class="hint">Force quit a game that has stopped responding. Steam is asked to quit it first; unsaved progress is lost.</div>
|
||||
</section>
|
||||
<div id="libraryOwned"></div><div id="steamDiscover" hidden></div>
|
||||
<section id="library">
|
||||
<div class="shelf-head"><h2>Steam games</h2><span class="count" id="gameCount"></span></div>
|
||||
@@ -2455,6 +2460,7 @@ function render(s) {
|
||||
<div class="name">${esc(g.name)}</div>
|
||||
<div class="size">${gb(g.size)}</div>
|
||||
<button class="play small" data-launch="${g.appid}" data-name="${esc(g.name)}">▶ Play</button>
|
||||
<button class="small danger" data-force-quit-app="${g.appid}" data-name="${esc(g.name)}" title="Quit it if it has stopped responding">Force quit…</button>
|
||||
</div>
|
||||
</div>`).join("") : `<div class="sub">No games installed.</div>`;
|
||||
// Tall library art, falling back to the wide header, then to the name card.
|
||||
@@ -3247,7 +3253,7 @@ $("ctrlKeys").addEventListener("click", e => {
|
||||
});
|
||||
|
||||
// ---- controls ----
|
||||
$("refreshAll").onclick = () => { refresh(); loadTitles(); loadAndroid(); }; // titles too: the Frame may have been asleep at start
|
||||
$("refreshAll").onclick = () => { refresh(); loadTitles(); loadAndroid(); loadRunning().catch(() => {}); }; // titles too: the Frame may have been asleep at start
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) $("refreshAll").onclick();
|
||||
});
|
||||
@@ -3255,6 +3261,7 @@ document.body.addEventListener("click", async (e) => {
|
||||
const b = e.target.closest("button");
|
||||
if (!b) return;
|
||||
if (b.dataset.launch) act(`Launch ${b.dataset.name}`, () => api("/api/launch", { appid: b.dataset.launch }), b);
|
||||
else if (b.dataset.forceQuitApp) forceQuitFrom(g => String(g.appid) === b.dataset.forceQuitApp, b.dataset.name, b);
|
||||
else if (b.dataset.uninstall) {
|
||||
if (confirm(`Remove ${b.dataset.name} from the Frame?`))
|
||||
act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh);
|
||||
@@ -3832,6 +3839,7 @@ async function loadTitles() {
|
||||
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">Play</button>
|
||||
<details class="lib-more"><summary aria-label="More for ${esc(t.id)}">⋯</summary><div class="lib-menu">
|
||||
${t.art_missing ? `<button data-title="refresh-art" data-id="${esc(t.id)}">Add artwork<small>Its Steam library tile has none yet</small></button>` : ""}
|
||||
<button class="danger" data-title="force-quit" data-id="${esc(t.id)}">Force quit…<small>If it has stopped responding</small></button>
|
||||
<button class="danger" data-title="remove" data-id="${esc(t.id)}">Remove…<small>Deletes the game and its files</small></button>
|
||||
</div></details>
|
||||
</div>`).join("") : `<div class="sub">None yet.</div>`;
|
||||
@@ -3844,6 +3852,8 @@ $("titleList").onclick = async e => {
|
||||
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
|
||||
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
|
||||
loadTitles();
|
||||
} else if (b.dataset.title === "force-quit") {
|
||||
forceQuitFrom(g => g.kind === "devkit" && g.title === id, id, b);
|
||||
} else if (b.dataset.title === "refresh-art") {
|
||||
await runJob(`Add Steam artwork for ${id}`, "art-" + id, () => api("/api/titles", { action: "refresh-art", id }));
|
||||
loadTitles();
|
||||
@@ -3852,6 +3862,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" };
|
||||
@@ -5528,7 +5580,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();
|
||||
@@ -6525,7 +6577,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(); });
|
||||
@@ -6949,7 +7002,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);
|
||||
|
||||
+42
-3
@@ -875,12 +875,12 @@ def launch(body):
|
||||
return {"message": f"Launching {appid}"}
|
||||
|
||||
|
||||
def steam_frame(*args, timeout=40, script="frame_steam.py"):
|
||||
def steam_frame(*args, timeout=40, script="frame_steam.py", source=None):
|
||||
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
|
||||
helper with the same JSON contract, on the Frame and return its JSON."""
|
||||
try:
|
||||
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
|
||||
stdin=(HERE / script).read_text(), timeout=timeout)
|
||||
stdin=source or (HERE / script).read_text(), timeout=timeout)
|
||||
except Failure as e:
|
||||
# The helper prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# reports stderr instead if there was any, so look in both.
|
||||
@@ -893,6 +893,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")
|
||||
@@ -2896,7 +2933,7 @@ 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/autosync": auto_sync,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
@@ -3181,6 +3218,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":
|
||||
|
||||
Reference in new issue
Block a user