Adapt exact Battery Tracker archive for Frame user service

This commit is contained in:
SAPHID committed 2026-10-28 13:44:59 +00:00
1 parent 21c609b4a2
commit d4c523efd9
14 files changed
+261 -18

No files matched your search

@@ -0,0 +1,11 @@
# Battery Tracker 0.2.0 user-service adaptation
Official store ID 67, ZIP SHA-256 `91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94`; the exact `main.py` SHA-256 is `2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725`. The BSD-licensed archive requests `_root`, hard-codes Steam Deck `BAT1`, raises on empty history, and drops the final sample in per-app grouping. The backend records local battery readings in plugin runtime SQLite; it does not need privileged writes for the verified path.
The digest-scoped managed install removes `_root`, discovers a readable supply whose `type` is `Battery` (preferring `BAT1` on Deck), fixes empty and single-sample history, retains pending samples while the panel is open, and flushes them on unload. The original ZIP and license remain pinned in the test fixture. Changed archive bytes still fail the root gate; other root plugins remain blocked.
Under a unique owner token on `/tmp/frame-test.lock`, a **read-only** real Frame probe found SteamVR activity `3` (standby), proximity raw `2.25`, and one Battery-type supply named `max1720x_bat_7-36`. It exposed readable `capacity` (89%), `status` (Charging), `voltage_now`, and `current_now`; `power_now` was absent. The owned lock was released. No wake, input, normal service, power setting, or plugin install was touched.
In network/device-isolated, unprivileged **actual Decky 3.3.0 amd64**, the exact adapted archive was discovered. A private Frame-shaped supply fixture produced history and per-app power through public legacy RPC. The complete native regression runner, including existing negative controls, passed. Local managed install/remove tests and the exact-source/manifest guard passed. CI's native ARM64 and fake Frame e2e jobs remain required before merge.
This supports **partial** compatibility. The official frontend initialized in the earlier opaque Chromium audit with backend calls denied; it has not been tested with these live samples. Actual managed recording on Frame ARM64, long-term history, foreground-app attribution, panel controls, and worn VR remain unverified. Existing copies need update or reinstall.
+4 -2
View File
@@ -4,7 +4,7 @@ Snapshot: 2026-10-26T06:10:58.537029+00:00; 116 current visible official plugins
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
**Works: 6 · Partial: 53 · Broken: 46 · N/A: 11.**
**Works: 6 · Partial: 54 · Broken: 45 · N/A: 11.**
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
@@ -31,6 +31,8 @@ A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2
A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.
A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. That initial pass left Simple Timer partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSSLine truncated
@@ -83,7 +85,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 67: Battery Tracker 0.2.0 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 67: Battery Tracker 0.2.0 | legacy / legacy | partial | Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 amd64 recorded Frame-shaped private battery samples and returned history/power data. A locked real Frame read verified the required sysfs fields, not the managed plugin. Live ARM64 recording, long-term battery history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall. | static archive, local browser, fake-Frame, isolated Decky amd64 backend, real Frame read-only sysfs |
| 68: Wine Cellar 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-wine-cellar/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
| 70: MicroSDeck 0.11.2 | legacy / 2 | broken | Plugin contains a non-ARM64 Linux binary: MicroSDeck/bin/backend. | static archive, local browser, fake-Frame |
| 72: ScreenshotUploader 0.0.3 | legacy / legacy | partial | Initializer succeeds; screenshot upload requires external credentials/network and was not run. | static archive, local browser, fake-Frame |
+1
View File
@@ -43,6 +43,7 @@ def render(data):
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.', '',
'A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck\'s advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.', '',
'A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.', '',
'A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.', '',
'The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. '
'Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.', '',
'After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. '
+5
View File
@@ -1,4 +1,9 @@
[
{
"id": 67,
"shim": "battery-tracker-user-supply",
"probe": "battery-tracker"
},
{
"id": 114,
"shim": "network-sentinel-user-psutil",
+22 -1
View File
@@ -119,6 +119,13 @@ def main():
env.update(UNPRIVILEGED_PATH=str(base), PRIVILEGED_PATH=str(base), CHOWN_PLUGIN_PATH='0',
LIVE_RELOAD='0', USE_CEF_CLOSE_WORKAROUND='0', KEEP_SYSTEMD_SERVICE='1',
DBUS_SESSION_BUS_ADDRESS='unix:path=/run/user/1100/bus')
if case['probe'] == 'battery-tracker':
supply = home/'battery-supply'/'max1720x_bat_7-36'
supply.mkdir(parents=True)
for name, value in {'type':'Battery', 'capacity':'89', 'status':'Charging',
'voltage_now':'8746718', 'current_now':'1030625'}.items():
(supply/name).write_text(value+'\n')
env['FRAMEY_BATTERY_ROOT'] = str(supply.parent)
processes = []
report = {'id': record['id'], 'name': record['name'], 'version': record['version'],
'sha256': record['sha256'], 'shim': case['shim'], 'calls': []}
@@ -152,7 +159,21 @@ def main():
report['calls'].append({'method': method, 'result': response})
return response
if case['probe'] == 'network-sentinel':
if case['probe'] == 'battery-tracker':
empty_or_first = legacy('get_recent_data', lookback=2)
assert isinstance(empty_or_first['x'], list) and isinstance(empty_or_first['cap'], list)
assert isinstance(empty_or_first['power_data'], list)
initial = until_ready(lambda: legacy('get_recent_data', lookback=2)['cap'], 'private first battery sample')
assert initial[-1] == .89, initial
assert legacy('set_app', app='Framey private') is True
(supply/'status').write_text('Discharging\n')
sample = until_ready(lambda: next((row for row in legacy('get_recent_data', lookback=2)['power_data']
if row['name']=='Framey private'), None), 'private discharging battery sample')
assert sample['average_power'] > 0, sample
history = legacy('get_recent_data', lookback=2)
assert len(history['x']) == len(history['cap']) >= 2, history
report['result'] = 'discovery, Frame-shaped private battery samples, history and per-app power passed; real managed Frame backend unverified'
elif case['probe'] == 'network-sentinel':
assert current('get_settings')['auto_monitor'] is False
assert current('get_network_history') == []
assert current('get_live_ping') == 0
+5
View File
@@ -225,6 +225,11 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['deckyspy-psutil-and-battery'])
self.assertEqual(ssh(f'test -f {installed}/py_modules/psutil/_psutil_linux.abi3.so && test -f {installed}/py_modules/framey_battery.py && echo yes').strip(), 'yes')
if plugin['id'] == 67:
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['battery-tracker-user-supply'])
self.assertEqual(found[0]['compatibility']['status'], 'partial')
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], [])
if plugin['id'] == 114:
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['network-sentinel-user-psutil'])
Binary file not shown.
+6 -1
View File
@@ -3,7 +3,7 @@
These are unmodified ZIPs downloaded from the official Decky store CDN on
2026-10-25, except `96.zip` and `111.zip` downloaded on 2026-10-26 and `60.zip` on
2026-10-27; `13.zip` and `125.zip` were added from the digest-verified audit cache on 2026-10-27,
and `114.zip` on 2026-10-28.
and `67.zip` and `114.zip` on 2026-10-28.
Each ZIP includes its upstream `LICENSE`. They are test data, not plugins shipped or installed by
Frame Control. The fake Frame tests serve them
from a local fixture so CI does not depend on the store being online.
@@ -14,6 +14,7 @@ from a local fixture so CI does not depend on the store being online.
| `60.zip` | DeckyFileServer | 1.2.0 | `7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0` |
| `125.zip` | Decky Localsend | 0.4.5 | `e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7` |
| `114.zip` | Network Sentinel | 1.0.4 | `d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5` |
| `67.zip` | Battery Tracker | 0.2.0 | `91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94` |
| `96.zip` | Simple Timer | 1.0.0 | `973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312` |
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
@@ -63,6 +64,10 @@ Network Sentinel runs through the same isolated native Decky gate. Its exact
archive gets plugin-local psutil and a user-service manifest; the probe checks
discovery, private settings and one monitoring-loop sample without external
network access. It does not verify real network latency or worn VR.
Battery Tracker's exact archive also has a user-service adaptation. The native
Decky gate checks discovery, empty history, Frame-shaped private battery
samples and per-app power aggregation. The real Frame probe checked only the
readable Battery-type sysfs fields; the plugin itself did not run there.
DeckyFileServer also runs through actual Decky 3.3.0 discovery/API1 RPC with a
native helper replacement. HTTPS listing/download use only public markers in a
private directory; uploads remain disabled. The exact source build, GPL licenses
+2
View File
@@ -4,6 +4,8 @@ from pathlib import Path
ROOT = Path(__file__).parent / 'fixtures/framey-official'
PLUGINS = (
{'id': 67, 'name': 'Battery Tracker', 'version': '0.2.0', 'api_version': 'legacy',
'folder': 'steam-deck-battery-tracker', 'sha256': '91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94'},
{'id': 114, 'name': 'Network Sentinel', 'version': '1.0.4', 'api_version': 1,
'folder': 'network-sentinel', 'sha256': 'd3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5'},
{'id': 13, 'name': 'Pause Games', 'version': '1.0.3', 'api_version': 1,
+82
View File
@@ -1,14 +1,18 @@
"""Official Decky store transport and owned plugin transaction."""
import sandbox # noqa: F401
import hashlib
import asyncio
import importlib.util
import io
import json
import logging
import os
from pathlib import Path
import secrets
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
import warnings
@@ -83,6 +87,15 @@ class ArchiveTests(unittest.TestCase):
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertTrue(frame_decky_store.catalog()[0]['root'])
def test_only_exact_battery_tracker_adaptation_clears_store_root(self):
records = [{'id': 67, 'name': 'Battery Tracker', 'tags': ['_root'],
'versions': [{'name': '0.2.0', 'hash': frame_decky_store.BATTERY_TRACKER_DIGEST}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertFalse(frame_decky_store.catalog()[0]['root'])
records[0]['versions'][0]['hash'] = 'a'*64
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertTrue(frame_decky_store.catalog()[0]['root'])
def test_accepts_hashed_non_root_archive(self):
data, digest = archive()
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
@@ -278,6 +291,75 @@ class StoreTransaction(unittest.TestCase):
frame_decky_store.LOCALSEND_AARCH64)
self.assertEqual(int.from_bytes(helper.read_bytes()[18:20], 'little'), 183)
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
def test_exact_battery_tracker_uses_user_battery_supply_and_preserves_license(self):
from framey_official import PLUGINS, archive as official_archive
record = next(p for p in PLUGINS if p['id'] == 67)
data = official_archive(record)
self.assertEqual(frame_decky_store.archive_info(data, record['sha256'], record['name']), record['folder'])
result = self.apply('install', data, plugin_id=67,
folder=record['folder'], version=record['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent / record['folder']
receipt = json.loads((folder / '.frame-control-store.json').read_text())
self.assertEqual(receipt['compatShims'], ['battery-tracker-user-supply'])
self.assertEqual(json.loads((folder / 'plugin.json').read_text())['flags'], [])
source = (folder / 'main.py').read_text()
self.assertNotIn('/sys/class/power_supply/BAT1/', source)
self.assertIn("root / 'BAT1'", source)
compile(source, str(folder / 'main.py'), 'exec')
with zipfile.ZipFile(io.BytesIO(data)) as original:
self.assertEqual((folder / 'LICENSE').read_bytes(), original.read(record['folder']+'/LICENSE'))
self.assertEqual(self.apply('remove', plugin_id=67, folder=record['folder']).returncode, 0)
self.assertFalse(folder.exists())
changed = io.BytesIO(data)
with zipfile.ZipFile(changed, 'a') as archive:
archive.writestr(record['folder']+'/changed.txt', 'changed')
altered = changed.getvalue()
with self.assertRaisesRegex(ValueError, 'root'):
frame_decky_store.archive_info(altered, hashlib.sha256(altered).hexdigest(), record['name'])
result = self.apply('install', altered, plugin_id=67,
folder=record['folder'], version=record['version'])
self.assertNotEqual(result.returncode, 0)
self.assertFalse(folder.exists())
def test_adapted_battery_tracker_empty_history_and_unload_flush(self):
from framey_official import PLUGINS, archive as official_archive
record = next(p for p in PLUGINS if p['id'] == 67)
result = self.apply('install', official_archive(record), plugin_id=67,
folder=record['folder'], version=record['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent / record['folder']
runtime = Path(self.tmp.name) / 'battery-runtime'
runtime.mkdir()
supply = Path(self.tmp.name) / 'battery-supply' / 'frame-battery'
supply.mkdir(parents=True)
for name, value in {'type': 'Battery', 'capacity': '89', 'status': 'Charging',
'voltage_now': '8746718', 'current_now': '1030625'}.items():
(supply / name).write_text(value + '\n')
decky = types.ModuleType('decky_plugin')
decky.logger = logging.getLogger('battery-tracker-test')
decky.DECKY_PLUGIN_RUNTIME_DIR = str(runtime)
with patch.dict(sys.modules, {'decky_plugin': decky}), patch.dict(os.environ, {'FRAMEY_BATTERY_ROOT': str(supply.parent)}):
spec = importlib.util.spec_from_file_location('framey_battery_tracker_test', folder / 'main.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
async def exercise():
plugin = module.Plugin()
await plugin._main()
self.assertEqual(await plugin.get_recent_data(), {'x': [], 'cap': [], 'power_data': []})
# The first sample is still pending when Decky unloads the plugin.
await asyncio.sleep(0)
self.assertEqual(len(plugin._pending_samples), 1)
self.assertEqual((await plugin.get_recent_data())['cap'], [.89])
await plugin._unload()
self.assertEqual(plugin._pending_samples, [])
asyncio.run(exercise())
import sqlite3
with sqlite3.connect(runtime / 'battery.db') as db:
self.assertEqual(db.execute('select capacity from battery').fetchall(), [(89,)])
def test_exact_network_sentinel_is_user_scoped_and_digest_guarded(self):
from framey_official import PLUGINS, archive as official_archive
import platform
+2 -1
View File
@@ -20,7 +20,8 @@ class RuntimeCoverage(unittest.TestCase):
for case in cases:
self.assertIn(case['id'], official)
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
'toggle-settings', 'fileserver', 'localsend', 'network-sentinel'))
'toggle-settings', 'fileserver', 'localsend', 'network-sentinel',
'battery-tracker'))
def test_new_shim_without_runtime_case_fails_coverage(self):
import frame_decky_store
+1 -1
View File
@@ -84,7 +84,7 @@ class OfficialBundles(unittest.TestCase):
folder = plugins / plugin['folder']
(folder / '.frame-control-store.json').write_text(json.dumps({
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
if plugin['id'] in (60, 114):
if plugin['id'] in (60, 67, 114):
# This renderer fixture extracts the original root ZIP.
# Transaction and Decky tests cover managed adaptation;
# an unadapted root copy must fail here.
+107 -3
View File
@@ -39,6 +39,8 @@ LOCALSEND_MAIN = 'db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8
LOCALSEND_AARCH64 = 'e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
NETWORK_SENTINEL_DIGEST = 'd3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5'
NETWORK_SENTINEL_MAIN = '2baa390cb66dc267e8f04e61ba1edc43af75115ad5e453d308f6bf26ed70b6b9'
BATTERY_TRACKER_DIGEST = '91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94'
BATTERY_TRACKER_MAIN = '2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725'
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
@@ -86,7 +88,8 @@ def catalog():
unique = [item for item in result if counts[item['id']] == 1]
for item in unique:
if ((item['id'] == 60 and item['name'] == 'DeckyFileServer' and item['sha256'] == FILESERVER_DIGEST)
or (item['id'] == 114 and item['name'] == 'Network Sentinel' and item['sha256'] == NETWORK_SENTINEL_DIGEST)):
or (item['id'] == 114 and item['name'] == 'Network Sentinel' and item['sha256'] == NETWORK_SENTINEL_DIGEST)
or (item['id'] == 67 and item['name'] == 'Battery Tracker' and item['sha256'] == BATTERY_TRACKER_DIGEST)):
item['root'] = False # Exact managed adaptation runs as the Frame user.
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
return unique
@@ -112,6 +115,8 @@ def archive_info(data, expected_hash, expected_name):
and folder == 'decky-localsend')
network_sentinel = (expected_hash == NETWORK_SENTINEL_DIGEST and expected_name == 'Network Sentinel'
and folder == 'network-sentinel')
battery_tracker = (expected_hash == BATTERY_TRACKER_DIGEST and expected_name == 'Battery Tracker'
and folder == 'steam-deck-battery-tracker')
seen = {}
for entry in files:
name = entry.filename
@@ -156,13 +161,16 @@ def archive_info(data, expected_hash, expected_name):
flags = manifest.get('flags', [])
if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags):
raise ValueError('Invalid plugin flags')
if ('root' in flags or '_root' in flags) and not (fileserver or network_sentinel):
if ('root' in flags or '_root' in flags) and not (fileserver or network_sentinel or battery_tracker):
raise ValueError('This plugin needs root and is not supported by the Frame user service')
if localsend and hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != LOCALSEND_MAIN:
raise ValueError('LocalSend Python source differs from the pinned archive')
if network_sentinel and (flags != ['debug', '_root'] or manifest.get('api_version') != 1
or hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != NETWORK_SENTINEL_MAIN):
raise ValueError('Network Sentinel manifest or source differs from the pinned archive')
if battery_tracker and (flags != ['_root'] or 'api_version' in manifest
or hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != BATTERY_TRACKER_MAIN):
raise ValueError('Battery Tracker manifest or source differs from the pinned archive')
return folder
@@ -256,12 +264,13 @@ try:
fileserver=(int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer')
localsend=(int(plugin_id)==125 and digest=='e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7' and folder=='decky-localsend')
network_sentinel=(int(plugin_id)==114 and digest=='d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5' and folder=='network-sentinel')
battery_tracker=(int(plugin_id)==67 and digest=='91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94' and folder=='steam-deck-battery-tracker')
manifest=json.loads(z.read(folder+'/plugin.json'))
if not isinstance(manifest,dict): raise RuntimeError('Invalid plugin manifest')
flags=manifest.get('flags',[])
if not isinstance(flags,list) or not all(isinstance(flag,str) for flag in flags):
raise RuntimeError('Invalid plugin flags')
if ('root' in flags or '_root' in flags) and not (fileserver or network_sentinel):
if ('root' in flags or '_root' in flags) and not (fileserver or network_sentinel or battery_tracker):
raise RuntimeError('Root plugins are not supported by the Frame user service')
seen={}
for f in files:
@@ -544,6 +553,101 @@ try:
' async def _migration(self):\n # Managed user service does not move legacy Decky paths.\n return\n')
main.write_text(source,encoding='utf-8')
compat_shims.append('network-sentinel-user-psutil')
if battery_tracker:
manifest_path=unpacked/'plugin.json'
manifest=json.loads(manifest_path.read_text())
main=unpacked/'main.py'
if (manifest.get('name')!='Battery Tracker' or manifest.get('flags')!=['_root']
or 'api_version' in manifest or
hashlib.sha256(main.read_bytes()).hexdigest()!='2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725'):
raise RuntimeError('Battery Tracker manifest or source changed')
manifest['flags']=[]
manifest_path.write_text(json.dumps(manifest),encoding='utf-8')
source=main.read_text(encoding='utf-8')
split_start=source.index('def split_by_app(data):\n')
split_end=source.index('\n\nclass Plugin:',split_start)
source=source[:split_start]+"""def _framey_battery_path():
# A normal user can read Frame's Battery-type supply; BAT1 remains first
# on Steam Deck. The override is for a private, networkless runtime fixture.
root = Path(os.environ.get('FRAMEY_BATTERY_ROOT', '/sys/class/power_supply'))
if not root.is_absolute():
raise ValueError('Battery supply root must be absolute')
for candidate in [root / 'BAT1', *sorted(root.iterdir())]:
try:
if ((candidate / 'type').read_text().strip() == 'Battery' and
all((candidate / field).is_file() for field in
('capacity', 'status', 'voltage_now', 'current_now'))):
return candidate
except OSError:
pass
raise FileNotFoundError('No readable Battery-type power supply')
def split_by_app(data):
if not data:
return []
groups = []
start = 0
app = data[0][4]
for index, row in enumerate(data[1:], 1):
if row[4] != app:
groups.append((start, index, app))
start, app = index, row[4]
groups.append((start, len(data), app))
return groups
"""+source[split_end:]
old=' self.app = "Unknown"\n'
if source.count(old)!=1: raise RuntimeError('Battery Tracker startup source changed')
source=source.replace(old,old+' self._pending_samples = []\n')
unload_start=source.index(' async def _unload(self):\n')
unload_end=source.index(' async def set_app(',unload_start)
source=source[:unload_start]+""" async def _unload(self):
task = getattr(self, '_recorder_task', None)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
pending = getattr(self, '_pending_samples', [])
if pending:
self.cursor.executemany('insert into battery values (?, ?, ?, ?, ?)', pending)
self.con.commit()
pending.clear()
if hasattr(self, 'con'):
self.con.close()
"""+source[unload_end:]
old=' ).fetchall()\n'
if source.count(old)!=2: raise RuntimeError('Battery Tracker query source changed')
query=' data = self.cursor.execute(\n "select * from battery where time > " + str(int(start_time))\n ).fetchall()\n'
if source.count(query)!=1: raise RuntimeError('Battery Tracker history source changed')
source=source.replace(query,query+' data.extend(row for row in self._pending_samples if row[0] > start_time)\n')
recorder_start=source.index(' async def recorder(self):\n')
source=source[:recorder_start]+""" async def recorder(self):
battery = _framey_battery_path()
decky_plugin.logger.info('recorder started')
while True:
try:
volt = int((battery / 'voltage_now').read_text().strip())
curr = int((battery / 'current_now').read_text().strip())
cap = int((battery / 'capacity').read_text().strip())
status = (battery / 'status').read_text().strip()
state = -1 if status == 'Discharging' else 1 if status == 'Charging' else 0
power = int(volt * curr * 10.0**-11)
self._pending_samples.append((int(time.time()), cap, state, power, self.app))
if len(self._pending_samples) > 10:
self.cursor.executemany('insert into battery values (?, ?, ?, ?, ?)', self._pending_samples)
self.con.commit()
self._pending_samples.clear()
except asyncio.CancelledError:
raise
except Exception:
decky_plugin.logger.exception('recorder')
await asyncio.sleep(5)
"""
main.write_text(source,encoding='utf-8')
compat_shims.append('battery-tracker-user-supply')
if deckyspy:
compat=modules/'framey_battery.py'
if compat.exists(): raise RuntimeError('DeckySpy battery module collides with archive')
+13 -9
View File
@@ -5176,13 +5176,18 @@
"https://github.com/safijari/steam-deck-battery-tracker",
"https://github.com/safijari/steam-deck-battery-tracker.git"
],
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service.",
"blockInstall": true,
"status": "partial",
"reason": "Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 amd64 recorded Frame-shaped private battery samples and returned history/power data. A locked real Frame read verified the required sysfs fields, not the managed plugin. Live ARM64 recording, long-term battery history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall.",
"blockInstall": false,
"requiredShims": [
"battery-tracker-user-supply"
],
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"isolated Decky amd64 backend",
"real Frame read-only sysfs"
],
"browser": {
"id": 67,
@@ -5206,8 +5211,8 @@
},
"archiveValidation": {
"id": 67,
"accepted": false,
"reason": "This plugin needs root and is not supported by the Frame user service"
"accepted": true,
"reason": "Exact digest-scoped user-service adaptation"
},
"backend": "Python",
"hardware": "/sys/class/power_supply/BAT1/capacity; /sys/class/power_supply/BAT1/current_now; /sys/class/power_supply/BAT1/status; /sys/class/power_supply/BAT1/voltage_now",
@@ -5217,9 +5222,8 @@
"fakeFrame": {
"id": 67,
"sha256": "91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94",
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
"archiveValidation": "accepted",
"result": "installed-removed"
}
},
{