From a5980d8f980a7e38d9c0cec77ca938859e785a45 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Fri, 23 Oct 2026 23:17:59 +0000 Subject: [PATCH 1/5] Force quit a running game from Games and the Control Center Running now, at the top of Games, lists what's running on the Frame (Steam games, sideloaded titles, non-Steam shortcuts, Android apps), with Force quit on each, on a game's library tile and in a sideloaded title's menu. The Control Center gets a Force quit a game card. Each asks first: unsaved progress will be lost. Nothing running says so. ui/frame_running.py finds games by Steam's `reaper SteamLaunch AppId=` process and its tree, never by name. It asks Steam to quit (SteamClient.Apps.TerminateApp), then sends SIGTERM and SIGKILL to the tree, skipping init, the SSH session, other users and Steam, gamescope, SteamVR, sshd and the session. Android apps also get the Apps tab's container stop. The fake Frame runs games under a stand-in reaper and lists them in SteamUIStore.RunningApps, so tests/e2e/test_running.py force quits real process trees. Not tested on a headset. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/frame-control.md | 13 + docs/sideloading.md | 3 +- docs/steam-games.md | 55 +++ docs/testing.md | 15 +- tests/control_center.cjs | 66 ++- tests/e2e/test_running.py | 82 ++++ .../usr/local/lib/fakeframe/cef_shim.js | 9 +- .../usr/local/lib/fakeframe/fakesteam.py | 31 +- tests/test_force_quit.py | 390 ++++++++++++++++++ tests/ui_review.cjs | 2 +- ui/control-center.css | 2 + ui/control-center.js | 77 +++- ui/frame_running.py | 329 +++++++++++++++ ui/index.html | 61 ++- ui/server.py | 45 +- 16 files changed, 1159 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/test_running.py create mode 100644 tests/test_force_quit.py create mode 100644 ui/frame_running.py diff --git a/README.md b/README.md index 5483f4d..7991bff 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and w **🎮 Steam games**
-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. diff --git a/docs/frame-control.md b/docs/frame-control.md index 1696757..ba0ae9a 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -54,6 +54,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 @@ -127,6 +134,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, diff --git a/docs/sideloading.md b/docs/sideloading.md index 53a0ad8..1943e08 100644 --- a/docs/sideloading.md +++ b/docs/sideloading.md @@ -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: diff --git a/docs/steam-games.md b/docs/steam-games.md index 5548925..567bf02 100644 --- a/docs/steam-games.md +++ b/docs/steam-games.md @@ -101,6 +101,61 @@ 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. + +`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= -- `, 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(, 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-`). +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) diff --git a/docs/testing.md b/docs/testing.md index e45a762..9d96596 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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= -- ` (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, diff --git a/tests/control_center.cjs b/tests/control_center.cjs index 2bc56f2..41ffd48 100644 --- a/tests/control_center.cjs +++ b/tests/control_center.cjs @@ -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>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')); +}); diff --git a/tests/e2e/test_running.py b/tests/e2e/test_running.py new file mode 100644 index 0000000..2ad4caf --- /dev/null +++ b/tests/e2e/test_running.py @@ -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() diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js index c4fa0dc..a65563a 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js @@ -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)]; diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/fakesteam.py b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/fakesteam.py index 7c9ee78..d5d2c31 100755 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/fakesteam.py +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/fakesteam.py @@ -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= -- `, +# 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)) diff --git a/tests/test_force_quit.py b/tests/test_force_quit.py new file mode 100644 index 0000000..b2f55ec --- /dev/null +++ b/tests/test_force_quit.py @@ -0,0 +1,390 @@ +"""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 signal +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_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 == signal.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 == signal.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, signal.SIGTERM), world.sent) + self.assertIn((403, signal.SIGKILL), world.sent) + self.assertGreaterEqual(world.now, frame_running.TERM_WAIT) + kills = [pid for pid, sig in world.sent if sig == signal.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, signal.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, signal.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)] + 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() diff --git a/tests/ui_review.cjs b/tests/ui_review.cjs index 070a170..853ce86 100644 --- a/tests/ui_review.cjs +++ b/tests/ui_review.cjs @@ -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(){},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'; diff --git a/ui/control-center.css b/ui/control-center.css index ca0d911..e671ce9 100644 --- a/ui/control-center.css +++ b/ui/control-center.css @@ -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; } } diff --git a/ui/control-center.js b/ui/control-center.js index ef11b99..bc9e54a 100644 --- a/ui/control-center.js +++ b/ui/control-center.js @@ -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']]}, @@ -129,6 +132,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'; @@ -160,9 +165,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(); @@ -180,16 +206,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) => `
${label}
Unavailable
`; const perf = `
${[['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('')}

GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.

Trip data unavailable

`; const audio = `
Waiting for an audio reading

`; const health = `
${[['battery','Charge'],['power','Battery power ¡ signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}
`; + const forcequit = `

    `; const storage = `
    ${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}
    `; get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>``).join(''); - const specials = {audio, performance:perf, health, storage}; + const specials = {audio, performance:perf, health, storage, forcequit}; get('ccCards').innerHTML = catalog.map(item=>`
    ${icon(item.icon)}${item.label}

    ${item.title}

    ${item.description}

    ${specials[item.special] || ''}${item.guidance ? `
    ${item.guidance}
    ` : ''}${item.links ? `` : ''}
    `).join(''); // Host-only features stay searchable with a reason; only their unsupported // action disappears. The host API, rather than viewport/UA, decides availability. @@ -285,6 +312,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=>`
  • ${esc(app.name)}${KINDS[app.kind] || 'Running'}
  • `).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}); @@ -310,7 +379,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); }, }; } diff --git a/ui/frame_running.py b/ui/frame_running.py new file mode 100644 index 0000000..45e98f4 --- /dev/null +++ b/ui/frame_running.py @@ -0,0 +1,329 @@ +#!/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= -- `, 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 + +HOME = os.path.expanduser("~") +STEAM = os.path.join(HOME, ".local/share/Steam") +DEVKIT = os.path.join(HOME, "devkit-game") +ANDROID = os.path.join(HOME, "Applications/Android") # frame_android.APPS_DIR +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_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= -- ...`, 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 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 ((signal.SIGTERM, TERM_WAIT, False), (signal.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 == signal.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//...).""" + 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 (): + 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() diff --git a/ui/index.html b/ui/index.html index ca1c962..6c6f8c2 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1253,6 +1253,11 @@
    Play on your Frame

    Games

    Your Steam library and games you add yourself.

    +

    Steam games

    @@ -2415,6 +2420,7 @@ function render(s) {
    ${esc(g.name)}
    ${gb(g.size)}
    + `).join("") : `
    No games installed.
    `; // Tall library art, falling back to the wide header, then to the name card. @@ -3207,7 +3213,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(); }); @@ -3215,6 +3221,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); @@ -3783,6 +3790,7 @@ async function loadTitles() {
    ⋯
    ${t.art_missing ? `` : ""} +
    `).join("") : `
    None yet.
    `; @@ -3795,6 +3803,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(); @@ -3803,6 +3813,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 => ` +
    ${esc(g.name)}
    ${RUNNING_KIND[g.kind] || "Running"} ¡ running on the Frame
    +
    `).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" }; @@ -5339,7 +5391,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(); @@ -6332,7 +6384,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(); 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(); }); @@ -6756,7 +6809,7 @@ api("/api/host").then(h => {