mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 09:01:40 +02:00
Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb44ec49f1 | ||
|
|
86636fb7d2 | ||
|
|
c7489a88eb | ||
|
|
be74850a8e | ||
|
|
dc21428f85 | ||
|
|
fa43c83b06 | ||
|
|
1d4c57d648 | ||
|
|
c6215712fe | ||
|
|
cd7ab2d9f7 | ||
|
|
4b68971383 | ||
|
|
54c001d537 | ||
|
|
5ce5c023b6 | ||
|
|
709fef0dbd | ||
|
|
49408dc171 | ||
|
|
c728e64fbd | ||
|
|
0626c52e12 | ||
|
|
b9b4a00485 | ||
|
|
eb125b59c7 | ||
|
|
7e53fe32cb | ||
|
|
ebae409632 | ||
|
|
08cb267ce4 | ||
|
|
0119544c3e | ||
|
|
2a9e7dcabf | ||
|
|
5a9099dddb | ||
|
|
ec4086db69 | ||
|
|
5a7c3541f8 | ||
|
|
525040162e | ||
|
|
21e771d16e | ||
|
|
2cd095a8c4 | ||
|
|
bb9236b2b7 | ||
|
|
1bc090d00d | ||
|
|
63ba25ffdd | ||
|
|
98e6e90d84 | ||
|
|
7c061846bd | ||
|
|
a91d55151f | ||
|
|
7f25e87b42 | ||
|
|
ff245953ac | ||
|
|
46665d51c0 | ||
|
|
67615e85c3 | ||
|
|
9e3c3cd31d | ||
|
|
537754ebce | ||
|
|
573a9e2730 | ||
|
|
8b91b58336 | ||
|
|
12c60ec024 | ||
|
|
60261f093f | ||
|
|
3120a3cacf | ||
|
|
fecb226e43 | ||
|
|
e9cbb8fd55 | ||
|
|
d72be95f25 | ||
|
|
65fedbf01e | ||
|
|
311ee70783 | ||
|
|
f832d0b912 | ||
|
|
361bcf8a1b | ||
|
|
10f86d3e7a | ||
|
|
5fea1526ca | ||
|
|
7a7dca70de | ||
|
|
bf8c500a87 | ||
|
|
71fe208fea | ||
|
|
2b838ede9e | ||
|
|
8402a671ef | ||
|
|
310e778d00 | ||
|
|
a909beb39e | ||
|
|
e2601965bd | ||
|
|
96502e6e4d | ||
|
|
d5f26efc1a | ||
|
|
eac9557286 | ||
|
|
4aa9f18fd1 | ||
|
|
fa40e30f3c | ||
|
|
d7a887b98c | ||
|
|
30cbe1c901 | ||
|
|
a5980d8f98 | ||
|
|
a13f4fafab | ||
|
|
ea0b6a80bd | ||
|
|
b351fe7ad6 | ||
|
|
3cb3defaa7 | ||
|
|
a992f6d896 | ||
|
|
d9d538690e | ||
|
|
391ecbeac5 | ||
|
|
1dc2a99617 | ||
|
|
d6f3d7c380 | ||
|
|
838eaa4e6b | ||
|
|
6104c3f192 | ||
|
|
da5c954ce4 | ||
|
|
2466c5aa14 | ||
|
|
060cf9cd57 | ||
|
|
cd202433bb | ||
|
|
c5fc552c60 | ||
|
|
559caa2dd0 |
No files matched your search
@@ -0,0 +1,53 @@
|
||||
name: PC host libraries
|
||||
# PC in the headset (Experimental). The Windows job builds the native adapter
|
||||
# and runs the real-encoder protocol tests; the installer's own bundle is
|
||||
# tested in release.yml. The Linux adapter from PR #38 isn't shipped or offered
|
||||
# in the app, but shares capture.c and controller.c, so it's still built here.
|
||||
# These builds add the GPL x264 encoder for headless tests only: they're never
|
||||
# uploaded or shipped.
|
||||
on:
|
||||
pull_request:
|
||||
paths: ["desktop/**", "ui/**", "tests/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
linux:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Native libraries
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
|
||||
python3 desktop/build.py --with-x264
|
||||
- name: Tests (real x264, fake capture consent/input)
|
||||
env:
|
||||
FRAME_PC_REQUIRE_NATIVE: "1"
|
||||
FRAME_PC_REQUIRE_X264: "1"
|
||||
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
python desktop/build.py --with-x264
|
||||
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
@@ -3,14 +3,14 @@ name: release
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# macOS helper it compiles, or this workflow) build the same installers as
|
||||
# Windows PC helper libraries, the macOS helper, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
@@ -26,7 +26,9 @@ jobs:
|
||||
# "view || create" could race and make duplicate drafts. The draft is tied to
|
||||
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
|
||||
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
|
||||
# by tag. It is created with the REST API so the id comes straight back: the
|
||||
# by tag. Editing a draft can make GitHub show an untagged-* name again; the
|
||||
# upload step below recovers that exact draft if tag lookup fails. It is
|
||||
# created with the REST API so the id comes straight back: the
|
||||
# release list can lag a fresh draft (v0.4.2's first run found nothing and
|
||||
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
|
||||
# this step against tests/fakegh.
|
||||
@@ -40,8 +42,13 @@ jobs:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
# A rerun finds the draft an earlier attempt made.
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
|
||||
# A rerun finds the draft even if GitHub temporarily shows untagged-*.
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
|
||||
--jq ".[] | select(.tag_name == \"$tag\" or (.draft and (.tag_name | startswith(\"untagged-\")) and .name == \"Frame Control ${tag#v}\")) | .id")
|
||||
if [ -n "$id" ] && ! [[ "$id" =~ ^[0-9]+$ ]]; then
|
||||
echo "expected one release for $tag; found: $id" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$id" ]; then
|
||||
# Given a missing tag, GitHub would create it on the default branch.
|
||||
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|
||||
@@ -75,6 +82,28 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
# PC in the headset (Experimental, Windows only): our capture adapter and
|
||||
# the GStreamer libraries it uses, bundled into resources/desktop/bundle.
|
||||
- uses: msys2/setup-msys2@v2
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Windows PC streaming libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: python desktop/build.py --out app/build/desktop/win-x64
|
||||
- name: Build
|
||||
working-directory: app
|
||||
shell: bash
|
||||
@@ -95,13 +124,43 @@ jobs:
|
||||
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
|
||||
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
# From the installed app, so this also proves the installer carries the
|
||||
# libraries. After the check above, and without writing __pycache__,
|
||||
# so it can't change what that check compares.
|
||||
- name: Test the installed app's Python with the Windows PC libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
run: |
|
||||
resources="$(cygpath -u "$LOCALAPPDATA")/Programs/Frame Control/resources"
|
||||
python="$resources/python/python.exe"
|
||||
native="$(cygpath -w "$resources/desktop/bundle")"
|
||||
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_INSTALLER_BUNDLE=1 "$python" -B -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- name: Upload to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
# A release-notes edit can make GitHub expose a draft as untagged-*.
|
||||
# Keep the normal upload path; recover only that lookup failure.
|
||||
if result=$(gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber 2>&1); then
|
||||
printf '%s\n' "$result"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$result" >&2
|
||||
case "$result" in *"release not found"*) ;; *) exit 1 ;; esac
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
ids=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
|
||||
--jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\" and (.tag_name == \"$tag\" or (.tag_name | startswith(\"untagged-\")))) | .id")
|
||||
if ! [[ "$ids" =~ ^[0-9]+$ ]]; then
|
||||
echo "expected one matching draft for $tag; found: ${ids:-none}" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api -X PATCH "repos/$GH_REPO/releases/$ids" -f tag_name="$tag" >/dev/null
|
||||
gh release upload "$tag" ${{ matrix.files }} --clobber
|
||||
# Tagged builds are attached to the release above; only other builds need artifacts.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
+10
@@ -7,5 +7,15 @@ compat-db/.lakebed/
|
||||
tests/smoke/results/
|
||||
mac/bin/
|
||||
|
||||
# Shared desktop streaming controller build products
|
||||
desktop/controller.dll
|
||||
desktop/controller.dylib
|
||||
desktop/controller.so
|
||||
desktop/*.obj
|
||||
desktop/*.lib
|
||||
desktop/*.exp
|
||||
desktop/bundle/
|
||||
app/build/desktop/
|
||||
|
||||
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
|
||||
frame/kdeconnect/packages/
|
||||
@@ -0,0 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix Return to Game Mode misidentifying full Plasma Desktop Mode when its VR
|
||||
overlay also runs gamescope (issue #142). Session targets now determine the
|
||||
mode; conflicting or incomplete evidence stays unknown. Use SteamOS's
|
||||
`steamosctl` switch when available, with older selector commands retained.
|
||||
|
||||
- Export read-only power and heat observations to an offline accessible HTML
|
||||
report with JSON/CSV downloads. `--html FILE` limits sessions to ten minutes;
|
||||
Ctrl+C saves received samples with an explicit incomplete state. Units,
|
||||
unknown counts and sources remain visible; no headset controls are added.
|
||||
|
||||
- Add a guarded `frame-doctor.sh --plan` with read-only, evidence-specific next
|
||||
steps and explicit local baseline reports for before/after diagnostics (issue
|
||||
#34). Restart risk blocks plans/comparisons; no automatic repairs, password
|
||||
changes or headset security changes are performed.
|
||||
|
||||
- Recover a tagged release upload when GitHub temporarily displays its draft
|
||||
as `untagged-…` after an edit, without creating a duplicate draft or
|
||||
silently retrying unrelated upload failures.
|
||||
|
||||
- Import standalone APK/OBB backups from XAPK/APKS/APKM archives through the
|
||||
existing preview and game-data setup flow. Unsafe archives, split sets and
|
||||
Android/data restores are refused with guidance. This does not establish RE4
|
||||
compatibility or diagnose its reported failure (issue #102).
|
||||
|
||||
- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for
|
||||
battery, CPU, named temperatures, fan and available power rails (issue #52).
|
||||
JSON samples/summaries and CSV exports retain explicit unavailable states;
|
||||
active smoke-test locks stop measurement. No power settings are changed.
|
||||
|
||||
- Show saved Android compatibility reports on store search cards and app details.
|
||||
Reports refresh in the background, so a stalled database or credential lookup
|
||||
cannot delay search. Warnings allow installation; build compatibility blockers
|
||||
still apply. Public copies use this computer's reports, not private shared ratings.
|
||||
|
||||
- Fix two cases where optional diagnostic logs could retain part of a headset
|
||||
display name or an SSH host name, while preserving existing privacy redaction
|
||||
(issue #81).
|
||||
|
||||
- Keep the full host-key recovery explanation readable in Devices, including
|
||||
long addresses, and remove doubled punctuation from the connection tooltip
|
||||
(issue #87). Refresh stays within the header and switching headsets selects
|
||||
the new headset's details.
|
||||
|
||||
- Fix experimental PC screen capture on Windows builds whose streaming libraries
|
||||
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
|
||||
windows are hidden with an explanation instead of failing when shown. A Windows
|
||||
VM streamed a real desktop to the Frame; physical input remains unverified.
|
||||
|
||||
- Add read-only battery health in Tools: charging state, temperature, cycle count,
|
||||
and available capacity with explicit unknown states. The parser was checked
|
||||
on a real Frame; the connected desktop UI was tested with synthetic readings.
|
||||
|
||||
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
|
||||
library/game install-size breakdown, with unknown sizes, partial inventories
|
||||
and permission/missing-drive states. Includes instructions for Steam's own
|
||||
Move UI; Frame Control does not move or delete game files. The read-only
|
||||
parser was checked on a real Frame; the connected UI and game moves were not.
|
||||
|
||||
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
|
||||
with verified SSH identity, private summaries and actionable next steps
|
||||
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
|
||||
manual; restart-loop evidence stops deeper diagnostics. The real Frame
|
||||
verified the SSH connection and safety stop; deeper checks were skipped.
|
||||
- Fixed headset smoke cleanup reporting success while a smoke-title process
|
||||
could remain running after its files were removed. Cleanup now stops and
|
||||
rechecks processes tied to exact reserved test-title paths, uses PID handles
|
||||
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
@@ -44,13 +44,16 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
For repeatable read-only battery, power-rail, temperature and fan measurements,
|
||||
use the [power and heat sampler](docs/power-and-heat.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**🎮 Steam games**<br>
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
@@ -89,6 +92,14 @@ Install desktop apps like Moonlight or VLC, and set each Android app's resolutio
|
||||
**⚡ One-click tools**<br>
|
||||
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
**💾 Backup and recovery**<br>
|
||||
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -218,12 +229,14 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Power and heat measurements](docs/power-and-heat.md) | Read-only sampling and offline HTML reports with JSON/CSV downloads |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
@@ -266,3 +279,5 @@ into a draft release, which reaches users once published. See
|
||||
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||
|
||||
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
|
||||
@@ -10,6 +10,8 @@ ship other people's software, unchanged, each under its own licence:
|
||||
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
|
||||
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
|
||||
| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
|
||||
| GStreamer 1.x, a few of its plugins and the libraries they need, as [MSYS2](https://packages.msys2.org/) builds them | Windows app only, for the experimental [PC in the headset](docs/pc-in-headset.md) | LGPL-2.1-or-later and others (per package); the GPL x264 encoder is left out | `libraries.json` (exact package versions) and the `licenses` folder in the app's `desktop/bundle` folder |
|
||||
|
||||
Frame Control starts KDE Connect and talks to it over its network protocol;
|
||||
it doesn't link to it or include its code.
|
||||
it doesn't link to it or include its code. On Windows, Frame Control's own
|
||||
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.9",
|
||||
"version": "0.4.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.9",
|
||||
"version": "0.4.14",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+11
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.9",
|
||||
"version": "0.4.14",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -198,7 +198,16 @@
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}"
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/desktop/${os}-${arch}",
|
||||
"to": "desktop/bundle",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Client choices live outside the local server's changing URL/origin.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const KEYS = ["mvSeparate", "padUsed"];
|
||||
const KEYS = ["mvSeparate", "padUsed", "pcView"];
|
||||
const valid = value => value === "0" || value === "1";
|
||||
|
||||
function preferenceStore(file) {
|
||||
|
||||
@@ -36,7 +36,7 @@ test("client choices survive a new native session and local server origin", t =>
|
||||
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("only the two non-sensitive binary choices can enter the native store", t => {
|
||||
test("only the non-sensitive binary choices can enter the native store", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
|
||||
assert.throws(() => store.write(key, value), /Invalid preference/);
|
||||
@@ -58,3 +58,11 @@ test("a missing or damaged preference file retains usable defaults", t => {
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(store.read(), { padUsed: "1" });
|
||||
});
|
||||
|
||||
test("the PC in the headset opt-in is remembered and off unless saved", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.equal(store.read().pcView, undefined);
|
||||
store.write("pcView", "1");
|
||||
assert.deepEqual(preferenceStore(file).read(), { pcView: "1" });
|
||||
assert.throws(() => store.write("pcView", "on"), /Invalid preference/);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-hairpin",
|
||||
"date": "2026-09-28T22:36:19",
|
||||
"commit": "c5fc552+dirty",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 351,
|
||||
"frames_drawn": 351,
|
||||
"frames_shown": 324,
|
||||
"duration_s": 10.1,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.5,
|
||||
"n": 351
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 351
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.9,
|
||||
"p99": 3.5,
|
||||
"n": 351
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.4,
|
||||
"p99": 0.6,
|
||||
"n": 351
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.6,
|
||||
"p95": 75.7,
|
||||
"p99": 130.1,
|
||||
"n": 351
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 2.1,
|
||||
"p99": 4.1,
|
||||
"n": 351
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.4,
|
||||
"p95": 0.8,
|
||||
"p99": 1.1,
|
||||
"n": 351
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 18.1,
|
||||
"p99": 24.9,
|
||||
"n": 324
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 80.3,
|
||||
"p99": 134.2,
|
||||
"n": 351
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 21.0,
|
||||
"p95": 71.8,
|
||||
"p99": 146.3,
|
||||
"n": 324
|
||||
}
|
||||
},
|
||||
"fps": 34.8,
|
||||
"fps_shown": 32.0,
|
||||
"late_pct": 53.41,
|
||||
"stall_max": 226.8,
|
||||
"stalls_over_100ms": 7,
|
||||
"mbps": 0.26,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 607,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 43.3,
|
||||
"p95": 98.4,
|
||||
"p99": 110.1,
|
||||
"n": 18
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 54.7,
|
||||
"p95": 98.9,
|
||||
"p99": 114.0,
|
||||
"n": 18
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 17.1,
|
||||
"p95": 48.3,
|
||||
"p99": 53.1,
|
||||
"n": 18
|
||||
},
|
||||
"mac": {
|
||||
"p50": 12.5,
|
||||
"p95": 32.9,
|
||||
"p99": 35.7,
|
||||
"n": 18
|
||||
},
|
||||
"back": {
|
||||
"p50": 16.8,
|
||||
"p95": 50.0,
|
||||
"p99": 55.9,
|
||||
"n": 18
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 18,
|
||||
"sent": 18,
|
||||
"seen": 18
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 38,
|
||||
"mbps": 0.3,
|
||||
"content_p50": 14.8,
|
||||
"content_p95": 25.3,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 33,
|
||||
"mbps": 0.24,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 103.5,
|
||||
"bitrate": 3845448,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 39,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 29.9,
|
||||
"bitrate": 5283790,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 25,
|
||||
"mbps": 0.21,
|
||||
"content_p50": 17.8,
|
||||
"content_p95": 175.9,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 34,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 15.4,
|
||||
"content_p95": 69.8,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 17.7,
|
||||
"content_p95": 68.7,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 38,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 15.0,
|
||||
"content_p95": 18.2,
|
||||
"bitrate": 3091280,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 30,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 19.2,
|
||||
"content_p95": 111.0,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 35,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 13.4,
|
||||
"content_p95": 102.6,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 12.3,
|
||||
"content_p95": 20.9,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 3,
|
||||
"mbps": 0.02,
|
||||
"content_p50": 20.9,
|
||||
"content_p95": 24.7,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 80.3,
|
||||
"input_p50": 43.3,
|
||||
"input_p95": 98.4,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "acceptable",
|
||||
"input_p50": "local",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"controller": {
|
||||
"target": 3442081,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 13.334,
|
||||
"inFlight": 1,
|
||||
"slack": 71.738,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 3.77,
|
||||
"e": "target 2764800 bit/s; tier 0"
|
||||
},
|
||||
{
|
||||
"t": 7.42,
|
||||
"e": "target 1725204 bit/s; tier 0"
|
||||
}
|
||||
],
|
||||
"source_fps": 60.1,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
|
||||
"show_s": 1.88,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-final",
|
||||
"date": "2026-09-28T22:55:27",
|
||||
"commit": "cd20243",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "native",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 400,
|
||||
"frames_drawn": 400,
|
||||
"frames_shown": 339,
|
||||
"duration_s": 10.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.1,
|
||||
"p99": 0.2,
|
||||
"n": 400
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.7,
|
||||
"p99": 3.1,
|
||||
"n": 400
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.8,
|
||||
"p95": 25.2,
|
||||
"p99": 37.8,
|
||||
"n": 400
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.5,
|
||||
"p99": 2.0,
|
||||
"n": 400
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.3,
|
||||
"p95": 0.7,
|
||||
"p99": 1.0,
|
||||
"n": 400
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 19.3,
|
||||
"p99": 26.8,
|
||||
"n": 339
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 28.6,
|
||||
"p99": 41.3,
|
||||
"n": 400
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 17.6,
|
||||
"p95": 39.0,
|
||||
"p99": 47.2,
|
||||
"n": 339
|
||||
}
|
||||
},
|
||||
"fps": 39.3,
|
||||
"fps_shown": 33.4,
|
||||
"late_pct": 50.25,
|
||||
"stall_max": 69.3,
|
||||
"stalls_over_100ms": 0,
|
||||
"mbps": 0.35,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 612,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 76.6,
|
||||
"p95": 98.2,
|
||||
"p99": 127.8,
|
||||
"n": 19
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 80.5,
|
||||
"p95": 107.8,
|
||||
"p99": 128.1,
|
||||
"n": 16
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 36.5,
|
||||
"p95": 57.6,
|
||||
"p99": 83.2,
|
||||
"n": 19
|
||||
},
|
||||
"mac": {
|
||||
"p50": 14.6,
|
||||
"p95": 31.3,
|
||||
"p99": 31.7,
|
||||
"n": 19
|
||||
},
|
||||
"back": {
|
||||
"p50": 15.1,
|
||||
"p95": 32.8,
|
||||
"p99": 36.2,
|
||||
"n": 19
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 19,
|
||||
"sent": 19,
|
||||
"seen": 19
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 40,
|
||||
"mbps": 0.34,
|
||||
"content_p50": 11.7,
|
||||
"content_p95": 27.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 11.9,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 41,
|
||||
"mbps": 0.35,
|
||||
"content_p50": 11.4,
|
||||
"content_p95": 16.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 12.7,
|
||||
"content_p95": 28.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 38,
|
||||
"mbps": 0.33,
|
||||
"content_p50": 15.3,
|
||||
"content_p95": 30.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 41,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 29.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 41,
|
||||
"mbps": 0.31,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 38,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.5,
|
||||
"content_p95": 25.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.8,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 6,
|
||||
"mbps": 0.03,
|
||||
"content_p50": 17.6,
|
||||
"content_p95": 23.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 28.6,
|
||||
"input_p50": 76.6,
|
||||
"input_p95": 98.2,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "acceptable",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "local"
|
||||
},
|
||||
"controller": {
|
||||
"target": 5529600,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 12.332,
|
||||
"inFlight": 0,
|
||||
"slack": 74.12,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [],
|
||||
"source_fps": 60.0,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
|
||||
"show_s": 1.75,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
root = Path(__file__).resolve().parent
|
||||
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
|
||||
out = root / ('controller' + suffix)
|
||||
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
|
||||
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
|
||||
else:
|
||||
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
|
||||
subprocess.run(cmd, cwd=root, check=True)
|
||||
print(out)
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build our native PC adapter and bundle its ordinary shared libraries.
|
||||
|
||||
Run on the target architecture after installing GStreamer development packages.
|
||||
No GStreamer executable or desktop streaming application is shipped or invoked.
|
||||
Linux libc, display/GPU drivers and the portal service remain platform pieces.
|
||||
|
||||
x264 is GPL, so the bundle the Windows installer ships leaves it out: Windows
|
||||
encodes with Media Foundation. --with-x264 adds it for CI's headless protocol
|
||||
tests, which have no GPU or desktop to encode with.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def output(*args):
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
|
||||
def build(destination, with_x264=False):
|
||||
win = sys.platform == 'win32'
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
|
||||
if not win:
|
||||
modules += ['gio-unix-2.0']
|
||||
import shlex
|
||||
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
|
||||
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
|
||||
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
|
||||
if not win:
|
||||
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
|
||||
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
|
||||
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
|
||||
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
|
||||
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
|
||||
plugins_out.mkdir(parents=True, exist_ok=True)
|
||||
libs_out.mkdir(parents=True, exist_ok=True)
|
||||
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad']
|
||||
if with_x264:
|
||||
names.append('x264')
|
||||
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
|
||||
pending = [lib]
|
||||
for name in names:
|
||||
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
|
||||
if not matches:
|
||||
raise SystemExit('Missing GStreamer library: ' + name)
|
||||
for source in matches:
|
||||
target = plugins_out / source.name
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
if not win:
|
||||
# PipeWire dynamically loads support and protocol modules. ldd cannot
|
||||
# discover those; bundle them with a private client-only config instead
|
||||
# of accidentally loading a different distro's client modules/config.
|
||||
libdir = plugin_dir.parent
|
||||
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
|
||||
'pipewire-0.3/libpipewire-module-client-node.so',
|
||||
'pipewire-0.3/libpipewire-module-adapter.so',
|
||||
'spa-0.2/support/libspa-support.so',
|
||||
'spa-0.2/support/libspa-dbus.so',
|
||||
'spa-0.2/videoconvert/libspa-videoconvert.so'):
|
||||
source = libdir / relative
|
||||
if not source.is_file():
|
||||
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
|
||||
target = destination / 'lib' / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
config = destination / 'share' / 'pipewire'
|
||||
config.mkdir(parents=True, exist_ok=True)
|
||||
(config / 'client.conf').write_text(
|
||||
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
|
||||
'context.modules = [\n'
|
||||
' { name = libpipewire-module-protocol-native }\n'
|
||||
' { name = libpipewire-module-client-node }\n'
|
||||
' { name = libpipewire-module-adapter }\n'
|
||||
']\n')
|
||||
copied = set()
|
||||
while pending:
|
||||
binary = pending.pop()
|
||||
if win:
|
||||
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
|
||||
dependencies = [prefix / 'bin' / name for name in imported]
|
||||
else:
|
||||
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
|
||||
for dep in dependencies:
|
||||
if not dep.is_file() or dep.name in copied:
|
||||
continue
|
||||
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
|
||||
continue
|
||||
shutil.copy2(dep, libs_out / dep.name)
|
||||
copied.add(dep.name)
|
||||
pending.append(dep)
|
||||
# License texts and package provenance accompany the dynamically linked
|
||||
# libraries. Distribution builders keep the upstream package/source URLs.
|
||||
licenses = destination / 'licenses'
|
||||
licenses.mkdir(exist_ok=True)
|
||||
if win:
|
||||
source = prefix / 'share' / 'licenses'
|
||||
if source.exists():
|
||||
shutil.copytree(source, licenses, dirs_exist_ok=True)
|
||||
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
|
||||
else:
|
||||
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
|
||||
# Debian copyright files contain licenses and source homepage details.
|
||||
for path in Path('/usr/share/doc').glob('*/copyright'):
|
||||
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
|
||||
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
|
||||
plugins=names, libraries=sorted(copied), packages=packages), indent=2))
|
||||
print(lib)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
|
||||
parser.add_argument('--with-x264', action='store_true', help='add the GPL x264 encoder (CI tests only, never shipped)')
|
||||
args = parser.parse_args()
|
||||
build(args.out.resolve(), args.with_x264)
|
||||
@@ -0,0 +1,134 @@
|
||||
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
|
||||
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
|
||||
#include "controller.h"
|
||||
#include <gst/gst.h>
|
||||
#include <gst/app/gstappsink.h>
|
||||
#include <gst/video/video-event.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
|
||||
typedef struct {
|
||||
GstElement *pipeline, *encoder, *sink, *raw_queue;
|
||||
Gate gate;
|
||||
GstSample *sample;
|
||||
GstMapInfo map;
|
||||
int mapped;
|
||||
char error[512];
|
||||
} Capture;
|
||||
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
|
||||
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
|
||||
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
|
||||
FC_API int fc_has_element(const char *name) {
|
||||
GstElementFactory *f=gst_element_factory_find(name);
|
||||
if(!f)return 0;gst_object_unref(f);return 1;
|
||||
}
|
||||
FC_API int fc_has_property(const char *element, const char *property) {
|
||||
/* Probe the same initialized GStreamer instance that capture uses. The
|
||||
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
|
||||
GstElement *source=gst_element_factory_make(element,NULL);
|
||||
if(!source)return 0;
|
||||
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
|
||||
gst_object_unref(source);
|
||||
return found;
|
||||
}
|
||||
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
|
||||
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
|
||||
if(!b)return GST_PAD_PROBE_OK;
|
||||
int64_t now=fc_now(), cap=now;
|
||||
GstClock *clock=gst_element_get_clock(c->pipeline);
|
||||
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
|
||||
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
|
||||
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
|
||||
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
|
||||
}
|
||||
if(clock)gst_object_unref(clock);
|
||||
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
|
||||
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
int phase=0;
|
||||
for(;;) {
|
||||
/* While congested, prefer a newer raw picture queued upstream. If
|
||||
* nothing changed, retain this last picture until the gate opens;
|
||||
* an idle window must not stay stale after a dropped final update. */
|
||||
guint queued=0;
|
||||
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
|
||||
if(queued)return GST_PAD_PROBE_DROP;
|
||||
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
|
||||
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
phase=2;g_usleep(2000);
|
||||
}
|
||||
}
|
||||
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
|
||||
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
|
||||
c->pipeline=gst_parse_launch(pipeline,&e);
|
||||
if(e || !c->pipeline) {
|
||||
g_strlcpy(error,e?e->message:"No pipeline",capacity);
|
||||
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
|
||||
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
|
||||
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
|
||||
if(!c->encoder || !c->sink || !raw) {
|
||||
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
|
||||
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
|
||||
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
|
||||
GstPad *p=gst_element_get_static_pad(raw,"src");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
|
||||
p=gst_element_get_static_pad(c->encoder,"sink");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
|
||||
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
|
||||
return c;
|
||||
}
|
||||
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
|
||||
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
|
||||
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
|
||||
GstBus *bus=gst_element_get_bus(c->pipeline);
|
||||
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
|
||||
if(m) {
|
||||
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
|
||||
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
|
||||
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
|
||||
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
|
||||
gst_message_unref(m);return -1;
|
||||
}
|
||||
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
|
||||
if(!c->sample) {
|
||||
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
|
||||
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
GstBuffer *b=gst_sample_get_buffer(c->sample);
|
||||
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
|
||||
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
|
||||
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
|
||||
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
|
||||
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
|
||||
}
|
||||
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
|
||||
FC_API void fc_capture_bitrate(Capture *c,int bps) {
|
||||
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
|
||||
}
|
||||
FC_API void fc_capture_test(Capture *c,uint32_t input) {
|
||||
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
|
||||
if(source) {
|
||||
/* Each benchmark click visibly changes the ball, before injection is
|
||||
* timestamped. This is a response, not merely a protocol input echo. */
|
||||
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
|
||||
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
|
||||
}
|
||||
}
|
||||
FC_API void fc_capture_key(Capture *c) {
|
||||
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
|
||||
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
|
||||
}
|
||||
FC_API void fc_capture_close(Capture *c) {
|
||||
gst_element_set_state(c->pipeline,GST_STATE_NULL);
|
||||
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
|
||||
if(c->sample)gst_sample_unref(c->sample);
|
||||
if(c->raw_queue)gst_object_unref(c->raw_queue);
|
||||
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* A C port of the Mac agent's RateController (mac/frame-mac-view/Sources/
|
||||
* Controller.swift) for the Windows host. Same gate, demand protection, bitrate
|
||||
* recovery and tier hysteresis; bounded sample buffers. The Mac agent keeps its
|
||||
* Swift original, so a change to either must be made to both:
|
||||
* tests/test_pc_controller.py checks this one against the Swift trace. */
|
||||
#include "controller.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define CAP 1024
|
||||
#define MIN(a,b) ((a)<(b)?(a):(b))
|
||||
#define MAX(a,b) ((a)>(b)?(a):(b))
|
||||
typedef struct { int64_t t, v; uint32_t seq; } Sample;
|
||||
typedef struct { Sample a[CAP]; int n; } Samples;
|
||||
struct FCController {
|
||||
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
|
||||
int64_t decrease, increase, below, above, slack;
|
||||
Samples flight, rtts, acked, sent, captures;
|
||||
};
|
||||
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
|
||||
static const double floors[] = {.45,.28,.16,.08,0};
|
||||
static void remove_first(Samples *s, int n) {
|
||||
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
|
||||
}
|
||||
static void add(Samples *s, Sample v, int cap) {
|
||||
if (s->n >= cap) remove_first(s, 1);
|
||||
s->a[s->n++] = v;
|
||||
}
|
||||
static void expire(Samples *s, int64_t oldest) {
|
||||
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
|
||||
remove_first(s,n);
|
||||
}
|
||||
static int64_t base(FCController *c) {
|
||||
int64_t v=0;
|
||||
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
|
||||
return v;
|
||||
}
|
||||
static int compare(const void *a, const void *b) {
|
||||
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
|
||||
return (x>y)-(x<y);
|
||||
}
|
||||
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
|
||||
int64_t a[CAP]; int n=0;
|
||||
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
|
||||
if(!n) return 0;
|
||||
qsort(a,(size_t)n,sizeof(int64_t),compare);
|
||||
return a[n*numerator/denominator];
|
||||
}
|
||||
static int rate(Samples *s) {
|
||||
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
|
||||
return (int)MIN(total*16,2147483647);
|
||||
}
|
||||
FCController *fc_new(int max_fps,int enabled) {
|
||||
FCController *c=calloc(1,sizeof(*c));
|
||||
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
|
||||
return c;
|
||||
}
|
||||
void fc_free(FCController *c) {free(c);}
|
||||
void fc_ceiling(FCController *c,int bps) {
|
||||
if(!c->target || c->target>bps) c->target=bps;
|
||||
c->ceiling=bps;
|
||||
}
|
||||
int fc_gate(FCController *c,int64_t now,int counts) {
|
||||
if(!c->enabled || !c->saw_ack) return 1;
|
||||
expire(&c->flight,now-2000000);
|
||||
if(!c->flight.n) return 1;
|
||||
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
|
||||
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
|
||||
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
|
||||
if(counts) c->held++;
|
||||
return 0;
|
||||
}
|
||||
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
|
||||
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
|
||||
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
|
||||
}
|
||||
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
|
||||
c->saw_ack=1;
|
||||
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
|
||||
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
|
||||
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
|
||||
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
int fc_update(FCController *c,int64_t now) {
|
||||
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
|
||||
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
|
||||
expire(&c->captures,now-1000000);
|
||||
if(!c->enabled || c->ceiling<=0) return 0;
|
||||
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
|
||||
int64_t jitter=spread ? spread-baseline : 0;
|
||||
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
|
||||
int64_t recent=quantile(&c->rtts,now-300000,1,2);
|
||||
int64_t queue=recent ? recent-baseline : 0;
|
||||
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
|
||||
int delivered=rate(&c->acked),sending=rate(&c->sent);
|
||||
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
|
||||
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
|
||||
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
|
||||
int congested=signal && c->signal;c->signal=signal;c->held=0;
|
||||
if(congested && now-c->decrease>300000) {
|
||||
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
|
||||
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
|
||||
c->target=next;c->decrease=now;
|
||||
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
|
||||
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
|
||||
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
|
||||
}
|
||||
double share=(double)c->target/MAX(c->ceiling,1);
|
||||
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
|
||||
if(!c->below)c->below=now;
|
||||
if(now-c->below>500000) {
|
||||
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
|
||||
c->below=0;
|
||||
}
|
||||
} else c->below=0;
|
||||
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
|
||||
if(!c->above)c->above=now;
|
||||
if(now-c->above>2000000) {c->tier--;c->above=0;}
|
||||
} else c->above=0;
|
||||
return c->target;
|
||||
}
|
||||
int64_t fc_value(FCController *c,int field) {
|
||||
switch(field) {
|
||||
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
|
||||
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
|
||||
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
|
||||
default:return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Shared latency controller. Times are monotonic host microseconds.
|
||||
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
|
||||
* encoded inter frame would break the decoder's reference chain. */
|
||||
#ifndef FRAME_CONTROLLER_H
|
||||
#define FRAME_CONTROLLER_H
|
||||
#include <stdint.h>
|
||||
#ifdef _WIN32
|
||||
#define FC_API __declspec(dllexport)
|
||||
#else
|
||||
#define FC_API
|
||||
#endif
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct FCController FCController;
|
||||
FC_API FCController *fc_new(int fps, int enabled);
|
||||
FC_API void fc_free(FCController *c);
|
||||
FC_API void fc_ceiling(FCController *c, int bps);
|
||||
FC_API int fc_gate(FCController *c, int64_t now, int counts);
|
||||
FC_API void fc_capture(FCController *c, int64_t now);
|
||||
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
|
||||
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
|
||||
FC_API int fc_update(FCController *c, int64_t now);
|
||||
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
|
||||
FC_API int64_t fc_value(FCController *c, int field);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
|
||||
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
|
||||
* Input uses only the devices granted in Start's response. */
|
||||
#ifndef _WIN32
|
||||
#include "controller.h"
|
||||
#include <gio/gio.h>
|
||||
#include <gio/gunixfdlist.h>
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#define BUS "org.freedesktop.portal.Desktop"
|
||||
#define PATH "/org/freedesktop/portal/desktop"
|
||||
#define RD "org.freedesktop.portal.RemoteDesktop"
|
||||
#define SC "org.freedesktop.portal.ScreenCast"
|
||||
typedef struct {
|
||||
GDBusConnection *bus;
|
||||
GMainContext *context;
|
||||
char *session;
|
||||
int fd;
|
||||
unsigned node, devices;
|
||||
int width,height;
|
||||
} Portal;
|
||||
typedef struct {GVariant *result;int done;unsigned code;} Response;
|
||||
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
|
||||
const char *signal,GVariant *params,gpointer data) {
|
||||
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
|
||||
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
|
||||
}
|
||||
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
|
||||
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
|
||||
const char *token,char *error,int capacity) {
|
||||
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
|
||||
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
|
||||
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
|
||||
Response r={0};GError *e=NULL;
|
||||
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
|
||||
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
|
||||
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
|
||||
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
|
||||
if(reply)g_variant_unref(reply);
|
||||
int64_t deadline=g_get_monotonic_time()+120000000;
|
||||
while(!e && !r.done && g_get_monotonic_time()<deadline) {
|
||||
while(g_main_context_iteration(p->context,FALSE)) {}
|
||||
g_usleep(10000);
|
||||
}
|
||||
if(!r.done) {
|
||||
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(closed)g_variant_unref(closed);
|
||||
}
|
||||
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
|
||||
if(e || !r.done || r.code) {
|
||||
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
|
||||
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
|
||||
}
|
||||
return r.result;
|
||||
}
|
||||
FC_API void fc_portal_close(Portal *p) {
|
||||
if(!p)return;
|
||||
if(p->session && p->bus) {
|
||||
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(r)g_variant_unref(r);
|
||||
}
|
||||
if(p->fd>=0){close(p->fd);p->fd=-1;}
|
||||
g_free(p->session);if(p->bus)g_object_unref(p->bus);
|
||||
if(p->context)g_main_context_unref(p->context);g_free(p);
|
||||
}
|
||||
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
|
||||
* previously consumed protocol socket is not a new connection.
|
||||
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
|
||||
* pipewiresrc never takes the fd it is given: its core connects with
|
||||
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
|
||||
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
|
||||
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
|
||||
* close; not closing it would leak one socket per pipeline reopen. The
|
||||
* caller closes the previous pipeline before asking for a new fd. */
|
||||
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
|
||||
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
GUnixFDList *fds=NULL;GError *e=NULL;
|
||||
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
|
||||
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
|
||||
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
|
||||
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
|
||||
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
if(p->fd>=0)close(p->fd);
|
||||
p->fd=fd;return fd;
|
||||
}
|
||||
FC_API Portal *fc_portal_select(char *error,int capacity) {
|
||||
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
|
||||
g_main_context_push_thread_default(p->context);
|
||||
GError *e=NULL;GVariant *r=NULL;
|
||||
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
|
||||
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
|
||||
GVariantBuilder b;char token[64],session[64];
|
||||
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
|
||||
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
|
||||
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
|
||||
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
|
||||
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));
|
||||
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"devices","u",&p->devices);
|
||||
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
|
||||
if(streams && g_variant_n_children(streams)>0) {
|
||||
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
|
||||
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
|
||||
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
|
||||
g_variant_unref(props);g_variant_unref(entry);
|
||||
}
|
||||
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
|
||||
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
|
||||
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
|
||||
g_main_context_pop_thread_default(p->context);return p;
|
||||
fail:
|
||||
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
|
||||
}
|
||||
FC_API int fc_portal_value(Portal *p,int field) {
|
||||
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
|
||||
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
|
||||
}
|
||||
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
|
||||
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
if(type==0 && (p->devices&2)) {
|
||||
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
|
||||
} else if(type==1 && (p->devices&2)) {
|
||||
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else if(type==2 && (p->devices&2)) {
|
||||
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
|
||||
} else if(type==3 && (p->devices&1)) {
|
||||
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else return 0;
|
||||
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
|
||||
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
|
||||
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
|
||||
}
|
||||
#endif
|
||||
@@ -190,6 +190,35 @@ IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on th
|
||||
Frame as cards, with Play and a menu for Test, Report and Remove; the display
|
||||
settings and recent reports are below it.
|
||||
|
||||
Store search cards and app details also show **Reported not working** or
|
||||
**Reported issues** when saved compatibility reports say so. Details include
|
||||
the report date, version and notes. These are advisories: Install remains
|
||||
available unless the selected build has an existing compatibility blocker
|
||||
(for example, no arm64-v8a build or Android API above 30). Reports never clear
|
||||
those blockers. Apps reported not working are excluded from Home's featured
|
||||
app and recommendation shelves; search and category results still show them.
|
||||
An unrated install failure caused by the wrong APK architecture or Android API
|
||||
is ignored, using the same rules as the catalogue. A person's rating takes
|
||||
priority over automated results, with the latest rating winning.
|
||||
|
||||
Reports warm in a background worker at server start. Search and details read
|
||||
only the last complete in-memory snapshot and schedule a single background
|
||||
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
|
||||
the report database. During initial loading they show the usual build verdict;
|
||||
after a refresh, the next search or detail request sees the reports. Failed
|
||||
refreshes keep the previous snapshot and retry after another 60 seconds.
|
||||
|
||||
**Availability:** public copies benefit from their own saved reports, including
|
||||
reports for apps outside the bundled catalogue, across every source with the
|
||||
same package name. This does not give public users the private shared ratings.
|
||||
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
|
||||
report verdicts, so it cannot supply a useful public report snapshot. No private
|
||||
reports, free-text notes or credentials are exported or published. The existing
|
||||
maintainer loader still uses its configured database access in the background;
|
||||
the search feature adds no sharing, endpoint or network destination. Tests leave
|
||||
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
|
||||
server subprocesses) and use synthetic reports to exercise refresh behavior.
|
||||
|
||||
The store groups apps by what a headset is good for, not by F-Droid's list:
|
||||
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
|
||||
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
|
||||
@@ -402,3 +431,49 @@ app instance and back up/restore a stopped instance's private app data. See
|
||||
[SideQuest features and limits](sidequest.md) for commands, archive scope and
|
||||
verification status. These paths have offline coverage; real Frame storage and
|
||||
permissions remain unverified. They do not change APK install or launch behavior.
|
||||
|
||||
|
||||
## Local APK/OBB backups and Android bundles
|
||||
|
||||
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
|
||||
standalone APK and its matching `main.<version>.<package>.obb` and/or
|
||||
`patch.<version>.<package>.obb` files together. The desktop app also discovers
|
||||
matching OBB files beside a lone selected APK. Browser uploads use only the
|
||||
files you selected. Installed apps can receive OBB-only imports.
|
||||
|
||||
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
|
||||
one standalone APK and optional matching OBBs. These extensions describe
|
||||
containers, not a guarantee that their contents are supported. OBBs may be
|
||||
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
|
||||
one enclosing backup folder is allowed. Known manifest/icon/signature
|
||||
sidecars are ignored; the APK manifest determines the package. Unknown files,
|
||||
unsafe paths, links, encrypted entries, duplicate paths (including case
|
||||
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
|
||||
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
|
||||
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
extract an Android ZIP yourself and select its APK/OBBs.
|
||||
|
||||
The importer previews the app and files before **Import game & data**.
|
||||
Confirming authorizes installing the app, starting it to prepare Android,
|
||||
checking the running package/version, copying OBBs through the existing
|
||||
verified data helper, and closing the game. Open it from Steam afterwards.
|
||||
If setup fails, read the error and retry the same backup; do not uninstall
|
||||
first, since that deletes saves. An app that cannot start may also prevent
|
||||
data setup. Uploaded and extracted staging is removed on cancellation,
|
||||
inspection failure, or job completion/failure; abandoned imports expire
|
||||
through the existing one-hour staging lifecycle. Original local files stay intact.
|
||||
|
||||
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
|
||||
bases marked as requiring splits. It does not select device variants or restore
|
||||
`Android/data`, private app data, saves, or entitlements. For the separate
|
||||
restricted native-library/asset split workflow see
|
||||
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
|
||||
code and feature splits are not supported by that merger.
|
||||
|
||||
For issue #102, the RE4 report only establishes that an APK attempt failed.
|
||||
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
|
||||
and runtime compatibility remain unknown. Record those details, Frame Control
|
||||
and SteamOS versions, and sanitized server/crash errors before diagnosing it.
|
||||
`python3 ui/frame_android.py info /path/to/game.apk` inspects the APK locally.
|
||||
Successful installation or fixture tests do not establish RE4 compatibility;
|
||||
OBBs cannot fix ABI/API, VrApi/OpenXR, entitlement or Android-service failures.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Backup and recovery
|
||||
|
||||
**Tools → Backup & recovery** copies what a factory reset would lose from the
|
||||
Steam Frame to your computer, puts it back afterwards, and gets the headset out
|
||||
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
|
||||
app or the Frame itself.
|
||||
|
||||
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
|
||||
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
|
||||
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
|
||||
with no sign-in files or keys in the archives. Restoring one game's saves on top
|
||||
of identical files wrote nothing and left nothing behind. The session check
|
||||
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
|
||||
headset: a restore after a real factory reset, a restore that replaces files,
|
||||
Android data restore. Unit tests and the fake Frame cover those (see
|
||||
[Verification](#verification)).
|
||||
On 2026-10-24, the real-device reproduction in [issue #142](https://github.com/saphid/frame-control/issues/142)
|
||||
confirmed full Plasma Desktop Mode on the same build: the Plasma session target
|
||||
was active and the Game Mode target inactive. Desktop Mode also ran gamescope
|
||||
for `steamvr-plasma.service`, so the old process check incorrectly reported
|
||||
Game Mode. SteamOS's `steamosctl switch-to-game-mode` restored Game Mode and
|
||||
reversed the target states; battery stayed at 99%, and the rollback timer and
|
||||
shared lock were cleaned up. After #143 passed CI, the corrected code was
|
||||
tested in another full Plasma session under a three-minute rollback timer.
|
||||
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
|
||||
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
|
||||
the button. Game Mode became active and a second call made no switch. Battery
|
||||
remained at 99%; the timer and lock were removed. The literal desktop button
|
||||
was not clicked in this test, and older SteamOS builds remain fixture-tested.
|
||||
|
||||
## Back up
|
||||
|
||||
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
|
||||
(all three by default), then **Back up now**. **See what's included** lists each
|
||||
part and its size first. Backing up only reads the headset.
|
||||
|
||||
Each backup is a folder named after the time it started:
|
||||
|
||||
```
|
||||
~/Documents/Frame Control Backups/2026-10-23-142530/
|
||||
manifest.json
|
||||
saves/saves-1234.tar.gz one per game
|
||||
saves/saves-linux.tar.gz
|
||||
settings/settings-steam.tar.gz
|
||||
settings/settings-system.tar.gz
|
||||
android/org.example.game.tar.gz one per Android app
|
||||
```
|
||||
|
||||
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
|
||||
file with its size and SHA-256, and each archive's SHA-256. It also lists what
|
||||
was skipped and why. A backup is written under a hidden `.…partial` name and
|
||||
renamed when complete, so a cut-off backup never shows up as one.
|
||||
|
||||
### What's saved
|
||||
|
||||
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|
||||
|---|---|---|
|
||||
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
|
||||
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
|
||||
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
|
||||
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
|
||||
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
|
||||
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
|
||||
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
|
||||
|
||||
"Kept for reference" files are in the backup but not put back automatically:
|
||||
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
|
||||
point at apps that aren't installed after a reset. Open the backup folder to
|
||||
copy one by hand if you need it.
|
||||
|
||||
### What's never copied
|
||||
|
||||
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
|
||||
`registry.vdf` and `ssfn*` files.
|
||||
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
|
||||
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
|
||||
(`secrets.env`). These are refused by name wherever they turn up, including
|
||||
inside a game's folder, and a restore refuses them too.
|
||||
- Games, Proton and the Steam runtimes: reinstall them from Steam.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
|
||||
Android apps that are open are skipped (the helper needs the app closed); close
|
||||
them and back up again.
|
||||
|
||||
## Restore after a factory reset
|
||||
|
||||
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
|
||||
Steam with the same account.
|
||||
2. Reinstall your games, and **start each once** so Proton creates its folders.
|
||||
3. Reinstall your Android apps from Frame Control.
|
||||
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
|
||||
each part against the headset and says what it would do: how many files it
|
||||
replaces and how many are new.
|
||||
5. Tick the parts to restore and **Restore selected**. It asks before replacing
|
||||
anything.
|
||||
|
||||
Parts that can't go back yet are skipped, not failed, with the reason:
|
||||
|
||||
| Reason | What to do |
|
||||
|---|---|
|
||||
| Install the game first | Install it from Steam, then restore |
|
||||
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
|
||||
| Sign in to Steam first | Controller layouts go under your account's folder |
|
||||
| Install the app first / close the app first | Android app data needs the app installed and closed |
|
||||
| The backup file doesn't match its checksum | The archive changed since it was made |
|
||||
|
||||
A restore writes only the files in the parts you chose, and only inside that
|
||||
part's folders: it never deletes a file, and a save archive can't write outside
|
||||
its game. Before replacing a file it copies the current one to
|
||||
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
|
||||
already identical are left alone. Android app data goes through the existing
|
||||
`restore-data` helper, which keeps the previous data folder beside it.
|
||||
|
||||
## Stuck in Desktop Mode
|
||||
|
||||
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
|
||||
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
|
||||
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
|
||||
is what you see, you're already in Game Mode: close it from the Steam dashboard.
|
||||
|
||||
Full Desktop Mode is a different session. **Check** reads the main user's
|
||||
systemd session targets over SSH: `gamescope-session.target` and
|
||||
`plasma-workspace-wayland.target`. Game Mode requires the former active and the
|
||||
latter inactive; Desktop Mode requires the reverse. This takes precedence over
|
||||
process names: full Desktop Mode can run gamescope for its VR overlay, and
|
||||
Game Mode can run a nested Plasma panel. Check changes nothing.
|
||||
|
||||
Conflicting, transitional, failed or unreadable targets produce **unknown**.
|
||||
On older images where both targets are explicitly absent, the fallback accepts
|
||||
only gamescope without Plasma/nested desktop as Game Mode, or Plasma without
|
||||
gamescope/nested desktop as Desktop Mode. Mixed processes remain unknown.
|
||||
The SDDM autologin file is still reported as a login preference; it cannot
|
||||
establish which session is running.
|
||||
|
||||
After confirmation, **Return to Game Mode** runs exactly
|
||||
`steamosctl switch-to-game-mode` when available, or the older
|
||||
`holo-session-select gamescope` / `steamos-session-select gamescope` command.
|
||||
It closes the desktop and anything open in it. Frame Control never edits the
|
||||
SDDM files itself, and runs no switch when the headset is in Game Mode, when
|
||||
its mode is unknown, or when no supported switch command is installed. A
|
||||
refused command reports an error; a request being sent does not verify that
|
||||
the new session has started.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
If the button can't help, or the headset isn't reachable:
|
||||
|
||||
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
|
||||
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
|
||||
Desktop Mode session, so a restart brings Game Mode back. That the Frame
|
||||
behaves the same is **inferred**, not tested.
|
||||
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
|
||||
`ssh frame steamosctl switch-to-game-mode` (verified on SteamOS 0.5.6).
|
||||
Older images may instead provide `steamos-session-select gamescope` or
|
||||
`holo-session-select gamescope`.
|
||||
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
|
||||
button on top of Power. Choose `Current`, or `Previous` to start the other
|
||||
SteamOS slot; both keep your data. Details and the further options
|
||||
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
|
||||
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
|
||||
`Erase User Data` is a factory reset.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
|
||||
the server, deletes it and restores it. Neither involves a real headset.
|
||||
|
||||
Isolated session tests in the same suite reproduce full Plasma plus gamescope,
|
||||
Game Mode with and without the nested desktop, conflicting/transitional targets,
|
||||
missing targets on older builds, failed/timed-out systemd reads, selector
|
||||
compatibility, and the confirmed Return action with SSH mocked. Run them with
|
||||
`python3 -m unittest discover -s tests -p test_backup.py -v`.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Battery and health monitoring
|
||||
|
||||
Open **Control Center → Battery & thermal health**, or expand **System
|
||||
information**, to read the Frame's charge, charging state, signed battery
|
||||
power, battery temperature and driver-reported health. Optional readings show
|
||||
charge cycles, full charge capacity, design capacity and full / design capacity.
|
||||
The existing battery chip also shows charging state and temperature.
|
||||
|
||||
These are read-only readings from the Linux host's `/sys/class/power_supply`,
|
||||
not Android's container battery view. The probe prefers the documented Frame
|
||||
fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral
|
||||
(`scope=Device`) and absent batteries, and otherwise uses a deterministic
|
||||
battery fallback. The UI shows the selected source path.
|
||||
|
||||
| Reading | Linux attribute | Interpretation |
|
||||
|---|---|---|
|
||||
| Charge | `capacity` | Integer 0–100 percent; zero is a real reading |
|
||||
| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable |
|
||||
| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame |
|
||||
| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings |
|
||||
| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage |
|
||||
| Charge cycles | `cycle_count` | Driver-reported count, including zero |
|
||||
| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh |
|
||||
| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime |
|
||||
| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state |
|
||||
|
||||
Unavailable means the field is absent, unreadable or invalid. One missing
|
||||
sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second
|
||||
to 7 days and battery temperatures outside −273.15 to 200 °C are rejected
|
||||
as invalid readings; these bounds are not thermal warning thresholds.
|
||||
The ratio may exceed 100% because full capacity is a gauge estimate, so the app
|
||||
does not clamp it or invent a wear warning. The app does not estimate health
|
||||
from charge percentage or assign temperature thresholds. Existing thermal
|
||||
alerts use the kernel's own hot/critical trip points.
|
||||
|
||||
A connected charger does not necessarily mean charging: the Frame can still
|
||||
drain under load or sit idle near full charge. A 100% capacity reading alone
|
||||
does not prove the driver reports Full. Missing status is explicitly unknown;
|
||||
near-zero measured battery power with a charger is shown as not charging.
|
||||
Failed, stale or disconnected status samples clear Control Center readings.
|
||||
|
||||
**No charge controls are provided.** Frame's SteamOS charge-limit backend is
|
||||
unsupported; see [charge-limit research](charge-limit.md). Monitoring never
|
||||
writes to power supplies, pauses charging, changes current or sets a limit.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_battery.py` uses temporary sysfs-like files for conversion,
|
||||
malformed/missing values, permission failures, source selection and partial
|
||||
sensor support. `tests/control_center.cjs` exercises charging states, optional
|
||||
metrics and failed/stale/disconnected sample handling. These tests do not
|
||||
connect to a headset. On 2026-10-24, the PR's uninstalled parser was also
|
||||
piped over SSH to a real Frame running SteamOS 0.5.6 (build
|
||||
`20261015.6141207`). Its read-only sysfs result selected
|
||||
`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C,
|
||||
Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity
|
||||
(108.1% fuel-gauge estimate). The Control Center UI was not run against this
|
||||
headset; Windows and macOS were not tested. Optional fields are not claimed to
|
||||
be available on every firmware build.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Battery monitoring now selects the Frame fuel gauge predictably, handles
|
||||
missing or invalid readings, and shows optional cycle count, full/design
|
||||
capacity and estimated capacity health in Control Center. Unknown charging
|
||||
status stays explicit. Monitoring is read-only; charge limits remain
|
||||
unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not.
|
||||
+17
-5
@@ -3,11 +3,23 @@
|
||||
**Question:** can Frame Control set an 80% battery charge limit on the Frame
|
||||
over SSH or adb, safely?
|
||||
|
||||
**Verdict: don't build it yet; it needs a headset check.** Valve's supported
|
||||
mechanism (the one Steam's own charge-limit setting uses on the Deck) is not
|
||||
configured for the Frame. The only control the Frame seems to have is a
|
||||
vendor charger attribute that Valve's own charging daemon also manages.
|
||||
Research as of 2026-10-23. **Not tested on a headset.**
|
||||
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
|
||||
charge-limit setting uses on the Deck) is not configured for the Frame. The
|
||||
only control the Frame has is a vendor charger attribute that Valve's own
|
||||
charging daemon also manages. Research as of 2026-10-23, confirmed with the
|
||||
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
|
||||
|
||||
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
|
||||
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
|
||||
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
|
||||
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
|
||||
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
|
||||
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
|
||||
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
|
||||
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
|
||||
|
||||
For safe read-only readings in Control Center, see [battery and health
|
||||
monitoring](battery-health.md). It does not provide charging controls.
|
||||
|
||||
## How Steam does it on other devices
|
||||
|
||||
|
||||
@@ -97,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||
|
||||
## The connection, stage by stage
|
||||
|
||||
When a connection fails, the header pill keeps its compact offline state and
|
||||
retry countdown; its tooltip and connection dialog contain the full reason.
|
||||
Devices shows a short reason in the headset row (with the full reason in its
|
||||
tooltip) and a wrapping recovery explanation in the selected headset's details.
|
||||
**Use this headset** also selects that headset's details.
|
||||
|
||||
The connector runs in the server (`frame_link.Link`) and moves through:
|
||||
|
||||
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"scope": "Read-only three-sample HTML export and browser download check; no installs, app launches, or remote process-control commands",
|
||||
"preflight": {
|
||||
"smokeLock": "absent",
|
||||
"version": "0.5.6",
|
||||
"build": "20261015.6141207"
|
||||
},
|
||||
"command": "scripts/frame-power-sample.sh --count 3 --interval 1 --html /tmp/frame-power-export-check.html",
|
||||
"exitCode": 0,
|
||||
"sampleCount": 3,
|
||||
"metricCount": 91,
|
||||
"unavailableGroups": [
|
||||
"proximity"
|
||||
],
|
||||
"browser": {
|
||||
"engine": "T3 collaborative Chromium preview",
|
||||
"widths": [
|
||||
1280,
|
||||
390
|
||||
],
|
||||
"jsonDownloadMatches": true,
|
||||
"csvDownloadMatches": true,
|
||||
"csvDataRows": 276,
|
||||
"pageScripts": 0,
|
||||
"horizontalOverflow": "confined to focusable table region"
|
||||
},
|
||||
"limits": [
|
||||
"No real-device Ctrl+C cancellation test; local child termination and retained samples verified with subprocess fixtures",
|
||||
"No screen-reader, Windows or macOS verification",
|
||||
"No controlled baseline, load or worn-headset testing",
|
||||
"Sensor accuracy not established"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"osVersion": "0.5.6",
|
||||
"osBuild": "20261015.6141207",
|
||||
"commands": [
|
||||
"scripts/frame-power-sample.sh --count 3 --interval 1",
|
||||
"scripts/frame-power-sample.sh --count 1 --csv"
|
||||
],
|
||||
"jsonSampleCount": 3,
|
||||
"elapsedSeconds": [
|
||||
0.078011,
|
||||
1.088542,
|
||||
2.107291
|
||||
],
|
||||
"stopped": null,
|
||||
"unavailableGroups": [
|
||||
[
|
||||
"proximity"
|
||||
],
|
||||
[
|
||||
"proximity"
|
||||
],
|
||||
[
|
||||
"proximity"
|
||||
]
|
||||
],
|
||||
"selectedSummary": {
|
||||
"battery.percent": {
|
||||
"unit": "%",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 99,
|
||||
"max": 99,
|
||||
"mean": 99.0,
|
||||
"first": 99,
|
||||
"last": 99
|
||||
},
|
||||
"battery.status": {
|
||||
"unit": "state",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"states": [
|
||||
"Not charging"
|
||||
]
|
||||
},
|
||||
"battery.tempC": {
|
||||
"unit": "C",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 26.400000000000002,
|
||||
"max": 26.400000000000002,
|
||||
"mean": 26.4,
|
||||
"first": 26.400000000000002,
|
||||
"last": 26.400000000000002
|
||||
},
|
||||
"battery.watts": {
|
||||
"unit": "W",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": -0.12,
|
||||
"max": -0.02,
|
||||
"mean": -0.07,
|
||||
"first": -0.02,
|
||||
"last": -0.07
|
||||
},
|
||||
"cpu.percent": {
|
||||
"unit": "%",
|
||||
"sources": [
|
||||
"/proc/stat"
|
||||
],
|
||||
"available": 2,
|
||||
"unavailable": 1,
|
||||
"min": 8.7,
|
||||
"max": 11.0,
|
||||
"mean": 9.85,
|
||||
"first": 11.0,
|
||||
"last": 8.7
|
||||
},
|
||||
"fan.pwmRaw": {
|
||||
"unit": "raw",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon50/pwm1"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 41,
|
||||
"max": 41,
|
||||
"mean": 41.0,
|
||||
"first": 41,
|
||||
"last": 41
|
||||
},
|
||||
"fan.slg4ax46073v.fan1_input": {
|
||||
"unit": "RPM",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon50/fan1_input"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 8624,
|
||||
"max": 8638,
|
||||
"mean": 8628.666667,
|
||||
"first": 8638,
|
||||
"last": 8624
|
||||
},
|
||||
"proximity.raw": {
|
||||
"unit": "raw",
|
||||
"sources": [
|
||||
"/sys/bus/iio/devices/iio:device2/in_proximity_raw"
|
||||
],
|
||||
"available": 0,
|
||||
"unavailable": 3,
|
||||
"states": []
|
||||
},
|
||||
"rail.max34417_9_10.vph.power1": {
|
||||
"unit": "W",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon53/power1_input"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 4.656299,
|
||||
"max": 4.804066,
|
||||
"mean": 4.705555,
|
||||
"first": 4.656299,
|
||||
"last": 4.804066
|
||||
}
|
||||
},
|
||||
"csvRows": 92,
|
||||
"csvSummaryMetrics": 91,
|
||||
"csvHasExplicitUnavailable": true,
|
||||
"limits": [
|
||||
"Read-only short transport/parser check; no controlled baseline or worn/load/battery experiment.",
|
||||
"Proximity unavailable; numeric thermistors are not proof of sensor accuracy.",
|
||||
"No settings, services, device files or application state changed; no remote installation.",
|
||||
"No smoke lock at preflight or sampler checks; advisory checks cannot exclude a concurrent lock appearing during a read."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# Windows VM and real Frame test, 2026-10-24
|
||||
|
||||
The v0.4.10 installer could enumerate windows/screens and encode a synthetic
|
||||
Media Foundation stream, but every desktop capture failed at pipeline parsing:
|
||||
`no property "capture-api" in element "d3d11screencapturesrc"`.
|
||||
MSYS2's GStreamer 1.28.8 package disables WGC. After the fix, the same installed
|
||||
bundle captured the actual Windows desktop with Desktop Duplication and sent
|
||||
H.264 to a real Frame: 219 sent frame records, 215 decoded and drawn before the
|
||||
snapshot. Individual windows are explicitly unavailable with this bundle.
|
||||
|
||||
The base was `e9cbb8fd5555f5cfceeba1babadf2d7d35dcd3be` (`v0.4.10`, origin/main,
|
||||
including merged #121), in isolated branch `t3/test-windows-vm-headset`.
|
||||
|
||||
## Evidence
|
||||
|
||||
- [Capture before the fix](windows-vm-2026-10-24/capture-before.json): five
|
||||
synthetic H.264 frames and five JPEG frames; zero desktop/window frames,
|
||||
with the missing-property error for both codecs.
|
||||
- [Actual bundled properties](windows-vm-2026-10-24/plugin-properties.json):
|
||||
`monitor-handle` exists; `capture-api`, `window-handle`, `show-border` do not.
|
||||
[MSYS2 package build](https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-gst-plugins-bad/PKGBUILD)
|
||||
sets `-Dd3d11-wgc=disabled`. Upstream
|
||||
[installs those properties conditionally](https://github.com/GStreamer/gstreamer/blob/main/subprojects/gst-plugins-bad/sys/d3d11/gstd3d11screencapturesrc.cpp).
|
||||
- [Capture after the fix](windows-vm-2026-10-24/capture-after.json): the real
|
||||
display produces five H.264 and five JPEG frames. Window sources are hidden
|
||||
with an explicit reason, and the display selects `capture_api=dxgi`.
|
||||
- [Raw locked headset session](windows-vm-2026-10-24/headset-session.json):
|
||||
Windows helper, Media Foundation encoder, reverse SSH tunnel, production
|
||||
panel launcher and shared viewer. The image below was captured from that
|
||||
viewer's own X window on the Frame, rather than the VM's console.
|
||||
- [Windows tests](windows-vm-2026-10-24/windows-tests.txt) and
|
||||
[guest system details](windows-vm-2026-10-24/windows-system.txt).
|
||||
|
||||

|
||||
|
||||
## Host and VM prerequisites
|
||||
|
||||
lxso2: Intel Core i7-8700, 12 logical CPUs, VT-x/KVM; Docker 29.1.3 and Compose
|
||||
2.40.3. Before starting: 31 GiB RAM, 15 GiB available; 147 GiB free disk.
|
||||
Swap was already full. No other VM or service workload was started or stopped.
|
||||
Docker listed no Windows VM, and ports 2222/8006/13389 were unused.
|
||||
|
||||
The execution sandbox hid KVM and denied Docker/network access. Checks outside
|
||||
it found `/dev/kvm`, `/dev/net/tun` and the Docker daemon. Direct user access to
|
||||
KVM was denied, but a disposable Docker container with `--device /dev/kvm`
|
||||
opened it successfully; no host permissions changed.
|
||||
|
||||
Created only `frame-winvm-priority3`, labelled
|
||||
`frame-control.task=priority3-windows-test`: 4 vCPUs, 8 GiB guest RAM, 32 GiB
|
||||
growable disk, no restart, 150-second stop timeout. SSH 2222 and console 8006
|
||||
bind only 127.0.0.1; RDP is unpublished. No GPU passthrough. Image:
|
||||
`dockurr/windows@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea`
|
||||
(Windows for Docker v6.06, QEMU v11.1.1).
|
||||
|
||||
The container downloaded Windows 11 from Microsoft, with no activation changes.
|
||||
It took about 16 minutes to reach the first desktop. Guest build 26200.8037,
|
||||
Red Hat VirtIO GPU DOD driver 100.103.104.30200. The old WindowsProductName
|
||||
registry field still says Windows 10 Pro; the actual desktop/setup is Windows
|
||||
11. After installing the app, the 32 GiB disk had about 9.3 GB free.
|
||||
|
||||
Official `Frame-Control-Setup-x64.exe` v0.4.10 SHA-256:
|
||||
`30165bc5aa39f941cacc697d195dc5c30b01ad0b9ffb472da8a18a5220549202`.
|
||||
Its installer exited 0. The VM stream used an intermediate Python/UI-only
|
||||
fix with the released DLL bundle and bundled Python 3.12.14. A later CI failure
|
||||
showed that probing GStreamer through a separately loaded DLL could see a
|
||||
different, uninitialized instance. The final fix probes optional properties
|
||||
through the capture DLL itself. Windows CI checks that native change; the VM
|
||||
stream was not repeated with the final DLL.
|
||||
|
||||
OEM setup added OpenSSH Server, PowerShell and a dedicated public login key;
|
||||
password authentication is disabled. Its config needed an explicit
|
||||
`AuthorizedKeysFile C:/ProgramData/ssh/administrators_authorized_keys` before
|
||||
key login worked. The default informational share was replaced with a private
|
||||
writable staging share inside this container. These were test-VM setup steps,
|
||||
not app defects. Host setup files and the stopped VM disk are retained under
|
||||
`/tmp/frame-winvm-priority3/`; no private keys are committed or printed.
|
||||
The Frame test key was generated inside Windows and its private key stayed
|
||||
there. Only its public key was temporarily authorized on the Frame. After
|
||||
testing, the guest key and scheduled task were removed; `docker stop -t 150`
|
||||
shut down the dedicated VM. Inspection confirmed `exited`, restart `no`
|
||||
(container exit 143; logs show the ACPI shutdown signal, waiting for Windows,
|
||||
and `Shutdown completed!`). The disk is retained
|
||||
for reuse; the VM is not running.
|
||||
|
||||
## Checks
|
||||
|
||||
- Baseline PC-focused suite: 24 tests passed, one native class skipped.
|
||||
- After fix: full Linux unittest suite **1260 tests, OK, 15 skipped**, 144.366 s;
|
||||
focused final PC suite **27 tests, OK, one native class skipped**. The C
|
||||
controller's frozen 600-state trace ran on Linux.
|
||||
- Actual Windows installer bundle with the intermediate Python/UI fix:
|
||||
**33 tests, OK, four skipped** (no C compiler, Linux PipeWire test, two
|
||||
CI-only x264 tests). Its GObject property probe, host/encoder check and
|
||||
x264-absence check ran. The final native probe is checked in the PR's
|
||||
Windows CI build, not by this VM run.
|
||||
- Browser check with the real production HTML/JavaScript and mocked Windows API
|
||||
responses: DXGI whole-screen Show remains available, the limitation appears
|
||||
once, WGC window sources retain Show, off hides controls, and the limitation
|
||||
text is escaped. This used a static asset server, without headset API access.
|
||||
- `node --test app/test/*.test.js`: passed, three test files.
|
||||
- `node --test site/test/*.test.mjs`: passed, one test file.
|
||||
- The first full Linux run inside the sandbox failed (11 failures, 139 errors),
|
||||
including loopback socket `PermissionError`. The successful full runs were
|
||||
outside that socket restriction.
|
||||
- An initial guest test archive omitted `apk-catalog`, causing one import
|
||||
error; the complete archive produced the 33-test passing Windows run.
|
||||
|
||||
Raw local logs: `/tmp/frame-windows-{pc,app,site}-tests.log`,
|
||||
`/tmp/frame-windows-full-tests-unrestricted.log`,
|
||||
`/tmp/frame-windows-full-fixed-tests.log`, and
|
||||
`/tmp/frame-winvm-priority3/`. The committed artifacts above preserve the
|
||||
capture, Windows test and headset evidence independently of those local files.
|
||||
|
||||
## Shared Frame session and cleanup
|
||||
|
||||
Read-only checks first: SteamOS 0.5.6, BUILD_ID `20261015.6141207`, battery 99%,
|
||||
Not charging (another supply reports Full). The lock was free.
|
||||
|
||||
Both attempts acquired `/tmp/frame-test.lock` atomically before any key or
|
||||
viewer changes, and removed only the lock acquired by this task. The first,
|
||||
35-second attempt failed because the test wrapper sent CRLF script input and
|
||||
left SSH stdin open. Production already sends script bytes and closes stdin;
|
||||
correcting the wrapper resolved both. Its key was removed and lock released.
|
||||
|
||||
The successful session held the lock **03:09:36–03:10:05 UTC (29 seconds)**.
|
||||
It used a dedicated `pc-view-priority3` profile and port range 47960–47964,
|
||||
leaving existing viewers/profiles alone. The app's Windows `PCView` captured
|
||||
`display:65537`, 1280×800, Light preset (30 fps ceiling), and opened panel
|
||||
`valve.steam.desktopgame.2001396898`. The viewer reported H.264 software decode,
|
||||
218 received frames, 215 decoded/drawn before the snapshot (219 sent records).
|
||||
|
||||
This mostly idle, roughly ten-second sample reported 19 fps and content delay
|
||||
p50/p95 51.6/75.2 ms. These are observations, not a performance-target pass.
|
||||
|
||||
Cleanup stopped the helper and tunnel, closed the own viewer, removed its two
|
||||
possible profile paths and log, then removed the exact public-key line. The
|
||||
`authorized_keys` SHA-256 was identical before/after. A follow-up login from
|
||||
Windows with that key failed with exit 255. Separate checks found no task-owned
|
||||
viewer processes, the profile absent, and the shared lock released. Battery
|
||||
remained 99%; the sampled gamescope focus properties matched before/after.
|
||||
No reboot, global settings changes, game launch or Steam/SteamVR stop occurred.
|
||||
|
||||
## Limits
|
||||
|
||||
This verifies a Windows VM's desktop capture, Media Foundation encoding and
|
||||
real Frame viewer decode/rendering. It does not verify physical-PC hardware
|
||||
encoding, WGC or individual-window capture, mixed DPI, multiple displays,
|
||||
physical laser alignment/clicks, keyboard layouts, resize/minimise, busy-scene
|
||||
performance, bitrate/encoder restarts, or worn-headset comfort. No input was
|
||||
injected during the real Frame session. The installed app’s Tools navigation was not exercised; its streaming
|
||||
controller, tunnel and viewer were exercised directly. No independent review was performed.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windowCaptureReason": "Individual windows are unavailable in this build. Choose a whole screen instead.",
|
||||
"windows": [],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"capture_api": "dxgi"
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 65
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 113339
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"src": "window:131836",
|
||||
"id": 131836,
|
||||
"title": "Administrator: C:\\WINDOWS\\system32\\cmd.exe - powershell -noprofile -executionpolicy bypass -file \\\\host.lan\\data\\bootstrap.ps1",
|
||||
"app": "Windows",
|
||||
"w": 1115,
|
||||
"h": 628,
|
||||
"x": 51,
|
||||
"y": 52
|
||||
},
|
||||
{
|
||||
"src": "window:327926",
|
||||
"id": 327926,
|
||||
"title": "Administrator: Install",
|
||||
"app": "Windows",
|
||||
"w": 979,
|
||||
"h": 512,
|
||||
"x": 85,
|
||||
"y": 78
|
||||
},
|
||||
{
|
||||
"src": "window:65856",
|
||||
"id": 65856,
|
||||
"title": "Program Manager",
|
||||
"app": "Windows",
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"gstreamer": "1.28.8",
|
||||
"plugins": [
|
||||
"coreelements",
|
||||
"videotestsrc",
|
||||
"videoconvertscale",
|
||||
"app",
|
||||
"jpeg",
|
||||
"videoparsersbad",
|
||||
"d3d11",
|
||||
"mediafoundation"
|
||||
],
|
||||
"packages": [
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad-libs 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gstreamer 1.28.8-1"
|
||||
],
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"blocksize": "Size in bytes to read per buffer (-1 = default)",
|
||||
"num-buffers": "Number of buffers to output before sending EOS (-1 = unlimited)",
|
||||
"typefind": "Run typefind before negotiating (deprecated, non-functional)",
|
||||
"do-timestamp": "Apply current stream time to buffers",
|
||||
"automatic-eos": "Automatically EOS when the segment is done",
|
||||
"monitor-index": "Zero-based index for monitor to capture (-1 = primary monitor)",
|
||||
"monitor-handle": "A HMONITOR handle of monitor to capture",
|
||||
"show-cursor": "Whether to show mouse cursor",
|
||||
"crop-x": "Horizontal coordinate of top left corner for the screen capture area",
|
||||
"crop-y": "Vertical coordinate of top left corner for the screen capture area",
|
||||
"crop-width": "Width of screen capture area (0 = maximum)",
|
||||
"crop-height": "Height of screen capture area (0 = maximum)"
|
||||
},
|
||||
"d3d12screencapturesrc": null,
|
||||
"mfh264enc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"qos": "Handle Quality-of-Service events from downstream",
|
||||
"min-force-key-unit-interval": "Minimum interval between force-keyunit requests in nanoseconds",
|
||||
"bitrate": "Bitrate in kbit/sec",
|
||||
"rc-mode": "Rate Control Mode",
|
||||
"adaptive-mode": "Adaptive Mode",
|
||||
"vbv-buffer-size": "VBV(HRD) Buffer Size in bytes (0 = MFT default)",
|
||||
"max-bitrate": "The maximum bitrate applied when rc-mode is \"pcvbr\" in kbit/sec",
|
||||
"quality-vs-speed": "Quality and speed tradeoff, [0, 33]: Low complexity, [34, 66]: Medium complexity, [67, 100]: High complexity",
|
||||
"cabac": "Enable CABAC entropy coding",
|
||||
"sps-id": "The SPS id to use",
|
||||
"pps-id": "The PPS id to use",
|
||||
"bframes": "The maximum number of consecutive B frames",
|
||||
"gop-size": "The number of pictures from one GOP header to the next. Depending on GPU vendor implementation, zero gop-size might produce only one keyframe at the beginning (-1 for automatic)",
|
||||
"threads": "The number of worker threads used by a encoder, (0 = MFT default)",
|
||||
"content-type": "Indicates the type of video content",
|
||||
"qp": "QP applied when rc-mode is \"qvbr\"",
|
||||
"low-latency": "Enable low latency encoding",
|
||||
"min-qp": "The minimum allowed QP applied to all rc-mode",
|
||||
"max-qp": "The maximum allowed QP applied to all rc-mode",
|
||||
"qp-i": "QP applied to I frames",
|
||||
"qp-p": "QP applied to P frames",
|
||||
"qp-b": "QP applied to B frames",
|
||||
"ref": "The number of reference frames",
|
||||
"d3d11-aware": "Whether device can support Direct3D11 interop"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
WindowsProductName WindowsVersion OsBuildNumber
|
||||
------------------ -------------- -------------
|
||||
Windows 10 Pro 2009 26200
|
||||
|
||||
|
||||
|
||||
Name DriverVersion Status
|
||||
---- ------------- ------
|
||||
Red Hat VirtIO GPU DOD controller 100.103.104.30200 OK
|
||||
|
||||
|
||||
|
||||
Used Free
|
||||
---- ----
|
||||
23951253504 9314816000
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
python.exe : test_original_mac_congestion_and_recovery
|
||||
(test_pc_controller.ControllerTrace.test_original_mac_congestion_and_recovery) ... skipped 'C
|
||||
compiler not installed; native build jobs cover this trace'
|
||||
At line:12 char:1
|
||||
+ & $python -B -m unittest discover -s tests -p 'test_pc*.py' -v 2>&1 | ...
|
||||
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
+ CategoryInfo : NotSpecified: (test_original_m...ver this trace':String) [], Remote
|
||||
Exception
|
||||
+ FullyQualifiedErrorId : NativeCommandError
|
||||
|
||||
test_controller_is_inert_after_close
|
||||
(test_pcview.AdapterTests.test_controller_is_inert_after_close) ... ok
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties (test_pcview.AdapterTests.
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties) ... ok
|
||||
test_idle_source_is_not_a_stall (test_pcview.AdapterTests.test_idle_source_is_not_a_stall) ... ok
|
||||
test_library_path_prepend_adds_no_empty_entry
|
||||
(test_pcview.AdapterTests.test_library_path_prepend_adds_no_empty_entry) ... ok
|
||||
test_linux_uses_only_portal_fd_and_node
|
||||
(test_pcview.AdapterTests.test_linux_uses_only_portal_fd_and_node) ... ok
|
||||
test_malformed_viewer_input_is_dropped_not_fatal
|
||||
(test_pcview.AdapterTests.test_malformed_viewer_input_is_dropped_not_fatal) ... ok
|
||||
test_pc_card_receives_window_capture_limitation
|
||||
(test_pcview.AdapterTests.test_pc_card_receives_window_capture_limitation) ... ok
|
||||
test_pc_reuses_launcher_and_namespaces_panel_ids
|
||||
(test_pcview.AdapterTests.test_pc_reuses_launcher_and_namespaces_panel_ids) ... ok
|
||||
test_portal_input_clamps_and_releases_held_state
|
||||
(test_pcview.AdapterTests.test_portal_input_clamps_and_releases_held_state) ... ok
|
||||
test_refused_windows_focus_never_clicks_the_covering_app
|
||||
(test_pcview.AdapterTests.test_refused_windows_focus_never_clicks_the_covering_app) ... ok
|
||||
test_session_end_releases_native_input_only_once
|
||||
(test_pcview.AdapterTests.test_session_end_releases_native_input_only_once) ... ok
|
||||
test_stats_keep_capture_time_and_bound_records
|
||||
(test_pcview.AdapterTests.test_stats_keep_capture_time_and_bound_records) ... ok
|
||||
test_windows_release_and_negative_monitor_origin
|
||||
(test_pcview.AdapterTests.test_windows_release_and_negative_monitor_origin) ... ok
|
||||
test_windows_sources_follow_actual_wgc_capabilities
|
||||
(test_pcview.AdapterTests.test_windows_sources_follow_actual_wgc_capabilities) ... ok
|
||||
test_windows_uses_wgc_and_media_foundation
|
||||
(test_pcview.AdapterTests.test_windows_uses_wgc_and_media_foundation) ... ok
|
||||
test_never_offered_off_windows_even_when_turned_on
|
||||
(test_pcview.GatingTests.test_never_offered_off_windows_even_when_turned_on) ... ok
|
||||
test_off_by_default_starts_nothing (test_pcview.GatingTests.test_off_by_default_starts_nothing)
|
||||
... ok
|
||||
test_only_windows_gets_the_pc_host (test_pcview.GatingTests.test_only_windows_gets_the_pc_host)
|
||||
... ok
|
||||
test_server_enable_action (test_pcview.GatingTests.test_server_enable_action) ... ok
|
||||
test_turning_off_stops_streams_and_the_helper
|
||||
(test_pcview.GatingTests.test_turning_off_stops_streams_and_the_helper) ... ok
|
||||
test_turning_on_checks_for_the_bundled_libraries
|
||||
(test_pcview.GatingTests.test_turning_on_checks_for_the_bundled_libraries) ... ok
|
||||
test_expiry_and_stop_before_redeem (test_pcview.GrantsTests.test_expiry_and_stop_before_redeem)
|
||||
... ok
|
||||
test_source_bound_retry_ack_and_stop
|
||||
(test_pcview.GrantsTests.test_source_bound_retry_ack_and_stop) ... ok
|
||||
test_agent_reports_its_host_and_encoder
|
||||
(test_pcview.NativeTests.test_agent_reports_its_host_and_encoder) ... ok
|
||||
test_auth_and_real_h264_stats (test_pcview.NativeTests.test_auth_and_real_h264_stats) ... skipped
|
||||
'x264 is only in CI test bundles, not the installer'
|
||||
test_bundled_pipewire_context_needs_no_system_modules
|
||||
(test_pcview.NativeTests.test_bundled_pipewire_context_needs_no_system_modules) ... skipped
|
||||
'Linux PipeWire client libraries'
|
||||
test_installer_bundle_leaves_out_gpl_x264
|
||||
(test_pcview.NativeTests.test_installer_bundle_leaves_out_gpl_x264) ... ok
|
||||
test_last_raw_picture_survives_a_closed_gate
|
||||
(test_pcview.NativeTests.test_last_raw_picture_survives_a_closed_gate) ... skipped 'x264 is only
|
||||
in CI test bundles, not the installer'
|
||||
test_native_property_probe_checks_the_instantiated_element
|
||||
(test_pcview.NativeTests.test_native_property_probe_checks_the_instantiated_element) ... ok
|
||||
test_card_says_experimental_and_what_is_unverified
|
||||
(test_pcview.PageTests.test_card_says_experimental_and_what_is_unverified) ... ok
|
||||
test_opt_in_is_remembered_with_the_client_preferences
|
||||
(test_pcview.PageTests.test_opt_in_is_remembered_with_the_client_preferences) ... ok
|
||||
test_tool_link_is_windows_only_and_marked_experimental
|
||||
(test_pcview.PageTests.test_tool_link_is_windows_only_and_marked_experimental) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 33 tests in 1.684s
|
||||
|
||||
OK (skipped=4)
|
||||
@@ -62,6 +62,12 @@ refused with a message, so send big things as a file. An empty clipboard, or
|
||||
one holding an image, says so. Neither the server nor its log ever records what
|
||||
was on the clipboard.
|
||||
|
||||
**Not tested on a headset yet.** Reading uses the same Klipper interface as
|
||||
sending, which was verified on 2026-09-25, but `getClipboardContents` itself
|
||||
hasn't been tried on a Frame. The fake Frame (`tests/fakeframe`) answers it.
|
||||
**Verified on a Frame, 2026-10-24:** SteamOS 0.5.6, build
|
||||
`20261015.6141207`. With a temporary nested desktop open, Frame Control sent
|
||||
a 47-character test marker to Klipper, then **Copy the headset's clipboard**
|
||||
read it through `getClipboardContents` and set a Linux PC's X11 clipboard.
|
||||
Reading that PC clipboard returned the exact marker. The prior headset
|
||||
clipboard was restored and the test desktop was closed. The PC side used a
|
||||
temporary X display on lxso2; macOS and Windows clipboard backends, a user's
|
||||
copy action in a headset app, and image/size error cases remain untested on a
|
||||
real headset. The fake Frame (`tests/fakeframe`) covers the protocol cases.
|
||||
@@ -53,11 +53,23 @@ counts them while they run.
|
||||
`~/Videos/SteamFrame`, and **Steam clips**: export clips and background
|
||||
recordings from Steam's game recording at any length, without re-encoding.
|
||||
See [recording.md](recording.md). Not tested on a headset yet.
|
||||
- **Backup & recovery** (Tools): copy game saves, settings and Android app data
|
||||
to `~/Documents/Frame Control Backups/<time>/` with a checksummed manifest,
|
||||
restore chosen parts after a factory reset, and **Return to Game Mode** when
|
||||
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
|
||||
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
|
||||
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||
the desktop, Lepton and xrdp are running.
|
||||
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
|
||||
- **Force quit** a game that has stopped responding: Steam games, sideloaded
|
||||
titles, non-Steam shortcuts and Android apps. **Running now** at the top of
|
||||
Games lists what's running; each game's own **Force quit…** (on its library
|
||||
tile, or in a sideloaded title's ⋯ menu) and the Control Center's **Force quit
|
||||
a game** card do the same. Each asks first, because unsaved progress is lost.
|
||||
Steam is asked to quit the game, then its processes get SIGTERM and SIGKILL;
|
||||
see [steam-games.md](steam-games.md#force-quitting-a-running-game).
|
||||
- **Get games**: every game you own with its Steam Frame rating (Verified,
|
||||
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
||||
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
||||
@@ -137,6 +149,12 @@ GPU time is render time, not GPU utilisation. The temperature is the hottest
|
||||
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||
not a guessed universal temperature threshold.
|
||||
|
||||
**Force quit a game** lists what's running only when you choose **Check what's
|
||||
running**, and says so when nothing is. Each **Force quit…** names the game and
|
||||
warns that unsaved progress will be lost before anything is sent; it uses the
|
||||
selected headset, refuses a second quit while one is running, and drops the
|
||||
list when you switch headsets.
|
||||
|
||||
**Audio** controls the default output sink through the existing `/api/volume`
|
||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Frame doctor
|
||||
|
||||
Run `scripts/frame-doctor.sh` from a checkout on macOS or Linux to diagnose
|
||||
first-time setup, pairing and common Frame failures without applying repairs.
|
||||
It needs Bash, Python 3 and OpenSSH on the computer, and Python 3 on the Frame.
|
||||
Windows users can run `python scripts/frame_doctor.py` with Python and OpenSSH
|
||||
installed; the desktop app does not bundle this command.
|
||||
|
||||
```sh
|
||||
scripts/frame-doctor.sh
|
||||
scripts/frame-doctor.sh --alias bedroom --lan frame.local
|
||||
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
|
||||
```
|
||||
|
||||
Use only addresses belonging to your intended headset. The command tries the
|
||||
existing `frame` SSH alias first (Tailscale if setup configured it that way),
|
||||
then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb`
|
||||
override those defaults. It reports the successful path without printing its
|
||||
address. It neither discovers other devices nor enables Tailscale.
|
||||
|
||||
The existing alias supplies the user, port, keys and known-hosts file. LAN and
|
||||
USB overrides preserve its `HostKeyAlias`, or its configured hostname if no
|
||||
alias was set. A first connection therefore needs trusted setup first. The
|
||||
doctor never accepts a new host key, deletes a saved key, generates keys or
|
||||
sends a pairing request. It uses your trusted OpenSSH configuration; OpenSSH
|
||||
can evaluate `Match exec` conditions in that configuration. Proxy commands,
|
||||
jumps, local commands, forwarding and connection sharing are disabled for
|
||||
these direct diagnostic connections.
|
||||
|
||||
## Reading the result
|
||||
|
||||
Each of the eleven runbook sections has one line: `OK`, `WARN`, `BROKEN` or
|
||||
`UNKNOWN`. `OK` means only that the stated check passed, not that the whole
|
||||
feature works. Optional features that are off may produce a warning.
|
||||
|
||||
| Section | Read-only evidence and limits |
|
||||
|---|---|
|
||||
| 1. Boot and crash history | Previous-boot kernel-oops signatures; one day of coredump history. An oops alone does not prove an unclean reset. |
|
||||
| 2. Wi-Fi | NetworkManager's wlan0 state, without SSIDs or connection profiles. |
|
||||
| 3. Displays and SteamVR | DSI timeout signatures in the recent kernel journal; no display, compositor or game is launched. |
|
||||
| 4. Steam and storage | Free space in home and `devkit-game`, including separate mounts; missing Steam install marker. No CRC scan or restart. |
|
||||
| 5. Idle sleep and keep-awake | Existing keep-awake unit state; never takes ownership or changes idle timers. |
|
||||
| 6. Remote wake | WoWLAN state, without arming it or testing suspend. Wake with the power button. |
|
||||
| 7. Lepton | Recent port-clash, binder and data-clearing signatures; no Android/container commands. |
|
||||
| 8. Chromium XR | Crash history; no browser profile or environment inspection. |
|
||||
| 9. KDE Connect | Process presence; absence is normal when sharing is off. |
|
||||
| 10. Streaming and capture | ffmpeg crash history; no capture or encoder is started. |
|
||||
| 11. Panels | Always `UNKNOWN`: placement requires the user's visual confirmation. |
|
||||
|
||||
Journal samples are capped at 2,000 entries; current-boot samples cover the
|
||||
last ten minutes. This can miss older or high-volume failures. Empty,
|
||||
inaccessible or permission-limited journals are `UNKNOWN`, never zero errors.
|
||||
Commands time out individually, and each SSH probe is bounded to 90 seconds.
|
||||
|
||||
Exit codes: **0** if all reported checks are OK; **1** for warnings, broken or
|
||||
unknown checks after connecting; **2** if no authenticated SSH path exists or
|
||||
local SSH setup is unavailable. Panel verification currently means a connected
|
||||
run normally exits 1. Automation should read the labelled checks rather than
|
||||
treating a warning as proof the headset is unusable.
|
||||
|
||||
## Read-only repair plan and before/after reports
|
||||
|
||||
```sh
|
||||
scripts/frame-doctor.sh --plan --save-report before.json
|
||||
# Review the plan with the headset user. Any manual action is separate.
|
||||
scripts/frame-doctor.sh --plan --compare-before before.json --save-report after.json
|
||||
```
|
||||
|
||||
`--plan` prints advice; it never executes repair commands or prompts for approval.
|
||||
There is no automatic repair mode. Advice about Wi-Fi, display timeouts, low
|
||||
storage, a missing Steam install marker or keep-awake requires the exact
|
||||
recognized section, severity and evidence. Other warnings request local review;
|
||||
unknown evidence never justifies a repair. A signature or missing marker alone
|
||||
does not establish a cause. Restart risk, rising counters or unknown counters
|
||||
block all section-specific plans and direct you to the recovery runbook.
|
||||
Nothing changes passwords, headset security, pairing, services or power settings.
|
||||
|
||||
Reports are optional **local** JSON files containing only fixed diagnostic
|
||||
summaries, a format version and an opaque SSH-target configuration fingerprint.
|
||||
The fingerprint binds comparisons to the same alias, identity/routing
|
||||
configuration and fallback addresses; it does not prove physical headset
|
||||
identity or that the headset was not replaced behind that configuration. Keep
|
||||
using trusted SSH host keys. Report creation is exclusive (existing files and
|
||||
symlinks are refused), with owner-only permissions on POSIX. Protect reports
|
||||
using your local account permissions on Windows. Choose a fresh output filename
|
||||
for each run. Doctor never writes a report on the headset.
|
||||
|
||||
`--compare-before` validates a bounded baseline before connecting and refuses
|
||||
changed target configuration. It prints each section's before/after status and
|
||||
prints both fixed evidence messages when they differ, even if the status
|
||||
stayed the same. Changes are observations, not proof a repair worked or caused them;
|
||||
recent journal windows can simply age out. There is no intervening repair by
|
||||
doctor, freshness guarantee, or authenticated report signature. If either run
|
||||
skipped deeper checks, comparison is blocked and no improvement is claimed.
|
||||
Unreachable or invalid probes produce no new baseline or comparison. Malformed
|
||||
baseline/target mismatch/report-write errors exit **2**; otherwise normal
|
||||
diagnostic exit codes apply. No saved report is needed for ordinary diagnostics.
|
||||
|
||||
## First-time setup and pairing
|
||||
|
||||
- **No SSH response:** wake the headset with its power button. Check the cable
|
||||
or Steam Settings → Internet, then enable Developer Mode. Sleep and network
|
||||
failure can look alike; a timeout cannot distinguish them.
|
||||
- **Key rejected:** open **Set Up Connection** in Frame Control. In the headset,
|
||||
open Steam Settings → Developer → **Pair new host**, then approve setup's
|
||||
request. The doctor only checks whether devkit port 32000 responds; that
|
||||
does not prove pairing mode is open or the service is healthy.
|
||||
- **Unknown or changed identity:** verify the intended headset through setup
|
||||
or Devices → **Check its key**. Do not delete `known_hosts` to bypass this.
|
||||
- **No devkit response:** check Developer Mode and the address. See
|
||||
[SSH setup](ssh.md) for the manual fallback. The doctor cannot turn SSH on.
|
||||
|
||||
No password is requested. Enter any password required by the separate setup
|
||||
flow only in its local terminal; never paste it into an issue or report.
|
||||
|
||||
## Restart safety and recovery
|
||||
|
||||
Before deeper checks, and again afterwards, the doctor samples Steam and
|
||||
SteamVR `NRestarts` and health-check tracker sizes two seconds apart. Rising
|
||||
counters stop diagnostics immediately. Nonempty health-check trackers also stop
|
||||
deeper checks even when stable, since a short sample could miss a slower loop.
|
||||
Missing counters or counters that reset stop deeper checks as `UNKNOWN`. Stable
|
||||
counters with empty trackers still cannot rule out a slower restart loop.
|
||||
|
||||
The [historical runbook from PR #33](https://github.com/saphid/frame-control/blob/cce3d940302054ab5246c92d740afaefc4e58d38/docs/frame-doctor.md)
|
||||
is the source for these section numbers and observed signatures (September
|
||||
2026 builds). Its section 4 describes freezing and verifying health-check
|
||||
trackers before prolonged recovery during a live loop. That changes headset
|
||||
state, so this read-only implementation stops and directs you there instead.
|
||||
The optional plan and baseline comparison are a guarded next step for issue
|
||||
#34; automatic fixes and full recovery automation remain unimplemented. Review
|
||||
build-specific recovery instructions with the person using the headset; a repair can remove data.
|
||||
|
||||
Never reload `ath12k`, suspend remotely, kill gamescope to diagnose a loop,
|
||||
reset or force power off during extraction/repair, or write passwords to disk.
|
||||
The doctor never restarts services, signals processes, repairs Steam, changes
|
||||
WoWLAN/standby settings, takes keep-awake ownership, installs anything or writes
|
||||
a remote report. Only `--save-report` creates a local file. Nothing needs
|
||||
restoring on the headset after its checks. Manual/device tests must
|
||||
follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test).
|
||||
|
||||
## Privacy and verification
|
||||
|
||||
Only fixed diagnostic summaries leave the remote probe. Raw journals, process
|
||||
arguments, environment variables, SSH errors, account IDs, network addresses,
|
||||
SSIDs, keys and tokens are not printed. No telemetry or HTTP request is sent;
|
||||
the only pairing-service probe is a TCP connection. SSH produces normal access
|
||||
logs. Review summaries before sharing them; feature availability can still be
|
||||
personal information. Redirecting stdout or using `--save-report` saves a report
|
||||
only if you choose it.
|
||||
Treat the target fingerprint as private metadata; it is a hash, not encryption.
|
||||
|
||||
Unit tests use mocked boundaries. `scripts/e2e.sh test_doctor` covers asleep,
|
||||
disk-full, SSH/devkit-off and missing-key faults against the fake Frame. Its
|
||||
stable restart counters are explicitly simulated, not proof about SteamOS.
|
||||
On 2026-10-24 the script ran read-only against a real Frame, SteamOS build
|
||||
`20261015.6141207`. It verified the configured SSH alias and key login,
|
||||
then reported `WARN Restart safety`: nonempty health-check trackers. It
|
||||
correctly skipped all eleven deeper sections and exited 1. No raw logs,
|
||||
addresses or keys were printed, and no repair or restart was attempted.
|
||||
This verifies the safety stop on that device; the deeper checks are not
|
||||
claimed as headset-tested.
|
||||
|
||||
Follow-up verification on 2026-10-24: `--plan --save-report` and then
|
||||
`--plan --compare-before` ran read-only on the same real Frame. Both reported
|
||||
nonempty health-check trackers, skipped deeper checks and exited 1. The plan
|
||||
was blocked and the comparison reported `risk -> risk` without claiming
|
||||
improvement. No install, launch, stop or repair was performed; this validates
|
||||
the guarded report path, not the deeper checks or any manual repair.
|
||||
|
||||
The follow-up's 17 focused offline tests passed. All five doctor fake-Frame
|
||||
tests passed, including a disk-full baseline followed by fault removal and a
|
||||
fresh comparison. The final evidence-output change also passed that comparison
|
||||
test again. Because zsh is unavailable on this host, the repository's images
|
||||
were built and its Compose harness run directly under the isolated project
|
||||
`frame-doctor-plan-34`; its containers and volumes were removed afterwards.
|
||||
These storage changes were fake fault controls, not doctor repairs.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Game storage
|
||||
|
||||
Open **Tools → Game storage** for a read-only snapshot of the headset's user
|
||||
filesystem and Steam libraries. Refresh after an installation, download or move.
|
||||
The page never moves, deletes or modifies content, starts Steam, requests root,
|
||||
or recursively scans game files. It works through the existing SSH connection.
|
||||
|
||||
## What the numbers mean
|
||||
|
||||
- **Total, free and used** come from `statvfs` on the headset user's home and
|
||||
each library path. Free is space available to that user; reserved blocks mean
|
||||
used + free can be less than total. These are filesystem sizes, not advertised
|
||||
drive capacity, and do not include separate system partitions.
|
||||
- Libraries come from `~/.local/share/Steam/steamapps/libraryfolders.vdf` plus
|
||||
the default Steam library. Modern nested and older numbered-path records are
|
||||
accepted; canonical duplicate paths are shown once. No drive is mounted.
|
||||
- Two paths can share one filesystem. Their capacity must **not** be added.
|
||||
An unmounted drive whose mount directory still exists can report the parent
|
||||
filesystem's capacity: the page labels this as the filesystem containing the
|
||||
path, rather than claiming the removable drive is connected.
|
||||
- Game sizes are Steam's `SizeOnDisk` records in `appmanifest_<appid>.acf`, sorted
|
||||
largest first, with unknown sizes last. A missing, negative or invalid size
|
||||
is **Unknown**, while an explicit zero remains zero. Records may be stale,
|
||||
including during downloads or moves; they do not measure allocated blocks.
|
||||
- Games/apps and runtimes/tools have separate subtotals of **known recorded**
|
||||
sizes. Runtime classification uses names (Proton, Steam Linux Runtime,
|
||||
Steamworks Common, FEX and Lepton Development), so it is only a convenience.
|
||||
Additional apps or runtimes may fall in the game/app group.
|
||||
- Saves, Proton prefixes, shaders, workshops, downloads, Android apps,
|
||||
sideloaded games, media and OS files are **unmeasured other usage**. The page
|
||||
does not subtract manifest totals from filesystem usage to invent a size for
|
||||
them. Recorded content is not an exhaustive storage breakdown.
|
||||
|
||||
Permission denied, missing/disconnected paths, unavailable filesystem reads,
|
||||
invalid metadata and partial inventories are reported separately. A failed read
|
||||
is never proof of an empty library. If library discovery fails, other libraries
|
||||
may be missing from the view. Refresh clears the old results immediately; a
|
||||
headset switch clears the snapshot and ignores late replies from the old device.
|
||||
|
||||
## Moving a game safely
|
||||
|
||||
Use Steam's own **Settings → Storage** on the headset (or in desktop Steam):
|
||||
choose the source drive, select the games and choose **Move**, then pick a
|
||||
Steam-supported destination. A second Steam library must already be available.
|
||||
Steam may withhold Move or a destination when it cannot perform the operation.
|
||||
Let Steam finish, follow Steam's errors/recovery prompts, then Refresh here.
|
||||
Do not manually delete files to make room or interrupt an in-progress move.
|
||||
|
||||
[Valve's moving guide](https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move)
|
||||
was read in a browser on 2026-10-24 and documents this UI. This is an instructional
|
||||
handoff: the page does not send a URI or silently open a UI on the headset.
|
||||
The guide's separate Windows instructions for moving the entire Steam client
|
||||
are outside this feature's scope.
|
||||
|
||||
### Why Frame Control does not automate moving
|
||||
|
||||
The repository's Valve devkit tools register and launch developer titles; no
|
||||
supported game-move operation is provided there. The existing Steam CEF bridge
|
||||
uses internal client APIs, which are not a verified supported contract for a
|
||||
move transaction. [Valve's public ISteamApps reference](https://partner.steamgames.com/doc/api/ISteamApps) provides install-directory
|
||||
and installed-depot queries but no documented move transaction with completion,
|
||||
cancellation, rollback and error guarantees. The public support guide describes
|
||||
the user-operated Storage UI, rather than such an automation interface.
|
||||
|
||||
Consequently this feature adds no move endpoint or guessed internal Steam call,
|
||||
manifest rewrite, copy/delete fallback, or custom rollback. Automating a move
|
||||
requires a supported interface and verified behaviour for full disks, disconnects,
|
||||
running games, downloads, cancellation and Steam restart first.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
`tests/test_storage.py` runs the actual probe against temporary Steam metadata
|
||||
and filesystem/error fixtures. It covers multiple libraries, duplicate paths,
|
||||
zero/unknown/invalid sizes, corrupt manifests, unreadable directories and files,
|
||||
missing discovery/drives, reserved capacity and read-only file preservation.
|
||||
A loopback HTTP test executes the real guarded endpoint with SSH mocked.
|
||||
`tests/storage.cjs` runs the production renderer, navigation and refresh handlers
|
||||
with DOM/transport boundaries faked, including escaping, offline errors,
|
||||
out-of-order refreshes and headset switches. It runs through unittest discovery.
|
||||
|
||||
The production storage component was rendered with synthetic data at 390px and
|
||||
900px widths; both fit without horizontal overflow. This checks component
|
||||
layout, not the full connected application or headset browser.
|
||||
|
||||
On 2026-10-24 the uninstalled storage parser was piped read-only over SSH
|
||||
to a real Frame running SteamOS build `20261015.6141207`. It reported
|
||||
710.4 GiB free on the user filesystem, one available Steam library, 29
|
||||
readable app manifests, zero unknown sizes and zero parse/permission issues.
|
||||
No game names or raw metadata were printed in the test report. The
|
||||
connected UI, removable media, Steam's Move UI and an actual game move were
|
||||
not tested. UI and error-state fixtures remain synthetic.
|
||||
@@ -96,5 +96,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
|
||||
- Backing up saves and settings, restoring after a reset, leaving Desktop Mode: [backup-and-recovery.md](backup-and-recovery.md)
|
||||
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
@@ -4,7 +4,8 @@ Frame Control can show any Mac window, or a whole Mac screen, as its own panel
|
||||
in the Steam Frame. You place each panel anywhere in the room with the SteamVR
|
||||
dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
|
||||
the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
|
||||
only).
|
||||
only). On Windows there's an experimental port,
|
||||
[PC in the headset](pc-in-headset.md).
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# PC in the headset (Experimental)
|
||||
|
||||
**Tools → PC in the headset**, on Windows only. It's the Windows version of
|
||||
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Individual windows are available only
|
||||
with streaming libraries built with Windows Graphics Capture (WGC); the shipped
|
||||
MSYS2 libraries disable WGC, so this build explains that limitation and offers
|
||||
whole screens. Place the panel with the dashboard's Float
|
||||
in World, Move and Size, point and click with the laser, scroll with the
|
||||
thumbstick, and type on the PC's keyboard.
|
||||
|
||||
**It's experimental.** A Windows 11 VM has captured its real desktop with
|
||||
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
|
||||
hardware encoding and physical laser input remain unverified. See
|
||||
[the dated evidence](evidence/windows-vm-2026-10-24.md) for results and limits.
|
||||
Reports are welcome through the
|
||||
[feedback form](https://frame-control.pages.dev/feedback/).
|
||||
|
||||
## Turning it on
|
||||
|
||||
It's off by default. Until you tick **Turn on PC in the headset** on the card,
|
||||
Frame Control starts no helper, loads none of the streaming libraries and lists
|
||||
no windows. The choice is remembered on this computer. Turning it off ends
|
||||
every stream, closes the viewers in the headset and stops the helper.
|
||||
|
||||
The card and its Tools link only appear in the Windows app. On macOS the card
|
||||
is Mac in the headset, as before; on Linux and the iPhone app there's no card.
|
||||
|
||||
## How it works
|
||||
|
||||
The ported pieces are from [PR #38](https://github.com/saphid/frame-control/pull/38).
|
||||
Frame Control owns the host helper, input, authentication, streaming protocol,
|
||||
panel launcher and adaptation. It doesn't need Sunshine, OBS or any other
|
||||
streaming app. GStreamer is bundled as ordinary libraries.
|
||||
|
||||
| | Windows |
|
||||
|---|---|
|
||||
| Capture | GStreamer's `d3d11screencapturesrc`: Desktop Duplication for whole screens in the shipped build; Windows.Graphics.Capture by window (HWND) or screen (HMONITOR) when that implementation is present |
|
||||
| Encoding | Media Foundation's H.264 encoder (`mfh264enc`), low latency, no B-frames. Whether it uses the GPU depends on the PC; **not verified** |
|
||||
| Input | `SendInput`, mapped through the source's bounds, with per-monitor DPI awareness |
|
||||
|
||||
API references:
|
||||
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
|
||||
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
|
||||
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html).
|
||||
|
||||
It needs Windows 10 or 11 with Desktop Duplication and a Media Foundation
|
||||
H.264 encoder (Windows "N" editions need the Media Feature Pack). WGC-enabled
|
||||
builds can also capture individual windows. Clicks and
|
||||
keys can't reach administrator apps or the sign-in screen, which an ordinary
|
||||
process can't drive. If Windows won't bring the chosen window to the front,
|
||||
input is refused rather than sent to the app covering it: bring the window
|
||||
forward, then Stop and Show again. Minimised or closed windows stop sending
|
||||
pictures. Protected video isn't supported. Mac in the headset's "own display
|
||||
per window" is Mac-only.
|
||||
|
||||
The pieces it shares with Mac in the headset:
|
||||
|
||||
- `ui/frame_macview.py` owns the SSH tunnel, reconnects, quality presets and
|
||||
panel launch. `ui/frame_pcview.py` picks the Windows helper
|
||||
(`ui/frame_pc_agent.py`, `ui/frame_pc_capture.py`, `desktop/capture.c`) and
|
||||
holds the on/off switch. `/api/macview` is the endpoint for both.
|
||||
- `ui/mac-view.html` is the one viewer in the headset. Its frame header, H.264
|
||||
and JPEG payloads, reconnect handshake, clock sync, timing reports and input
|
||||
messages are unchanged.
|
||||
- `desktop/controller.c` is a C port of the Mac helper's Swift rate controller,
|
||||
for the Windows helper. The Mac keeps its Swift original;
|
||||
`tests/test_pc_controller.py` checks the port against a frozen 600-state
|
||||
trace from it. Capture is gated before encoding, so encoded frames are never
|
||||
dropped, and an idle window's last picture still goes out once the link
|
||||
clears.
|
||||
- Stream records use the Mac helper's JSON schema (`ui/frame_stream_stats.py`),
|
||||
so the benchmark's analysis is shared.
|
||||
|
||||
## Packaging
|
||||
|
||||
The Windows installer carries `resources/desktop/bundle`: `pc-host.dll` (our
|
||||
adapter and the controller), the GStreamer plugins it uses (`coreelements`,
|
||||
`videotestsrc`, `videoconvertscale`, `app`, `jpeg`, `videoparsersbad`,
|
||||
`d3d11`, `mediafoundation`) and the DLLs they depend on, all from MSYS2's
|
||||
UCRT64 packages. `libraries.json` records the GStreamer version, the plugins,
|
||||
each copied DLL and the exact package versions; licence texts are in
|
||||
`licenses/`.
|
||||
|
||||
MSYS2's `gst-plugins-bad` build disables `d3d11-wgc`. The presence of
|
||||
`d3d11screencapturesrc` alone does not prove WGC support: its `capture-api`,
|
||||
`window-handle` and `show-border` properties are conditional. The helper queries
|
||||
those properties on the instantiated element, advertises only supported
|
||||
sources, and omits all three properties for the DXGI whole-screen pipeline.
|
||||
It never substitutes a screen crop for an unavailable window capture.
|
||||
|
||||
The x264 encoder is **not** in the installer. It's GPL, and Windows encodes
|
||||
with Media Foundation, so only CI's test builds include it
|
||||
(`desktop/build.py --with-x264`). The macOS and Linux installers are unchanged.
|
||||
|
||||
From a source checkout on Windows, build the bundle from an MSYS2 UCRT64 shell
|
||||
with the packages listed in `.github/workflows/pc-host.yml`:
|
||||
|
||||
```sh
|
||||
python desktop/build.py # what the installer ships
|
||||
python desktop/build.py --with-x264 # for the native tests
|
||||
```
|
||||
|
||||
A missing bundle is reported on the card; nothing is downloaded on first use.
|
||||
|
||||
The Linux adapter from #38 (desktop portal, PipeWire, VA-API) is in the tree,
|
||||
because it shares `capture.c` and the controller, and CI still builds and tests
|
||||
it. The app never offers it and the Linux installers don't include it.
|
||||
|
||||
## Measuring
|
||||
|
||||
The Mac benchmark accepts the PC host:
|
||||
|
||||
```sh
|
||||
python scripts/macview-bench.py run --pc --scenario test --label pc-test
|
||||
python scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
|
||||
```
|
||||
|
||||
`capture` needs a window or screen id from the helper's `/windows` or
|
||||
`/displays`. CPU sampling isn't available on PC hosts. See
|
||||
[Mac in the headset](mac-in-headset.md) for the targets and shaping options.
|
||||
|
||||
## What's verified
|
||||
|
||||
- **Verified in CI, this PR:** on `windows-latest`, the native adapter builds
|
||||
and a real x264 synthetic stream passes the ticket, reconnect, timing and
|
||||
input-echo tests through the helper. The Windows installer builds with the
|
||||
bundle, and its bundled Python loads the bundle, starts the helper and
|
||||
confirms x264 isn't in it. Unit tests check the Windows-only, off-by-default
|
||||
gating, the card and the Tools link on macOS, Windows and Linux.
|
||||
- **Verified, #38, 2026-09-28, real Frame on SteamOS BUILD_ID 20260925.6191901:**
|
||||
the PC helper (ARM64, on the Frame itself, x264 test pattern) streamed
|
||||
through a Mac SSH relay into the shared viewer: 400 frames at 39.3 fps,
|
||||
content latency p50/p95 15.1/28.6 ms, synthetic input p50 76.6 ms, at
|
||||
revision `cd20243`. The frame-rate and late-frame targets failed. This
|
||||
proves the helper and the viewer work together; it isn't Windows capture or
|
||||
a real PC link.
|
||||
[Raw result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json),
|
||||
[earlier probe](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
|
||||
- **Verified, #38, on a Mac:** the C controller matched all 600 states of the
|
||||
Swift controller's congestion and recovery trace.
|
||||
- **Verified, 2026-10-24:** Windows 11 VM build 26200.8037 on lxso2, using
|
||||
the v0.4.10 installer with this fix, captured its actual desktop and streamed
|
||||
H.264 to Frame BUILD_ID `20261015.6141207`: 219 sent frame records, 215 decoded
|
||||
and drawn before the snapshot. The own-panel screenshot shows the Windows
|
||||
desktop. This short, mostly idle run does not establish performance targets.
|
||||
[Evidence, raw traces and cleanup](evidence/windows-vm-2026-10-24.md).
|
||||
- **Not verified:** Windows.Graphics.Capture, physical-PC capture, hardware
|
||||
Media Foundation encoding, encoder restarts when the bitrate
|
||||
changes, mixed-DPI and multi-monitor input alignment, window resize and
|
||||
minimise, non-US keyboard layouts, physical laser input, and how it performs
|
||||
in a worn headset. No numbers are claimed for these. Not tested with a
|
||||
worn headset. See the dated VM evidence for the separate automated checks.
|
||||
- **Not reviewed independently.** #38 tried an independent review that never
|
||||
completed.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Read-only power and heat measurements
|
||||
|
||||
Issue [#52](https://github.com/saphid/frame-control/issues/52) starts with
|
||||
measurement. `scripts/frame-power-sample.sh` is a bounded, on-demand sampler
|
||||
over the existing `ssh frame` alias. It needs Python 3 on both computers and
|
||||
an already paired, awake, reachable Frame. It uses strict SSH host-key checking
|
||||
and batch mode; it never pairs, prompts for a password or changes SSH security.
|
||||
|
||||
```sh
|
||||
# Twelve samples, five seconds apart (about 55 seconds), JSON report:
|
||||
scripts/frame-power-sample.sh > power.json
|
||||
|
||||
# Five-minute observation: samples at 0, 5, ... 300 seconds:
|
||||
scripts/frame-power-sample.sh --count 61 --interval 5 > baseline.json
|
||||
|
||||
# Long-form CSV samples; JSON summary goes separately to stderr:
|
||||
scripts/frame-power-sample.sh --count 12 --interval 5 --csv > power.csv 2> summary.json
|
||||
```
|
||||
|
||||
Count is limited to 1–720; interval to finite values from 1–60 seconds. These
|
||||
are target start-to-start intervals, not guarantees: read latency can delay a
|
||||
sample. Wall-clock `time` and monotonic `elapsedSeconds` record actual timing.
|
||||
The first sample has no CPU utilization: two valid `/proc/stat` observations
|
||||
are required. CPU utilization then covers the interval between observations,
|
||||
averaged across the machine, rather than the utilization of one process.
|
||||
|
||||
## Start, stop and open an accessible report
|
||||
|
||||
From a source checkout with Python 3 and your already trusted `ssh frame`
|
||||
connection, start a local observation:
|
||||
|
||||
```sh
|
||||
scripts/frame-power-sample.sh --count 12 --interval 5 --html power-report.html
|
||||
```
|
||||
|
||||
Progress is printed on your computer as each sample arrives. Wait for completion,
|
||||
or press **Ctrl+C once** to stop local collection and save the samples already
|
||||
received. Open `power-report.html` in your browser using its Open File command.
|
||||
The file is self-contained and works offline: no server, scripts, external
|
||||
resources, account or upload. It includes a table with named metrics, source
|
||||
paths, units, minimum/mean/maximum/last values, and available/unknown counts.
|
||||
Keyboard users can focus the table region to scroll through its columns.
|
||||
Charging states and entirely unavailable sensor families remain explicit.
|
||||
Use **Download JSON samples and summary** or **Download CSV samples** to save
|
||||
raw measurements. The HTML also embeds those measurements; review it before
|
||||
sharing. No automatic browser launch occurs.
|
||||
|
||||
HTML sessions are limited to `count × interval <= 600` seconds, with a 30-second
|
||||
transport allowance and at most nine seconds of local child/reader cleanup. The default
|
||||
12 samples at five-second intervals take about 55 seconds. Existing output files
|
||||
are never overwritten; choose a new filename for each run. `--html` cannot be
|
||||
combined with `--csv`. This source-checkout flow is not a desktop Tools panel
|
||||
and is not added to the packaged app.
|
||||
|
||||
Stop, timeout, SSH failure or a smoke-test lock marks the report incomplete;
|
||||
earlier received samples remain downloadable. Exit status is 75 for incomplete
|
||||
reports, 0 for completion. A connection that cannot start may instead return 1
|
||||
without an HTML file. Empty reports clearly show zero samples, not a successful
|
||||
measurement. If saving the file fails, received JSON is printed on stdout as a
|
||||
recovery path. Do not treat a partial report as a full baseline.
|
||||
|
||||
Stopping terminates only this command's local SSH child. It sends no remote
|
||||
process-control command. The bounded remote Python reader exits when it next
|
||||
writes to the closed connection (normally at the next sample, up to one interval
|
||||
later); it can still read sensors before discovering the disconnect. Stop is
|
||||
not an immediate headset-side cancellation guarantee. The reader still checks
|
||||
the smoke-test lock before every sample. No lock is acquired or removed by this
|
||||
flow, and no power, thermal, service, password or security controls are exposed.
|
||||
|
||||
## What it reads
|
||||
|
||||
- Battery percentage, driver charging state, signed battery watts and battery
|
||||
temperature, using the same parser as Frame Control's battery details.
|
||||
Positive battery watts mean charging on the Frame fuel gauge; negative means
|
||||
discharge. `Not charging` alone does not prove a charger is connected.
|
||||
- CPU utilization from `/proc/stat`; GPU clock from the known Frame devfreq path.
|
||||
- Named thermal zones in °C, with zone names retained. A thermal zone is not
|
||||
automatically a CPU sensor; there is no inferred “CPU temperature” or safe
|
||||
temperature threshold.
|
||||
- hwmon power channels, labels and driver names in watts. `vph` is the measured
|
||||
system rail, not power at the wall. CPU `apc*` and GPU `gfx` rails are separate
|
||||
readings; do not sum all channels as if they were independent power consumers.
|
||||
- Fan RPM and the Frame fan driver's raw `pwm1`. No generic percentage conversion
|
||||
is assumed. The historical Frame driver reported a percent-like value of 41.
|
||||
- Named IIO board temperatures, PM8550B input/charge current in amps, and raw
|
||||
VCNL4000 proximity if the account can read it. Raw proximity has no inferred
|
||||
worn/unworn threshold.
|
||||
|
||||
Each metric includes its unit, source path and availability state. Missing,
|
||||
unreadable or invalid values are JSON `null`, not zero. A missing sensor family
|
||||
is also listed in each sample's `unavailableGroups`; CSV adds unavailable group
|
||||
rows. Numeric zero remains a valid reading. The OS build is recorded per sample
|
||||
when `/etc/os-release` is readable; hostnames, addresses, machine IDs, process
|
||||
lists, logs, screenshots and application inventories are not collected.
|
||||
|
||||
The JSON report contains raw samples and a summary per metric: available and
|
||||
unavailable counts, numeric minimum/maximum/arithmetic mean/first/last, or the
|
||||
observed charging states. Means exclude unavailable readings. They are sample
|
||||
means, not time-weighted energy or battery-runtime estimates. Different sample
|
||||
availability and intervals must be considered when comparing sessions.
|
||||
|
||||
Numeric validity does not establish sensor accuracy. Historical readings
|
||||
already showed an apparently absent/broken right-display thermistor near
|
||||
−16 °C. This firmware also reports one charger-lite die sensor at −60 °C.
|
||||
Those raw numbers remain visible for diagnosis; neither should be used to
|
||||
infer cooling performance or safety. This tool does not diagnose heat, explain
|
||||
a spike, predict battery health, or promise power savings.
|
||||
|
||||
## Safety and shared-device use
|
||||
|
||||
Only file reads under `/proc`, `/sys` and `/etc/os-release` run on the Frame.
|
||||
The Python sources are sent on SSH stdin and not installed on the headset.
|
||||
There is no sudo, OpenVR initialization, service call, settings write, process
|
||||
control, journal query, wake, suspend or reboot. No daemon, telemetry upload or
|
||||
automatic measurement is added. Redirects above write only to your computer.
|
||||
The observer itself uses a little CPU and SSH traffic, so label baseline
|
||||
conditions and avoid interpreting very small changes as savings.
|
||||
|
||||
The sampler checks `/tmp/frame-test.lock` on the computer before SSH, and on
|
||||
the Frame before any probe and before each sample. If a smoke session is active,
|
||||
it exits 75. A lock appearing mid-run stops the next sample, retains earlier
|
||||
samples, sets `stopped`, and exits 75. It never removes or acquires another
|
||||
session's lock. This is an advisory check, not an atomic lease: a smoke session
|
||||
can start during a file read. Coordinate sessions and defer measurement when
|
||||
timing overlaps. It does not block someone else from starting a smoke test.
|
||||
|
||||
SSH failures/timeouts return a nonzero exit and an explanation, without
|
||||
presenting a zero or an old report as a successful measurement. Fully missing
|
||||
sensors can still produce a successful report with explicit unavailable states.
|
||||
On a connection failure, partial samples buffered on the headset can be lost.
|
||||
|
||||
## Evidence and remaining work
|
||||
|
||||
Read-only checks on **2026-10-24**, SteamOS **0.5.6**, build
|
||||
**20261015.6141207**, confirmed battery, CPU ticks, thermal zones, rail power,
|
||||
fan, GPU clock, board temperatures and charger current through the final
|
||||
sampler. Proximity was unavailable. Both JSON and CSV paths were exercised;
|
||||
see [the recorded results](evidence/power-sampler-2026-10-24.json).
|
||||
No smoke lock was present at the preflight or sampler checks. No settings,
|
||||
services, application state or device files were changed. This short run is
|
||||
a parser/transport check, not a controlled baseline or a worn-headset test.
|
||||
|
||||
The original planning document is in commit `fe132e3` (2026-09-29). It recorded
|
||||
an off-head, charging idle baseline of about 4.71 W, CPU zones around 40–45 °C,
|
||||
and about 8,350 fan RPM on build 20260925.6191901. Those conditions and firmware
|
||||
differ from the current check; comparisons do not establish a regression.
|
||||
|
||||
This change completes only the sampler portion of “Plan: leave it on, but
|
||||
cheaply.” Controlled worn/home-space, streaming/game load and unplugged
|
||||
baselines, and the cause of the 2026-09-28 95 °C spike, remain uninvestigated.
|
||||
Quiet mode, compositor changes, CPU/fan/charging changes and remote-wake tests
|
||||
are separate work. No controls are offered here. Follow the shared-device
|
||||
procedure in [testing.md](testing.md#headset-smoke-test) for future experiments.
|
||||
|
||||
The accessible HTML export was checked on the same build on **2026-10-24**:
|
||||
three streamed samples, 91 metric summaries and 276 CSV data rows. Chromium
|
||||
preview checks at 1280 px and 390 px confirmed the table stays in its scroll
|
||||
region; both browser downloads matched the original samples. See
|
||||
[export evidence](evidence/power-export-2026-10-24.json). Ctrl+C retention and
|
||||
local process cleanup were tested with local subprocess fixtures, not on the
|
||||
real headset. Screen-reader, Windows and macOS checks were not performed.
|
||||
|
||||
Offline validation:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p 'test_power*.py' -v
|
||||
bash -n scripts/frame-power-sample.sh
|
||||
python3 -m py_compile scripts/frame_power_sample.py scripts/frame_power_report.py
|
||||
```
|
||||
|
||||
Fixtures cover units, signed and zero values, missing/malformed/permission-denied
|
||||
readings, named/discovered channels, unknown charging state, CPU warmup,
|
||||
summary counts, CSV quoting, strict SSH transport and smoke-lock interruption.
|
||||
|
||||
Export fixtures also cover HTML escaping, download round trips, zero versus unknown,
|
||||
stream completion/failure, smoke-lock stop, timeout, Ctrl+C retention, local child
|
||||
cleanup, duration bounds and refusing to overwrite reports.
|
||||
+5
-3
@@ -203,9 +203,11 @@ and display name of the headset being tried (whole, whatever their case), and
|
||||
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
|
||||
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
|
||||
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
|
||||
in some other wording can still get through, so check the log lines in **Show
|
||||
exactly what's included** before sending. A failure that repeats on every
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. Whole display names
|
||||
are hidden even when they contain a scrubber token such as `<host>` or overlap
|
||||
ssh's wording. Those overlaps also preserve redaction of URLs, home folders,
|
||||
and keys. A host name ssh mentions in some other wording can still get through,
|
||||
so check the log lines in **Show exactly what's included** before sending. A failure that repeats on every
|
||||
retry is written at most once every 5 minutes.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
|
||||
+17
-10
@@ -8,10 +8,13 @@ Both are on **Tools → Capture & Live**. Everything is saved to
|
||||
Nothing needs installing on the computer or on the Frame: the work is done by
|
||||
the `ffmpeg` that SteamOS ships.
|
||||
|
||||
**Not tested on a headset yet.** This is built from what the code already does
|
||||
on the Frame (the live view, the media player) and from how Steam is reported
|
||||
to store its recordings. The parts we're least sure of are listed under
|
||||
[What hasn't been checked](#what-hasnt-been-checked). Please report what you see.
|
||||
**Recording is tested on a headset; Steam clip export isn't yet.** On a Frame
|
||||
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
|
||||
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
|
||||
second, and nothing was left running or stored on the Frame. That Frame had no
|
||||
Steam recordings, so the clip list came back empty and export has only been
|
||||
tested with files in Steam's format made by `ffmpeg`. The parts we're least sure
|
||||
of are listed under [What hasn't been checked](#what-hasnt-been-checked).
|
||||
|
||||
## Record
|
||||
|
||||
@@ -36,8 +39,11 @@ the Frame, converted to `.mp4` there (without re-encoding) and copied to
|
||||
- **Where it waits on the Frame**: `~/.cache/frame-control/video/`, not `/tmp`,
|
||||
which is a RAM disk on SteamOS. It's deleted once copied.
|
||||
- **If the connection drops or the app quits** while recording, the Frame
|
||||
stops `ffmpeg` cleanly when the SSH channel closes, as the live view does. A
|
||||
recording that was never copied is deleted when the next one starts.
|
||||
stops `ffmpeg` cleanly when the SSH channel closes: it types `q` into
|
||||
`ffmpeg`'s input, as at its keyboard, and kills it if it's still there 10
|
||||
seconds later. Not a signal: SteamOS's `ffmpeg` 7.0 catches SIGTERM and
|
||||
SIGINT and carries on recording. A recording that was never copied is
|
||||
deleted when the next one starts.
|
||||
|
||||
Recording and **Live** can run together; both read the same device.
|
||||
|
||||
@@ -143,11 +149,12 @@ recording with Steam's file names and export it with the real `ffmpeg`.
|
||||
|
||||
## What hasn't been checked
|
||||
|
||||
None of this has run on a Steam Frame yet. In particular:
|
||||
The 2026-10-24 read-only clip list on build `20261015.6141207` again returned
|
||||
zero clips: its `gamerecordings` directory held only `gamerecording.pb`. No
|
||||
Steam recording was created during that check, so a real Steam clip export is
|
||||
still unverified. The recording and Stop path were verified earlier on that
|
||||
build, as described above. Remaining checks:
|
||||
|
||||
- That SteamOS's `ffmpeg` can read `@DEFAULT_MONITOR@`. It has the PulseAudio
|
||||
output device (the media player plays through it), so the input is expected,
|
||||
but if it fails the recording is video only, as above.
|
||||
- Audio and video sync. Both inputs are stamped with the wall clock, because
|
||||
the camera device and the audio server otherwise count from different clocks.
|
||||
- The exact layout of `gamerecordings` on the Frame's SteamOS build, and
|
||||
|
||||
@@ -4,6 +4,9 @@ Where to get the Steam Frame's operating system, what's inside it, and how to
|
||||
run it for testing without the headset. For recovering a Frame that won't boot,
|
||||
see the boot menu and boot-loop entries in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
|
||||
To keep your game saves, settings and Android app data through a factory reset
|
||||
(`Erase User Data`) or a re-image, and to get out of Desktop Mode, see
|
||||
[backup-and-recovery.md](backup-and-recovery.md).
|
||||
|
||||
## Downloads
|
||||
|
||||
|
||||
+4
-1
@@ -14,7 +14,10 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
|
||||
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
|
||||
attaches everything to a **draft** release for that tag. Nobody is
|
||||
offered a draft.
|
||||
offered a draft. Editing a draft while installers are uploading can make
|
||||
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
|
||||
draft by its exact version title, restores the tag association and retries
|
||||
the affected upload; it refuses ambiguous matches.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
@@ -7,6 +7,10 @@ run on Linux. On Windows, use the app.
|
||||
|
||||
## Minimum typing on the headset
|
||||
|
||||
If setup or pairing fails, run `scripts/frame-doctor.sh` from this checkout.
|
||||
It checks existing SSH access without requesting passwords, pairing or applying
|
||||
repairs. See the [Frame doctor guide](frame-doctor.md) for results and next steps.
|
||||
|
||||
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
|
||||
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
|
||||
thing you type on the headset is a password you choose.
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ it.) A dialog shows:
|
||||
|
||||
Install copies it to the Frame and registers it with Steam; progress shows in
|
||||
the bar and the activity log. **Sideloaded titles** lists what's installed,
|
||||
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
|
||||
with Launch, Remove and, for a game that has stopped responding, **Force
|
||||
quit…** in its ⋯ menu ([how](steam-games.md#force-quitting-a-running-game)). **Copy to ~/Downloads instead** keeps the old
|
||||
behaviour for a zip that isn't a game.
|
||||
|
||||
From a terminal:
|
||||
|
||||
@@ -101,6 +101,62 @@ Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
|
||||
returns nothing without `cc`, so Frame Control takes the country from
|
||||
`SteamClient.User.GetIPCountry()` on the Frame.
|
||||
|
||||
## Force quitting a running game
|
||||
|
||||
**Running now**, at the top of Games, lists what's running on the Frame: Steam
|
||||
games, sideloaded titles, non-Steam shortcuts and Android apps. A game's library
|
||||
tile, a sideloaded title's ⋯ menu and the Control Center's **Force quit a game**
|
||||
card have **Force quit…** too. Each asks first ("Unsaved progress will be
|
||||
lost"); from a game's own menu it checks that the game is running and says so if
|
||||
it isn't. SteamVR (app 250820, and its beta 1009850) is never listed or quit:
|
||||
on the Frame it shows the headset view, so quitting it isn't a way out of a game.
|
||||
|
||||
`ui/frame_running.py` does the work on the Frame, piped over SSH with
|
||||
`frame_steam.py` as its DevTools client:
|
||||
|
||||
```sh
|
||||
scp ui/frame_steam.py ui/frame_running.py frame:/tmp/
|
||||
ssh frame 'python3 /tmp/frame_running.py list' # {"running": [{appid, gameid, name, kind, ...}]}
|
||||
ssh frame 'python3 /tmp/frame_running.py quit 620' # {"name", "method", "message"}
|
||||
```
|
||||
|
||||
1. **What's running.** Steam starts every game, shortcut and devkit title under
|
||||
its `reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper
|
||||
stays the parent of the game's processes. So a running game is one of your
|
||||
own processes whose program is named `reaper`, with `SteamLaunch` and an
|
||||
`AppId=` before `--`; the game is that reaper's process tree. Steam's own list
|
||||
(`SteamUIStore.RunningApps`) and Android apps whose Lepton container is up
|
||||
(`podman ps`) are added. Names come from `appStore`, else the app manifest,
|
||||
the Android app's `meta.json` or the devkit title's folder.
|
||||
2. **Steam first.** `SteamClient.Apps.TerminateApp(<game id>, false)`, the
|
||||
call Steam's own Stop makes for Android apps ([vr-apks.md](vr-apks.md)). The
|
||||
game id is the appid, or `(appid << 32) | 0x02000000` for a non-Steam
|
||||
shortcut. An Android app's container is also stopped, as the Apps tab's Stop
|
||||
does (`podman stop -t 5 lepton-steamlaunch-<instance>`).
|
||||
3. **Then signals.** If the game's processes are still there after 5 seconds,
|
||||
they get SIGTERM, deepest first; whatever is left 3 seconds later gets
|
||||
SIGKILL, the reaper last, so nothing it holds is orphaned unsignalled. A pid
|
||||
is only signalled while its start time still matches, so a reused pid is safe.
|
||||
|
||||
Nothing is found by name. These are never signalled, even inside a game's tree:
|
||||
init, the helper itself and every process above it (the SSH session), other
|
||||
users' processes, and anything named `steam`, `steamwebhelper`, `gamescope`,
|
||||
`gamescope-wl`, `gamescopereaper`, `gamescope-session`, `sshd`, `sshd-session`,
|
||||
`ssh-agent`, `systemd`, `dbus-daemon`, `dbus-broker`, `pipewire`,
|
||||
`pipewire-pulse`, `wireplumber`, `plasmashell`, `kwin_wayland`, `kwin_x11`,
|
||||
`Xwayland`, `sddm`, `vrserver`, `vrcompositor`, `vrmonitor`, `vrdashboard`,
|
||||
`vrwebhelper` or `vrstartup`, with everything under them.
|
||||
|
||||
**Not tested on a headset.** The reaper command line is how Steam launches games
|
||||
on Linux desktops and the Steam Deck, **inferred** for the Frame.
|
||||
`SteamUIStore.RunningApps` comes from Steam's client code as plugin loaders use
|
||||
it and hasn't been seen on the Frame's build; without it, the reapers alone
|
||||
decide. `TerminateApp` with a shortcut's game id is the call Steam's Stop was
|
||||
seen making for an Android app (docs/vr-apks.md); for Steam games it is
|
||||
**inferred**. Unit tests (`tests/test_force_quit.py`) cover the parsing, the
|
||||
process selection and the escalation; the fake Frame launches its games under a
|
||||
stand-in `reaper` so `tests/e2e/test_running.py` force quits a real process tree.
|
||||
|
||||
## Not yet checked
|
||||
|
||||
- Free-to-play games: `steam://install` should stop at state 3 (free license)
|
||||
|
||||
+153
-6
@@ -12,6 +12,12 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
|
||||
## Unit tests
|
||||
|
||||
The read-only power/heat sampler has fixture and transport tests in
|
||||
`tests/test_power_sample.py`. Run
|
||||
`python3 -m unittest discover -s tests -p test_power_sample.py -v`.
|
||||
See [power-and-heat.md](power-and-heat.md) for sampling, smoke-lock behavior and
|
||||
real-device verification limits.
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
@@ -44,6 +50,23 @@ them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
## The fake Frame
|
||||
|
||||
Frame doctor has focused offline tests in `tests/test_frame_doctor.py` and
|
||||
fake-Frame fault tests in `tests/e2e/test_doctor.py`:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_frame_doctor.py -v
|
||||
scripts/e2e.sh test_doctor
|
||||
```
|
||||
|
||||
They cover privacy filtering, host-key preservation, restart safety and missing
|
||||
journal evidence, plus asleep, full-title-storage, SSH/devkit-off and missing-key
|
||||
faults. The plan/report tests cover exact-evidence advice, blocked plans,
|
||||
private exclusive baselines, malformed input, target mismatches and comparisons
|
||||
without repair claims. The fake-Frame storage test records a full-disk baseline,
|
||||
clears only the fake fault and compares a fresh run. The fake's doctor-only
|
||||
`systemctl show` responses assume stable restart counters and inactive keep-awake; unsupported queries fail. These tests do not
|
||||
exercise a real SteamOS user manager or touch a headset.
|
||||
|
||||
`tests/fakeframe/` builds a container that stands in for the headset, and a
|
||||
second one for the computer Frame Control runs on. `scripts/e2e.sh` builds
|
||||
both, starts them with `docker compose`, runs `tests/e2e` in the host
|
||||
@@ -76,8 +99,8 @@ its guards want.
|
||||
| `sshd` with key and password logins, `rsync`, `python3` | Real |
|
||||
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
|
||||
| Valve's devkit-utils, copied over by Frame Control itself | Real |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs. Games it really runs start under a stand-in `reaper SteamLaunch AppId=<id> -- <command>` (bash with that command line), so force quit finds a real process tree | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF. `SteamUIStore.RunningApps` lists launches that haven't exited; `TerminateApp` ends only launches that never ran a process, so a running program needs Frame Control's signals | The JS engine is real; the objects are fake |
|
||||
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
|
||||
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
|
||||
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
|
||||
@@ -164,6 +187,43 @@ does). A cleanup that fails counts as a failed step. Results go to
|
||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||
isn't reachable.
|
||||
|
||||
Smoke process cleanup runs before and after title removal and during the
|
||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
|
||||
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
|
||||
an exact title directory in `/proc` cwd/exe or an individual absolute command
|
||||
argument; similarly named folders and shell command substrings do not count.
|
||||
Deleted cwd/exe paths still count, so a process left after file removal can be
|
||||
found. It sends SIGTERM, waits up to three seconds, then uses SIGKILL with
|
||||
another three-second bound, rescanning for survivors. PID handles prevent PID
|
||||
reuse from signalling another process. Linux Python 3.9+ with pidfd support
|
||||
is required; missing support, signal errors and survivors fail the
|
||||
cleanup step. Stopped PIDs are recorded in the results JSON.
|
||||
If the initial leftover cleanup fails, the run skips installs and pairing and
|
||||
records the failure instead of overwriting an active test title.
|
||||
|
||||
This deliberately does not stop shared Wine/Proton services by name or prefix,
|
||||
Steam, SteamVR, process groups or arbitrary descendants. A process that drops
|
||||
all title-path evidence cannot safely be attributed and is outside this
|
||||
check. Unreadable `/proc` fields provide no ownership evidence; other readable
|
||||
fields are still checked, and a wholly unattributable process is left alone.
|
||||
Real Frame verification, 2026-10-24, SteamOS build `20261015.6141207`: the
|
||||
fixed Python smoke entry point passed all 13 steps under the shared device lock.
|
||||
The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
|
||||
remove step stopped five test-owned wrapper/Proton PIDs before removing the
|
||||
title; the following process sweep found none. The final cleanup found no
|
||||
test titles or processes, the test desktop was closed, and the battery
|
||||
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
|
||||
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
|
||||
the same entry point instead.
|
||||
The local regression suite runs real Linux processes in temporary fake title
|
||||
directories (including deleted folders and a TERM-resistant wrapper); it checks
|
||||
exit, escalation and survival of unrelated processes without SSH or a headset:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
|
||||
```
|
||||
|
||||
`--pair` asks the devkit service to pair a new RSA key, which needs someone
|
||||
in the headset to open **Settings → Developer → Pair new host** and approve
|
||||
it; the key is checked and then taken out of `authorized_keys` again.
|
||||
@@ -187,11 +247,36 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||
Steam accepts.
|
||||
|
||||
## PC panel host tests
|
||||
|
||||
PC in the headset (Experimental, Windows only). `tests/test_pcview.py` checks
|
||||
that only Windows gets the PC host and that it starts nothing until turned on,
|
||||
the Tools link and card wording, source-bound tickets, reconnect/Stop
|
||||
revocation, input release, source-coordinate mapping and use of the existing
|
||||
panel launcher. `tests/test_pc_controller.py` checks the C rate controller
|
||||
against a frozen trace of the Mac's Swift one.
|
||||
|
||||
The `PC host libraries` workflow builds the native adapter on Windows (and
|
||||
the unshipped Linux adapter on Ubuntu x64 and ARM64), then runs a real x264
|
||||
synthetic stream through the HTTP/WebSocket agent. x264 is GPL, so only these
|
||||
CI builds include it (`desktop/build.py --with-x264`); the release workflow
|
||||
tests the installer's own bundle with its bundled Python and checks x264 isn't
|
||||
in it. `FRAME_PC_REQUIRE_NATIVE=1` and `FRAME_PC_REQUIRE_X264=1` make a missing
|
||||
bundle or encoder fail CI instead of skipping.
|
||||
|
||||
These automated CI checks do not capture a desktop, exercise Media Foundation
|
||||
or a GPU, or check laser alignment. A [Windows test VM](#windows-test-vm) run
|
||||
on 2026-10-24 captured its actual desktop with Desktop Duplication, encoded
|
||||
with Media Foundation and streamed to a real Frame. It found MSYS2 disables
|
||||
WGC: an element name alone does not prove its optional capture properties
|
||||
exist. Native property-probe tests and source-selection regressions now cover
|
||||
that boundary. [Dated evidence and limits](evidence/windows-vm-2026-10-24.md);
|
||||
[what's verified](pc-in-headset.md#whats-verified).
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
depends on the Windows desktop (Remote Desktop, the installer, the bundled
|
||||
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
|
||||
Windows CI runs the unit tests and checks installer contents. Desktop capture,
|
||||
installer launch and interactive behavior need a Windows machine. This is a Windows 11 VM in a
|
||||
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
|
||||
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
|
||||
|
||||
@@ -211,7 +296,14 @@ Setting it up, once, on the Linux machine:
|
||||
`~/.ssh/id_ed25519_winvm` on the Mac) in
|
||||
`C:\ProgramData\ssh\administrators_authorized_keys`.
|
||||
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
|
||||
and the tests.
|
||||
and the tests. Inspect free memory/disk, KVM access and existing VMs before
|
||||
starting; leave other VMs alone. The first install can take tens of minutes
|
||||
(the 2026-10-24 run took about 16 minutes to reach the desktop), rather than
|
||||
the 1–2 minute startup of an already installed guest.
|
||||
- For PC streaming, check the actual bundled capture properties. MSYS2's
|
||||
GStreamer build disables WGC: this app falls back to Desktop Duplication for
|
||||
whole screens and explains why individual windows are unavailable. A VM
|
||||
capture test does not prove physical GPU encoding or laser alignment.
|
||||
|
||||
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
|
||||
|
||||
@@ -267,6 +359,17 @@ connect it to the Frame and follow Remote Desktop through to the Frame's
|
||||
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
|
||||
`frame-key` left `authorized_keys` byte-for-byte as it was.
|
||||
|
||||
## Force quit
|
||||
|
||||
`tests/test_force_quit.py` feeds `ui/frame_running.py` made-up process tables:
|
||||
the `reaper SteamLaunch AppId=` parsing, naming from Steam, manifests, Android
|
||||
`meta.json` and devkit folders, and a Frame's worth of Steam, gamescope, SteamVR,
|
||||
sshd and session processes that must never be signalled. Processes that ignore
|
||||
SIGTERM, survive SIGKILL or exit and have their pid reused check the escalation.
|
||||
The Control Center's confirmation, duplicate and headset-switch handling run in
|
||||
`tests/control_center.cjs`. `tests/e2e/test_running.py` force quits real process
|
||||
trees on the fake Frame. None of it has run on a headset.
|
||||
|
||||
## Owned media player
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
@@ -314,3 +417,47 @@ Chromium rendering and the same helper through HTTP. Close the switcher after
|
||||
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
|
||||
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
|
||||
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
|
||||
|
||||
## Backup and recovery kit
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script `ui/frame_backup_remote.py`
|
||||
for real against a temporary folder laid out like the Frame's home, with only
|
||||
SSH faked: path selection and the secrets it must never copy, the manifest and
|
||||
checksums, restore checks after a reset, restore writing only its own files,
|
||||
unsafe archives and backup names, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
|
||||
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
|
||||
A full backup and an identical-file Proton save restore were checked on a
|
||||
real Frame running build `20261015.6141207`; an after-reset restore and
|
||||
Android data restore remain unverified. See the linked device evidence.
|
||||
|
||||
## Game storage
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_storage.py -v` exercises the
|
||||
read-only metadata probe, guarded HTTP endpoint and Node renderer/navigation
|
||||
regressions. All headset reads are temporary fixtures or mocked SSH. See
|
||||
[game storage](game-storage.md#verification-and-limits) for coverage and device
|
||||
verification limits. This suite does not move games or contact a headset.
|
||||
|
||||
## Battery monitoring
|
||||
|
||||
`tests/test_battery.py` covers real file parsing with temporary sysfs-like
|
||||
files, source preference, missing/invalid sensors and permission errors.
|
||||
`tests/control_center.cjs` covers charging states and optional health fields,
|
||||
alongside existing stale/offline handling. No headset is used. See
|
||||
[battery monitoring](battery-health.md) for sources, units and limitations.
|
||||
|
||||
## Host-key recovery presentation
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
|
||||
connection/banner/selection functions in Node and checks the recovery CSS guard.
|
||||
`test_link.py` covers connection failures and short probe reasons with fake SSH
|
||||
and local sockets. Neither suite uses a real headset.
|
||||
|
||||
Browser verification on 2026-10-24 used the current page's markup, styles and
|
||||
recovery renderers with synthetic changed-key state and two headsets at
|
||||
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
|
||||
rule, which the original source-only assertion missed. Check computed wrapping,
|
||||
the complete explanation, tooltip punctuation, Refresh bounds and selection
|
||||
after **Use this headset** when changing these styles. This fixture does not
|
||||
exercise Electron integration or a real SSH identity change.
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Read-only diagnostics, repair plans and explicit local before/after reports; see docs/frame-doctor.md.
|
||||
set -euo pipefail
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
exec python3 "$root/scripts/frame_doctor.py" "$@"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Read-only, bounded power/heat sampler. See docs/power-and-heat.md.
|
||||
set -euo pipefail
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
exec python3 "$root/scripts/frame_power_sample.py" "$@"
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Read-only doctor. Also sent over SSH on stdin; never installed on the Frame.
|
||||
|
||||
Only fixed summaries leave the probe: no raw journals, addresses, account IDs,
|
||||
process arguments, environment, key material or HTTP response bodies.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
SECTIONS = ('Boot and crash history', 'Wi-Fi', 'Displays and SteamVR',
|
||||
'Steam and storage', 'Idle sleep and keep-awake', 'Remote wake',
|
||||
'Lepton (Android)', 'Chromium XR', 'KDE Connect',
|
||||
'Streaming and capture', 'Panels')
|
||||
|
||||
|
||||
def run(args, timeout=4, **kwargs):
|
||||
try:
|
||||
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, **kwargs)
|
||||
except (OSError, UnicodeError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def command(*args):
|
||||
r = run(list(args))
|
||||
return r.stdout if r is not None and r.returncode == 0 else None
|
||||
|
||||
|
||||
def journal(*args):
|
||||
# Even exit 0 can mean the account cannot see the system journal.
|
||||
r = run(['journalctl', '--no-pager', '-q', '-n', '2000', *args])
|
||||
if r is None or r.returncode or r.stderr.strip() or not r.stdout.strip():
|
||||
return None
|
||||
return r.stdout
|
||||
|
||||
|
||||
def snapshot():
|
||||
values = []
|
||||
for unit in ('steam', 'steamvr'):
|
||||
s = command('systemctl', '--user', 'show', unit + '.service',
|
||||
'-p', 'NRestarts', '--value')
|
||||
if s is None or not s.strip().isdigit():
|
||||
return None
|
||||
values.append(int(s.strip()))
|
||||
p = Path('/run/user') / str(os.getuid()) / (unit + '-short-session-tracker')
|
||||
try:
|
||||
values.append(p.stat().st_size)
|
||||
except FileNotFoundError:
|
||||
values.append(0)
|
||||
except OSError:
|
||||
return None
|
||||
return values
|
||||
|
||||
|
||||
def guard():
|
||||
first = snapshot()
|
||||
time.sleep(2)
|
||||
second = snapshot()
|
||||
if first is None or second is None:
|
||||
return 'unknown'
|
||||
if any(b > a for a, b in zip(first, second)):
|
||||
return 'loop'
|
||||
if first != second:
|
||||
return 'unknown' # counters reset or tracker replaced during the sample
|
||||
if second[1] or second[3]:
|
||||
return 'risk' # avoid a long run even if a short sample missed the loop
|
||||
return 'stable'
|
||||
|
||||
|
||||
def signal_check(text, pattern, problem, healthy):
|
||||
if text is None:
|
||||
return ['UNKNOWN', 'Evidence unavailable; consult the runbook locally.']
|
||||
return ['WARN', problem] if re.search(pattern, text, re.I) else ['OK', healthy]
|
||||
|
||||
|
||||
def storage():
|
||||
# devkit-game may be a separate mount (including in the fake Frame).
|
||||
try:
|
||||
paths = [Path.home()]
|
||||
games = Path.home() / 'devkit-game'
|
||||
if games.exists():
|
||||
paths.append(games)
|
||||
free = min(shutil.disk_usage(p).free for p in paths)
|
||||
if free < 16 * 1024**2:
|
||||
return ['BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.']
|
||||
if free < 256 * 1024**2:
|
||||
return ['WARN', 'Less than 256 MiB free; review storage before large installs.']
|
||||
return ['OK', 'Home and title storage have at least 256 MiB free.']
|
||||
except OSError:
|
||||
return ['UNKNOWN', 'Storage could not be read.']
|
||||
|
||||
|
||||
def probe():
|
||||
state = guard()
|
||||
if state != 'stable':
|
||||
return {'guard': state, 'checks': []}
|
||||
kernel = journal('-b', '-k', '--since=-10min')
|
||||
recent = journal('-b', '--since=-10min')
|
||||
previous = journal('-b', '-1', '-k')
|
||||
crashes = command('coredumpctl', 'list', '--no-pager', '--since=-1d')
|
||||
checks = []
|
||||
boot = signal_check(previous, r'Unable to handle kernel|Internal error|Kernel panic',
|
||||
'Previous boot has a kernel oops; this alone does not prove an unclean reset.',
|
||||
'No kernel-oops signature in the sampled previous boot.')
|
||||
if crashes is None and boot[0] != 'WARN':
|
||||
boot = ['UNKNOWN', 'Crash history unavailable; inspect boot history locally.']
|
||||
elif crashes is not None and boot[0] != 'WARN' and re.search(r'SIG(SEGV|ABRT)|\b(SEGV|ABRT)\b', crashes):
|
||||
boot = ['WARN', 'Crash records exist in the last day; inspect locally using the runbook.']
|
||||
checks.append(boot)
|
||||
wifi = command('nmcli', '-t', '-f', 'DEVICE,TYPE,STATE', 'device')
|
||||
checks.append(signal_check(wifi if wifi and 'wlan0:wifi:' in wifi else None, r'wlan0:wifi:(unavailable|disconnected|failed)',
|
||||
'Wi-Fi is unavailable or disconnected; check Steam Settings > Internet. Never reload ath12k.',
|
||||
'No unavailable/disconnected wlan0 reported; other interfaces may differ.'))
|
||||
checks.append(signal_check(kernel, r'wait for video done',
|
||||
'Display timeouts observed; follow display recovery before touching Steam.',
|
||||
'No display-timeout signature in recent kernel sample.'))
|
||||
if checks[-1][0] != 'WARN' and recent is not None and re.search(r'left-over process|steamvr.service: Failed|!modifiers.empty', recent):
|
||||
checks[-1] = ['WARN', 'SteamVR/session failure signature observed; inspect locally before restarting anything.']
|
||||
disk = storage()
|
||||
if disk[0] == 'OK' and not (Path.home() / '.local/share/Steam/.install-complete').exists():
|
||||
disk = ['WARN', 'Steam install marker is missing; do not restart Steam for diagnosis. Consult the runbook.']
|
||||
checks.append(disk)
|
||||
awake = command('systemctl', '--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value')
|
||||
checks.append(['UNKNOWN', 'Keep-awake state unavailable.'] if awake is None or not awake.strip() else
|
||||
['WARN', 'Keep-awake is active; leave it with its owner.'] if awake.strip() == 'active' else
|
||||
['OK', 'Keep-awake is inactive; diagnostics do not inhibit sleep.'])
|
||||
wow = command('iw', 'phy', 'phy0', 'wowlan', 'show')
|
||||
checks.append(['UNKNOWN', 'WoWLAN state unavailable; remote wake is not verified.'] if wow is None else
|
||||
['OK', 'WoWLAN is disabled; wake the headset with its power button.'] if 'WoWLAN is disabled' in wow else
|
||||
['WARN', 'WoWLAN may be armed; review the runbook. Remote wake is not reliable.'])
|
||||
checks.append(signal_check(recent, r'Listen failed for HOST TCP|binder_user_error|Clearing baked app data',
|
||||
'Android failure signature observed; inspect Lepton locally before retrying.',
|
||||
'No known Android failure signature in recent journal sample.'))
|
||||
checks.append(signal_check(crashes, r'chromium|/chrome\b',
|
||||
'Chromium crash history found; update/relaunch the panel through its normal UI.',
|
||||
'No Chromium crash signature in available crash history.'))
|
||||
kde = run(['pgrep', '-f', 'frame-control/kdeconnect/root/usr/lib/kdeconnectd'])
|
||||
checks.append(['UNKNOWN', 'KDE Connect process check unavailable.'] if kde is None or kde.returncode not in (0, 1) else
|
||||
['OK', 'KDE Connect process found.'] if kde.returncode == 0 else
|
||||
['WARN', 'KDE Connect is not running; normal if keyboard/trackpad sharing is off.'])
|
||||
checks.append(signal_check(crashes, r'ffmpeg',
|
||||
'Capture crash history found; consult software-encoder guidance in the runbook.',
|
||||
'No ffmpeg crash signature in available crash history.'))
|
||||
checks.append(['UNKNOWN', 'Panel placement needs visual confirmation; open one panel at a time.'])
|
||||
# A short sample is not proof that Steam is healthy. Recheck after collecting.
|
||||
after = guard()
|
||||
return {'guard': after, 'checks': checks if after == 'stable' else []}
|
||||
|
||||
|
||||
SSH_OPTIONS = ['BatchMode=yes', 'NumberOfPasswordPrompts=0', 'StrictHostKeyChecking=yes',
|
||||
'UpdateHostKeys=no', 'CheckHostIP=no', 'ConnectTimeout=3', 'ConnectionAttempts=1',
|
||||
'ControlMaster=no', 'ControlPath=none', 'ClearAllForwardings=yes',
|
||||
'PermitLocalCommand=no', 'RemoteCommand=none', 'RequestTTY=no',
|
||||
'ProxyCommand=none', 'ProxyJump=none', 'ForwardAgent=no', 'ForwardX11=no']
|
||||
|
||||
|
||||
def ssh_args(alias, host=None, identity=None):
|
||||
args = ['ssh']
|
||||
for opt in SSH_OPTIONS:
|
||||
args += ['-o', opt]
|
||||
if host:
|
||||
args += ['-o', 'HostName=' + host, '-o', 'HostKeyAlias=' + identity]
|
||||
return args + [alias]
|
||||
|
||||
|
||||
def failure(text):
|
||||
# Never echo SSH stderr: it can contain addresses, paths or remote text.
|
||||
if 'REMOTE HOST IDENTIFICATION HAS CHANGED' in text:
|
||||
return 'SSH identity changed; use Devices > Check its key after verifying the headset. Do not delete known_hosts.'
|
||||
if 'Host key verification failed' in text:
|
||||
return 'SSH identity is untrusted; verify it through Set Up Connection or Devices > Check its key.'
|
||||
if 'Permission denied' in text:
|
||||
return "SSH key rejected; use Set Up Connection, then Developer > Pair new host in the headset."
|
||||
return 'No SSH response; wake the headset, check the network/cable and enable Developer Mode.'
|
||||
|
||||
|
||||
def port_open(host, port):
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def report(data):
|
||||
state = data.get('guard')
|
||||
if state != 'stable':
|
||||
status = 'BROKEN' if state == 'loop' else 'WARN' if state == 'risk' else 'UNKNOWN'
|
||||
message = {'loop': 'Counters are rising. Stop diagnostics; use the runbook health-check recovery procedure.',
|
||||
'risk': 'Health-check trackers contain failures. Deeper checks skipped; review the runbook before recovery.'}
|
||||
print(status + ' Restart safety: ' + message.get(state, 'Cannot verify restart counters; deeper checks skipped for safety.'))
|
||||
for i, label in enumerate(SECTIONS, 1):
|
||||
print(f'UNKNOWN {i}. {label}: Skipped until restart safety is verified.')
|
||||
return 1
|
||||
checks = data.get('checks')
|
||||
# Treat the remote endpoint as untrusted. Only accept pairs from our own
|
||||
# fixed vocabulary by matching against the production probe's messages.
|
||||
if not isinstance(checks, list) or len(checks) != len(SECTIONS):
|
||||
print('UNKNOWN Probe: Incomplete diagnostic response; no raw output displayed.')
|
||||
return 1
|
||||
for check in checks:
|
||||
if (not isinstance(check, list) or len(check) != 2 or check[0] not in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')
|
||||
or not isinstance(check[1], str) or check[1] not in MESSAGES):
|
||||
print('UNKNOWN Probe: Unexpected diagnostic response; no raw output displayed.')
|
||||
return 1
|
||||
print('OK Restart safety: No rising counters during short samples; this is not a health guarantee.')
|
||||
for i, (label, (status, message)) in enumerate(zip(SECTIONS, checks), 1):
|
||||
print(f'{status} {i}. {label}: {message}')
|
||||
return int(any(c[0] != 'OK' for c in checks))
|
||||
|
||||
|
||||
def valid_data(data):
|
||||
"""Validate before saving, comparing or deriving any recommendation."""
|
||||
if (not isinstance(data, dict) or set(data) != {'guard', 'checks'}
|
||||
or data.get('guard') not in ('stable', 'loop', 'risk', 'unknown')):
|
||||
return False
|
||||
checks = data.get('checks')
|
||||
if data['guard'] != 'stable':
|
||||
return checks == []
|
||||
return (isinstance(checks, list) and len(checks) == len(SECTIONS)
|
||||
and all(isinstance(c, list) and len(c) == 2
|
||||
and c[0] in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')
|
||||
and isinstance(c[1], str) and c[1] in MESSAGES for c in checks))
|
||||
|
||||
|
||||
def repair_plan(data):
|
||||
print('Repair plan (READ-ONLY): no commands executed; no automatic repair is available.')
|
||||
if data['guard'] != 'stable':
|
||||
print('BLOCKED: Restart safety is unverified or unsafe. Needs your hands: review the runbook health-check recovery with the headset user; do not restart services.')
|
||||
return
|
||||
# Each action needs the exact section, severity AND recognized evidence.
|
||||
actions = {
|
||||
(1, 'WARN', 'Wi-Fi is unavailable or disconnected; check Steam Settings > Internet. Never reload ath12k.'):
|
||||
'Needs your hands: check Steam Settings > Internet. Do not reload ath12k.',
|
||||
(2, 'WARN', 'Display timeouts observed; follow display recovery before touching Steam.'):
|
||||
'Needs your hands: inspect the display in-headset and review display recovery before any Steam action.',
|
||||
(3, 'BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.'):
|
||||
'Needs your hands: review Game storage and use Steam storage management after choosing what to keep; no files are deleted by doctor.',
|
||||
(3, 'WARN', 'Less than 256 MiB free; review storage before large installs.'):
|
||||
'Needs your hands: review Game storage before installing; choose any storage changes yourself.',
|
||||
(3, 'WARN', 'Steam install marker is missing; do not restart Steam for diagnosis. Consult the runbook.'):
|
||||
'Needs your hands: inspect Steam installation/extraction locally with the runbook. A missing marker does not authorize repair or restart.',
|
||||
(4, 'WARN', 'Keep-awake is active; leave it with its owner.'):
|
||||
'Leave keep-awake with its owner; do not take ownership or change idle settings.',
|
||||
}
|
||||
for i, (status, message) in enumerate(data['checks']):
|
||||
if status == 'OK':
|
||||
continue
|
||||
action = actions.get((i, status, message))
|
||||
if action is None:
|
||||
action = ('Collect missing evidence locally; no repair is justified.' if status == 'UNKNOWN'
|
||||
else 'Review this signal locally with the runbook; it does not establish a safe automatic fix.')
|
||||
print(f'PLAN {i + 1}. {SECTIONS[i]}: {action}')
|
||||
|
||||
|
||||
def compare(before, after):
|
||||
print('Before/after diagnostics: observations only; changes do not prove repair or causation.')
|
||||
print('Restart safety: ' + before['guard'] + ' -> ' + after['guard'])
|
||||
if before['guard'] != 'stable' or after['guard'] != 'stable':
|
||||
print('Comparison blocked: deeper checks were skipped in at least one sample; no improvement claimed.')
|
||||
return
|
||||
for i, label in enumerate(SECTIONS):
|
||||
a, b = before['checks'][i], after['checks'][i]
|
||||
detail = 'unchanged' if a == b else 'evidence changed'
|
||||
print(f'{i + 1}. {label}: {a[0]} -> {b[0]} ({detail})')
|
||||
if a != b:
|
||||
print(' Before evidence: ' + a[1])
|
||||
print(' After evidence: ' + b[1])
|
||||
|
||||
|
||||
def load_baseline(path):
|
||||
# Local reports are untrusted too; bound input and never echo file contents.
|
||||
with open(path, 'rb') as stream:
|
||||
raw = stream.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError()
|
||||
saved = json.loads(raw)
|
||||
if (not isinstance(saved, dict) or saved.get('version') != 1
|
||||
or not isinstance(saved.get('target'), str)
|
||||
or not re.fullmatch('[a-f0-9]{64}', saved['target'])
|
||||
or not valid_data(saved.get('diagnostics'))):
|
||||
raise ValueError()
|
||||
return saved
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Read-only Frame setup diagnostics. Never pairs, repairs or asks for a password.')
|
||||
parser.add_argument('--alias', default='frame', help='Existing trusted SSH alias (default: frame)')
|
||||
parser.add_argument('--lan', default='frame.local', help='LAN fallback host/IP')
|
||||
parser.add_argument('--usb', default='10.86.200.233', help='USB fallback IP; use only with the intended headset connected')
|
||||
parser.add_argument('--plan', action='store_true', help='Print a read-only repair plan from recognized evidence')
|
||||
parser.add_argument('--save-report', metavar='FILE', help='Create a local JSON baseline (never overwrite an existing file)')
|
||||
parser.add_argument('--compare-before', metavar='FILE', help='Compare a saved baseline with this fresh read-only run')
|
||||
args = parser.parse_args(argv)
|
||||
baseline = None
|
||||
if args.compare_before:
|
||||
try:
|
||||
baseline = load_baseline(args.compare_before)
|
||||
except (OSError, ValueError, TypeError, UnicodeError, RecursionError):
|
||||
print('UNKNOWN Baseline: Cannot read a valid doctor report; no comparison performed.')
|
||||
return 2
|
||||
for value in (args.alias, args.lan, args.usb):
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,252}', value):
|
||||
parser.error('Use a plain alias, hostname or IP address.')
|
||||
print('Frame doctor: read-only; no passwords, pairing, repairs or raw logs. Reports are saved only with --save-report.')
|
||||
if not shutil.which('ssh'):
|
||||
print('BROKEN Setup: Install an OpenSSH client, then run Set Up Connection.')
|
||||
return 2
|
||||
config = run(ssh_args(args.alias)[:-1] + ['-G', args.alias])
|
||||
if config is None or config.returncode:
|
||||
print('UNKNOWN Setup: SSH configuration could not be read; check it locally.')
|
||||
return 2
|
||||
settings = dict(line.split(' ', 1) for line in config.stdout.splitlines() if ' ' in line)
|
||||
identity = settings.get('hostkeyalias') or settings.get('hostname', args.alias)
|
||||
# Bind reports to the configured logical SSH target, across LAN/USB fallback.
|
||||
# This is configuration continuity, not proof of physical headset identity.
|
||||
target = hashlib.sha256(json.dumps([args.alias, identity, settings.get('hostname'),
|
||||
settings.get('user'), settings.get('port'), args.lan, args.usb]).encode()).hexdigest()
|
||||
if baseline is not None and baseline['target'] != target:
|
||||
print('UNKNOWN Baseline: SSH target configuration differs; comparison refused.')
|
||||
return 2
|
||||
source = Path(__file__).read_text()
|
||||
for label, host in (('configured alias (Tailscale if configured)', None), ('LAN', args.lan), ('USB-C', args.usb)):
|
||||
result = run(ssh_args(args.alias, host, identity) + ['python3 -B - --probe'], timeout=90, input=source)
|
||||
if result is not None and result.returncode == 0:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
if not valid_data(data):
|
||||
raise ValueError()
|
||||
except (ValueError, RecursionError):
|
||||
print('UNKNOWN Probe: Invalid response; check Python 3 availability on the headset. No raw output displayed.')
|
||||
return 1
|
||||
print('OK Connection: ' + label + '; verified SSH identity and key login.')
|
||||
code = report(data)
|
||||
if args.plan:
|
||||
repair_plan(data)
|
||||
if baseline is not None:
|
||||
compare(baseline['diagnostics'], data)
|
||||
if args.save_report:
|
||||
try:
|
||||
# Exclusive create prevents clobbering a baseline or following a symlink.
|
||||
fd = os.open(args.save_report, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
json.dump({'version': 1, 'target': target, 'diagnostics': data}, stream)
|
||||
stream.write('\n')
|
||||
print('OK Report: Local baseline created; review it before sharing.')
|
||||
except OSError:
|
||||
print('UNKNOWN Report: Could not create local baseline; existing files are never overwritten.')
|
||||
return 2
|
||||
return code
|
||||
if result is not None and result.returncode != 255:
|
||||
print('UNKNOWN Probe: SSH connected but diagnostics failed; check Python 3 on the headset.')
|
||||
return 1
|
||||
reason = failure(result.stderr if result is not None else '')
|
||||
print('WARN Connection (' + label + '): ' + reason)
|
||||
# No /register call: an open devkit port cannot prove pairing mode is enabled.
|
||||
hosts = (settings.get('hostname', args.alias), args.lan, args.usb)
|
||||
if any(port_open(host, 32000) for host in hosts):
|
||||
print('WARN Pairing: Devkit port responds. Use Set Up Connection and Developer > Pair new host; no pairing request was sent.')
|
||||
else:
|
||||
print('UNKNOWN Pairing: Devkit port did not respond. Needs your hands: wake headset, enable Developer Mode and check its address.')
|
||||
for i, label in enumerate(SECTIONS, 1):
|
||||
print(f'UNKNOWN {i}. {label}: No authenticated SSH path.')
|
||||
return 2
|
||||
|
||||
|
||||
# Derive the output allowlist from literal strings, not remote data. This module
|
||||
# has no dynamic diagnostic messages. AST stays stdlib and runs only locally.
|
||||
def messages():
|
||||
import ast
|
||||
tree = ast.parse(Path(__file__).read_text())
|
||||
allowed = set()
|
||||
for function in tree.body:
|
||||
if not isinstance(function, ast.FunctionDef) or function.name not in ('probe', 'storage', 'signal_check'):
|
||||
continue
|
||||
for node in ast.walk(function):
|
||||
if (isinstance(node, ast.List) and len(node.elts) == 2
|
||||
and all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in node.elts)
|
||||
and node.elts[0].value in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')):
|
||||
allowed.add(node.elts[1].value)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == 'signal_check':
|
||||
allowed.update(arg.value for arg in node.args[2:4] if isinstance(arg, ast.Constant))
|
||||
return allowed
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[1:] == ['--probe']:
|
||||
print(json.dumps(probe()))
|
||||
else:
|
||||
MESSAGES = messages()
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Offline, accessible power report. No network requests or external resources."""
|
||||
import base64
|
||||
import html
|
||||
import json
|
||||
|
||||
|
||||
def render(report, csv_text):
|
||||
def esc(value):
|
||||
return html.escape(str(value), quote=True)
|
||||
|
||||
def download(text, mime, filename, label):
|
||||
data = base64.b64encode(text.encode('utf-8')).decode('ascii')
|
||||
return f'<a download="{filename}" href="data:{mime};base64,{data}">{label}</a>'
|
||||
|
||||
rows = []
|
||||
for metric, item in report['summary'].items():
|
||||
numeric = 'mean' in item
|
||||
values = [item.get(key) for key in ('min', 'mean', 'max', 'last')]
|
||||
cells = ''.join(f'<td>{esc(value) if value is not None else "Unknown"}</td>' for value in values)
|
||||
states = ', '.join(item.get('states', [])) or ('Numeric' if numeric else 'Unknown')
|
||||
rows.append(f'<tr><th scope="row">{esc(metric)}</th><td>{esc(item["unit"])}</td>'
|
||||
f'{cells}<td>{item["available"]} / {item["unavailable"]}</td>'
|
||||
f'<td>{esc(states)}</td><td>{esc(", ".join(item["sources"]))}</td></tr>')
|
||||
missing = sorted({g for sample in report['samples'] for g in sample['unavailableGroups']})
|
||||
json_text = json.dumps(report, allow_nan=False, indent=2)
|
||||
links = download(json_text, 'application/json', 'frame-power.json', 'Download JSON samples and summary')
|
||||
links += ' · ' + download(csv_text, 'text/csv', 'frame-power.csv', 'Download CSV samples')
|
||||
return f'''<!doctype html>
|
||||
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
||||
<title>Frame power and heat report</title>
|
||||
<style>body{{font:16px system-ui,sans-serif;line-height:1.5;margin:24px;color:#18212b;background:#fff}}
|
||||
a{{color:#0645ad}}a:focus-visible{{outline:3px solid #0645ad;outline-offset:4px}}
|
||||
table{{border-collapse:collapse;width:100%;min-width:1100px}}th,td{{padding:8px;text-align:left;border:1px solid #adb5bd}}
|
||||
thead th,td{{white-space:nowrap}}th[scope=row]{{min-width:240px;max-width:300px;overflow-wrap:anywhere}}
|
||||
td:last-child{{min-width:220px;max-width:300px;white-space:normal;overflow-wrap:anywhere}}
|
||||
thead{{background:#edf1f5}}.scroll{{overflow:auto}}h1{{line-height:1.2}}</style>
|
||||
<main><h1>Frame power and heat report</h1>
|
||||
<p>{len(report['samples'])} of {report['requestedSamples']} requested samples received; target interval {esc(report['intervalSeconds'])} seconds.</p>
|
||||
<p>Collection: {esc(report['stopped'] or 'Completed')}. Results describe only the samples received.</p>
|
||||
<p>{links}</p>
|
||||
<p>Unknown means missing, unreadable or invalid, never zero. Means exclude unknown values and are not time-weighted.
|
||||
Battery watts: positive charging, negative discharge. C means °C, W watts, A amps, MHz megahertz, RPM revolutions per minute.
|
||||
Raw readings have no inferred percentage or worn threshold. Rails overlap: do not sum them.</p>
|
||||
<p>Unavailable sensor groups in one or more samples: {esc(', '.join(missing) or 'None reported')}.</p>
|
||||
<div class="scroll" role="region" aria-label="Metric summary, scroll horizontally for all columns" tabindex="0">
|
||||
<table><caption>Received measurements, with units and availability counts</caption>
|
||||
<thead><tr><th scope="col">Metric</th><th scope="col">Unit</th><th scope="col">Min</th><th scope="col">Mean</th><th scope="col">Max</th><th scope="col">Last</th><th scope="col">Available / unknown</th><th scope="col">States</th><th scope="col">Sources</th></tr></thead>
|
||||
<tbody>{''.join(rows)}</tbody></table></div>
|
||||
<p>Sensor accuracy is unverified. Suspect negative thermistors remain visible. No temperature safety limits,
|
||||
energy use or battery-runtime estimates are inferred. JSON includes actual timing, OS builds and per-sample availability.</p>
|
||||
<p>This file stays on your computer and works offline. Downloaded files contain sensor source paths and OS builds;
|
||||
review them before sharing. No settings, services, security or passwords were changed.</p></main></html>'''
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Bounded read-only Frame measurements; no device files are written.
|
||||
|
||||
The host sends this and frame_status.py over one trusted SSH connection. Only
|
||||
battery() and CPU tick helpers are reused: no OpenVR, services or analytics.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import glob
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
LOCK = Path('/tmp/frame-test.lock')
|
||||
UNAVAILABLE = 'unavailable: not reported, unreadable or invalid'
|
||||
|
||||
|
||||
def read(path):
|
||||
try:
|
||||
return Path(path).read_text().strip()
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def number(path, scale=1, minimum=None, maximum=None):
|
||||
try:
|
||||
value = int(read(path)) * scale
|
||||
if not math.isfinite(value):
|
||||
return None
|
||||
if minimum is not None and value < minimum or maximum is not None and value > maximum:
|
||||
return None
|
||||
return round(value, 6)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def snapshot(status, before):
|
||||
if LOCK.exists():
|
||||
raise RuntimeError('Smoke session active (/tmp/frame-test.lock); no sensors read.')
|
||||
ticks = status.cpu_ticks()
|
||||
readings = {}
|
||||
|
||||
def add(key, value, unit, source):
|
||||
readings[key] = {'value': value, 'unit': unit, 'source': source,
|
||||
'state': 'available' if value is not None else UNAVAILABLE}
|
||||
|
||||
battery = status.battery() or {}
|
||||
for field, unit in (('percent', '%'), ('status', 'state'), ('watts', 'W'),
|
||||
('tempC', 'C')):
|
||||
value = battery.get(field)
|
||||
if field == 'status' and value not in ('Charging', 'Discharging', 'Not charging', 'Full'):
|
||||
value = None
|
||||
add('battery.' + field, value, unit, battery.get('source'))
|
||||
add('cpu.percent', status.cpu_percent(before, ticks), '%', '/proc/stat')
|
||||
if before is None:
|
||||
readings['cpu.percent']['state'] = 'unavailable: needs two valid CPU tick samples'
|
||||
add('gpu.clock', number('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6, 0),
|
||||
'MHz', '/sys/class/devfreq/3d00000.gpu/cur_freq')
|
||||
|
||||
# Discover by driver name/label, never assume a hwmon index. Keep source
|
||||
# paths and channel numbers to distinguish duplicate labels.
|
||||
for node in sorted(glob.glob('/sys/class/hwmon/hwmon*')):
|
||||
name = read(node + '/name') or Path(node).name
|
||||
for path in sorted(glob.glob(node + '/power*_input')):
|
||||
channel = Path(path).name[:-6]
|
||||
label = read(node + '/' + channel + '_label') or channel
|
||||
add(f'rail.{name}.{label}.{channel}', number(path, 1e-6, 0), 'W', path)
|
||||
for path in sorted(glob.glob(node + '/fan*_input')):
|
||||
add(f'fan.{name}.{Path(path).stem}', number(path, minimum=0), 'RPM', path)
|
||||
# pwm1 on Frame's driver is percent; leave raw to avoid assuming the
|
||||
# generic hwmon 0..255 scale on other drivers or later firmware.
|
||||
if name == 'slg4ax46073v':
|
||||
add('fan.pwmRaw', number(node + '/pwm1', minimum=0), 'raw', node + '/pwm1')
|
||||
for node in sorted(glob.glob('/sys/class/thermal/thermal_zone*')):
|
||||
name = read(node + '/type') or Path(node).name
|
||||
add(f'thermal.{name}.{Path(node).name}', number(node + '/temp', 0.001, -273.15, 200),
|
||||
'C', node + '/temp')
|
||||
for node in sorted(glob.glob('/sys/bus/iio/devices/iio:device*')):
|
||||
for path in sorted(glob.glob(node + '/in_temp_*_input')):
|
||||
add(f'board.{Path(node).name}.{Path(path).stem}', number(path, 0.001, -273.15, 200),
|
||||
'C', path)
|
||||
for field in ('iin', 'ichg'):
|
||||
path = node + '/in_current_pm8550b_' + field + '_fb_input'
|
||||
# Known names, but no fixed IIO index; absent channels are covered
|
||||
# by group availability below.
|
||||
if Path(path).exists():
|
||||
add(f'charger.{Path(node).name}.{field}', number(path, 1e-6), 'A', path)
|
||||
if read(node + '/name') == 'vcnl4000':
|
||||
add('proximity.raw', number(node + '/in_proximity_raw', minimum=0),
|
||||
'raw', node + '/in_proximity_raw')
|
||||
unavailable = [group for group in ('rail', 'fan', 'thermal', 'board', 'charger', 'proximity')
|
||||
if not any(k.startswith(group + '.') and v['value'] is not None
|
||||
for k, v in readings.items())]
|
||||
os_info = dict(line.split('=', 1) for line in (read('/etc/os-release') or '').splitlines()
|
||||
if '=' in line)
|
||||
return {'time': time.time(), 'osBuild': os_info.get('BUILD_ID', '').strip('"') or None,
|
||||
'readings': readings, 'unavailableGroups': unavailable}, ticks
|
||||
|
||||
|
||||
def summarize(samples):
|
||||
result = {}
|
||||
keys = sorted({key for sample in samples for key in sample['readings']})
|
||||
for key in keys:
|
||||
rows = [s['readings'][key] for s in samples if key in s['readings']]
|
||||
values = [r['value'] for r in rows if r['value'] is not None]
|
||||
item = {'unit': rows[0]['unit'], 'sources': sorted({r['source'] for r in rows if r['source']}),
|
||||
'available': len(values), 'unavailable': len(samples) - len(values)}
|
||||
if values and all(isinstance(v, (int, float)) for v in values):
|
||||
item.update(min=min(values), max=max(values), mean=round(sum(values) / len(values), 6),
|
||||
first=values[0], last=values[-1])
|
||||
else:
|
||||
item['states'] = sorted(set(values))
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
|
||||
def collect(status, count, interval, emit=None):
|
||||
samples, before = [], None
|
||||
started = time.monotonic()
|
||||
stopped = None
|
||||
for index in range(count):
|
||||
if index:
|
||||
time.sleep(max(0, started + index * interval - time.monotonic()))
|
||||
try:
|
||||
sample, before = snapshot(status, before)
|
||||
except RuntimeError as exc:
|
||||
stopped = str(exc)
|
||||
break
|
||||
sample['elapsedSeconds'] = round(time.monotonic() - started, 6)
|
||||
samples.append(sample)
|
||||
if emit:
|
||||
emit({'sample': sample})
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'stopped': stopped, 'samples': samples, 'summary': summarize(samples)}
|
||||
|
||||
|
||||
def csv_samples(report):
|
||||
output = io.StringIO(newline='')
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(('time', 'elapsedSeconds', 'metric', 'value', 'unit', 'source', 'state'))
|
||||
for sample in report['samples']:
|
||||
for key, row in sample['readings'].items():
|
||||
# csv quoting does not prevent spreadsheet formulas in labels.
|
||||
def cell(value):
|
||||
return "'" + value if isinstance(value, str) and value.startswith(('=', '+', '-', '@')) else value
|
||||
writer.writerow([cell(v) for v in (sample['time'], sample['elapsedSeconds'], key,
|
||||
row['value'], row['unit'], row['source'], row['state'])])
|
||||
for group in sample['unavailableGroups']:
|
||||
writer.writerow((sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE))
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def parser():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--count', type=int, default=12, help='samples, 1..720 (default 12)')
|
||||
ap.add_argument('--interval', type=float, default=5, help='seconds, 1..60 (default 5)')
|
||||
ap.add_argument('--csv', action='store_true', help='CSV samples on stdout; JSON summary on stderr')
|
||||
ap.add_argument('--html', type=Path, metavar='FILE', help='save an offline accessible report with JSON/CSV downloads; Ctrl+C retains received samples (max 10 minutes); never overwrites')
|
||||
ap.add_argument('--probe', action='store_true', help=argparse.SUPPRESS)
|
||||
ap.add_argument('--stream', action='store_true', help=argparse.SUPPRESS)
|
||||
return ap
|
||||
|
||||
|
||||
def remote_payload():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = (root / 'ui/frame_status.py').read_text()
|
||||
# Executing as a module prevents frame_status.main() from probing unrelated
|
||||
# status. Nothing is installed, cached or logged on the headset.
|
||||
return ('import types, sys\n'
|
||||
'status = types.ModuleType("frame_status")\n'
|
||||
f'exec(compile({source!r}, "frame_status.py", "exec"), status.__dict__)\n'
|
||||
'sys.modules["frame_status"] = status\n' + Path(__file__).read_text())
|
||||
|
||||
|
||||
def stream_report(command, payload, count, interval):
|
||||
"""Receive each sample before the next wait; stop only our local SSH child.
|
||||
|
||||
The remote bounded reader exits on its next write to the closed connection.
|
||||
No remote process-control command is sent. Keep already received samples on
|
||||
interrupt, transport failure or deadline, clearly marked as incomplete.
|
||||
"""
|
||||
samples, final, stopped = [], None, None
|
||||
events = queue.Queue()
|
||||
deadline = time.monotonic() + count * interval + 30
|
||||
with tempfile.TemporaryFile(mode='w+') as errors:
|
||||
proc = subprocess.Popen(command + ['--stream'], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=errors, text=True)
|
||||
def receive():
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
events.put(line)
|
||||
finally:
|
||||
events.put(None)
|
||||
reader = threading.Thread(target=receive, daemon=True)
|
||||
reader.start()
|
||||
try:
|
||||
proc.stdin.write(payload)
|
||||
proc.stdin.close()
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
stopped = 'Timed out; received samples retained'
|
||||
break
|
||||
try:
|
||||
line = events.get(timeout=min(remaining, 0.2))
|
||||
except queue.Empty:
|
||||
continue
|
||||
if line is None:
|
||||
break
|
||||
event = json.loads(line)
|
||||
if 'sample' in event:
|
||||
if len(samples) >= count:
|
||||
raise ValueError('Too many samples received')
|
||||
samples.append(event['sample'])
|
||||
print(f'Received {len(samples)}/{count} samples; Ctrl+C to stop and save.', file=sys.stderr)
|
||||
elif 'finished' in event:
|
||||
final = event['finished']
|
||||
if not stopped:
|
||||
proc.wait(timeout=max(0.1, deadline - time.monotonic()))
|
||||
except KeyboardInterrupt:
|
||||
stopped = 'Stopped by user; received samples retained'
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
||||
stopped = f'Collection interrupted: {exc}'
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=3)
|
||||
if not proc.stdin.closed:
|
||||
try:
|
||||
proc.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
reader.join(timeout=3)
|
||||
proc.stdout.close()
|
||||
if not stopped:
|
||||
errors.seek(0)
|
||||
if final and final.get('stopped') and proc.returncode in (0, 75):
|
||||
stopped = final['stopped']
|
||||
elif proc.returncode or final is None or len(samples) != count:
|
||||
stopped = f'Incomplete collection (SSH exit {proc.returncode}): ' + errors.read(2000).strip()
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'stopped': stopped, 'samples': samples, 'summary': summarize(samples)}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = parser()
|
||||
args = ap.parse_args(argv)
|
||||
if not 1 <= args.count <= 720 or not math.isfinite(args.interval) or not 1 <= args.interval <= 60:
|
||||
ap.error('count must be 1..720 and interval must be finite, 1..60 seconds')
|
||||
if args.html and (args.csv or args.probe or args.count * args.interval > 600):
|
||||
ap.error('--html requires a host session, no --csv, and count × interval <= 600 seconds')
|
||||
if args.html and args.html.exists():
|
||||
ap.error('--html output already exists; choose a new file')
|
||||
# Also honor a coordinator lock on the computer before opening SSH.
|
||||
if LOCK.exists():
|
||||
print('Smoke session active (/tmp/frame-test.lock); no SSH or sensors accessed.', file=sys.stderr)
|
||||
return 75
|
||||
if args.probe:
|
||||
import frame_status
|
||||
emit = (lambda event: print(json.dumps(event, allow_nan=False), flush=True)) if args.stream else None
|
||||
report = collect(frame_status, args.count, args.interval, emit)
|
||||
if args.stream:
|
||||
emit({'finished': {'stopped': report['stopped']}})
|
||||
return 75 if report['stopped'] else 0
|
||||
else:
|
||||
command = ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
|
||||
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=5',
|
||||
'-o', 'ServerAliveCountMax=2', 'frame',
|
||||
'python3', '-', '--probe', '--count', str(args.count), '--interval', str(args.interval)]
|
||||
try:
|
||||
if args.html:
|
||||
report = stream_report(command, remote_payload(), args.count, args.interval)
|
||||
else:
|
||||
run = subprocess.run(command, input=remote_payload(), text=True, capture_output=True,
|
||||
timeout=args.count * args.interval + 30)
|
||||
if run.returncode:
|
||||
print('Read-only sample failed: ' + (run.stderr.strip() or f'SSH exit {run.returncode}'),
|
||||
file=sys.stderr)
|
||||
return run.returncode
|
||||
report = json.loads(run.stdout)
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
|
||||
print(f'Read-only sample unavailable: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
if args.html:
|
||||
from frame_power_report import render
|
||||
try:
|
||||
with args.html.open('x', encoding='utf-8') as output:
|
||||
output.write(render(report, csv_samples(report)))
|
||||
except OSError as exc:
|
||||
print(f'Could not save report: {exc}; received JSON report follows on stdout.', file=sys.stderr)
|
||||
print(json.dumps(report, allow_nan=False))
|
||||
return 1
|
||||
print(f'Saved {len(report["samples"])} samples to {args.html}. Open it in your browser.', file=sys.stderr)
|
||||
elif args.csv:
|
||||
sys.stdout.write(csv_samples(report))
|
||||
print(json.dumps({k: v for k, v in report.items() if k != 'samples'}, allow_nan=False), file=sys.stderr)
|
||||
else:
|
||||
print(json.dumps(report, allow_nan=False))
|
||||
return 75 if report['stopped'] else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+55
-13
@@ -55,6 +55,7 @@ from urllib.parse import quote, urlencode # %20, not +: the agent's URLComponen
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import frame_macview # noqa: E402
|
||||
import frame_pcview # noqa: E402
|
||||
|
||||
RESULTS = ROOT / "bench" / "results"
|
||||
PAGES = ROOT / "bench" / "pages"
|
||||
@@ -254,7 +255,10 @@ class LabView(frame_macview.MacView):
|
||||
|
||||
def ssh_runner(base):
|
||||
def run(remote, stdin=None, timeout=30):
|
||||
r = subprocess.run([*base, remote], input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||
r = subprocess.run([*base, remote], input=stdin.encode("utf-8") if stdin is not None else None,
|
||||
capture_output=True, timeout=timeout)
|
||||
r.stdout = r.stdout.decode("utf-8", errors="replace")
|
||||
r.stderr = r.stderr.decode("utf-8", errors="replace")
|
||||
if r.returncode:
|
||||
e = RuntimeError((r.stderr or r.stdout).strip())
|
||||
e.stdout = r.stdout
|
||||
@@ -381,7 +385,11 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
if args.browser_flag is not None:
|
||||
mv.browser_flags = [f for f in args.browser_flag if f]
|
||||
chrome = None
|
||||
src = "test"
|
||||
src = args.source or "test"
|
||||
cpu = None
|
||||
if args.pc:
|
||||
mv.host = "windows" if sys.platform == "win32" else "linux"
|
||||
mv.viewer_profile = "pc-view"
|
||||
try:
|
||||
if scenario in ("scroll", "type"):
|
||||
chrome = chrome_window(f"{scenario}.html")
|
||||
@@ -405,9 +413,10 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
since = max([f["s"] for f in first["frames"]] or [0])
|
||||
captured_before = first["captured"]
|
||||
start_mac_us = mv.call("/stats", id=stream["id"])["now"]
|
||||
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
|
||||
capture_output=True, text=True).stdout.split()[0])
|
||||
cpu = CpuSampler(frame_ssh, agent_pid)
|
||||
if not args.pc:
|
||||
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
|
||||
capture_output=True, text=True).stdout.split()[0])
|
||||
cpu = CpuSampler(frame_ssh, agent_pid)
|
||||
if relay:
|
||||
relay.begin()
|
||||
expected = 0 # input events the harness asked the viewer for
|
||||
@@ -443,7 +452,8 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
if not streams:
|
||||
raise SystemExit(f"{scenario}: the stream ended during the run (did the Frame go to sleep?)")
|
||||
data = streams[0]
|
||||
cpu.stop()
|
||||
if cpu:
|
||||
cpu.stop()
|
||||
if args.raw:
|
||||
Path(f"{args.raw}-{scenario}.json").write_text(json.dumps(data))
|
||||
result = summarize(scenario, data, start_mac_us, end_mac_us, args, relay, schedule, expected)
|
||||
@@ -452,7 +462,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
for e in data.get("events", []) if e["t"] >= start_mac_us]
|
||||
result["captured"] = (captured_end if captured_end is not None else data["captured"]) - captured_before
|
||||
result["source_fps"] = round(result["captured"] / max(result["duration_s"], 1), 1)
|
||||
result["cpu"] = cpu.summary()
|
||||
result["cpu"] = cpu.summary() if cpu else {"host": "not sampled"}
|
||||
result["viewer"] = data["summary"].get("decoder", "")
|
||||
result["show_s"] = show_s
|
||||
result["panel"] = shown.get("panel")
|
||||
@@ -460,6 +470,8 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
result["route"] = mv.route
|
||||
return result
|
||||
finally:
|
||||
if cpu:
|
||||
cpu.stop()
|
||||
try:
|
||||
mv.stop(src)
|
||||
except Exception: # noqa: BLE001 - best effort
|
||||
@@ -670,6 +682,8 @@ def fmt(v):
|
||||
|
||||
def add_run_args(r):
|
||||
r.add_argument("--scenario", default="test,scroll,type")
|
||||
r.add_argument("--pc", action="store_true", help="run the bundled PC host; use --scenario test or capture")
|
||||
r.add_argument("--source", default="", help="PC source: window:ID, display:ID, or choose (Linux portal)")
|
||||
r.add_argument("--duration", type=float, default=20)
|
||||
r.add_argument("--warmup", type=float, default=3)
|
||||
r.add_argument("--quality", default="balanced", choices=list(frame_macview.QUALITY))
|
||||
@@ -717,7 +731,7 @@ def frame_ssh_for(args):
|
||||
|
||||
|
||||
def run_suite(args, frame_ssh, quiet=False):
|
||||
agent_port, token = lab_agent()
|
||||
agent_port, token = (args.pc_view.port, args.pc_view.token) if args.pc else lab_agent()
|
||||
results = []
|
||||
for sc in args.scenario.split(","):
|
||||
if not quiet:
|
||||
@@ -744,11 +758,12 @@ def document(args, frame_ssh, results, **extra):
|
||||
commit = git("rev-parse", "--short", "HEAD") + ("+dirty" if git("status", "--porcelain", "--", "mac", "ui") else "")
|
||||
return {
|
||||
"label": args.label or args.cmd, "date": datetime.datetime.now().isoformat(timespec="seconds"), "commit": commit,
|
||||
"config": {"quality": args.quality, "mode": args.mode, "net": args.net or "none", "delay_ms": args.delay,
|
||||
"config": {"quality": args.quality, "mode": "native" if args.pc else args.mode, "net": args.net or "none", "delay_ms": args.delay,
|
||||
"buffer_ms": args.buffer, "host": args.host or args.frame, "usb": args.usb, "ssh_opts": args.ssh_opt,
|
||||
"encoder": os.environ.get("FRAME_MAC_VIEW_ENCODER", ""), "duration_s": args.duration,
|
||||
"browser_flags": args.browser_flag if args.browser_flag is not None else frame_macview.BROWSER_FLAGS},
|
||||
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0], "headset": standby[-40:],
|
||||
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0],
|
||||
"host_platform": platform.platform(), "source": args.source, "pc_host": args.pc, "headset": standby[-40:],
|
||||
"scenarios": results, **extra,
|
||||
}
|
||||
|
||||
@@ -819,19 +834,46 @@ def main():
|
||||
print("\nworse:\n " + "\n ".join(regs))
|
||||
sys.exit(1 if regs else 0)
|
||||
|
||||
lab_agent()
|
||||
if args.pc and (args.cmd == "ab" or args.scenario not in ("test", "capture")):
|
||||
p.error("PC runs use --scenario test or --scenario capture; Mac automation is not portable")
|
||||
if args.pc and args.scenario == "capture" and not args.source:
|
||||
p.error("capture needs --source window:ID, display:ID, or choose")
|
||||
if not args.pc:
|
||||
lab_agent()
|
||||
frame_ssh = frame_ssh_for(args)
|
||||
# Keep the Mac's screen awake: virtual displays aren't removed while it sleeps.
|
||||
runs = 1 if args.cmd == "run" else args.repeat * len(args.arm)
|
||||
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))])
|
||||
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))]) if sys.platform == "darwin" else None
|
||||
args.pc_view = None
|
||||
try:
|
||||
if args.pc:
|
||||
args.pc_view = frame_pcview.PCView(frame_ssh[:-1], ssh_runner(frame_ssh), frame_ssh[-1])
|
||||
args.pc_view.ensure_agent()
|
||||
if args.source == "choose":
|
||||
args.pc_view.call("/permissions", method="POST")
|
||||
print("Choose a window or screen in your desktop's sharing dialog.", flush=True)
|
||||
deadline = time.monotonic()+125
|
||||
while time.monotonic() < deadline:
|
||||
state = args.pc_view.call("/status")
|
||||
if not state.get("selecting"):
|
||||
sources = args.pc_view.call("/windows")["windows"]
|
||||
if not sources:
|
||||
raise SystemExit(state.get("selectionError") or "Nothing was shared")
|
||||
args.source = sources[-1]["src"]
|
||||
break
|
||||
time.sleep(.5)
|
||||
else:
|
||||
raise SystemExit("Sharing dialog timed out")
|
||||
if args.cmd == "ab":
|
||||
arm_runs, table = ab(args, frame_ssh)
|
||||
write(document(args, frame_ssh, [], arms=args.arm, runs=arm_runs, table=table), args)
|
||||
return
|
||||
results = run_suite(args, frame_ssh)
|
||||
finally:
|
||||
awake.terminate()
|
||||
if args.pc_view:
|
||||
args.pc_view.shutdown()
|
||||
if awake:
|
||||
awake.terminate()
|
||||
doc = document(args, frame_ssh, results)
|
||||
write(doc, args)
|
||||
bad = [f"{r['scenario']} {k}" for r in results for k, v in r["grades"].items() if v in ("bad", "missing")]
|
||||
|
||||
+109
-1
@@ -68,7 +68,7 @@ function sharedPage({productionApi=false} = {}) {
|
||||
vm.runInContext(`const UI_KEY = 'test-only';
|
||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0};
|
||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, page = 'control';
|
||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, runningGames = [], runningSeq = 0, page = 'control';
|
||||
const androidPending = new Set();`,context);
|
||||
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
|
||||
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
|
||||
@@ -165,6 +165,50 @@ test('metrics use real fields, preserve zero, and distinguish unavailable values
|
||||
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
|
||||
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
|
||||
});
|
||||
test('battery health distinguishes charging states and optional sensors', () => {
|
||||
for (const status of [null, 'Unknown', 'Unexpected']) {
|
||||
const m = cc.metrics({battery:{percent:100,status,timeToEmpty:3600}});
|
||||
assert.equal(m.batteryDetail,'Charging state unavailable');
|
||||
assert.equal(m.estimate,'Unavailable');
|
||||
}
|
||||
assert.equal(cc.metrics({battery:{status:'Not charging'}}).batteryDetail,'Not charging');
|
||||
assert.equal(cc.metrics({battery:{status:'Full'}}).batteryDetail,'Fully charged');
|
||||
assert.equal(cc.metrics({battery:{status:'Charging',timeToFull:3600}}).estimate,'1 h 0 min to full');
|
||||
assert.equal(cc.metrics({battery:{status:'Discharging',timeToEmpty:600}}).estimate,'10 min remaining');
|
||||
const m = cc.metrics({battery:{cycleCount:0,fullMah:4500,designMah:5000,capacityHealthPercent:90,
|
||||
source:'/sys/class/power_supply/max1720x_bat_7-36',health:'Unknown',tempC:0}});
|
||||
assert.equal(m.cycles,'0'); assert.equal(m.capacityHealth,'90%');
|
||||
assert.equal(m.fullCapacity,'4,500 mAh'); assert.equal(m.batteryTemp,'0 °C');
|
||||
assert.equal(m.batteryHealth,'Unavailable'); assert.match(m.batterySource,/max1720x/);
|
||||
for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource','batteryHealth','batteryTemp'])
|
||||
assert.equal(cc.metrics(null)[key],'Unavailable');
|
||||
});
|
||||
test('battery chip preserves unknown status and does not infer Full at 100%', () => {
|
||||
const f=fixture();
|
||||
const start=pageSource.indexOf('function battery(b, power)');
|
||||
const end=pageSource.indexOf('// hidePopover', start);
|
||||
const context={$:f.element,dur:String}; vm.createContext(context);
|
||||
vm.runInContext(pageSource.slice(start,end),context);
|
||||
context.battery({percent:100,status:'Unknown'},{watts:20});
|
||||
assert.equal(f.element('battState').textContent,'Charging state unavailable');
|
||||
context.battery({percent:50,status:'Not charging'},null);
|
||||
assert.equal(f.element('battState').textContent,'Not charging');
|
||||
context.battery({percent:50,status:'Charging',watts:2,tempC:0},null);
|
||||
assert.match(f.element('battState').textContent,/Charging/);
|
||||
assert.match(f.element('battDetail').textContent,/battery 0°C/);
|
||||
context.battery(null,null);
|
||||
assert.equal(f.element('battState').textContent,'Battery unknown');
|
||||
});
|
||||
test('new health readings clear after a failed status read', () => {
|
||||
const f=mounted();
|
||||
f.ui.status({...sample(),battery:{...sample().battery,cycleCount:42,capacityHealthPercent:92,
|
||||
fullMah:4600,designMah:5000,source:'/sys/class/power_supply/max1720x_bat_7-36'}});
|
||||
assert.equal(f.element('metric-capacityHealth').textContent,'92%');
|
||||
f.ui.unavailable(Error('Read failed'));
|
||||
for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource'])
|
||||
assert.equal(f.element('metric-'+key).textContent,'Unavailable');
|
||||
f.ui.destroy();
|
||||
});
|
||||
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
|
||||
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
|
||||
@@ -449,3 +493,67 @@ test('every Control Center workflow reaches its intentional workspace', () => {
|
||||
}
|
||||
assert.equal(f.run('PAGES.length'),4);
|
||||
});
|
||||
// Force quit: an explicit check, a confirmation naming the game, and the captured headset.
|
||||
function quitPage({confirmAnswer=true, running=[{appid:620,name:'Portal <2>',kind:'steam'}], quit} = {}) {
|
||||
const asked=[], calls=[];
|
||||
const f=mounted({confirm:text=>{asked.push(text);return confirmAnswer;},
|
||||
api:async(path,body,device)=>{calls.push({path,body,device});
|
||||
if(body===undefined)return {running};
|
||||
return quit ? quit(body) : {message:'Quit Portal 2',method:'steam'};}});
|
||||
return Object.assign(f,{asked,apiCalls:calls,click:appid=>f.element('ccRunningList').onclick({target:{closest:()=>({dataset:{ccQuit:String(appid)}})}})});
|
||||
}
|
||||
test('force quit lists what is running and says so when nothing is', async t => {
|
||||
const f=quitPage({running:[]});t.after(()=>f.ui.destroy());
|
||||
await f.element('ccRunningCheck').onclick();
|
||||
assert.deepEqual(f.apiCalls,[{path:'/api/running',body:undefined,device:'frame-a'}]);
|
||||
assert.equal(f.element('ccRunningResult').textContent,'Nothing is running on the Frame.');
|
||||
assert.equal(f.element('ccRunningList').innerHTML,'');
|
||||
const g=quitPage();t.after(()=>g.ui.destroy());
|
||||
await g.element('ccRunningCheck').onclick();
|
||||
assert.match(g.element('ccRunningList').innerHTML,/Portal <2><small>Steam game<\/small>.*data-cc-quit="620"/);
|
||||
assert.match(g.element('ccRunningResult').textContent,/^1 running/);
|
||||
});
|
||||
test('force quit confirms with the unsaved-progress warning before any request', async t => {
|
||||
const f=quitPage({confirmAnswer:false});t.after(()=>f.ui.destroy());
|
||||
await f.element('ccRunningCheck').onclick();
|
||||
f.click(620);await flush();
|
||||
assert.deepEqual(f.asked,['Force quit Portal <2>? Unsaved progress will be lost.']);
|
||||
assert.equal(f.apiCalls.length,1,'declining sends nothing');
|
||||
const g=quitPage();t.after(()=>g.ui.destroy());
|
||||
await g.element('ccRunningCheck').onclick();
|
||||
g.click(620);await flush();
|
||||
assert.deepEqual(g.apiCalls[1],{path:'/api/running',body:{action:'quit',appid:'620'},device:'frame-a'});
|
||||
assert.equal(g.element('ccRunningResult').textContent,'Quit Portal 2');
|
||||
assert.equal(g.element('ccRunningList').innerHTML,'');
|
||||
g.click(999);await flush();
|
||||
assert.equal(g.apiCalls.length,2,'an id that was not listed is ignored');
|
||||
});
|
||||
test('force quit shows errors, refuses duplicates and offline use, and ignores a switched headset', async t => {
|
||||
const pending=deferred();
|
||||
const f=quitPage({quit:()=>pending.promise});t.after(()=>f.ui.destroy());
|
||||
await f.element('ccRunningCheck').onclick();
|
||||
f.click(620);f.click(620);await flush();
|
||||
assert.equal(f.asked.length,1);
|
||||
assert.match(f.element('ccRunningList').innerHTML,/disabled/);
|
||||
pending.reject(Error("That game isn't running any more"));await flush();
|
||||
assert.equal(f.element('ccRunningResult').textContent,"Couldn’t force quit Portal <2>: That game isn't running any more");
|
||||
assert.doesNotMatch(f.element('ccRunningList').innerHTML,/disabled/);
|
||||
// A switch while quitting: the old answer is dropped and the list cleared.
|
||||
const late=deferred(),g=quitPage({quit:()=>late.promise});t.after(()=>g.ui.destroy());
|
||||
await g.element('ccRunningCheck').onclick();g.click(620);await flush();
|
||||
g.context={online:true,generation:2,device:'frame-b'};g.ui.reset();
|
||||
late.resolve({message:'Quit Portal 2'});await flush();
|
||||
assert.equal(g.element('ccRunningResult').textContent,'');
|
||||
assert.equal(g.element('ccRunningList').innerHTML,'');
|
||||
const h=quitPage();t.after(()=>h.ui.destroy());h.context={online:false,generation:1,device:'frame-a'};
|
||||
await h.element('ccRunningCheck').onclick();
|
||||
assert.equal(h.apiCalls.length,0);
|
||||
assert.match(h.element('ccRunningResult').textContent,/Connect to the Frame/);
|
||||
const controller=cc.createController({api:async()=>assert.fail('no request'),getContext:()=>({online:true,generation:1})});
|
||||
await assert.rejects(controller.forceQuit({appid:'620; reboot'}),/Choose a running game/);
|
||||
});
|
||||
test('the Control Center finds force quit by what a stuck game looks like', () => {
|
||||
const item=cc.catalog.find(i=>i.id==='forcequit');
|
||||
for(const q of ['frozen game','hung','not responding','kill','stuck app'])assert.ok(cc.matches(item,q,'all'),q);
|
||||
assert.ok(cc.matches(item,'','apps'));
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Real doctor/SSH against fake-Frame faults; never a real headset."""
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import harness
|
||||
from harness import ROOT, ctl
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
class Doctor(harness.FrameTestCase):
|
||||
def doctor(self, *args):
|
||||
return subprocess.run([sys.executable, str(ROOT / 'scripts/frame_doctor.py'),
|
||||
'--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST, *args],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
|
||||
def test_disk_full(self):
|
||||
before = self.doctor()
|
||||
self.assertNotIn('BROKEN 4. Steam and storage', before.stdout)
|
||||
ctl('disk-full', 'on')
|
||||
r = self.doctor()
|
||||
self.assertEqual(r.returncode, 1, r.stdout + r.stderr)
|
||||
self.assertIn('BROKEN 4. Steam and storage: Less than 16 MiB free', r.stdout)
|
||||
|
||||
def test_unreachable_asleep(self):
|
||||
ctl('sleep', 'on')
|
||||
r = self.doctor()
|
||||
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
|
||||
self.assertIn('No authenticated SSH path', r.stdout)
|
||||
|
||||
def test_no_ssh_and_no_devkit(self):
|
||||
ctl('sshd', 'off')
|
||||
ctl('devkit-service', 'off')
|
||||
r = self.doctor()
|
||||
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
|
||||
self.assertIn('Devkit port did not respond', r.stdout)
|
||||
|
||||
def test_no_keys_but_devkit_available(self):
|
||||
ctl('keys', 'none')
|
||||
r = self.doctor()
|
||||
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
|
||||
self.assertIn('SSH key rejected', r.stdout)
|
||||
self.assertIn('no pairing request was sent', r.stdout)
|
||||
|
||||
def test_plan_and_before_after_storage(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
baseline = str(Path(folder) / 'before.json')
|
||||
ctl('disk-full', 'on')
|
||||
before = self.doctor('--plan', '--save-report', baseline)
|
||||
self.assertEqual(before.returncode, 1, before.stdout + before.stderr)
|
||||
self.assertIn('use Steam storage management', before.stdout)
|
||||
ctl('disk-full', 'off') # fake fault control, not a doctor repair
|
||||
after = self.doctor('--plan', '--compare-before', baseline)
|
||||
self.assertEqual(after.returncode, 1, after.stdout + after.stderr)
|
||||
self.assertIn('Steam and storage: BROKEN ->', after.stdout)
|
||||
self.assertNotIn('Steam and storage: BROKEN -> BROKEN', after.stdout)
|
||||
self.assertIn('no automatic repair is available', after.stdout)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Backup and recovery kit through the server against the fake Frame: a Proton game's save
|
||||
goes to this computer and back, Steam's sign-in token never leaves the headset."""
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import harness
|
||||
from harness import api, finished, ok, ssh
|
||||
|
||||
harness.require()
|
||||
|
||||
SAVE = '.local/share/Steam/steamapps/compatdata/620980/pfx/drive_c/users/steamuser/AppData/LocalLow/Beat Games/Beat Saber/PlayerData.dat'
|
||||
|
||||
|
||||
class Backup(harness.FrameTestCase):
|
||||
def test_saves_round_trip_and_secrets_stay(self):
|
||||
ssh(f"mkdir -p ~/'{Path(SAVE).parent}' && printf 'level 7' > ~/'{SAVE}' && "
|
||||
"mkdir -p ~/.steam && printf SECRET > ~/.steam/steam.token")
|
||||
job = finished(ok('POST', '/api/backup', {'action': 'backup', 'kinds': ['saves', 'settings']}), 120)
|
||||
self.assertIsNone(job['error'], job)
|
||||
folder = Path(job['result']['path'])
|
||||
self.addCleanup(shutil.rmtree, str(folder), True)
|
||||
manifest = json.loads((folder / 'manifest.json').read_text())
|
||||
self.assertEqual(manifest['headset']['build'], '20260922.6101926')
|
||||
saves = next(p for p in manifest['parts'] if p['id'] == 'saves:620980')
|
||||
self.assertEqual(saves['label'], 'Beat Saber saves')
|
||||
self.assertEqual([f['path'] for f in saves['files']], [SAVE])
|
||||
for part in manifest['parts']:
|
||||
with tarfile.open(str(folder / part['archive'])) as tar:
|
||||
self.assertFalse([m.name for m in tar if 'steam.token' in m.name])
|
||||
self.assertEqual(ssh('cat ~/.steam/steam.token'), 'SECRET') # read-only on the headset
|
||||
|
||||
ssh(f"rm ~/'{SAVE}'")
|
||||
name = folder.name
|
||||
plan = {p['id']: p for p in ok('POST', '/api/backup', {'action': 'plan', 'name': name})['parts']}
|
||||
self.assertEqual((plan['saves:620980']['status'], plan['saves:620980']['new']), ('ok', 1))
|
||||
status, out, _ = api('POST', '/api/backup', {'action': 'restore', 'name': name, 'parts': ['saves:620980']})
|
||||
self.assertEqual(status, 400, out) # not confirmed
|
||||
job = finished(ok('POST', '/api/backup', {'action': 'restore', 'name': name, 'parts': ['saves:620980'],
|
||||
'confirm': True}), 120)
|
||||
self.assertIsNone(job['error'], job)
|
||||
self.assertEqual(ssh(f"cat ~/'{SAVE}'"), 'level 7')
|
||||
|
||||
def test_bad_names_and_session(self):
|
||||
status, out, _ = api('POST', '/api/backup', {'action': 'plan', 'name': '../../etc'})
|
||||
self.assertEqual(status, 400, out)
|
||||
state = ok('POST', '/api/backup', {'action': 'session'})
|
||||
self.assertIn(state['mode'], ('game', 'desktop', 'unknown'))
|
||||
self.assertIsNone(state['selector']) # the fake has no session switch command
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Force quit (ui/frame_running.py) through the server's HTTP API, against games the
|
||||
fake Steam client really runs under its stand-in reaper (fakesteam.REAPER)."""
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import harness
|
||||
from harness import api, install_title, launches, ok, ssh, state, wait_for
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
class Running(harness.FrameTestCase):
|
||||
def script_title(self, name, body):
|
||||
folder = self.path(name)
|
||||
os.makedirs(folder)
|
||||
with open(os.path.join(folder, 'run.sh'), 'w') as f:
|
||||
f.write('#!/bin/sh\n' + body)
|
||||
os.chmod(os.path.join(folder, 'run.sh'), 0o755)
|
||||
plan, job = install_title(folder)
|
||||
self.assertIsNone(job['error'], job)
|
||||
return plan['id'], plan['name']
|
||||
|
||||
def running(self, match):
|
||||
return next((g for g in ok('GET', '/api/running')['running'] if match(g)), None)
|
||||
|
||||
def launch_title(self, gid):
|
||||
ok('POST', '/api/titles', {'action': 'launch', 'id': gid})
|
||||
game = wait_for(lambda: self.running(lambda g: g.get('title') == gid), 20, f'{gid} to be running')
|
||||
# The fake's reset doesn't stop programs: don't leave one running for the next test.
|
||||
self.addCleanup(ssh, f"pkill -KILL -f '[d]evkit-game/{gid}/' || true", check=False)
|
||||
return game
|
||||
|
||||
def assert_steam_alive(self, pid):
|
||||
self.assertEqual(ssh(f'kill -0 {pid} && echo alive').strip(), 'alive')
|
||||
|
||||
def test_nothing_running(self):
|
||||
self.assertEqual(ok('GET', '/api/running'), {'running': [], 'message': 'Nothing is running on the Frame'})
|
||||
status, out, _ = api('POST', '/api/running', {'action': 'quit', 'appid': '620'})
|
||||
self.assertEqual(status, 409, out)
|
||||
|
||||
def test_sigterm_quits_a_sideloaded_game(self):
|
||||
gid, name = self.script_title('Hang Game', 'sleep 600 &\nwait\n')
|
||||
steam = ssh('cat ~/.steam/steam.pid').strip()
|
||||
game = self.launch_title(gid)
|
||||
self.assertEqual((game['kind'], game['name']), ('devkit', name))
|
||||
self.assertGreaterEqual(game['processes'], 3) # reaper, run.sh, sleep
|
||||
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
|
||||
self.assertEqual(out['method'], 'term', out)
|
||||
self.assertEqual(out['message'], f'Force quit {name}')
|
||||
# Steam was asked first, with the shortcut's game id.
|
||||
self.assertEqual(state()['steam']['last_terminate'], game['gameid'])
|
||||
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
|
||||
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
|
||||
self.assert_steam_alive(steam)
|
||||
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
|
||||
10, 'the reaper to report the exit')
|
||||
self.assertIsNotNone(done['exit'])
|
||||
|
||||
def test_sigkill_when_it_ignores_sigterm(self):
|
||||
gid, name = self.script_title('Stuck Game', "trap '' TERM\nsleep 600 &\nwait\n")
|
||||
steam = ssh('cat ~/.steam/steam.pid').strip()
|
||||
game = self.launch_title(gid)
|
||||
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
|
||||
self.assertEqual(out['method'], 'kill', out)
|
||||
self.assertEqual(out['message'], f'Force quit {name} (it had to be killed)')
|
||||
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
|
||||
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
|
||||
self.assert_steam_alive(steam)
|
||||
|
||||
def test_steam_game_quits_through_steam(self):
|
||||
ok('POST', '/api/launch', {'appid': 620980})
|
||||
game = wait_for(lambda: self.running(lambda g: g['appid'] == 620980), 20, 'the Steam game to be running')
|
||||
self.assertEqual((game['kind'], game['gameid']), ('steam', '620980'))
|
||||
out = ok('POST', '/api/running', {'action': 'quit', 'appid': '620980'})
|
||||
self.assertEqual(out['method'], 'steam', out)
|
||||
self.assertEqual(state()['steam']['last_terminate'], '620980')
|
||||
self.assertEqual(launches('rungameid')[-1]['exit'], 'terminated')
|
||||
self.assertEqual(ok('GET', '/api/running')['running'], [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Doctor's read-only systemd queries in a container without a user manager.
|
||||
|
||||
Stable NRestarts=0 and inactive keep-awake are harness assumptions, not observed
|
||||
device behavior. Real Frame query forms come from the historical doctor runbook
|
||||
(2026-09-28, BUILD_ID 20260925.6191901). No service control is emulated here.
|
||||
"""
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
if args in (['--user', 'show', 'steam.service', '-p', 'NRestarts', '--value'],
|
||||
['--user', 'show', 'steamvr.service', '-p', 'NRestarts', '--value']):
|
||||
print('0')
|
||||
elif args == ['--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value']:
|
||||
print('inactive')
|
||||
else:
|
||||
sys.exit(1)
|
||||
@@ -79,6 +79,12 @@ function build(steam) {
|
||||
return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null;
|
||||
},
|
||||
},
|
||||
// Guess: the running list as Steam's client code and plugin loaders read it
|
||||
// (SteamUIStore.RunningApps of app overviews); not seen on the Frame. fakesteam
|
||||
// passes the ids of launches that haven't exited.
|
||||
SteamUIStore: {
|
||||
get RunningApps() { return (steam.running || []).map(id => allApps().find(a => a.appid === id) || { appid: id }); },
|
||||
},
|
||||
appStore: {
|
||||
get allApps() { return allApps(); },
|
||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
||||
@@ -109,7 +115,8 @@ function build(steam) {
|
||||
if (s?.artwork) delete s.artwork[type];
|
||||
},
|
||||
// Container fallback in frame_android.stop performs the simulated stop.
|
||||
TerminateApp(gameid) { steam.last_terminate = gameid; },
|
||||
// fakesteam ends pid-less launches for the ids in `terminated`.
|
||||
TerminateApp(gameid) { steam.last_terminate = gameid; (steam.terminated ||= []).push(String(gameid)); },
|
||||
RemoveShortcut(id) {
|
||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||
delete steam.compat_tools[String(id)];
|
||||
|
||||
@@ -47,6 +47,12 @@ COMPAT_LOG = fs.STEAM_ROOT + '/logs/compat_log.txt'
|
||||
DEVTOOLS_PORT = 8080
|
||||
TARGET_ID = 'F0CA11ED5EA4ED0000000000000000AB'
|
||||
GAME_LOGS = '/var/log/fakeframe'
|
||||
# Steam starts a game under its reaper, `reaper SteamLaunch AppId=<id> -- <command>`,
|
||||
# which stays the game's parent until it ends (guess for the Frame: that's how Steam
|
||||
# launches on Linux desktops and the Steam Deck; docs/steam-games.md). The fake's
|
||||
# reaper is bash with that command line: it waits for the game and exits with its status.
|
||||
REAPER = fs.STEAM_ROOT + '/ubuntu12_32/reaper'
|
||||
REAPER_SH = 'while [ "$1" != "--" ]; do shift; done; shift; "$@" & wait $!'
|
||||
|
||||
# The 403 text /register returned while Steam wasn't on "Pair new host"
|
||||
# (docs/ssh.md, verified 2026-09-26). approve-ssh-key passes the Steam
|
||||
@@ -105,11 +111,24 @@ class JS:
|
||||
self._start()
|
||||
self.next += 1
|
||||
with fs.update() as state:
|
||||
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression,
|
||||
'awaitPromise': await_promise, 'steam': state['steam']}) + '\n')
|
||||
# What Steam counts as running: launches that started and haven't exited.
|
||||
running = sorted({l['appid'] for l in state['launches']
|
||||
if l.get('started') and l.get('appid') and l.get('exit') is None})
|
||||
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression, 'awaitPromise': await_promise,
|
||||
'steam': dict(state['steam'], running=running)}) + '\n')
|
||||
self.proc.stdin.flush()
|
||||
reply = json.loads(self.proc.stdout.readline())
|
||||
state['steam'] = reply['steam']
|
||||
steam = reply['steam']
|
||||
steam.pop('running', None)
|
||||
for gid in map(int, steam.pop('terminated', [])):
|
||||
appid = gid >> 32 if gid >= 1 << 32 else gid
|
||||
# Guess: TerminateApp ends a launch the fake didn't really run (a Steam
|
||||
# game, a Proton title). A real process it leaves alone, so Frame
|
||||
# Control's own fallback (SIGTERM, SIGKILL) is what the e2e tests see.
|
||||
for l in state['launches']:
|
||||
if l.get('appid') == appid and l.get('exit') is None and not l.get('pid'):
|
||||
l['exit'] = 'terminated'
|
||||
state['steam'] = steam
|
||||
return reply['result']
|
||||
|
||||
|
||||
@@ -253,8 +272,10 @@ def add_launch(record):
|
||||
os.makedirs(GAME_LOGS, exist_ok=True)
|
||||
with open(f'{GAME_LOGS}/{label}.log', 'ab') as log:
|
||||
try:
|
||||
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
|
||||
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
||||
argv = [REAPER, '-c', REAPER_SH, 'reaper', 'SteamLaunch', f"AppId={record['appid']}", '--', path]
|
||||
proc = subprocess.Popen(argv, executable='/bin/bash', cwd=cwd if os.path.isdir(cwd) else HOME,
|
||||
env={**os.environ, **env}, stdin=subprocess.DEVNULL, stdout=log, stderr=log,
|
||||
start_new_session=True)
|
||||
record['pid'] = proc.pid
|
||||
except OSError as e:
|
||||
record.update(pid=None, exec_error=str(e))
|
||||
|
||||
+15
-2
@@ -3,8 +3,8 @@
|
||||
tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list,
|
||||
drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is
|
||||
written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line.
|
||||
Anything else fails, so the script under test can't fall back to `gh release ...`
|
||||
(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release
|
||||
Anything else fails; `gh release upload` works only when a release-workflow test
|
||||
explicitly sets FAKEGH_ALLOW_RELEASE_UPLOAD=1. POST .../releases (release.yml's draft step) answers as release
|
||||
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
|
||||
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
|
||||
(raw strings, compact JSON)."""
|
||||
@@ -17,6 +17,13 @@ import sys
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["FAKEGH_LOG"], "a") as f:
|
||||
f.write(json.dumps(args) + "\n")
|
||||
if args[:2] == ["release", "upload"] and os.environ.get("FAKEGH_ALLOW_RELEASE_UPLOAD") == "1":
|
||||
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
|
||||
if os.environ.get("FAKEGH_UPLOAD_ERROR"):
|
||||
sys.exit(os.environ["FAKEGH_UPLOAD_ERROR"])
|
||||
if not any(r.get("tag_name") == args[2] for r in releases):
|
||||
sys.exit("release not found")
|
||||
sys.exit(0)
|
||||
if not args or args[0] != "api":
|
||||
sys.exit("fakegh: only `gh api` is supported: %r" % args)
|
||||
|
||||
@@ -80,6 +87,12 @@ elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
|
||||
rid = endpoint[len(repo + "releases/"):]
|
||||
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
|
||||
sys.exit("gh: Not Found (HTTP 404)")
|
||||
if os.environ.get("FAKEGH_ALLOW_RELEASE_UPLOAD") == "1":
|
||||
for item in releases:
|
||||
if str(item["id"]) == rid:
|
||||
item.update(fields)
|
||||
with open(os.environ["FAKEGH_RELEASES"], "w") as f:
|
||||
json.dump(releases, f)
|
||||
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
|
||||
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
|
||||
"published %s at %s" % (fields.get("tag_name"), page))
|
||||
|
||||
@@ -13,13 +13,13 @@ function grab(name) {
|
||||
if (start < 0) fail(`no top-level ${name} in index.html`);
|
||||
let depth = 0;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
|
||||
for (const c of lines[i]) depth += c === "{" || c === "[" ? 1 : c === "}" || c === "]" ? -1 : 0;
|
||||
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
|
||||
}
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeReportDetails", "storePickEligible", "STORE_CATS", "storeCat", "storeCard", "storeRow", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
|
||||
const code = names.map(grab).join("");
|
||||
|
||||
@@ -34,7 +34,7 @@ function page(api = async () => ({}), androidApps = []) {
|
||||
const document = { activeElement: null, querySelector: () => null };
|
||||
const console = { error() {} };
|
||||
const calls = [];
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp, storeCard, storeRow, storePickEligible };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps,
|
||||
async (title, key, work) => work(), () => {}, async (title, work) => work(),
|
||||
{ open() { fail("an in-app installation opened an external page"); } }, 0);
|
||||
@@ -114,4 +114,31 @@ for (const [job, expected] of [[null, 1], [{ done: false }, 0],
|
||||
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
|
||||
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
|
||||
}
|
||||
|
||||
// Report warnings appear on real cards, shelves and details; they are advisory.
|
||||
for (const [kind, tone, label] of [['no', 'warning', 'Reported not working'], ['maybe', 'issues', 'Reported issues']]) {
|
||||
const p = page();
|
||||
const o = {...offer, verdict: {tone, label: label+' on the Frame'},
|
||||
compat_report: {verdict: kind, lines: ['Reported <unsafe> notes', '2 reports, 0 working']}};
|
||||
const app = {name: o.name, offers: [o]};
|
||||
p.sourceState.apps = [app];
|
||||
p.sourceState.detail = {app, choice: 0};
|
||||
const card = p.storeCard(app), shelf = p.storeRow('VR picks', 'Synthetic fixtures', [app]);
|
||||
if (!card.includes('>'+label+'</span>') || !shelf.includes('>'+label+'</span>')) fail('warning missing on card or shelf');
|
||||
if (!p.renderAppDetail()) fail('report detail did not render');
|
||||
const html = p.els.detailBody.innerHTML;
|
||||
if (!html.includes('Saved compatibility reports') || !html.includes('Reported <unsafe> notes')) fail('report evidence missing or unescaped');
|
||||
if (html.includes('Reported <unsafe>')) fail('report notes injected HTML');
|
||||
if (p.els.detailInstall.disabled || p.els.detailInstall.textContent !== 'Install on the Frame') fail('report disabled installation');
|
||||
if (kind==='no' && p.storePickEligible(o)) fail('broken report is eligible for featured/VR picks');
|
||||
// A positive report cannot remove a hard blocker. Negative evidence stays visible too.
|
||||
for (const reportKind of [kind, 'works']) {
|
||||
const blocked = {...o, verdict: {tone: 'blocked', label: 'No arm64 build'}, compat_report: {...o.compat_report, verdict: reportKind}};
|
||||
app.offers = [blocked];
|
||||
if (!p.renderAppDetail() || !p.els.detailInstall.disabled) fail('report overrode hard blocker');
|
||||
if (p.storePickEligible(blocked)) fail('blocked app eligible for picks');
|
||||
if (reportKind!=='works' && !p.storeCard(app).includes('>'+label+'</span>')) fail('hard blocker hid report warning');
|
||||
}
|
||||
}
|
||||
|
||||
console.log("ok");
|
||||
@@ -22,6 +22,7 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
|
||||
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
|
||||
# A maintainer's machine holds the database key; send anything that slips through nowhere.
|
||||
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
|
||||
os.environ["FRAME_COMPAT_SEARCH_REPORTS"] = "0" # server subprocesses never start real report readers
|
||||
|
||||
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
|
||||
# its lock file) and the other per-user folders some modules use. A server started
|
||||
|
||||
+36
-17
@@ -13,11 +13,12 @@ BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
|
||||
(someone has to open Settings > Developer > Pair new host and approve it in
|
||||
the headset), checked, and taken out of authorized_keys again.
|
||||
|
||||
Everything it installs is removed again, also when a step fails: the titles
|
||||
(named fc_smoke_*, and leftovers of an interrupted run first), their Steam
|
||||
Everything it installs is removed again, also when a step fails: the reserved
|
||||
fc_smoke_{arm64,x86_64,exe} titles (and legacy leftovers first), their Steam
|
||||
shortcuts, the paired key, and ~/devkit-utils if it wasn't there before (if it
|
||||
was, it stays, synced to this checkout as Frame Control always does). A
|
||||
cleanup that fails is a failed step.
|
||||
cleanup that fails is a failed step. Test-owned processes are stopped and
|
||||
rechecked around deletion; shared Proton services are never stopped by name.
|
||||
|
||||
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
||||
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
|
||||
@@ -45,11 +46,17 @@ import frame_android # noqa: E402
|
||||
import frame_connect # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
import tiny_programs # noqa: E402
|
||||
import stop_processes # noqa: E402
|
||||
|
||||
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
|
||||
PREFIX = 'fc_smoke_'
|
||||
# Earlier runs named titles fc-smoke*, which Steam refused to register but left on disk.
|
||||
OLD_PREFIX = 'fc-smoke'
|
||||
|
||||
|
||||
def stop_titles(ids):
|
||||
"""Run the same bounded, PID-safe helper used by the local process tests."""
|
||||
return json.loads(frame_android.ssh(
|
||||
'python3 - ' + ' '.join(shlex.quote(gid) for gid in sorted(ids)),
|
||||
input=(HERE / 'stop_processes.py').read_text(), timeout=20))
|
||||
|
||||
|
||||
class Smoke:
|
||||
@@ -172,14 +179,17 @@ def title_cycle(smoke, kind, folder):
|
||||
return detail
|
||||
|
||||
def remove():
|
||||
stopped = stop_titles({gid})
|
||||
frame_titles.remove(gid)
|
||||
# A wrapper could have launched a child just as deletion completed.
|
||||
after = stop_titles({gid})
|
||||
left = ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip()
|
||||
if left:
|
||||
raise AssertionError(f'left behind: {left}')
|
||||
if gid in {t['id'] for t in frame_titles.list_titles()}:
|
||||
raise AssertionError(f'{gid} is still listed')
|
||||
smoke.installed.discard(gid)
|
||||
return {'removed': gid}
|
||||
return {'removed': gid, 'process_cleanup': [stopped, after]}
|
||||
|
||||
if smoke.step(f'{kind}: install', install)['ok']:
|
||||
smoke.step(f'{kind}: launch', launch)
|
||||
@@ -187,15 +197,21 @@ def title_cycle(smoke, kind, folder):
|
||||
|
||||
|
||||
def cleanup(smoke):
|
||||
"""Remove this run's titles and any fc_smoke_* an interrupted run left: the folder,
|
||||
its json files, and (through steamos-delete) Steam's shortcut. Raises if anything stays."""
|
||||
"""Stop reserved smoke processes and remove their titles and shortcuts.
|
||||
|
||||
Legacy fc-smoke-* IDs are included; arbitrary similarly named titles are
|
||||
never removed. Raises if any owned process, file or shortcut stays.
|
||||
"""
|
||||
problems = []
|
||||
try:
|
||||
ids = {t['id'] for t in frame_titles.list_titles() if t['id'].startswith((PREFIX, OLD_PREFIX))}
|
||||
ids = {t['id'] for t in frame_titles.list_titles() if t['id'] in stop_processes.IDS}
|
||||
except frame_android.FrameError as e:
|
||||
ids = set()
|
||||
problems.append(f'could not list titles: {e}')
|
||||
ids |= smoke.installed
|
||||
# Scan every reserved ID, even when a prior run removed its folder and
|
||||
# shortcut but left Proton running. Stop before removing any more files.
|
||||
stopped = stop_titles(stop_processes.IDS)
|
||||
gone = set()
|
||||
for gid in sorted(ids):
|
||||
try:
|
||||
@@ -226,9 +242,10 @@ def cleanup(smoke):
|
||||
smoke.installed.discard(gid)
|
||||
except (frame_android.FrameError, AssertionError) as e:
|
||||
problems.append(f"syncing Steam's shortcuts: {e}")
|
||||
after = stop_titles(stop_processes.IDS)
|
||||
if problems:
|
||||
raise AssertionError('; '.join(problems))
|
||||
return {'removed': sorted(ids)}
|
||||
return {'removed': sorted(ids), 'process_cleanup': [stopped, after]}
|
||||
|
||||
|
||||
# Runs on the Frame: drop the lines holding one key from authorized_keys, writing a
|
||||
@@ -307,15 +324,17 @@ def main():
|
||||
# Frame Control copies Valve's devkit tools to ~/devkit-utils on the first install
|
||||
# (as Valve's client does). If they weren't there before, they go again at the end.
|
||||
had_utils = ssh('test -d ~/devkit-utils && echo yes || true').strip() == 'yes'
|
||||
smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)
|
||||
ready = smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)['ok']
|
||||
folder = tempfile.mkdtemp(prefix='frame-smoke-')
|
||||
try:
|
||||
smoke.step('devkit service: properties.json', properties)
|
||||
smoke.step('status', status, build)
|
||||
for kind in ('arm64', 'x86_64', 'exe'):
|
||||
title_cycle(smoke, kind, folder)
|
||||
if args.pair:
|
||||
smoke.step('devkit pairing (throwaway key)', pair, folder)
|
||||
# Do not overwrite a leftover title when safe process cleanup failed.
|
||||
if ready:
|
||||
smoke.step('devkit service: properties.json', properties)
|
||||
smoke.step('status', status, build)
|
||||
for kind in ('arm64', 'x86_64', 'exe'):
|
||||
title_cycle(smoke, kind, folder)
|
||||
if args.pair:
|
||||
smoke.step('devkit pairing (throwaway key)', pair, folder)
|
||||
finally:
|
||||
smoke.step('cleanup: everything this run installed', cleanup, smoke)
|
||||
if not had_utils:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Linux-only smoke cleanup helper, sent over SSH before deleting test files.
|
||||
|
||||
Only exact, reserved smoke title directories establish ownership. Never signal
|
||||
by process name, process group, Steam app id, or a shared Wine/Proton prefix.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
IDS = frozenset(prefix + kind for prefix in ('fc_smoke_', 'fc-smoke-')
|
||||
for kind in ('arm64', 'x86_64', 'exe'))
|
||||
|
||||
|
||||
def owned(pid, roots):
|
||||
"""Require our uid and an exact cwd/exe or individual absolute argv path.
|
||||
|
||||
/proc retains a deleted cwd/exe with ' (deleted)' appended. This also lets
|
||||
the final sweep catch an older run whose title was already removed.
|
||||
"""
|
||||
proc = Path('/proc') / str(pid)
|
||||
try:
|
||||
if pid == os.getpid() or proc.stat().st_uid != os.getuid():
|
||||
return False
|
||||
state = (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
|
||||
if state == 'Z':
|
||||
return False
|
||||
paths = []
|
||||
for key in ('cwd', 'exe'):
|
||||
try:
|
||||
paths.append(os.readlink(proc / key).removesuffix(' (deleted)'))
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
try:
|
||||
paths += os.fsdecode((proc / 'cmdline').read_bytes()).split('\0')
|
||||
except PermissionError:
|
||||
pass
|
||||
return any(path == root or path.startswith(root + '/')
|
||||
for path in paths for root in roots)
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
return False
|
||||
|
||||
|
||||
def candidates(roots):
|
||||
return sorted(int(p.name) for p in Path('/proc').iterdir()
|
||||
if p.name.isdigit() and owned(int(p.name), roots))
|
||||
|
||||
|
||||
def stop(ids, grace=3):
|
||||
if not ids or not set(ids) <= IDS:
|
||||
raise ValueError('only reserved smoke title IDs may be stopped')
|
||||
if not hasattr(os, 'pidfd_open') or not hasattr(signal, 'pidfd_send_signal'):
|
||||
raise RuntimeError('safe smoke cleanup requires Linux Python with pidfd support')
|
||||
roots = [str(Path.home() / 'devkit-game' / gid) for gid in ids]
|
||||
stopped = set()
|
||||
for sig in (signal.SIGTERM, signal.SIGKILL):
|
||||
# Re-scan while waiting: wrappers can spawn a child during shutdown.
|
||||
deadline = time.monotonic() + grace
|
||||
while True:
|
||||
pids = candidates(roots)
|
||||
if not pids:
|
||||
return {'stopped_pids': sorted(stopped)}
|
||||
for pid in pids:
|
||||
try:
|
||||
fd = os.pidfd_open(pid)
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
try:
|
||||
# Open first, then revalidate ownership. The handle cannot
|
||||
# signal an unrelated process if this numeric PID is reused.
|
||||
if owned(pid, roots):
|
||||
try:
|
||||
signal.pidfd_send_signal(fd, sig)
|
||||
stopped.add(pid)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
finally:
|
||||
os.close(fd)
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
left = candidates(roots)
|
||||
if left:
|
||||
raise RuntimeError(f'smoke processes survived cleanup: {left}')
|
||||
return {'stopped_pids': sorted(stopped)}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(stop(sys.argv[1:])))
|
||||
@@ -0,0 +1,50 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||
function fixture() {
|
||||
const f=workspaceFixture();
|
||||
const start=html.indexOf('// ---- Read-only game storage ----');
|
||||
f.run(html.slice(start,html.indexOf('// ---- Backup and recovery kit',start)));
|
||||
const size=html.indexOf('function sizeText(');
|
||||
f.run(html.slice(size,html.indexOf('const backupKinds',size)));
|
||||
return f;
|
||||
}
|
||||
const sample={home:{state:'ok',total:1000,free:200,used:700},discovery:'permission-denied',libraries:[{
|
||||
path:'<img onerror=alert(1)>',capacity:{state:'missing'},state:'partial',gameBytes:10,toolBytes:0,
|
||||
recordedBytes:10,unknownSizes:1,issues:[{file:'bad.acf',state:'invalid-metadata'}],
|
||||
apps:[{appid:'1',name:'<script>bad</script>',bytes:null,kind:'game'},{appid:'2',name:'Zero',bytes:0,kind:'game'}]}]};
|
||||
test('navigation reaches storage and refresh runs once per visit',()=>{
|
||||
const f=fixture();let calls=0;f.context.loadStorage=()=>calls++;
|
||||
f.navigate('#storage');assert.equal(f.activePage,'tools');assert.equal(f.el('storage').hidden,false);
|
||||
assert.equal(f.el('storage').parentNode,f.el('toolWorkspace'));assert.equal(calls,1);
|
||||
});
|
||||
test('unknown, shared capacity, partial discovery and untrusted names remain explicit',()=>{
|
||||
const f=fixture();f.context.sample=sample;const out=f.run('renderStorage(sample)');
|
||||
assert.match(out,/Permission denied/);assert.match(out,/Missing or disconnected/);
|
||||
assert.match(out,/Unknown/);assert.match(out,/0 bytes/);assert.match(out,/Partial inventory/);
|
||||
assert.match(out,/must not be added/);assert.match(out,/Other usage is unmeasured/);
|
||||
assert.match(out,/<script>/);assert.doesNotMatch(out,/<script>|<img/);
|
||||
});
|
||||
test('refresh clears old data, ignores earlier requests and shows offline failure',async()=>{
|
||||
const f=fixture(),pending=[];
|
||||
f.context.api=()=>new Promise((resolve,reject)=>pending.push({resolve,reject}));
|
||||
f.el('storageResult').innerHTML='old data';
|
||||
const first=f.run('loadStorage()'),second=f.run('loadStorage()');
|
||||
assert.match(f.el('storageResult').textContent,/Reading/);
|
||||
pending[1].resolve(sample);await second;
|
||||
const current=f.el('storageResult').innerHTML;
|
||||
pending[0].resolve({...sample,libraries:[]});await first;
|
||||
assert.equal(f.el('storageResult').innerHTML,current);
|
||||
const last=f.run('loadStorage()');pending[2].reject(new Error('Offline'));await last;
|
||||
assert.match(f.el('storageResult').textContent,/Storage unavailable: Offline/);
|
||||
});
|
||||
test('a response or error from another headset cannot update the view',async()=>{
|
||||
for(const fail of [false,true]) {
|
||||
const f=fixture();let resolve,reject;
|
||||
f.context.api=()=>new Promise((yes,no)=>{resolve=yes;reject=no;});
|
||||
const request=f.run('loadStorage()');f.context.devGen++;
|
||||
f.el('storageResult').textContent='New headset';
|
||||
fail?reject(new Error('Old headset')):resolve(sample);await request;
|
||||
assert.equal(f.el('storageResult').textContent,'New headset');assert.equal(f.el('storageResult').innerHTML,'');
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Local APK/OBB backups: inspect as a unit, verify the target, and finish setup."""
|
||||
import sandbox # noqa: F401
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -173,5 +178,152 @@ class StagingTests(unittest.TestCase):
|
||||
{'android': False})
|
||||
|
||||
|
||||
class BundleTests(unittest.TestCase):
|
||||
setUp = BackupTests.setUp
|
||||
tearDown = StagingTests.tearDown
|
||||
def bundle(self, entries=None, suffix='.xapk'):
|
||||
path = self.root / ('backup' + suffix)
|
||||
path.write_bytes(apk(entries if entries is not None else {
|
||||
'backup/game.apk': self.apk.read_bytes(),
|
||||
f'backup/Android/obb/{PKG}/{self.obb[0].name}': b'game data',
|
||||
'backup/manifest.json': b'{"package_name":"untrusted.metadata"}',
|
||||
}))
|
||||
return path
|
||||
|
||||
def test_all_explicit_extensions_use_current_preview_and_cancel_cleanup(self):
|
||||
for suffix in ('.xapk', '.apks', '.apkm'):
|
||||
with self.subTest(suffix=suffix):
|
||||
source = self.bundle(suffix=suffix)
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
|
||||
ssh.assert_not_called()
|
||||
self.assertEqual(read['plan']['package'], PKG)
|
||||
self.assertEqual(read['plan']['obb'][0]['name'], self.obb[0].name)
|
||||
folders = list(server._android_backups[read['token']]['dirs'])
|
||||
server.android_backup({'action': 'discard', 'token': read['token']})
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
self.assertTrue(source.exists())
|
||||
|
||||
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
|
||||
source = self.bundle()
|
||||
token = server.android_backup({'action': 'begin'})['token']
|
||||
class Request:
|
||||
headers = {'X-Filename': source.name, 'X-Mode': 'android-backup',
|
||||
'X-Android-Backup': token, 'Content-Length': str(source.stat().st_size)}
|
||||
rfile = io.BytesIO(source.read_bytes())
|
||||
server.Handler.upload(Request())
|
||||
read = server.android_backup({'action': 'inspect', 'token': token})
|
||||
self.assertEqual(read['plan']['package'], PKG)
|
||||
self.assertEqual(len(server._android_backups[token]['dirs']), 2)
|
||||
folders = list(server._android_backups[token]['dirs'])
|
||||
server.android_backup({'action': 'discard', 'token': token})
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
|
||||
def test_reject_layouts_before_any_headset_access_and_cleanup_failed_stage(self):
|
||||
cases = [
|
||||
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'Split APK'),
|
||||
({'base.apk': self.apk.read_bytes(), f'Android/data/{PKG}/save': b'private'}, 'restore'),
|
||||
({'base.apk': self.apk.read_bytes(), 'main.1.org.other.game.obb': b'wrong'}, 'does not belong'),
|
||||
({'base.apk': self.apk.read_bytes(), '../outside.obb': b'bad'}, 'unsafe'),
|
||||
({'base.apk': self.apk.read_bytes(), '/outside.obb': b'bad'}, 'unsafe'),
|
||||
({'base.apk': self.apk.read_bytes(), 'CON.apk': b'bad'}, 'unsafe'),
|
||||
({'base.apk': self.apk.read_bytes(), 'other.exe': b'bad'}, 'Unsupported'),
|
||||
({'base.apk': self.apk.read_bytes(), 'BASE.apk': b'bad'}, 'duplicate'),
|
||||
({'manifest.json': b'{}'}, 'standalone APK'),
|
||||
]
|
||||
for entries, message in cases:
|
||||
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
|
||||
source = self.bundle(entries)
|
||||
with self.assertRaisesRegex(server.Failure, message):
|
||||
server.android_backup({'action': 'inspect', 'paths': [str(source)]})
|
||||
self.assertFalse(server._android_backups)
|
||||
ssh.assert_not_called()
|
||||
self.assertTrue(source.exists())
|
||||
|
||||
def test_links_limits_bad_zip_and_mixed_selection_are_refused(self):
|
||||
source = self.bundle({'base.apk': self.apk.read_bytes()})
|
||||
link = zipfile.ZipInfo('link.apk')
|
||||
link.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
with zipfile.ZipFile(source, 'a') as archive:
|
||||
archive.writestr(link, 'somewhere')
|
||||
destination = self.root / 'extract'
|
||||
destination.mkdir()
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'links'):
|
||||
backup.unpack_bundle(source, destination)
|
||||
self.assertEqual(list(destination.iterdir()), [])
|
||||
source = self.bundle()
|
||||
for limit, value, message in [('MAX_FILES', 1, 'too many'), ('MAX_BYTES', 1, 'size limit')]:
|
||||
with patch.object(backup, limit, value):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, message):
|
||||
backup.unpack_bundle(source, destination)
|
||||
with patch.object(backup.shutil, 'disk_usage', return_value=type('Disk', (), {'free': 0})()):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'staging space'):
|
||||
backup.unpack_bundle(source, destination)
|
||||
with self.assertRaisesRegex(server.Failure, 'one complete'):
|
||||
server.android_backup({'action': 'inspect', 'paths': [str(source), str(self.apk)]})
|
||||
source.write_bytes(b'not a zip')
|
||||
with self.assertRaisesRegex(server.Failure, 'Could not read'):
|
||||
server.android_backup({'action': 'inspect', 'paths': [str(source)]})
|
||||
self.assertFalse(server._android_backups)
|
||||
|
||||
def test_manifest_split_and_required_split_are_refused(self):
|
||||
from test_quest import axml
|
||||
for attrs in ({'split': 'config.arm64'}, {'split_required': True},
|
||||
{'split_required': True, 'resmap': True}):
|
||||
data = axml(PKG, **attrs)
|
||||
if attrs.get('resmap'):
|
||||
# Strip the attribute name, retaining the Android resource ID.
|
||||
data = data.replace('isSplitRequired'.encode('utf-16-le'),
|
||||
('x' * len('isSplitRequired')).encode('utf-16-le'))
|
||||
self.apk.write_bytes(apk({'AndroidManifest.xml': data}))
|
||||
with self.subTest(attrs=attrs):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'needs split APKs'):
|
||||
backup.inspect([str(self.apk)])
|
||||
|
||||
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
|
||||
source = self.bundle()
|
||||
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
|
||||
entry = server._android_backups[read['token']]
|
||||
folders = list(entry['dirs'])
|
||||
def job(label, work, **kwargs):
|
||||
return work(lambda *args: None)
|
||||
with patch.object(server, 'ensure_master'), patch.object(server, 'start_job', side_effect=job), \
|
||||
patch.object(backup, 'install', side_effect=backup.android.FrameError('fixture install failure')) as install:
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'fixture install failure'):
|
||||
server.android_backup({'action': 'install', 'token': read['token']})
|
||||
install.assert_called_once_with(entry['plan'], 'auto', unittest.mock.ANY)
|
||||
self.assertFalse(server._android_backups)
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
self.assertTrue(source.exists())
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'needs node')
|
||||
def test_actual_send_handler_routes_bundle_to_importer_and_pc_zip_to_titles(self):
|
||||
page = (Path(__file__).resolve().parents[1] / 'ui/index.html').read_text(encoding='utf-8')
|
||||
function = page[page.index('async function sendFilesOne('):page.index('$("androidBackupChoose").onclick')]
|
||||
script = r"""
|
||||
const calls = [];
|
||||
global.window = {connDevice:'fixture'};
|
||||
global.$ = () => ({});
|
||||
global.refresh = () => {};
|
||||
global.toast = msg => {throw new Error(msg)};
|
||||
global.TITLE_EXT = /\.(zip|exe)$/i;
|
||||
global.importAndroidFiles = async files => calls.push(['android', files.map(f => f.name)]);
|
||||
global.sideload = async file => calls.push(['title', file.name]);
|
||||
global.act = () => {throw new Error('unexpected copy')};
|
||||
(async () => {
|
||||
for (const name of ['game.xapk', 'game.apks', 'game.apkm', 'pc.zip']) {
|
||||
await sendFilesOne([{name, size:123}], new Set(), 'fixture');
|
||||
}
|
||||
await sendFilesOne([{name:'base.apk',size:123},{name:'split.apk',size:123}], new Set(), 'fixture');
|
||||
process.stdout.write(JSON.stringify(calls));
|
||||
})().catch(e => {console.error(e); process.exitCode = 1});
|
||||
"""
|
||||
result = subprocess.run(['node', '-e', function + script], capture_output=True, text=True, check=True)
|
||||
self.assertEqual(json.loads(result.stdout), [
|
||||
['android', ['game.xapk']], ['android', ['game.apks']],
|
||||
['android', ['game.apkm']], ['title', 'pc.zip'],
|
||||
['android', ['base.apk', 'split.apk']]])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -17,6 +17,9 @@ PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').rea
|
||||
|
||||
class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
p = patch.object(search, 'compat_reports', None)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
with _images._lock:
|
||||
_images._urls.clear()
|
||||
_images._tokens.clear()
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Search report snapshots: synthetic evidence only, no credentials or services."""
|
||||
import sandbox # noqa: F401
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_catalog
|
||||
import frame_compat_db as db
|
||||
from apk_sources import search
|
||||
from test_apk_search import SettingsTest, fake, ENTRIES
|
||||
|
||||
|
||||
class ReportTests(SettingsTest):
|
||||
def hook(self, rows):
|
||||
by = frame_catalog.reports.by_package(rows)
|
||||
snapshot = {pkg: v for pkg, rs in by.items() if (v := frame_catalog.reports.verdict(rs))}
|
||||
p = patch.object(search, 'compat_reports', lambda: snapshot)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_search_and_details_share_catalogue_rules(self):
|
||||
pkg = ENTRIES[0]['package']
|
||||
self.hook([dict(package=pkg, date='2026-10-01', result='runs'),
|
||||
dict(package=pkg, date='2026-10-02', rating='broken', notes='Crashes <here>'),
|
||||
dict(package=pkg, date='2026-10-03', result='runs')])
|
||||
mod = fake()
|
||||
with patch.object(search, 'modules', return_value=([mod], [])):
|
||||
offer = search.search(timeout=.01)['apps'][0]['offers'][0]
|
||||
detail = search.details('one', ENTRIES[0]['id'])
|
||||
self.assertEqual(detail['compat_report']['verdict'], 'no')
|
||||
self.assertEqual(offer['compat_report'], detail['compat_report'])
|
||||
self.assertEqual(detail['verdict']['tone'], 'warning')
|
||||
self.assertIn('Reported not working', detail['verdict']['label'])
|
||||
|
||||
def test_warnings_never_change_installability_or_hard_blockers(self):
|
||||
entry = dict(ENTRIES[0], min_sdk=23, abis=['arm64-v8a'])
|
||||
for kind, tone in [('works', 'works'), ('issues', 'issues'), ('broken', 'warning')]:
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', rating=kind)])
|
||||
decorated = search.decorate(entry)
|
||||
self.assertEqual(decorated['verdict']['tone'], tone)
|
||||
self.assertTrue(decorated['fit']['installable'])
|
||||
for blocker in [dict(min_sdk=31), dict(abis=['x86_64']),
|
||||
dict(requires_meta_services=True), dict(engine='VrApi')]:
|
||||
blocked = search.decorate(dict(entry, **blocker))
|
||||
self.assertEqual(blocked['verdict']['tone'], 'blocked')
|
||||
self.assertIsNotNone(blocked['compat_report'])
|
||||
|
||||
def test_wrong_file_failures_do_not_warn_but_real_failures_do(self):
|
||||
entry = dict(ENTRIES[0], min_sdk=23, abis=[])
|
||||
for notes in ['no arm64-v8a build', 'needs Android API 32']:
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes=notes)])
|
||||
self.assertIsNone(search.decorate(entry)['compat_report'])
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', result='crashes')])
|
||||
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes='installer crashed')])
|
||||
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
|
||||
|
||||
def test_report_warning_does_not_prevent_install(self):
|
||||
self.hook([dict(package=ENTRIES[0]['package'], date='2026-10-01', rating='broken')])
|
||||
mod = fake()
|
||||
with patch.object(search, 'modules', return_value=([mod], [])), \
|
||||
patch.object(frame_catalog.frame_android, 'install', return_value={'package': ENTRIES[0]['package']}) as install:
|
||||
result = search.install('one', ENTRIES[0]['id'])
|
||||
self.assertEqual(result['package'], ENTRIES[0]['package'])
|
||||
install.assert_called_once()
|
||||
|
||||
def test_stalled_loader_does_not_block_search_or_details(self):
|
||||
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
|
||||
main = threading.get_ident()
|
||||
|
||||
def loader():
|
||||
self.assertNotEqual(threading.get_ident(), main)
|
||||
entered.set()
|
||||
try:
|
||||
if not release.wait(2):
|
||||
raise TimeoutError('test stalled')
|
||||
return []
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
cache = search.ReportCache(loader, frame_catalog.reports.verdict)
|
||||
from apk_sources import _web
|
||||
with patch.object(search, 'compat_reports', cache.read), \
|
||||
patch.object(search, 'modules', return_value=([fake()], [])), \
|
||||
patch.object(_web, 'prune'), \
|
||||
patch.object(db, 'key', side_effect=AssertionError('credentials on request')), \
|
||||
patch.object(db._opener, 'open', side_effect=AssertionError('network on request')), \
|
||||
patch.object(db.subprocess, 'run', side_effect=AssertionError('subprocess on request')):
|
||||
try:
|
||||
search.warm() # production server startup, before the first request
|
||||
self.assertTrue(entered.wait(1))
|
||||
start = time.monotonic()
|
||||
result = search.search(timeout=.01)
|
||||
detail = search.details('one', ENTRIES[0]['id'])
|
||||
self.assertLess(time.monotonic() - start, .2)
|
||||
self.assertTrue(result['apps'])
|
||||
self.assertIsNone(detail['compat_report'])
|
||||
finally:
|
||||
release.set()
|
||||
self.assertTrue(finished.wait(1))
|
||||
|
||||
|
||||
class CacheTests(unittest.TestCase):
|
||||
def wait_idle(self, cache):
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline:
|
||||
with cache._lock:
|
||||
if not cache._running:
|
||||
return
|
||||
time.sleep(.001)
|
||||
self.fail('report worker did not finish')
|
||||
|
||||
def test_public_copy_reads_its_local_mirror_and_outbox(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mirror, outbox = Path(tmp) / 'mirror.json', Path(tmp) / 'outbox.jsonl'
|
||||
mirror.write_text(json.dumps({'reports': [dict(package='org.example.app', id='old',
|
||||
date='2026-10-01', result='runs')]}))
|
||||
outbox.write_text(json.dumps(dict(package='org.example.app', id='new',
|
||||
date='2026-10-02', rating='broken')) + '\n')
|
||||
with patch.object(db, 'MIRROR', str(mirror)), patch.object(db, 'OUTBOX', str(outbox)), \
|
||||
patch.object(db, '_mem', dict(at=0, reports=None, source=None)), \
|
||||
patch.object(db, 'shared', return_value=False), \
|
||||
patch.object(db, 'fetch_all', side_effect=AssertionError('private database read')):
|
||||
cache = search.ReportCache(db.load, frame_catalog.reports.verdict)
|
||||
cache.read()
|
||||
self.wait_idle(cache)
|
||||
self.assertEqual(cache.read()['org.example.app'][0], 'no')
|
||||
|
||||
def test_ttl_refresh_is_single_flight_and_keeps_last_good_snapshot(self):
|
||||
loader = Mock(return_value=[dict(package='org.example.app', date='2026-10-01', rating='issues')])
|
||||
cache = search.ReportCache(loader, frame_catalog.reports.verdict, ttl=60)
|
||||
cache.read()
|
||||
self.wait_idle(cache)
|
||||
first = cache.read()
|
||||
self.assertEqual(first['org.example.app'][0], 'maybe')
|
||||
self.assertEqual(loader.call_count, 1)
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
|
||||
def stalled():
|
||||
entered.set()
|
||||
release.wait(2)
|
||||
raise OSError('offline')
|
||||
|
||||
loader.side_effect = stalled
|
||||
cache._at -= 61
|
||||
try:
|
||||
self.assertIs(cache.read(), first)
|
||||
self.assertTrue(entered.wait(1))
|
||||
for _ in range(10):
|
||||
self.assertIs(cache.read(), first)
|
||||
self.assertEqual(loader.call_count, 2)
|
||||
finally:
|
||||
release.set()
|
||||
self.wait_idle(cache)
|
||||
self.assertIs(cache.read(), first)
|
||||
self.assertEqual(loader.call_count, 2) # failure also has a retry TTL
|
||||
loader.side_effect = None
|
||||
loader.return_value = [dict(package='org.example.app', date='2026-10-02', rating='works')]
|
||||
cache._at -= 61
|
||||
cache.read()
|
||||
self.wait_idle(cache)
|
||||
self.assertEqual(cache.read()['org.example.app'][0], 'works')
|
||||
self.assertEqual(first['org.example.app'][0], 'maybe') # published snapshots never mutated
|
||||
@@ -27,6 +27,9 @@ def fake(source_id='one', fn=None):
|
||||
|
||||
class SettingsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
p = patch.object(search, 'compat_reports', None)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json')
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Backup and recovery kit (ui/frame_backup.py, ui/frame_backup_remote.py) against a folder laid
|
||||
out like the Frame's home. The headset-side script runs for real, locally; only SSH is faked."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_android as android # noqa: E402
|
||||
import frame_backup as backup # noqa: E402
|
||||
import frame_backup_remote as remote # noqa: E402
|
||||
import server # noqa: E402
|
||||
|
||||
STEAM = '.local/share/Steam'
|
||||
USER = STEAM + '/steamapps/compatdata/%s/pfx/drive_c/users/steamuser'
|
||||
SECRETS = ['.steam/steam.token', STEAM + '/config/config.vdf', STEAM + '/config/loginusers.vdf',
|
||||
STEAM + '/ssfn123456', '.ssh/id_ed25519', '.ssh/authorized_keys',
|
||||
USER % 1234 + '/AppData/Roaming/Launcher/session.token',
|
||||
USER % 1234 + '/AppData/Roaming/Launcher/.ssh/id_rsa',
|
||||
'.config/cliproxyapi/secrets.env', '.local/share/keyrings/login.keyring']
|
||||
|
||||
|
||||
def write(home, rel, data=b'x'):
|
||||
path = Path(home) / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data if isinstance(data, bytes) else data.encode())
|
||||
return path
|
||||
|
||||
|
||||
def manifest(home, appid, name):
|
||||
write(home, '%s/steamapps/appmanifest_%s.acf' % (STEAM, appid),
|
||||
'"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n}\n' % (appid, name))
|
||||
|
||||
|
||||
def frame_home(home):
|
||||
"""A Frame before a reset: one Proton game, a Linux game, settings and secrets."""
|
||||
manifest(home, 1234, 'Test Game')
|
||||
write(home, USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat', b'save one')
|
||||
write(home, USER % 1234 + '/Documents/My Games/Game/profile.sav', b'profile')
|
||||
write(home, USER % 1234 + '/AppData/Local/Temp/junk.tmp', b'junk')
|
||||
write(home, USER % 1234 + '/AppData/Local/Microsoft/Windows/x.db', b'wine')
|
||||
write(home, USER % 999 + '/AppData/Local/Temp/only-cache', b'c') # no saves: no part
|
||||
write(home, STEAM + '/steamapps/compatdata/2800000001/internal/org.example/x', b'android')
|
||||
write(home, STEAM + '/userdata/555/config/localconfig.vdf', b'local')
|
||||
write(home, STEAM + '/userdata/555/config/shortcuts.vdf', b'shortcuts')
|
||||
write(home, STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', b'layout')
|
||||
write(home, STEAM + '/userdata/555/1234/remote/cloud.sav', b'cloud') # Steam Cloud's own
|
||||
write(home, STEAM + '/userdata/555/760/remote/1234/screenshots/a.jpg', b'shot')
|
||||
write(home, '.config/kdeglobals', b'[General]')
|
||||
write(home, '.config/openvr/config/steamvr.vrsettings', b'{}')
|
||||
write(home, '.config/unity3d/Company/LinuxGame/prefs', b'prefs')
|
||||
write(home, '.bashrc', b'# mine')
|
||||
for rel in SECRETS:
|
||||
write(home, rel, b'SECRET')
|
||||
|
||||
|
||||
class Remote(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.home = Path(self.tmp.name)
|
||||
patcher = patch.object(remote, 'HOME', self.home)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
|
||||
def files(self, part):
|
||||
return [rel for rel, _ in remote.part_files(part)[0]]
|
||||
|
||||
def test_plan_picks_saves_and_settings_and_never_secrets(self):
|
||||
frame_home(self.home)
|
||||
plan = remote.plan()
|
||||
ids = [p['id'] for p in plan['parts']]
|
||||
self.assertEqual(ids, ['saves:1234', 'saves:linux', 'settings:steam', 'settings:system'])
|
||||
self.assertEqual(plan['parts'][0]['label'], 'Test Game saves')
|
||||
saves = self.files('saves:1234')
|
||||
self.assertEqual(sorted(saves), [USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat',
|
||||
USER % 1234 + '/Documents/My Games/Game/profile.sav'])
|
||||
steam = self.files('settings:steam')
|
||||
self.assertIn(STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', steam)
|
||||
self.assertIn(STEAM + '/userdata/555/config/localconfig.vdf', steam)
|
||||
self.assertFalse([f for f in steam if '/1234/remote/' in f or '/760/' in f])
|
||||
everything = sum((self.files(i) for i in ids), [])
|
||||
for rel in SECRETS + ['.bashrc']:
|
||||
self.assertNotIn(rel, everything)
|
||||
self.assertFalse([f for f in everything if remote.is_secret(f)])
|
||||
self.assertTrue(any('steam.token' in x for x in plan['excluded']))
|
||||
# The controller layout goes back; localconfig.vdf and shortcuts.vdf are for reference.
|
||||
steam_part = plan['parts'][2]
|
||||
self.assertEqual((steam_part['files'], steam_part['restorable']), (3, 1))
|
||||
|
||||
def test_secret_names(self):
|
||||
for rel in SECRETS + ['a/b/ID_RSA.pub', 'x/kwalletd/k', 'x/cert.PEM']:
|
||||
self.assertTrue(remote.is_secret(rel), rel)
|
||||
for rel in ['saves/game.sav', 'Documents/tokens-collected.txt', 'config/localconfig.vdf']:
|
||||
self.assertFalse(remote.is_secret(rel), rel)
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_links_are_never_followed(self):
|
||||
frame_home(self.home)
|
||||
outside = write(self.home, 'elsewhere/private.txt', b'p')
|
||||
base = self.home / (USER % 1234) / 'Documents'
|
||||
os.symlink(str(outside.parent), str(base / 'linked-dir'))
|
||||
os.symlink(str(outside), str(base / 'linked-file'))
|
||||
self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f])
|
||||
|
||||
def test_big_files_are_listed_as_skipped(self):
|
||||
frame_home(self.home)
|
||||
with patch.object(remote, 'MAX_FILE', 3):
|
||||
files, skipped = remote.part_files('saves:1234')
|
||||
self.assertEqual(files, [])
|
||||
self.assertEqual({s['reason'] for s in skipped}, {'over 2 GiB'})
|
||||
|
||||
def test_pack_holds_exactly_the_planned_files(self):
|
||||
frame_home(self.home)
|
||||
out = io.BytesIO()
|
||||
remote.pack('saves:1234', out)
|
||||
out.seek(0)
|
||||
with tarfile.open(fileobj=out, mode='r:gz') as archive:
|
||||
names = sorted(m.name for m in archive)
|
||||
self.assertEqual(names, sorted(self.files('saves:1234')))
|
||||
with self.assertRaises(ValueError):
|
||||
remote.pack('saves:../../etc', io.BytesIO())
|
||||
|
||||
def archive(self, members):
|
||||
out = io.BytesIO()
|
||||
with tarfile.open(fileobj=out, mode='w:gz') as tar:
|
||||
for name, data in members:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
out.seek(0)
|
||||
return out
|
||||
|
||||
def test_check_after_a_reset(self):
|
||||
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
|
||||
ask = {'parts': [{'id': 'saves:1234', 'files': [save]},
|
||||
{'id': 'settings:steam', 'files': [STEAM + '/userdata/555/241100/remote/controller_config/1/a.vdf']},
|
||||
{'id': 'settings:system', 'files': ['.config/openvr/config/steamvr.vrsettings']},
|
||||
{'id': 'saves:../x', 'files': []}]}
|
||||
states = lambda: [p['status'] for p in remote.check(ask)['parts']] # noqa: E731
|
||||
self.assertEqual(states(), ['not_installed', 'no_account', 'reference', 'unknown'])
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
(self.home / STEAM / 'userdata/555').mkdir(parents=True)
|
||||
self.assertEqual(states()[:2], ['no_prefix', 'ok'])
|
||||
(self.home / (USER % 1234)).mkdir(parents=True)
|
||||
result = remote.check(ask)['parts'][0]
|
||||
self.assertEqual((result['status'], result['new'], result['replace']), ('ok', 1, 0))
|
||||
write(self.home, save, b'newer')
|
||||
self.assertEqual(remote.check(ask)['parts'][0]['replace'], 1)
|
||||
|
||||
def test_restore_replaces_only_its_files_and_keeps_the_old_ones(self):
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
|
||||
write(self.home, save, b'played since')
|
||||
other = write(self.home, USER % 1234 + '/AppData/Roaming/Studio/Game/other.dat', b'untouched')
|
||||
bashrc = write(self.home, '.bashrc', b'# mine')
|
||||
source = self.archive([(save, b'from backup'), (USER % 1234 + '/Documents/new.sav', b'new'),
|
||||
('.bashrc', b'# replaced?')])
|
||||
result = remote.restore('saves:1234', '20261023-120000', source)
|
||||
self.assertEqual((result['written'], result['replaced']), (2, 1))
|
||||
self.assertEqual(result['held'], {'reference': 1})
|
||||
self.assertEqual((self.home / save).read_bytes(), b'from backup')
|
||||
self.assertEqual(other.read_bytes(), b'untouched')
|
||||
self.assertEqual(bashrc.read_bytes(), b'# mine')
|
||||
kept = self.home / remote.PREVIOUS / '20261023-120000' / save
|
||||
self.assertEqual(kept.read_bytes(), b'played since')
|
||||
self.assertFalse([p for p in (self.home / save).parent.iterdir() if p.name.endswith('.part')])
|
||||
|
||||
def test_unsafe_archives_write_nothing(self):
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
(self.home / (USER % 1234)).mkdir(parents=True)
|
||||
good = (USER % 1234 + '/Documents/a.sav', b'a')
|
||||
for bad in ('../escape', '/etc/passwd', USER % 1234 + '/Documents/../../../../../../x',
|
||||
USER % 1234 + '/Documents/a.sav'):
|
||||
with self.assertRaises(ValueError, msg=bad):
|
||||
remote.restore('saves:1234', '20261023-120000', self.archive([good, (bad, b'evil')]))
|
||||
self.assertFalse((self.home / good[0]).exists(), bad)
|
||||
link = io.BytesIO()
|
||||
with tarfile.open(fileobj=link, mode='w:gz') as tar:
|
||||
info = tarfile.TarInfo(USER % 1234 + '/Documents/l')
|
||||
info.type, info.linkname = tarfile.SYMTYPE, '/etc/passwd'
|
||||
tar.addfile(info)
|
||||
link.seek(0)
|
||||
with self.assertRaises(ValueError):
|
||||
remote.restore('saves:1234', '20261023-120000', link)
|
||||
with self.assertRaises(ValueError):
|
||||
remote.restore('saves:1234', '../stamp', self.archive([good]))
|
||||
|
||||
def test_restore_refuses_secrets_and_other_games(self):
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
(self.home / (USER % 1234)).mkdir(parents=True)
|
||||
result = remote.restore('saves:1234', '20261023-120000', self.archive([
|
||||
(USER % 1234 + '/AppData/Roaming/L/auth.token', b't'), (USER % 5678 + '/Documents/x.sav', b'x')]))
|
||||
self.assertEqual(result['written'], 0)
|
||||
self.assertEqual(result['held'], {'refused': 1, 'reference': 1})
|
||||
|
||||
|
||||
class Session(unittest.TestCase):
|
||||
def state(self, targets, running=(), configured=None):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
conf = write(folder, 'sddm.conf', 'Session=%s\n' % (configured or 'gamescope.desktop'))
|
||||
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
|
||||
patch.object(remote, '_session_target', side_effect=targets), \
|
||||
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
|
||||
patch.object(remote.shutil, 'which', return_value='/usr/bin/steamosctl'):
|
||||
return remote.session()
|
||||
|
||||
def test_full_plasma_with_vr_gamescope_is_desktop(self):
|
||||
# Issue #142, SteamOS 0.5.6: steamvr-plasma also starts nested scripts.
|
||||
state = self.state(['inactive', 'active'],
|
||||
{'gamescope-wl', 'plasmashell', 'steamos-nested-desktop'},
|
||||
configured='gamescope.desktop')
|
||||
self.assertEqual(state['mode'], 'desktop')
|
||||
self.assertTrue(state['gamescope'])
|
||||
self.assertTrue(state['nested_desktop'])
|
||||
self.assertEqual(state['selector'], 'steamosctl')
|
||||
self.assertEqual(state['session_targets']['plasma-workspace-wayland.target'], 'active')
|
||||
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
|
||||
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_called_once_with('steamosctl switch-to-game-mode', timeout=60)
|
||||
|
||||
def test_game_target_wins_with_or_without_nested_desktop(self):
|
||||
for running in ((), {'gamescope-wl'}, {'gamescope', 'plasmashell', 'steamos-nested-desktop'}):
|
||||
state = self.state(['active', 'inactive'], running, configured='plasma.desktop')
|
||||
self.assertEqual(state['mode'], 'game')
|
||||
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
|
||||
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_conflicting_incomplete_and_transitional_targets_block_return(self):
|
||||
for targets in (['active', 'active'], ['inactive', 'inactive'], ['active', 'unknown'],
|
||||
['not-found', 'active'], ['unknown', 'unknown'], ['inactive', 'activating'],
|
||||
['deactivating', 'active'], ['failed', 'active']):
|
||||
with self.subTest(targets=targets):
|
||||
state = self.state(targets, {'gamescope', 'plasmashell'}, 'plasma.desktop')
|
||||
self.assertEqual(state['mode'], 'unknown')
|
||||
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
|
||||
with self.assertRaisesRegex(backup.BackupError, 'nothing was changed'):
|
||||
backup.return_to_game_mode(confirmed=True)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_older_builds_use_only_unambiguous_running_processes(self):
|
||||
cases = [((), 'unknown'), ({'plasmashell'}, 'desktop'),
|
||||
({'gamescope'}, 'game'), ({'gamescope-wl'}, 'game'),
|
||||
({'start-gamescope-session'}, 'game'),
|
||||
({'gamescope', 'plasmashell'}, 'unknown'),
|
||||
({'gamescope', 'steamos-nested-desktop'}, 'unknown')]
|
||||
for running, expected in cases:
|
||||
with self.subTest(running=running):
|
||||
state = self.state(['not-found', 'not-found'], running, 'plasma.desktop')
|
||||
self.assertEqual(state['mode'], expected)
|
||||
self.assertEqual(state['mode_source'], 'legacy-processes')
|
||||
|
||||
def test_target_probe_parses_systemd_and_uses_main_user_bus(self):
|
||||
cases = [('LoadState=loaded\nActiveState=active\n', 0, 'active'),
|
||||
('ActiveState=inactive\nLoadState=loaded\n', 0, 'inactive'),
|
||||
('LoadState=not-found\nActiveState=inactive\n', 0, 'not-found'),
|
||||
('LoadState=error\nActiveState=active\n', 0, 'unknown'),
|
||||
('garbage', 0, 'unknown'), ('LoadState=loaded\nActiveState=active', 1, 'unknown')]
|
||||
for stdout, code, expected in cases:
|
||||
with self.subTest(stdout=stdout, code=code), \
|
||||
patch.object(remote.os, 'getuid', return_value=1000, create=True), \
|
||||
patch.object(remote.subprocess, 'run', return_value=subprocess.CompletedProcess([], code, stdout)) as run:
|
||||
self.assertEqual(remote._session_target('gamescope-session.target'), expected)
|
||||
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'show',
|
||||
'gamescope-session.target', '--property=LoadState', '--property=ActiveState'])
|
||||
self.assertEqual(run.call_args.kwargs['timeout'], 5)
|
||||
self.assertEqual(run.call_args.kwargs['env']['DBUS_SESSION_BUS_ADDRESS'], 'unix:path=/run/user/1000/bus')
|
||||
self.assertEqual(run.call_args.kwargs['env']['XDG_RUNTIME_DIR'], '/run/user/1000')
|
||||
for error in (OSError('no systemctl'), subprocess.TimeoutExpired('systemctl', 5)):
|
||||
with patch.object(remote.os, 'getuid', return_value=1000, create=True), \
|
||||
patch.object(remote.subprocess, 'run', side_effect=error):
|
||||
self.assertEqual(remote._session_target('gamescope-session.target'), 'unknown')
|
||||
|
||||
def test_selector_preference_and_older_builds(self):
|
||||
for available, expected in [({'steamosctl', 'steamos-session-select'}, 'steamosctl'),
|
||||
({'holo-session-select', 'steamos-session-select'}, 'holo-session-select'),
|
||||
({'steamos-session-select'}, 'steamos-session-select'), (set(), None)]:
|
||||
with patch.object(remote, 'SDDM_CONFS', ()), \
|
||||
patch.object(remote, '_session_target', return_value='unknown'), \
|
||||
patch.object(remote, '_running', return_value=False), \
|
||||
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n in available else None):
|
||||
self.assertEqual(remote.session()['selector'], expected)
|
||||
|
||||
def test_script_probe_does_not_match_names_in_transmitted_python_source(self):
|
||||
with patch.object(remote.subprocess, 'call', return_value=1) as call:
|
||||
self.assertFalse(remote._running('steamos-nested-desktop', full=True))
|
||||
pattern = call.call_args.args[0][-1]
|
||||
for command in ('/usr/bin/steamos-nested-desktop',
|
||||
'/bin/sh /usr/bin/steamos-nested-desktop',
|
||||
'bash steamos-nested-desktop --flag'):
|
||||
self.assertIsNotNone(re.search(pattern, command), command)
|
||||
for command in ('python3 -c ' + backup.REMOTE.read_text(),
|
||||
'/bin/sh -c pgrep -f steamos-nested-desktop',
|
||||
'/usr/bin/steamos-nested-desktop-other'):
|
||||
self.assertIsNone(re.search(pattern, command), command)
|
||||
|
||||
|
||||
def fake_ssh(home, os_release):
|
||||
"""Runs the headset-side command locally, as the Frame's python3 would."""
|
||||
def run(command, src=None, dst=None, timeout=None):
|
||||
argv = shlex.split(command)
|
||||
assert argv[:2] == ['python3', '-c'], argv
|
||||
data = src if isinstance(src, bytes) else src.read() if src is not None else b''
|
||||
env = dict(os.environ, FRAME_BACKUP_HOME=str(home), FRAME_BACKUP_OS_RELEASE=str(os_release))
|
||||
r = subprocess.run([sys.executable, *argv[1:]], input=data, capture_output=True, env=env)
|
||||
if r.returncode:
|
||||
raise backup.BackupError(r.stderr.decode(errors='replace'))
|
||||
if dst is not None:
|
||||
dst.write(r.stdout)
|
||||
return None
|
||||
return r.stdout
|
||||
return run
|
||||
|
||||
|
||||
class Local(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
base = Path(self.tmp.name)
|
||||
self.home, self.store = base / 'frame', base / 'backups'
|
||||
self.release = write(base, 'os-release', 'ID=steamos\nVERSION_ID=0.4.1\nBUILD_ID=20260925.6191901\n')
|
||||
frame_home(self.home)
|
||||
for p in (patch.dict(os.environ, FRAME_BACKUP_DIR=str(self.store)),
|
||||
patch.object(backup, '_ssh', side_effect=fake_ssh(self.home, self.release))):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def android_backup(self, package, destination):
|
||||
write(Path(destination).parent, Path(destination).name, b'android archive ' + package.encode())
|
||||
return {'package': package, 'instance': android.instance_id(package), 'files': 3, 'bytes': 42,
|
||||
'path': str(destination), 'sha256': backup._sha256(destination)}
|
||||
|
||||
def make(self):
|
||||
apps = [{'package': 'org.example.open', 'label': 'Open app', 'running': True},
|
||||
{'package': 'org.example.game', 'label': 'Game', 'running': False}]
|
||||
with patch.object(android, 'list_apps', return_value=apps), \
|
||||
patch.object(android, 'backup_data', side_effect=self.android_backup) as data, \
|
||||
patch.object(backup.time, 'strftime', side_effect=lambda f: '2026-10-23-120000' if f == '%Y-%m-%d-%H%M%S'
|
||||
else '2026-10-23T12:00:00+0100'):
|
||||
result = backup.backup(headset='My Frame')
|
||||
data.assert_called_once()
|
||||
return result
|
||||
|
||||
def test_backup_writes_a_manifest_with_checksums(self):
|
||||
result = self.make()
|
||||
folder = backup.backup_path('2026-10-23-120000') # resolved: macOS's temp folder is a link
|
||||
self.assertEqual(result['path'], str(folder))
|
||||
self.assertEqual(sorted(p.name for p in self.store.iterdir()), ['2026-10-23-120000']) # no .partial left
|
||||
m = json.loads((folder / 'manifest.json').read_text())
|
||||
self.assertEqual(m['headset'], {'name': 'My Frame', 'build': '20260925.6191901', 'version': '0.4.1'})
|
||||
self.assertEqual([p['id'] for p in m['parts']],
|
||||
['saves:1234', 'saves:linux', 'settings:steam', 'settings:system', 'android:org.example.game'])
|
||||
for part in m['parts']:
|
||||
self.assertEqual(backup._sha256(folder / part['archive']), part['sha256'])
|
||||
saves = m['parts'][0]
|
||||
self.assertEqual(saves['archive'], 'saves/saves-1234.tar.gz')
|
||||
self.assertEqual(sum(f['bytes'] for f in saves['files']), saves['bytes'])
|
||||
self.assertEqual(saves['files'][0]['sha256'], __import__('hashlib').sha256(b'save one').hexdigest())
|
||||
self.assertEqual([s['id'] for s in m['skipped']], ['android:org.example.open'])
|
||||
for part in m['parts'][:-1]:
|
||||
with tarfile.open(str(folder / part['archive'])) as tar:
|
||||
for member in tar:
|
||||
self.assertFalse(remote.is_secret(member.name), member.name)
|
||||
self.assertNotEqual(tar.extractfile(member).read(), b'SECRET', member.name)
|
||||
|
||||
def test_a_failed_backup_leaves_nothing_behind(self):
|
||||
with patch.object(backup, 'inspect_archive', side_effect=backup.BackupError('cut off')), \
|
||||
patch.object(android, 'list_apps', return_value=[]):
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.backup(kinds=['saves'])
|
||||
self.assertEqual(list(self.store.iterdir()), [])
|
||||
|
||||
def test_backup_names_cannot_leave_the_folder(self):
|
||||
for name in ('../2026-10-23-120000', '2026-10-23-120000/..', '..', '/etc', 'C:\\x', '',
|
||||
'2026-10-23-120000/../../x', '2026-10-23-12000', None, 5):
|
||||
with self.assertRaises(backup.BackupError, msg=repr(name)):
|
||||
backup.backup_path(name)
|
||||
self.assertEqual(backup.backup_path('2026-10-23-120000'), (self.store / '2026-10-23-120000').resolve())
|
||||
|
||||
def test_manifest_entries_cannot_point_outside_the_backup(self):
|
||||
self.make()
|
||||
path = self.store / '2026-10-23-120000' / 'manifest.json'
|
||||
m = json.loads(path.read_text())
|
||||
for archive in ('../../secret.tar.gz', '/etc/passwd', 'saves/saves-5678.tar.gz'):
|
||||
m['parts'][0]['archive'] = archive
|
||||
path.write_text(json.dumps(m))
|
||||
with self.assertRaises(backup.BackupError, msg=archive):
|
||||
backup.read_manifest('2026-10-23-120000')
|
||||
|
||||
def reset(self):
|
||||
"""A factory reset: the home folder is empty again."""
|
||||
import shutil
|
||||
shutil.rmtree(str(self.home))
|
||||
self.home.mkdir()
|
||||
|
||||
def test_restore_after_a_reset_skips_what_is_not_back_yet(self):
|
||||
self.make()
|
||||
self.reset()
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
(self.home / (USER % 1234)).mkdir(parents=True) # started once
|
||||
with patch.object(android, 'list_apps', return_value=[]):
|
||||
plan = {p['id']: p for p in backup.restore_plan('2026-10-23-120000')['parts']}
|
||||
self.assertEqual(plan['saves:1234']['status'], 'ok')
|
||||
self.assertEqual(plan['settings:steam']['status'], 'no_account')
|
||||
self.assertEqual(plan['android:org.example.game']['status'], 'not_installed')
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.restore('2026-10-23-120000', ['saves:1234']) # not confirmed
|
||||
self.assertFalse((self.home / (USER % 1234) / 'AppData').exists())
|
||||
result = backup.restore('2026-10-23-120000', ['saves:1234', 'settings:steam', 'android:org.example.game'],
|
||||
confirmed=True)
|
||||
self.assertEqual([r['id'] for r in result['restored']], ['saves:1234'])
|
||||
self.assertEqual({s['id'] for s in result['skipped']}, {'settings:steam', 'android:org.example.game'})
|
||||
self.assertEqual((self.home / (USER % 1234) / 'AppData/Roaming/Studio/Game/save1.dat').read_bytes(), b'save one')
|
||||
|
||||
def test_android_data_goes_back_through_the_existing_helper(self):
|
||||
self.make()
|
||||
apps = [{'package': 'org.example.game', 'label': 'Game', 'running': False}]
|
||||
with patch.object(android, 'list_apps', return_value=apps), \
|
||||
patch.object(android, 'restore_data', return_value={'restored': True}) as restore:
|
||||
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
|
||||
restore.assert_called_once_with('org.example.game', str(backup.backup_path('2026-10-23-120000') /
|
||||
'android' / 'org.example.game.tar.gz'))
|
||||
self.assertEqual(len(result['restored']), 1)
|
||||
apps[0]['running'] = True
|
||||
with patch.object(android, 'list_apps', return_value=apps), patch.object(android, 'restore_data') as restore:
|
||||
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
|
||||
restore.assert_not_called()
|
||||
self.assertEqual(result['skipped'][0]['reason'], 'Close the app first')
|
||||
|
||||
def test_a_damaged_archive_is_skipped(self):
|
||||
self.make()
|
||||
with open(str(self.store / '2026-10-23-120000/saves/saves-1234.tar.gz'), 'ab') as f:
|
||||
f.write(b'tampered')
|
||||
with patch.object(android, 'list_apps', return_value=[]):
|
||||
result = backup.restore('2026-10-23-120000', ['saves:1234'], confirmed=True)
|
||||
self.assertEqual(result['restored'], [])
|
||||
self.assertIn('checksum', result['skipped'][0]['reason'])
|
||||
|
||||
def test_list_backups(self):
|
||||
self.make()
|
||||
(self.store / '.2026-10-23-130000.partial').mkdir()
|
||||
(self.store / 'notes').mkdir()
|
||||
listed = backup.list_backups()['backups']
|
||||
self.assertEqual([b['name'] for b in listed], ['2026-10-23-120000'])
|
||||
self.assertEqual(listed[0]['build'], '20260925.6191901')
|
||||
|
||||
|
||||
class Ssh(unittest.TestCase):
|
||||
def test_bytes_go_as_input_and_files_as_stdin(self):
|
||||
done = subprocess.CompletedProcess([], 0, b'{}', b'')
|
||||
with patch.object(backup.frame_host, 'run_ssh', return_value=done) as run:
|
||||
backup._ssh('true', src=b'{"parts": []}')
|
||||
self.assertEqual(run.call_args.kwargs['input'], b'{"parts": []}')
|
||||
self.assertNotIn('stdin', run.call_args.kwargs)
|
||||
with tempfile.TemporaryFile() as f:
|
||||
backup._ssh('true', src=f)
|
||||
self.assertIs(run.call_args.kwargs['stdin'], f)
|
||||
backup._ssh('true')
|
||||
self.assertIs(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
|
||||
self.assertEqual(run.call_args.args[0][-1], 'true')
|
||||
with patch.object(backup.frame_host, 'run_ssh',
|
||||
return_value=subprocess.CompletedProcess([], 1, b'', b'Permission denied')):
|
||||
with self.assertRaisesRegex(backup.BackupError, 'Permission denied'):
|
||||
backup._ssh('true')
|
||||
|
||||
|
||||
class GameMode(unittest.TestCase):
|
||||
def test_command_is_only_ever_the_session_switch(self):
|
||||
self.assertEqual(backup.session_command('steamosctl'), 'steamosctl switch-to-game-mode')
|
||||
self.assertEqual(backup.session_command('steamos-session-select'), 'steamos-session-select gamescope')
|
||||
self.assertEqual(backup.session_command('holo-session-select'), 'holo-session-select gamescope')
|
||||
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot',
|
||||
'steamosctl switch-to-desktop-mode', 'steamosctl; reboot'):
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.session_command(bad)
|
||||
|
||||
def test_switch_needs_confirmation_and_desktop_mode(self):
|
||||
with patch.object(backup, '_ssh') as ssh:
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.return_to_game_mode()
|
||||
ssh.assert_not_called()
|
||||
with patch.object(backup, 'session_status', return_value={'mode': 'game', 'selector': 'steamos-session-select'}), \
|
||||
patch.object(backup, '_ssh') as ssh:
|
||||
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_not_called()
|
||||
with patch.object(backup, 'session_status', return_value={'mode': 'unknown', 'selector': 'steamos-session-select'}), \
|
||||
patch.object(backup, '_ssh') as ssh:
|
||||
with self.assertRaisesRegex(backup.BackupError, 'nothing was changed'):
|
||||
backup.return_to_game_mode(confirmed=True)
|
||||
ssh.assert_not_called()
|
||||
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': 'steamos-session-select'}), \
|
||||
patch.object(backup, '_ssh') as ssh:
|
||||
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_called_once_with('steamos-session-select gamescope', timeout=60)
|
||||
ssh.side_effect = backup.BackupError('the headset took too long to answer') # the session restarted
|
||||
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.side_effect = backup.BackupError('Interactive authentication required')
|
||||
with self.assertRaisesRegex(backup.BackupError, 'manual steps'):
|
||||
backup.return_to_game_mode(confirmed=True)
|
||||
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': None}), \
|
||||
patch.object(backup, '_ssh') as ssh:
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.return_to_game_mode(confirmed=True)
|
||||
ssh.assert_not_called()
|
||||
|
||||
|
||||
class Endpoint(unittest.TestCase):
|
||||
def test_guards_run_before_the_headset(self):
|
||||
with patch.object(backup, '_ssh') as ssh, patch.object(server, 'start_job') as job:
|
||||
for body in ({'action': 'plan', 'name': '../x'}, {'action': 'open', 'name': '..'},
|
||||
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:1234']},
|
||||
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:x'], 'confirm': True},
|
||||
{'action': 'gamemode'}, {'action': 'backup', 'kinds': ['ssh-keys']}, {'action': 'nope'}):
|
||||
with self.assertRaises(server.Failure, msg=body) as caught:
|
||||
server.backup_action(body)
|
||||
self.assertEqual(caught.exception.status, 400)
|
||||
ssh.assert_not_called()
|
||||
job.assert_not_called()
|
||||
with patch.object(server, 'LOCAL', True):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.backup_action({'action': 'list'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Read-only sysfs battery parsing; fixture values, no real headset access."""
|
||||
import sandbox # noqa: F401
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_status as status
|
||||
|
||||
|
||||
class Battery(unittest.TestCase):
|
||||
def probe(self, fields):
|
||||
# Real files exercise missing files, whitespace and integer conversion.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
node = Path(tmp) / 'max1720x_bat_7-36'
|
||||
node.mkdir()
|
||||
for name, value in dict(type='Battery', **fields).items():
|
||||
(node / name).write_text(str(value) + '\n')
|
||||
with mock.patch.object(status.glob, 'glob', return_value=[str(node)]):
|
||||
result = status.battery()
|
||||
if result:
|
||||
result.pop('source')
|
||||
return result
|
||||
|
||||
def test_frame_units_and_optional_health(self):
|
||||
b = self.probe(dict(capacity=73, status='Charging', voltage_now=3800000,
|
||||
current_now=1000000, temp=325, health='Good',
|
||||
charge_full=4600000, charge_full_design=5000000,
|
||||
cycle_count=42, time_to_full_now=3600))
|
||||
self.assertEqual(b, dict(percent=73, status='Charging', watts=3.8,
|
||||
tempC=32.5, health='Good', timeToFull=3600,
|
||||
timeToEmpty=None, cycleCount=42, fullMah=4600,
|
||||
designMah=5000, capacityHealthPercent=92))
|
||||
|
||||
def test_zero_negative_current_and_temperature(self):
|
||||
b = self.probe(dict(capacity=0, cycle_count=0, temp=-50,
|
||||
voltage_now=3800000, current_now=-1000000))
|
||||
self.assertEqual((b['percent'], b['cycleCount'], b['tempC'], b['watts']),
|
||||
(0, 0, -5, -3.8))
|
||||
|
||||
def test_missing_optional_sensors_stay_null(self):
|
||||
b = self.probe(dict(capacity=50, status='Discharging'))
|
||||
self.assertEqual(b['percent'], 50)
|
||||
self.assertTrue(all(value is None for key, value in b.items()
|
||||
if key not in ('percent', 'status')))
|
||||
|
||||
def test_invalid_values_do_not_break_other_readings(self):
|
||||
for invalid in ('abc', '', '-1', '101', '12.5'):
|
||||
b = self.probe(dict(capacity=invalid, status='Charging', temp='bad',
|
||||
charge_full=4500000, charge_full_design=0,
|
||||
time_to_full_now=-1, time_to_empty_now=4294967295,
|
||||
cycle_count=-1, voltage_now='bad', current_now=100))
|
||||
self.assertIsNone(b['percent'])
|
||||
for key in ('watts', 'tempC', 'capacityHealthPercent', 'timeToFull', 'timeToEmpty', 'cycleCount'):
|
||||
self.assertIsNone(b[key], key)
|
||||
self.assertEqual(b['status'], 'Charging')
|
||||
|
||||
def test_no_battery_and_unreadable_type(self):
|
||||
with mock.patch.object(status.glob, 'glob', return_value=[]):
|
||||
self.assertIsNone(status.battery())
|
||||
with mock.patch.object(status.glob, 'glob', return_value=['/missing']), \
|
||||
mock.patch('builtins.open', side_effect=PermissionError):
|
||||
self.assertIsNone(status.battery())
|
||||
|
||||
def test_prefers_frame_and_excludes_peripheral_or_absent_batteries(self):
|
||||
paths = ['/sys/class/power_supply/aaa', '/sys/class/power_supply/max1720x_bat_7-36']
|
||||
def read(path):
|
||||
return {'type': 'Battery', 'capacity': '80'}.get(path.rsplit('/', 1)[-1])
|
||||
with mock.patch.object(status.glob, 'glob', return_value=paths), \
|
||||
mock.patch.object(status, 'read', side_effect=read):
|
||||
self.assertEqual(status.battery()['source'], paths[1])
|
||||
self.assertIsNone(self.probe(dict(scope='Device', capacity=99)))
|
||||
self.assertIsNone(self.probe(dict(present=0, capacity=99)))
|
||||
|
||||
def test_permission_failure_affects_only_one_sensor(self):
|
||||
actual_open = open
|
||||
def guarded(path, *args, **kwargs):
|
||||
if str(path).endswith('/temp'):
|
||||
raise PermissionError('unreadable')
|
||||
return actual_open(path, *args, **kwargs)
|
||||
with mock.patch('builtins.open', side_effect=guarded):
|
||||
b = self.probe(dict(capacity=45, temp=300))
|
||||
self.assertEqual(b['percent'], 45)
|
||||
self.assertIsNone(b['tempC'])
|
||||
+35
-6
@@ -335,8 +335,10 @@ class Commands(unittest.TestCase):
|
||||
|
||||
|
||||
# A stand-in for ffmpeg on the Frame: records how it was run, writes its output file,
|
||||
# and finishes the file when asked to stop (SIGTERM), as ffmpeg does. FAKE_FFMPEG_RUN
|
||||
# makes the recording end by itself after that many seconds (the time limit).
|
||||
# and finishes the file when q arrives on stdin. Like SteamOS's ffmpeg n7.0 (seen on a
|
||||
# Frame), it catches SIGTERM and SIGINT and carries on, exiting only after more than
|
||||
# three. FAKE_FFMPEG_RUN makes the recording end by itself after that many seconds (the
|
||||
# time limit); FAKE_FFMPEG_DEAF makes it ignore q too, so only SIGKILL ends it.
|
||||
FAKE_FFMPEG = r'''#!/usr/bin/env python3
|
||||
import json, os, signal, sys, time
|
||||
home = os.environ["HOME"]
|
||||
@@ -347,8 +349,22 @@ if "-c" in sys.argv and sys.argv[sys.argv.index("-c") + 1] == "copy": # the re
|
||||
src = sys.argv[sys.argv.index("-i") + 1]
|
||||
open(out, "wb").write(open(src, "rb").read() + b"+mp4")
|
||||
sys.exit(0)
|
||||
stop = []
|
||||
signal.signal(signal.SIGTERM, lambda *a: stop.append("TERM"))
|
||||
import threading
|
||||
stop, signals = [], []
|
||||
def caught(*a):
|
||||
signals.append(1)
|
||||
if len(signals) > 3:
|
||||
os._exit(255) # "Received > 3 system signals, hard exiting"
|
||||
signal.signal(signal.SIGTERM, caught)
|
||||
signal.signal(signal.SIGINT, caught)
|
||||
def keys():
|
||||
while True:
|
||||
ch = os.read(0, 1)
|
||||
if not ch:
|
||||
return
|
||||
if ch == b"q" and not os.environ.get("FAKE_FFMPEG_DEAF"):
|
||||
stop.append("q")
|
||||
threading.Thread(target=keys, daemon=True).start()
|
||||
f = open(out, "wb")
|
||||
f.write(b"v"); f.flush()
|
||||
with open(os.path.join(home, "ffmpeg.pid.tmp"), "w") as p:
|
||||
@@ -439,7 +455,7 @@ class Recording(unittest.TestCase):
|
||||
self.assertEqual(rec.stop()["state"], "stopping")
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual(done["state"], "saved", done)
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "TERM") # asked to finish, not killed
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q") # asked to finish, not killed
|
||||
saved = Path(done["file"])
|
||||
self.assertEqual(saved.parent, self.here)
|
||||
self.assertRegex(saved.name, r"^rec-\d{8}-\d{6}\.mp4$")
|
||||
@@ -457,7 +473,20 @@ class Recording(unittest.TestCase):
|
||||
pid = int((self.frame / "ffmpeg.pid").read_text())
|
||||
self.channels[0].stdin.close()
|
||||
self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "TERM")
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q")
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
for _ in range(100):
|
||||
os.kill(pid, 0)
|
||||
time.sleep(0.05)
|
||||
|
||||
def test_an_ffmpeg_that_ignores_q_is_killed_not_left_recording(self):
|
||||
self.env["FAKE_FFMPEG_DEAF"] = "1"
|
||||
rec = self.recorder()
|
||||
rec.start()
|
||||
pid = int((self.frame / "ffmpeg.pid").read_text())
|
||||
rec.stop()
|
||||
self.wait_for(rec, ("saved", "failed"), timeout=30)
|
||||
self.assertFalse((self.frame / "ffmpeg-ended").exists()) # it never finished by itself
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
for _ in range(100):
|
||||
os.kill(pid, 0)
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Force quit (ui/frame_running.py and /api/running) without a headset: running-game
|
||||
detection, which processes may be signalled, SIGTERM-then-SIGKILL, and the Android path.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_running # noqa: E402
|
||||
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
|
||||
|
||||
ME, OTHER = 1000, 0
|
||||
HOME = frame_running.HOME
|
||||
REAPER = f"{HOME}/.local/share/Steam/ubuntu12_32/reaper"
|
||||
|
||||
|
||||
def proc(ppid, comm, argv=None, uid=ME, start=None, state="S"):
|
||||
return {"ppid": ppid, "comm": comm, "argv": argv if argv is not None else [comm], "uid": uid,
|
||||
"start": start or 1000 + ppid, "state": state}
|
||||
|
||||
|
||||
def frame():
|
||||
"""A Frame's processes, as Steam on Linux launches games: Steam's reaper with
|
||||
AppId= is the parent of the game (Portal 2 here, appid 620)."""
|
||||
return {
|
||||
1: proc(0, "systemd", uid=OTHER),
|
||||
200: proc(1, "sshd", uid=OTHER),
|
||||
201: proc(200, "sshd-session"),
|
||||
202: proc(201, "bash"),
|
||||
203: proc(202, "python3", ["python3", "-", "quit", "620"]), # this helper
|
||||
300: proc(1, "systemd", ["/usr/lib/systemd/systemd", "--user"]),
|
||||
301: proc(300, "gamescope-session"),
|
||||
302: proc(301, "gamescope"),
|
||||
303: proc(302, "steam", [f"{HOME}/.local/share/Steam/ubuntu12_32/steam", "-cef-enable-debugging"]),
|
||||
304: proc(303, "steamwebhelper"),
|
||||
305: proc(300, "vrserver"),
|
||||
306: proc(300, "plasmashell"),
|
||||
307: proc(300, "pipewire"),
|
||||
400: proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=620", "--", "steam-launch-wrapper", "--", "portal2.sh"]),
|
||||
401: proc(400, "steam-launch-wr", ["steam-launch-wrapper", "--", "portal2.sh"]),
|
||||
402: proc(401, "portal2.sh", ["/bin/sh", "portal2.sh"]),
|
||||
403: proc(402, "portal2_linux", ["./portal2_linux", "-game", "portal2"]),
|
||||
404: proc(403, "steam", ["steam"]), # a child named like Steam: left alone
|
||||
405: proc(404, "crashhandler"), # and so is anything under it
|
||||
# Another game, and processes that only look like launches.
|
||||
500: proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=999", "--", "other"]),
|
||||
501: proc(500, "other"),
|
||||
600: proc(1, "reaper", [REAPER, "SteamLaunch", "AppId=620", "--", "x"], uid=OTHER),
|
||||
601: proc(300, "bash", ["bash", "-c", "echo reaper SteamLaunch AppId=620"]),
|
||||
602: proc(300, "fakereaper", ["/tmp/notreaper", "SteamLaunch", "AppId=620"]),
|
||||
}
|
||||
|
||||
|
||||
class Parsing(unittest.TestCase):
|
||||
def test_launch_appid(self):
|
||||
cases = [
|
||||
([REAPER, "SteamLaunch", "AppId=620", "--", "game"], 620),
|
||||
(["reaper", "SteamLaunch", "AppId=3346865537", "--", "lepton-app.sh"], 3346865537),
|
||||
([REAPER, "AppId=620", "--", "game"], None), # no SteamLaunch
|
||||
([REAPER, "SteamLaunch", "--", "game", "AppId=620"], None), # the game's own arguments
|
||||
(["/usr/bin/steam", "SteamLaunch", "AppId=620"], None), # not the reaper
|
||||
(["/tmp/reaper-ish", "SteamLaunch", "AppId=620"], None),
|
||||
([REAPER, "SteamLaunch", "AppId=62x0"], None),
|
||||
([REAPER, "SteamLaunch", "AppId=12345678901"], None), # more than 10 digits
|
||||
([], None),
|
||||
]
|
||||
for argv, want in cases:
|
||||
with self.subTest(argv=argv):
|
||||
self.assertEqual(frame_running.launch_appid(argv), want)
|
||||
|
||||
def test_reapers_are_this_users_live_launches(self):
|
||||
procs = frame()
|
||||
procs[700] = proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=777"], state="Z")
|
||||
self.assertEqual(frame_running.reapers(procs, ME), {620: [400], 999: [500]})
|
||||
|
||||
def test_steamvr_is_never_a_game(self):
|
||||
procs = frame()
|
||||
procs[710] = proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=250820", "--", "vrstartup"])
|
||||
procs[711] = proc(710, "vrcompositor-launcher")
|
||||
self.assertNotIn(250820, frame_running.reapers(procs, ME))
|
||||
found = frame_running.running(procs, ME, steam=lambda ids: ([250820], {}),
|
||||
android=dict, containers=set)
|
||||
self.assertNotIn(250820, [a["appid"] for a in found])
|
||||
|
||||
def test_gameid(self):
|
||||
self.assertEqual(frame_running.gameid(620), "620")
|
||||
# A non-Steam shortcut: (appid << 32) | 0x02000000, as steam_shortcuts.py stop sends.
|
||||
self.assertEqual(frame_running.gameid(3346865537), str((3346865537 << 32) | 0x02000000))
|
||||
|
||||
def test_snapshot_reads_proc(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(f"{tmp}/42")
|
||||
os.makedirs(f"{tmp}/self")
|
||||
fields = " ".join(["S", "7"] + ["0"] * 17 + ["123456"])
|
||||
Path(f"{tmp}/42/stat").write_text(f"42 (my (game) x) {fields} 0 0\n")
|
||||
Path(f"{tmp}/42/cmdline").write_bytes(b"reaper\0SteamLaunch\0AppId=5\0--\0g\0")
|
||||
os.makedirs(f"{tmp}/43") # vanished between listdir and read
|
||||
got = frame_running.snapshot(tmp)
|
||||
self.assertEqual(list(got), [42])
|
||||
self.assertEqual(got[42]["comm"], "my (game) x")
|
||||
self.assertEqual((got[42]["ppid"], got[42]["start"], got[42]["state"]), (7, 123456, "S"))
|
||||
self.assertEqual(got[42]["argv"], ["reaper", "SteamLaunch", "AppId=5", "--", "g"])
|
||||
|
||||
|
||||
class Detection(unittest.TestCase):
|
||||
def run_list(self, procs, steam=(None, {}), android=None, containers=()):
|
||||
return frame_running.running(procs, ME, steam=lambda ids: steam, android=lambda: android or {},
|
||||
containers=lambda: set(containers))
|
||||
|
||||
def test_steam_game_named_by_steam(self):
|
||||
got = self.run_list(frame(), steam=([620], {620: "Portal 2", 999: "Other"}))
|
||||
self.assertEqual([(a["appid"], a["kind"], a["name"]) for a in got],
|
||||
[(620, "steam", "Portal 2"), (999, "steam", "Other")])
|
||||
self.assertEqual(got[0]["gameid"], "620")
|
||||
self.assertEqual(got[0]["processes"], 6)
|
||||
|
||||
def test_without_steam_the_manifest_names_it(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(f"{tmp}/steamapps")
|
||||
Path(f"{tmp}/steamapps/appmanifest_620.acf").write_text('"AppState"\n{\n\t"appid"\t"620"\n\t"name"\t"Portal 2"\n}')
|
||||
with mock.patch.object(frame_running, "STEAM", tmp):
|
||||
got = self.run_list(frame())
|
||||
self.assertEqual([(a["appid"], a["name"]) for a in got], [(620, "Portal 2"), (999, "App 999")])
|
||||
|
||||
def test_nothing_running(self):
|
||||
procs = {pid: p for pid, p in frame().items() if pid < 400}
|
||||
self.assertEqual(self.run_list(procs), [])
|
||||
|
||||
def test_steam_only_listing_counts(self):
|
||||
# Steam says it's running but no reaper was found: listed, with no processes.
|
||||
got = self.run_list({1: proc(0, "systemd", uid=OTHER)}, steam=([620], {620: "Portal 2"}))
|
||||
self.assertEqual(got, [{"appid": 620, "gameid": "620", "processes": 0, "kind": "steam", "name": "Portal 2"}])
|
||||
|
||||
def test_devkit_title_and_shortcut(self):
|
||||
sid, other = 0x80001234, 0x80005678
|
||||
procs = {1: proc(0, "systemd", uid=OTHER),
|
||||
10: proc(1, "reaper", [REAPER, "SteamLaunch", f"AppId={sid}", "--", f'"{HOME}/devkit-game/Cool_Game/run.sh"']),
|
||||
11: proc(10, "run.sh", ["/bin/sh", f"{HOME}/devkit-game/Cool_Game/run.sh"]),
|
||||
20: proc(1, "reaper", [REAPER, "SteamLaunch", f"AppId={other}", "--", "/usr/bin/flatpak", "run", "x"])}
|
||||
got = self.run_list(procs)
|
||||
self.assertEqual([(a["kind"], a.get("title"), a["name"]) for a in got],
|
||||
[("devkit", "Cool_Game", "Cool_Game"), ("shortcut", None, f"Shortcut {other}")])
|
||||
self.assertEqual(got[0]["gameid"], str((sid << 32) | 0x02000000))
|
||||
|
||||
def test_android_app_from_its_container(self):
|
||||
meta = {"package": "org.example.game", "label": "Example", "shortcut": 3346865537, "instance": 2812345678}
|
||||
apps = {3346865537: meta, 2812345678: meta}
|
||||
got = self.run_list({1: proc(0, "systemd", uid=OTHER)}, android=apps,
|
||||
containers={"lepton-steamlaunch-2812345678"})
|
||||
self.assertEqual(got, [{"appid": 3346865537, "gameid": str((3346865537 << 32) | 0x02000000), "processes": 0,
|
||||
"kind": "android", "package": "org.example.game", "name": "Example"}])
|
||||
# Its container stopped: not running.
|
||||
self.assertEqual(self.run_list({1: proc(0, "systemd", uid=OTHER)}, android=apps, containers=()), [])
|
||||
|
||||
def test_android_meta_reads_shortcut_and_instance(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(f"{tmp}/org.example.game")
|
||||
Path(f"{tmp}/org.example.game/meta.json").write_text(json.dumps(
|
||||
{"package": "org.example.game", "shortcut": 3346865537, "instance": 2812345678}))
|
||||
os.makedirs(f"{tmp}/broken")
|
||||
Path(f"{tmp}/broken/meta.json").write_text("{")
|
||||
with mock.patch.object(frame_running, "ANDROID", tmp):
|
||||
self.assertEqual(sorted(frame_running.android_apps()), [2812345678, 3346865537])
|
||||
|
||||
|
||||
class World:
|
||||
"""Processes that react to signals: kill() removes them, unless they ignore it."""
|
||||
|
||||
def __init__(self, procs, ignore=(), unkillable=()):
|
||||
self.procs, self.ignore, self.unkillable = dict(procs), set(ignore), set(unkillable)
|
||||
self.sent, self.now = [], 0.0
|
||||
|
||||
def scan(self):
|
||||
return dict(self.procs)
|
||||
|
||||
def kill(self, pid, sig):
|
||||
if pid not in self.procs:
|
||||
raise ProcessLookupError
|
||||
self.sent.append((pid, sig))
|
||||
if pid in self.unkillable or (sig == frame_running.SIGTERM and pid in self.ignore):
|
||||
return
|
||||
self.procs.pop(pid)
|
||||
# A reaper exits once its last child has.
|
||||
for rid, p in list(self.procs.items()):
|
||||
if p["comm"] == "reaper" and not any(c["ppid"] == rid for c in self.procs.values()):
|
||||
self.procs.pop(rid)
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
def escalate(self, appid=620):
|
||||
return frame_running.escalate(appid, me=203, uid=ME, scan=self.scan, kill=self.kill,
|
||||
sleep=self.sleep, clock=lambda: self.now)
|
||||
|
||||
|
||||
class Escalation(unittest.TestCase):
|
||||
GAME = {401, 402, 403}
|
||||
|
||||
def assert_only_game(self, world, allowed=GAME | {400}):
|
||||
signalled = {pid for pid, _ in world.sent}
|
||||
self.assertTrue(signalled, "nothing was signalled")
|
||||
self.assertLessEqual(signalled, allowed)
|
||||
protected = frame_running.protected(frame(), 203, ME)
|
||||
for pid in (1, 200, 201, 202, 203, 300, 301, 302, 303, 304, 305, 306, 307, 404, 405, 500, 501, 600, 601, 602):
|
||||
self.assertNotIn(pid, signalled)
|
||||
self.assertTrue({1, 201, 202, 203, 302, 303, 304, 305, 306, 307, 404, 600}.issubset(protected))
|
||||
|
||||
def test_sigterm_ends_it(self):
|
||||
world = World(frame())
|
||||
self.assertEqual(world.escalate(), "term")
|
||||
self.assert_only_game(world)
|
||||
self.assertEqual({pid for pid, sig in world.sent}, self.GAME)
|
||||
self.assertTrue(all(sig == frame_running.SIGTERM for _, sig in world.sent))
|
||||
# Deepest first, and the reaper isn't signalled while its children are alive.
|
||||
self.assertEqual([pid for pid, _ in world.sent], [403, 402, 401])
|
||||
self.assertNotIn(400, world.procs)
|
||||
self.assertIn(404, world.procs) # the "steam" child under the game is never touched
|
||||
|
||||
def test_sigkill_after_a_grace_period(self):
|
||||
world = World(frame(), ignore={403})
|
||||
self.assertEqual(world.escalate(), "kill")
|
||||
self.assert_only_game(world)
|
||||
self.assertIn((403, frame_running.SIGTERM), world.sent)
|
||||
self.assertIn((403, frame_running.SIGKILL), world.sent)
|
||||
self.assertGreaterEqual(world.now, frame_running.TERM_WAIT)
|
||||
kills = [pid for pid, sig in world.sent if sig == frame_running.SIGKILL]
|
||||
self.assertEqual(kills[0], 403)
|
||||
|
||||
def test_survivor_is_an_error(self):
|
||||
world = World(frame(), unkillable={403})
|
||||
with self.assertRaisesRegex(frame_running.Fail, "still running after SIGKILL"):
|
||||
world.escalate()
|
||||
self.assert_only_game(world)
|
||||
|
||||
def test_reused_pid_is_not_signalled(self):
|
||||
world = World(frame(), ignore={402, 403})
|
||||
real_scan = world.scan
|
||||
|
||||
def scan():
|
||||
procs = real_scan()
|
||||
if world.sent and 402 in procs: # 402 exits and its pid goes to an unrelated program
|
||||
procs[402] = proc(300, "editor", start=99999)
|
||||
world.procs[402] = procs[402]
|
||||
return procs
|
||||
world.scan = scan
|
||||
world.escalate()
|
||||
self.assertNotIn((402, frame_running.SIGKILL), world.sent)
|
||||
|
||||
def test_nothing_left(self):
|
||||
world = World({pid: p for pid, p in frame().items() if pid < 400})
|
||||
self.assertEqual(world.escalate(), "gone")
|
||||
self.assertEqual(world.sent, [])
|
||||
|
||||
def test_lingering_reaper_alone(self):
|
||||
procs = {pid: p for pid, p in frame().items() if pid not in self.GAME and pid not in (404, 405)}
|
||||
world = World(procs)
|
||||
self.assertEqual(world.escalate(), "term")
|
||||
self.assertEqual(world.sent, [(400, frame_running.SIGTERM)])
|
||||
|
||||
def test_protected_never_signalled_even_as_the_root(self):
|
||||
# A reaper whose only children are protected: nothing to signal but the reaper itself.
|
||||
procs = frame()
|
||||
procs[402]["comm"] = "gamescope"
|
||||
world = World(procs)
|
||||
world.escalate()
|
||||
self.assertNotIn(402, {pid for pid, _ in world.sent})
|
||||
self.assertNotIn(403, {pid for pid, _ in world.sent})
|
||||
|
||||
|
||||
class Quit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.page = mock.MagicMock()
|
||||
patches = [mock.patch.object(frame_running, "Page", return_value=self.page),
|
||||
mock.patch.object(frame_running, "running", return_value=[
|
||||
{"appid": 620, "gameid": "620", "name": "Portal 2", "kind": "steam", "processes": 4}]),
|
||||
mock.patch.object(frame_running.os, "getuid", return_value=ME, create=True)] # none on Windows
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_steam_terminate_first(self):
|
||||
scans = iter([frame(), {}])
|
||||
self.page.eval.return_value = {"running": [], "names": {}}
|
||||
with mock.patch.object(frame_running, "snapshot", side_effect=lambda: next(scans)), \
|
||||
mock.patch.object(frame_running, "escalate") as escalate:
|
||||
out = frame_running.quit_game(620, sleep=lambda s: None)
|
||||
self.assertEqual(out["method"], "steam")
|
||||
self.page.eval.assert_any_call('SteamClient.Apps.TerminateApp("620", false)')
|
||||
escalate.assert_not_called()
|
||||
|
||||
def test_signals_when_steam_leaves_it_running(self):
|
||||
now = [0.0]
|
||||
with mock.patch.object(frame_running, "snapshot", return_value=frame()), \
|
||||
mock.patch.object(frame_running, "escalate", return_value="kill") as escalate:
|
||||
out = frame_running.quit_game(620, sleep=lambda s: now.__setitem__(0, now[0] + s), clock=lambda: now[0])
|
||||
escalate.assert_called_once_with(620)
|
||||
self.assertGreaterEqual(now[0], frame_running.STEAM_WAIT)
|
||||
self.assertEqual(out["message"], "Force quit Portal 2 (it had to be killed)")
|
||||
|
||||
def test_without_steam_goes_straight_to_signals(self):
|
||||
frame_running.Page.side_effect = frame_running.Fail("no SharedJSContext page")
|
||||
with mock.patch.object(frame_running, "snapshot", return_value=frame()), \
|
||||
mock.patch.object(frame_running, "escalate", return_value="term") as escalate:
|
||||
out = frame_running.quit_game(620, sleep=self.fail)
|
||||
escalate.assert_called_once_with(620)
|
||||
self.assertEqual(out["method"], "term")
|
||||
|
||||
def test_not_running(self):
|
||||
frame_running.running.return_value = []
|
||||
with mock.patch.object(frame_running, "escalate") as escalate:
|
||||
self.assertEqual(frame_running.quit_game(620)["method"], "gone")
|
||||
escalate.assert_not_called()
|
||||
self.page.eval.assert_not_called()
|
||||
|
||||
|
||||
class Script(unittest.TestCase):
|
||||
def test_server_sends_it_with_frame_steam_inside(self):
|
||||
import server
|
||||
with mock.patch.object(server, "ssh", return_value='{"running": []}') as ssh:
|
||||
self.assertEqual(server.running_games(), {"running": [], "message": "Nothing is running on the Frame"})
|
||||
source = ssh.call_args.kwargs["stdin"]
|
||||
self.assertEqual(ssh.call_args.args[0], "python3 - list")
|
||||
# The same script runs as `python3 -`: frame_steam resolves, and bad usage is one JSON error.
|
||||
r = subprocess.run([sys.executable, "-I", "-", "bogus"], input=source, capture_output=True, text=True,
|
||||
cwd=tempfile.gettempdir(), timeout=30)
|
||||
self.assertEqual(r.returncode, 1, r.stderr)
|
||||
self.assertEqual(json.loads(r.stdout), {"error": "usage: list | quit APPID"})
|
||||
|
||||
|
||||
class Server(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import server
|
||||
self.server = server
|
||||
|
||||
def quit(self, listed, quit_result=None, stop_error=None):
|
||||
calls = []
|
||||
|
||||
def frame(*args, timeout=40):
|
||||
calls.append(args)
|
||||
return {"running": listed} if args == ("list",) else quit_result
|
||||
with mock.patch.object(self.server, "running_frame", side_effect=frame), \
|
||||
mock.patch.object(self.server, "ensure_master"), \
|
||||
mock.patch.object(self.server.frame_android, "stop", side_effect=stop_error) as stop:
|
||||
out = self.server.force_quit({"action": "quit", "appid": "3346865537"})
|
||||
return out, calls, stop
|
||||
|
||||
def test_android_app_stops_its_container_first(self):
|
||||
app = {"appid": 3346865537, "kind": "android", "package": "org.example.game", "name": "Example"}
|
||||
out, calls, stop = self.quit([app], {"name": "Example", "method": "gone", "message": "That game isn't running"})
|
||||
stop.assert_called_once_with("org.example.game")
|
||||
self.assertEqual(calls, [("list",), ("quit", "3346865537")])
|
||||
self.assertEqual(out["message"], "Stopped Example")
|
||||
|
||||
def test_android_stop_failure_falls_back_to_processes(self):
|
||||
app = {"appid": 3346865537, "kind": "android", "package": "org.example.game", "name": "Example"}
|
||||
err = self.server.frame_android.FrameError("podman failed")
|
||||
out, calls, _ = self.quit([app], {"name": "Example", "method": "term", "message": "Force quit Example"}, err)
|
||||
self.assertEqual(calls[-1], ("quit", "3346865537"))
|
||||
self.assertEqual(out["message"], "Force quit Example")
|
||||
|
||||
def test_other_games_skip_the_android_path(self):
|
||||
app = {"appid": 3346865537, "kind": "devkit", "title": "Cool_Game", "name": "Cool_Game"}
|
||||
out, _, stop = self.quit([app], {"name": "Cool_Game", "method": "steam", "message": "Quit Cool_Game"})
|
||||
stop.assert_not_called()
|
||||
self.assertEqual(out["message"], "Quit Cool_Game")
|
||||
|
||||
def test_not_running_is_a_conflict(self):
|
||||
with self.assertRaises(self.server.Failure) as cm:
|
||||
self.quit([])
|
||||
self.assertEqual(cm.exception.status, 409)
|
||||
|
||||
|
||||
class Routes(test_server.ServerGuards):
|
||||
"""Validation runs before any SSH."""
|
||||
|
||||
def test_input_validation(self):
|
||||
for body in ({"action": "quit", "appid": "620; reboot"}, {"action": "quit", "appid": ""},
|
||||
{"action": "kill", "appid": "620"}, {"appid": "620"}, {"action": "quit", "appid": "12345678901"}):
|
||||
status, payload = self.post("/api/running", body)
|
||||
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||
|
||||
def test_routes_need_custom_header(self):
|
||||
self.assertEqual(self.request("GET", "/api/running")[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/running", {"action": "quit", "appid": "620"})[0], 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Doctor tests never contact a headset or the user's SSH configuration."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('frame_doctor', ROOT / 'scripts/frame_doctor.py')
|
||||
doctor = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(doctor)
|
||||
doctor.MESSAGES = doctor.messages()
|
||||
|
||||
|
||||
def result(out='', code=0, err=''):
|
||||
return subprocess.CompletedProcess([], code, out, err)
|
||||
|
||||
|
||||
class FrameDoctor(unittest.TestCase):
|
||||
def main(self, responses, **kwargs):
|
||||
config = result('hostname private-tailnet.ts.net\nhostkeyalias pinned-frame\n')
|
||||
with patch.object(doctor.shutil, 'which', return_value='/usr/bin/ssh'), \
|
||||
patch.object(doctor, 'run', side_effect=[config, *responses]) as run, \
|
||||
patch.object(doctor, 'port_open', return_value=kwargs.get('devkit', False)), \
|
||||
patch('sys.stdout', new_callable=io.StringIO) as output:
|
||||
code = doctor.main(kwargs.get('argv', []))
|
||||
return code, output.getvalue(), run
|
||||
|
||||
def test_unreachable_is_actionable_without_exposing_stderr(self):
|
||||
code, text, _ = self.main([result(code=255, err='secret-token 192.0.2.5')] * 3)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn('wake headset', text)
|
||||
self.assertEqual(text.count('No authenticated SSH path.'), 11)
|
||||
self.assertNotIn('secret-token', text)
|
||||
self.assertNotIn('192.0.2.5', text)
|
||||
self.assertNotIn('private-tailnet', text)
|
||||
|
||||
def test_no_keys_and_devkit_available_never_registers(self):
|
||||
code, text, run = self.main([result(code=255, err='Permission denied')] * 3, devkit=True)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn('no pairing request was sent', text)
|
||||
self.assertIn('Pair new host', text)
|
||||
self.assertFalse(any('/register' in str(c.args[0]) for c in run.call_args_list))
|
||||
|
||||
def test_fallback_preserves_identity_and_disables_mutating_ssh_options(self):
|
||||
_, text, run = self.main([result(code=255), result(json.dumps({'guard': 'loop', 'checks': []}))])
|
||||
args = run.call_args_list[2].args[0]
|
||||
for flag in ('HostKeyAlias=pinned-frame', 'HostName=frame.local', 'BatchMode=yes',
|
||||
'StrictHostKeyChecking=yes', 'UpdateHostKeys=no', 'PermitLocalCommand=no',
|
||||
'ClearAllForwardings=yes', 'ControlPath=none'):
|
||||
self.assertIn(flag, args)
|
||||
self.assertIn('OK Connection: LAN', text)
|
||||
self.assertIn('Counters are rising', text)
|
||||
|
||||
def test_hostkey_failure_does_not_suggest_deleting_identity(self):
|
||||
_, text, _ = self.main([result(code=255, err='REMOTE HOST IDENTIFICATION HAS CHANGED')] * 3)
|
||||
self.assertIn('Do not delete known_hosts', text)
|
||||
|
||||
def test_invalid_probe_response_never_prints_remote_data(self):
|
||||
for payload in ('a secret token', json.dumps({'guard': 'stable', 'checks': [['OK', 'secret']] * 11})):
|
||||
code, text, _ = self.main([result(payload)])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertNotIn('secret', text)
|
||||
|
||||
def test_guard_stops_before_any_deeper_checks(self):
|
||||
for state in ('loop', 'unknown', 'risk'):
|
||||
with patch.object(doctor, 'guard', return_value=state), patch.object(doctor, 'journal') as journal:
|
||||
self.assertEqual(doctor.probe(), {'guard': state, 'checks': []})
|
||||
journal.assert_not_called()
|
||||
|
||||
def test_rising_and_reset_counters_and_missing_data(self):
|
||||
for snapshots, expected in (([[0, 0, 0, 0], [1, 0, 0, 0]], 'loop'),
|
||||
([[1, 0, 0, 0], [0, 0, 0, 0]], 'unknown'),
|
||||
([None, None], 'unknown'),
|
||||
([[0, 4, 0, 0]] * 2, 'risk'),
|
||||
([[0, 0, 0, 0]] * 2, 'stable')):
|
||||
with patch.object(doctor, 'snapshot', side_effect=snapshots), patch.object(doctor.time, 'sleep'):
|
||||
self.assertEqual(doctor.guard(), expected)
|
||||
|
||||
def test_journal_permission_denied_and_empty_are_unknown(self):
|
||||
for response in (result('partial', err='not seeing messages from other users'), result(), result(code=1)):
|
||||
with patch.object(doctor, 'run', return_value=response):
|
||||
self.assertIsNone(doctor.journal('-b'))
|
||||
self.assertEqual(doctor.signal_check(None, 'bad', 'problem', 'healthy')[0], 'UNKNOWN')
|
||||
|
||||
def test_disk_full_reports_broken_and_never_fixes(self):
|
||||
with patch.object(doctor.shutil, 'disk_usage', return_value=Mock(free=0)):
|
||||
check = doctor.storage()
|
||||
with patch('sys.stdout', new_callable=io.StringIO) as output:
|
||||
code = doctor.report({'guard': 'stable', 'checks': [check] * 11})
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('BROKEN 4. Steam and storage', output.getvalue())
|
||||
|
||||
def test_probe_only_emits_approved_messages_and_rechecks_guard(self):
|
||||
with patch.object(doctor, 'guard', side_effect=['stable', 'stable']) as guard, \
|
||||
patch.object(doctor, 'journal', return_value=None), \
|
||||
patch.object(doctor, 'command', return_value=None), \
|
||||
patch.object(doctor, 'run', return_value=None), \
|
||||
patch.object(doctor, 'storage', return_value=['UNKNOWN', 'Storage could not be read.']):
|
||||
data = doctor.probe()
|
||||
self.assertEqual(guard.call_count, 2)
|
||||
self.assertEqual(len(data['checks']), 11)
|
||||
with patch('sys.stdout', new_callable=io.StringIO):
|
||||
self.assertEqual(doctor.report(data), 1)
|
||||
|
||||
def stable(self, disk=None):
|
||||
checks = [['UNKNOWN', 'Evidence unavailable; consult the runbook locally.'] for _ in doctor.SECTIONS]
|
||||
checks[3] = disk or ['BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.']
|
||||
return {'guard': 'stable', 'checks': checks}
|
||||
|
||||
def test_plan_requires_exact_evidence_and_never_adds_remote_commands(self):
|
||||
data = self.stable()
|
||||
code, text, calls = self.main([result(json.dumps(data))], argv=['--plan'])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('READ-ONLY', text)
|
||||
self.assertIn('use Steam storage management', text)
|
||||
self.assertEqual(calls.call_count, 2) # config read and unchanged read-only probe
|
||||
data['checks'][3] = ['BROKEN', 'Evidence unavailable; consult the runbook locally.']
|
||||
_, text, _ = self.main([result(json.dumps(data))], argv=['--plan'])
|
||||
self.assertNotIn('use Steam storage management', text)
|
||||
self.assertIn('does not establish a safe automatic fix', text)
|
||||
|
||||
def test_plan_guard_blocks_all_section_actions(self):
|
||||
for state in ('loop', 'risk', 'unknown'):
|
||||
_, text, _ = self.main([result(json.dumps({'guard': state, 'checks': []}))], argv=['--plan'])
|
||||
self.assertIn('BLOCKED', text)
|
||||
self.assertNotIn('PLAN 4.', text)
|
||||
|
||||
def test_before_after_and_exclusive_private_baseline(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'before.json'
|
||||
before = self.stable()
|
||||
_, text, _ = self.main([result(json.dumps(before))], argv=['--save-report', str(path)])
|
||||
saved = path.read_text()
|
||||
self.assertNotIn('private-tailnet', saved)
|
||||
self.assertNotIn('pinned-frame', saved)
|
||||
if __import__('os').name != 'nt':
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
after = self.stable(['OK', 'Home and title storage have at least 256 MiB free.'])
|
||||
_, text, _ = self.main([result(json.dumps(after))], argv=['--compare-before', str(path)])
|
||||
self.assertIn('Steam and storage: BROKEN -> OK', text)
|
||||
self.assertIn('do not prove repair', text)
|
||||
self.assertIn('Before evidence: Less than 16 MiB free', text)
|
||||
self.assertIn('After evidence: Home and title storage', text)
|
||||
code, _, _ = self.main([result(json.dumps(after))], argv=['--save-report', str(path)])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(path.read_text(), saved)
|
||||
# Same alias with changed routing cannot masquerade as the same baseline.
|
||||
code, text, calls = self.main([], argv=['--compare-before', str(path), '--usb', '192.0.2.9'])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn('comparison refused', text)
|
||||
self.assertEqual(calls.call_count, 1)
|
||||
|
||||
def test_report_refuses_symlinks(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
target = Path(folder) / 'target'
|
||||
target.write_text('preserve')
|
||||
link = Path(folder) / 'link'
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except OSError:
|
||||
self.skipTest('symlinks unavailable')
|
||||
code, _, _ = self.main([result(json.dumps(self.stable()))], argv=['--save-report', str(link)])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(target.read_text(), 'preserve')
|
||||
|
||||
def test_comparison_suppresses_improvement_if_guard_fails(self):
|
||||
with patch('sys.stdout', new_callable=io.StringIO) as output:
|
||||
doctor.compare(self.stable(), {'guard': 'risk', 'checks': []})
|
||||
self.assertIn('Comparison blocked', output.getvalue())
|
||||
self.assertNotIn('BROKEN -> OK', output.getvalue())
|
||||
|
||||
def test_untrusted_baselines_and_probe_are_rejected_without_echo(self):
|
||||
for payload in ('secret', '[]', '{"version":1}', 'x' * 65537, '[' * 2000 + ']' * 2000):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / 'baseline.json'
|
||||
path.write_text(payload)
|
||||
code, text, calls = self.main([], argv=['--compare-before', str(path)])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertNotIn('secret', text)
|
||||
calls.assert_not_called()
|
||||
for data in ({'guard': 'secret', 'checks': []},
|
||||
{'guard': 'risk', 'checks': [['WARN', 'secret']]},
|
||||
dict(self.stable(), raw='secret'),
|
||||
self.stable(['WARN', 'Never pairs'])):
|
||||
code, text, _ = self.main([result(json.dumps(data))], argv=['--plan'])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertNotIn('secret', text)
|
||||
self.assertNotIn('Repair plan', text)
|
||||
|
||||
def test_help_shell_entry_point_is_offline(self):
|
||||
# This Bash wrapper is for macOS/Linux; Windows uses the Python CLI.
|
||||
import os, shutil
|
||||
if os.name == 'nt':
|
||||
self.skipTest('POSIX shell wrapper')
|
||||
if not shutil.which('bash'):
|
||||
self.skipTest('bash unavailable')
|
||||
r = subprocess.run(['bash', str(ROOT / 'scripts/frame-doctor.sh'), '--help'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertIn('Never pairs', r.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -64,6 +64,7 @@ class LinkUI(unittest.TestCase):
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
|
||||
assert.ok($('conn').title.includes(changed.error.message));
|
||||
assert.ok(!$('conn').title.includes('..'), $('conn').title);
|
||||
// Without a short reason: the stage that failed.
|
||||
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
||||
renderPill();
|
||||
@@ -120,7 +121,9 @@ class LinkUI(unittest.TestCase):
|
||||
class HeaderLayout(unittest.TestCase):
|
||||
def test_the_devices_tab_explanation_wraps(self):
|
||||
# It was cut to one line with no way to read the rest.
|
||||
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
|
||||
# More specific than the later .item .s rule: the old equal-specificity
|
||||
# rule passed this test while the browser still clipped the explanation.
|
||||
self.assertIn(".item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }", PAGE)
|
||||
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
|
||||
|
||||
def test_the_header_buttons_dont_give_way(self):
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Issue #81: synthetic, deterministic log scrubbing; no sockets or private data."""
|
||||
import sandbox # noqa: F401
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_link as fl
|
||||
import frame_telemetry as tm
|
||||
|
||||
|
||||
def baseline(text, hosts):
|
||||
"""Frozen v0.4.11 connection pipeline, before issue #81's extra spans.
|
||||
|
||||
Keep these operations independent of frame_link's implementations so a change
|
||||
to its ordering or matching cannot silently weaken the differential oracle.
|
||||
The shared scrubber's default behavior is deliberately unchanged by this fix.
|
||||
"""
|
||||
keep = {'host', 'hostname', 'to', 'port', 'answer', 'the', 'a', 'an', 'this',
|
||||
'it', 'its', 'be', 'connect', 'find', 'work', 'try'}
|
||||
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
|
||||
parts = re.split(r'(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)', text)
|
||||
for i in range(0, len(parts), 2):
|
||||
for name in names:
|
||||
parts[i] = re.sub(r'(?<![\w.:-])%s(?![\w-]|[:.%%]\w)' % re.escape(name),
|
||||
'<host>', parts[i], flags=re.I)
|
||||
plain = keep - {'host', 'hostname', 'to', 'port'}
|
||||
|
||||
def operand(match):
|
||||
word = match.group(2).rstrip('.')
|
||||
return (match.group(0) if word.lower() in plain else
|
||||
f'{match.group(1)} <host>{match.group(2)[len(word):]}')
|
||||
|
||||
text = re.sub(r'''(?i)\b(hostname|host|to(?:\s+host)?)\s+(?!<)([^\s:,;'"()|]+)''',
|
||||
operand, ''.join(parts))
|
||||
return tm.scrub(text, 600)
|
||||
|
||||
|
||||
class LogScrubbing(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patch = mock.patch.object(tm, '_user_names', return_value={'SyntheticLogin'})
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
def test_display_name_crosses_a_scrubber_token(self):
|
||||
name = '<host> Jane Doe headset'
|
||||
self.assertEqual(fl.scrub_failure(name + ' stopped answering', [name]), '<host> stopped answering')
|
||||
self.assertEqual(fl.scrub_failure('<user>@<host>: <ip>', ['user', 'host', 'ip']),
|
||||
'<user>@<host>: <ip>')
|
||||
self.assertEqual(fl.scrub_failure('<host>', ['<host>']), '<host>')
|
||||
|
||||
def test_all_standalone_tokens_survive_and_whole_names_cross_them(self):
|
||||
for token in ('host', 'user', 'email', 'ip', 'mac', 'steamid', 'hex',
|
||||
'token', 'ssh-key', 'pem', 'url', 'redacted'):
|
||||
token = '<' + token + '>'
|
||||
name = token + ' Synthetic Fixture headset'
|
||||
with self.subTest(token=token):
|
||||
self.assertEqual(fl.scrub_failure(token, [token, token[1:-1]]), token)
|
||||
self.assertEqual(fl.scrub_failure(name.upper() + ' failed', [name]), '<host> failed')
|
||||
|
||||
def test_a_name_crossing_the_output_limit_leaves_no_fragment(self):
|
||||
name = '<host> ' + 'Synthetic ' * 100 + 'headset'
|
||||
self.assertEqual(fl.scrub_failure(name + ' trailing detail', [name]), '<host>')
|
||||
|
||||
def test_display_name_cannot_swallow_ssh_operand_marker(self):
|
||||
text = 'ssh: connect to host bastion-jane port 22: Connection refused'
|
||||
self.assertEqual(fl.scrub_failure(text, ['to host']),
|
||||
'ssh: connect <host> <host> port 22: Connection refused')
|
||||
|
||||
def test_reverted_fix_counterexamples(self):
|
||||
cases = [
|
||||
('Could not connect to hostname bastion-jane', ['connect to'], ['bastion-jane']),
|
||||
('Jane Smith@<host>: Permission denied', ['Smith@<host>'], ['Jane', 'Smith']),
|
||||
('frame.local Jane Doe headset stopped answering', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
|
||||
('x ' * 295 + 'frame.local Jane Doe headset', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
|
||||
('https://jane@example.com/private(confidential)', ['jane@example.com'], ['private', 'confidential']),
|
||||
(r'C:\Users\Jane@workpc (Smith)\.ssh\config', ['Jane@workpc'], ['Jane', 'Smith']),
|
||||
('-----BEGIN PRIVATE KEY-----\nSYNTHETICBODY\n-----END PRIVATE KEY-----@frame',
|
||||
['KEY-----@frame'], ['SYNTHETICBODY']),
|
||||
('Could not connect to hostname /home/Jane Doe/.ssh/config', ['connect to'], ['Jane', 'Doe']),
|
||||
]
|
||||
for text, hosts, private in cases:
|
||||
with self.subTest(text=text):
|
||||
out = fl.scrub_failure(text, hosts)
|
||||
for value in private:
|
||||
self.assertNotIn(value, out)
|
||||
self.assertLessEqual(len(out), 600)
|
||||
|
||||
def test_overlapping_names_and_operands_and_literal_tokens(self):
|
||||
cases = [
|
||||
('Alpha Beta Gamma failed', ['Alpha Beta', 'Beta Gamma'], '<host> failed'),
|
||||
('<host> Jane Doe headset failed', ['<host> Jane Doe headset', 'Jane Doe'], '<host> failed'),
|
||||
('to hostname bastion-jane', [], 'to <host> <host>'),
|
||||
('<host failed', ['<host'], '<host> <host>'),
|
||||
]
|
||||
for text, hosts, expected in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(fl.scrub_failure(text, hosts), expected)
|
||||
|
||||
def test_randomized_differential_against_v0411(self):
|
||||
rng = random.Random(81)
|
||||
templates = [
|
||||
'https://{u}@example.invalid/{p}(confidential)?key={p}',
|
||||
r'C:\Users\{u}@workpc ({p})\.ssh\config',
|
||||
'/home/{u} {p}/.ssh/config',
|
||||
'-----BEGIN PRIVATE KEY-----\n{p}\n-----END PRIVATE KEY-----@frame',
|
||||
'{u}.{p}@example.invalid',
|
||||
'fe80::abcd%{u}',
|
||||
'{u} {p}@frame.local: Permission denied',
|
||||
'ssh-ed25519 {p}',
|
||||
'password={p}',
|
||||
'<host> {u} {p} headset stopped answering',
|
||||
'frame.local {u} {p} headset stopped answering',
|
||||
]
|
||||
for iteration in range(1200):
|
||||
user = 'Synthetic' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=14))
|
||||
private = 'Fixture' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=18))
|
||||
field = rng.choice(templates).format(u=user, p=private)
|
||||
prefix = rng.choice(['', 'Could not connect to hostname ', 'ssh: connect to host ',
|
||||
'Timed out talking to ', 'debug | ', 'x ' * 295])
|
||||
text = prefix + field + rng.choice(['', ' port 22: refused', '\nnext line', "'s password: "])
|
||||
# Names intentionally cut through the syntax later scrubber rules need.
|
||||
options = [user, user + '@workpc', private, 'connect to', 'to host',
|
||||
'<host> ' + user + ' ' + private + ' headset',
|
||||
'frame.local ' + user + ' ' + private + ' headset',
|
||||
'KEY-----@frame', 'host', '<host>', 'SyntheticLogin']
|
||||
hosts = rng.sample(options, rng.randrange(5))
|
||||
before, after = baseline(text, hosts), fl.scrub_failure(text, hosts)
|
||||
with self.subTest(iteration=iteration, text=text, hosts=hosts):
|
||||
trace = fl._ScrubTrace(text)
|
||||
traced = tm.scrub(fl.hide_operands(fl.hide_hosts(text, hosts, _trace=trace),
|
||||
_sub=trace.sub), 600, _sub=trace.sub)
|
||||
self.assertEqual(traced, before)
|
||||
# Compare every original three-character window, not just whole
|
||||
# canaries: even a partially exposed user/path/secret is a regression.
|
||||
# Mask placeholders with a separator so removing one cannot join
|
||||
# unrelated text into a misleadingly new three-character window.
|
||||
old_visible = fl.PLACEHOLDER.sub('\0', before)
|
||||
new_visible = fl.PLACEHOLDER.sub('\0', after)
|
||||
for start in range(len(text) - 2):
|
||||
fragment = text[start:start + 3]
|
||||
if fragment not in old_visible:
|
||||
self.assertNotIn(fragment, new_visible)
|
||||
self.assertLessEqual(len(after), 600)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -84,7 +84,10 @@ class Helpers(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
||||
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
||||
def test_launch_script_parses(self):
|
||||
r = subprocess.run(["bash", "-n"], input=frame_macview.LAUNCH, text=True, capture_output=True)
|
||||
bash = str(Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe") if sys.platform == "win32" else "bash"
|
||||
if sys.platform == "win32" and not Path(bash).exists():
|
||||
self.skipTest("Git Bash is not installed (WSL bash is not a local shell)")
|
||||
r = subprocess.run([bash, "-n"], input=frame_macview.LAUNCH.encode("utf-8"), capture_output=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_show_checks_the_source_before_anything_else(self):
|
||||
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Frozen regression trace from the original mac-in-headset Swift controller.
|
||||
The 600-state reference was produced at 1b90c64, before the C extraction:
|
||||
60 seconds with a slow/congested middle, then recovery. No generated behavior
|
||||
from the new implementation is used to calculate the expected digest.
|
||||
"""
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
from frame_pc_capture import Controller
|
||||
|
||||
|
||||
class ControllerTrace(unittest.TestCase):
|
||||
def test_original_mac_congestion_and_recovery(self):
|
||||
cc = shutil.which('cc') or shutil.which('gcc')
|
||||
if not cc:
|
||||
self.skipTest('C compiler not installed; native build jobs cover this trace')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / ('trace.dll' if sys.platform == 'win32' else 'trace.so')
|
||||
subprocess.run([cc, '-O2', '-shared', '-fPIC', str(ROOT/'desktop/controller.c'), '-o', str(path)], check=True, capture_output=True)
|
||||
lib = C.CDLL(str(path))
|
||||
sig = {'new': (C.c_void_p, [C.c_int, C.c_int]), 'free': (None, [C.c_void_p]),
|
||||
'ceiling': (None, [C.c_void_p, C.c_int]), 'value': (C.c_int64, [C.c_void_p, C.c_int]),
|
||||
'capture': (None, [C.c_void_p, C.c_int64]), 'gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
|
||||
'sent': (None, [C.c_void_p, C.c_uint32, C.c_int, C.c_int64]),
|
||||
'ack': (C.c_int, [C.c_void_p, C.c_uint32, C.c_int64]), 'update': (C.c_int, [C.c_void_p, C.c_int64])}
|
||||
for name, (ret, args) in sig.items():
|
||||
fn = getattr(lib, 'fc_'+name)
|
||||
fn.restype, fn.argtypes = ret, args
|
||||
c = Controller(lib, 60, 8000000)
|
||||
due, states, seq = [], [], 0
|
||||
try:
|
||||
for tick in range(6000):
|
||||
now = 10000000+tick*10000
|
||||
for t, s in due:
|
||||
if t <= now:
|
||||
c.call('ack', s, now)
|
||||
due = [(t, s) for t, s in due if t > now]
|
||||
if tick % 2 == 0:
|
||||
c.call('capture', now)
|
||||
if c.call('gate', now, 1):
|
||||
seq += 1
|
||||
c.call('sent', seq, 8000 if tick < 1000 or tick > 4000 else 22000, now)
|
||||
due.append((now+(240000 if 1500 < tick < 3500 else 20000), seq))
|
||||
if tick % 10 == 0:
|
||||
c.update(now)
|
||||
x = c.state()
|
||||
states.append([x[k] for k in ('target', 'ceiling', 'tier', 'fps', 'inFlight')] +
|
||||
[round(x['scale']*100), round(x['baseRtt']*1000), round(x['slack']*1000)])
|
||||
digest = hashlib.sha256(json.dumps(states, separators=(',', ':')).encode()).hexdigest()
|
||||
self.assertEqual(digest, '1932f16aafce5c9157bcd2aba5d90719a366f9999422920aaeae78a6cdec08f6')
|
||||
finally:
|
||||
c.close()
|
||||
if sys.platform == 'win32':
|
||||
import _ctypes
|
||||
_ctypes.FreeLibrary(lib._handle)
|
||||
@@ -0,0 +1,582 @@
|
||||
"""PC adapters: grants, input cleanup, launcher reuse and native codec contract.
|
||||
Native tests require the bundled libraries; CI sets REQUIRE_NATIVE so a missing
|
||||
bundle is a failure, never a silent skip. No desktop consent or GPU is faked as
|
||||
successful real-host capture.
|
||||
|
||||
The encoding tests need x264, which only CI's own builds bundle (it's GPL, so
|
||||
the installer leaves it out). FRAME_PC_REQUIRE_X264=1 makes its absence a
|
||||
failure; FRAME_PC_INSTALLER_BUNDLE=1 checks the installer's bundle instead.
|
||||
"""
|
||||
import ctypes
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_pc_agent as agent
|
||||
import frame_pc_capture as capture
|
||||
import frame_pcview
|
||||
import frame_macview
|
||||
from frame_stream_stats import Stats
|
||||
from test_macview import WS
|
||||
|
||||
|
||||
class GrantsTests(unittest.TestCase):
|
||||
def test_source_bound_retry_ack_and_stop(self):
|
||||
g = agent.Grants('master')
|
||||
ticket = g.ticket('window:1')
|
||||
self.assertIsNone(g.redeem('window:2', {'t': ticket}))
|
||||
key = g.redeem('window:1', {'t': ticket})
|
||||
self.assertEqual(key, g.redeem('window:1', {'t': ticket}))
|
||||
g.ack(key)
|
||||
self.assertIsNone(g.redeem('window:1', {'t': ticket}))
|
||||
self.assertEqual(key, g.redeem('window:1', {'r': key}))
|
||||
self.assertIsNone(g.redeem('window:2', {'r': key}))
|
||||
g.revoke('window:1')
|
||||
self.assertIsNone(g.redeem('window:1', {'r': key}))
|
||||
|
||||
def test_expiry_and_stop_before_redeem(self):
|
||||
g = agent.Grants('master')
|
||||
with mock.patch.object(agent.time, 'monotonic', return_value=1):
|
||||
ticket = g.ticket('test')
|
||||
with mock.patch.object(agent.time, 'monotonic', return_value=62):
|
||||
self.assertIsNone(g.redeem('test', {'t': ticket}))
|
||||
ticket = g.ticket('test')
|
||||
g.revoke()
|
||||
self.assertIsNone(g.redeem('test', {'t': ticket}))
|
||||
|
||||
|
||||
class GatingTests(unittest.TestCase):
|
||||
"""Experimental, Windows only, and off until the person turns it on."""
|
||||
|
||||
def view(self):
|
||||
return frame_pcview.PCView(['ssh'], mock.Mock(), 'frame')
|
||||
|
||||
def test_only_windows_gets_the_pc_host(self):
|
||||
for platform, cls in (('win32', frame_pcview.PCView), ('linux', frame_macview.MacView),
|
||||
('darwin', frame_macview.MacView)):
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', platform):
|
||||
view = frame_pcview.host_view(['ssh'], mock.Mock(), 'frame')
|
||||
self.assertIs(type(view), cls, platform)
|
||||
|
||||
def test_off_by_default_starts_nothing(self):
|
||||
view = self.view()
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(frame_macview.subprocess, 'Popen', side_effect=AssertionError('started while off')):
|
||||
state = view.state()
|
||||
self.assertEqual(state, {'available': False, 'reason': frame_pcview.OFF, 'host': view.host,
|
||||
'experimental': True, 'enabled': False})
|
||||
with self.assertRaises(frame_macview.MacViewError):
|
||||
view.show('window:12')
|
||||
view.run.assert_not_called()
|
||||
|
||||
def test_never_offered_off_windows_even_when_turned_on(self):
|
||||
view = self.view()
|
||||
view.enabled = True
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'linux'):
|
||||
self.assertIn('only available on Windows', view.unavailable())
|
||||
|
||||
def test_turning_on_checks_for_the_bundled_libraries(self):
|
||||
view = self.view()
|
||||
missing = mock.Mock(is_file=mock.Mock(return_value=False))
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(frame_pcview, 'LIBRARY', missing):
|
||||
state = view.set_enabled(True)
|
||||
self.assertTrue(state['enabled'])
|
||||
self.assertFalse(state['available'])
|
||||
self.assertIn('libraries are missing', state['reason'])
|
||||
|
||||
def test_turning_off_stops_streams_and_the_helper(self):
|
||||
view = self.view()
|
||||
view.enabled = True
|
||||
agent = mock.Mock()
|
||||
agent.poll.return_value = None
|
||||
view.agent, view.tunnel = agent, mock.Mock()
|
||||
tunnel = view.tunnel
|
||||
tunnel.poll.return_value = None
|
||||
view.stop = mock.Mock()
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'):
|
||||
state = view.set_enabled(False)
|
||||
view.stop.assert_called_once_with()
|
||||
agent.stdin.close.assert_called_once()
|
||||
agent.wait.assert_called()
|
||||
tunnel.terminate.assert_called_once()
|
||||
self.assertIsNone(view.agent)
|
||||
self.assertEqual((state['enabled'], state['reason']), (False, frame_pcview.OFF))
|
||||
|
||||
def test_server_enable_action(self):
|
||||
import sandbox # noqa: F401 (keeps the server off real data and services)
|
||||
import server
|
||||
with mock.patch.object(server, 'macview', frame_macview.MacView(['ssh'], mock.Mock(), 'frame')):
|
||||
with self.assertRaises(server.Failure) as e:
|
||||
server.macview_action({'action': 'enable', 'on': True})
|
||||
self.assertIn('only available on Windows', str(e.exception))
|
||||
view = self.view()
|
||||
view.set_enabled = mock.Mock(return_value={'enabled': True})
|
||||
with mock.patch.object(server, 'macview', view):
|
||||
self.assertEqual(server.macview_action({'action': 'enable', 'on': True}), {'enabled': True})
|
||||
server.macview_action({'action': 'enable', 'on': 'yes'}) # anything but true is off
|
||||
self.assertEqual([c.args for c in view.set_enabled.call_args_list], [(True,), (False,)])
|
||||
|
||||
|
||||
class PageTests(unittest.TestCase):
|
||||
page = (ROOT / 'ui' / 'index.html').read_text(encoding='utf-8')
|
||||
|
||||
def test_tool_link_is_windows_only_and_marked_experimental(self):
|
||||
self.assertIn('<a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span>'
|
||||
'<small>Experimental:', self.page)
|
||||
self.assertIn('$("pcToolLink").hidden = h.mobile || h.os !== "Windows";', self.page)
|
||||
self.assertIn('$("macToolLink").hidden = h.mobile || h.os !== "macOS";', self.page)
|
||||
|
||||
def test_card_says_experimental_and_what_is_unverified(self):
|
||||
self.assertIn('<span class="tag" id="mvTag" hidden>Experimental</span>', self.page)
|
||||
self.assertIn('<input type="checkbox" id="mvPcOn">', self.page)
|
||||
self.assertIn('Tested in a Windows VM; physical PCs, hardware encoding and laser clicks remain unverified.', self.page)
|
||||
self.assertIn('docs/pc-in-headset.md', self.page)
|
||||
|
||||
def test_opt_in_is_remembered_with_the_client_preferences(self):
|
||||
self.assertIn("const keys = ['mvSeparate', 'padUsed', 'pcView']", self.page)
|
||||
prefs = (ROOT / 'app' / 'preferences.js').read_text(encoding='utf-8')
|
||||
self.assertIn('const KEYS = ["mvSeparate", "padUsed", "pcView"];', prefs)
|
||||
|
||||
|
||||
class AdapterTests(unittest.TestCase):
|
||||
def test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties(self):
|
||||
p = capture.pipeline(dict(src='display:65537', capture_api='dxgi'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
self.assertIn('monitor-handle=65537', p)
|
||||
self.assertNotIn('capture-api=', p)
|
||||
self.assertNotIn('show-border=', p)
|
||||
self.assertIn('mfh264enc', p)
|
||||
for source in (dict(src='window:42', capture_api='dxgi'),
|
||||
dict(src='display:42', capture_api='dxgi ! fakesink')):
|
||||
with self.assertRaises(ValueError):
|
||||
capture.pipeline(source, 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
|
||||
def test_windows_sources_follow_actual_wgc_capabilities(self):
|
||||
for wgc in (False, True):
|
||||
with self.subTest(wgc=wgc):
|
||||
native, windows = mock.Mock(), mock.Mock()
|
||||
native.has_property.return_value = wgc
|
||||
windows.sources.side_effect = lambda: ([dict(src='window:12', w=800, h=600)],
|
||||
[dict(src='display:4', w=1920, h=1080)])
|
||||
with mock.patch.object(agent.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(agent, 'Windows', return_value=windows):
|
||||
host = agent.Agent(native, 'test', ROOT/'ui/mac-view.html')
|
||||
wins, displays = host.lists()
|
||||
self.assertEqual(len(wins), int(wgc))
|
||||
self.assertEqual(displays[0]['capture_api'], 'wgc' if wgc else 'dxgi')
|
||||
self.assertEqual(host.source('display:4')['capture_api'], displays[0]['capture_api'])
|
||||
if wgc:
|
||||
self.assertEqual(host.source('window:12')['capture_api'], 'wgc')
|
||||
self.assertEqual(host.window_capture_reason, '')
|
||||
else:
|
||||
with self.assertRaisesRegex(ValueError, 'Individual windows are unavailable'):
|
||||
host.source('window:12')
|
||||
self.assertIn('whole screen', host.window_capture_reason)
|
||||
# Synthetic encoder/protocol checks remain available without WGC.
|
||||
self.assertEqual(host.source('test')['src'], 'test')
|
||||
|
||||
def test_pc_card_receives_window_capture_limitation(self):
|
||||
view = frame_pcview.PCView([], lambda *a: None, 'frame')
|
||||
view.enabled = True
|
||||
view.call = mock.Mock(side_effect=lambda path: {
|
||||
'/status': dict(screen=True, accessibility=True, windowCaptureReason=agent.WINDOW_CAPTURE_UNAVAILABLE),
|
||||
'/windows': dict(windows=[]), '/displays': dict(displays=[dict(src='display:4')])}[path])
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(frame_pcview, 'LIBRARY', mock.Mock(is_file=lambda: True)):
|
||||
state = view.state()
|
||||
self.assertTrue(state['available'])
|
||||
self.assertEqual(state['windowCaptureReason'], agent.WINDOW_CAPTURE_UNAVAILABLE)
|
||||
self.assertEqual(state['windows'], [])
|
||||
self.assertEqual(state['displays'], [dict(src='display:4')])
|
||||
|
||||
def test_windows_uses_wgc_and_media_foundation(self):
|
||||
for kind in ('window', 'display'):
|
||||
pipeline = capture.pipeline(dict(src=kind+':42'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
self.assertIn('capture-api=wgc', pipeline)
|
||||
self.assertIn('window-handle=42' if kind == 'window' else 'monitor-handle=42', pipeline)
|
||||
self.assertIn('mfh264enc name=enc low-latency=true bframes=0', pipeline)
|
||||
self.assertNotIn('drop=true', pipeline)
|
||||
with self.assertRaises(ValueError):
|
||||
capture.pipeline(dict(src='window:42 ! fakesink'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
|
||||
def test_linux_uses_only_portal_fd_and_node(self):
|
||||
p = capture.pipeline(dict(src='window:consented', fd=19, node=47), 'linux', 'x264enc', 640, 360, 30, 1000000)
|
||||
self.assertIn('pipewiresrc fd=19 path=47', p)
|
||||
self.assertIn('tune=zerolatency', p)
|
||||
self.assertEqual(capture.dimensions(1920, 1080, 1280), (1280, 720))
|
||||
|
||||
def test_portal_input_clamps_and_releases_held_state(self):
|
||||
native = mock.Mock()
|
||||
native.lib.fc_portal_input.return_value = 1
|
||||
inp = capture.PortalInput(native, dict(portal=123, w=100, h=50))
|
||||
inp.handle(dict(t='m', e='down', b=0, x=2, y=-1))
|
||||
inp.handle(dict(t='k', e='down', key='Control'))
|
||||
inp.release()
|
||||
calls = native.lib.fc_portal_input.call_args_list
|
||||
self.assertEqual(calls[0].args, (123, 0, 99, 0, 0, 0))
|
||||
self.assertIn(mock.call(123, 1, 0, 0, 0x110, 0), calls)
|
||||
self.assertIn(mock.call(123, 3, 0, 0, 0xffe3, 0), calls)
|
||||
self.assertFalse(inp.buttons or inp.keys)
|
||||
|
||||
def test_windows_release_and_negative_monitor_origin(self):
|
||||
host = mock.Mock()
|
||||
host.user.GetSystemMetrics.side_effect = [-1920, 0, 3840, 1080]
|
||||
inp = capture.WindowsInput(host, dict(src='display:4', x=-1920, y=0, w=1920, h=1080))
|
||||
inp.handle(dict(t='m', e='down', b=2, x=0, y=0))
|
||||
self.assertEqual(host.send.call_args_list[0], mock.call(mouse=(0, 0, 0, 0xc001)))
|
||||
inp.handle(dict(t='k', e='down', code='ControlLeft', key='Control'))
|
||||
inp.release()
|
||||
self.assertIn(mock.call(mouse=(0, 0, 0, 0x10)), host.send.call_args_list)
|
||||
self.assertIn(mock.call(key=(162, 0, 2)), host.send.call_args_list)
|
||||
|
||||
def test_refused_windows_focus_never_clicks_the_covering_app(self):
|
||||
host = mock.Mock()
|
||||
host.rect.return_value = (0, 0, 800, 600)
|
||||
host.user.GetForegroundWindow.return_value = 99
|
||||
host.user.SetForegroundWindow.return_value = False
|
||||
inp = capture.WindowsInput(host, dict(src='window:12', w=800, h=600))
|
||||
with self.assertRaisesRegex(RuntimeError, 'could not focus'):
|
||||
inp.handle(dict(t='m', e='down', b=0, x=.5, y=.5))
|
||||
host.send.assert_not_called()
|
||||
|
||||
def test_pc_reuses_launcher_and_namespaces_panel_ids(self):
|
||||
view = frame_pcview.PCView(['ssh'], mock.Mock(return_value='panel created'), 'frame')
|
||||
view.call = mock.Mock(side_effect=lambda path, **kw: {'screen': True, 'ticket': 'one-use', 'streams': []})
|
||||
view.ensure_tunnel = mock.Mock()
|
||||
view.remote_port = 47900
|
||||
view.show('window:12')
|
||||
self.assertEqual(view.run.call_args.kwargs['stdin'], frame_macview.LAUNCH)
|
||||
self.assertNotEqual(frame_macview.panel_id('window:12'), frame_macview.panel_id('window:12', 'windows'))
|
||||
self.assertFalse(view.prefer_usb)
|
||||
with self.assertRaises(frame_macview.MacViewError):
|
||||
view.show('separate:12')
|
||||
|
||||
def test_session_end_releases_native_input_only_once(self):
|
||||
session = object.__new__(agent.Session)
|
||||
session.lock, session.input_lock = threading.RLock(), threading.RLock()
|
||||
session.stop_event = threading.Event()
|
||||
session.ws, session.input = mock.Mock(), mock.Mock()
|
||||
session.released = False
|
||||
session.end()
|
||||
session.end()
|
||||
session.input.release.assert_called_once()
|
||||
self.assertTrue(session.stop_event.is_set())
|
||||
|
||||
def test_idle_source_is_not_a_stall(self):
|
||||
# PipeWire/WGC deliver frames only on damage: a static desktop sends
|
||||
# its first frame and then nothing. That must not end the session.
|
||||
session = object.__new__(agent.Session)
|
||||
session.lock, session.pending = threading.RLock(), {}
|
||||
session.stats = Stats(lambda: 0)
|
||||
session.stats.captured = 1
|
||||
self.assertIsNone(session.stalled(60000000, 0, 0))
|
||||
# A frame that entered the encoder and never came out is a fault.
|
||||
session.pending[5] = dict(cap=0, arr=0, e0=1000000, tier=0, br=0)
|
||||
self.assertIn('encoder', session.stalled(12000000, 0, 0))
|
||||
self.assertIsNone(session.stalled(10000000, 0, 0))
|
||||
# No initial frame at all after the pipeline opened is a fault.
|
||||
session.pending.clear()
|
||||
self.assertIn('capture', session.stalled(12000000, 0, 1))
|
||||
self.assertIsNone(session.stalled(9000000, 0, 1))
|
||||
|
||||
def test_controller_is_inert_after_close(self):
|
||||
lib = mock.Mock()
|
||||
lib.fc_new.return_value = 1234
|
||||
lib.fc_value.return_value = 100
|
||||
controller = capture.Controller(lib, 60, 5000000)
|
||||
controller.close()
|
||||
lib.fc_free.assert_called_once_with(1234)
|
||||
lib.reset_mock()
|
||||
state = controller.state()
|
||||
self.assertEqual((state['fps'], state['target'], state['scale']), (60, 0, 1))
|
||||
self.assertEqual(controller.call('gate', 1, 1), 0)
|
||||
self.assertEqual(controller.update(1), 0)
|
||||
controller.close()
|
||||
self.assertEqual(lib.method_calls, []) # nothing touched the freed pointer
|
||||
|
||||
def test_library_path_prepend_adds_no_empty_entry(self):
|
||||
env = {}
|
||||
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/opt/fc/lib')
|
||||
self.assertEqual(env['LD_LIBRARY_PATH'], '/opt/fc/lib')
|
||||
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/x')
|
||||
self.assertEqual(env['LD_LIBRARY_PATH'], '/x' + os.pathsep + '/opt/fc/lib')
|
||||
with mock.patch.dict(os.environ, clear=True):
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
for name in ('PATH', 'LD_LIBRARY_PATH'):
|
||||
parts = env.get(name, 'x').split(os.pathsep)
|
||||
self.assertNotIn('', parts, name)
|
||||
|
||||
def test_malformed_viewer_input_is_dropped_not_fatal(self):
|
||||
session = object.__new__(agent.Session)
|
||||
session.lock, session.input_lock = threading.RLock(), threading.RLock()
|
||||
session.stop_event, session.key_event = threading.Event(), threading.Event()
|
||||
session.src, session.codec, session.key, session.w, session.h = 'display:1', 'h264', 'r', 100, 100
|
||||
session.source = dict(src='display:1', w=100, h=100, portal=1)
|
||||
session.input_enabled, session.released = True, False
|
||||
session.native = mock.Mock()
|
||||
session.native.now.return_value = 0
|
||||
session.controller, session.agent = mock.Mock(), mock.Mock()
|
||||
session.stats = Stats(lambda: 0)
|
||||
session.produce = lambda: None
|
||||
lib = mock.Mock()
|
||||
lib.fc_portal_input.return_value = 1
|
||||
session.input = capture.PortalInput(mock.Mock(lib=lib), session.source)
|
||||
session.ws = mock.Mock()
|
||||
session.ws.receive.side_effect = [
|
||||
dict(t='m', x='left', y=0), dict(t='wheel', dx=[1]), dict(t='k', e='down', key=None, i='x'),
|
||||
dict(t='fd', drop='many'), dict(t='rx', s=[1]), dict(t='m', x=.5, y=.5), ConnectionError('closed')]
|
||||
with self.assertRaises(ConnectionError):
|
||||
session.start()
|
||||
# Every malformed message was skipped; the good move after them still ran.
|
||||
moves = [c.args for c in lib.fc_portal_input.call_args_list if c.args[1] == 0]
|
||||
self.assertAlmostEqual(moves[-1][2], .5*99)
|
||||
self.assertEqual(session.ws.receive.call_count, 7)
|
||||
|
||||
def test_stats_keep_capture_time_and_bound_records(self):
|
||||
stats = Stats(lambda: 10000000)
|
||||
stats.input(dict(t='m', i=1, tv=9999990))
|
||||
f = stats.add(cap=10000001, arr=10000002, e0=10000003, e1=10000004)
|
||||
self.assertEqual(f['echo'], 1)
|
||||
stats.report(dict(t='rx', s=f['s'], r=10000010))
|
||||
stats.report(dict(t='fd', f=[[f['s'], 10000011, 10000012, 10000013]]))
|
||||
self.assertEqual(stats.snapshot(settle=0)['inputs'][0]['frame'], f['s'])
|
||||
for _ in range(4100):
|
||||
stats.add(cap=10000001)
|
||||
self.assertEqual(len(stats.frames), 4096)
|
||||
|
||||
|
||||
def x264_bundled():
|
||||
return any((capture.NATIVE / 'lib' / 'gstreamer-1.0').glob('*gstx264*'))
|
||||
|
||||
|
||||
def needs_x264(test):
|
||||
def run(self):
|
||||
if not x264_bundled():
|
||||
if os.environ.get('FRAME_PC_REQUIRE_X264') == '1':
|
||||
raise AssertionError('CI required x264 in the native PC bundle (desktop/build.py --with-x264)')
|
||||
raise unittest.SkipTest('x264 is only in CI test bundles, not the installer')
|
||||
return test(self)
|
||||
run.__name__, run.__doc__ = test.__name__, test.__doc__
|
||||
return run
|
||||
|
||||
|
||||
class NativeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not capture.LIBRARY.exists():
|
||||
if os.environ.get('FRAME_PC_REQUIRE_NATIVE') == '1':
|
||||
raise AssertionError('CI required native PC libraries but they were not built')
|
||||
raise unittest.SkipTest('native PC libraries not built on this host')
|
||||
cls.token = 'test-' + os.urandom(8).hex()
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['FRAME_MAC_VIEW_TOKEN'] = cls.token
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT/'ui/frame_pc_agent.py'), 'serve', '--port', '0',
|
||||
'--page', str(ROOT/'ui/mac-view.html'), '--exit-on-eof'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, env=env)
|
||||
line = cls.proc.stdout.readline()
|
||||
if 'listening' not in line:
|
||||
raise AssertionError('Native agent failed: ' + cls.proc.stderr.read())
|
||||
cls.port = int(line.rsplit(':', 1)[1])
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.proc.stdin.close()
|
||||
try:
|
||||
rc = cls.proc.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
raise AssertionError('Native agent did not stop after EOF')
|
||||
error = cls.proc.stderr.read()
|
||||
cls.proc.stdout.close()
|
||||
cls.proc.stderr.close()
|
||||
if rc:
|
||||
raise AssertionError('Native agent exited %s: %s' % (rc, error))
|
||||
|
||||
def request(self, path, method='GET'):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', self.port, timeout=10)
|
||||
try:
|
||||
conn.request(method, path)
|
||||
response = conn.getresponse()
|
||||
return response.status, response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def tearDown(self):
|
||||
self.request('/close?k='+self.token, 'POST')
|
||||
|
||||
def test_native_property_probe_checks_the_instantiated_element(self):
|
||||
code = """
|
||||
from frame_pc_capture import Native
|
||||
n = Native()
|
||||
assert n.has_property('fakesink', 'sync')
|
||||
assert not n.has_property('fakesink', 'not-a-gstreamer-property')
|
||||
assert not n.has_property('not-a-gstreamer-element', 'sync')
|
||||
"""
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['PYTHONPATH'] = str(ROOT/'ui')
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux'), 'Linux PipeWire client libraries')
|
||||
def test_bundled_pipewire_context_needs_no_system_modules(self):
|
||||
# No portal request, remote connection or desktop capture: create and
|
||||
# destroy a private client context to test the dynamically loaded SPA
|
||||
# and protocol modules that ldd cannot discover.
|
||||
code = """
|
||||
import ctypes as C, os
|
||||
lib = C.CDLL(os.path.join(os.environ['FRAME_PC_NATIVE'], 'lib', 'libpipewire-0.3.so.0'))
|
||||
for name, ret, args in [
|
||||
('pw_init', None, [C.c_void_p, C.c_void_p]),
|
||||
('pw_main_loop_new', C.c_void_p, [C.c_void_p]),
|
||||
('pw_main_loop_get_loop', C.c_void_p, [C.c_void_p]),
|
||||
('pw_context_new', C.c_void_p, [C.c_void_p, C.c_void_p, C.c_size_t]),
|
||||
('pw_context_find_factory', C.c_void_p, [C.c_void_p, C.c_char_p]),
|
||||
('pw_context_find_spa_lib', C.c_char_p, [C.c_void_p, C.c_char_p]),
|
||||
('pw_context_destroy', None, [C.c_void_p]),
|
||||
('pw_main_loop_destroy', None, [C.c_void_p])]:
|
||||
fn = getattr(lib, name)
|
||||
fn.restype, fn.argtypes = ret, args
|
||||
lib.pw_init(None, None)
|
||||
loop = lib.pw_main_loop_new(None)
|
||||
assert loop, 'bundled SPA loop support did not load'
|
||||
context = lib.pw_context_new(lib.pw_main_loop_get_loop(loop), None, 0)
|
||||
assert context, 'bundled PipeWire client modules did not load'
|
||||
assert lib.pw_context_find_factory(context, b'adapter'), 'video adapter factory is missing'
|
||||
# The exported factory is video.adapt, which needs a live follower node to
|
||||
# instantiate. Check its configured library and exported factory without
|
||||
# pretending a headless context is a consented video stream.
|
||||
plugin_name = lib.pw_context_find_spa_lib(context, b'video.adapt')
|
||||
assert plugin_name, 'video.adapt has no bundled library mapping'
|
||||
plugin = C.CDLL(os.path.join(os.environ['SPA_PLUGIN_DIR'], plugin_name.decode() + '.so'))
|
||||
class Factory(C.Structure):
|
||||
_fields_ = [('version', C.c_uint32), ('name', C.c_char_p)]
|
||||
plugin.spa_handle_factory_enum.argtypes = [C.POINTER(C.POINTER(Factory)), C.POINTER(C.c_uint32)]
|
||||
plugin.spa_handle_factory_enum.restype = C.c_int
|
||||
factory, index, names = C.POINTER(Factory)(), C.c_uint32(), []
|
||||
while plugin.spa_handle_factory_enum(C.byref(factory), C.byref(index)) > 0:
|
||||
names.append(factory.contents.name)
|
||||
assert b'video.adapt' in names, 'bundled SPA video adapter factory is missing'
|
||||
lib.pw_context_destroy(context)
|
||||
lib.pw_main_loop_destroy(loop)
|
||||
"""
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['FRAME_PC_NATIVE'] = str(capture.NATIVE)
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_agent_reports_its_host_and_encoder(self):
|
||||
self.assertEqual(self.request('/status')[0], 403)
|
||||
status, body = self.request('/status?k='+self.token)
|
||||
self.assertEqual(status, 200)
|
||||
data = json.loads(body)
|
||||
if sys.platform == 'win32':
|
||||
self.assertEqual((data['host'], data['encoder']), ('windows', 'mfh264enc'))
|
||||
self.assertEqual(data['streams'], [])
|
||||
|
||||
@unittest.skipUnless(os.environ.get('FRAME_PC_INSTALLER_BUNDLE') == '1', 'checks the installer\'s bundle')
|
||||
def test_installer_bundle_leaves_out_gpl_x264(self):
|
||||
self.assertFalse(x264_bundled())
|
||||
self.assertFalse(list((capture.NATIVE / 'bin').glob('libx264*')))
|
||||
plugins = [p.name for p in (capture.NATIVE / 'lib' / 'gstreamer-1.0').iterdir()]
|
||||
for name in ('mediafoundation', 'd3d11', 'jpeg'):
|
||||
self.assertTrue(any(p.endswith('gst%s.dll' % name) for p in plugins), (name, plugins))
|
||||
|
||||
@needs_x264
|
||||
def test_last_raw_picture_survives_a_closed_gate(self):
|
||||
# A source sends ONE changed frame, then becomes idle. It must reach
|
||||
# the encoder after congestion clears, without needing another update.
|
||||
code = """
|
||||
import ctypes as C, sys, time
|
||||
from frame_pc_capture import Native, GATE, Encoded, pipeline
|
||||
native = Native()
|
||||
start = None
|
||||
closing = False
|
||||
phases = []
|
||||
def gate(stage, pts, capture, arrived):
|
||||
global start
|
||||
phases.append(stage)
|
||||
if closing: return -1
|
||||
if start is None: start = time.monotonic()
|
||||
return int(stage == 1 or time.monotonic()-start >= .15)
|
||||
callback = GATE(gate)
|
||||
p = pipeline(dict(src='test'), sys.platform, 'x264enc', 320, 180, 30, 300000)
|
||||
p = p.replace('is-live=true', 'is-live=true num-buffers=1')
|
||||
error = C.create_string_buffer(1024)
|
||||
handle = native.lib.fc_capture_open(p.encode(), callback, error, len(error))
|
||||
assert handle, error.value
|
||||
try:
|
||||
frame = Encoded()
|
||||
result = 0
|
||||
deadline = time.monotonic()+5
|
||||
while not result and time.monotonic() < deadline:
|
||||
result = native.lib.fc_capture_pull(handle, C.byref(frame))
|
||||
assert result == 1, native.lib.fc_capture_error(handle)
|
||||
assert frame.key and frame.size > 17
|
||||
assert phases.count(0) == 1 and 2 in phases, phases
|
||||
finally:
|
||||
closing = True
|
||||
native.lib.fc_capture_close(handle)
|
||||
"""
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['PYTHONPATH'] = str(ROOT / 'ui')
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@needs_x264
|
||||
def test_auth_and_real_h264_stats(self):
|
||||
self.assertEqual(self.request('/status')[0], 403)
|
||||
status, body = self.request('/ticket?src=test&k='+self.token, 'POST')
|
||||
self.assertEqual(status, 200)
|
||||
ticket = json.loads(body)['ticket']
|
||||
ws = WS(self.port, '/stream?src=test&fps=30&max=640&t='+ticket)
|
||||
try:
|
||||
self.assertEqual(ws.status, 101)
|
||||
key, got, echo = None, 0, False
|
||||
ws.send_text(json.dumps(dict(t='m', e='down', i=7, tv=1, x=.2, y=.3)))
|
||||
deadline = time.monotonic()+10
|
||||
while time.monotonic() < deadline and (got < 5 or not echo):
|
||||
op, data = ws.recv()
|
||||
if op == 1:
|
||||
m = json.loads(data)
|
||||
self.assertNotEqual(m.get('t'), 'error', m)
|
||||
if m.get('t') == 'hello':
|
||||
key = m['r']
|
||||
ws.send_text(json.dumps(dict(t='ack')))
|
||||
elif op == 2:
|
||||
got += 1
|
||||
seq, echoed = struct.unpack('!II', data[9:17])
|
||||
now = struct.unpack('!Q', data[1:9])[0]
|
||||
echo |= echoed == 7
|
||||
self.assertIn(b'\x00\x00\x00\x01', data[17:])
|
||||
ws.send_text(json.dumps(dict(t='rx', s=seq, r=now+100)))
|
||||
ws.send_text(json.dumps(dict(t='fd', f=[[seq, now+200, now+300, now+400]])))
|
||||
self.assertGreaterEqual(got, 5)
|
||||
self.assertTrue(echo)
|
||||
time.sleep(.1)
|
||||
stats = json.loads(self.request('/stats?settle=0&k='+self.token)[1])['streams'][0]
|
||||
self.assertGreater(stats['frames'][0]['e1'], stats['frames'][0]['e0'])
|
||||
self.assertIn('controller', stats)
|
||||
used = WS(self.port, '/stream?src=test&t='+ticket)
|
||||
self.assertEqual(used.status, 403)
|
||||
used.close()
|
||||
self.request('/close?src=test&k='+self.token, 'POST')
|
||||
revoked = WS(self.port, '/stream?src=test&r='+key)
|
||||
self.assertEqual(revoked.status, 403)
|
||||
revoked.close()
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Offline HTML exports and streamed transport, with real local child cleanup."""
|
||||
import sandbox # noqa: F401
|
||||
import base64
|
||||
import contextlib
|
||||
from html.parser import HTMLParser
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||
import frame_power_sample as power
|
||||
import frame_power_report as page
|
||||
|
||||
|
||||
SAMPLE = {'time': 1, 'elapsedSeconds': 0, 'osBuild': 'test',
|
||||
'readings': {'battery.watts': {'value': 0, 'unit': 'W', 'source': '/sys/battery', 'state': 'available'},
|
||||
'thermal.<script>alert(1)</script>': {'value': None, 'unit': 'C', 'source': '/sys/temp', 'state': power.UNAVAILABLE}},
|
||||
'unavailableGroups': ['proximity']}
|
||||
|
||||
|
||||
class Links(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.links = {}
|
||||
self.scripts = 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
if tag == 'script':
|
||||
self.scripts += 1
|
||||
if tag == 'a':
|
||||
self.links[attrs['download']] = base64.b64decode(attrs['href'].split(',', 1)[1]).decode()
|
||||
|
||||
|
||||
class Report(unittest.TestCase):
|
||||
def collect(self, program, count=1):
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
return power.stream_report([sys.executable, '-c', program], '', count, 1)
|
||||
|
||||
def test_completed_stream_preserves_zero_unknown_units_and_downloads(self):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
program = f'import sys; sys.stdin.read(); print({event!r}); print(\'{{"finished": {{"stopped": null}}}}\')'
|
||||
report = self.collect(program)
|
||||
self.assertIsNone(report['stopped'])
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
markup = page.render(report, power.csv_samples(report))
|
||||
parser = Links()
|
||||
parser.feed(markup)
|
||||
self.assertEqual(parser.scripts, 0)
|
||||
self.assertIn('<script>', markup)
|
||||
self.assertIn('<td>0</td>', markup)
|
||||
self.assertIn('<td>Unknown</td>', markup)
|
||||
self.assertIn('<td>C</td>', markup)
|
||||
self.assertIn('scope="row"', markup)
|
||||
self.assertEqual(json.loads(parser.links['frame-power.json']), report)
|
||||
self.assertEqual(parser.links['frame-power.csv'], power.csv_samples(report))
|
||||
self.assertIn(power.UNAVAILABLE, parser.links['frame-power.csv'])
|
||||
|
||||
def test_transport_failure_retains_received_samples_as_incomplete(self):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
report = self.collect(f'import sys; sys.stdin.read(); print({event!r}); sys.exit(255)', 2)
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
self.assertIn('SSH exit 255', report['stopped'])
|
||||
|
||||
def test_lock_stop_retains_samples(self):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
end = json.dumps({'finished': {'stopped': 'Smoke session active'}})
|
||||
report = self.collect(f'import sys; sys.stdin.read(); print({event!r}); print({end!r}); sys.exit(75)', 2)
|
||||
self.assertEqual(report['stopped'], 'Smoke session active')
|
||||
self.assertEqual(len(report['samples']), 1)
|
||||
|
||||
def test_user_stop_terminates_only_local_child_and_retains_samples(self):
|
||||
children = []
|
||||
real_popen = subprocess.Popen
|
||||
def spawn(*args, **kwargs):
|
||||
proc = real_popen(*args, **kwargs)
|
||||
children.append(proc)
|
||||
return proc
|
||||
with mock.patch.object(power.subprocess, 'Popen', side_effect=spawn), \
|
||||
mock.patch.object(power.queue.Queue, 'get', side_effect=[json.dumps({'sample': SAMPLE}), KeyboardInterrupt]):
|
||||
report = self.collect('import sys,time; sys.stdin.read(); time.sleep(30)', 2)
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
self.assertIn('Stopped by user', report['stopped'])
|
||||
self.assertIsNotNone(children[0].poll())
|
||||
|
||||
def test_invalid_stream_is_not_completed(self):
|
||||
report = self.collect('import sys; sys.stdin.read(); print("not json")')
|
||||
self.assertIn('interrupted', report['stopped'])
|
||||
self.assertEqual(report['samples'], [])
|
||||
|
||||
def test_deadline_terminates_child(self):
|
||||
with mock.patch.object(power.time, 'monotonic', side_effect=[0, 40]):
|
||||
report = self.collect('import sys,time; sys.stdin.read(); time.sleep(30)')
|
||||
self.assertIn('Timed out', report['stopped'])
|
||||
|
||||
def test_export_bounds_and_existing_file_fail_before_ssh(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
existing = Path(temp) / 'report.html'
|
||||
existing.write_text('keep')
|
||||
for args in (['--html', str(existing)], ['--html', 'new.html', '--count', '11', '--interval', '60'],
|
||||
['--html', 'new.html', '--csv']):
|
||||
with mock.patch.object(power.subprocess, 'Popen') as spawn, contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
power.main(args)
|
||||
spawn.assert_not_called()
|
||||
self.assertEqual(existing.read_text(), 'keep')
|
||||
|
||||
def test_cli_writes_incomplete_report_and_returns_nonzero(self):
|
||||
report = {'schemaVersion': 1, 'requestedSamples': 2, 'intervalSeconds': 1,
|
||||
'stopped': 'Stopped by user', 'samples': [SAMPLE], 'summary': power.summarize([SAMPLE])}
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / 'report.html'
|
||||
with mock.patch.object(power, 'stream_report', return_value=report), \
|
||||
mock.patch.object(power, 'LOCK', Path(temp) / 'no-lock'), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(power.main(['--html', str(path)]), 75)
|
||||
self.assertIn('Stopped by user', path.read_text())
|
||||
|
||||
def test_collect_emits_each_sample_before_completion(self):
|
||||
status = mock.Mock()
|
||||
with mock.patch.object(power, 'snapshot', return_value=(SAMPLE.copy(), None)):
|
||||
events = []
|
||||
report = power.collect(status, 1, 1, events.append)
|
||||
self.assertEqual(events, [{'sample': report['samples'][0]}])
|
||||
|
||||
def test_failed_save_preserves_json_on_stdout(self):
|
||||
report = {'schemaVersion': 1, 'requestedSamples': 1, 'intervalSeconds': 1,
|
||||
'stopped': None, 'samples': [SAMPLE], 'summary': power.summarize([SAMPLE])}
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(power, 'stream_report', return_value=report), \
|
||||
mock.patch.object(power, 'LOCK', Path(temp) / 'no-lock'), \
|
||||
contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(output):
|
||||
code = power.main(['--html', str(Path(temp) / 'missing' / 'report.html')])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(json.loads(output.getvalue()), report)
|
||||
|
||||
def test_premature_completion_is_incomplete_even_with_clean_ssh_exit(self):
|
||||
report = self.collect('import sys; sys.stdin.read(); print(\'{"finished": {"stopped": null}}\')')
|
||||
self.assertIn('Incomplete', report['stopped'])
|
||||
self.assertEqual(report['samples'], [])
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Power sampler fixtures and SSH transport contract; no real device contact."""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import csv
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('power_sample', ROOT / 'scripts/frame_power_sample.py')
|
||||
power = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(power)
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_status as status
|
||||
|
||||
|
||||
class Sampler(unittest.TestCase):
|
||||
def patch(self, obj, name, **kwargs):
|
||||
p = mock.patch.object(obj, name, **kwargs)
|
||||
result = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
return result
|
||||
|
||||
def fixture(self, files):
|
||||
# Windows cannot create a directory named iio:device8. Keep the
|
||||
# production sysfs spelling in returned paths while using a portable
|
||||
# name inside this temporary fixture tree.
|
||||
def local_path(path):
|
||||
return self.root / str(path).replace('\\', '/').lstrip('/').replace(':', '__COLON__')
|
||||
|
||||
for path, value in files.items():
|
||||
node = local_path(path)
|
||||
node.parent.mkdir(parents=True, exist_ok=True)
|
||||
node.write_text(str(value))
|
||||
actual_glob = power.glob.glob
|
||||
def paths(pattern):
|
||||
return ['/' + Path(p).relative_to(self.root).as_posix().replace('__COLON__', ':')
|
||||
for p in actual_glob(str(local_path(pattern)))]
|
||||
actual_read = power.read
|
||||
self.patch(power.glob, 'glob', side_effect=paths)
|
||||
self.patch(power, 'read', side_effect=lambda p: actual_read(str(local_path(str(p)))))
|
||||
self.patch(status, 'read', side_effect=lambda p: actual_read(str(local_path(str(p)))))
|
||||
return local_path
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
p = mock.patch.object(power, 'LOCK', self.root / 'lock')
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_real_file_units_zero_signed_battery_and_named_sensors(self):
|
||||
h = '/sys/class/hwmon/hwmon88'
|
||||
b = '/sys/class/power_supply/max1720x_bat_7-36'
|
||||
self.fixture({h + '/name': 'max34417_9_10', h + '/power1_label': 'vph',
|
||||
h + '/power1_input': '4710000',
|
||||
'/sys/class/hwmon/hwmon3/name': 'slg4ax46073v',
|
||||
'/sys/class/hwmon/hwmon3/fan1_input': '0',
|
||||
'/sys/class/hwmon/hwmon3/pwm1': '41',
|
||||
'/sys/class/thermal/thermal_zone9/type': 'cpu3_top_thermal',
|
||||
'/sys/class/thermal/thermal_zone9/temp': '45321',
|
||||
b + '/type': 'Battery', b + '/capacity': '0', b + '/status': 'Discharging',
|
||||
b + '/voltage_now': '3800000', b + '/current_now': '-1000000',
|
||||
'/proc/stat': 'cpu 10 0 0 90 0 0 0 0'})
|
||||
sample, ticks = power.snapshot(status, (80, 80))
|
||||
r = sample['readings']
|
||||
self.assertEqual(r['rail.max34417_9_10.vph.power1']['value'], 4.71)
|
||||
self.assertEqual(r['battery.percent']['value'], 0)
|
||||
self.assertEqual(r['battery.watts']['value'], -3.8)
|
||||
self.assertEqual(r['battery.status']['value'], 'Discharging')
|
||||
self.assertEqual(r['fan.slg4ax46073v.fan1_input']['value'], 0)
|
||||
self.assertEqual(r['fan.pwmRaw']['unit'], 'raw')
|
||||
self.assertEqual(r['thermal.cpu3_top_thermal.thermal_zone9']['value'], 45.321)
|
||||
self.assertEqual(r['cpu.percent']['value'], 50)
|
||||
self.assertEqual(ticks, (100, 90))
|
||||
self.assertEqual(sample['unavailableGroups'], ['board', 'charger', 'proximity'])
|
||||
|
||||
def test_missing_and_invalid_values_remain_unavailable(self):
|
||||
self.fixture({'/sys/class/thermal/thermal_zone0/temp': '999999999',
|
||||
'/sys/class/hwmon/hwmon2/name': 'fan',
|
||||
'/sys/class/hwmon/hwmon2/fan1_input': '-1'})
|
||||
sample, ticks = power.snapshot(status, None)
|
||||
self.assertIsNone(ticks)
|
||||
self.assertTrue(all(r['value'] is None for r in sample['readings'].values()))
|
||||
self.assertEqual(len(sample['unavailableGroups']), 6)
|
||||
self.assertIn('two valid CPU', sample['readings']['cpu.percent']['state'])
|
||||
|
||||
def test_permission_error_is_unavailable(self):
|
||||
with mock.patch.object(Path, 'read_text', side_effect=PermissionError):
|
||||
self.assertIsNone(power.number('/unreadable'))
|
||||
for value in ('NaN', 'Infinity', '1.5', '', '9' * 5000):
|
||||
with mock.patch.object(power, 'read', return_value=value):
|
||||
self.assertIsNone(power.number('/invalid'))
|
||||
|
||||
def test_iio_units_and_permission_denied_proximity(self):
|
||||
node = '/sys/bus/iio/devices/iio:device8'
|
||||
local_path = self.fixture({node + '/name': 'vcnl4000',
|
||||
node + '/in_temp_pcba_at_wifi_input': '37500',
|
||||
node + '/in_current_pm8550b_iin_fb_input': '241671',
|
||||
'/etc/os-release': 'BUILD_ID=20261015.6141207\n'})
|
||||
# exists() is used only for optional known charger channels and lock.
|
||||
exists = Path.exists
|
||||
with mock.patch.object(Path, 'exists', lambda p: exists(p) if p == power.LOCK
|
||||
else exists(local_path(str(p)))):
|
||||
sample, _ = power.snapshot(status, None)
|
||||
self.assertEqual(sample['osBuild'], '20261015.6141207')
|
||||
self.assertEqual(sample['readings']['board.iio:device8.in_temp_pcba_at_wifi_input']['value'], 37.5)
|
||||
self.assertEqual(sample['readings']['charger.iio:device8.iin']['value'], 0.241671)
|
||||
self.assertIsNone(sample['readings']['proximity.raw']['value'])
|
||||
self.assertIn('proximity', sample['unavailableGroups'])
|
||||
|
||||
def test_unknown_charging_state_is_not_claimed(self):
|
||||
self.fixture({})
|
||||
with mock.patch.object(status, 'battery', return_value={'status': 'Unknown', 'percent': 50}):
|
||||
r = power.snapshot(status, None)[0]['readings']
|
||||
self.assertIsNone(r['battery.status']['value'])
|
||||
self.assertEqual(r['battery.percent']['value'], 50)
|
||||
|
||||
def test_smoke_lock_prevents_any_sensor_reads(self):
|
||||
power.LOCK.mkdir()
|
||||
with mock.patch.object(status, 'cpu_ticks') as ticks:
|
||||
with self.assertRaisesRegex(RuntimeError, 'Smoke session'):
|
||||
power.snapshot(status, None)
|
||||
ticks.assert_not_called()
|
||||
|
||||
def test_smoke_lock_appearing_between_samples_stops_and_preserves_partial_report(self):
|
||||
self.fixture({'/proc/stat': 'cpu 10 0 0 90 0 0 0 0'})
|
||||
with mock.patch.object(power.time, 'sleep', side_effect=lambda _: power.LOCK.mkdir()):
|
||||
report = power.collect(status, 3, 1)
|
||||
self.assertEqual(len(report['samples']), 1)
|
||||
self.assertIn('Smoke session', report['stopped'])
|
||||
self.assertEqual(report['requestedSamples'], 3)
|
||||
|
||||
def test_summaries_ignore_missing_values_and_keep_state_changes(self):
|
||||
def row(value, unit='W'):
|
||||
return {'value': value, 'unit': unit, 'source': '/sensor', 'state': 'available'}
|
||||
samples = [{'readings': {'rail': row(0), 'state': row('Charging', 'state')}},
|
||||
{'readings': {'rail': row(None), 'state': row('Full', 'state')}},
|
||||
{'readings': {'rail': row(6)}}]
|
||||
summary = power.summarize(samples)
|
||||
self.assertEqual(summary['rail'], dict(unit='W', sources=['/sensor'], available=2,
|
||||
unavailable=1, min=0, max=6, mean=3, first=0, last=6))
|
||||
self.assertEqual(summary['state']['states'], ['Charging', 'Full'])
|
||||
self.assertEqual(summary['state']['unavailable'], 1)
|
||||
|
||||
def test_csv_quoting_and_unavailable_groups(self):
|
||||
report = {'samples': [{'time': 1, 'elapsedSeconds': 0,
|
||||
'readings': {'fan,test': {'value': -3.8, 'unit': 'W',
|
||||
'source': '=formula', 'state': 'available'}},
|
||||
'unavailableGroups': ['thermal']}]}
|
||||
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
|
||||
self.assertEqual(rows[1][2:6], ['fan,test', '-3.8', 'W', "'=formula"])
|
||||
self.assertEqual(rows[2][-1], power.UNAVAILABLE)
|
||||
|
||||
def test_bounded_arguments_rejected_before_ssh(self):
|
||||
for args in (['--count', '0'], ['--count', '721'], ['--interval', 'nan'],
|
||||
['--interval', 'inf'], ['--interval', '0.5'], ['--interval', '61']):
|
||||
with mock.patch.object(power.subprocess, 'run') as run, contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
power.main(args)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_local_lock_prevents_ssh(self):
|
||||
power.LOCK.mkdir()
|
||||
with mock.patch.object(power.subprocess, 'run') as run, contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(power.main([]), 75)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_ssh_payload_runs_as_module_and_outputs_report(self):
|
||||
report = dict(samples=[], summary={}, stopped=None)
|
||||
def ssh(command, **kwargs):
|
||||
self.assertIn('BatchMode=yes', command)
|
||||
self.assertIn('StrictHostKeyChecking=yes', command)
|
||||
self.assertIn('frame', command)
|
||||
self.assertEqual(command[-4:], ['--count', '2', '--interval', '1.0'])
|
||||
# Execute the exact transmitted source, with main suppressed. Its
|
||||
# frame_status module must be importable without running status().
|
||||
namespace = {'__name__': 'payload_test'}
|
||||
with mock.patch.dict(sys.modules):
|
||||
exec(compile(kwargs['input'], '<ssh-stdin>', 'exec'), namespace)
|
||||
remote = sys.modules['frame_status']
|
||||
self.assertTrue(callable(remote.battery))
|
||||
with mock.patch.object(remote, 'status', side_effect=AssertionError('full status forbidden')), \
|
||||
mock.patch.object(remote, 'battery', return_value=None), \
|
||||
mock.patch.object(remote, 'cpu_ticks', return_value=(100, 90)), \
|
||||
mock.patch.object(namespace['glob'], 'glob', return_value=[]), \
|
||||
mock.patch.object(namespace['Path'], 'exists', return_value=False):
|
||||
report.update(namespace['collect'](remote, 1, 1))
|
||||
return subprocess.CompletedProcess(command, 0, json.dumps(report), '')
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(power.subprocess, 'run', side_effect=ssh), contextlib.redirect_stdout(output):
|
||||
self.assertEqual(power.main(['--count', '2', '--interval', '1']), 0)
|
||||
self.assertEqual(len(json.loads(output.getvalue())['samples']), 1)
|
||||
|
||||
def test_ssh_failures_are_not_measurements(self):
|
||||
for result in (subprocess.CompletedProcess([], 255, '', 'offline'),
|
||||
subprocess.CompletedProcess([], 0, 'bad json', '')):
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(power.subprocess, 'run', return_value=result), \
|
||||
contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertNotEqual(power.main([]), 0)
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
@@ -21,12 +21,13 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
|
||||
FAKEGH = ROOT / "tests" / "fakegh"
|
||||
STEP = "- name: Create the draft release"
|
||||
UPLOAD_STEP = "- name: Upload to the release"
|
||||
|
||||
|
||||
def step_script():
|
||||
def step_script(step=STEP):
|
||||
"""The step's `run: |` block, dedented."""
|
||||
lines = WORKFLOW.read_text().splitlines()
|
||||
i = next(n for n, line in enumerate(lines) if line.strip() == STEP)
|
||||
i = next(n for n, line in enumerate(lines) if line.strip() == step)
|
||||
while lines[i].strip() != "run: |":
|
||||
i += 1
|
||||
indent = len(lines[i]) - len(lines[i].lstrip())
|
||||
@@ -87,6 +88,19 @@ class DraftStep(unittest.TestCase):
|
||||
[patch] = self.patches(log)
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
|
||||
|
||||
def test_a_rerun_reuses_the_same_untagged_draft(self):
|
||||
p, log = self.run_step([release(42, "untagged-abc")])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(self.posts(log), [])
|
||||
self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/42")
|
||||
|
||||
def test_ambiguous_drafts_do_not_create_or_patch(self):
|
||||
p, log = self.run_step([release(42, "untagged-abc"), release(43, "untagged-def")])
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("expected one release", p.stderr)
|
||||
self.assertEqual(self.posts(log), [])
|
||||
self.assertEqual(self.patches(log), [])
|
||||
|
||||
def test_other_releases_alone_mean_a_new_draft(self):
|
||||
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False),
|
||||
release(8, "v9.8.70", "Frame Control 9.8.70")])
|
||||
@@ -102,5 +116,55 @@ class DraftStep(unittest.TestCase):
|
||||
self.assertEqual(self.patches(log), [])
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
|
||||
"needs a POSIX bash and jq; Actions runs this step under bash")
|
||||
class UploadStep(unittest.TestCase):
|
||||
def run_step(self, releases, error=None):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
d = Path(tmp)
|
||||
script = step_script(UPLOAD_STEP).replace("${{ matrix.files }}", "artifact.exe artifact.zip")
|
||||
(d / "step.sh").write_text(script)
|
||||
(d / "releases.json").write_text(json.dumps(releases))
|
||||
(d / "artifact.exe").write_bytes(b"installer")
|
||||
(d / "artifact.zip").write_bytes(b"portable")
|
||||
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
||||
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS="v9.8.7",
|
||||
FAKEGH_LOG=str(d / "log"), FAKEGH_ALLOW_RELEASE_UPLOAD="1",
|
||||
GITHUB_REF_NAME="v9.8.7", GH_REPO="saphid/frame-control")
|
||||
if error:
|
||||
env["FAKEGH_UPLOAD_ERROR"] = error
|
||||
p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")],
|
||||
cwd=d, env=env, capture_output=True, text=True, timeout=30)
|
||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()]
|
||||
return p, log
|
||||
|
||||
def test_tagged_draft_uses_normal_upload(self):
|
||||
p, log = self.run_step([release(42, "v9.8.7")])
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
self.assertEqual(len([c for c in log if c[:2] == ["release", "upload"]]), 1)
|
||||
self.assertFalse(any(c[:3] == ["api", "-X", "PATCH"] for c in log))
|
||||
|
||||
def test_untagged_draft_is_reassociated_then_uploaded(self):
|
||||
p, log = self.run_step([release(42, "untagged-abc")])
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
self.assertEqual(len([c for c in log if c[:2] == ["release", "upload"]]), 2)
|
||||
patches = [c for c in log if c[:3] == ["api", "-X", "PATCH"]]
|
||||
self.assertEqual(len(patches), 1)
|
||||
self.assertIn("repos/saphid/frame-control/releases/42", patches[0])
|
||||
self.assertIn("tag_name=v9.8.7", patches[0])
|
||||
|
||||
def test_ambiguous_untagged_drafts_are_not_changed(self):
|
||||
p, log = self.run_step([release(42, "untagged-abc"), release(43, "untagged-def")])
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("expected one matching draft", p.stderr)
|
||||
self.assertFalse(any(c[:3] == ["api", "-X", "PATCH"] for c in log))
|
||||
|
||||
def test_other_upload_failures_do_not_patch_draft(self):
|
||||
p, log = self.run_step([release(42, "v9.8.7")], error="network unavailable")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("network unavailable", p.stderr)
|
||||
self.assertFalse(any(c[:3] == ["api", "-X", "PATCH"] for c in log))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Smoke cleanup against real local Linux processes, never a headset or SSH."""
|
||||
import sandbox # noqa: F401
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
SMOKE = Path(__file__).resolve().parent / 'smoke'
|
||||
sys.path.insert(0, str(SMOKE))
|
||||
import stop_processes # noqa: E402
|
||||
import frame_smoke # noqa: E402
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux' and hasattr(os, 'pidfd_open'), 'Linux pidfds required')
|
||||
class RealProcesses(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
self.title = self.home / 'devkit-game' / 'fc_smoke_exe'
|
||||
self.title.mkdir(parents=True)
|
||||
self.children = []
|
||||
self.patcher = mock.patch.object(stop_processes.Path, 'home', return_value=self.home)
|
||||
self.patcher.start()
|
||||
self.addCleanup(self.patcher.stop)
|
||||
self.addCleanup(self.reap)
|
||||
|
||||
def reap(self):
|
||||
for child in self.children:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait(timeout=5)
|
||||
|
||||
def start(self, cwd, *args, ignore=False):
|
||||
code = ('import signal,time; '
|
||||
+ ('signal.signal(signal.SIGTERM, signal.SIG_IGN); ' if ignore else '')
|
||||
+ 'print("ready", flush=True); time.sleep(60)')
|
||||
child = subprocess.Popen([sys.executable, '-c', code, *args], cwd=cwd,
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
self.children.append(child)
|
||||
self.addCleanup(child.stdout.close)
|
||||
self.assertEqual(child.stdout.readline().strip(), 'ready')
|
||||
return child
|
||||
|
||||
def test_deleted_title_process_is_stopped_but_similar_names_survive(self):
|
||||
owned = self.start(self.title)
|
||||
neighbour = self.title.with_name('fc_smoke_exe_other')
|
||||
neighbour.mkdir()
|
||||
other = self.start(neighbour, 'fc_smoke_exe', 'wine', 'proton')
|
||||
shutil.rmtree(self.title)
|
||||
# Reproduces the old success condition: no title remains, yet it runs.
|
||||
self.assertFalse(self.title.exists())
|
||||
self.assertIsNone(owned.poll())
|
||||
def local_target(command, *, input, timeout):
|
||||
return subprocess.run(shlex.split(command), input=input, text=True,
|
||||
capture_output=True, check=True, timeout=timeout,
|
||||
env={**os.environ, 'HOME': str(self.home)}).stdout
|
||||
|
||||
# Exercise the production SSH command and stdin payload against a local
|
||||
# fake target, including the helper's CLI and JSON result contract.
|
||||
with mock.patch.object(frame_smoke.frame_android, 'ssh', side_effect=local_target):
|
||||
result = frame_smoke.stop_titles({'fc_smoke_exe'})
|
||||
self.assertIn(owned.pid, result['stopped_pids'])
|
||||
self.assertEqual(owned.wait(timeout=5), -signal.SIGTERM)
|
||||
self.assertIsNone(other.poll())
|
||||
|
||||
def test_proton_style_absolute_argument_escalates_without_stopping_shared_wine(self):
|
||||
owned = self.start(self.home, str(self.title / 'fc-smoke-exe.exe'), ignore=True)
|
||||
shared = self.start(self.home, 'wineserver', '-p')
|
||||
quoted = self.start(self.home, 'echo ' + str(self.title / 'fc-smoke-exe.exe'))
|
||||
result = stop_processes.stop(['fc_smoke_exe'], grace=0.2)
|
||||
self.assertIn(owned.pid, result['stopped_pids'])
|
||||
self.assertEqual(owned.wait(timeout=5), -signal.SIGKILL)
|
||||
self.assertIsNone(shared.poll())
|
||||
self.assertIsNone(quoted.poll())
|
||||
self.assertEqual(stop_processes.stop(['fc_smoke_exe']), {'stopped_pids': []})
|
||||
|
||||
def test_unreadable_cwd_does_not_abort_scan_or_hide_readable_argv(self):
|
||||
owned = self.start(self.home, str(self.title / 'fc-smoke-exe.exe'))
|
||||
other = self.start(self.home, 'wineserver')
|
||||
with mock.patch.object(stop_processes.os, 'readlink', side_effect=PermissionError):
|
||||
self.assertTrue(stop_processes.owned(owned.pid, [str(self.title)]))
|
||||
self.assertFalse(stop_processes.owned(other.pid, [str(self.title)]))
|
||||
result = stop_processes.stop(['fc_smoke_exe'], grace=0.2)
|
||||
self.assertIn(owned.pid, result['stopped_pids'])
|
||||
self.assertEqual(owned.wait(timeout=5), -signal.SIGTERM)
|
||||
self.assertIsNone(other.poll())
|
||||
|
||||
|
||||
class Safety(unittest.TestCase):
|
||||
def test_non_test_ids_rejected_before_scanning(self):
|
||||
for ids in ([], ['portal2'], ['fc_smoke_exe/../portal2'], ['fc_smoke_custom']):
|
||||
with self.subTest(ids=ids), self.assertRaises(ValueError):
|
||||
stop_processes.stop(ids)
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux', 'Linux signalling helper')
|
||||
def test_lost_ownership_after_open_never_signalled(self):
|
||||
with mock.patch.object(stop_processes, 'candidates', side_effect=[[123], [], []]), \
|
||||
mock.patch.object(stop_processes.os, 'pidfd_open', return_value=9, create=True), \
|
||||
mock.patch.object(stop_processes.os, 'close'), \
|
||||
mock.patch.object(stop_processes, 'owned', return_value=False), \
|
||||
mock.patch.object(stop_processes.signal, 'pidfd_send_signal', create=True) as send:
|
||||
self.assertEqual(stop_processes.stop(['fc_smoke_exe'], grace=0), {'stopped_pids': []})
|
||||
send.assert_not_called()
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux', 'Linux signalling helper')
|
||||
def test_survivor_is_a_failure(self):
|
||||
with mock.patch.object(stop_processes, 'candidates', return_value=[123]), \
|
||||
mock.patch.object(stop_processes.os, 'pidfd_open', return_value=9, create=True), \
|
||||
mock.patch.object(stop_processes.os, 'close'), \
|
||||
mock.patch.object(stop_processes, 'owned', return_value=True), \
|
||||
mock.patch.object(stop_processes.signal, 'pidfd_send_signal', create=True):
|
||||
with self.assertRaisesRegex(RuntimeError, 'survived cleanup'):
|
||||
stop_processes.stop(['fc_smoke_exe'], grace=0)
|
||||
|
||||
def test_cleanup_scans_even_when_title_already_removed(self):
|
||||
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[]), \
|
||||
mock.patch.object(frame_smoke, 'ssh') as ssh, \
|
||||
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': [42]}) as stop:
|
||||
result = frame_smoke.cleanup(frame_smoke.Smoke())
|
||||
self.assertEqual(stop.call_args_list, [mock.call(stop_processes.IDS)] * 2)
|
||||
self.assertEqual(result['process_cleanup'][0]['stopped_pids'], [42])
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_stop_failure_prevents_file_removal(self):
|
||||
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[{'id': 'fc_smoke_exe'}]), \
|
||||
mock.patch.object(frame_smoke, 'ssh') as ssh, \
|
||||
mock.patch.object(frame_smoke, 'stop_titles', side_effect=RuntimeError('still running')):
|
||||
with self.assertRaisesRegex(RuntimeError, 'still running'):
|
||||
frame_smoke.cleanup(frame_smoke.Smoke())
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_failed_initial_cleanup_prevents_install_and_records_failure(self):
|
||||
with tempfile.TemporaryDirectory() as folder, \
|
||||
mock.patch.object(frame_smoke, 'RESULTS', Path(folder)), \
|
||||
mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
|
||||
mock.patch.object(frame_smoke.subprocess, 'run', return_value=mock.Mock(returncode=0)), \
|
||||
mock.patch.object(frame_smoke.tempfile, 'mkdtemp', return_value=folder), \
|
||||
mock.patch.object(frame_smoke, 'os_release', return_value={
|
||||
'BUILD_ID': 'fake', 'VERSION_ID': 'fake', 'VARIANT_ID': 'fake'}), \
|
||||
mock.patch.object(frame_smoke, 'ssh', return_value='yes'), \
|
||||
mock.patch.object(frame_smoke, 'cleanup', side_effect=RuntimeError('cannot stop')), \
|
||||
mock.patch.object(frame_smoke, 'title_cycle') as cycle, \
|
||||
mock.patch('builtins.print'):
|
||||
self.assertEqual(frame_smoke.main(), 1)
|
||||
cycle.assert_not_called()
|
||||
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Storage inventory uses only local temporary metadata; no headset connection."""
|
||||
import sandbox # noqa: F401
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_storage as storage
|
||||
import server
|
||||
|
||||
|
||||
class Storage(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
self.steam = self.home / '.local/share/Steam'
|
||||
(self.steam / 'steamapps').mkdir(parents=True)
|
||||
|
||||
def write(self, relative, text):
|
||||
path = self.steam / 'steamapps' / relative
|
||||
path.write_text(text)
|
||||
return path
|
||||
|
||||
def app(self, appid, name, size=None):
|
||||
return self.write(f'appmanifest_{appid}.acf', '"AppState" { "appid" "%s" "name" "%s" %s }' %
|
||||
(appid, name, '' if size is None else '"SizeOnDisk" "%s"' % size))
|
||||
|
||||
def inventory(self):
|
||||
return storage.inventory(self.home, self.steam)
|
||||
|
||||
def test_multilibrary_dedup_sizes_and_read_only(self):
|
||||
extra = self.home / 'other library'
|
||||
(extra / 'steamapps').mkdir(parents=True)
|
||||
self.write('libraryfolders.vdf', '"libraryfolders" { "0" { "path" "%s" } "1" { "path" "%s" } "2" "%s" }' %
|
||||
(self.steam, extra, extra))
|
||||
self.app(1, 'Game', '400')
|
||||
self.app(2, 'Proton 10', '100')
|
||||
self.app(3, 'Unknown')
|
||||
self.app(4, 'Zero', '0')
|
||||
self.app(5, 'Bad', '-10')
|
||||
(extra / 'steamapps/appmanifest_9.acf').write_text('"AppState" { "appid" "9" "name" "Other" "SizeOnDisk" "200" }')
|
||||
before = {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()}
|
||||
result = self.inventory()
|
||||
self.assertEqual(result['discovery'], 'ok')
|
||||
self.assertEqual(len(result['libraries']), 2)
|
||||
lib = result['libraries'][0]
|
||||
self.assertEqual((lib['gameBytes'], lib['toolBytes'], lib['recordedBytes'], lib['unknownSizes']), (400, 100, 500, 2))
|
||||
self.assertEqual([a['bytes'] for a in lib['apps']], [400, 100, 0, None, None])
|
||||
self.assertEqual(result['libraries'][1]['recordedBytes'], 200)
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()})
|
||||
|
||||
def test_partial_and_corrupt_metadata_never_become_empty_success(self):
|
||||
self.write('libraryfolders.vdf', '"libraryfolders" {')
|
||||
self.app(1, 'Good', '12')
|
||||
self.write('appmanifest_2.acf', '"AppState" { "appid" "2"')
|
||||
self.write('appmanifest_3.acf', '"AppState" { "appid" "9" }')
|
||||
r = self.inventory()
|
||||
self.assertEqual(r['discovery'], 'invalid-metadata')
|
||||
lib = r['libraries'][0]
|
||||
self.assertEqual((lib['state'], lib['recordedBytes'], len(lib['issues'])), ('partial', 12, 2))
|
||||
|
||||
def test_permission_denied_discovery_listing_manifest_and_capacity(self):
|
||||
original = storage.read_vdf
|
||||
self.app(1, 'Game', '10')
|
||||
def read(path):
|
||||
if str(path).endswith('appmanifest_1.acf') or str(path).endswith('libraryfolders.vdf'):
|
||||
raise PermissionError('denied')
|
||||
return original(path)
|
||||
with patch.object(storage, 'read_vdf', side_effect=read), patch.object(storage.os, 'statvfs', side_effect=PermissionError(), create=True):
|
||||
r = self.inventory()
|
||||
self.assertEqual(r['discovery'], 'permission-denied')
|
||||
self.assertEqual(r['home']['state'], 'permission-denied')
|
||||
self.assertIsNone(r['home']['free'])
|
||||
self.assertEqual(r['libraries'][0]['issues'][0]['state'], 'permission-denied')
|
||||
with patch.object(storage.os, 'scandir', side_effect=PermissionError()):
|
||||
self.assertEqual(self.inventory()['libraries'][0]['state'], 'permission-denied')
|
||||
|
||||
def test_disconnected_library_and_missing_discovery(self):
|
||||
r = self.inventory()
|
||||
self.assertEqual(r['discovery'], 'missing')
|
||||
missing = self.home / 'disconnected'
|
||||
self.write('libraryfolders.vdf', '"libraryfolders" { "1" { "path" "%s" } }' % missing)
|
||||
lib = self.inventory()['libraries'][1]
|
||||
self.assertEqual(lib['state'], 'missing')
|
||||
self.assertIsNone(lib['capacity']['total'])
|
||||
|
||||
def test_reserved_capacity_and_shared_filesystem(self):
|
||||
stat = SimpleNamespace(f_blocks=100, f_frsize=1024, f_bavail=10, f_bfree=20)
|
||||
with patch.object(storage.os, 'statvfs', return_value=stat, create=True):
|
||||
r = self.inventory()
|
||||
self.assertEqual((r['home']['total'], r['home']['free'], r['home']['used']), (102400, 10240, 81920))
|
||||
self.assertEqual(r['home']['filesystem'], r['libraries'][0]['capacity']['filesystem'])
|
||||
|
||||
def test_keyvalues_comments_escaping_and_limits(self):
|
||||
path = self.write('parse.vdf', '// comment\n"root" { "name" "A \\"quote\\"" "nested" { "size" "42" } }')
|
||||
self.assertEqual(storage.read_vdf(path)['root']['name'], 'A "quote"')
|
||||
path.write_text('"root" { "name" "}" }')
|
||||
self.assertEqual(storage.read_vdf(path)['root']['name'], '}')
|
||||
for bad in ['"a" {', '"a" "1" "a" "2"', '"a" }', 'garbage', '"a" {"b" "c"}}']:
|
||||
path.write_text(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
storage.read_vdf(path)
|
||||
path.write_text('x' * (storage.MAX_TEXT + 1))
|
||||
with self.assertRaises(ValueError):
|
||||
storage.read_vdf(path)
|
||||
|
||||
def test_http_probe_is_guarded_and_read_only(self):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with patch.object(server, 'ssh', return_value=json.dumps(self.inventory())) as ssh:
|
||||
conn = http.client.HTTPConnection(*httpd.server_address)
|
||||
conn.request('GET', '/api/storage')
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 403)
|
||||
response.read()
|
||||
ssh.assert_not_called()
|
||||
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY})
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn('libraries', json.loads(response.read()))
|
||||
self.assertEqual(ssh.call_args.args, ('python3 -',))
|
||||
self.assertEqual(ssh.call_args.kwargs['stdin'], (ROOT / 'ui/frame_storage.py').read_text())
|
||||
with patch.object(server, 'LINK', SimpleNamespace(active_device=lambda: {'id': 'new-frame'})):
|
||||
ssh.reset_mock()
|
||||
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY, 'X-Frame-Device': 'old-frame'})
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 409)
|
||||
response.read()
|
||||
ssh.assert_not_called()
|
||||
conn.close()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
thread.join()
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node required')
|
||||
def test_ui_behavior(self):
|
||||
r = subprocess.run(['node', '--test', str(ROOT / 'tests/storage.cjs')], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
+3
-1
@@ -6,7 +6,7 @@ const pick = (s,a,b) => s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)));
|
||||
const tick = async () => { for (let i=0;i<16;i++) await Promise.resolve(); };
|
||||
function connectionFixture() {
|
||||
const f=workspaceFixture(), c=f.context;
|
||||
Object.assign(c,{URLSearchParams,Date,clearTimeout,link:{s:{phase:'connected'},device:'a',phase:'connected',rev:0},disp:{list:[],seq:0},cat:{apps:null},wi:{},devGen:1,androidSeq:0,androidPending:new Set(),titlesSeq:0,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
|
||||
Object.assign(c,{URLSearchParams,Date,clearTimeout,link:{s:{phase:'connected'},device:'a',phase:'connected',rev:0},disp:{list:[],seq:0},cat:{apps:null},wi:{},devGen:1,androidSeq:0,androidPending:new Set(),titlesSeq:0,runningGames:[],runningSeq:0,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
|
||||
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
|
||||
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},resetClips(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
|
||||
c.window.connDevice='a';
|
||||
@@ -40,7 +40,9 @@ test('a device switch discards completed install results and the open detail',()
|
||||
const f=connectionFixture(),c=f.context;
|
||||
Object.assign(c,{pad:{state:'off',queue:[]},padReset(){},sourceState:{jobs:new Map([['test:one',{done:true,result:{package:'app.on.a',game_data:true}}]]),detail:{app:{}},detailRequest:0}});
|
||||
f.el('appDetail').open=true;
|
||||
f.el('storageResult').textContent='Old headset storage';
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.match(f.el('storageResult').textContent,/Headset changed/);
|
||||
assert.equal(c.sourceState.jobs.size,0);assert.equal(c.sourceState.detail,null);assert.equal(f.el('appDetail').open,false);
|
||||
});
|
||||
test('only a preview matching current diagnostics consent can be sent or copied',async()=>{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Optional store metadata: images {icon, banner, screenshots}, developer,
|
||||
description, popularity, open_source, requires_meta_services, frame_tested.
|
||||
Only an explicit frame_tested=True produces a working-on-Frame verdict.
|
||||
An explicit frame_tested=True or a saved working report produces a working verdict.
|
||||
"""
|
||||
import importlib
|
||||
import inspect
|
||||
@@ -24,6 +24,46 @@ _game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
|
||||
_status = {}
|
||||
TIMEOUT = 12
|
||||
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
|
||||
compat_reports = None # optional memory-only snapshot hook; enabled at server start, not import
|
||||
|
||||
|
||||
class ReportCache:
|
||||
"""Stale-while-refresh reports. Never hold the snapshot lock while loading.
|
||||
|
||||
The loader may block on disk, credentials or the database. Only its daemon
|
||||
worker calls it; readers keep the last complete snapshot, even after errors.
|
||||
"""
|
||||
def __init__(self, loader, report_verdict, ttl=60):
|
||||
self.loader, self.report_verdict, self.ttl = loader, report_verdict, ttl
|
||||
self._lock = threading.Lock()
|
||||
self._snapshot, self._at, self._running = {}, None, False
|
||||
|
||||
def read(self):
|
||||
with self._lock:
|
||||
snapshot = self._snapshot
|
||||
if not self._running and (self._at is None or time.monotonic() - self._at >= self.ttl):
|
||||
self._running = True
|
||||
threading.Thread(target=self._refresh, daemon=True).start()
|
||||
return snapshot
|
||||
|
||||
def _refresh(self):
|
||||
try:
|
||||
by = {}
|
||||
for report in self.loader():
|
||||
if report.get('package'):
|
||||
by.setdefault(report['package'], []).append(report)
|
||||
snapshot = {pkg: v for pkg, rows in by.items() if (v := self.report_verdict(rows))}
|
||||
with self._lock:
|
||||
self._snapshot = snapshot
|
||||
except Exception:
|
||||
pass # optional evidence must never break search or discard a good snapshot
|
||||
finally:
|
||||
with self._lock:
|
||||
self._at, self._running = time.monotonic(), False
|
||||
|
||||
|
||||
def _reports():
|
||||
return compat_reports() if compat_reports else {}
|
||||
|
||||
|
||||
def modules():
|
||||
@@ -149,7 +189,7 @@ def fit(entry):
|
||||
'reasons': reasons}
|
||||
|
||||
|
||||
def verdict(entry):
|
||||
def verdict(entry, report=None):
|
||||
compatibility = fit(entry)
|
||||
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
|
||||
if entry.get('requires_meta_services') is True:
|
||||
@@ -160,6 +200,12 @@ def verdict(entry):
|
||||
return {'label': "This version isn't made for the Frame", 'tone': 'blocked'}
|
||||
if 'vrapi' in hints:
|
||||
return {'label': "Made for older Quest headsets; won't run on the Frame", 'tone': 'blocked'}
|
||||
if report:
|
||||
kind, _ = report
|
||||
return {'label': {'works': 'Reported working on the Frame',
|
||||
'maybe': 'Reported issues on the Frame',
|
||||
'no': 'Reported not working on the Frame'}[kind],
|
||||
'tone': {'works': 'works', 'maybe': 'issues', 'no': 'warning'}[kind]}
|
||||
if entry.get('frame_tested') is True:
|
||||
return {'label': 'Works on the Frame', 'tone': 'works'}
|
||||
if compatibility['installable'] is True:
|
||||
@@ -167,10 +213,13 @@ def verdict(entry):
|
||||
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
|
||||
|
||||
|
||||
def decorate(entry, shots=12):
|
||||
def decorate(entry, shots=12, reports=None):
|
||||
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
|
||||
from apk_sources import _images
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
|
||||
report = (reports if reports is not None else _reports()).get(entry.get('package'))
|
||||
evidence = {'verdict': report[0], 'lines': list(report[1])} if report else None
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry, report), compat_report=evidence,
|
||||
artwork=_images.artwork(entry, shots),
|
||||
**categories.classify(entry))
|
||||
|
||||
|
||||
@@ -191,8 +240,9 @@ def offer_rank(entry):
|
||||
|
||||
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
|
||||
groups = {}
|
||||
reports = _reports() # one complete snapshot for every offer in this response
|
||||
for entry in entries:
|
||||
entry = decorate(entry, shots=3)
|
||||
entry = decorate(entry, shots=3, reports=reports)
|
||||
if not categories.matches(entry, category, phone):
|
||||
continue
|
||||
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
|
||||
@@ -310,6 +360,7 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
|
||||
|
||||
def warm():
|
||||
"""Start every enabled source's index download in the background (server start, new repo)."""
|
||||
_reports() # warm independently of potentially slow source indexes
|
||||
from apk_sources import _web
|
||||
_web.prune()
|
||||
items, _ = registry()
|
||||
|
||||
@@ -47,3 +47,5 @@
|
||||
@media (max-width:900px) { .cc-workspace { grid-template-columns:minmax(0,1fr); }.cc-sidebar { position:static; }.cc-sidebar > p,.cc-status { display:none; }.cc-categories { flex-direction:row; overflow-x:auto; padding-bottom:6px; }.cc-categories button { flex:none; white-space:nowrap; }.cc-category-count { margin-left:2px; }.cc-categories button[aria-pressed=true] { box-shadow:inset 0 -2px var(--blue); }.cc-summary { gap:10px; }.cc-metric { padding:15px; } }
|
||||
@media (max-width:640px) { .cc { gap:20px; }.cc-hero { padding:22px 20px; }.cc-hero p { font-size:12px; }.cc-hero-mark { width:72px; height:55px; }.cc-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-metric > strong { font-size:24px; }.cc-topline { gap:8px; }.cc-sample { order:3; width:100%; }.cc-device-facts { padding:14px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-cards { grid-template-columns:minmax(0,1fr); gap:12px; }.cc-search-row { gap:8px; }.cc-results { font-size:10px; }.cc-card { padding:20px; }.cc-card h2 { font-size:18px; } }
|
||||
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-links a { min-height:44px; }.cc-audio-actions button,.cc-topline button { min-height:44px; }.cc-search button { min-width:36px; min-height:44px; } }
|
||||
.cc-running { list-style:none; margin:12px 0 0; padding:0; display:grid; gap:6px; }.cc-running:empty { display:none; }.cc-running li { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:8px 10px; border-radius:6px; background:#ffffff0a; }.cc-running span { min-width:0; overflow-wrap:anywhere; }.cc-running small { display:block; font-size:11px; color:var(--muted); }.cc-running button { border-radius:5px; flex:none; }
|
||||
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-running button { min-height:44px; } }
|
||||
+84
-11
@@ -16,8 +16,8 @@
|
||||
description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.',
|
||||
keywords:'sound speakers headphones loud quiet', special:'audio'},
|
||||
{id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics',
|
||||
description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.',
|
||||
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
|
||||
description:'Inspect charge, signed battery power, charger readings, battery temperature, health and optional capacity / cycle readings. Heat warnings use the kernel’s own hot and critical trip points.',
|
||||
keywords:'battery charging charger charge time estimate remaining power watts usb health cycles capacity wear heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
|
||||
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
|
||||
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
|
||||
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#linuxApps','Linux apps'],['#power','SFTP & remote tools']]},
|
||||
@@ -54,6 +54,9 @@
|
||||
{id:'steam', category:'apps', icon:'game', title:'Steam library', kind:'flow', label:'Existing controls',
|
||||
description:'Browse owned games and Frame ratings, install with progress, or launch an installed game. Purchases and license prompts use Steam.',
|
||||
keywords:'games download install store owned compatibility rating verified proton', links:[['#library','Installed games'],['#getgames','Library & store']]},
|
||||
{id:'forcequit', category:'apps', icon:'power', title:'Force quit a game', kind:'control', label:'Available here',
|
||||
description:'See what’s running on the Frame and quit a game or app that has stopped responding. Steam is asked first; if the game stays open, its own processes are stopped. Unsaved progress is lost.',
|
||||
keywords:'hang hung frozen freeze crash stuck kill close stop end task running not responding exit', special:'forcequit'},
|
||||
{id:'android', category:'apps', icon:'apps', title:'Android apps', kind:'flow', label:'Existing controls',
|
||||
description:'Discover apps, manage Lepton instances, and launch, stop, test or remove installed apps. View compatibility reports before installing.',
|
||||
keywords:'apk fdroid lepton application sideload sources repository reports', links:[['#appStore','Discover apps'],['#androidLibrary','Manage installed apps']]},
|
||||
@@ -131,6 +134,8 @@
|
||||
categories.find(([id])=>id === item.category)?.[1]].join(' ').toLowerCase();
|
||||
return (category === 'all' || item.category === category) && query.toLowerCase().trim().split(/\s+/).every(word => text.includes(word));
|
||||
}
|
||||
const esc = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
|
||||
const KINDS = {steam:'Steam game', devkit:'Sideloaded game', shortcut:'Non-Steam shortcut', android:'Android app'};
|
||||
const number = (value, suffix = '', digits = 0) => Number.isFinite(value) ? value.toLocaleString(undefined, {maximumFractionDigits:digits}) + suffix : 'Unavailable';
|
||||
const gib = value => Number.isFinite(value) ? number(value / 1073741824, ' GiB', 1) : 'Unavailable';
|
||||
const duration = seconds => Number.isFinite(seconds) && seconds >= 0 ? seconds < 3600 ? `${Math.floor(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.floor(seconds % 3600 / 60)} min` : 'Unavailable';
|
||||
@@ -139,9 +144,9 @@
|
||||
const capacity = b.percent;
|
||||
const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5;
|
||||
const charging = b.status === 'Charging';
|
||||
const full = b.status === 'Full' || (!!s.power && capacity >= 100);
|
||||
const full = b.status === 'Full';
|
||||
const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' :
|
||||
full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery';
|
||||
full ? 'Fully charged' : charging ? 'Charging' : b.status === 'Not charging' || idle ? (s.power ? 'Plugged in · not charging' : 'Not charging') : b.status === 'Discharging' ? (s.power ? 'Plugged in · on battery' : 'On battery') : 'Charging state unavailable';
|
||||
return {
|
||||
battery:number(capacity, '%'), batteryDetail:batteryState,
|
||||
storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable',
|
||||
@@ -150,8 +155,11 @@
|
||||
rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available),
|
||||
fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS',
|
||||
power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable',
|
||||
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable',
|
||||
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
|
||||
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health && b.health !== 'Unknown' ? b.health : 'Unavailable',
|
||||
batterySource:b.source || 'Unavailable', cycles:number(b.cycleCount),
|
||||
fullCapacity:number(b.fullMah, ' mAh', 1), designCapacity:number(b.designMah, ' mAh', 1),
|
||||
capacityHealth:number(b.capacityHealthPercent, '%', 1),
|
||||
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : b.status === 'Discharging' && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
|
||||
wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable',
|
||||
uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable',
|
||||
temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1),
|
||||
@@ -162,9 +170,30 @@
|
||||
// Strict validation and captured device identity apply even if a stale enabled
|
||||
// button is clicked during a disconnect, headset switch, or pending request.
|
||||
function createController({api, getContext}) {
|
||||
let busy = false;
|
||||
let busy = false, quitting = false;
|
||||
const connected = message => {
|
||||
const context = getContext();
|
||||
if (context.online !== true) throw new Error(message);
|
||||
return context;
|
||||
};
|
||||
return {
|
||||
get busy() { return busy; },
|
||||
get quitting() { return quitting; },
|
||||
async running() {
|
||||
const context = connected('Connect to the Frame to see what’s running.');
|
||||
const result = await api('/api/running', undefined, context.device);
|
||||
return getContext().generation === context.generation ? result : null;
|
||||
},
|
||||
async forceQuit(app) {
|
||||
if (quitting) throw new Error('A force quit is still in progress.');
|
||||
if (!/^\d{1,10}$/.test(String(app?.appid))) throw new Error('Choose a running game to quit.');
|
||||
const context = connected('Connect to the Frame before quitting a game.');
|
||||
quitting = true;
|
||||
try {
|
||||
const result = await api('/api/running', {action:'quit', appid:String(app.appid)}, context.device);
|
||||
return getContext().generation === context.generation ? result : null;
|
||||
} finally { quitting = false; }
|
||||
},
|
||||
async audio(change) {
|
||||
if (busy) throw new Error('An audio change is still in progress.');
|
||||
const context = getContext();
|
||||
@@ -182,16 +211,18 @@
|
||||
},
|
||||
};
|
||||
}
|
||||
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform}) {
|
||||
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform, confirm = text => globalThis.confirm(text)}) {
|
||||
const get = id => document.getElementById(id), page = get('controlCenter');
|
||||
let category = 'all', sample = null, receivedAt = null, available = false, error = '', dirty = false, sampleGeneration = null, audioPending = false;
|
||||
const metric = (id, label) => `<div><dt>${label}</dt><dd data-cc-metric="${id}">Unavailable</dd></div>`;
|
||||
const perf = `<dl class="cc-readings">${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.</p><p class="cc-note" data-cc-metric="thermal">Trip data unavailable</p>`;
|
||||
const audio = `<div class="cc-audio"><label for="ccVolume">Output volume <output id="ccVolumeValue" for="ccVolume">—</output></label><input id="ccVolume" type="range" min="0" max="100" step="1" value="0" disabled><div class="cc-audio-actions"><button class="action small" id="ccApplyVolume" disabled>Apply volume</button><button class="small" id="ccMute" disabled>Mute</button><span id="ccAudioReadback" class="cc-note">Waiting for an audio reading</span></div><p class="cc-note" id="ccAudioResult" role="status"></p></div>`;
|
||||
const health = `<dl class="cc-readings">${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
const batteryNote = 'Read-only. Unavailable means not reported, unreadable or invalid. Health is the driver’s status; full / design capacity is a fuel-gauge estimate, not a diagnosis. Charge limits are unsupported on Frame.';
|
||||
const health = `<dl class="cc-readings">${[['battery','Charge'],['batteryDetail','Charging state'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health'],['cycles','Charge cycles'],['fullCapacity','Full charge capacity'],['designCapacity','Design capacity'],['capacityHealth','Full / design capacity'],['batterySource','Battery source']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">${batteryNote}</p>`;
|
||||
const forcequit = `<div class="cc-quit"><div class="cc-audio-actions"><button class="small" type="button" id="ccRunningCheck">Check what’s running</button></div><ul class="cc-running" id="ccRunningList"></ul><p class="cc-note" id="ccRunningResult" role="status"></p></div>`;
|
||||
const storage = `<dl class="cc-readings">${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>`<button type="button" data-cc-category="${id}" aria-pressed="${id === 'all'}">${icon(symbol)}<span>${label}</span><span class="cc-category-count"></span></button>`).join('');
|
||||
const specials = {audio, performance:perf, health, storage};
|
||||
const specials = {audio, performance:perf, health, storage, forcequit};
|
||||
get('ccCards').innerHTML = catalog.map(item=>`<section class="cc-card" data-cc-card="${item.id}"><div class="cc-card-head"><span class="cc-icon">${icon(item.icon)}</span><span class="cc-kind cc-kind-${item.kind}">${item.label}</span></div><h2>${item.title}</h2><p>${item.description}</p>${specials[item.special] || ''}${item.guidance ? `<div class="cc-guidance">${item.guidance}</div>` : ''}${item.links ? `<div class="cc-links">${item.links.map(([href,label,desktop])=>`<a href="${href}"${desktop ? ' class="desk-only"' : ''}${item.platform === 'mac' ? ' data-cc-mac-link' : ''}>${label}<span aria-hidden="true">↗</span></a>`).join('')}</div>` : ''}</section>`).join('');
|
||||
// Host-only features stay searchable with a reason; only their unsupported
|
||||
// action disappears. The host API, rather than viewport/UA, decides availability.
|
||||
@@ -287,6 +318,48 @@
|
||||
get('ccAudioResult').textContent = `Couldn’t apply audio: ${e.message}`;
|
||||
} finally { audioPending = false; renderSample(); }
|
||||
}
|
||||
// Force quit: an explicit check, then a confirmation for each game. A headset
|
||||
// switch drops the list, so a stale entry can't quit the other Frame's game.
|
||||
let runningApps = [];
|
||||
function renderRunning(message) {
|
||||
get('ccRunningList').innerHTML = runningApps.map(app=>`<li><span>${esc(app.name)}<small>${KINDS[app.kind] || 'Running'}</small></span><button class="small danger" type="button" data-cc-quit="${esc(app.appid)}"${controller.quitting ? ' disabled' : ''}>Force quit…</button></li>`).join('');
|
||||
if (message !== undefined) get('ccRunningResult').textContent = message;
|
||||
}
|
||||
async function checkRunning() {
|
||||
const generation = getContext().generation;
|
||||
get('ccRunningCheck').disabled = true;
|
||||
get('ccRunningResult').textContent = 'Checking…';
|
||||
try {
|
||||
const result = await controller.running();
|
||||
if (!result || generation !== getContext().generation) return;
|
||||
runningApps = Array.isArray(result.running) ? result.running : [];
|
||||
renderRunning(runningApps.length ? `${runningApps.length} running. Force quit only a game that has stopped responding.` : 'Nothing is running on the Frame.');
|
||||
} catch (e) {
|
||||
if (generation !== getContext().generation) return;
|
||||
runningApps = []; renderRunning(`Couldn’t check what’s running: ${e.message}`);
|
||||
} finally { get('ccRunningCheck').disabled = false; }
|
||||
}
|
||||
async function forceQuit(appid) {
|
||||
const app = runningApps.find(item=>String(item.appid) === String(appid));
|
||||
if (!app || controller.quitting) return;
|
||||
if (!confirm(`Force quit ${app.name}? Unsaved progress will be lost.`)) return;
|
||||
const generation = getContext().generation, pending = controller.forceQuit(app);
|
||||
renderRunning(`Quitting ${app.name}…`);
|
||||
try {
|
||||
const result = await pending;
|
||||
if (!result || generation !== getContext().generation) return;
|
||||
runningApps = runningApps.filter(item=>item !== app);
|
||||
renderRunning(result.message || `Quit ${app.name}`);
|
||||
} catch (e) {
|
||||
if (generation !== getContext().generation) return;
|
||||
renderRunning(`Couldn’t force quit ${app.name}: ${e.message}`);
|
||||
} finally { if (generation === getContext().generation) renderRunning(); }
|
||||
}
|
||||
get('ccRunningCheck').onclick = checkRunning;
|
||||
get('ccRunningList').onclick = event => {
|
||||
const button = event.target.closest('[data-cc-quit]');
|
||||
if (button) forceQuit(button.dataset.ccQuit);
|
||||
};
|
||||
get('ccVolume').oninput = () => { dirty = true; get('ccVolumeValue').textContent = `${get('ccVolume').value}%`; };
|
||||
get('ccApplyVolume').onclick = () => changeAudio({level:Number(get('ccVolume').value) / 100});
|
||||
get('ccMute').onclick = () => changeAudio({muted:!sample?.volume?.muted});
|
||||
@@ -312,7 +385,7 @@
|
||||
},
|
||||
unavailable(reason) { if (reason?.stale) return; available = false; error = `No live sample${reason?.message ? ' · ' + reason.message : ''}`; renderSample(); },
|
||||
connection(ok) { if (!ok) { available = false; renderSample(); } else audioAvailability(); },
|
||||
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; renderSample(); },
|
||||
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; runningApps = []; renderRunning(''); renderSample(); },
|
||||
destroy() { clearInterval(timer); },
|
||||
};
|
||||
}
|
||||
|
||||
+142
-1
@@ -1,13 +1,153 @@
|
||||
"""Import a local Android backup: one APK and its matching expansion files."""
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
import shutil
|
||||
import stat
|
||||
import struct
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
import frame_apk
|
||||
|
||||
import frame_android as android
|
||||
import frame_quest as quest
|
||||
|
||||
OBB = re.compile(r'(main|patch)\.[0-9]+\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\.obb')
|
||||
|
||||
ARCHIVES = {'.xapk', '.apks', '.apkm'}
|
||||
MAX_BYTES = 16 * 1024**3
|
||||
MAX_FILES = 4096
|
||||
METADATA = {'manifest.json', 'info.json', 'icon.png', 'toc.pb', 'meta.sai_v1.json', 'meta.sai_v2.json'}
|
||||
WINDOWS_DEVICE = re.compile(r'(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\..*)?', re.I)
|
||||
|
||||
|
||||
def _finder_metadata(name):
|
||||
parts = PurePosixPath(name).parts
|
||||
return bool(parts) and (parts[0] == '__MACOSX' or parts[-1] == '.DS_Store' or parts[-1].startswith('._'))
|
||||
|
||||
|
||||
def _members(archive):
|
||||
"""Ignore Finder sidecars and strip at most one shared enclosing folder.
|
||||
|
||||
Original member names are still checked by _unpack before extraction.
|
||||
Android itself is a payload root, never an enclosing folder.
|
||||
"""
|
||||
members = archive.infolist()
|
||||
names = [m.filename for m in members if not m.is_dir() and not _finder_metadata(m.filename)]
|
||||
first = names[0].split('/')[0] if names else ''
|
||||
prefix = first + '/' if first and first != 'Android' and all(n.startswith(first + '/') for n in names) else ''
|
||||
return [(m, m.filename[len(prefix):] if prefix and m.filename.startswith(prefix) else m.filename)
|
||||
for m in members]
|
||||
|
||||
|
||||
def _path(name):
|
||||
parts = PurePosixPath(name.rstrip('/')).parts
|
||||
if (not parts or len(name) > 512 or len(parts) > 32 or name.startswith('/') or '\\' in name or
|
||||
any(p in ('.', '..') or ':' in p or p.rstrip(' .') != p or WINDOWS_DEVICE.fullmatch(p)
|
||||
for p in parts) or
|
||||
'/'.join(parts) != name.rstrip('/')):
|
||||
raise android.FrameError('unsafe path in Android bundle: ' + name[:200])
|
||||
return parts
|
||||
|
||||
|
||||
def _kind(name):
|
||||
parts = _path(name)
|
||||
if len(parts) >= 4 and parts[:2] == ('Android', 'data') and android.PKG_RE.fullmatch(parts[2]):
|
||||
raise android.FrameError('Android/data restore is not supported by this importer; choose an APK/OBB-only backup')
|
||||
if name.lower().endswith('.apk'):
|
||||
return 'apk', None, None
|
||||
if name.lower().endswith('.obb'):
|
||||
match = OBB.fullmatch(parts[-1])
|
||||
if not match or not (len(parts) == 1 or
|
||||
len(parts) == 2 and parts[0] == match[2] or
|
||||
len(parts) == 4 and parts[:2] == ('Android', 'obb') and parts[2] == match[2]):
|
||||
raise android.FrameError('OBB needs its standard main/patch.<version>.<package>.obb name and Android/obb/<package> location')
|
||||
return 'obb', match[2], None
|
||||
if (len(parts) == 1 and name in METADATA) or name.lower().endswith('.apk.idsig'):
|
||||
return 'metadata', None, None
|
||||
raise android.FrameError('Unsupported Android bundle file: ' + name + '; no files were installed')
|
||||
|
||||
|
||||
def _unpack(path, root, budget):
|
||||
output, seen = [], set()
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
members = _members(archive)
|
||||
if len(members) + budget['files'] > MAX_FILES:
|
||||
raise android.FrameError('Android import has too many files')
|
||||
declared = sum(m.file_size for m, _ in members)
|
||||
if declared + budget['bytes'] > MAX_BYTES or declared > max(0, shutil.disk_usage(root).free - 64 * 1024**2):
|
||||
raise android.FrameError('Android bundle exceeds the size limit or available staging space')
|
||||
# Validate the entire directory before creating any file.
|
||||
for member, name in members:
|
||||
_path(member.filename)
|
||||
key = member.filename.rstrip('/').casefold()
|
||||
mode = stat.S_IFMT(member.external_attr >> 16)
|
||||
if key in seen or member.flag_bits & 1 or mode not in (0, stat.S_IFREG, stat.S_IFDIR):
|
||||
raise android.FrameError('Android bundle contains duplicate paths, encrypted files, links or special files')
|
||||
seen.add(key)
|
||||
if not member.is_dir() and not _finder_metadata(member.filename):
|
||||
_kind(name)
|
||||
for member, name in members:
|
||||
if member.is_dir():
|
||||
continue
|
||||
if _finder_metadata(member.filename):
|
||||
budget['bytes'] += member.file_size
|
||||
continue
|
||||
kind, pkg, rel = _kind(name)
|
||||
target = root.joinpath(*_path(member.filename))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
size = 0
|
||||
with archive.open(member) as src, target.open('xb') as dst:
|
||||
while True:
|
||||
chunk = src.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
budget['bytes'] += len(chunk)
|
||||
if budget['bytes'] > MAX_BYTES or size > member.file_size:
|
||||
raise android.FrameError('Android bundle exceeds the size limit')
|
||||
dst.write(chunk)
|
||||
if size != member.file_size:
|
||||
raise android.FrameError('Incomplete file in Android bundle: ' + member.filename)
|
||||
if kind != 'metadata':
|
||||
output.append((target, kind, pkg, rel))
|
||||
budget['files'] += len(members)
|
||||
return output
|
||||
|
||||
|
||||
def unpack_bundle(path, root):
|
||||
"""Extract a single explicit Android bundle into caller-owned staging.
|
||||
|
||||
Reuse the regular backup inspector and installer; no archive metadata is
|
||||
trusted as package identity, and ordinary PC ZIPs keep their existing route.
|
||||
"""
|
||||
try:
|
||||
entries = _unpack(Path(path), Path(root), {'bytes': 0, 'files': 0})
|
||||
except android.FrameError:
|
||||
raise
|
||||
except (OSError, ValueError, RuntimeError, EOFError, struct.error, zlib.error,
|
||||
zipfile.BadZipFile) as error:
|
||||
raise android.FrameError('Could not read Android bundle: ' + str(error)) from error
|
||||
paths = [str(local) for local, _, _, _ in entries]
|
||||
if not any(Path(p).suffix.lower() == '.apk' for p in paths):
|
||||
raise android.FrameError('Android bundles must contain one standalone APK and optional OBB files.')
|
||||
return paths
|
||||
|
||||
|
||||
def _check_standalone(path):
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
elements = frame_apk.manifest_elements(
|
||||
frame_apk._read(archive, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
|
||||
if any((tag == 'manifest' and attrs.get('split', (None, None, None))[2]) or
|
||||
(tag == 'application' and attrs.get('isSplitRequired', (None, 0, None))[1])
|
||||
for tag, attrs in elements):
|
||||
raise android.FrameError('This backup needs split APKs. This importer accepts only a standalone APK; '
|
||||
'use the documented Quest/CLI split workflow for supported library/asset splits.')
|
||||
except (zipfile.BadZipFile, frame_apk.ApkError, OSError, ValueError, struct.error, IndexError) as error:
|
||||
raise android.FrameError('Could not read APK manifest: ' + str(error)) from error
|
||||
|
||||
|
||||
def inspect(paths, discover=False):
|
||||
"""Inspect selected files before installing anything. Desktop APK drops can find
|
||||
@@ -32,6 +172,7 @@ def inspect(paths, discover=False):
|
||||
raise android.FrameError('Choose one game at a time. Split APK backups are not supported by this importer.')
|
||||
apk = apks[0] if apks else None
|
||||
if apk:
|
||||
_check_standalone(apk)
|
||||
info = android.apk_info(str(apk))
|
||||
try:
|
||||
android.check_installable(info)
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import zlib
|
||||
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
|
||||
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
|
||||
0x01010024: 'value', 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion',
|
||||
0x01010202: 'targetActivity'}
|
||||
0x01010202: 'targetActivity', 0x01010591: 'isSplitRequired'}
|
||||
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
|
||||
# APKs can come from websites (install links), so nothing read from one may be
|
||||
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""Backup and recovery kit: copy game saves, settings and Android app data off the Frame,
|
||||
put them back after a factory reset, and get out of Desktop Mode. Python stdlib only.
|
||||
|
||||
A backup is a folder in ~/Documents/Frame Control Backups/<YYYY-MM-DD-HHMMSS>/ on this
|
||||
computer: one tar.gz per part (saves/, settings/, android/) and manifest.json listing every
|
||||
file with its size and SHA-256, the archives' checksums and the headset's BUILD_ID. What the
|
||||
parts hold, and what is never copied, is decided on the headset by frame_backup_remote.py.
|
||||
Backing up only reads the headset. See docs/backup-and-recovery.md.
|
||||
|
||||
CLI: python3 ui/frame_backup.py backup | list | plan NAME | restore NAME PART... | session | game-mode
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
import frame_android as android
|
||||
import frame_host
|
||||
|
||||
REMOTE = Path(__file__).resolve().parent / 'frame_backup_remote.py'
|
||||
NAME = re.compile(r'^\d{4}-\d{2}-\d{2}-\d{6}$')
|
||||
ARCHIVE = re.compile(r'^(saves|settings|android)/[A-Za-z0-9._-]{1,200}\.tar\.gz$')
|
||||
PART = re.compile(r'^(saves:(\d{1,10}|linux)|settings:(steam|system)|android:[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+)$')
|
||||
KINDS = ('saves', 'settings', 'android')
|
||||
FORMAT = 1
|
||||
# Fixed SteamOS switches only; never execute a command supplied by remote status.
|
||||
SELECTORS = ('steamosctl', 'holo-session-select', 'steamos-session-select')
|
||||
REASONS = {
|
||||
'not_installed': 'Install the game first, then restore its saves',
|
||||
'no_prefix': 'Start the game once so Proton sets up its folders, then restore',
|
||||
'no_account': 'Sign in to Steam on the headset first',
|
||||
'reference': 'Kept for reference only; copy by hand if you need it',
|
||||
'refused': 'Not restored: the path is not safe to write',
|
||||
'running': 'Close the app first',
|
||||
'damaged': "The backup file doesn't match its checksum",
|
||||
'missing': 'The backup file is missing',
|
||||
'empty': 'Nothing to restore',
|
||||
'unknown': 'Not something this version of Frame Control can restore',
|
||||
}
|
||||
|
||||
|
||||
class BackupError(android.FrameError):
|
||||
pass
|
||||
|
||||
|
||||
def backup_root():
|
||||
return Path(os.environ.get('FRAME_BACKUP_DIR') or Path.home() / 'Documents' / 'Frame Control Backups')
|
||||
|
||||
|
||||
def backup_path(name):
|
||||
"""The folder for a backup name; refuses anything that isn't one of ours."""
|
||||
if not isinstance(name, str) or not NAME.fullmatch(name):
|
||||
raise BackupError('not a backup name')
|
||||
root = backup_root().resolve()
|
||||
path = (root / name).resolve()
|
||||
if path.parent != root:
|
||||
raise BackupError('not a backup name')
|
||||
return path
|
||||
|
||||
|
||||
def archive_name(part):
|
||||
kind, _, rest = part.partition(':')
|
||||
return '%s/%s.tar.gz' % (kind, rest if kind == 'android' else part.replace(':', '-'))
|
||||
|
||||
|
||||
def remote_command(cmd, *args):
|
||||
return shlex.join(['python3', '-c', REMOTE.read_text(), cmd, *args])
|
||||
|
||||
|
||||
def _ssh(command, src=None, dst=None, timeout=1800):
|
||||
try:
|
||||
# src: bytes, or a file to stream (an archive can be larger than memory).
|
||||
feed = {'input': src} if isinstance(src, bytes) else {
|
||||
'stdin': src if src is not None else subprocess.DEVNULL}
|
||||
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command], **feed,
|
||||
stdout=dst if dst is not None else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise BackupError('the headset took too long to answer')
|
||||
except OSError as error:
|
||||
raise BackupError('could not reach the headset: ' + str(error))
|
||||
if result.returncode:
|
||||
raise BackupError(result.stderr.decode(errors='replace').strip()[-600:] or
|
||||
'the headset command failed')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def remote_json(cmd, *args, src=None, timeout=120):
|
||||
out = _ssh(remote_command(cmd, *args), src=src, timeout=timeout)
|
||||
try:
|
||||
return json.loads(out)
|
||||
except (ValueError, TypeError):
|
||||
raise BackupError('unexpected answer from the headset')
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(str(path), 'rb') as src:
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def inspect_archive(path):
|
||||
"""Every file in one of our part archives, with its size and SHA-256; refuses anything else."""
|
||||
files, names = [], set()
|
||||
try:
|
||||
with tarfile.open(str(path), 'r:gz') as archive:
|
||||
for member in archive:
|
||||
name = member.name
|
||||
parts = name.split('/')
|
||||
if (not member.isfile() or name.startswith('/') or '\\' in name or name in names
|
||||
or any(p in ('', '.', '..') for p in parts)):
|
||||
raise BackupError('backup archive has an unsafe entry: %r' % name[:200])
|
||||
names.add(name)
|
||||
digest = hashlib.sha256()
|
||||
src = archive.extractfile(member)
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
files.append({'path': name, 'bytes': member.size, 'sha256': digest.hexdigest()})
|
||||
except (OSError, EOFError, tarfile.TarError) as error:
|
||||
raise BackupError('could not read the backup archive: ' + str(error))
|
||||
return files
|
||||
|
||||
|
||||
def preview():
|
||||
"""What a backup would hold, before making one."""
|
||||
plan = remote_json('plan')
|
||||
apps, error = _android_apps()
|
||||
return {'build': plan.get('build'), 'version': plan.get('version'), 'parts': plan.get('parts', []),
|
||||
'android': apps, 'android_error': error, 'excluded': plan.get('excluded', []), 'folder': str(backup_root())}
|
||||
|
||||
|
||||
def _android_apps():
|
||||
"""(installed Android apps, None) or ([], why they couldn't be listed)."""
|
||||
try:
|
||||
return [{'package': a['package'], 'label': a.get('label') or a['package'],
|
||||
'running': bool(a.get('running'))} for a in android.list_apps()], None
|
||||
except android.FrameError as error:
|
||||
return [], str(error)
|
||||
|
||||
|
||||
def backup(report=lambda stage, percent=None: None, kinds=KINDS, headset=None):
|
||||
kinds = [k for k in KINDS if k in kinds]
|
||||
if not kinds:
|
||||
raise BackupError('choose something to back up')
|
||||
report('Reading what is on the headset', 1)
|
||||
plan = remote_json('plan')
|
||||
parts = [p for p in plan.get('parts', []) if p.get('kind') in kinds and PART.fullmatch(str(p.get('id')))]
|
||||
apps, error = _android_apps() if 'android' in kinds else ([], None)
|
||||
root = backup_root()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
name = time.strftime('%Y-%m-%d-%H%M%S')
|
||||
final = backup_path(name)
|
||||
if final.exists():
|
||||
raise BackupError('a backup was started this second already; try again')
|
||||
# Built under a hidden name and renamed when complete, so a cut-off backup is never listed.
|
||||
staging = root / ('.' + name + '.partial')
|
||||
staging.mkdir()
|
||||
entries = []
|
||||
skipped = [{'id': 'android', 'label': 'Android apps', 'reason': error}] if error else []
|
||||
total = len(parts) + len(apps) or 1
|
||||
try:
|
||||
for i, part in enumerate(parts):
|
||||
report('Copying ' + part['label'], 2 + 90 * i / total)
|
||||
rel = archive_name(part['id'])
|
||||
dest = staging / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(str(dest), 'wb') as dst:
|
||||
_ssh(remote_command('pack', part['id']), dst=dst)
|
||||
files = inspect_archive(dest)
|
||||
entries.append({'id': part['id'], 'kind': part['kind'], 'label': part['label'],
|
||||
'appid': part.get('appid'), 'archive': rel, 'sha256': _sha256(dest),
|
||||
'archive_bytes': dest.stat().st_size, 'files': files,
|
||||
'bytes': sum(f['bytes'] for f in files)})
|
||||
skipped += [{'id': part['id'], 'label': part['label'], 'path': s['path'], 'reason': s['reason']}
|
||||
for s in part.get('skipped', [])]
|
||||
for j, app in enumerate(apps):
|
||||
report('Copying ' + app['label'] + ' (Android app data)', 2 + 90 * (len(parts) + j) / total)
|
||||
part_id = 'android:' + app['package']
|
||||
if app['running']:
|
||||
skipped.append({'id': part_id, 'label': app['label'], 'reason': 'It was open; close it and back up again'})
|
||||
continue
|
||||
rel = archive_name(part_id)
|
||||
dest = staging / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
result = android.backup_data(app['package'], dest)
|
||||
except android.FrameError as error:
|
||||
skipped.append({'id': part_id, 'label': app['label'], 'reason': str(error)})
|
||||
continue
|
||||
entries.append({'id': part_id, 'kind': 'android', 'label': app['label'] + ' (Android app data)',
|
||||
'package': app['package'], 'instance': result.get('instance'), 'archive': rel,
|
||||
'sha256': result['sha256'], 'archive_bytes': dest.stat().st_size,
|
||||
'files': result.get('files'), 'bytes': result.get('bytes')})
|
||||
report('Writing the manifest', 95)
|
||||
manifest = {'format': FORMAT, 'app': 'Frame Control', 'name': name,
|
||||
'created': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
'headset': {'name': headset or android.FRAME, 'build': plan.get('build'),
|
||||
'version': plan.get('version')},
|
||||
'parts': entries, 'skipped': skipped, 'excluded': plan.get('excluded', [])}
|
||||
(staging / 'manifest.json').write_text(json.dumps(manifest, indent=1))
|
||||
os.rename(str(staging), str(final))
|
||||
except BaseException:
|
||||
shutil.rmtree(str(staging), ignore_errors=True)
|
||||
raise
|
||||
size = sum(e['archive_bytes'] for e in entries)
|
||||
message = 'Backed up %d part%s (%s) to %s' % (len(entries), '' if len(entries) == 1 else 's',
|
||||
human(size), final)
|
||||
if skipped:
|
||||
message += '; %d skipped' % len(skipped)
|
||||
return {'message': message, 'name': name, 'path': str(final), 'parts': len(entries),
|
||||
'bytes': size, 'skipped': skipped}
|
||||
|
||||
|
||||
def human(n):
|
||||
for unit in ('bytes', 'KB', 'MB', 'GB'):
|
||||
if n < 1000 or unit == 'GB':
|
||||
return ('%d %s' % (n, unit)) if unit == 'bytes' else '%.1f %s' % (n, unit)
|
||||
n /= 1000.0
|
||||
|
||||
|
||||
def read_manifest(name):
|
||||
path = backup_path(name)
|
||||
try:
|
||||
manifest = json.loads((path / 'manifest.json').read_text())
|
||||
except (OSError, ValueError):
|
||||
raise BackupError('that backup has no readable manifest')
|
||||
if not isinstance(manifest, dict) or manifest.get('format') != FORMAT or \
|
||||
not isinstance(manifest.get('parts'), list):
|
||||
raise BackupError('that backup was made by a newer or unknown version')
|
||||
for part in manifest['parts']:
|
||||
if (not isinstance(part, dict) or not PART.fullmatch(str(part.get('id'))) or
|
||||
not ARCHIVE.fullmatch(str(part.get('archive'))) or part['archive'] != archive_name(part['id'])):
|
||||
raise BackupError('that backup manifest lists something unexpected')
|
||||
return path, manifest
|
||||
|
||||
|
||||
def list_backups():
|
||||
root = backup_root()
|
||||
out = []
|
||||
try:
|
||||
names = sorted((p.name for p in root.iterdir() if NAME.fullmatch(p.name) and p.is_dir()), reverse=True)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
try:
|
||||
_, m = read_manifest(name)
|
||||
except BackupError as error:
|
||||
out.append({'name': name, 'error': str(error)})
|
||||
continue
|
||||
out.append({'name': name, 'created': m.get('created'), 'build': (m.get('headset') or {}).get('build'),
|
||||
'headset': (m.get('headset') or {}).get('name'), 'parts': len(m['parts']),
|
||||
'bytes': sum(p.get('archive_bytes') or 0 for p in m['parts'])})
|
||||
return {'backups': out, 'folder': str(root)}
|
||||
|
||||
|
||||
def restore_plan(name):
|
||||
"""Each part of a backup and whether it can go back now; reads only."""
|
||||
path, manifest = read_manifest(name)
|
||||
plans, check = {}, []
|
||||
for part in manifest['parts']:
|
||||
archive = path / part['archive']
|
||||
state = None
|
||||
if not archive.is_file():
|
||||
state = 'missing'
|
||||
elif _sha256(archive) != part.get('sha256'):
|
||||
state = 'damaged'
|
||||
plans[part['id']] = {'id': part['id'], 'kind': part.get('kind'), 'label': part.get('label'),
|
||||
'bytes': part.get('bytes'), 'status': state}
|
||||
if state is None and part['kind'] != 'android':
|
||||
check.append({'id': part['id'], 'files': [f['path'] for f in part.get('files') or []]})
|
||||
if check:
|
||||
answer = remote_json('check', src=json.dumps({'parts': check}).encode())
|
||||
for result in answer.get('parts', []):
|
||||
plan = plans.get(result.get('id'))
|
||||
if plan:
|
||||
plan.update(status=result.get('status'), replace=result.get('replace', 0),
|
||||
new=result.get('new', 0), held=result.get('held', {}))
|
||||
android_parts = [p for p in plans.values() if p['kind'] == 'android' and p['status'] is None]
|
||||
if android_parts:
|
||||
installed = {a['package']: a for a in android.list_apps()}
|
||||
for plan in android_parts:
|
||||
app = installed.get(plan['id'].split(':', 1)[1])
|
||||
plan['status'] = 'not_installed' if not app else 'running' if app.get('running') else 'ok'
|
||||
if plan['status'] == 'not_installed':
|
||||
plan['reason'] = 'Install the app first, then restore its data'
|
||||
elif plan['status'] == 'ok':
|
||||
plan['replace'] = 1 # the app's whole private data folder, kept beside it
|
||||
for plan in plans.values():
|
||||
plan.setdefault('reason', REASONS.get(plan['status']) if plan['status'] != 'ok' else None)
|
||||
return {'name': name, 'headset': manifest.get('headset'), 'parts': list(plans.values())}
|
||||
|
||||
|
||||
def restore(name, ids, confirmed=False, report=lambda stage, percent=None: None):
|
||||
"""Restore the chosen parts that can go back now; the rest are skipped, not failed."""
|
||||
if not isinstance(ids, list) or not ids or not all(isinstance(i, str) and PART.fullmatch(i) for i in ids):
|
||||
raise BackupError('choose what to restore')
|
||||
if confirmed is not True:
|
||||
raise BackupError('confirm the restore first: it replaces files on the headset')
|
||||
path, manifest = read_manifest(name)
|
||||
by_id = {p['id']: p for p in manifest['parts']}
|
||||
plan = {p['id']: p for p in restore_plan(name)['parts']}
|
||||
stamp = time.strftime('%Y%m%d-%H%M%S')
|
||||
done, skipped = [], []
|
||||
for i, part_id in enumerate(ids):
|
||||
part, state = by_id.get(part_id), plan.get(part_id)
|
||||
if not part or not state:
|
||||
skipped.append({'id': part_id, 'reason': 'Not in this backup'})
|
||||
continue
|
||||
if state['status'] != 'ok':
|
||||
skipped.append({'id': part_id, 'label': state['label'], 'reason': state.get('reason') or state['status']})
|
||||
continue
|
||||
report('Restoring ' + state['label'], 5 + 90 * i / len(ids))
|
||||
archive = path / part['archive']
|
||||
try:
|
||||
if part['kind'] == 'android':
|
||||
result = android.restore_data(part_id.split(':', 1)[1], str(archive))
|
||||
else:
|
||||
with open(str(archive), 'rb') as src:
|
||||
result = remote_json('restore', part_id, stamp, src=src, timeout=1800)
|
||||
except android.FrameError as error:
|
||||
skipped.append({'id': part_id, 'label': state['label'], 'reason': str(error)})
|
||||
continue
|
||||
done.append(dict(result, id=part_id, label=state['label']))
|
||||
message = 'Restored %d part%s' % (len(done), '' if len(done) == 1 else 's')
|
||||
if skipped:
|
||||
message += ', skipped %d' % len(skipped)
|
||||
return {'message': message, 'restored': done, 'skipped': skipped}
|
||||
|
||||
|
||||
# ---- Game Mode ---------------------------------------------------------------
|
||||
|
||||
def session_status():
|
||||
return remote_json('session', timeout=30)
|
||||
|
||||
|
||||
def session_command(selector):
|
||||
"""Allow only fixed Game Mode commands, including older devkit selectors."""
|
||||
if selector not in SELECTORS:
|
||||
raise BackupError('this SteamOS build has no session switch command; use the steps in the guide')
|
||||
return shlex.join([selector, 'switch-to-game-mode' if selector == 'steamosctl' else 'gamescope'])
|
||||
|
||||
|
||||
def return_to_game_mode(confirmed=False):
|
||||
if confirmed is not True:
|
||||
raise BackupError('confirm first: switching closes the desktop and any apps open in it')
|
||||
state = session_status()
|
||||
if state.get('mode') == 'game':
|
||||
return {'message': 'The headset is already in Game Mode. The desktop you see is a panel: '
|
||||
'close it from the Steam dashboard.', 'state': state, 'switched': False}
|
||||
if state.get('mode') != 'desktop':
|
||||
# Only a desktop session seen running is switched: the switch restarts the session,
|
||||
# which would close whatever is running in Game Mode.
|
||||
raise BackupError("couldn't tell that the headset is in Desktop Mode, so nothing was changed. "
|
||||
'Use the manual steps in the guide if it is stuck.')
|
||||
command = session_command(state.get('selector'))
|
||||
try:
|
||||
# The session restarts under it, so the command may not get to answer.
|
||||
_ssh(command, timeout=60)
|
||||
except BackupError as error:
|
||||
if 'took too long' not in str(error):
|
||||
raise BackupError('the switch was refused: %s. Use the manual steps in the guide.' % error)
|
||||
return {'message': 'Asked the headset to return to Game Mode; it restarts its session in a few seconds',
|
||||
'state': state, 'switched': True, 'command': command}
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
try:
|
||||
if cmd == 'backup':
|
||||
r = backup(lambda stage, percent=None: print(stage, file=sys.stderr))
|
||||
elif cmd == 'list':
|
||||
r = list_backups()
|
||||
elif cmd == 'plan' and len(args) == 1:
|
||||
r = restore_plan(args[0])
|
||||
elif cmd == 'restore' and len(args) >= 2:
|
||||
r = restore(args[0], args[1:], confirmed=True)
|
||||
elif cmd == 'session':
|
||||
r = session_status()
|
||||
elif cmd == 'game-mode':
|
||||
r = return_to_game_mode(confirmed=True)
|
||||
else:
|
||||
print(__doc__.strip().splitlines()[-1], file=sys.stderr)
|
||||
return 2
|
||||
except android.FrameError as error:
|
||||
print('error:', error, file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(r, indent=1))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Backup and restore on the Frame: what to save, packing it and putting it back. Stdlib only.
|
||||
|
||||
Runs on the headset as `python3 -c <this file> <command> ...` (ui/frame_backup.py sends it),
|
||||
so stdin is free for the archive or file list. Commands:
|
||||
|
||||
plan JSON: the headset's build and every part worth saving, with sizes
|
||||
pack PART a tar.gz of that part's files on stdout (reads only)
|
||||
check stdin JSON {"parts": [{"id", "files": [paths]}]}: what a restore would do
|
||||
restore PART STAMP stdin tar.gz: write that part's restorable files, keeping replaced ones
|
||||
session JSON: Game Mode or Desktop Mode, and the switch command on this image
|
||||
|
||||
Paths are relative to the home folder, with '/' separators, everywhere: in archives, in the
|
||||
manifest and in `check`. Nothing here deletes a file. `restore` replaces only files named in
|
||||
its archive, and first copies each one it replaces under PREVIOUS/<stamp>/.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
# FRAME_BACKUP_HOME: the tests point this at a folder laid out like the Frame's home.
|
||||
HOME = Path(os.environ.get('FRAME_BACKUP_HOME') or Path.home())
|
||||
STEAM = '.local/share/Steam'
|
||||
COMPAT = STEAM + '/steamapps/compatdata'
|
||||
PREFIX_USER = 'pfx/drive_c/users/steamuser'
|
||||
# Where Windows games keep saves inside a Proton prefix. Steam Cloud covers many games, not all.
|
||||
SAVE_DIRS = ('AppData/Roaming', 'AppData/Local', 'AppData/LocalLow', 'Documents', 'Saved Games')
|
||||
# Wine's and Windows' own folders inside those: caches, not saves.
|
||||
SAVE_SKIP = ('AppData/Local/Temp', 'AppData/Local/Microsoft', 'AppData/Roaming/Microsoft',
|
||||
'AppData/Local/D3DSCache', 'AppData/Local/NVIDIA', 'AppData/Local/CrashDumps',
|
||||
'AppData/LocalLow/Microsoft')
|
||||
# Native Linux games: the engines' standard save folders.
|
||||
LINUX_SAVES = ('.config/unity3d', '.local/share/godot/app_userdata')
|
||||
# Steam settings. Controller layouts go back; the rest is kept so you can look things up.
|
||||
STEAM_RESTORE = ('241100/remote/controller_config',) # under userdata/<account>/
|
||||
STEAM_REFERENCE = ('config/localconfig.vdf', 'config/shortcuts.vdf', 'config/grid')
|
||||
STEAM_CONTROLLER = STEAM + '/steamapps/common/Steam Controller Configs' # <account>/config/
|
||||
# Desktop settings worth keeping. Steam rewrites steamvr.vrsettings while SteamVR runs, so
|
||||
# it's only for reference.
|
||||
SYSTEM_RESTORE = ('.config/kdeglobals', '.config/kwinrc', '.config/kxkbrc', '.config/konsolerc',
|
||||
'.config/dolphinrc')
|
||||
SYSTEM_REFERENCE = ('.config/openvr/config/steamvr.vrsettings',)
|
||||
PREVIOUS = '.local/share/frame-control/restore-previous'
|
||||
MAX_FILE = 2 * 1024 ** 3 # a bigger "save" is a cache or a video; listed as skipped
|
||||
MAX_PART = 20 * 1024 ** 3
|
||||
MAX_FILES = 100000
|
||||
SHORTCUT_IDS = 2 ** 31 # non-Steam shortcuts and Lepton instances use ids above this
|
||||
|
||||
# Never copied, whatever folder they turn up in. Checked on every path, packing and restoring.
|
||||
SECRET_NAMES = {'steam.token', 'loginusers.vdf', 'registry.vdf', 'config.vdf', 'secrets.env',
|
||||
'authorized_keys', 'known_hosts', 'credentials', 'credentials.json'}
|
||||
SECRET_DIRS = {'.ssh', '.gnupg', '.pki', 'keyrings', 'kwalletd', 'cliproxyapi', 'tailscale'}
|
||||
SECRET_SUFFIXES = ('.token', '.pem', '.key', '.kwl')
|
||||
EXCLUDED = [
|
||||
'Steam sign-in: steam.token, config/config.vdf, loginusers.vdf, registry.vdf, ssfn files',
|
||||
'Keys and passwords: ~/.ssh, ~/.gnupg, keyrings and KWallet, *.token/*.key/*.pem files, '
|
||||
'Tailscale and proxy settings (secrets.env)',
|
||||
'Games themselves, Proton and runtimes (reinstall them from Steam), shader caches, Steam '
|
||||
'Cloud folders under userdata/<account>/<appid>, screenshots (Tools → Capture & Live saves them)',
|
||||
'Android apps themselves and their shared storage (/sdcard); only private app data is saved',
|
||||
'Symbolic links, and single files over 2 GiB',
|
||||
]
|
||||
|
||||
|
||||
def is_secret(rel):
|
||||
parts = PurePosixPath(rel).parts
|
||||
name = parts[-1].lower() if parts else ''
|
||||
return (name in SECRET_NAMES or name.startswith('ssfn') or name.startswith('id_rsa') or
|
||||
name.startswith('id_ed25519') or name.startswith('id_ecdsa') or
|
||||
name.endswith(SECRET_SUFFIXES) or any(p.lower() in SECRET_DIRS for p in parts[:-1]))
|
||||
|
||||
|
||||
def safe_rel(rel):
|
||||
"""A relative '/' path with no '..', '.', empty or absolute parts."""
|
||||
if not isinstance(rel, str) or not rel or '\\' in rel or '\0' in rel or rel.startswith('/'):
|
||||
return False
|
||||
parts = rel.split('/')
|
||||
return all(p not in ('', '.', '..') for p in parts) and not re.match(r'^[A-Za-z]:', rel)
|
||||
|
||||
|
||||
def _under(rel, root):
|
||||
return rel == root or rel.startswith(root + '/')
|
||||
|
||||
|
||||
def _walk(rel_root, skip=()):
|
||||
"""Regular files under HOME/rel_root, never following links: [(rel, size)], [skipped]."""
|
||||
files, skipped = [], []
|
||||
base = HOME / rel_root
|
||||
if base.is_symlink() or not base.is_dir():
|
||||
if base.is_file() and not base.is_symlink():
|
||||
return [(rel_root, base.stat().st_size)], skipped
|
||||
return files, skipped
|
||||
for top, dirs, names in os.walk(str(base)):
|
||||
rel_top = PurePosixPath(rel_root, *Path(top).relative_to(base).parts).as_posix()
|
||||
dirs[:] = sorted(d for d in dirs if not os.path.islink(os.path.join(top, d)) and
|
||||
not any(_under(rel_top + '/' + d, s) for s in skip) and
|
||||
not is_secret(rel_top + '/' + d + '/x'))
|
||||
for name in sorted(names):
|
||||
path, rel = os.path.join(top, name), rel_top + '/' + name
|
||||
if os.path.islink(path) or is_secret(rel):
|
||||
continue
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
if st.st_size > MAX_FILE:
|
||||
skipped.append({'path': rel, 'reason': 'over 2 GiB'})
|
||||
continue
|
||||
files.append((rel, st.st_size))
|
||||
return files, skipped
|
||||
|
||||
|
||||
def _manifest_field(text, key):
|
||||
m = re.search(r'"%s"\s+"([^"]*)"' % key, text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def game_name(appid):
|
||||
try:
|
||||
text = (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).read_text(errors='replace')
|
||||
except OSError:
|
||||
return None
|
||||
return _manifest_field(text, 'name')
|
||||
|
||||
|
||||
def accounts():
|
||||
root = HOME / STEAM / 'userdata'
|
||||
try:
|
||||
return sorted(p.name for p in root.iterdir() if p.name.isdigit() and p.is_dir()
|
||||
and not p.is_symlink() and p.name != '0')
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _prefixes():
|
||||
try:
|
||||
return sorted((p.name for p in (HOME / COMPAT).iterdir()
|
||||
if p.name.isdigit() and (p / PREFIX_USER).is_dir()), key=int)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def part_roots(part):
|
||||
"""(roots to save, roots that a restore may write) for a part id, or None if unknown."""
|
||||
if part.startswith('saves:') and part[6:].isdigit():
|
||||
user = '%s/%s/%s' % (COMPAT, part[6:], PREFIX_USER)
|
||||
roots = tuple(user + '/' + d for d in SAVE_DIRS)
|
||||
return roots, roots
|
||||
if part == 'saves:linux':
|
||||
return LINUX_SAVES, LINUX_SAVES
|
||||
if part == 'settings:steam':
|
||||
save, restore = [], []
|
||||
for acct in accounts():
|
||||
base = '%s/userdata/%s/' % (STEAM, acct)
|
||||
restore += [base + r for r in STEAM_RESTORE] + ['%s/%s/config' % (STEAM_CONTROLLER, acct)]
|
||||
save += [base + r for r in STEAM_REFERENCE]
|
||||
return tuple(save + restore), tuple(restore)
|
||||
if part == 'settings:system':
|
||||
return SYSTEM_RESTORE + SYSTEM_REFERENCE, SYSTEM_RESTORE
|
||||
return None
|
||||
|
||||
|
||||
def restore_roots(part, rel):
|
||||
"""The restore roots for rel within part; the Steam ones don't depend on today's accounts."""
|
||||
if part == 'settings:steam':
|
||||
m = re.match(r'^(?:%s/userdata/(\d+)/|%s/(\d+)/)' % (re.escape(STEAM), re.escape(STEAM_CONTROLLER)), rel)
|
||||
acct = m and (m.group(1) or m.group(2))
|
||||
if not acct:
|
||||
return ()
|
||||
base = '%s/userdata/%s/' % (STEAM, acct)
|
||||
return tuple(base + r for r in STEAM_RESTORE) + ('%s/%s/config' % (STEAM_CONTROLLER, acct),)
|
||||
roots = part_roots(part)
|
||||
return roots[1] if roots else ()
|
||||
|
||||
|
||||
def part_files(part):
|
||||
roots = part_roots(part)
|
||||
if roots is None:
|
||||
raise ValueError('unknown backup part %r' % part)
|
||||
skip = ()
|
||||
if part.startswith('saves:') and part != 'saves:linux':
|
||||
skip = tuple('%s/%s/%s/%s' % (COMPAT, part[6:], PREFIX_USER, s) for s in SAVE_SKIP)
|
||||
files, skipped = [], []
|
||||
for root in roots[0]:
|
||||
f, s = _walk(root, skip)
|
||||
files += f
|
||||
skipped += s
|
||||
return files, skipped
|
||||
|
||||
|
||||
def part_label(part):
|
||||
if part == 'saves:linux':
|
||||
return 'Linux game saves (Unity and Godot games)'
|
||||
if part == 'settings:steam':
|
||||
return 'Steam settings and controller layouts'
|
||||
if part == 'settings:system':
|
||||
return 'Desktop and SteamVR settings'
|
||||
appid = part[6:]
|
||||
name = game_name(appid)
|
||||
if name:
|
||||
return name + ' saves'
|
||||
return ('Non-Steam game %s saves' if int(appid) >= SHORTCUT_IDS else 'Game %s saves') % appid
|
||||
|
||||
|
||||
def plan():
|
||||
parts = []
|
||||
ids = ['saves:' + a for a in _prefixes()] + ['saves:linux', 'settings:steam', 'settings:system']
|
||||
for part in ids:
|
||||
files, skipped = part_files(part)
|
||||
if not files:
|
||||
continue
|
||||
parts.append({'id': part, 'kind': part.split(':')[0], 'label': part_label(part),
|
||||
'files': len(files), 'bytes': sum(s for _, s in files), 'skipped': skipped,
|
||||
'appid': part[6:] if part[6:].isdigit() else None,
|
||||
'restorable': sum(1 for rel, _ in files
|
||||
if any(_under(rel, r) for r in restore_roots(part, rel)))})
|
||||
return {'build': os_release().get('BUILD_ID'), 'version': os_release().get('VERSION_ID'),
|
||||
'parts': parts, 'excluded': EXCLUDED}
|
||||
|
||||
|
||||
def os_release():
|
||||
out = {}
|
||||
try:
|
||||
for line in Path(os.environ.get('FRAME_BACKUP_OS_RELEASE') or '/etc/os-release').read_text().splitlines():
|
||||
k, _, v = line.partition('=')
|
||||
out[k.strip()] = v.strip().strip('"')
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def pack(part, output):
|
||||
files, _ = part_files(part)
|
||||
if len(files) > MAX_FILES or sum(s for _, s in files) > MAX_PART:
|
||||
raise ValueError('this part is too big to back up (over 100,000 files or 20 GiB)')
|
||||
with tarfile.open(fileobj=output, mode='w|gz') as archive:
|
||||
for rel, _ in files:
|
||||
path = HOME / rel
|
||||
try:
|
||||
with open(str(path), 'rb') as src:
|
||||
st = os.fstat(src.fileno())
|
||||
member = tarfile.TarInfo(rel)
|
||||
member.size, member.mtime, member.mode = st.st_size, int(st.st_mtime), st.st_mode & 0o755
|
||||
archive.addfile(member, src)
|
||||
except OSError:
|
||||
continue # gone since the walk (a game wrote it); the manifest lists what's in the archive
|
||||
|
||||
|
||||
def target_status(part, rel):
|
||||
"""None if rel may be restored now, else why not (a short code)."""
|
||||
if is_secret(rel) or not safe_rel(rel):
|
||||
return 'refused'
|
||||
if not any(_under(rel, r) for r in restore_roots(part, rel)):
|
||||
return 'reference'
|
||||
if part.startswith('saves:') and part != 'saves:linux':
|
||||
appid = part[6:]
|
||||
if int(appid) < SHORTCUT_IDS and \
|
||||
not (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).is_file():
|
||||
return 'not_installed'
|
||||
if not (HOME / COMPAT / appid / PREFIX_USER).is_dir():
|
||||
return 'no_prefix'
|
||||
if part == 'settings:steam':
|
||||
acct = re.search(r'/(\d+)/', rel[len(STEAM):])
|
||||
if not acct or not (HOME / STEAM / 'userdata' / acct.group(1)).is_dir():
|
||||
return 'no_account'
|
||||
# A link anywhere on the way could send the write somewhere else.
|
||||
path = HOME
|
||||
for p in rel.split('/')[:-1]:
|
||||
path = path / p
|
||||
if path.is_symlink() or (path.exists() and not path.is_dir()):
|
||||
return 'refused'
|
||||
if (HOME / rel).is_symlink() or ((HOME / rel).exists() and not (HOME / rel).is_file()):
|
||||
return 'refused'
|
||||
return None
|
||||
|
||||
|
||||
def check(request):
|
||||
out = []
|
||||
for item in request.get('parts', []):
|
||||
part, files = item.get('id'), item.get('files') or []
|
||||
if not isinstance(part, str) or part_roots(part) is None:
|
||||
out.append({'id': part, 'status': 'unknown'})
|
||||
continue
|
||||
counts = {}
|
||||
replace = new = 0
|
||||
for rel in files:
|
||||
why = target_status(part, rel)
|
||||
if why:
|
||||
counts[why] = counts.get(why, 0) + 1
|
||||
elif (HOME / rel).exists():
|
||||
replace += 1
|
||||
else:
|
||||
new += 1
|
||||
status = 'ok' if replace + new else next(
|
||||
(c for c in ('not_installed', 'no_prefix', 'no_account', 'reference', 'refused') if c in counts), 'empty')
|
||||
out.append({'id': part, 'status': status, 'replace': replace, 'new': new, 'held': counts})
|
||||
return {'parts': out}
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(str(path), 'rb') as src:
|
||||
for chunk in iter(lambda: src.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def restore(part, stamp, source):
|
||||
if part_roots(part) is None:
|
||||
raise ValueError('unknown backup part %r' % part)
|
||||
if not re.fullmatch(r'\d{8}-\d{6}', stamp):
|
||||
raise ValueError('bad restore stamp')
|
||||
# Spool first: everything is checked before anything is written.
|
||||
spool_dir = HOME / '.cache/frame-control'
|
||||
spool_dir.mkdir(parents=True, exist_ok=True)
|
||||
written, replaced, held = [], [], {}
|
||||
with tempfile.TemporaryDirectory(prefix='restore-', dir=str(spool_dir)) as tmp:
|
||||
spool = Path(tmp) / 'part.tar.gz'
|
||||
with open(str(spool), 'wb') as dst:
|
||||
shutil.copyfileobj(source, dst, 1024 * 1024)
|
||||
todo, names, total = [], set(), 0
|
||||
with tarfile.open(str(spool), 'r:gz') as archive:
|
||||
for member in archive:
|
||||
if not member.isfile() or not safe_rel(member.name) or member.name in names:
|
||||
raise ValueError('backup has an unsafe, duplicate or non-file entry: %r' % member.name[:200])
|
||||
names.add(member.name)
|
||||
total += member.size
|
||||
if len(names) > MAX_FILES or total > MAX_PART:
|
||||
raise ValueError('backup part is too big')
|
||||
why = target_status(part, member.name)
|
||||
if why:
|
||||
held[why] = held.get(why, 0) + 1
|
||||
else:
|
||||
todo.append(member.name)
|
||||
previous = HOME / PREVIOUS / stamp
|
||||
keep = set(todo)
|
||||
with tarfile.open(str(spool), 'r:gz') as archive:
|
||||
for member in archive:
|
||||
if member.name not in keep:
|
||||
continue
|
||||
target = HOME / member.name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
part_file = target.parent / ('.frame-restore-%d.part' % os.getpid())
|
||||
with archive.extractfile(member) as src, open(str(part_file), 'wb') as dst:
|
||||
shutil.copyfileobj(src, dst, 1024 * 1024)
|
||||
os.chmod(str(part_file), (member.mode & 0o755) | 0o600)
|
||||
os.utime(str(part_file), (member.mtime, member.mtime))
|
||||
if target.exists():
|
||||
if _sha256(target) == _sha256(part_file):
|
||||
os.unlink(str(part_file))
|
||||
held['same'] = held.get('same', 0) + 1
|
||||
continue
|
||||
keep_copy = previous / member.name
|
||||
keep_copy.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(str(target), str(keep_copy))
|
||||
replaced.append(member.name)
|
||||
os.replace(str(part_file), str(target))
|
||||
written.append(member.name)
|
||||
return {'id': part, 'written': len(written), 'replaced': len(replaced), 'held': held,
|
||||
'previous': (PREVIOUS + '/' + stamp) if replaced else None}
|
||||
|
||||
|
||||
# ---- Game Mode and Desktop Mode --------------------------------------------
|
||||
#
|
||||
# The Frame normally runs gamescope (Game Mode) with Plasma nested inside it as one panel
|
||||
# (steamos-nested-desktop). A full Desktop Mode session is SDDM autologin into Plasma
|
||||
# instead. Valve's own devkit tools (frame/devkit-utils: steamos-get-status, steamos-delete)
|
||||
# read the login preference from these files. It is not the running session.
|
||||
|
||||
SDDM_CONFS = ('/etc/sddm.conf.d/zz-steamos-autologin.conf', '/etc/sddm.conf.d/steamos.conf')
|
||||
SESSION_TARGETS = ('gamescope-session.target', 'plasma-workspace-wayland.target')
|
||||
|
||||
|
||||
def _session_target(unit):
|
||||
"""Read the main user's manager, not a nested Plasma session bus over SSH."""
|
||||
runtime = '/run/user/%d' % os.getuid()
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=runtime,
|
||||
DBUS_SESSION_BUS_ADDRESS='unix:path=%s/bus' % runtime)
|
||||
try:
|
||||
result = subprocess.run(['systemctl', '--user', 'show', unit,
|
||||
'--property=LoadState', '--property=ActiveState'],
|
||||
capture_output=True, text=True, timeout=5, env=env)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 'unknown'
|
||||
if result.returncode:
|
||||
return 'unknown'
|
||||
properties = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
||||
if properties.get('LoadState') == 'not-found':
|
||||
return 'not-found'
|
||||
if properties.get('LoadState') == 'loaded':
|
||||
return properties.get('ActiveState', 'unknown')
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _running(name, full=False):
|
||||
# This module is sent as `python3 -c <source>`: unanchored pgrep -f would
|
||||
# match the script names inside our own source, even when no script runs.
|
||||
pattern = (r'^([^ ]*/)?((ba|da|z)?sh +([^ ]*/)?)?%s( |$)' % re.escape(name)
|
||||
if full else name)
|
||||
try:
|
||||
return subprocess.call(['pgrep', '-f' if full else '-x', pattern], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL) == 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def session():
|
||||
configured, conf = None, None
|
||||
for path in SDDM_CONFS:
|
||||
try:
|
||||
text = Path(path).read_text(errors='replace')
|
||||
except OSError:
|
||||
continue
|
||||
m = re.search(r'^\s*Session\s*=\s*(\S+)', text, re.M)
|
||||
conf, configured = path, (m.group(1) if m else None)
|
||||
break
|
||||
selector = next((s for s in ('steamosctl', 'holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
|
||||
# On the Frame gamescope's process is named gamescope-wl (seen on build 20261015.6141207),
|
||||
# under the start-gamescope-session script.
|
||||
gamescope = (_running('gamescope') or _running('gamescope-wl') or
|
||||
_running('start-gamescope-session', full=True))
|
||||
plasma = _running('plasmashell')
|
||||
nested = _running('steamos-nested-desktop', full=True) # a script: its process name is the shell's
|
||||
targets = {unit: _session_target(unit) for unit in SESSION_TARGETS}
|
||||
game, desktop = (targets[unit] for unit in SESSION_TARGETS)
|
||||
mode = 'unknown'
|
||||
source = 'session-targets'
|
||||
if (game, desktop) == ('active', 'inactive'):
|
||||
mode = 'game'
|
||||
elif (game, desktop) == ('inactive', 'active'):
|
||||
mode = 'desktop'
|
||||
elif game == desktop == 'not-found':
|
||||
# Older images have neither target. A compositor plus Plasma is ambiguous:
|
||||
# Desktop Mode's steamvr-plasma.service also runs gamescope and nested scripts.
|
||||
source = 'legacy-processes'
|
||||
if gamescope and not plasma and not nested:
|
||||
mode = 'game'
|
||||
elif plasma and not gamescope and not nested:
|
||||
mode = 'desktop'
|
||||
return {'mode': mode, 'gamescope': gamescope, 'plasma': plasma, 'nested_desktop': nested,
|
||||
'configured': configured, 'config_file': conf, 'selector': selector,
|
||||
'session_targets': targets, 'mode_source': source}
|
||||
|
||||
|
||||
def main(argv):
|
||||
cmd, args = (argv[0] if argv else ''), argv[1:]
|
||||
try:
|
||||
if cmd == 'plan':
|
||||
print(json.dumps(plan()))
|
||||
elif cmd == 'pack' and len(args) == 1:
|
||||
out = sys.stdout.buffer
|
||||
pack(args[0], out)
|
||||
out.flush()
|
||||
elif cmd == 'check':
|
||||
print(json.dumps(check(json.load(sys.stdin))))
|
||||
elif cmd == 'restore' and len(args) == 2:
|
||||
print(json.dumps(restore(args[0], args[1], sys.stdin.buffer)))
|
||||
elif cmd == 'session':
|
||||
print(json.dumps(session()))
|
||||
else:
|
||||
sys.stderr.write('usage: plan | pack PART | check | restore PART STAMP | session\n')
|
||||
return 2
|
||||
except (OSError, ValueError, tarfile.TarError) as error:
|
||||
sys.stderr.write(str(error) + '\n')
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+14
-7
@@ -98,10 +98,11 @@ def record_command(name, audio, max_seconds=MAX_SECONDS):
|
||||
|
||||
ffmpeg runs in the background while the shell watches its stdin, which is the SSH
|
||||
channel: when that closes (Stop closed it, or the computer's ssh went away) the
|
||||
shell sends ffmpeg SIGTERM, which makes it finish the file properly (as q or
|
||||
Ctrl-C would; SIGINT itself is ignored by a background job of a script). A recording
|
||||
that ends by itself (the time limit, an error) ends the wait too. The shell prints
|
||||
"started" once ffmpeg is running and "exit N" when it has gone."""
|
||||
shell types q into ffmpeg's stdin, a FIFO, which makes it finish the file properly.
|
||||
Not a signal: SteamOS's ffmpeg (n7.0) catches SIGTERM and SIGINT and records on
|
||||
(seen on a Frame, build 20261015.6141207). If it's still there 10 s later it's
|
||||
killed. A recording that ends by itself (the time limit, an error) ends the wait
|
||||
too. The shell prints "started" once ffmpeg is running and "exit N" when it has gone."""
|
||||
if not REC_NAME.fullmatch(name):
|
||||
raise ValueError(f"bad recording name {name!r}")
|
||||
if not 1 <= max_seconds <= MAX_SECONDS:
|
||||
@@ -115,12 +116,18 @@ def record_command(name, audio, max_seconds=MAX_SECONDS):
|
||||
inputs += f" {clock} -f pulse -i {AUDIO_SOURCE}"
|
||||
maps += " -map 1:a:0"
|
||||
codecs += f" -af aresample=async=1 -c:a aac -b:a {AUDIO_RATE}k"
|
||||
# ffmpeg's stdin is a FIFO the shell holds open on fd 3 (opening it waits for both
|
||||
# ends, so ffmpeg is started first); it's unlinked once both ends are open.
|
||||
script = (f"export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; d=\"$HOME/{WORK}\"; "
|
||||
f"ffmpeg -hide_banner -loglevel error -nostdin -y {inputs} {maps} {codecs} -t {max_seconds} "
|
||||
f"\"$d/{name}.mkv\" </dev/null >/dev/null 2>\"$d/{name}.log\" & p=$!; "
|
||||
f"trap '' PIPE; c=\"$d/{name}.ctl\"; rm -f \"$c\"; mkfifo \"$c\" || exit 1; "
|
||||
f"ffmpeg -hide_banner -loglevel error -y {inputs} {maps} {codecs} -t {max_seconds} "
|
||||
f"\"$d/{name}.mkv\" <\"$c\" >/dev/null 2>\"$d/{name}.log\" & p=$!; "
|
||||
f"exec 3>\"$c\"; rm -f \"$c\"; "
|
||||
f"echo started; "
|
||||
f"while kill -0 $p 2>/dev/null; do read -r -t 1 _; r=$?; [ $r -eq 0 ] || [ $r -gt 128 ] || break; done; "
|
||||
f"kill $p 2>/dev/null; wait $p; echo \"exit $?\"")
|
||||
f"kill -0 $p 2>/dev/null && printf q >&3 2>/dev/null; exec 3>&-; "
|
||||
f"i=0; while kill -0 $p 2>/dev/null && [ $i -lt 40 ]; do sleep 0.25; i=$((i+1)); done; "
|
||||
f"kill -0 $p 2>/dev/null && kill -KILL $p 2>/dev/null; wait $p; echo \"exit $?\"")
|
||||
return "bash -c " + shlex.quote(script)
|
||||
|
||||
|
||||
|
||||
+143
-16
@@ -25,6 +25,7 @@ when the page asks.
|
||||
|
||||
Python stdlib only. Runs on this computer, never on the Frame.
|
||||
"""
|
||||
from bisect import bisect_right
|
||||
import copy
|
||||
import hashlib
|
||||
import ipaddress
|
||||
@@ -203,16 +204,33 @@ def probes_summary(probes):
|
||||
PLACEHOLDER = re.compile(r"(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)")
|
||||
|
||||
|
||||
def hide_hosts(text, hosts, keep=()):
|
||||
"""text with each of `hosts` (the headset's own addresses and names) replaced by <host>,
|
||||
longest first and whatever the case, so a bare name like "steamdeck" that the scrubber
|
||||
can't recognise goes too."""
|
||||
keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording
|
||||
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
|
||||
parts = PLACEHOLDER.split(text) # never inside a scrubber's own <token> already there
|
||||
for i in range(0, len(parts), 2):
|
||||
for h in names:
|
||||
parts[i] = re.sub(r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(h), "<host>", parts[i], flags=re.I)
|
||||
def _host_names(hosts, keep=()):
|
||||
keep = {k.lower() for k in keep if k} | KEYWORDS
|
||||
return sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
|
||||
|
||||
|
||||
def _host_pattern(name):
|
||||
return r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(name)
|
||||
|
||||
|
||||
def hide_hosts(text, hosts, keep=(), *, _trace=None):
|
||||
"""Hide whole known names, longest first, preserving existing scrubber tokens."""
|
||||
names = _host_names(hosts, keep)
|
||||
parts = PLACEHOLDER.split(text)
|
||||
ranges, offset = [], 0
|
||||
for i, part in enumerate(parts):
|
||||
trace = _ScrubTrace(part) if _trace is not None else None
|
||||
if trace is not None:
|
||||
trace.ranges = _trace.ranges[offset:offset + len(part)]
|
||||
offset += len(part)
|
||||
if i % 2 == 0:
|
||||
sub = trace.sub if trace is not None else re.sub
|
||||
for name in names:
|
||||
parts[i] = sub(_host_pattern(name), "<host>", parts[i], flags=re.I)
|
||||
if trace is not None:
|
||||
ranges.extend(trace.ranges)
|
||||
if _trace is not None:
|
||||
_trace.ranges = ranges
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
@@ -223,19 +241,128 @@ PLAIN_WORDS = {"answer", "the", "a", "an", "this", "it", "its", "be", "connect",
|
||||
KEYWORDS = {"host", "hostname", "to", "port"} | PLAIN_WORDS
|
||||
|
||||
|
||||
def hide_operands(text):
|
||||
def hide_operands(text, *, _sub=re.sub):
|
||||
def one(m):
|
||||
word = m.group(2).rstrip(".")
|
||||
dots = m.group(2)[len(word):]
|
||||
return m.group(0) if word.lower() in PLAIN_WORDS else f"{m.group(1)} <host>{dots}"
|
||||
return OPERAND.sub(one, text)
|
||||
return _sub(OPERAND, one, text)
|
||||
|
||||
|
||||
class _ScrubTrace:
|
||||
"""Map every output character to its source interval through sequential redactions.
|
||||
|
||||
Unchanged prefixes/suffixes retain their origins. The changed middle inherits
|
||||
its source interval and is marked as replaced; extra spans can widen to cover
|
||||
that whole interval without restoring anything the old pipeline removed.
|
||||
"""
|
||||
def __init__(self, text):
|
||||
self.ranges = [(i, i + 1, False) for i in range(len(text))]
|
||||
self.redactions = []
|
||||
|
||||
def sub(self, pattern, replacement, text, flags=0):
|
||||
pattern = re.compile(pattern, flags)
|
||||
pieces, ranges, end = [], [], 0
|
||||
for match in pattern.finditer(text):
|
||||
start, stop = match.span()
|
||||
value = replacement(match) if callable(replacement) else match.expand(replacement)
|
||||
pieces.extend((text[end:start], value))
|
||||
ranges.extend(self.ranges[end:start])
|
||||
if value == match.group(0):
|
||||
ranges.extend(self.ranges[start:stop])
|
||||
else:
|
||||
original = match.group(0)
|
||||
prefix = 0
|
||||
while prefix < min(len(original), len(value)) and original[prefix] == value[prefix]:
|
||||
prefix += 1
|
||||
suffix = 0
|
||||
while (suffix < min(len(original), len(value)) - prefix
|
||||
and original[-suffix - 1] == value[-suffix - 1]):
|
||||
suffix += 1
|
||||
ranges.extend(self.ranges[start:start + prefix])
|
||||
source = self.ranges[start + prefix:stop - suffix]
|
||||
# A known name can itself be a prefix of "<host>". Attribute
|
||||
# inserted characters to its last character in that case.
|
||||
if not source:
|
||||
source = self.ranges[max(start, start + prefix - 1):start + prefix]
|
||||
interval = (source[0][0], source[-1][1])
|
||||
self.redactions.append(interval)
|
||||
ranges.extend([(*interval, True)] * (len(value) - prefix - suffix))
|
||||
if suffix:
|
||||
ranges.extend(self.ranges[stop - suffix:stop])
|
||||
end = stop
|
||||
pieces.append(text[end:])
|
||||
ranges.extend(self.ranges[end:])
|
||||
self.ranges = ranges
|
||||
return "".join(pieces)
|
||||
|
||||
|
||||
def _failure_spans(text, hosts):
|
||||
"""Extra redactions discovered before any replacement can destroy context."""
|
||||
spans = []
|
||||
tokens = [m.span() for m in PLACEHOLDER.finditer(text)]
|
||||
token_starts = [start for start, end in tokens]
|
||||
for name in _host_names(hosts):
|
||||
for match in re.finditer(_host_pattern(name), text, re.I):
|
||||
# Standalone scrubber tokens survive; a whole display name may cross one.
|
||||
token = bisect_right(token_starts, match.start()) - 1
|
||||
if token < 0 or match.end() > tokens[token][1]:
|
||||
spans.append(match.span())
|
||||
start = 0
|
||||
while True:
|
||||
match = OPERAND.search(text, start)
|
||||
if not match:
|
||||
break
|
||||
word = match.group(2).rstrip(".")
|
||||
if word.lower() not in PLAIN_WORDS:
|
||||
spans.append((match.start(2), match.start(2) + len(word)))
|
||||
# An operand can itself be another marker ("to hostname bastion").
|
||||
start = match.start(2)
|
||||
merged = []
|
||||
for start, end in sorted(spans):
|
||||
if merged and start <= merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], max(end, merged[-1][1]))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def scrub_failure(text, hosts=()):
|
||||
"""Free text about a failed attempt, for the log: the attempt's own names, whole and
|
||||
longest first (case-insensitively, never ssh's own words), then anything ssh names as a
|
||||
host, then the shared scrubber (addresses, paths, user names)."""
|
||||
return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
|
||||
"""Keep the existing scrubbed log, then hide additional original name/ssh spans.
|
||||
|
||||
Source ranges carry those spans through the existing substitutions. Applying
|
||||
them only to that pipeline's output cannot reveal text it previously removed.
|
||||
Keep the old output boundary, then truncate again after extra redaction so
|
||||
shortening a name cannot pull previously omitted text into the log.
|
||||
"""
|
||||
text = str(text or "")
|
||||
hosts = tuple(hosts)
|
||||
spans = _failure_spans(text, hosts)
|
||||
# Also preserve rules whose context the old name/operand pass could destroy,
|
||||
# such as a URL, home folder, or PEM footer containing a display name.
|
||||
original = _ScrubTrace(text)
|
||||
frame_telemetry.scrub(text, limit=None, _sub=original.sub)
|
||||
privacy_spans = sorted(original.redactions)
|
||||
trace = _ScrubTrace(text)
|
||||
clean = frame_telemetry.scrub(
|
||||
hide_operands(hide_hosts(text, hosts, _trace=trace), _sub=trace.sub),
|
||||
limit=600, _sub=trace.sub)
|
||||
out, hidden, index, privacy_index = [], False, 0, 0
|
||||
for char, (start, end, replaced) in zip(clean, trace.ranges):
|
||||
while index < len(spans) and spans[index][1] <= start:
|
||||
index += 1
|
||||
redact = index < len(spans) and spans[index][0] < end
|
||||
while privacy_index < len(privacy_spans) and privacy_spans[privacy_index][1] <= start:
|
||||
privacy_index += 1
|
||||
if not replaced and privacy_index < len(privacy_spans):
|
||||
redact |= privacy_spans[privacy_index][0] < end
|
||||
if redact:
|
||||
if not hidden:
|
||||
out.append("<host>")
|
||||
else:
|
||||
out.append(char)
|
||||
hidden = redact
|
||||
return "".join(out)[:600]
|
||||
|
||||
|
||||
def failure_detail(error):
|
||||
|
||||
+28
-16
@@ -56,12 +56,12 @@ BROWSER_FLAGS = []
|
||||
PANEL_BOX = (1920, 1080)
|
||||
|
||||
# Opens one viewer on gamescope's X display and gives its window its own panel
|
||||
# id. Args: appid url width height tag [browser flags...]. The page puts "[tag]" in its title at
|
||||
# id. Args: appid url width height tag profile [browser flags...]. The page puts "[tag]" in its title at
|
||||
# once, which is how its X window is found (Chromium may hand the URL to an
|
||||
# instance that's already running, so there's no process to follow).
|
||||
LAUNCH = r"""set -u
|
||||
appid=$1 url=$2 w=$3 h=$4 tag=$5
|
||||
shift 5
|
||||
appid=$1 url=$2 w=$3 h=$4 tag=$5 profile=$6
|
||||
shift 6
|
||||
export DISPLAY=:0 LC_ALL=C.UTF-8
|
||||
unset WAYLAND_DISPLAY
|
||||
if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
|
||||
@@ -73,15 +73,15 @@ common=(--ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-d
|
||||
--disable-features=Translate,MediaRouter --autoplay-policy=no-user-gesture-required
|
||||
"--window-size=$w,$h" "$@" "--app=$url")
|
||||
if [ -x "$HOME/chromium-xr/chrome" ]; then
|
||||
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/mac-view" "${common[@]}")
|
||||
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/$profile" "${common[@]}")
|
||||
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
|
||||
cmd=(flatpak run org.chromium.Chromium
|
||||
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-mac-view" "${common[@]}")
|
||||
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-$profile" "${common[@]}")
|
||||
else
|
||||
echo "NO_BROWSER"
|
||||
exit 3
|
||||
fi
|
||||
log=/tmp/frame-mac-view.log
|
||||
log=/tmp/frame-$profile.log
|
||||
setsid nohup "${cmd[@]}" >>"$log" 2>&1 </dev/null &
|
||||
for _ in $(seq 1 60); do
|
||||
sleep 0.5
|
||||
@@ -105,9 +105,9 @@ class MacViewError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def panel_id(src):
|
||||
def panel_id(src, host="mac"):
|
||||
"""A stable panel id per source, in the range panel-on-frame.sh uses."""
|
||||
return 2_001_000_000 + zlib.crc32(f"mac:{src}".encode()) % 1_000_000
|
||||
return 2_001_000_000 + zlib.crc32(f"{host}:{src}".encode()) % 1_000_000
|
||||
|
||||
|
||||
def fit(w, h, box=PANEL_BOX):
|
||||
@@ -116,6 +116,9 @@ def fit(w, h, box=PANEL_BOX):
|
||||
|
||||
|
||||
class MacView:
|
||||
host = "mac"
|
||||
viewer_profile = "mac-view"
|
||||
|
||||
def __init__(self, tunnel_ssh, run, frame, track=None):
|
||||
self.tunnel_ssh = list(tunnel_ssh)
|
||||
self.run = run
|
||||
@@ -174,6 +177,12 @@ class MacView:
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def agent_command(self):
|
||||
return [str(AGENT)]
|
||||
|
||||
def agent_environment(self):
|
||||
return {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
|
||||
|
||||
def ensure_agent(self):
|
||||
with self.lock:
|
||||
if self.agent and self.agent.poll() is None:
|
||||
@@ -182,11 +191,11 @@ class MacView:
|
||||
if reason:
|
||||
raise MacViewError(reason)
|
||||
self.build()
|
||||
env = {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
|
||||
env = self.agent_environment()
|
||||
# The same port as before when restarting, so a running tunnel still
|
||||
# fits; otherwise (or if it's gone) whatever the system gives.
|
||||
for port in dict.fromkeys([self.port or 0, 0]):
|
||||
self.agent = subprocess.Popen([str(AGENT), "serve", "--port", str(port), "--page", str(PAGE),
|
||||
self.agent = subprocess.Popen([*self.agent_command(), "serve", "--port", str(port), "--page", str(PAGE),
|
||||
"--exit-on-eof"], env=env, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
line = self.agent.stdout.readline()
|
||||
@@ -195,7 +204,7 @@ class MacView:
|
||||
break
|
||||
self.agent.kill()
|
||||
else:
|
||||
raise MacViewError(f"The Mac streaming helper didn't start: {line.strip() or 'no output'}")
|
||||
raise MacViewError(f"The streaming helper didn't start: {line.strip() or 'no output'}")
|
||||
if self.port != int(m.group(1)):
|
||||
self._drop_tunnel()
|
||||
self.port = int(m.group(1))
|
||||
@@ -211,7 +220,7 @@ class MacView:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.load(r)
|
||||
except (urllib.error.URLError, OSError, ValueError) as e:
|
||||
raise MacViewError(f"The Mac streaming helper didn't answer: {e}")
|
||||
raise MacViewError(f"The streaming helper didn't answer: {e}")
|
||||
|
||||
# ---- the tunnel from the Frame ----
|
||||
|
||||
@@ -396,7 +405,7 @@ class MacView:
|
||||
# the agent no longer counts them, so only move when none are out there.
|
||||
others = self.shown - {src}
|
||||
self.ensure_tunnel(allow_new_port=not others)
|
||||
appid = panel_id(src)
|
||||
appid = panel_id(src, self.host)
|
||||
# Unique per launch, so a new window is never confused with an old one.
|
||||
tag = "fc" + secrets.token_hex(4)
|
||||
# A single-use ticket for this source, not Frame Control's key: the URL
|
||||
@@ -406,7 +415,7 @@ class MacView:
|
||||
"bpp": q["bpp"]}
|
||||
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
|
||||
w, h = fit(width or 1280, height or 720)
|
||||
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, *self.browser_flags))
|
||||
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, self.viewer_profile, *self.browser_flags))
|
||||
try:
|
||||
out = self.run("bash -s -- " + args, stdin=LAUNCH, timeout=60)
|
||||
except Exception as e: # noqa: BLE001 - the server's Failure carries the Frame's words
|
||||
@@ -438,7 +447,8 @@ class MacView:
|
||||
if self.shown or self.shows != shows or self.launching:
|
||||
return
|
||||
try:
|
||||
self.run("pkill -f '[f]rame-control/mac-view|[d]ata/frame-mac-view' || true", timeout=10)
|
||||
pattern = "[f]rame-control/" + self.viewer_profile + "|[d]ata/frame-" + self.viewer_profile
|
||||
self.run("pkill -f " + _quote(pattern) + " || true", timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -449,7 +459,9 @@ class MacView:
|
||||
status = self.call("/status")
|
||||
windows = self.call("/windows").get("windows", []) if status.get("screen") else []
|
||||
displays = self.call("/displays").get("displays", [])
|
||||
return {"available": True, "screen": status.get("screen", False),
|
||||
return {"available": True, "host": self.host, "selecting": status.get("selecting", False),
|
||||
**({"windowCaptureReason": status["windowCaptureReason"]} if status.get("windowCaptureReason") else {}),
|
||||
"selectionError": status.get("selectionError", ""), "screen": status.get("screen", False),
|
||||
"accessibility": status.get("accessibility", False), "streams": status.get("streams", []),
|
||||
"windows": windows, "displays": displays, "tunnel": self.tunnel_up(),
|
||||
"route": self.route}
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user