Compare commits

...
Author SHA1 Message Date
SAPHID ff245953ac Docs: record read-only storage probe on Frame 2026-10-24 02:49:33 +00:00
SAPHID 537754ebce Add read-only headset game storage inventory 2026-10-24 02:44:45 +00:00
Alex Southwell e9cbb8fd55 Merge pull request #122 from saphid/release/0.4.10
Frame Control 0.4.10
2026-10-24 01:22:05 +00:00
11 changed files with 521 additions and 4 deletions

No files matched your search

+9
View File
@@ -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.
+2
View File
@@ -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.
+91
View File
@@ -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.
+8
View File
@@ -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.
+50
View File
@@ -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,/&lt;script&gt;/);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,'');
}
});
+1 -1
View File
@@ -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'''
+151
View File
@@ -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)
+2
View File
@@ -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()=>{
+152
View File
@@ -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
View File
@@ -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 &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; 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 &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; 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.
+3
View File
@@ -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":