mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 05:01:40 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff245953ac | ||
|
|
537754ebce | ||
|
|
e9cbb8fd55 |
No files matched your search
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
|
||||
library/game install-size breakdown, with unknown sizes, partial inventories
|
||||
and permission/missing-drive states. Includes instructions for Steam's own
|
||||
Move UI; Frame Control does not move or delete game files. The read-only parser was
|
||||
checked on a real Frame; the connected UI and game moves were not.
|
||||
@@ -275,3 +275,5 @@ into a draft release, which reaches users once published. See
|
||||
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||
|
||||
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Game storage
|
||||
|
||||
Open **Tools → Game storage** for a read-only snapshot of the headset's user
|
||||
filesystem and Steam libraries. Refresh after an installation, download or move.
|
||||
The page never moves, deletes or modifies content, starts Steam, requests root,
|
||||
or recursively scans game files. It works through the existing SSH connection.
|
||||
|
||||
## What the numbers mean
|
||||
|
||||
- **Total, free and used** come from `statvfs` on the headset user's home and
|
||||
each library path. Free is space available to that user; reserved blocks mean
|
||||
used + free can be less than total. These are filesystem sizes, not advertised
|
||||
drive capacity, and do not include separate system partitions.
|
||||
- Libraries come from `~/.local/share/Steam/steamapps/libraryfolders.vdf` plus
|
||||
the default Steam library. Modern nested and older numbered-path records are
|
||||
accepted; canonical duplicate paths are shown once. No drive is mounted.
|
||||
- Two paths can share one filesystem. Their capacity must **not** be added.
|
||||
An unmounted drive whose mount directory still exists can report the parent
|
||||
filesystem's capacity: the page labels this as the filesystem containing the
|
||||
path, rather than claiming the removable drive is connected.
|
||||
- Game sizes are Steam's `SizeOnDisk` records in `appmanifest_<appid>.acf`, sorted
|
||||
largest first, with unknown sizes last. A missing, negative or invalid size
|
||||
is **Unknown**, while an explicit zero remains zero. Records may be stale,
|
||||
including during downloads or moves; they do not measure allocated blocks.
|
||||
- Games/apps and runtimes/tools have separate subtotals of **known recorded**
|
||||
sizes. Runtime classification uses names (Proton, Steam Linux Runtime,
|
||||
Steamworks Common, FEX and Lepton Development), so it is only a convenience.
|
||||
Additional apps or runtimes may fall in the game/app group.
|
||||
- Saves, Proton prefixes, shaders, workshops, downloads, Android apps,
|
||||
sideloaded games, media and OS files are **unmeasured other usage**. The page
|
||||
does not subtract manifest totals from filesystem usage to invent a size for
|
||||
them. Recorded content is not an exhaustive storage breakdown.
|
||||
|
||||
Permission denied, missing/disconnected paths, unavailable filesystem reads,
|
||||
invalid metadata and partial inventories are reported separately. A failed read
|
||||
is never proof of an empty library. If library discovery fails, other libraries
|
||||
may be missing from the view. Refresh clears the old results immediately; a
|
||||
headset switch clears the snapshot and ignores late replies from the old device.
|
||||
|
||||
## Moving a game safely
|
||||
|
||||
Use Steam's own **Settings → Storage** on the headset (or in desktop Steam):
|
||||
choose the source drive, select the games and choose **Move**, then pick a
|
||||
Steam-supported destination. A second Steam library must already be available.
|
||||
Steam may withhold Move or a destination when it cannot perform the operation.
|
||||
Let Steam finish, follow Steam's errors/recovery prompts, then Refresh here.
|
||||
Do not manually delete files to make room or interrupt an in-progress move.
|
||||
|
||||
[Valve's moving guide](https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move)
|
||||
was read in a browser on 2026-10-24 and documents this UI. This is an instructional
|
||||
handoff: the page does not send a URI or silently open a UI on the headset.
|
||||
The guide's separate Windows instructions for moving the entire Steam client
|
||||
are outside this feature's scope.
|
||||
|
||||
### Why Frame Control does not automate moving
|
||||
|
||||
The repository's Valve devkit tools register and launch developer titles; no
|
||||
supported game-move operation is provided there. The existing Steam CEF bridge
|
||||
uses internal client APIs, which are not a verified supported contract for a
|
||||
move transaction. [Valve's public ISteamApps reference](https://partner.steamgames.com/doc/api/ISteamApps) provides install-directory
|
||||
and installed-depot queries but no documented move transaction with completion,
|
||||
cancellation, rollback and error guarantees. The public support guide describes
|
||||
the user-operated Storage UI, rather than such an automation interface.
|
||||
|
||||
Consequently this feature adds no move endpoint or guessed internal Steam call,
|
||||
manifest rewrite, copy/delete fallback, or custom rollback. Automating a move
|
||||
requires a supported interface and verified behaviour for full disks, disconnects,
|
||||
running games, downloads, cancellation and Steam restart first.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
`tests/test_storage.py` runs the actual probe against temporary Steam metadata
|
||||
and filesystem/error fixtures. It covers multiple libraries, duplicate paths,
|
||||
zero/unknown/invalid sizes, corrupt manifests, unreadable directories and files,
|
||||
missing discovery/drives, reserved capacity and read-only file preservation.
|
||||
A loopback HTTP test executes the real guarded endpoint with SSH mocked.
|
||||
`tests/storage.cjs` runs the production renderer, navigation and refresh handlers
|
||||
with DOM/transport boundaries faked, including escaping, offline errors,
|
||||
out-of-order refreshes and headset switches. It runs through unittest discovery.
|
||||
|
||||
The production storage component was rendered with synthetic data at 390px and
|
||||
900px widths; both fit without horizontal overflow. This checks component
|
||||
layout, not the full connected application or headset browser.
|
||||
|
||||
On 2026-10-24 the uninstalled storage parser was piped read-only over SSH
|
||||
to a real Frame running SteamOS build `20261015.6141207`. It reported
|
||||
710.4 GiB free on the user filesystem, one available Steam library, 29
|
||||
readable app manifests, zero unknown sizes and zero parse/permission issues.
|
||||
No game names or raw metadata were printed in the test report. The
|
||||
connected UI, removable media, Steam's Move UI and an actual game move were
|
||||
not tested. UI and error-state fixtures remain synthetic.
|
||||
@@ -357,3 +357,11 @@ unsafe archives and backup names, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
|
||||
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
|
||||
No backup or restore has been run on a real headset yet.
|
||||
|
||||
## Game storage
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_storage.py -v` exercises the
|
||||
read-only metadata probe, guarded HTTP endpoint and Node renderer/navigation
|
||||
regressions. All headset reads are temporary fixtures or mocked SSH. See
|
||||
[game storage](game-storage.md#verification-and-limits) for coverage and device
|
||||
verification limits. This suite does not move games or contact a headset.
|
||||
@@ -0,0 +1,50 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||
function fixture() {
|
||||
const f=workspaceFixture();
|
||||
const start=html.indexOf('// ---- Read-only game storage ----');
|
||||
f.run(html.slice(start,html.indexOf('// ---- Backup and recovery kit',start)));
|
||||
const size=html.indexOf('function sizeText(');
|
||||
f.run(html.slice(size,html.indexOf('const backupKinds',size)));
|
||||
return f;
|
||||
}
|
||||
const sample={home:{state:'ok',total:1000,free:200,used:700},discovery:'permission-denied',libraries:[{
|
||||
path:'<img onerror=alert(1)>',capacity:{state:'missing'},state:'partial',gameBytes:10,toolBytes:0,
|
||||
recordedBytes:10,unknownSizes:1,issues:[{file:'bad.acf',state:'invalid-metadata'}],
|
||||
apps:[{appid:'1',name:'<script>bad</script>',bytes:null,kind:'game'},{appid:'2',name:'Zero',bytes:0,kind:'game'}]}]};
|
||||
test('navigation reaches storage and refresh runs once per visit',()=>{
|
||||
const f=fixture();let calls=0;f.context.loadStorage=()=>calls++;
|
||||
f.navigate('#storage');assert.equal(f.activePage,'tools');assert.equal(f.el('storage').hidden,false);
|
||||
assert.equal(f.el('storage').parentNode,f.el('toolWorkspace'));assert.equal(calls,1);
|
||||
});
|
||||
test('unknown, shared capacity, partial discovery and untrusted names remain explicit',()=>{
|
||||
const f=fixture();f.context.sample=sample;const out=f.run('renderStorage(sample)');
|
||||
assert.match(out,/Permission denied/);assert.match(out,/Missing or disconnected/);
|
||||
assert.match(out,/Unknown/);assert.match(out,/0 bytes/);assert.match(out,/Partial inventory/);
|
||||
assert.match(out,/must not be added/);assert.match(out,/Other usage is unmeasured/);
|
||||
assert.match(out,/<script>/);assert.doesNotMatch(out,/<script>|<img/);
|
||||
});
|
||||
test('refresh clears old data, ignores earlier requests and shows offline failure',async()=>{
|
||||
const f=fixture(),pending=[];
|
||||
f.context.api=()=>new Promise((resolve,reject)=>pending.push({resolve,reject}));
|
||||
f.el('storageResult').innerHTML='old data';
|
||||
const first=f.run('loadStorage()'),second=f.run('loadStorage()');
|
||||
assert.match(f.el('storageResult').textContent,/Reading/);
|
||||
pending[1].resolve(sample);await second;
|
||||
const current=f.el('storageResult').innerHTML;
|
||||
pending[0].resolve({...sample,libraries:[]});await first;
|
||||
assert.equal(f.el('storageResult').innerHTML,current);
|
||||
const last=f.run('loadStorage()');pending[2].reject(new Error('Offline'));await last;
|
||||
assert.match(f.el('storageResult').textContent,/Storage unavailable: Offline/);
|
||||
});
|
||||
test('a response or error from another headset cannot update the view',async()=>{
|
||||
for(const fail of [false,true]) {
|
||||
const f=fixture();let resolve,reject;
|
||||
f.context.api=()=>new Promise((yes,no)=>{resolve=yes;reject=no;});
|
||||
const request=f.run('loadStorage()');f.context.devGen++;
|
||||
f.el('storageResult').textContent='New headset';
|
||||
fail?reject(new Error('Old headset')):resolve(sample);await request;
|
||||
assert.equal(f.el('storageResult').textContent,'New headset');assert.equal(f.el('storageResult').innerHTML,'');
|
||||
}
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Storage inventory uses only local temporary metadata; no headset connection."""
|
||||
import sandbox # noqa: F401
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_storage as storage
|
||||
import server
|
||||
|
||||
|
||||
class Storage(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
self.steam = self.home / '.local/share/Steam'
|
||||
(self.steam / 'steamapps').mkdir(parents=True)
|
||||
|
||||
def write(self, relative, text):
|
||||
path = self.steam / 'steamapps' / relative
|
||||
path.write_text(text)
|
||||
return path
|
||||
|
||||
def app(self, appid, name, size=None):
|
||||
return self.write(f'appmanifest_{appid}.acf', '"AppState" { "appid" "%s" "name" "%s" %s }' %
|
||||
(appid, name, '' if size is None else '"SizeOnDisk" "%s"' % size))
|
||||
|
||||
def inventory(self):
|
||||
return storage.inventory(self.home, self.steam)
|
||||
|
||||
def test_multilibrary_dedup_sizes_and_read_only(self):
|
||||
extra = self.home / 'other library'
|
||||
(extra / 'steamapps').mkdir(parents=True)
|
||||
self.write('libraryfolders.vdf', '"libraryfolders" { "0" { "path" "%s" } "1" { "path" "%s" } "2" "%s" }' %
|
||||
(self.steam, extra, extra))
|
||||
self.app(1, 'Game', '400')
|
||||
self.app(2, 'Proton 10', '100')
|
||||
self.app(3, 'Unknown')
|
||||
self.app(4, 'Zero', '0')
|
||||
self.app(5, 'Bad', '-10')
|
||||
(extra / 'steamapps/appmanifest_9.acf').write_text('"AppState" { "appid" "9" "name" "Other" "SizeOnDisk" "200" }')
|
||||
before = {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()}
|
||||
result = self.inventory()
|
||||
self.assertEqual(result['discovery'], 'ok')
|
||||
self.assertEqual(len(result['libraries']), 2)
|
||||
lib = result['libraries'][0]
|
||||
self.assertEqual((lib['gameBytes'], lib['toolBytes'], lib['recordedBytes'], lib['unknownSizes']), (400, 100, 500, 2))
|
||||
self.assertEqual([a['bytes'] for a in lib['apps']], [400, 100, 0, None, None])
|
||||
self.assertEqual(result['libraries'][1]['recordedBytes'], 200)
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()})
|
||||
|
||||
def test_partial_and_corrupt_metadata_never_become_empty_success(self):
|
||||
self.write('libraryfolders.vdf', '"libraryfolders" {')
|
||||
self.app(1, 'Good', '12')
|
||||
self.write('appmanifest_2.acf', '"AppState" { "appid" "2"')
|
||||
self.write('appmanifest_3.acf', '"AppState" { "appid" "9" }')
|
||||
r = self.inventory()
|
||||
self.assertEqual(r['discovery'], 'invalid-metadata')
|
||||
lib = r['libraries'][0]
|
||||
self.assertEqual((lib['state'], lib['recordedBytes'], len(lib['issues'])), ('partial', 12, 2))
|
||||
|
||||
def test_permission_denied_discovery_listing_manifest_and_capacity(self):
|
||||
original = storage.read_vdf
|
||||
self.app(1, 'Game', '10')
|
||||
def read(path):
|
||||
if str(path).endswith('appmanifest_1.acf') or str(path).endswith('libraryfolders.vdf'):
|
||||
raise PermissionError('denied')
|
||||
return original(path)
|
||||
with patch.object(storage, 'read_vdf', side_effect=read), patch.object(storage.os, 'statvfs', side_effect=PermissionError(), create=True):
|
||||
r = self.inventory()
|
||||
self.assertEqual(r['discovery'], 'permission-denied')
|
||||
self.assertEqual(r['home']['state'], 'permission-denied')
|
||||
self.assertIsNone(r['home']['free'])
|
||||
self.assertEqual(r['libraries'][0]['issues'][0]['state'], 'permission-denied')
|
||||
with patch.object(storage.os, 'scandir', side_effect=PermissionError()):
|
||||
self.assertEqual(self.inventory()['libraries'][0]['state'], 'permission-denied')
|
||||
|
||||
def test_disconnected_library_and_missing_discovery(self):
|
||||
r = self.inventory()
|
||||
self.assertEqual(r['discovery'], 'missing')
|
||||
missing = self.home / 'disconnected'
|
||||
self.write('libraryfolders.vdf', '"libraryfolders" { "1" { "path" "%s" } }' % missing)
|
||||
lib = self.inventory()['libraries'][1]
|
||||
self.assertEqual(lib['state'], 'missing')
|
||||
self.assertIsNone(lib['capacity']['total'])
|
||||
|
||||
def test_reserved_capacity_and_shared_filesystem(self):
|
||||
stat = SimpleNamespace(f_blocks=100, f_frsize=1024, f_bavail=10, f_bfree=20)
|
||||
with patch.object(storage.os, 'statvfs', return_value=stat, create=True):
|
||||
r = self.inventory()
|
||||
self.assertEqual((r['home']['total'], r['home']['free'], r['home']['used']), (102400, 10240, 81920))
|
||||
self.assertEqual(r['home']['filesystem'], r['libraries'][0]['capacity']['filesystem'])
|
||||
|
||||
def test_keyvalues_comments_escaping_and_limits(self):
|
||||
path = self.write('parse.vdf', '// comment\n"root" { "name" "A \\"quote\\"" "nested" { "size" "42" } }')
|
||||
self.assertEqual(storage.read_vdf(path)['root']['name'], 'A "quote"')
|
||||
path.write_text('"root" { "name" "}" }')
|
||||
self.assertEqual(storage.read_vdf(path)['root']['name'], '}')
|
||||
for bad in ['"a" {', '"a" "1" "a" "2"', '"a" }', 'garbage', '"a" {"b" "c"}}']:
|
||||
path.write_text(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
storage.read_vdf(path)
|
||||
path.write_text('x' * (storage.MAX_TEXT + 1))
|
||||
with self.assertRaises(ValueError):
|
||||
storage.read_vdf(path)
|
||||
|
||||
def test_http_probe_is_guarded_and_read_only(self):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with patch.object(server, 'ssh', return_value=json.dumps(self.inventory())) as ssh:
|
||||
conn = http.client.HTTPConnection(*httpd.server_address)
|
||||
conn.request('GET', '/api/storage')
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 403)
|
||||
response.read()
|
||||
ssh.assert_not_called()
|
||||
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY})
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn('libraries', json.loads(response.read()))
|
||||
self.assertEqual(ssh.call_args.args, ('python3 -',))
|
||||
self.assertEqual(ssh.call_args.kwargs['stdin'], (ROOT / 'ui/frame_storage.py').read_text())
|
||||
with patch.object(server, 'LINK', SimpleNamespace(active_device=lambda: {'id': 'new-frame'})):
|
||||
ssh.reset_mock()
|
||||
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY, 'X-Frame-Device': 'old-frame'})
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 409)
|
||||
response.read()
|
||||
ssh.assert_not_called()
|
||||
conn.close()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
thread.join()
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node required')
|
||||
def test_ui_behavior(self):
|
||||
r = subprocess.run(['node', '--test', str(ROOT / 'tests/storage.cjs')], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
@@ -40,7 +40,9 @@ test('a device switch discards completed install results and the open detail',()
|
||||
const f=connectionFixture(),c=f.context;
|
||||
Object.assign(c,{pad:{state:'off',queue:[]},padReset(){},sourceState:{jobs:new Map([['test:one',{done:true,result:{package:'app.on.a',game_data:true}}]]),detail:{app:{}},detailRequest:0}});
|
||||
f.el('appDetail').open=true;
|
||||
f.el('storageResult').textContent='Old headset storage';
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.match(f.el('storageResult').textContent,/Headset changed/);
|
||||
assert.equal(c.sourceState.jobs.size,0);assert.equal(c.sourceState.detail,null);assert.equal(f.el('appDetail').open,false);
|
||||
});
|
||||
test('only a preview matching current diagnostics consent can be sent or copied',async()=>{
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Read-only headset storage inventory, piped over SSH as python3 -.
|
||||
|
||||
No recursive size scans, Steam IPC, subprocesses or writes. Manifest sizes are
|
||||
Steam's recorded content sizes, not filesystem allocation measurements.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
|
||||
HOME = Path.home()
|
||||
STEAM = HOME / '.local/share/Steam'
|
||||
MAX_TEXT = 2 * 1024 * 1024
|
||||
TOOLS = re.compile(r'^(Steam Linux Runtime|Proton|Steamworks Common|FEX$|Lepton Development$)')
|
||||
|
||||
|
||||
def state(error):
|
||||
if isinstance(error, PermissionError):
|
||||
return 'permission-denied'
|
||||
if isinstance(error, FileNotFoundError):
|
||||
return 'missing'
|
||||
return 'unavailable'
|
||||
|
||||
|
||||
def read_vdf(path):
|
||||
"""Small strict KeyValues reader; reject truncated/malformed input explicitly."""
|
||||
with open(path, encoding='utf-8', errors='strict') as stream:
|
||||
text = stream.read(MAX_TEXT + 1)
|
||||
if len(text) > MAX_TEXT:
|
||||
raise ValueError('metadata too large')
|
||||
tokens = []
|
||||
pos = 0
|
||||
pattern = re.compile(r'\s+|//[^\n]*|"((?:[^"\\]|\\.)*)"|([{}])')
|
||||
while pos < len(text):
|
||||
match = pattern.match(text, pos)
|
||||
if not match:
|
||||
raise ValueError('invalid KeyValues')
|
||||
pos = match.end()
|
||||
if match.group(1) is not None:
|
||||
tokens.append(('text', re.sub(r'\\(["\\])', r'\1', match.group(1))))
|
||||
elif match.group(2):
|
||||
tokens.append(('brace', match.group(2)))
|
||||
index = 0
|
||||
|
||||
def pairs(depth=0):
|
||||
nonlocal index
|
||||
if depth > 20:
|
||||
raise ValueError('metadata too deep')
|
||||
result = {}
|
||||
while index < len(tokens):
|
||||
key_type, key = tokens[index]
|
||||
index += 1
|
||||
if key_type == 'brace' and key == '}':
|
||||
if not depth:
|
||||
raise ValueError('unexpected brace')
|
||||
return result
|
||||
if key_type == 'brace' or index >= len(tokens):
|
||||
raise ValueError('missing value')
|
||||
value_type, value = tokens[index]
|
||||
index += 1
|
||||
if value_type == 'brace' and value == '{':
|
||||
value = pairs(depth + 1)
|
||||
elif value_type == 'brace':
|
||||
raise ValueError('missing value')
|
||||
if key.lower() in result:
|
||||
raise ValueError('duplicate key')
|
||||
result[key.lower()] = value
|
||||
if depth:
|
||||
raise ValueError('unclosed object')
|
||||
return result
|
||||
return pairs()
|
||||
|
||||
|
||||
def capacity(path):
|
||||
try:
|
||||
s = os.statvfs(path)
|
||||
return {'state': 'ok', 'total': s.f_blocks * s.f_frsize,
|
||||
'free': s.f_bavail * s.f_frsize,
|
||||
'used': (s.f_blocks - s.f_bfree) * s.f_frsize,
|
||||
'filesystem': str(os.stat(path).st_dev)}
|
||||
except (OSError, AttributeError) as error:
|
||||
return {'state': state(error), 'total': None, 'free': None, 'used': None,
|
||||
'filesystem': None}
|
||||
|
||||
|
||||
def inventory(home=None, steam=None):
|
||||
home, steam = Path(home or HOME), Path(steam or STEAM)
|
||||
paths = [steam]
|
||||
discovery = 'ok'
|
||||
try:
|
||||
folders = read_vdf(steam / 'steamapps/libraryfolders.vdf').get('libraryfolders')
|
||||
if not isinstance(folders, dict):
|
||||
raise ValueError('missing libraryfolders')
|
||||
for key, value in folders.items():
|
||||
if not key.isdigit():
|
||||
continue
|
||||
path = value.get('path') if isinstance(value, dict) else value
|
||||
if not isinstance(path, str) or not os.path.isabs(path) or '\x00' in path:
|
||||
raise ValueError('invalid library path')
|
||||
paths.append(Path(path))
|
||||
except OSError as error:
|
||||
discovery = state(error)
|
||||
except (ValueError, UnicodeError):
|
||||
discovery = 'invalid-metadata'
|
||||
libraries, seen = [], set()
|
||||
for path in paths:
|
||||
canonical = os.path.realpath(path)
|
||||
if canonical in seen:
|
||||
continue
|
||||
seen.add(canonical)
|
||||
lib = {'path': str(path), 'capacity': capacity(path), 'state': 'ok',
|
||||
'apps': [], 'issues': [], 'recordedBytes': 0, 'unknownSizes': 0,
|
||||
'gameBytes': 0, 'toolBytes': 0}
|
||||
try:
|
||||
with os.scandir(path / 'steamapps') as entries:
|
||||
manifests = sorted(entry.path for entry in entries
|
||||
if re.fullmatch(r'appmanifest_\d+\.acf', entry.name))
|
||||
for manifest in manifests:
|
||||
try:
|
||||
fields = read_vdf(manifest).get('appstate')
|
||||
if not isinstance(fields, dict):
|
||||
raise ValueError('missing AppState')
|
||||
appid = fields.get('appid', '')
|
||||
if not isinstance(appid, str) or not appid.isdigit() or Path(manifest).name != f'appmanifest_{appid}.acf':
|
||||
raise ValueError('invalid appid')
|
||||
name = fields.get('name') or appid
|
||||
if not isinstance(name, str):
|
||||
raise ValueError('invalid name')
|
||||
raw = fields.get('sizeondisk')
|
||||
size = int(raw) if isinstance(raw, str) and raw.isascii() and raw.isdigit() and len(raw) <= 18 else None
|
||||
kind = 'tool' if TOOLS.match(name) else 'game'
|
||||
lib['apps'].append({'appid': appid, 'name': name, 'bytes': size, 'kind': kind})
|
||||
if size is None:
|
||||
lib['unknownSizes'] += 1
|
||||
else:
|
||||
lib['recordedBytes'] += size
|
||||
lib['toolBytes' if kind == 'tool' else 'gameBytes'] += size
|
||||
except (OSError, ValueError, UnicodeError) as error:
|
||||
lib['issues'].append({'file': Path(manifest).name,
|
||||
'state': state(error) if isinstance(error, OSError) else 'invalid-metadata'})
|
||||
lib['apps'].sort(key=lambda app: (app['bytes'] is None, -(app['bytes'] or 0), app['name'].lower()))
|
||||
if lib['issues']:
|
||||
lib['state'] = 'partial'
|
||||
except OSError as error:
|
||||
lib['state'] = state(error)
|
||||
libraries.append(lib)
|
||||
return {'time': time.time(), 'home': capacity(home), 'discovery': discovery, 'libraries': libraries}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(inventory()))
|
||||
+52
-3
@@ -1018,6 +1018,9 @@
|
||||
#appsLinux .row { flex-wrap:wrap!important; }
|
||||
}
|
||||
|
||||
#storage .card { padding:16px; border:1px solid rgba(255,255,255,.1); border-radius:8px; margin-top:16px; }
|
||||
#storage .grow, #storage .sub { overflow-wrap:anywhere; }
|
||||
#storage .item > span { flex-shrink:0; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/control-center.css">
|
||||
</head>
|
||||
@@ -1420,7 +1423,7 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's windows and screens</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's windows and screens</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
@@ -1667,6 +1670,17 @@
|
||||
Saved spatial layouts are not available yet.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="storage" hidden>
|
||||
<div class="shelf-head"><h2>Game storage</h2><span class="spacer"></span><button class="small" id="storageRefresh">Refresh</button></div>
|
||||
<p class="sub">Read-only snapshot. Steam's recorded install sizes may be stale and exclude saves, shaders, downloads, Android and sideloaded content. No files are scanned recursively or changed.</p>
|
||||
<div id="storageResult" role="status" aria-live="polite">Open this tool to read headset storage.</div>
|
||||
<div class="card" style="margin-top:16px"><h3>Move a game with Steam</h3>
|
||||
<p>On the headset, open Steam → Settings → Storage, choose the source drive, select the game, then choose Move and a destination. If Storage is unavailable in the headset UI, use desktop Steam’s Settings → Storage.</p>
|
||||
<p class="sub">Steam offers destinations it supports. Let Steam finish and handle any errors, then Refresh here. Frame Control cannot verify a supported move API or its recovery behaviour, so it never moves or deletes game files.</p>
|
||||
<a href="https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move" target="_blank" rel="noopener">Steam's storage and moving guide ↗</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="backup" hidden>
|
||||
<div class="shelf-head"><h2>Back up the Frame</h2><span class="spacer"></span>
|
||||
<button class="small" id="backupFolder">Open backups folder</button></div>
|
||||
@@ -5425,6 +5439,39 @@ $("panelList").onclick = e => {
|
||||
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
|
||||
};
|
||||
|
||||
// ---- Read-only game storage ----
|
||||
let storageSeq = 0;
|
||||
const storageState = s => ({'permission-denied':'Permission denied', missing:'Missing or disconnected',
|
||||
unavailable:'Unavailable', 'invalid-metadata':'Invalid Steam metadata', partial:'Partial inventory', ok:'Available'}[s] || 'Unknown');
|
||||
const storageBytes = n => typeof n === 'number' && Number.isFinite(n) && n >= 0 ? sizeText(n) : 'Unknown';
|
||||
function storageCapacity(c) {
|
||||
if (!c || c.state !== 'ok') return esc(storageState(c?.state));
|
||||
return `${storageBytes(c.free)} free / ${storageBytes(c.total)} total · ${storageBytes(c.used)} used`;
|
||||
}
|
||||
function renderStorage(r) {
|
||||
return `<div class="card"><h3>Headset user filesystem</h3><p>${storageCapacity(r.home)}</p>
|
||||
<p class="sub">Filesystem space available to your user; excludes separate system partitions. Reserved space can make used + free less than total.</p></div>` +
|
||||
(r.discovery !== 'ok' ? `<p class="sub">Library discovery: ${esc(storageState(r.discovery))}. Additional libraries may be absent from this view.</p>` : '') +
|
||||
r.libraries.map(lib => `<div class="card" style="margin-top:16px"><h3 style="overflow-wrap:anywhere">${esc(lib.path)}</h3>
|
||||
<p>${storageCapacity(lib.capacity)}</p><p class="sub">Filesystem containing this library path; paths on the same filesystem share this space. Capacities must not be added together.</p>
|
||||
<p>${esc(storageState(lib.state))} · Known games/apps: ${storageBytes(lib.gameBytes)} · Known runtimes / tools (by name): ${storageBytes(lib.toolBytes)}</p>
|
||||
<p class="sub">Known recorded content: ${storageBytes(lib.recordedBytes)} · ${lib.unknownSizes} unknown sizes · ${lib.issues.length} unreadable / invalid manifests. Other usage is unmeasured.</p>
|
||||
<div class="list">${lib.apps.map(app => `<div class="item"><div class="grow">${esc(app.name)}<div class="sub">Steam ${esc(app.appid)} · ${app.kind === 'tool' ? 'Runtime / tool' : 'Game / app'}</div></div><span>${storageBytes(app.bytes)}</span></div>`).join('') || '<p class="sub">No readable Steam manifests found; this does not prove the library is empty.</p>'}</div>
|
||||
${lib.issues.map(i => `<p class="sub">${esc(i.file)}: ${esc(storageState(i.state))}</p>`).join('')}</div>`).join('') +
|
||||
`<p class="sub">Snapshot received ${esc(new Date().toLocaleTimeString())}. Refresh after installs, downloads or moves.</p>`;
|
||||
}
|
||||
async function loadStorage() {
|
||||
const seq = ++storageSeq, gen = devGen;
|
||||
$('storageResult').textContent = 'Reading headset storage…';
|
||||
try {
|
||||
const result = await api('/api/storage');
|
||||
if (seq === storageSeq && gen === devGen) $('storageResult').innerHTML = renderStorage(result);
|
||||
} catch (error) {
|
||||
if (seq === storageSeq && gen === devGen) $('storageResult').textContent = `Storage unavailable: ${error.message}`;
|
||||
}
|
||||
}
|
||||
$('storageRefresh').onclick = loadStorage;
|
||||
|
||||
// ---- Backup and recovery kit (ui/frame_backup.py) ----
|
||||
const BACKUP_KIND = {saves: "Game saves", settings: "Settings", android: "Android app data"};
|
||||
function sizeText(n) {
|
||||
@@ -5699,10 +5746,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", backup:"tools"};
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", backup:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", backup:"Backup & recovery"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", backup:"Backup & recovery"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
let page = "apps";
|
||||
// Reuse the existing live lists and form handlers in their new destinations.
|
||||
@@ -5809,6 +5856,7 @@ function showPage(e) {
|
||||
if (tool === 'macview') loadMacView();
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'backup') loadBackups();
|
||||
if (tool === 'storage') loadStorage();
|
||||
if (tool === 'androidSettings') {
|
||||
$('repNew').hidden = false; $('androidSettings').prepend($('repNew')); loadDisplays();
|
||||
}
|
||||
@@ -6787,6 +6835,7 @@ function onConnection(s) {
|
||||
androidApps = []; androidSeq++; androidPending.clear(); $('installedDetail').close(); resetShots(); resetClips();
|
||||
renderHome();
|
||||
titlesSeq++; disp.seq++; runningSeq++; // answers to loads already on their way are ignored
|
||||
$('storageResult').textContent = 'Headset changed. Open Game storage or Refresh to read this headset.';
|
||||
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.
|
||||
|
||||
@@ -3293,6 +3293,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||
elif path == "/api/status":
|
||||
self.send_json(status({}))
|
||||
elif path == "/api/storage":
|
||||
with working(self.headers.get("X-Frame-Device")):
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_storage.py").read_text(), timeout=20)))
|
||||
elif path == "/api/running":
|
||||
self.send_json(running_games())
|
||||
elif path == "/api/steam/owned":
|
||||
|
||||
Reference in new issue
Block a user