mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 21:01:38 +01:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
553c98aafd | ||
|
|
9b0df184cb | ||
|
|
38b5d84aad |
No files matched your search
@@ -58,6 +58,9 @@ node --test site/test/*.test.mjs # website feedback function
|
||||
|
||||
Say what you tested, and whether you tried it on a real Steam Frame.
|
||||
|
||||
To add a local feature without changing the core app, see the
|
||||
[extension guide](docs/extensions.md) and its working example.
|
||||
|
||||
## Blocking
|
||||
|
||||
If you ignore this document twice, or spam the tracker with agent-generated
|
||||
|
||||
@@ -290,6 +290,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [Frame Control extensions](docs/extensions.md) | Local extension API, folder format and example |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Frame Control extensions
|
||||
|
||||
Extensions add a local panel to Frame Control's Tools page. They are plain HTML,
|
||||
CSS and JavaScript. Frame Control discovers them from its per-user data folder;
|
||||
there is no build step, server process, or headset installation. Open an extension
|
||||
to run it. Closing it unloads its page.
|
||||
|
||||
## Install the example
|
||||
|
||||
Copy [`examples/extensions/headset-glance`](../examples/extensions/headset-glance)
|
||||
to the `extensions` folder shown on Tools → Extensions, then select **Refresh**
|
||||
and **Open Headset Glance**. The folder is normally:
|
||||
|
||||
- Linux: `~/.local/share/frame-control/extensions`
|
||||
- macOS: `~/Library/Application Support/Frame Control/extensions`
|
||||
- Windows: `%APPDATA%\Frame Control\extensions`
|
||||
|
||||
To remove it, close the panel and delete only its `headset-glance` folder. The
|
||||
extension is local to this computer; the iPhone app reads the folder on the
|
||||
Frame, where its server runs.
|
||||
|
||||
## Make an extension
|
||||
|
||||
Create `extensions/your-id/manifest.json` and `index.html`. IDs use lowercase
|
||||
letters, digits and hyphens, start with a letter, and are at most 40 characters.
|
||||
The manifest ID must match the folder name:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "your-id",
|
||||
"name": "Your feature",
|
||||
"version": "1.0.0",
|
||||
"description": "What the panel does.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
```
|
||||
|
||||
Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS,
|
||||
JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder.
|
||||
Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel
|
||||
uses an iframe with `sandbox="allow-scripts"`; its code has an opaque origin,
|
||||
cannot call Frame Control's HTTP API directly, and cannot navigate the app's
|
||||
top-level window. Content Security Policy restricts scripts, images, and
|
||||
`fetch` requests, but an extension can still navigate its own iframe to an
|
||||
external site. Treat third-party extensions as untrusted code and review them
|
||||
before installation.
|
||||
|
||||
The version 1 bridge uses `postMessage`. Send a request to `parent` and match
|
||||
the numeric `id` in the reply. The [example JavaScript](../examples/extensions/headset-glance/main.js)
|
||||
shows a complete client:
|
||||
|
||||
```js
|
||||
parent.postMessage({type: 'frame-control:request', id: 1, action: 'status'}, '*');
|
||||
// Reply: {type: 'frame-control:response', id: 1, value: {...}}
|
||||
```
|
||||
|
||||
Supported actions:
|
||||
|
||||
| Action | Value |
|
||||
| --- | --- |
|
||||
| `capabilities` | `{apiVersion: 1, actions: ['capabilities', 'status']}` |
|
||||
| `status` | `{connected, os: {version, build}, battery: {percent}, steamvr}` |
|
||||
|
||||
`status` is the app's latest successful status snapshot. When that reading fails,
|
||||
`connected` is false and its fields are empty. No write actions are currently exposed. Future APIs
|
||||
should use explicit, narrow actions and keep the iframe isolated. A third-party
|
||||
extension is code you choose to run locally: inspect its files before copying
|
||||
them into the folder. A Decky plugin is a separate Steam client plugin and does
|
||||
not use this API.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<title>Headset Glance</title>
|
||||
<main>
|
||||
<h1>Headset Glance</h1>
|
||||
<p id="summary">Reading the headset status…</p>
|
||||
<button id="refresh">Refresh</button>
|
||||
</main>
|
||||
<script src="main.js"></script>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== parent || event.data?.type !== 'frame-control:response') return;
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (resolve) {
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data.value);
|
||||
}
|
||||
});
|
||||
|
||||
function request(action) {
|
||||
return new Promise(resolve => {
|
||||
const id = ++nextId;
|
||||
pending.set(id, resolve);
|
||||
parent.postMessage({type: 'frame-control:request', id, action}, '*');
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const status = await request('status');
|
||||
document.getElementById('summary').textContent = status.connected
|
||||
? `Frame OS ${status.os.version || 'unknown'} · Battery ${status.battery.percent ?? '?'}% · SteamVR ${status.steamvr ? 'running' : 'stopped'}`
|
||||
: 'No headset status is available yet.';
|
||||
}
|
||||
|
||||
document.getElementById('refresh').addEventListener('click', refresh);
|
||||
refresh();
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "headset-glance",
|
||||
"name": "Headset Glance",
|
||||
"version": "1.0.0",
|
||||
"description": "Shows a small read-only summary of the connected Steam Frame.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
body { margin: 0; background: #101820; color: #dcdedf; font: 16px system-ui, sans-serif; }
|
||||
main { padding: 24px; }
|
||||
h1 { font-size: 20px; }
|
||||
button { border: 0; border-radius: 4px; padding: 8px 14px; background: #1a9fff; color: #fff; cursor: pointer; }
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -94,6 +94,33 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn(b"<title>APK Store</title>", payload)
|
||||
|
||||
def test_local_extension_is_listed_and_sandboxed(self):
|
||||
folder = Path(os.environ["FRAME_CONTROL_DATA_DIR"]) / "extensions" / "test-glance"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "manifest.json").write_text(json.dumps({"id": "test-glance", "name": "Test Glance",
|
||||
"version": "1.0", "description": "Test status", "entry": "index.html"}))
|
||||
(folder / "index.html").write_text('<script src="main.js"></script>')
|
||||
(folder / "main.js").write_text('parent.postMessage({type:"frame-control:request",id:1,action:"status"},"*");')
|
||||
try:
|
||||
code, _, body = self.request("GET", "/api/extensions", headers={"X-Frame-UI": "1"})
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn("test-glance", [x["id"] for x in json.loads(body)["extensions"]])
|
||||
code, headers, body = self.request("GET", "/extension/test-glance/index.html")
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn(b"main.js", body)
|
||||
self.assertIn("frame-ancestors 'self'", headers["Content-Security-Policy"])
|
||||
self.assertNotIn("X-Frame-Options", headers)
|
||||
self.assertEqual(self.request("GET", "/extension/test-glance/manifest.json")[0], 404)
|
||||
self.assertEqual(self.request("GET", "/extension/test-glance/../index.html")[0], 404)
|
||||
try:
|
||||
(folder / "escape.js").symlink_to(Path(__file__))
|
||||
except OSError: # Windows may not allow an unprivileged test to create a symlink.
|
||||
pass
|
||||
else:
|
||||
self.assertEqual(self.request("GET", "/extension/test-glance/escape.js")[0], 404)
|
||||
finally:
|
||||
shutil.rmtree(folder)
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Small, local Frame Control extensions. Code runs only in a sandboxed iframe."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import frame_host
|
||||
|
||||
|
||||
ID = re.compile(r"[a-z][a-z0-9-]{0,39}\Z")
|
||||
ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
|
||||
TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8", ".json": "application/json",
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml"}
|
||||
|
||||
|
||||
def root():
|
||||
return frame_host.data_dir("extensions")
|
||||
|
||||
|
||||
def _manifest(folder):
|
||||
if folder.is_symlink() or not ID.fullmatch(folder.name):
|
||||
raise ValueError("invalid extension directory")
|
||||
manifest = folder / "manifest.json"
|
||||
if manifest.is_symlink() or not manifest.is_file() or manifest.stat().st_size > 16_384:
|
||||
raise ValueError("missing or oversized manifest.json")
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or data.get("id") != folder.name:
|
||||
raise ValueError("manifest id must match its folder")
|
||||
for key, limit in (("name", 80), ("version", 40), ("description", 240)):
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise ValueError(f"invalid {key}")
|
||||
entry = data.get("entry", "index.html")
|
||||
if entry != "index.html" or (folder / entry).is_symlink() or not (folder / entry).is_file():
|
||||
raise ValueError("entry must be a regular index.html")
|
||||
return {key: data[key] for key in ("id", "name", "version", "description")}
|
||||
|
||||
|
||||
def list_extensions():
|
||||
base = root()
|
||||
if not base.exists():
|
||||
return {"extensions": [], "errors": [], "folder": str(base)}
|
||||
found, errors = [], []
|
||||
for folder in sorted(base.iterdir()):
|
||||
if folder.name.startswith(".") or not folder.is_dir():
|
||||
continue
|
||||
try:
|
||||
found.append(_manifest(folder))
|
||||
except (ValueError, OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
errors.append({"folder": folder.name, "error": str(exc)})
|
||||
return {"extensions": found, "errors": errors, "folder": str(base)}
|
||||
|
||||
|
||||
def asset(extension_id, name):
|
||||
"""Return bytes and MIME; never follow symlinks or serve outside one valid folder."""
|
||||
if not ID.fullmatch(extension_id) or not ASSET.fullmatch(name) or name.startswith("."):
|
||||
raise FileNotFoundError("invalid extension path")
|
||||
folder = root() / extension_id
|
||||
_manifest(folder)
|
||||
file = folder / name
|
||||
if name == "manifest.json" or file.is_symlink() or not file.is_file():
|
||||
raise FileNotFoundError("extension asset not found")
|
||||
ctype = TYPES.get(file.suffix.lower())
|
||||
if not ctype or file.stat().st_size > 5 * 1024 * 1024:
|
||||
raise FileNotFoundError("unsupported extension asset")
|
||||
return file.read_bytes(), ctype
|
||||
+55
-3
@@ -1436,8 +1436,18 @@
|
||||
|
||||
<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="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><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 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="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><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>Modding & extensions</h2><div class="quick-routes"><a href="#extensions"><span>Extensions</span><small>Add local tools to Frame Control</small></a></div></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="extensions">
|
||||
<div class="shelf-head"><h2>Extensions</h2><span class="spacer"></span><button class="small" id="extensionsRefresh">Refresh</button></div>
|
||||
<p class="sub">Add local tools to Frame Control. An extension runs only when you open it, in an isolated panel with read-only headset status.</p>
|
||||
<div id="extensionsList" class="sub">Loading…</div>
|
||||
<div id="extensionsFolder" class="hint"></div>
|
||||
<div id="extensionPanel" hidden style="margin-top:16px">
|
||||
<div class="shelf-head"><h2 id="extensionTitle"></h2><span class="spacer"></span><button class="small" id="extensionClose">Close</button></div>
|
||||
<iframe id="extensionFrame" title="Frame Control extension" sandbox="allow-scripts" referrerpolicy="no-referrer" style="width:100%;height:440px;border:1px solid rgba(255,255,255,.12);border-radius:4px;background:#101820"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" id="wireless" hidden>
|
||||
<h2>Wireless Adapter doctor</h2>
|
||||
<p class="sub">Read-only snapshot of this Windows PC. Works without a headset connection. Nothing on the Frame is read or changed.</p>
|
||||
@@ -2122,6 +2132,46 @@
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let activeExtension = null;
|
||||
async function loadExtensions() {
|
||||
try {
|
||||
const data = await api('/api/extensions');
|
||||
$('extensionsFolder').textContent = `Put a folder with manifest.json and index.html in ${data.folder}`;
|
||||
$('extensionsList').innerHTML = data.extensions.length
|
||||
? data.extensions.map(x => `<div class="row" style="margin:7px 0"><button class="small" data-extension="${esc(x.id)}">Open ${esc(x.name)}</button><span class="sub">${esc(x.description)} · ${esc(x.version)}</span></div>`).join('')
|
||||
: 'No extensions installed.';
|
||||
if (data.errors.length) $('extensionsList').innerHTML += `<div class="hint">${data.errors.map(x => `${esc(x.folder)}: ${esc(x.error)}`).join('<br>')}</div>`;
|
||||
$('extensionsList').querySelectorAll('[data-extension]').forEach(button => button.onclick = () => {
|
||||
const ext = data.extensions.find(x => x.id === button.dataset.extension);
|
||||
if (!ext) return;
|
||||
activeExtension = ext.id;
|
||||
$('extensionTitle').textContent = ext.name;
|
||||
$('extensionFrame').src = `/extension/${encodeURIComponent(ext.id)}/index.html`;
|
||||
$('extensionPanel').hidden = false;
|
||||
});
|
||||
} catch (error) { $('extensionsList').textContent = error.message; }
|
||||
}
|
||||
$('extensionsRefresh').onclick = loadExtensions;
|
||||
$('extensionClose').onclick = () => {
|
||||
activeExtension = null;
|
||||
$('extensionFrame').removeAttribute('src');
|
||||
$('extensionPanel').hidden = true;
|
||||
};
|
||||
window.addEventListener('message', event => {
|
||||
if (!activeExtension || event.source !== $('extensionFrame').contentWindow) return;
|
||||
const request = event.data;
|
||||
if (!request || request.type !== 'frame-control:request' || !Number.isSafeInteger(request.id)) return;
|
||||
let value;
|
||||
if (request.action === 'capabilities') value = {apiVersion: 1, actions: ['capabilities', 'status']};
|
||||
else if (request.action === 'status') {
|
||||
const snapshot = stateRead.valid ? state : null;
|
||||
value = {connected: !!snapshot,
|
||||
os: {version: snapshot?.os?.version || null, build: snapshot?.os?.build || null},
|
||||
battery: {percent: snapshot?.battery?.percent ?? null}, steamvr: !!snapshot?.services?.steamvr};
|
||||
}
|
||||
else return;
|
||||
event.source.postMessage({type:'frame-control:response', id:request.id, value}, '*');
|
||||
});
|
||||
// Every /api/ request carries this header. The desktop server takes "1"; the
|
||||
// iPhone app's server, on the Frame, takes a fresh key it puts in the page URL.
|
||||
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
|
||||
@@ -6067,6 +6117,7 @@ async function loadSteamPipewire() {
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
|
||||
$('steamPipewireStatus').textContent = e.message;
|
||||
}
|
||||
|
||||
}
|
||||
async function steamPipewireAction(action) {
|
||||
if (steamPipewireBusy) return;
|
||||
@@ -6090,10 +6141,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",
|
||||
steamPipewire:"tools", 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", storage:"tools", wireless:"tools", powerReport:"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", wireless:"tools", powerReport:"tools", backup:"tools", extensions:"tools"};
|
||||
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", 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", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery", extensions:"Extensions"};
|
||||
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.
|
||||
@@ -6200,6 +6251,7 @@ function showPage(e) {
|
||||
if (tool === 'steamPipewire') loadSteamPipewire();
|
||||
if (tool === 'macview') loadMacView();
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'extensions') loadExtensions();
|
||||
if (tool === 'backup') loadBackups();
|
||||
if (tool === 'storage') loadStorage();
|
||||
if (tool === 'powerReport') loadPower();
|
||||
|
||||
+22
-3
@@ -78,6 +78,7 @@ import frame_utilities # noqa: E402
|
||||
import frame_wireless_doctor # noqa: E402
|
||||
import frame_wired_doctor # noqa: E402
|
||||
import frame_compat_db # noqa: E402
|
||||
import frame_extensions # noqa: E402
|
||||
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
@@ -548,6 +549,7 @@ def status(_body):
|
||||
return s
|
||||
|
||||
|
||||
|
||||
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
|
||||
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
|
||||
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
|
||||
@@ -3466,7 +3468,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return False
|
||||
return True
|
||||
|
||||
def send_bytes(self, data, ctype, status=200, headers=()):
|
||||
def send_bytes(self, data, ctype, status=200, headers=(), extension=False):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
for name, value in headers:
|
||||
@@ -3474,8 +3476,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
# Nobody may frame the UI (clickjacking).
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
if extension:
|
||||
self.send_header("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'self'; base-uri 'none'; form-action 'none'")
|
||||
else:
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
try:
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
@@ -3514,6 +3520,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure(str(exc), 409) from exc
|
||||
self.send_bytes(data.encode('utf-8'), mime,
|
||||
headers=[('Content-Disposition', f'attachment; filename="frame-power-{report_id}.{format}"')])
|
||||
elif path == "/api/extensions":
|
||||
self.send_json(frame_extensions.list_extensions())
|
||||
elif path.startswith("/extension/"):
|
||||
parts = path.split("/")
|
||||
if len(parts) != 4:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
else:
|
||||
try:
|
||||
data, ctype = frame_extensions.asset(parts[2], parts[3])
|
||||
except (FileNotFoundError, ValueError, OSError):
|
||||
self.send_json({"error": "extension asset not found"}, 404)
|
||||
else:
|
||||
self.send_bytes(data, ctype, extension=True)
|
||||
elif path == "/api/settings/artwork":
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
|
||||
Reference in new issue
Block a user