Compare commits

...
12 changed files with 298 additions and 7 deletions

No files matched your search

+3
View File
@@ -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
+1
View File
@@ -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 |
+69
View File
@@ -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; }
+1 -1
View File
@@ -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'''
+27
View File
@@ -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"):
+67
View File
@@ -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
View File
@@ -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 &amp; 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 &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="#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 &amp; 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 &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>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 &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="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
View File
@@ -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":