mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 07:01:42 +01:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
553c98aafd | ||
|
|
9b0df184cb | ||
|
|
a5a554d0c2 | ||
|
|
38b5d84aad | ||
|
|
af2dca8365 | ||
|
|
6a8e0093de | ||
|
|
53107fd91b | ||
|
|
548f5d8496 |
No files matched your search
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Save Steam screenshots under a separate folder for each headset, Steam
|
||||
account and game. Equal screenshot filenames no longer overwrite one another;
|
||||
existing files in the old flat folder are left in place.
|
||||
[Copying guide](docs/recording.md).
|
||||
|
||||
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
|
||||
Highlight exact PnP instances and known state changes after a dropout;
|
||||
incomplete inventories stay unknown. A Forget button clears the comparison.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.25",
|
||||
"version": "0.4.26",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.25",
|
||||
"version": "0.4.26",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.25",
|
||||
"version": "0.4.26",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.25**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
Current for Frame Control **v0.4.26**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.25 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30 on a Linux host; H.264 video and a synthetic 440 Hz output tone reached a saved recording | No | Real game audio, audible monitoring, perceptual A/V sync and awake headset view | 25 Oct, PR #174 head, shipped in v0.4.22: [OBS log](evidence/obs-media-source-real-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | No OBS/Frame VM end-to-end test | App Start/Stop path has automated Windows checks only | 25 Oct, v0.4.23: [Windows PR #179](https://github.com/saphid/frame-control/pull/179), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
|
||||
@@ -17,5 +17,6 @@ Current for Frame Control **v0.4.25**, 25 October 2026. “Real Frame + physical
|
||||
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Screenshot Save on a real Frame, 25 October 2026
|
||||
|
||||
After PR #192 merged, Frame Control's production `list_shots()` and
|
||||
`save_shots()` paths were run from lxso2 against the real Frame. The test held
|
||||
the shared `/tmp/frame-test.lock` with an owner marker and removed only its own
|
||||
lock. A separate locked check earlier in this round found SteamVR at activity
|
||||
level 3 (standby). No new screenshot was created.
|
||||
|
||||
- The Frame listed seven existing Steam screenshots.
|
||||
- One existing screenshot was copied to a temporary folder on the PC. Its
|
||||
local size matched the reported remote size: **294,885 bytes**. The new
|
||||
destination had four components below the temporary root: headset, Steam
|
||||
account, game ID and filename.
|
||||
- Saving the same screenshot a second time returned zero new copies. A fresh
|
||||
list then marked that screenshot as saved.
|
||||
- The temporary PC folder and its private image were removed at the end. The
|
||||
source screenshot on the Frame was only read. Battery was **100% Full**
|
||||
before and after.
|
||||
|
||||
This verifies one real screenshot transfer and the repeat-save behavior. It
|
||||
does **not** reproduce two different real screenshots with the same filename;
|
||||
the account/game/headset collision case is covered by the fake-source test in
|
||||
`tests/test_shot_copy.py`. Windows and macOS physical-PC saves were not tested.
|
||||
Active passthrough in the recording mirror remains unknown because the Frame
|
||||
was in standby.
|
||||
@@ -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.
|
||||
@@ -107,6 +107,18 @@ about once a minute and copies what's new with the same code as **Save** and
|
||||
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
|
||||
`~/Videos/SteamFrame`.
|
||||
|
||||
Each newly saved screenshot lives under
|
||||
`~/Pictures/SteamFrame/headset-<id>/<Steam account>/<game ID>/<filename>`.
|
||||
The headset folder uses a short hash of Frame Control's device identity.
|
||||
Steam can use the same screenshot filename for two games or accounts; these
|
||||
folders keep both originals. Existing screenshots in the old flat
|
||||
`~/Pictures/SteamFrame/<filename>` layout stay where they are. They cannot be
|
||||
matched reliably to one headset and game, so **Copy everything now** can create
|
||||
a new, correctly identified copy. It never overwrites the old file.
|
||||
One real Frame screenshot was copied through this path on 25 October 2026;
|
||||
see the [test record](evidence/screenshot-copy-2026-10-25.md). The collision
|
||||
case was tested with separate fake sources, not two real same-name screenshots.
|
||||
|
||||
- **Only what's new.** It copies what you take after turning it on, so the
|
||||
shots and clips already on the headset don't all arrive at once. **Copy
|
||||
everything now** copies everything that isn't on this computer yet, once.
|
||||
|
||||
@@ -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,92 @@
|
||||
"""Screenshot copies retain the headset, Steam account and game identity."""
|
||||
import sandbox # noqa: F401
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
NAME = "20261025020000_1.jpg"
|
||||
FIRST = f"1/620/{NAME}"
|
||||
SECOND = f"1/730/{NAME}"
|
||||
|
||||
|
||||
class ShotCopyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = Path(tempfile.mkdtemp(prefix="frame-shot-copy-"))
|
||||
self.addCleanup(shutil.rmtree, self.folder, ignore_errors=True)
|
||||
patch = mock.patch.object(server, "SHOTS_DIR", self.folder)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
patch = mock.patch.object(server, "LINK", None)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
self.payloads = {
|
||||
server.shot_path(FIRST): b"first game",
|
||||
server.shot_path(SECOND): b"second game",
|
||||
}
|
||||
|
||||
def scp(self, argv, **_kwargs):
|
||||
target = Path(argv[-1])
|
||||
for source in argv[1:-1]:
|
||||
if not source.startswith("frame-a:") and not source.startswith("frame-b:"):
|
||||
continue
|
||||
remote = source.split(":", 1)[1]
|
||||
(target / Path(remote).name).write_bytes(self.payloads[remote])
|
||||
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||
|
||||
def test_equal_names_survive_across_games_and_headsets(self):
|
||||
# A pre-upgrade flat file has no provable source identity. Leave it alone.
|
||||
legacy = self.folder / NAME
|
||||
legacy.write_bytes(b"older screenshot")
|
||||
with mock.patch.object(server, "FRAME", "frame-a"), \
|
||||
mock.patch.object(server, "ensure_master"), \
|
||||
mock.patch.object(server.frame_host, "run_ssh", side_effect=self.scp) as copy:
|
||||
self.assertEqual(server.save_shots({"ids": [FIRST, SECOND]})["saved"], 2)
|
||||
self.assertEqual(copy.call_count, 2) # scp cannot flatten equal names together
|
||||
first = server.shot_local_path(FIRST)
|
||||
second = server.shot_local_path(SECOND)
|
||||
self.assertEqual(first.read_bytes(), b"first game")
|
||||
self.assertEqual(second.read_bytes(), b"second game")
|
||||
self.assertNotEqual(first, second)
|
||||
self.assertEqual(legacy.read_bytes(), b"older screenshot")
|
||||
listing = "\n".join([
|
||||
f"1/760/remote/620/screenshots/{NAME}\t10\t1792893600",
|
||||
f"1/760/remote/730/screenshots/{NAME}\t11\t1792893601",
|
||||
])
|
||||
with mock.patch.object(server, "ssh", return_value=listing):
|
||||
self.assertTrue(all(s["saved"] for s in server.list_shots()["shots"]))
|
||||
with mock.patch.object(server.frame_host, "reveal_path") as reveal:
|
||||
server.open_thing({"what": "shot", "id": SECOND})
|
||||
reveal.assert_called_once_with(second)
|
||||
|
||||
self.payloads[server.shot_path(FIRST)] = b"another headset"
|
||||
with mock.patch.object(server, "FRAME", "frame-b"), \
|
||||
mock.patch.object(server, "ensure_master"), \
|
||||
mock.patch.object(server.frame_host, "run_ssh", side_effect=self.scp):
|
||||
self.assertEqual(server.save_shots({"ids": [FIRST]})["saved"], 1)
|
||||
other = server.shot_local_path(FIRST)
|
||||
self.assertNotEqual(other, first)
|
||||
self.assertEqual(other.read_bytes(), b"another headset")
|
||||
self.assertEqual(first.read_bytes(), b"first game")
|
||||
|
||||
def test_missing_staged_file_does_not_claim_saved(self):
|
||||
with mock.patch.object(server, "FRAME", "frame-a"), \
|
||||
mock.patch.object(server, "ensure_master"), \
|
||||
mock.patch.object(server.frame_host, "run_ssh",
|
||||
return_value=subprocess.CompletedProcess([], 0, "", "")):
|
||||
with self.assertRaisesRegex(server.Failure, "did not contain every"):
|
||||
server.save_shots({"ids": [FIRST]})
|
||||
self.assertFalse(server.shot_local_path(FIRST).exists())
|
||||
self.assertFalse(list(self.folder.glob(".incoming-*")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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();
|
||||
|
||||
+77
-26
@@ -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
|
||||
@@ -635,6 +637,7 @@ def _headset_view():
|
||||
SHOT_ROOT = ".local/share/Steam/userdata"
|
||||
SHOT_ID = re.compile(r"(\d{1,12})/(\d{1,20})/(\d{14}_\d{1,4}\.(?:jpg|png))")
|
||||
SHOTS_DIR = Path.home() / "Pictures" / "SteamFrame"
|
||||
_shots_copy_lock = threading.Lock()
|
||||
LIST_SHOTS = f"""cd ~/{SHOT_ROOT} 2>/dev/null || exit 0
|
||||
find . -mindepth 6 -maxdepth 6 -path './*/760/remote/*/screenshots/*' -type f \\
|
||||
\\( -name '*.jpg' -o -name '*.png' \\) -printf '%P\\t%s\\t%T@\\n'"""
|
||||
@@ -647,8 +650,19 @@ def shot_path(shot_id, thumb=False):
|
||||
return f"{SHOT_ROOT}/{m[1]}/760/remote/{m[2]}/screenshots/{'thumbnails/' if thumb else ''}{m[3]}"
|
||||
|
||||
|
||||
def shot_local_path(shot_id, device_id=None):
|
||||
"""Keep equal Steam filenames from different headsets/accounts/games separate."""
|
||||
m = SHOT_ID.fullmatch(shot_id) if isinstance(shot_id, str) else None
|
||||
if not m:
|
||||
raise Failure("bad screenshot id", 400)
|
||||
identity = device_id if device_id is not None else agent_target()["id"]
|
||||
headset = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:12]
|
||||
return SHOTS_DIR / f"headset-{headset}" / m[1] / m[2] / m[3]
|
||||
|
||||
|
||||
def list_shots():
|
||||
shots = []
|
||||
device_id = agent_target()["id"]
|
||||
for line in ssh(LIST_SHOTS, timeout=20).splitlines():
|
||||
rel, _, rest = line.partition("\t")
|
||||
parts = rel.split("/") # account/760/remote/appid/screenshots/file
|
||||
@@ -660,7 +674,7 @@ def list_shots():
|
||||
when = float(mtime)
|
||||
except ValueError:
|
||||
continue
|
||||
local = SHOTS_DIR / parts[-1]
|
||||
local = shot_local_path(shot_id, device_id)
|
||||
shots.append({"id": shot_id, "appid": parts[3], "file": parts[-1], "size": int(size), "time": when,
|
||||
"saved": local.exists() and local.stat().st_size == int(size)})
|
||||
shots.sort(key=lambda s: s["time"], reverse=True)
|
||||
@@ -682,31 +696,51 @@ def shot_image(query):
|
||||
|
||||
|
||||
def save_shots(body):
|
||||
"""Copy screenshots to ~/Pictures/SteamFrame, skipping ones already there."""
|
||||
"""Copy screenshots to per-headset/account/game folders without name collisions."""
|
||||
ids = body.get("ids")
|
||||
if not isinstance(ids, list) or not 0 < len(ids) <= 1000:
|
||||
raise Failure("ids must be a list of 1-1000 screenshot ids", 400)
|
||||
paths = [shot_path(i) for i in ids]
|
||||
todo = [p for p in paths if not (SHOTS_DIR / p.rsplit("/", 1)[-1]).exists()]
|
||||
if todo:
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ensure_master()
|
||||
# Copy into a hidden folder and move complete files in, so a cut-off
|
||||
# copy never looks saved. -p keeps the time the shot was taken.
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||
try:
|
||||
device_id = agent_target()["id"]
|
||||
items = [(shot_path(i), shot_local_path(i, device_id)) for i in ids]
|
||||
n = 0
|
||||
with _shots_copy_lock:
|
||||
todo = [(remote, local) for remote, local in items if not local.exists()]
|
||||
if todo:
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ensure_master()
|
||||
# scp flattens source paths into one destination directory. Split equal
|
||||
# basenames into separate transfers before assigning each file its ID path.
|
||||
while todo:
|
||||
names, group, rest = set(), [], []
|
||||
for item in todo:
|
||||
if item[1].name in names:
|
||||
rest.append(item)
|
||||
else:
|
||||
names.add(item[1].name)
|
||||
group.append(item)
|
||||
todo = rest
|
||||
# Stage under the destination filesystem, then move complete files in.
|
||||
# -p keeps Steam's screenshot timestamp.
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||
try:
|
||||
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure("Copying screenshots timed out")
|
||||
if r.returncode != 0:
|
||||
raise Failure(strip_ansi(r.stderr).strip() or f"scp exited {r.returncode}")
|
||||
for f in incoming.iterdir():
|
||||
os.replace(f, SHOTS_DIR / f.name)
|
||||
finally:
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
n, skipped = len(todo), len(ids) - len(todo)
|
||||
try:
|
||||
r = frame_host.run_ssh(["scp", "-p", *SSH[1:],
|
||||
*(f"{FRAME}:{remote}" for remote, _ in group), str(incoming)],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure("Copying screenshots timed out")
|
||||
if r.returncode != 0:
|
||||
raise Failure(strip_ansi(r.stderr).strip() or f"scp exited {r.returncode}")
|
||||
if any(not (incoming / local.name).is_file() for _, local in group):
|
||||
raise Failure("Screenshot copy did not contain every requested file")
|
||||
for _, local in group:
|
||||
local.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not local.exists():
|
||||
os.replace(incoming / local.name, local)
|
||||
n += 1
|
||||
finally:
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
skipped = len(ids) - n
|
||||
msg = f"Saved {n} screenshot{'s' * (n != 1)} to ~/Pictures/SteamFrame"
|
||||
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
|
||||
|
||||
@@ -1936,7 +1970,7 @@ def open_thing(body):
|
||||
frame_host.open_path(frame_clips.VIDEOS_DIR)
|
||||
return {"message": f"Opened {frame_clips.VIDEOS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||
if what == "shot":
|
||||
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
|
||||
saved = shot_local_path(body.get("id"))
|
||||
if not saved.exists():
|
||||
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
||||
frame_host.reveal_path(saved)
|
||||
@@ -3434,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:
|
||||
@@ -3442,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)
|
||||
@@ -3482,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