Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 0f770dc88a Tabs, one offline banner, background installs, drop anywhere
The page was one 6,800px scroll with nine nav links (hidden below 1150px).
It is now four tabs, Home, Games, Android and Tools, switched with 1-4; old
section links still land on the right tab.

When the Frame can't be reached, the server turns ssh's connection errors into
one plain message (503, offline: true), the page shows a single banner with
Retry and Set Up Connection, retries every 8 s, and reloads every panel when
the Frame answers. Panels say "Waiting for the Frame" instead of raw ssh text.

Flatpak and Android catalogue installs run as background jobs the page polls,
so a slow install no longer holds a request for up to 15 minutes or reports a
false failure; the bottom bar counts running installs.

Files can be dropped anywhere in the window, as the README already said.
Recent reports show the newest five, with Show all. Android display explains
an empty or failed read. A topped-up headset on a charger reads as not
charging rather than "still draining, using 0.0 W".

Fixes a race where the catalogue and reports loads wrote the compat-db
mirror's .tmp file at once and one failed with a 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 10:23:24 +10:00
14 changed files with 860 additions and 183 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ See what the headset sees, install games and Android apps, move files and text a
<br> <br>
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900"> <img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub> <sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
+1
View File
@@ -243,6 +243,7 @@ function fromUi(e) {
} }
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
// frame-control://install links from websites (docs/web-install.md). They can // frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch), // arrive before the window or server exists (macOS open-url on a cold launch),
+2
View File
@@ -2,12 +2,14 @@
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells // to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded // the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there). // as a title without zipping it (the local server reads it from there).
// It can open Set Up Connection when the headset can't be reached.
// It also receives frame-control://install links (docs/web-install.md): only // It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first. // what the link asked for, never an install; the page asks the user first.
const { contextBridge, ipcRenderer, webUtils } = require("electron"); const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", { contextBridge.exposeInMainWorld("frameApp", {
readClipboard: () => ipcRenderer.invoke("clipboard:read"), readClipboard: () => ipcRenderer.invoke("clipboard:read"),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } }, pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
onInstallLink: (cb) => { onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link"); ipcRenderer.removeAllListeners("install-link");
+9
View File
@@ -17,6 +17,15 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features ## Features
The window has four tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports) and **Tools** (sending files and text,
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
anywhere in the window. When the Frame can't be reached, one banner says why in
plain words and the app retries every few seconds, filling everything in once it
answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
- **Headset view**: what the lenses show, as SteamVR composites it (the room, - **Headset view**: what the lenses show, as SteamVR composites it (the room,
floating panels, dashboard and controllers). Shows the left eye, like pointing floating panels, dashboard and controllers). Shows the left eye, like pointing
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live** a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
-1
View File
@@ -33,7 +33,6 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) | | SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card | | Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn | | `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
| **Testing VR apps without wearing the headset.** In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. `vrcmd` (in `/opt/steamvr/bin/linuxarm64`) settings use `section.key`: `vrcmd --set-settings-bool power.pauseCompositorOnStandby 0` and `vrcmd --set-settings-float power.turnOffScreensTimeout 3600`, then `vrcmd --handlewakeup`, keep the compositor running, and the scene app becomes visible. If it stays `visible-blurred`, the Steam dashboard is open: `SteamClient.OpenVR.VROverlay.HideDashboard()` in Steam's `SharedJSContext` (CDP on 8080) closes it. The headset view then captures with `ui/frame_vrshot.py`. Restore afterwards with `--set-settings-bool power.pauseCompositorOnStandby 1` and `--set-settings-float power.turnOffScreensTimeout 5`. The bool setter reads `true` as false, so use 1/0. A Steam launch that stalls in standby at `ShowInterstitials` or `CreatingProcess` (see `console_log.txt`) continues with `SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>")`. **Verified 2026-09-27.** | Proving VR output remotely, [webxr-chromium.md](webxr-chromium.md) |
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) | | The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks | | SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging | | The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 892 KiB

After

Width:  |  Height:  |  Size: 824 KiB

+69 -64
View File
@@ -3,11 +3,9 @@
Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples), Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples),
press its VR button, and watch in 3D in the headset. press its VR button, and watch in 3D in the headset.
The build and installer now live in their own public repo, A standalone, public version of this build (build script, the SO_PEERCRED
[saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame): patch, and a Frame-side installer that adds "Chromium XR" to the Steam library)
a build script for an x86-64 Linux host, the SO_PEERCRED patch, and a is at [saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame).
Frame-side installer that adds "Chromium XR" to the Steam library. This page
keeps the findings and what was verified on this Frame.
## Why Flathub Chromium can't ## Why Flathub Chromium can't
@@ -47,52 +45,76 @@ gets both.
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`). Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
## Building and installing it ## Building it
Follow the [public repo's README](https://github.com/saphid/chromium-webxr-steam-frame#build). [`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh)
In short: `build/build.sh` on an x64 Linux host (no sudo, about 90 GB of cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
disk) produces `chromium-xr-arm64.tar.xz` (about 145 MB), and sudo: the arm64 sysroot comes from Chromium's own script. It needs about
`frame/install.sh` on the Frame unpacks it to `~/chromium-xr`, installs the 90 GB of disk. It shallow-fetches the CL ref (patchset 44), runs
`chromium-xr` launcher in `~/.local/bin`, and adds the Steam library shortcut `gclient sync --no-history`, installs the sysroot, applies one extra seccomp
through the Steam client's DevTools port, the same way as T3 Code fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and
([apks.md](apks.md)). Launching the shortcut gives Chromium its own panel, packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included).
`valve.steam.desktopgame.<appid>`, like any other app. Progress is logged to `~/chromium-xr/stage`. The build aborts if the disk
holding `~/chromium-xr` drops below 12 GB free.
First build, 2026-09-25, on a 12-thread, 31 GB x64 Linux box: 9 h 33 min for First run, 2026-09-25, on a 12-core, 31 GB x64 Linux box: 9 h 33 min for
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change 94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
takes under a minute, plus about 4 minutes to repack. takes under a minute, plus about 4 minutes to repack.
To debug from the Mac, launch it as a panel with DevTools on the Frame **The extra fix.** The CL's XR seccomp policy refuses `getsockopt`. SteamVR's
(verified 2026-09-27):
`scripts/panel-on-frame.sh -- '~/.local/bin/chromium-xr' --remote-debugging-port=9223 URL`
([panels.md](panels.md)). DevTools has no authentication. It listens on
loopback, but with the userspace Tailscale from [tailscale.md](tailscale.md)
running, loopback ports are reachable from your tailnet. Close the browser
when you're done. Chromium runs one browser per profile, so close the
Steam-launched one first or the flag is ignored.
**The SO_PEERCRED fix.** The XR seccomp policy refuses `getsockopt`. SteamVR's
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`, client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
so the XR process died with a seccomp crash (arm64 syscall 209). The patch so the XR process died with a seccomp crash (arm64 syscall 209). The script
allows that one option. It's needed but not enough: the launcher still turns allows that one option. That's needed but not enough: `launch` still turns
seccomp off (below), so the patch only matters once that's fixed too. seccomp off (below), so the patch only matters once that's fixed too.
**Seccomp is off.** The launcher passes `--disable-seccomp-filter-sandbox`. ## Running it on the Frame
With the XR seccomp policy on, SteamVR's client reads `/proc/self/status`
through Chrome's file broker and gets the broker's pid. SteamVR then binds
the app to the wrong process ("Unable to init path manager:
VRInitError_Init_Internal") and `xrCreateInstance` fails. The broker can't
answer `/proc/self` for another process, so fixing this needs a change in
Chromium's broker client or in the CL. The namespace sandbox stays on, but
seccomp is off for every process, so use this profile for VR sites rather
than everyday browsing.
**Upstream (2026-09-27).** CL 8441736 (the XR sandbox) has merged into [`scripts/chromium-xr.sh`](../scripts/chromium-xr.sh):
Chromium, still refusing `getsockopt`; CL 8132979 is still in review. Valve
and the CLs' author are working on Steam Frame support ```sh
([utzcoz/chromium-webxr-linux#5](https://github.com/utzcoz/chromium-webxr-linux/issues/5)). BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
Both sandbox problems above, with the patch, are reported in scripts/chromium-xr.sh launch [URL] # its own VR panel, --enable-features=OpenXR
[utzcoz/chromium-webxr-linux#7](https://github.com/utzcoz/chromium-webxr-linux/issues/7). scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
```
It runs natively, not as a Flatpak. `launch` opens it as its own panel on
gamescope's X display, the same way as [`panel-on-frame.sh`](../scripts/panel-on-frame.sh) ([panels.md](panels.md)), so
the Plasma desktop doesn't need to be open. It uses its own profile
(`~/.config/chromium-xr`) and DevTools on loopback port 9223, so it doesn't
collide with the Flatpak's 9222. When a page enters VR, Chrome asks
**Allow VR?** in the browser panel; choose *Allow this time* or *Allow while
visiting the site*.
Both ways of starting it run
[`frame/chromium-xr/launch.sh`](../frame/chromium-xr/launch.sh), copied to
`~/Applications/ChromiumXR/launch.sh` on the Frame, which holds Chrome's flags.
It sits outside `~/chromium-xr` so `install` doesn't delete it.
**From the Steam library (verified 2026-09-26).** `steam` adds a non-Steam
shortcut called "Chromium XR" (with the Flathub Chromium icon, if that's
installed) through the Steam client's DevTools port, the same way as T3 Code
([apks.md](apks.md)), without restarting Steam. It saves the app id in
`~/Applications/ChromiumXR/shortcut-appid`, so rerunning it, even after you
rename the shortcut in the library, doesn't add a second one. On this Frame
the shortcut app id is 2240749789. Launching it from the library gives
Chromium its own panel, `valve.steam.desktopgame.2240749789`, like any other
app. A Steam launch doesn't open a DevTools port, so `check` needs `launch`.
Chromium runs one browser per profile: while the Steam-launched one is open,
`launch` opens its URL in that window, without DevTools, then prints
`failed: ... exited` because no new window appeared. Close it first.
**Seccomp is off.** The wrapper passes `--disable-seccomp-filter-sandbox`. With
the XR seccomp policy on, SteamVR's client reads `/proc/self/status` through
Chrome's file broker and gets the broker's pid. SteamVR then binds the app to
the wrong process ("Unable to init path manager: VRInitError_Init_Internal")
and `xrCreateInstance` fails. The broker can't answer `/proc/self` for another
process, so fixing this needs a change in Chromium's broker client or in the
CL. The namespace sandbox stays on, but seccomp is off for every process, so
use this profile for VR sites rather than everyday browsing. DevTools on
port 9223 has no authentication. It listens on loopback, but with the
userspace Tailscale from [tailscale.md](tailscale.md) running, loopback ports
are reachable from your tailnet. Close the browser when you're done.
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10, **Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
this build): this build):
@@ -116,26 +138,9 @@ this build):
[`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo, [`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo,
a stereo 360 video, played in 3D after pressing Enter VR. a stereo 360 video, played in 3D after pressing Enter VR.
- **Launched from the Steam library, verified remotely 2026-09-27** with **Not verified yet:**
nobody wearing the headset (standby workaround in
[how-the-frame-works.md](how-the-frame-works.md)). The installer's Steam
shortcut starts Chromium, and SteamVR takes it as scene app
`steam.app.<shortcut id>`. A minimal WebXR session that clears every frame
to red ran at about 75 frames per second, and the stereo headset capture
showed both eyes solid red. Steam preloads its overlay
(`gameoverlayrenderer.so`), which crashed Chromium's zygote about 30 s after
a Steam launch. The public repo's launcher now removes it from
`LD_PRELOAD`. With the headset outside its playspace, SteamVR shows
passthrough wherever the page leaves transparent pixels.
- **Frame rate and input, measured 2026-09-27** (standby workaround, red - Frame rate and dropped frames during playback (nothing was measured; it
test session): 72 fps with every frame at 13.9–14 ms over 16 s, and SteamVR looked fine).
dropped frames only at startup. The right controller showed up as an - Third-party VR180 players (DeoVR and DL8 web embeds).
`oculus-touch` `tracked-pointer` with an `xr-standard` gamepad and a - Controller and hand input inside a WebXR page.
25-joint hand, with poses on every frame. A real squeeze reached the page
as `squeezestart`/`squeeze`. Haptics aren't exposed (no actuators).
Details are in the public repo's technical notes.
**Not verified yet:** trigger, thumbstick and face buttons, the left
controller, bare-hand tracking, and third-party VR180 players (DeoVR and
DL8 web embeds).
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Frame-side: start the WebXR Chromium build (~/chromium-xr). The Steam
# library shortcut "Chromium XR" runs this, and so does
# `scripts/chromium-xr.sh launch` (which adds a DevTools port). Extra
# arguments go to Chrome, so a URL opens that page.
#
# Lives in ~/Applications/ChromiumXR, outside ~/chromium-xr, so reinstalling
# the build doesn't delete it.
set -euo pipefail
CHROME="$HOME/chromium-xr/chrome"
[[ -x "$CHROME" ]] || { echo "launch.sh: no build at $CHROME (run chromium-xr.sh install)" >&2; exit 1; }
# Without --no-first-run and --password-store=basic, startup can stop at a
# first-run or keyring prompt.
# --disable-seccomp-filter-sandbox: under the XR seccomp policy, SteamVR's
# client reads /proc/self/status through the file broker, gets the broker's
# pid, and SteamVR binds the app to the wrong process, so xrCreateInstance
# fails. The namespace sandbox stays on, but seccomp is off for every
# process, so keep this profile for VR sites.
exec "$CHROME" \
--user-data-dir="$HOME/.config/chromium-xr" \
--enable-features=OpenXR \
--ozone-platform=x11 \
--no-first-run --no-default-browser-check --password-store=basic \
--disable-seccomp-filter-sandbox \
"$@"
+135
View File
@@ -0,0 +1,135 @@
#!/bin/bash
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, so WebXR
# immersive-vr works on the Frame.
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
# Progress: ~/chromium-xr/stage. Output: ~/chromium-xr/chromium-xr-arm64.tar.xz,
# which scripts/chromium-xr.sh install copies to the Frame.
# Re-running resumes: existing checkout and out/XR are reused.
set -euo pipefail
W=~/chromium-xr
cd "$W"
stage(){ echo "$(date -Is) $*" | tee -a "$W/stage"; }
# Returns non-zero below 12 GB free; set -e turns that into an exit at top level.
guard(){ avail=$(df --output=avail -BG "$W" | tail -n 1 | tr -dc 0-9); if [ "$avail" -lt 12 ]; then stage "ABORT: only ${avail}G free for $W"; return 3; fi; }
[ -d depot_tools ] || git clone -q https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$W/depot_tools:$PATH" DEPOT_TOOLS_UPDATE=1 DEPOT_TOOLS_METRICS=0
CL_REF=refs/changes/79/8132979/44
if [ ! -f .gclient ]; then
cat > .gclient <<'G'
solutions = [{ "name": "src", "url": "https://chromium.googlesource.com/chromium/src.git",
"managed": False, "custom_deps": {}, "custom_vars": { "checkout_nacl": False } }]
target_os = ["linux"]
target_cpu = ["arm64"]
G
fi
# Keyed on a real commit, so an interrupted first fetch is retried on re-run.
if ! git -C src rev-parse -q --verify HEAD >/dev/null 2>&1; then
stage "clone src at $CL_REF"
mkdir -p src
[ -d src/.git ] || git -C src init -q
git -C src remote get-url origin >/dev/null 2>&1 || git -C src remote add origin https://chromium.googlesource.com/chromium/src.git
git -C src fetch -q --depth=1 origin "$CL_REF"
git -C src checkout -q FETCH_HEAD
fi
guard
stage "src at $(git -C src log -1 --format='%h %s')"
rev=$(git -C src rev-parse HEAD)
# Sync once per revision: once the patch below is applied, gclient sync
# refuses to run on the modified checkout, so re-runs must skip it.
if [ "$(cat "$W/synced" 2>/dev/null)" != "$rev" ]; then
stage "gclient sync"
gclient sync --nohooks --no-history -D --shallow --revision "src@$rev" -j 8
guard
stage "runhooks"
gclient runhooks
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
echo "$rev" > "$W/synced"
fi
guard
cd src
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
# process (verified on the Frame 2026-09-26). Allow only that option.
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
index 435e13d396..297453f582 100644
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
@@ -11,6 +11,7 @@
#include "sandbox/linux/system_headers/linux_syscalls.h"
#include "sandbox/policy/linux/sandbox_linux.h"
+using sandbox::bpf_dsl::AllOf;
using sandbox::bpf_dsl::Allow;
using sandbox::bpf_dsl::Arg;
using sandbox::bpf_dsl::Error;
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
switch (system_call_number) {
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
- // stay disallowed; add a narrow level/optname restriction if ever needed.
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
#if defined(__NR_getpeername)
case __NR_getpeername:
#endif
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
case __NR_get_robust_list:
#endif
return Allow();
+#if defined(__NR_getsockopt)
+ case __NR_getsockopt: {
+ // SteamVR's IPC client checks who is on the other end of its socket
+ // with SO_PEERCRED. Nothing else is readable.
+ const Arg<int> level(1);
+ const Arg<int> optname(2);
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
+ .Else(Error(EPERM));
+ }
+#endif
#if defined(__NR_kill)
case __NR_kill: {
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
P
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
printf '%s\n' "$PEERCRED_PATCH" | git apply
stage "applied SO_PEERCRED patch"
fi
mkdir -p out/XR
cat > out/XR/args.gn <<'A'
target_os = "linux"
target_cpu = "arm64"
is_debug = false
is_official_build = false
is_component_build = false
dcheck_always_on = false
symbol_level = 0
blink_symbol_level = 0
v8_symbol_level = 0
proprietary_codecs = true
ffmpeg_branding = "Chrome"
use_remoteexec = false
use_siso = true
treat_warnings_as_errors = false
A
stage "gn gen"
gn gen out/XR
gn args out/XR --list=enable_openxr --short | tee -a "$W/stage"
stage "build"
( while sleep 600; do guard || { pkill -u "$(id -u)" -f "siso|ninja"; exit 3; }; done ) &
GUARD=$!
trap 'kill $GUARD 2>/dev/null || true' EXIT
autoninja -C out/XR chrome chrome_sandbox chrome_crashpad_handler
stage "package"
cd out/XR
files=(chrome chrome_sandbox chrome_crashpad_handler *.pak *.bin icudtl.dat locales)
# GPU libraries aren't produced by every config; pack the ones that exist.
for f in libEGL.so libGLESv2.so libvk_swiftshader.so libvulkan.so.1 vk_swiftshader_icd.json; do
[ -e "$f" ] && files+=("$f")
done
tar -cJf "$W/chromium-xr-arm64.tar.xz" "${files[@]}"
stage "DONE $(ls -la $W/chromium-xr-arm64.tar.xz)"
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env zsh
# Mac-side: install and launch the WebXR-enabled Chromium build on the Frame.
#
# Flathub Chromium can't enter immersive WebXR on Linux: upstream only wires
# the OpenXR device on Windows (see docs/webxr-chromium.md). This deploys an
# arm64 build with the Linux OpenXR CLs, made on a Linux host by
# scripts/build-chromium-xr.sh, into ~/chromium-xr on the Frame (not a
# Flatpak, so SteamVR's sockets and the XR sandbox work unmodified).
#
# Usage:
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
# scripts/chromium-xr.sh launch [URL] # opens as its own panel in the headset
# scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
BUILD_HOST=${BUILD_HOST:-}
BUILD_TARBALL=${BUILD_TARBALL:-chromium-xr/chromium-xr-arm64.tar.xz}
DEVTOOLS_PORT=${DEVTOOLS_PORT:-9223}
STEAM_NAME=${STEAM_NAME:-Chromium XR}
here=${0:A:h}
wrapper='~/Applications/ChromiumXR/launch.sh'
# frame/chromium-xr/launch.sh holds Chrome's flags; both launch paths run it.
push_wrapper() {
# Write then rename, so a dropped connection can't leave a torn script.
ssh "$FRAME_ALIAS" 'mkdir -p ~/Applications/ChromiumXR && cd ~/Applications/ChromiumXR && cat > launch.sh.new && chmod +x launch.sh.new && mv launch.sh.new launch.sh' \
< "$here/../frame/chromium-xr/launch.sh"
}
case "${1:-}" in
install)
tarball=${2:-}
if [[ -z "$tarball" ]]; then
[[ -n "$BUILD_HOST" ]] || { print -u2 "Pass a tarball, or set BUILD_HOST to the build machine"; exit 2; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
tarball=$tmp/chromium-xr-arm64.tar.xz
scp -q "$BUILD_HOST:$BUILD_TARBALL" "$tarball"
fi
ssh "$FRAME_ALIAS" 'rm -rf ~/chromium-xr.new && mkdir -p ~/chromium-xr.new'
ssh "$FRAME_ALIAS" 'tar -xJf - -C ~/chromium-xr.new' < "$tarball"
# Check the new build runs before replacing the old one.
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
;;
launch)
# Its own VR panel on gamescope's X display, so the Plasma desktop doesn't
# need to be open. DevTools is only on for this path (for `check`).
push_wrapper
exec "$here/panel-on-frame.sh" --name chromium-xr -- "$wrapper" \
--remote-debugging-port="$DEVTOOLS_PORT" \
"${2:-https://immersive-web.github.io/webxr-samples/}"
;;
steam)
# A non-Steam shortcut, added through the Steam client's DevTools port
# without restarting Steam (see docs/apks.md). Launching it from the
# library gives Chromium its own panel like any game. Safe to rerun: it
# refreshes the wrapper and only adds the shortcut if it's missing.
ssh "$FRAME_ALIAS" 'test -x ~/chromium-xr/chrome' ||
{ print -u2 "No build in ~/chromium-xr on the Frame: run 'chromium-xr.sh install' first"; exit 1; }
push_wrapper
# The app id is kept next to the wrapper, so renaming the shortcut in the
# library doesn't make a rerun add a second one.
shortcuts=$here/../frame/android/steam_shortcuts.py
home=$(ssh "$FRAME_ALIAS" 'printf %s "$HOME"')
saved=$(ssh "$FRAME_ALIAS" 'cat ~/Applications/ChromiumXR/shortcut-appid 2>/dev/null || true')
existing=$(ssh "$FRAME_ALIAS" python3 - list < "$shortcuts" |
python3 -c 'import json,sys
apps = json.load(sys.stdin)
ids = [a["appid"] for a in apps if str(a["appid"]) == sys.argv[2]] or [a["appid"] for a in apps if a["name"] == sys.argv[1]]
print(ids[0] if ids else "")' "$STEAM_NAME" "$saved")
if [[ -n "$existing" ]]; then
print -r -- "The shortcut is already in the Steam library (app id $existing)"
else
icon=''
for dir in /var/lib/flatpak '~/.local/share/flatpak'; do
candidate=$dir/exports/share/icons/hicolor/256x256/apps/org.chromium.Chromium.png
if ssh "$FRAME_ALIAS" "test -f $candidate"; then icon=${candidate/#\~/$home}; break; fi
done
existing=$(ssh "$FRAME_ALIAS" python3 - add ${(q)STEAM_NAME} ${(q)home}/Applications/ChromiumXR/launch.sh ${(q)home} ${(q)icon} < "$shortcuts")
[[ "$existing" == <-> ]] || { print -u2 -r -- "Steam didn't return a shortcut app id: $existing"; exit 1; }
print -r -- "Added $STEAM_NAME to the Steam library (shortcut app id $existing)"
fi
ssh "$FRAME_ALIAS" "printf '%s\n' $existing > ~/Applications/ChromiumXR/shortcut-appid"
;;
check)
# DevTools listens on the Frame's loopback only; evaluate there.
ssh "$FRAME_ALIAS" python3 - "$DEVTOOLS_PORT" <<'EOF'
import json, sys, urllib.request, base64, os, socket, struct
port = int(sys.argv[1])
tabs = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=10))
page = next((t for t in tabs if t["type"] == "page"), None)
if page is None:
sys.exit("no open page: run 'chromium-xr.sh launch' first")
path = page["webSocketDebuggerUrl"].split(f":{port}", 1)[1]
s = socket.create_connection(("127.0.0.1", port), timeout=30)
key = base64.b64encode(os.urandom(16)).decode()
s.sendall(f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n".encode())
s.recv(4096)
msg = json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
"expression": "navigator.xr ? navigator.xr.isSessionSupported('immersive-vr') : 'no navigator.xr'",
"awaitPromise": True}}).encode()
mask = os.urandom(4)
hdr = bytes([0x81]) + (bytes([0x80 | len(msg)]) if len(msg) < 126
else bytes([0x80 | 126]) + struct.pack(">H", len(msg)))
s.sendall(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(msg)))
buf = b""
reply = None
while reply is None:
chunk = s.recv(65536)
if not chunk:
sys.exit("DevTools closed the connection")
buf += chunk
# Consume every complete frame already buffered before reading again.
while len(buf) >= 2:
n = buf[1] & 0x7F
off = 2
if n == 126:
if len(buf) < 4:
break
n, off = struct.unpack(">H", buf[2:4])[0], 4
elif n == 127:
if len(buf) < 10:
break
n, off = struct.unpack(">Q", buf[2:10])[0], 10
if len(buf) < off + n:
break
frame, buf = buf[off:off + n], buf[off + n:]
msg = json.loads(frame)
if msg.get("id") == 1:
reply = msg
break
print("immersive-vr supported:", reply["result"]["result"].get("value"))
EOF
;;
*) sed -n '2,14p' "$0"; exit 2 ;;
esac
+60
View File
@@ -185,11 +185,71 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.request("GET", "/api/webinstall/job?id=x", headers={"X-Frame-UI": "1"})[0], 404) self.assertEqual(self.request("GET", "/api/webinstall/job?id=x", headers={"X-Frame-UI": "1"})[0], 404)
self.assertEqual(self.post("/api/webinstall/cancel", {"job": "x"})[0], 404) self.assertEqual(self.post("/api/webinstall/cancel", {"job": "x"})[0], 404)
def test_unreachable_frame_is_one_clear_offline_error(self):
status, _, payload = self.request("GET", "/api/status", headers={"X-Frame-UI": "1"})
body = json.loads(payload)
self.assertEqual(status, 503, body)
self.assertTrue(body["offline"])
self.assertIn("Can't find the Frame", body["error"])
self.assertIn("frame-control-test.invalid", body["detail"]) # ssh's own words stay available
def test_flatpak_install_runs_as_a_job(self):
status, started = self.post("/api/flatpak", {"id": "org.example.App", "action": "install"})
self.assertEqual(status, 200, started)
for _ in range(200):
status, _, payload = self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"})
job = json.loads(payload)
if job["done"]:
break
time.sleep(0.05)
self.assertEqual(status, 200)
self.assertTrue(job["done"])
self.assertIn("Can't find the Frame", job["error"])
self.assertEqual(self.request("GET", "/api/job?id=nope", headers={"X-Frame-UI": "1"})[0], 404)
def test_android_install_checks_the_package_before_starting(self):
status, body = self.post("/api/android", {"action": "install", "package": "org.example.not.in.catalogue"})
self.assertNotEqual(status, 200, body)
self.assertNotIn("job", body)
def test_unknown_routes(self): def test_unknown_routes(self):
self.assertEqual(self.request("GET", "/nope")[0], 404) self.assertEqual(self.request("GET", "/nope")[0], 404)
self.assertEqual(self.post("/api/nope", {})[0], 404) self.assertEqual(self.post("/api/nope", {})[0], 404)
class UnreachableMessages(unittest.TestCase):
"""Only ssh's own connection failures are reworded; other errors keep their text."""
@classmethod
def setUpClass(cls):
sys.path.insert(0, str(ROOT / "ui"))
import server
cls.server = server
def test_ssh_connection_failures(self):
cases = {
"ssh: Could not resolve hostname frame: nodename nor servname provided": "Can't find",
"ssh: connect to host frame.local port 22: Operation timed out": "isn't answering",
"ssh: connect to host 192.168.1.9 port 22: Host is down": "isn't answering",
"ssh: connect to host 192.168.1.9 port 22: No route to host": "isn't answering",
"ssh: connect to host 192.168.1.9 port 22: Connection refused": "refused",
"steamos@192.168.1.9: Permission denied (publickey,password).": "SSH key",
"Host key verification failed.": "identity changed",
"kex_exchange_identification: read: Connection reset by peer": "dropped",
"Timed out talking to frame": "too long",
}
for raw, words in cases.items():
body, status = self.server.error_body(raw)
self.assertEqual(status, 503, raw)
self.assertIn(words, body["error"], raw)
self.assertTrue(body["offline"])
def test_other_errors_pass_through(self):
for raw in ("bad Flatpak app ID", "error: No remote refs found for 'org.example.App'",
"cp: cannot open 'x': Permission denied", "timed out waiting for Steam"):
self.assertEqual(self.server.error_body(raw), ({"error": raw}, None), raw)
class StatusProbe(unittest.TestCase): class StatusProbe(unittest.TestCase):
# frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs. # frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
@unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only") @unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
+16 -4
View File
@@ -24,6 +24,7 @@ FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'ste
'label', 'source') 'label', 'source')
TTL = 60 # seconds a fetched copy is reused TTL = 60 # seconds a fetched copy is reused
_lock = threading.Lock() _lock = threading.Lock()
_load_lock = threading.Lock() # refreshing the cached reports (load); separate from _lock, which flush takes
_mem = {'at': 0, 'reports': None, 'source': None} _mem = {'at': 0, 'reports': None, 'source': None}
@@ -179,8 +180,22 @@ def _read_mirror():
return [] return []
def _save_mirror(reports):
"""Keep a copy for offline use. Failing to write it mustn't fail the read."""
try:
os.makedirs(STATE, exist_ok=True)
with open(MIRROR + '.tmp', 'w') as f:
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
os.replace(MIRROR + '.tmp', MIRROR)
except OSError:
pass
def load(): def load():
"""All reports: the database (cached for TTL s), else the offline mirror; plus unsent ones.""" """All reports: the database (cached for TTL s), else the offline mirror; plus unsent ones."""
# The page asks for the catalogue and the reports at once: one refresh at a
# time, so they share a fetch and never write the mirror's .tmp together.
with _load_lock:
now = time.time() now = time.time()
if _mem['reports'] is None or now - _mem['at'] > TTL: if _mem['reports'] is None or now - _mem['at'] > TTL:
try: try:
@@ -191,10 +206,7 @@ def load():
except Exception: except Exception:
pass # sending can fail for any reason; reading must still work pass # sending can fail for any reason; reading must still work
reports, source = fetch_all(), 'lakebed' reports, source = fetch_all(), 'lakebed'
os.makedirs(STATE, exist_ok=True) _save_mirror(reports)
with open(MIRROR + '.tmp', 'w') as f:
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
os.replace(MIRROR + '.tmp', MIRROR)
except DBError: except DBError:
reports, source = _read_mirror(), 'mirror' reports, source = _read_mirror(), 'mirror'
_mem.update(at=now, reports=reports, source=source) _mem.update(at=now, reports=reports, source=source)
+298 -107
View File
@@ -60,7 +60,31 @@
main { max-width: 1480px; margin: 0 auto; padding: 22px 28px 30px; display: flex; flex-direction: column; gap: 26px; } main { max-width: 1480px; margin: 0 auto; padding: 22px 28px 30px; display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; } .grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; } .grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } nav { display: none; } } @media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: 0 18px; } nav a { padding: 0 10px; } }
@media (max-width: 820px) { .brand b, #battChip { display: none; } }
/* ---- pages: one per tab; the header nav switches between them ---- */
.page { display: flex; flex-direction: column; gap: 26px; }
.page:not(.on) { display: none; }
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
@media (max-width: 1150px) { nav kbd { display: none; } }
/* ---- offline banner ---- */
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
background: rgba(217,65,38,.12); box-shadow: inset 3px 0 0 var(--bad); }
.banner svg { width: 22px; height: 22px; flex: none; color: #ff8a73; }
.banner .grow { flex: 1; min-width: 0; }
.banner .t { color: var(--bright); font-weight: 500; }
.banner .s { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
/* ---- drop anywhere ---- */
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
.dropzone > div { padding: 42px 60px; border: 2px dashed var(--blue); border-radius: 8px; text-align: center;
background: rgba(26,159,255,.08); color: var(--text); max-width: 520px; }
.dropzone b { display: block; color: var(--bright); font-size: 20px; font-weight: 500; margin-bottom: 6px; }
/* ---- panels and section headers ---- */ /* ---- panels and section headers ---- */
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%); .panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
@@ -203,6 +227,8 @@
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: 42px; display: flex; align-items: center; gap: 14px; .bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: 42px; display: flex; align-items: center; gap: 14px;
padding: 0 28px; background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; } padding: 0 28px; background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
.bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); } .bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); }
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; } .bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
.bottombar .last.e { color: #ff8a73; } .bottombar .last.e { color: #ff8a73; }
.drawer { position: fixed; left: 0; right: 0; bottom: 42px; z-index: 19; max-height: 0; overflow: hidden; .drawer { position: fixed; left: 0; right: 0; bottom: 42px; z-index: 19; max-height: 0; overflow: hidden;
@@ -270,8 +296,8 @@
.game-card .art { width: 100%; aspect-ratio: 231 / 87; border-radius: 2px; object-fit: cover; background: rgba(0,0,0,.25); display: block; } .game-card .art { width: 100%; aspect-ratio: 231 / 87; border-radius: 2px; object-fit: cover; background: rgba(0,0,0,.25); display: block; }
.game-card .price { color: var(--bright); font-size: 13px; } .game-card .price { color: var(--bright); font-size: 13px; }
.game-card .price s { color: var(--dim); margin-right: 6px; } .game-card .price s { color: var(--dim); margin-right: 6px; }
.game-card .row a { color: var(--link); text-decoration: none; font-size: 13px; } .cat-card .row a { color: var(--link); text-decoration: none; font-size: 13px; padding: 0 4px; }
.game-card .row a:hover { color: #fff; } .cat-card .row a:hover { color: #fff; }
.game-card .bar { height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; } .game-card .bar { height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; }
.game-card .bar > i { display: block; height: 100%; background: var(--action); } .game-card .bar > i { display: block; height: 100%; background: var(--action); }
.f-0 { color: var(--muted); } .f-0 { color: var(--muted); }
@@ -280,6 +306,7 @@
padding: 8px 10px; font: inherit; font-size: 13px; } padding: 8px 10px; font: inherit; font-size: 13px; }
.disp select:focus { outline: none; border-color: var(--blue); } .disp select:focus { outline: none; border-color: var(--blue); }
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; } .disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
.disp select:disabled { opacity: .6; }
.disp .stat .v { font-size: 15px; } .disp .stat .v { font-size: 15px; }
.disp .k2 { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin: 12px 0 5px; } .disp .k2 { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin: 12px 0 5px; }
.disp .seg { flex-wrap: wrap; } .disp .seg { flex-wrap: wrap; }
@@ -290,7 +317,7 @@
</head> </head>
<body> <body>
<header> <header>
<a class="brand" href="#view" aria-label="Frame Control"> <a class="brand" href="#home" aria-label="Frame Control">
<svg viewBox="0 0 32 32" aria-hidden="true"> <svg viewBox="0 0 32 32" aria-hidden="true">
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1a9fff"/><stop offset="1" stop-color="#1a44c2"/></linearGradient></defs> <defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1a9fff"/><stop offset="1" stop-color="#1a44c2"/></linearGradient></defs>
<circle cx="16" cy="16" r="15" fill="url(#lg)"/> <circle cx="16" cy="16" r="15" fill="url(#lg)"/>
@@ -300,18 +327,13 @@
<b>FRAME CONTROL</b> <b>FRAME CONTROL</b>
</a> </a>
<nav id="nav"> <nav id="nav">
<a href="#view" class="on">View</a> <a href="#home" class="on" title="Headset view, status and screenshots (1)">Home<kbd>1</kbd></a>
<a href="#shots">Shots</a> <a href="#games" title="Steam games and sideloaded titles (2)">Games<kbd>2</kbd></a>
<a href="#library">Library</a> <a href="#android" title="Android apps and their display (3)">Android<kbd>3</kbd></a>
<a href="#getgames">Games</a> <a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)">Tools<kbd>4</kbd></a>
<a href="#android">Android</a>
<a href="#transfer">Transfer</a>
<a href="#apps">Apps</a>
<a href="#display">Display</a>
<a href="#power">Power</a>
</nav> </nav>
<div class="spacer"></div> <div class="spacer"></div>
<span class="chip" id="conn"><span class="dot"></span><span>Connecting…</span></span> <span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
<span class="chip" id="battChip" title="Battery">—</span> <span class="chip" id="battChip" title="Battery">—</span>
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh"> <button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
@@ -319,6 +341,15 @@
</header> </header>
<main> <main>
<div class="banner" id="offline" role="alert" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
<div class="s" id="offDetail">Retrying every few seconds. Everything fills in when it answers.</div></div>
<button class="small" id="offSetup" hidden>Set Up Connection…</button>
<button class="action small" id="offRetry">Retry now</button>
</div>
<div class="page on" data-page="home">
<div class="grid-top" id="view"> <div class="grid-top" id="view">
<section class="panel"> <section class="panel">
<div class="toolbar"> <div class="toolbar">
@@ -401,12 +432,21 @@
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div> <div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div> <div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
</section> </section>
</div>
<div class="page" data-page="games">
<section id="library"> <section id="library">
<div class="shelf-head"><h2>Library</h2><span class="count" id="gameCount"></span></div> <div class="shelf-head"><h2>Installed</h2><span class="count" id="gameCount"></span></div>
<div class="shelf" id="games"><div class="sub">Loading…</div></div> <div class="shelf" id="games"><div class="sub">Loading…</div></div>
</section> </section>
<section class="panel" id="sideloaded">
<div class="shelf-head"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span><span class="spacer"></span>
<button class="small" id="titleAdd" title="Pick a game's .zip or .exe to add to the Steam library">+ Add a game…</button></div>
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
<div class="hint">Your own Linux or Windows builds, added to the Steam library as Devkit Games. Drop a game's
<code>.zip</code>, folder or <code>.exe</code> anywhere in this window.</div>
</section>
<section id="getgames"> <section id="getgames">
<div class="shelf-head"><h2>Get games</h2><span class="count" id="gmCount"></span><span class="spacer"></span> <div class="shelf-head"><h2>Get games</h2><span class="count" id="gmCount"></span><span class="spacer"></span>
@@ -429,7 +469,9 @@
compatibility categories.</div> compatibility categories.</div>
</div> </div>
</section> </section>
</div>
<div class="page" data-page="android">
<section id="android"> <section id="android">
<div class="shelf-head"><h2>Android apps</h2><span class="count" id="andCount"></span><span class="spacer"></span> <div class="shelf-head"><h2>Android apps</h2><span class="count" id="andCount"></span><span class="spacer"></span>
<button class="action small" id="repNew" title="Report how any APK worked on the Frame">+ Report an APK</button></div> <button class="action small" id="repNew" title="Report how any APK worked on the Frame">+ Report an APK</button></div>
@@ -442,62 +484,13 @@
library under its own name and opens in its own panel. Test launches it and records library under its own name and opens in its own panel. Test launches it and records
whether it stays up; Report records how it worked for you.</div> whether it stays up; Report records how it worked for you.</div>
</div> </div>
<div class="panel">
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
<div class="list" id="repList"><div class="sub">Loading…</div></div>
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
reported, including ones not on F-Droid.</div>
</div>
</div>
<div class="panel">
<div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
<div class="cat-tools">
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
</div>
<div class="vchips" id="catChips"></div>
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div>
</div>
</div>
</section>
<div class="grid-3">
<section class="panel" id="transfer">
<div class="shelf-head"><h2>Send to Frame</h2></div>
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
<b>Drop files here</b>
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
<input type="file" id="fileInput" multiple hidden>
</div>
<div class="progress" id="prog"><i></i></div>
<div class="shelf-head" style="margin-top:16px"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span></div>
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
<div class="row" style="margin-top:8px">
<button class="action small" id="clipSend">Send text</button>
<button class="small" id="clipMac">Send this computer's clipboard</button>
</div>
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
</section>
<section class="panel" id="apps">
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
<div class="row" style="margin-top:14px; flex-wrap:nowrap">
<input type="text" id="fpId" placeholder="Flathub ID, e.g. org.mozilla.firefox">
<button class="action" id="fpInstall">Install</button>
</div>
<div class="row" style="margin-top:8px" id="fpQuick"></div>
</section>
<section class="panel disp" id="display"> <section class="panel disp" id="display">
<div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span> <div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span>
<button class="small" id="dispReload" title="Read the current values again">Refresh</button></div> <button class="small" id="dispReload" title="Read the current values again">Refresh</button></div>
<select id="dispSel" aria-label="Lepton instance"><option>Loading…</option></select> <select id="dispSel" aria-label="Lepton instance"><option>Loading…</option></select>
<div class="hint" id="dispEmpty" hidden>Open an Android app in the headset, then Refresh, to change its
resolution, UI scale and text size here.</div>
<div id="dispCtl">
<div class="stats"> <div class="stats">
<div class="stat"><div class="k">Resolution</div><div class="v" id="dispSize">—</div><div class="d" id="dispSizeD">&nbsp;</div></div> <div class="stat"><div class="k">Resolution</div><div class="v" id="dispSize">—</div><div class="d" id="dispSizeD">&nbsp;</div></div>
<div class="stat"><div class="k">UI scale</div><div class="v" id="dispDens">—</div><div class="d" id="dispDensD">&nbsp;</div></div> <div class="stat"><div class="k">UI scale</div><div class="v" id="dispDens">—</div><div class="d" id="dispDensD">&nbsp;</div></div>
@@ -523,7 +516,63 @@
<div class="row" style="margin-top:14px"><button class="small danger" id="dispReset" title="wm size reset, wm density reset, and clear font_scale">Reset all</button></div> <div class="row" style="margin-top:14px"><button class="small danger" id="dispReset" title="wm size reset, wm density reset, and clear font_scale">Reset all</button></div>
<div class="hint">Changes apply at once, and the app may redraw or restart its screen. They may not survive the app <div class="hint">Changes apply at once, and the app may redraw or restart its screen. They may not survive the app
relaunching (not yet verified). Sharp gives Android more pixels in the same panel, which should make text crisper.</div> relaunching (not yet verified). Sharp gives Android more pixels in the same panel, which should make text crisper.</div>
</div>
</section> </section>
<div class="panel">
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
<div class="list" id="repList"><div class="sub">Loading…</div></div>
<div class="row" style="margin-top:10px"><button class="small" id="repMore" hidden>Show all</button></div>
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
reported, including ones not on F-Droid.</div>
</div>
</div>
<div class="panel">
<div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
<div class="cat-tools">
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
</div>
<div class="vchips" id="catChips"></div>
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div>
</div>
</div>
</section>
</div>
<div class="page" data-page="tools">
<div class="grid-3">
<section class="panel" id="transfer">
<div class="shelf-head"><h2>Send to Frame</h2></div>
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
<b>Drop files here, or click to choose</b>
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
You can also drop files anywhere in this window.
<input type="file" id="fileInput" multiple hidden>
</div>
<div class="progress" id="prog"><i></i></div>
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
<div class="row" style="margin-top:8px">
<button class="action small" id="clipSend">Send text</button>
<button class="small" id="clipMac">Send this computer's clipboard</button>
</div>
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
</section>
<section class="panel" id="apps">
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
<div class="row" style="margin-top:14px; flex-wrap:nowrap">
<input type="text" id="fpId" placeholder="Flathub ID, e.g. org.mozilla.firefox">
<button class="action" id="fpInstall">Install</button>
</div>
<div class="row" style="margin-top:8px" id="fpQuick"></div>
</section>
<section class="panel" id="power"> <section class="panel" id="power">
<div class="shelf-head"><h2>Remote &amp; power</h2></div> <div class="shelf-head"><h2>Remote &amp; power</h2></div>
@@ -544,11 +593,16 @@
</div> </div>
</section> </section>
</div> </div>
</div>
</main> </main>
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
<div class="drawer" id="drawer"><div class="log" id="log"></div></div> <div class="drawer" id="drawer"><div class="log" id="log"></div></div>
<div class="bottombar" id="bottombar" role="button" aria-label="Show activity"> <div class="bottombar" id="bottombar" role="button" aria-label="Show activity">
<span class="label">ACTIVITY</span> <span class="label">ACTIVITY</span>
<span class="jobs" id="jobs" hidden><span class="spinner"></span><span id="jobsText"></span></span>
<span class="last" id="lastLog">Ready</span> <span class="last" id="lastLog">Ready</span>
<span class="sub" id="drawerHint">Show ▴</span> <span class="sub" id="drawerHint">Show ▴</span>
</div> </div>
@@ -665,11 +719,23 @@ function applyHostWording(h) {
async function api(path, body) { async function api(path, body) {
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : { const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) }; method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
const r = await fetch(path, opts); let r;
try { r = await fetch(path, opts); }
catch { throw new Error("Frame Control's local server isn't running. Restart the app (Frame → Restart Server)."); }
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` })); const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); if (!r.ok) {
const err = new Error(data.error || `HTTP ${r.status}`);
err.offline = !!data.offline;
if (err.offline) setOnline(false, err.message);
throw err;
}
return data; return data;
} }
// What a panel shows when its data couldn't be read: a quiet placeholder while
// the Frame is unreachable (the banner explains), the real error otherwise.
function failed(el, e) {
el.innerHTML = e.offline ? `<div class="wait">Waiting for the Frame</div>` : `<div class="sub">${esc(e.message)}</div>`;
}
async function act(label, fn, btn) { async function act(label, fn, btn) {
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
log(label + "…"); log(label + "…");
@@ -687,17 +753,54 @@ function setConn(ok, text) {
$("conn").innerHTML = `<span class="dot ${ok ? "on" : "off"}"></span><span>${esc(text)}</span>`; $("conn").innerHTML = `<span class="dot ${ok ? "on" : "off"}"></span><span>${esc(text)}</span>`;
} }
async function refresh() { // Whether the Frame answers. Any request that couldn't reach it flips this off;
$("refreshAll").disabled = true; // the next successful status check flips it back and reloads what failed.
let online = null;
function setOnline(ok, why) {
const was = online;
online = ok;
$("offline").hidden = ok;
if (!ok) {
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
$("battChip").hidden = true;
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
} else if (was === false) {
log("Connected to the Frame again", "ok");
reloadAll();
}
}
function reloadAll() { loadTitles(); loadOwned(); loadAndroid(); loadDisplays(); loadShots(); }
$("offRetry").onclick = () => refresh();
if (window.frameApp && window.frameApp.setUpConnection) {
$("offSetup").hidden = false;
$("offSetup").onclick = () => window.frameApp.setUpConnection();
}
$("conn").onclick = () => { if (online === false) refresh(); };
let refreshing = null, pollTimer = null;
function schedule() { // every 30 s while connected, every 8 s while not
clearTimeout(pollTimer);
pollTimer = setTimeout(() => { if (document.hidden) schedule(); else refresh(); }, online === false ? 8000 : 30000);
}
function refresh() {
if (refreshing) return refreshing;
$("refreshAll").disabled = true; $("offRetry").disabled = true;
refreshing = (async () => {
try { try {
state = await api("/api/status"); state = await api("/api/status");
render(state); render(state);
setConn(true, state.hostname); setConn(true, state.hostname);
setOnline(true);
} catch (e) { } catch (e) {
setConn(false, "Offline"); if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
$("battChip").textContent = "—"; if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
log("Status failed: " + e.message, "e"); } finally {
} finally { $("refreshAll").disabled = false; } $("refreshAll").disabled = false; $("offRetry").disabled = false;
refreshing = null; schedule();
}
})();
return refreshing;
} }
// ---- battery ---- // ---- battery ----
@@ -706,7 +809,9 @@ function battery(b, power) {
const full = b?.status === "Full" || (!!power && pct >= 100); const full = b?.status === "Full" || (!!power && pct >= 100);
const charging = !full && b?.status === "Charging"; const charging = !full && b?.status === "Charging";
// A weak charger can lose to the load: plugged in but still draining. // A weak charger can lose to the load: plugged in but still draining.
const draining = !full && !charging && (!power || b?.status === "Discharging" || b?.watts < 0); // Topped up on a charger it reports Discharging at ~0 W: that's idle, not draining.
const idle = !!power && b?.watts != null && Math.abs(b.watts) < 0.5;
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
const low = !charging && !full && pct != null && pct <= 15; const low = !charging && !full && pct != null && pct <= 15;
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf"; const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
$("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1)); $("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1));
@@ -738,6 +843,7 @@ function battery(b, power) {
$("battChip").innerHTML = pct == null ? "—" : $("battChip").innerHTML = pct == null ? "—" :
`<svg width="22" height="12" viewBox="0 0 22 12" aria-hidden="true"><rect x=".75" y=".75" width="18" height="10.5" rx="2" fill="none" stroke="#8f98a0" stroke-width="1.5"/><rect x="19.8" y="3.5" width="1.8" height="5" rx=".6" fill="#8f98a0"/><rect x="2.5" y="2.5" width="${(14.5 * chipPct / 100).toFixed(1)}" height="7" rx="1" fill="${color}"/></svg>${bolt}<span>${pct}%</span>`; `<svg width="22" height="12" viewBox="0 0 22 12" aria-hidden="true"><rect x=".75" y=".75" width="18" height="10.5" rx="2" fill="none" stroke="#8f98a0" stroke-width="1.5"/><rect x="19.8" y="3.5" width="1.8" height="5" rx=".6" fill="#8f98a0"/><rect x="2.5" y="2.5" width="${(14.5 * chipPct / 100).toFixed(1)}" height="7" rx="1" fill="${color}"/></svg>${bolt}<span>${pct}%</span>`;
$("battChip").title = label; $("battChip").title = label;
$("battChip").hidden = pct == null;
} }
function render(s) { function render(s) {
@@ -795,7 +901,8 @@ function render(s) {
</div>`).join("") : `<div class="sub">No Flatpak apps.</div>`; </div>`).join("") : `<div class="sub">No Flatpak apps.</div>`;
const have = new Set(s.flatpaks.map(f => f.id)); const have = new Set(s.flatpaks.map(f => f.id));
$("fpQuick").innerHTML = QUICK.filter(([, id]) => !have.has(id)) $("fpQuick").innerHTML = QUICK.filter(([, id]) => !have.has(id))
.map(([n, id]) => `<button class="small" data-quick="${esc(id)}">+ ${esc(n)}</button>`).join(""); .map(([n, id]) => installing.has(id) ? `<button class="small" disabled>Installing ${esc(n)}…</button>`
: `<button class="small" data-quick="${esc(id)}">+ ${esc(n)}</button>`).join("");
} }
// ---- view ---- // ---- view ----
@@ -855,7 +962,7 @@ $("canvas").addEventListener("pointerdown", e => {
$("canvas").addEventListener("pointerup", up, { once: true }); $("canvas").addEventListener("pointerup", up, { once: true });
}); });
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if ($("canvas").hidden || /INPUT|TEXTAREA/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return; if (page !== "home" || $("canvas").hidden || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return;
if (e.key === "+" || e.key === "=") setZoom(zoom * 1.5); if (e.key === "+" || e.key === "=") setZoom(zoom * 1.5);
else if (e.key === "-") setZoom(zoom / 1.5); else if (e.key === "-") setZoom(zoom / 1.5);
else if (e.key === "0") setZoom(1); else if (e.key === "0") setZoom(1);
@@ -1073,17 +1180,48 @@ document.body.addEventListener("click", async (e) => {
else if (b.dataset.uninstall) { else if (b.dataset.uninstall) {
if (confirm(`Remove ${b.dataset.name} from the Frame?`)) if (confirm(`Remove ${b.dataset.name} from the Frame?`))
act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh); act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh);
} else if (b.dataset.quick) installFlatpak(b.dataset.quick, b); } else if (b.dataset.quick) installFlatpak(b.dataset.quick);
else if (b.dataset.open) { else if (b.dataset.open) {
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return; if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
act(b.textContent.trim(), () => api("/api/open", { what: b.dataset.open }), b); act(b.textContent.trim(), () => api("/api/open", { what: b.dataset.open }), b);
} }
}); });
function installFlatpak(id, btn) { // Installs run as server jobs: start one, then poll until it's done. The
toast(`Installing ${id}. This can take a few minutes…`); // bottom bar counts what's running; `installing` keeps re-rendered lists from
return act(`Install ${id}`, () => api("/api/flatpak", { action: "install", id }), btn).then(refresh); // offering Install again for something already on its way.
const installing = new Set();
let jobsRunning = 0;
function showJobs() {
$("jobs").hidden = !jobsRunning;
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`;
} }
$("fpInstall").onclick = () => { const id = $("fpId").value.trim(); if (id) installFlatpak(id, $("fpInstall")); }; async function runJob(label, key, start) {
installing.add(key); jobsRunning++; showJobs();
log(label + "…");
try {
const { job } = await start();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
if (!j.done) continue;
if (j.error) throw new Error(j.error);
log(j.message, "ok"); toast(j.message);
return j.result || {};
}
} catch (e) {
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
} finally { installing.delete(key); jobsRunning--; showJobs(); }
}
function installFlatpak(id) {
if (installing.has(id)) return;
toast(`Installing ${id}. This can take a few minutes…`);
const done = runJob(`Install ${id}`, id, () => api("/api/flatpak", { action: "install", id })).then(refresh);
if (state) render(state); // show it as installing
return done;
}
$("fpInstall").onclick = () => { const id = $("fpId").value.trim(); if (id) { installFlatpak(id); $("fpId").value = ""; } };
$("fpId").addEventListener("keydown", e => { if (e.key === "Enter") $("fpInstall").click(); }); $("fpId").addEventListener("keydown", e => { if (e.key === "Enter") $("fpInstall").click(); });
function setVolume(pct, muted) { function setVolume(pct, muted) {
@@ -1123,10 +1261,26 @@ const drop = $("drop");
drop.onclick = () => $("fileInput").click(); drop.onclick = () => $("fileInput").click();
drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("fileInput").click(); } }; drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("fileInput").click(); } };
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]); $("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
["dragenter", "dragover"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.add("over"); })); // Files can be dropped anywhere in the window; an overlay says what happens.
["dragleave", "drop"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.remove("over"); })); // Dialogs keep their own drops (none take files today), so the overlay stays out of them.
let dragDepth = 0;
const carriesFiles = e => [...(e.dataTransfer?.types || [])].includes("Files");
window.addEventListener("dragenter", e => {
if (!carriesFiles(e) || document.querySelector("dialog[open]")) return;
e.preventDefault();
if (dragDepth++ === 0) { $("dropzone").hidden = false; drop.classList.add("over"); }
});
window.addEventListener("dragover", e => { if (carriesFiles(e)) e.preventDefault(); });
window.addEventListener("dragleave", e => {
if (!carriesFiles(e) || dragDepth === 0) return;
if (--dragDepth === 0) { $("dropzone").hidden = true; drop.classList.remove("over"); }
});
window.addEventListener("drop", e => {
if (!carriesFiles(e)) return;
e.preventDefault();
dragDepth = 0; $("dropzone").hidden = true; drop.classList.remove("over");
if (document.querySelector("dialog[open]")) return;
// Folders only show up as directory entries, and only during the drop event itself. // Folders only show up as directory entries, and only during the drop event itself.
drop.addEventListener("drop", e => {
const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file"); const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file");
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0)); const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
sendFiles([...e.dataTransfer.files], dirs); sendFiles([...e.dataTransfer.files], dirs);
@@ -1284,7 +1438,7 @@ async function loadTitles() {
let list, err; let list, err;
try { list = (await api("/api/titles")).titles; } catch (e) { err = e; } try { list = (await api("/api/titles")).titles; } catch (e) { err = e; }
if (seq !== titlesSeq) return; if (seq !== titlesSeq) return;
if (err) { $("titleList").innerHTML = `<div class="sub">${esc(err.message)}</div>`; return; } if (err) return failed($("titleList"), err);
$("titleCount").textContent = list.length ? `${list.length}` : ""; $("titleCount").textContent = list.length ? `${list.length}` : "";
$("titleList").innerHTML = list.length ? list.map(t => ` $("titleList").innerHTML = list.length ? list.map(t => `
<div class="item"> <div class="item">
@@ -1292,7 +1446,7 @@ async function loadTitles() {
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div> <div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button> <button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button> <button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
</div>`).join("") : `<div class="sub">None yet. Drop a game's .zip, folder or .exe above.</div>`; </div>`).join("") : `<div class="sub">None yet.</div>`;
} }
$("titleList").onclick = async e => { $("titleList").onclick = async e => {
const b = e.target.closest("[data-title]"); if (!b) return; const b = e.target.closest("[data-title]"); if (!b) return;
@@ -1304,6 +1458,7 @@ $("titleList").onclick = async e => {
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b); } else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
}; };
loadTitles(); loadTitles();
$("titleAdd").onclick = () => $("fileInput").click();
// ---- Steam games: install owned games, find and buy on the store ---- // ---- Steam games: install owned games, find and buy on the store ----
@@ -1321,8 +1476,8 @@ async function loadOwned() {
gm.owned = r.games; gm.country = r.country || gm.country; gm.dl = r.download; gm.owned = r.games; gm.country = r.country || gm.country; gm.dl = r.download;
gm.byId = new Map(gm.owned.map(g => [g.id, g])); gm.byId = new Map(gm.owned.map(g => [g.id, g]));
} catch (e) { } catch (e) {
if (!gm.owned) $("gmGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; if (!gm.owned) failed($("gmGrid"), e);
log("Steam library: " + e.message, "e"); if (!e.offline) log("Steam library: " + e.message, "e");
// Keep trying while a download was in progress; one failed poll shouldn't freeze it. // Keep trying while a download was in progress; one failed poll shouldn't freeze it.
clearTimeout(gm.poll); clearTimeout(gm.poll);
if (gm.dl) gm.poll = setTimeout(loadOwned, 15000); if (gm.dl) gm.poll = setTimeout(loadOwned, 15000);
@@ -1458,7 +1613,7 @@ let androidApps = [];
async function loadAndroid() { async function loadAndroid() {
try { try {
androidApps = (await api("/api/android")).apps; androidApps = (await api("/api/android")).apps;
} catch (e) { $("andApps").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; } } catch (e) { return failed($("andApps"), e); }
$("andCount").textContent = androidApps.length ? `${androidApps.length} installed` : ""; $("andCount").textContent = androidApps.length ? `${androidApps.length} installed` : "";
$("andApps").innerHTML = androidApps.length ? androidApps.map(a => { $("andApps").innerHTML = androidApps.length ? androidApps.map(a => {
const c = cat.apps && cat.apps.find(x => x.p === a.package); const c = cat.apps && cat.apps.find(x => x.p === a.package);
@@ -1521,6 +1676,7 @@ function moreCatalog() {
<div class="why">${esc(a.why[0] || "")}${a.why.length > 1 ? `<span title="${esc(a.why.slice(1).join("\n"))}"> · more</span>` : ""}</div> <div class="why">${esc(a.why[0] || "")}${a.why.length > 1 ? `<span title="${esc(a.why.slice(1).join("\n"))}"> · more</span>` : ""}</div>
<div class="row"> <div class="row">
${installed.has(a.p) ? `<span class="tag">Installed</span>` ${installed.has(a.p) ? `<span class="tag">Installed</span>`
: installing.has(a.p) ? `<span class="tag">Installing…</span>`
: a.r === "no" ? "" : `<button class="action small" data-and="install" data-pkg="${esc(a.p)}" data-name="${esc(a.n)}">Install</button>`} : a.r === "no" ? "" : `<button class="action small" data-and="install" data-pkg="${esc(a.p)}" data-name="${esc(a.n)}">Install</button>`}
<button class="small" data-and="rate" data-pkg="${esc(a.p)}" data-ver="${esc(a.v)}" data-name="${esc(a.n)}" data-src="F-Droid" title="Report how it worked">Report</button> <button class="small" data-and="rate" data-pkg="${esc(a.p)}" data-ver="${esc(a.v)}" data-name="${esc(a.n)}" data-src="F-Droid" title="Report how it worked">Report</button>
<a class="small" href="https://f-droid.org/packages/${esc(a.p)}/" target="_blank" rel="noopener">F-Droid</a> <a class="small" href="https://f-droid.org/packages/${esc(a.p)}/" target="_blank" rel="noopener">F-Droid</a>
@@ -1543,8 +1699,11 @@ document.body.addEventListener("click", async e => {
const b = e.target.closest("[data-and]"); if (!b) return; const b = e.target.closest("[data-and]"); if (!b) return;
const pkg = b.dataset.pkg, action = b.dataset.and; const pkg = b.dataset.pkg, action = b.dataset.and;
if (action === "install") { if (action === "install") {
if (installing.has(pkg)) return;
toast(`Installing ${b.dataset.name}. The first time takes a minute…`); toast(`Installing ${b.dataset.name}. The first time takes a minute…`);
await act(`Install ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b); const done = runJob(`Install ${b.dataset.name}`, pkg, () => api("/api/android", { action, package: pkg }));
b.outerHTML = `<span class="tag">Installing…</span>`;
await done;
} else if (action === "remove") { } else if (action === "remove") {
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return; if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b); await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
@@ -1562,7 +1721,7 @@ document.body.addEventListener("click", async e => {
loadCatalog(); loadCatalog();
// ---- Android display (wm size / wm density / font_scale per Lepton instance) ---- // ---- Android display (wm size / wm density / font_scale per Lepton instance) ----
const disp = { list: [], port: null }; const disp = { list: [], port: null, error: null, seq: 0 };
const DPI = [120, 640]; const DPI = [120, 640];
const dims = s => { const m = /^(\d+)x(\d+)$/.exec(s || ""); return m ? [+m[1], +m[2]] : null; }; const dims = s => { const m = /^(\d+)x(\d+)$/.exec(s || ""); return m ? [+m[1], +m[2]] : null; };
const clampDpi = d => Math.max(DPI[0], Math.min(DPI[1], Math.round(d))); const clampDpi = d => Math.max(DPI[0], Math.min(DPI[1], Math.round(d)));
@@ -1584,16 +1743,27 @@ function dispCalc(i) {
} }
async function loadDisplays() { async function loadDisplays() {
$("dispReload").disabled = true; $("dispReload").disabled = true;
const seq = ++disp.seq; // an older, slower request mustn't overwrite a newer answer
let list = [], error = null;
try { try {
disp.list = (await api("/api/android/displays")).instances; list = (await api("/api/android/displays")).instances;
} catch (e) { } catch (e) {
disp.list = []; error = e;
log("Android display: " + e.message, "e"); if (!e.offline) log("Android display: " + e.message, "e");
} finally { $("dispReload").disabled = false; } } finally { $("dispReload").disabled = false; }
if (seq !== disp.seq) return;
disp.list = list; disp.error = error;
if (!disp.list.some(i => i.port === disp.port)) disp.port = disp.list.length ? disp.list[0].port : null; if (!disp.list.some(i => i.port === disp.port)) disp.port = disp.list.length ? disp.list[0].port : null;
const sel = $("dispSel"); const sel = $("dispSel");
sel.innerHTML = disp.list.length ? disp.list.map(i => `<option value="${i.port}">${esc(dispLabel(i))}</option>`).join("") sel.innerHTML = disp.list.length ? disp.list.map(i => `<option value="${i.port}">${esc(dispLabel(i))}</option>`).join("")
: `<option value="">No Lepton instance is running</option>`; : `<option value="">${disp.error ? (disp.error.offline ? "Waiting for the Frame" : "Couldn't read Android's display")
: "No Android app is open"}</option>`;
sel.disabled = !disp.list.length;
// Open-an-app advice only fits a successful, empty answer; otherwise say what went wrong.
$("dispEmpty").textContent = !disp.error ? "Open an Android app in the headset, then Refresh, to change its resolution, UI scale and text size here."
: disp.error.offline ? "This fills in when the Frame answers." : disp.error.message;
$("dispEmpty").hidden = !!disp.list.length;
$("dispCtl").hidden = !disp.list.length;
if (disp.port) sel.value = String(disp.port); if (disp.port) sel.value = String(disp.port);
renderDisplay(); renderDisplay();
} }
@@ -1661,6 +1831,9 @@ loadDisplays();
const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", runs: "Runs (test)", const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", runs: "Runs (test)",
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" }; crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
const RCLASS = { works: "works", runs: "works", issues: "maybe" }; const RCLASS = { works: "works", runs: "works", issues: "maybe" };
const REPORTS_SHOWN = 5;
let repsAll = false;
$("repMore").onclick = () => { repsAll = true; loadReports(); };
async function loadReports() { async function loadReports() {
let reps, shared; let reps, shared;
try { ({ reports: reps, shared } = await api("/api/android/reports")); } try { ({ reports: reps, shared } = await api("/api/android/reports")); }
@@ -1669,7 +1842,9 @@ async function loadReports() {
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid." ? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
: "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid."; : "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
$("repCount").textContent = reps.length ? `${reps.length} newest` : ""; $("repCount").textContent = reps.length ? `${reps.length} newest` : "";
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => { $("repMore").hidden = reps.length <= REPORTS_SHOWN || repsAll;
$("repMore").textContent = `Show all ${reps.length}`;
$("repList").innerHTML = reps.length ? (repsAll ? reps : reps.slice(0, REPORTS_SHOWN)).map(r => {
const k = r.rating || r.result; const k = r.rating || r.result;
return `<div class="item rep-item"><div class="grow"> return `<div class="item rep-item"><div class="grow">
<div class="t">${esc(r.name)} <span class="sub">v${esc(String(r.version || "?").replace(/^v/i, ""))}</span></div> <div class="t">${esc(r.name)} <span class="sub">v${esc(String(r.version || "?").replace(/^v/i, ""))}</span></div>
@@ -1749,8 +1924,7 @@ async function loadShots() {
try { try {
shots.list = (await api("/api/shots")).shots; shots.list = (await api("/api/shots")).shots;
} catch (e) { } catch (e) {
$("shotGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return failed($("shotGrid"), e);
return;
} finally { $("shotsRefresh").disabled = false; } } finally { $("shotsRefresh").disabled = false; }
shots.urls.forEach(URL.revokeObjectURL); shots.urls = []; shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
const unsaved = shots.list.filter(s => !s.saved).length; const unsaved = shots.list.filter(s => !s.saved).length;
@@ -1815,12 +1989,29 @@ $("shotsRefresh").onclick = loadShots;
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget); $("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget); $("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
// ---- nav highlight follows scroll ---- // ---- pages: #home, #games, #android, #tools (older section links still work) ----
const spy = new IntersectionObserver(entries => { const PAGES = ["home", "games", "android", "tools"];
const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id)); display: "android", transfer: "tools", apps: "tools", power: "tools" };
}, { rootMargin: "-80px 0px -55% 0px" }); let page = "home";
["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id))); function showPage() {
const id = location.hash.slice(1);
const next = PAGES.includes(id) ? id : SECTION_PAGE[id] || "home";
if (next !== page && page === "home" && live) toggleLive(false); // don't stream video nobody is watching
page = next;
document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page));
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
const section = !PAGES.includes(id) && id && $(id);
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
}
window.addEventListener("hashchange", showPage);
showPage();
document.addEventListener("keydown", e => {
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
const n = PAGES[+e.key - 1];
if (n) location.hash = n;
});
// ---- install links from websites (frame-control://install, docs/web-install.md) ---- // ---- install links from websites (frame-control://install, docs/web-install.md) ----
// The app passes each link here. The server checks it and reads the manifest; // The app passes each link here. The server checks it and reads the manifest;
@@ -1928,7 +2119,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
setView("headset"); setView("headset");
refresh().then(loadShots); // after status, so app names resolve refresh().then(loadShots); // after status, so app names resolve
setInterval(() => { if (!document.hidden) refresh(); }, 30000); document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script> </script>
</body> </body>
</html> </html>
+102 -6
View File
@@ -85,6 +85,89 @@ class Failure(Exception):
self.status = status self.status = status
# What ssh prints when it never reached the Frame, and what to tell the user
# instead. Only ssh's own wording is matched, so a command that ran on the Frame
# and failed keeps its real error.
UNREACHABLE = [
(re.compile(r"Could not resolve hostname"),
"Can't find the Frame on the network. Check it's on and connected, or run Set Up Connection."),
(re.compile(r"port \d+: (Operation timed out|Connection timed out|Host is down|No route to host|"
r"Network is unreachable)"),
"The Frame isn't answering. It may be asleep, switched off, or on another network."),
(re.compile(r"[Tt]imed out talking to "),
"The Frame took too long to answer. It may be asleep or busy; try again."),
(re.compile(r"port \d+: Connection refused"),
"The Frame refused the connection. Check Developer Mode is still on."),
(re.compile(r"Permission denied \(publickey"),
"The Frame didn't accept this computer's SSH key. Run Set Up Connection again."),
(re.compile(r"Host key verification failed"),
"The Frame's SSH identity changed (after a reinstall, or a different device). Run Set Up Connection again."),
(re.compile(r"kex_exchange_identification|Connection closed by .* port \d+|Connection reset by .* port \d+"),
"The connection to the Frame dropped. Try again."),
]
def unreachable(message):
"""The plain-language reason the Frame couldn't be reached, or None if it was."""
for pattern, friendly in UNREACHABLE:
if pattern.search(message):
return friendly
return None
def error_body(message):
"""A JSON error body and status; SSH connection failures become one clear offline message."""
friendly = unreachable(message)
if friendly:
return {"error": friendly, "offline": True, "detail": message}, 503
return {"error": message}, None
# ---- Background jobs: installs that can outlast a request ------------------
#
# Flatpak and Android installs can take many minutes. The request starts the
# work and returns a job id at once; the page polls /api/job for the outcome.
JOB_TTL = 3600
_jobs_lock = threading.Lock()
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
def start_job(label, work):
"""Run work() in the background. It returns a dict with a "message"."""
now = time.time()
with _jobs_lock:
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
del _jobs[j]
job = secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
def run():
fields = {}
try:
result = work()
fields = {"message": result.get("message") or f"{label}: done", "result": result}
except (Failure, frame_android.FrameError) as e:
fields = {"error": unreachable(str(e)) or str(e)}
except Exception as e:
fields = {"error": f"{type(e).__name__}: {e}"}
finally:
with _jobs_lock:
_jobs[job].update(fields, done=True, time=time.time())
threading.Thread(target=run, daemon=True).start()
return {"message": f"{label}…", "job": job}
def job_status(query):
with _jobs_lock:
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
snapshot = job and {k: v for k, v in job.items() if k != "time"}
if not snapshot:
raise Failure("no such job (the app may have restarted)", 404)
return snapshot
_master_lock = threading.Lock() _master_lock = threading.Lock()
_master = None _master = None
@@ -408,11 +491,13 @@ def flatpak(body):
if not FLATPAK_ID.match(app): if not FLATPAK_ID.match(app):
raise Failure("bad Flatpak app ID", 400) raise Failure("bad Flatpak app ID", 400)
if action == "install": if action == "install":
def work():
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh). # Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
ssh("flatpak remote-add --user --if-not-exists flathub " ssh("flatpak remote-add --user --if-not-exists flathub "
"https://dl.flathub.org/repo/flathub.flatpakrepo && " "https://dl.flathub.org/repo/flathub.flatpakrepo && "
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=900) f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=1800)
return {"message": f"Installed {app}"} return {"message": f"Installed {app}"}
return start_job(f"Install {app}", work)
if action == "uninstall": if action == "uninstall":
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300) out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
return {"message": strip_ansi(out).strip() or f"Removed {app}"} return {"message": strip_ansi(out).strip() or f"Removed {app}"}
@@ -449,8 +534,13 @@ def android(body):
ensure_master() ensure_master()
try: try:
if action == "install": if action == "install":
frame_catalog.app(pkg) # an unknown package fails now, not in the background
def work():
m = frame_catalog.install(pkg) m = frame_catalog.install(pkg)
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m} return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
"app": m}
return start_job(f"Install {pkg}", work)
if action in ("launch", "stop"): if action in ("launch", "stop"):
m = getattr(frame_android, action)(pkg) m = getattr(frame_android, action)(pkg)
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"} return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
@@ -1156,6 +1246,10 @@ class Handler(BaseHTTPRequestHandler):
def send_json(self, obj, status=200): def send_json(self, obj, status=200):
self.send_bytes(json.dumps(obj).encode(), "application/json", status) self.send_bytes(json.dumps(obj).encode(), "application/json", status)
def send_error_json(self, message, status):
body, offline_status = error_body(message)
self.send_json(body, offline_status or status)
def do_GET(self): def do_GET(self):
if not self.local_request(): if not self.local_request():
return return
@@ -1175,6 +1269,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"titles": frame_titles.list_titles()}) self.send_json({"titles": frame_titles.list_titles()})
elif path == "/api/titles/job": elif path == "/api/titles/job":
self.send_json(title_job(url.query)) self.send_json(title_job(url.query))
elif path == "/api/job":
self.send_json(job_status(url.query))
elif path == "/api/android/displays": elif path == "/api/android/displays":
self.send_json(android_displays()) self.send_json(android_displays())
elif path == "/api/android/reports": elif path == "/api/android/reports":
@@ -1204,9 +1300,9 @@ class Handler(BaseHTTPRequestHandler):
else: else:
self.send_json({"error": "not found"}, 404) self.send_json({"error": "not found"}, 404)
except Failure as e: except Failure as e:
self.send_json({"error": str(e)}, e.status) self.send_error_json(str(e), e.status)
except frame_android.FrameError as e: except frame_android.FrameError as e:
self.send_json({"error": str(e)}, 502) self.send_error_json(str(e), 502)
except Exception as e: except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500) self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
@@ -1230,11 +1326,11 @@ class Handler(BaseHTTPRequestHandler):
raise Failure("request body must be a JSON object", 400) raise Failure("request body must be a JSON object", 400)
self.send_json(handler(body)) self.send_json(handler(body))
except Failure as e: except Failure as e:
self.send_json({"error": str(e)}, e.status) self.send_error_json(str(e), e.status)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
self.send_json({"error": f"bad request: {e}"}, 400) self.send_json({"error": f"bad request: {e}"}, 400)
except frame_android.FrameError as e: except frame_android.FrameError as e:
self.send_json({"error": str(e)}, 502) self.send_error_json(str(e), 502)
except Exception as e: except Exception as e:
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500) self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)