mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 12:01:40 +02:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
535bf08139 | ||
|
|
040581a4c7 | ||
|
|
17f6d52717 | ||
|
|
b4e80cab0a | ||
|
|
b98e3e496f | ||
|
|
a148e02d39 | ||
|
|
0fb9174c23 | ||
|
|
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 |
No files matched your search
@@ -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 \
|
||||
@@ -135,8 +142,25 @@ jobs:
|
||||
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/') }}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Treat headset aliases and other CSV metadata as plain text in power and heat
|
||||
exports, so spreadsheet formula prefixes cannot run when a report is opened.
|
||||
|
||||
- Add desktop Tools → Power & heat report: bounded read-only one/five-minute
|
||||
observations with progress, stop and HTML/JSON/CSV downloads. Received samples
|
||||
survive stop, disconnect and headset switch, labeled incomplete and tied to the
|
||||
original headset. Shared sampler/report modules ship with the app; no power or
|
||||
thermal controls are added. Download before replacing a report or quitting.
|
||||
|
||||
- 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,6 +44,9 @@ 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>
|
||||
@@ -226,13 +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: your PC's windows and screens as panels in the Frame. Not yet tried on a real Windows desktop or headset |
|
||||
| [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 |
|
||||
|
||||
@@ -275,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.
|
||||
@@ -13,6 +13,10 @@ const path = require("path");
|
||||
|
||||
const REQUIRED = [
|
||||
"ui/server.py",
|
||||
"ui/frame_power.py",
|
||||
"ui/frame_status.py",
|
||||
"scripts/frame_power_sample.py",
|
||||
"scripts/frame_power_report.py",
|
||||
"ui/frame_android.py",
|
||||
"frame/android/lepton-app.sh",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.16",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.10",
|
||||
"version": "0.4.16",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -68,7 +68,9 @@
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh"
|
||||
"*.sh",
|
||||
"frame_power_sample.py",
|
||||
"frame_power_report.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,6 +22,15 @@ 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;
|
||||
|
||||
@@ -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.
|
||||
+44
-24
@@ -12,8 +12,21 @@ 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, and the switch out of a real Desktop Mode session. Unit
|
||||
tests and the fake Frame cover those (see [Verification](#verification)).
|
||||
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
|
||||
|
||||
@@ -111,29 +124,28 @@ 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. SteamOS picks the session through
|
||||
SDDM's autologin file, `/etc/sddm.conf.d/zz-steamos-autologin.conf` (or the
|
||||
default `/etc/sddm.conf.d/steamos.conf`), and switches with
|
||||
`steamos-session-select` (`holo-session-select` on newer images). This is what
|
||||
Valve's own devkit tools on the Frame read and run
|
||||
([`steamos-get-status`](../frame/devkit-utils/steamos-get-status),
|
||||
[`steamos-delete`](../frame/devkit-utils/steamos-delete)).
|
||||
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.
|
||||
|
||||
**Check** reads that file and whether gamescope and Plasma are running; it
|
||||
changes nothing. On the Frame gamescope's process is `gamescope-wl`, started by
|
||||
`start-gamescope-session`, and either counts as Game Mode. Only when Plasma is
|
||||
running without gamescope (Desktop Mode) does **Return to Game Mode** run
|
||||
exactly `steamos-session-select gamescope` (or `holo-session-select
|
||||
gamescope`) over SSH, after asking. It closes the desktop and anything open in
|
||||
it. Frame Control never edits the SDDM files itself, and runs nothing when the
|
||||
headset is in Game Mode, when it can't tell which mode it's in, or when the
|
||||
image has neither command.
|
||||
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.
|
||||
|
||||
**Uncertain:** whether the Frame offers a full Desktop Mode session at all
|
||||
(only the nested desktop has been seen on BUILD_ID 20260922.6101926 and
|
||||
20260925.6191901), and whether `steamos-session-select` there works without
|
||||
the Developer Mode password. If the switch is refused, Frame Control says so and
|
||||
leaves the headset as it was.
|
||||
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
|
||||
|
||||
@@ -144,7 +156,9 @@ If the button can't help, or the headset isn't reachable:
|
||||
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 steamos-session-select gamescope`.
|
||||
`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
|
||||
@@ -165,3 +179,9 @@ restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
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.
|
||||
@@ -18,6 +18,9 @@ read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207)
|
||||
`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
|
||||
|
||||
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
|
||||
|
||||
@@ -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,38 @@
|
||||
{
|
||||
"date_utc": "2026-10-24T07:54:37Z",
|
||||
"frame_control_tag": "v0.4.16",
|
||||
"steamos_version": "0.5.6",
|
||||
"steamos_build": "20261015.6141207",
|
||||
"host": "lxso2",
|
||||
"interface": "source UI served by a temporary private Frame Control server, opened in T3 Code's desktop browser",
|
||||
"action": "Clicked the visible Export button for Steam's retained real saved clip in Capture & Live",
|
||||
"clip_id": "10230640/clips/clip_13009719723623972864_20261024_072115",
|
||||
"api_observed": {
|
||||
"post_clips_export_http": 200,
|
||||
"get_job_http": 200,
|
||||
"get_clips_http": 200
|
||||
},
|
||||
"ui_result": "Saved clip_13009719723623972864_20261024_072115.mp4 to ~/Videos/SteamFrame; button changed to Export again",
|
||||
"output": {
|
||||
"path": "~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4",
|
||||
"bytes": 159829,
|
||||
"sha256": "f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1",
|
||||
"matches_previously_fully_decoded_export": true,
|
||||
"duration_seconds": 10.288532,
|
||||
"video": "H.264 1920x1080",
|
||||
"audio": "AAC stereo"
|
||||
},
|
||||
"cleanup": {
|
||||
"temporary_server_stopped": true,
|
||||
"shared_device_lock_released": true,
|
||||
"battery_percent_after": 99,
|
||||
"desktop_mode_active_after": false,
|
||||
"test_title_created": false,
|
||||
"steam_source_clip_retained": true
|
||||
},
|
||||
"limits": [
|
||||
"The test used the source UI, not a packaged desktop installer.",
|
||||
"The retained source clip was nearly black; visible gameplay capture remains unverified.",
|
||||
"Perceptual audio/video sync, long clips, trims and automatic copying were not tested."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"base": "c7489a8 (v0.4.14)",
|
||||
"feature": "Desktop Tools power and heat observations",
|
||||
"automated": {
|
||||
"targeted": "python3 -m unittest discover -s tests -p 'test_power*.py': 35 passed",
|
||||
"uiAndResourceGuard": "node --test tests/power_desktop.cjs: 7 passed",
|
||||
"desktopApp": "node --test app/test/*.test.js: 37 passed",
|
||||
"packaging": "Materialized ui/scripts resources selected by app/package.json; isolated Python -I imported frame_power, constructed remote payload including frame_status, and read idle status. Missing resource detection tested. Shared phone bundle import passed.",
|
||||
"syntax": "Python compilation, sampler shell syntax and git diff --check passed",
|
||||
"fullSuite": "python3 -m unittest discover -s tests -v: 1363 tests, OK (skipped=15), 153.274 seconds",
|
||||
"transientFailure": "An earlier full run had one nested sandbox-test failure: the existing OneServer second-server child exited -11 (SIGSEGV), expected 1. Isolated sandbox rerun passed (6 tests), then the complete suite passed. No unrelated server-lifecycle code was changed."
|
||||
},
|
||||
"browser": {
|
||||
"environment": "T3 collaborative Chromium preview, Linux, local Python Handler server; synthetic observation subprocess at accelerated one-second intervals; normal headset SSH disabled during start/stop/download verification",
|
||||
"observations": [
|
||||
"Opened #powerReport and verified labeled duration selector, disabled Stop/downloads before start and accessible sample progress.",
|
||||
"Started a synthetic observation and observed 11 of 12 samples in progress; completion allowed another start.",
|
||||
"Started again, observed progress, stopped, and saw 7 of 12 samples retained with explicit incomplete state and original frame alias.",
|
||||
"Clicked HTML download and saw the UI acknowledge the request; T3's download list did not record the blob download, so filesystem save was not verified.",
|
||||
"Fetched all three authenticated export endpoints in the browser: HTML 200 (7599 characters), JSON 200 (2618 characters, 7 samples), CSV 200 (977 characters); each retained the stop reason."
|
||||
]
|
||||
},
|
||||
"realFrame": {
|
||||
"lock": "ssh frame 'mkdir /tmp/frame-test.lock' succeeded before real-device probe; this task removed only that owned empty lock with rmdir after verification",
|
||||
"probe": "Ran the production desktop Observation using strict, batch SSH to frame. The first attempt exposed a missing structured reason on early remote lock refusal; fixed and reran successfully.",
|
||||
"result": "0 of 12 samples; running=false; stopped='Smoke session active (/tmp/frame-test.lock); no sensors read'. Exported HTML included the same incomplete reason.",
|
||||
"scope": "Lock refusal only. No successful real sensor session, installation, app launch/stop, power control or thermal control was performed for this feature."
|
||||
},
|
||||
"limitations": [
|
||||
"Native Electron installers were not built or launched; resource staging and build-hook contracts were tested.",
|
||||
"One latest report is retained only in server memory. Starting again, restarting the server or quitting discards it; the UI warns users to download first.",
|
||||
"Stopping ends only the owned local SSH process. The bounded remote reader discovers closure on its next write; immediate remote cancellation is not guaranteed.",
|
||||
"Phone-hosted observation starts are unsupported; the shared server bundle still imports without desktop sampler resources."
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+31
-13
@@ -1,15 +1,19 @@
|
||||
# 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 window or a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Place it with the dashboard's Float
|
||||
[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 and not yet tried on a real Windows desktop or with a
|
||||
headset.** It builds and passes its protocol tests on Windows in CI, but desktop
|
||||
capture, Media Foundation encoding and laser input have never run on a real
|
||||
PC. Expect it not to work yet. Reports are welcome through the
|
||||
**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
|
||||
@@ -31,17 +35,18 @@ streaming app. GStreamer is bundled as ordinary libraries.
|
||||
|
||||
| | Windows |
|
||||
|---|---|
|
||||
| Capture | Windows.Graphics.Capture, through GStreamer's `d3d11screencapturesrc capture-api=wgc`, by window (HWND) or screen (HMONITOR) |
|
||||
| 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 choices are documented, not device-verified:
|
||||
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 Windows.Graphics.Capture and a Media Foundation
|
||||
H.264 encoder (Windows "N" editions need the Media Feature Pack). Clicks and
|
||||
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
|
||||
@@ -77,6 +82,13 @@ 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.
|
||||
@@ -127,11 +139,17 @@ python scripts/macview-bench.py run --pc --scenario capture --source window:1234
|
||||
[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.
|
||||
- **Not verified:** anything on a real Windows desktop. Windows.Graphics.Capture,
|
||||
Media Foundation (hardware or software), encoder restarts when the bitrate
|
||||
- **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
|
||||
headset from this branch.
|
||||
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,224 @@
|
||||
# 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 twelve seconds of local child/pipe 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`. The desktop flow below uses the same sampler and renderer.
|
||||
|
||||
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.
|
||||
|
||||
## Desktop Tools → Power & heat report
|
||||
|
||||
In the packaged desktop app, connect to an already paired Frame, open **Tools →
|
||||
Power & heat report**, choose **1 minute** (12 samples) or **5 minutes** (60 samples),
|
||||
and select **Start observation**. Samples arrive every five seconds, starting at
|
||||
zero (about 55 or 295 seconds total). Progress shows received samples and the
|
||||
original headset alias. There is at most one observation per local app server;
|
||||
viewing progress never starts another SSH connection. No terminal, checkout,
|
||||
extra dependency or password is needed.
|
||||
|
||||
Use **Stop and keep samples** to end local collection. Disconnect, timeout,
|
||||
app shutdown or switching to another headset also ends collection. Switching
|
||||
network addresses for the same headset does not retarget an existing observation:
|
||||
it keeps its captured SSH route and may finish incomplete if that route fails.
|
||||
As with the CLI, only the owned local SSH child is terminated. The remote reader
|
||||
is bounded and discovers a closed connection on its next write. Desktop sessions
|
||||
have a 30-second transport allowance and bounded child cleanup. Stream lines are
|
||||
limited to 262,144 characters, sample events to the requested count, and the receive queue
|
||||
to count + 2 events; desktop SSH stderr is discarded rather than stored unbounded.
|
||||
The existing smoke lock is checked locally before start and remotely before each
|
||||
sample; this feature never creates, bypasses or removes that lock.
|
||||
|
||||
**Download HTML report**, **Download JSON** and **Download CSV** save local
|
||||
snapshots through the browser/app download mechanism. Open the downloaded HTML
|
||||
in your browser to view the offline table and its embedded JSON/CSV downloads.
|
||||
Snapshots taken before completion explicitly say incomplete. Desktop CSV includes
|
||||
`collection.state`, `collection.requestedSamples` and `collection.receivedSamples`
|
||||
metadata rows with the original headset alias in the source column, even when no
|
||||
sensor sample arrived. CSV text fields, including the alias, are escaped if they
|
||||
begin with a spreadsheet formula prefix. JSON records the alias and device ID;
|
||||
HTML names the alias.
|
||||
|
||||
Only the latest observation is retained in server memory, including partial
|
||||
samples after stop or disconnect. **Download before starting another observation,
|
||||
restarting the server or quitting the app**: those discard the previous report.
|
||||
Page reloads retain server state. Download requests include an observation ID, so
|
||||
an old page cannot silently download or stop a replacement observation. Reports
|
||||
are not uploaded or automatically opened, and may contain device identifiers,
|
||||
OS builds and sensor paths. Review before sharing. The phone-hosted UI does not
|
||||
support starting observations.
|
||||
|
||||
## 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.
|
||||
|
||||
Desktop validation on **2026-10-24** covered API start/stop, a synthetic browser
|
||||
observation with partial exports, shared-module resource staging under isolated
|
||||
Python and missing-resource build guards. A real Frame probe under this task's
|
||||
owned smoke lock correctly returned zero samples with an explicit incomplete
|
||||
reason; no successful live sensor baseline or native installer run was performed.
|
||||
See [desktop evidence and limitations](evidence/power-desktop-2026-10-24.json).
|
||||
+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
|
||||
|
||||
+34
-15
@@ -8,13 +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.
|
||||
|
||||
**Recording is tested on a headset; Steam clip export isn't yet.** On a Frame
|
||||
**Recording and Steam clip export have each been tested on a headset.** 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).
|
||||
second, and nothing was left running or stored on the Frame. A later test on
|
||||
that build created a saved clip with Steam's own recorder and exported it with
|
||||
Frame Control's production export function. The parts we're least sure of are
|
||||
listed under [What hasn't been checked](#what-hasnt-been-checked).
|
||||
|
||||
## Record
|
||||
|
||||
@@ -147,18 +147,37 @@ stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
|
||||
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
|
||||
recording with Steam's file names and export it with the real `ffmpeg`.
|
||||
|
||||
## What hasn't been checked
|
||||
## Real Steam clip verification and remaining checks
|
||||
|
||||
None of this has run on a Steam Frame yet. In particular:
|
||||
On 2026-10-24, v0.4.15 on build `20261015.6141207` recorded a ten-second
|
||||
reserved test session through Steam's own game recorder, saved it as a clip,
|
||||
then exported it with `frame_clips.export_clip`, the same implementation used
|
||||
by the desktop API. The 159,829-byte MP4 decoded completely: 10.29 seconds,
|
||||
1920×1080 H.264 video and stereo AAC audio (SHA-256
|
||||
`f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1`).
|
||||
All 616 decoded video frames and the decoded audio matched Steam's source
|
||||
segments. Recording mode was restored
|
||||
to off; the reserved title and its processes were removed, the shared test lock
|
||||
was released, and the battery remained at 99%. The Steam source clip was kept.
|
||||
|
||||
- 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
|
||||
whether a clip's segments start mid-recording. The parser is deliberately
|
||||
loose about the layout.
|
||||
The inspected frame is nearly black in both Steam's source and the export, so
|
||||
this establishes file creation and faithful export, **not visible gameplay
|
||||
capture**. On v0.4.16, the literal desktop **Export** button was also clicked
|
||||
against that retained real Steam clip in the source UI on lxso2. The job
|
||||
completed and saved `~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4`;
|
||||
its 159,829 bytes and SHA-256 matched the previously decoded export exactly.
|
||||
The temporary server stopped, the shared device lock was released, and the
|
||||
headset remained in Game Mode at 99% battery. See
|
||||
[`docs/evidence/clip-gui-export-2026-10-24.json`](evidence/clip-gui-export-2026-10-24.json)
|
||||
for the narrow proof. This did not exercise the packaged desktop installer.
|
||||
Remaining checks include:
|
||||
|
||||
- Perceptual audio/video sync and audible content. Frame Control's separate
|
||||
**Record** path stamps camera and audio inputs with the wall clock because
|
||||
they otherwise count from different clocks.
|
||||
- Long saved clips, trims starting partway through a Steam recording, and the
|
||||
packaged desktop app's **Export** button. One real saved clip's directory
|
||||
layout was checked; the parser remains deliberately loose for other layouts.
|
||||
- How much x264 at 1080p30 costs the Frame while a game is running.
|
||||
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
|
||||
finished saving a clip, and how a once-a-minute listing feels on the Frame.
|
||||
+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.
|
||||
|
||||
+112
-8
@@ -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
|
||||
@@ -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.
|
||||
@@ -204,15 +264,19 @@ 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.
|
||||
|
||||
None of this captures a desktop, exercises Media Foundation or a GPU, or
|
||||
checks laser alignment. The [Windows test VM](#windows-test-vm) is the next
|
||||
step for that. See [what's verified](pc-in-headset.md#whats-verified).
|
||||
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`.
|
||||
|
||||
@@ -232,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:
|
||||
|
||||
@@ -356,4 +427,37 @@ 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).
|
||||
No backup or restore has been run on a real headset yet.
|
||||
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,54 @@
|
||||
"""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>Headset: {esc(report.get("headset", {}).get("alias", "frame"))}</p>
|
||||
<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,359 @@
|
||||
"""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):
|
||||
# CSV quoting alone does not prevent spreadsheet formulas in text fields.
|
||||
def cell(value):
|
||||
if isinstance(value, str) and (value.startswith(('\t', '\r', '\n')) or
|
||||
value.lstrip(' \t\r\n').startswith(('=', '+', '-', '@'))):
|
||||
return "'" + value
|
||||
return value
|
||||
|
||||
output = io.StringIO(newline='')
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(('time', 'elapsedSeconds', 'metric', 'value', 'unit', 'source', 'state'))
|
||||
if 'headset' in report:
|
||||
for key, value in (('collection.state', report['stopped'] or 'Completed'),
|
||||
('collection.requestedSamples', report['requestedSamples']),
|
||||
('collection.receivedSamples', len(report['samples']))):
|
||||
writer.writerow([cell(v) for v in ('', '', key, value, '', report['headset']['alias'],
|
||||
'incomplete' if report['stopped'] else 'completed')])
|
||||
for sample in report['samples']:
|
||||
for key, row in sample['readings'].items():
|
||||
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([cell(v) for v in (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, stop_event=None, on_sample=None, discard_errors=False):
|
||||
"""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.
|
||||
"""
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'stopped': 'Stopped before collection; no SSH started', 'samples': [], 'summary': {}}
|
||||
samples, final, stopped = [], None, None
|
||||
events = queue.Queue(maxsize=count + 2)
|
||||
reader_stop = threading.Event()
|
||||
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=subprocess.DEVNULL if discard_errors else errors, text=True)
|
||||
def receive():
|
||||
try:
|
||||
for _ in range(count + 2):
|
||||
line = proc.stdout.readline(262145)
|
||||
if not line:
|
||||
break
|
||||
if len(line) > 262144 or not line.endswith('\n'):
|
||||
line = 'invalid or oversized sample'
|
||||
while not reader_stop.is_set():
|
||||
try:
|
||||
events.put(line, timeout=0.2)
|
||||
break
|
||||
except queue.Full:
|
||||
continue
|
||||
if reader_stop.is_set():
|
||||
return
|
||||
finally:
|
||||
while not reader_stop.is_set():
|
||||
try:
|
||||
events.put(None, timeout=0.2)
|
||||
break
|
||||
except queue.Full:
|
||||
continue
|
||||
reader = threading.Thread(target=receive, daemon=True)
|
||||
reader.start()
|
||||
def feed():
|
||||
try:
|
||||
proc.stdin.write(payload)
|
||||
proc.stdin.close()
|
||||
except OSError:
|
||||
pass # transport exit/EOF below marks the observation incomplete
|
||||
writer = threading.Thread(target=feed, daemon=True)
|
||||
writer.start()
|
||||
try:
|
||||
while True:
|
||||
if stop_event is not None and stop_event.is_set() and events.empty():
|
||||
stopped = 'Stopped; received samples retained'
|
||||
break
|
||||
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'])
|
||||
if on_sample:
|
||||
on_sample(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:
|
||||
reader_stop.set()
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=3)
|
||||
writer.join(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():
|
||||
if args.probe and args.stream:
|
||||
print(json.dumps({'finished': {'stopped': 'Smoke session active (/tmp/frame-test.lock); no sensors read'}}), flush=True)
|
||||
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())
|
||||
@@ -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});
|
||||
|
||||
@@ -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)
|
||||
+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)
|
||||
+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");
|
||||
@@ -0,0 +1,77 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||
function fixture() {
|
||||
const f=workspaceFixture();
|
||||
Object.assign(f.context, {clearTimeout(){},setTimeout(){},UI_KEY:'1',encodeURIComponent});
|
||||
const formats=['html','json','csv'].map(format=>Object.assign(f.el('download-'+format),{dataset:{powerFormat:format}}));
|
||||
const query=f.context.document.querySelectorAll;
|
||||
f.context.document.querySelectorAll=s=>s==='[data-power-format]'?formats:query(s);
|
||||
f.formats=formats;
|
||||
f.run(html.slice(html.indexOf('// ---- Read-only power observation ----'),html.indexOf('// ---- Read-only game storage ----')));
|
||||
return f;
|
||||
}
|
||||
const state={id:'abc',headset:{alias:'old-frame'},requested:12,received:3,running:true,stopped:null};
|
||||
test('Tools navigation loads the report and renders accessible progress',async()=>{
|
||||
const f=fixture();let calls=0;f.context.api=async()=>{calls++;return state;};
|
||||
f.navigate('#powerReport');await new Promise(setImmediate);
|
||||
assert.equal(f.activePage,'tools');assert.equal(f.el('powerReport').hidden,false);
|
||||
assert.equal(f.el('powerReport').parentNode,f.el('toolWorkspace'));
|
||||
assert.equal(calls,1);assert.equal(f.el('powerProgress').value,3);
|
||||
assert.equal(f.el('powerStart').disabled,true);assert.equal(f.el('powerStop').disabled,false);
|
||||
assert.match(f.el('powerStatus').textContent,/Headset: old-frame.*3 of 12.*Observing/);
|
||||
});
|
||||
test('incomplete result preserves original identity and start becomes available',()=>{
|
||||
const f=fixture();f.context.sample={...state,running:false,stopped:'Headset switched; samples retained'};
|
||||
f.run('renderPower(sample)');
|
||||
assert.match(f.el('powerStatus').textContent,/old-frame.*Incomplete: Headset switched/);
|
||||
assert.equal(f.el('powerStart').disabled,false);assert.equal(f.el('powerStop').disabled,true);
|
||||
});
|
||||
test('stop submits observation identity and keeps progress visible',async()=>{
|
||||
const f=fixture(),calls=[];f.context.sample=state;f.run('renderPower(sample)');
|
||||
f.context.api=async(path,body)=>{calls.push([path,body]);return {...state,running:false,stopped:'Stopped by user'};};
|
||||
await f.el('powerStop').onclick();
|
||||
assert.equal(calls[0][0],'/api/power');assert.equal(calls[0][1].action,'stop');assert.equal(calls[0][1].id,'abc');
|
||||
assert.equal(f.el('powerProgress').value,3);assert.match(f.el('powerStatus').textContent,/Incomplete/);
|
||||
});
|
||||
test('old polls cannot overwrite newer report and retry explains retained samples',async()=>{
|
||||
const f=fixture(),pending=[];f.context.api=()=>new Promise((resolve,reject)=>pending.push({resolve,reject}));
|
||||
const first=f.run('loadPower()'),second=f.run('loadPower()');
|
||||
pending[1].resolve({...state,running:false,stopped:'Disconnected'});await second;
|
||||
const current=f.el('powerStatus').textContent;pending[0].resolve(state);await first;
|
||||
assert.equal(f.el('powerStatus').textContent,current);
|
||||
const next=f.run('loadPower()');pending[2].reject(new Error('Offline'));await next;
|
||||
assert.match(f.el('powerStatus').textContent,/Offline.*Received samples remain/);
|
||||
});
|
||||
test('start failure remains actionable and does not imply success',async()=>{
|
||||
const f=fixture();f.context.api=async()=>{throw new Error('Smoke session active');};
|
||||
await f.el('powerStart').onclick();assert.match(f.el('powerStatus').textContent,/Smoke session/);
|
||||
assert.equal(f.el('powerStart').disabled,false);
|
||||
});
|
||||
test('download captures observation ID, fetches an authenticated blob and names the file',async()=>{
|
||||
const f=fixture(),requests=[],saved=[];
|
||||
f.context.sample={...state,running:false,stopped:'Disconnected'};f.run('renderPower(sample)');
|
||||
f.context.fetch=async(url,opts)=>{requests.push([url,opts]);return {ok:true,blob:async()=>({report:true})};};
|
||||
f.context.URL={createObjectURL:()=> 'blob:report',revokeObjectURL(){}};
|
||||
f.context.document.createElement=()=>({click(){saved.push({href:this.href,name:this.download});}});
|
||||
await f.formats[0].onclick();
|
||||
assert.equal(requests[0][0],'/api/power/report?id=abc&format=html');
|
||||
assert.equal(requests[0][1].headers['X-Frame-UI'],'1');
|
||||
assert.deepEqual(saved,[{href:'blob:report',name:'frame-power-abc.html'}]);
|
||||
assert.match(f.el('powerSaveStatus').textContent,/Download requested/);
|
||||
f.context.fetch=async()=>({ok:false,json:async()=>({error:'Report replaced'})});
|
||||
await f.formats[0].onclick();
|
||||
assert.match(f.el('powerSaveStatus').textContent,/Download failed: Report replaced/);
|
||||
assert.equal(f.formats[0].disabled,false);
|
||||
});
|
||||
test('packaging guard requires shared modules and detects missing resources',()=>{
|
||||
const fs=require('node:fs'),os=require('node:os'),path=require('node:path');
|
||||
const guard=require('../app/build/check-resources');const tmp=fs.mkdtempSync(path.join(os.tmpdir(),'power-resources-'));
|
||||
try {
|
||||
for(const rel of guard.REQUIRED) {const file=path.join(tmp,rel);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,'test');}
|
||||
assert.deepEqual(guard.missing(tmp),[]);
|
||||
for(const rel of ['ui/frame_power.py','ui/frame_status.py','scripts/frame_power_sample.py','scripts/frame_power_report.py']) {
|
||||
assert.ok(guard.REQUIRED.includes(rel));fs.unlinkSync(path.join(tmp,rel));assert.ok(guard.missing(tmp).includes(rel));
|
||||
}
|
||||
} finally {fs.rmSync(tmp,{recursive:true,force:true});}
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
+104
-18
@@ -5,6 +5,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -203,23 +204,106 @@ class Remote(unittest.TestCase):
|
||||
self.assertEqual(result['written'], 0)
|
||||
self.assertEqual(result['held'], {'refused': 1, 'reference': 1})
|
||||
|
||||
def test_session_reads_the_mode(self):
|
||||
conf = write(self.home, 'zz-steamos-autologin.conf', '[Autologin]\nSession=plasma-steamos-wayland-oneshot.desktop\n')
|
||||
running = set()
|
||||
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
|
||||
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
|
||||
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n == 'steamos-session-select' else None):
|
||||
state = remote.session()
|
||||
self.assertEqual((state['mode'], state['selector']), ('desktop', 'steamos-session-select'))
|
||||
self.assertEqual(state['configured'], 'plasma-steamos-wayland-oneshot.desktop')
|
||||
running.update({'gamescope', 'plasmashell'}) # the nested desktop panel
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
running.clear()
|
||||
running.add('gamescope-wl') # gamescope's process name on the Frame, with no desktop panel open
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
running.clear()
|
||||
running.add('start-gamescope-session')
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
|
||||
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):
|
||||
@@ -393,9 +477,11 @@ class Ssh(unittest.TestCase):
|
||||
|
||||
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'):
|
||||
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)
|
||||
|
||||
|
||||
@@ -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'])
|
||||
@@ -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()
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
+63
-1
@@ -141,7 +141,7 @@ class PageTests(unittest.TestCase):
|
||||
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("hasn't been tried on a real\n Windows desktop or with a headset yet", 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):
|
||||
@@ -151,6 +151,55 @@ class PageTests(unittest.TestCase):
|
||||
|
||||
|
||||
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)
|
||||
@@ -366,6 +415,19 @@ class NativeTests(unittest.TestCase):
|
||||
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
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Desktop observation lifecycle, guarded HTTP API and packaged resource contract."""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import io
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
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_power
|
||||
import server
|
||||
|
||||
STREAM = frame_power.sampler.stream_report
|
||||
|
||||
SAMPLE = {'time': 1, 'elapsedSeconds': 0, 'osBuild': 'test', 'readings': {
|
||||
'battery.watts': {'value': 0, 'unit': 'W', 'source': '/sys/test', 'state': 'available'}},
|
||||
'unavailableGroups': ['fan']}
|
||||
|
||||
|
||||
class DesktopPower(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.observation = frame_power.Observation()
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.lock = Path(self.temp.name) / 'frame-test.lock'
|
||||
self.patches = [patch.object(frame_power.sampler, 'LOCK', self.lock)]
|
||||
for p in self.patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.addCleanup(self.observation.shutdown)
|
||||
|
||||
def wait(self):
|
||||
self.observation.thread.join(4)
|
||||
self.assertFalse(self.observation.thread.is_alive())
|
||||
|
||||
def stream(self, program, preset='minute'):
|
||||
real = STREAM
|
||||
def collect(command, payload, count, interval, stop, received, **kw):
|
||||
return real([sys.executable, '-u', '-c', program], '', count, interval, stop, received, **kw)
|
||||
mocked_stream = patch.object(frame_power.sampler, 'stream_report', side_effect=collect)
|
||||
mocked_stream.start()
|
||||
self.addCleanup(mocked_stream.stop)
|
||||
return self.observation.start(preset, ('ssh', '-o', 'HostName=old', 'frame-old'),
|
||||
{'alias': 'frame-old', 'deviceId': 'old'})
|
||||
|
||||
def test_complete_exports_match_and_no_background_collection_on_status(self):
|
||||
def collect(command, payload, count, interval, stop, received, **kw):
|
||||
self.assertEqual(count, 12)
|
||||
self.assertEqual(interval, 5)
|
||||
self.assertIn('HostName=old', command)
|
||||
self.assertIn('StrictHostKeyChecking=yes', command)
|
||||
self.assertIn('frame-old', command)
|
||||
self.assertNotIn('pkill', ' '.join(command))
|
||||
for _ in range(count):
|
||||
received(SAMPLE.copy())
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'stopped': None, 'samples': [SAMPLE.copy() for _ in range(count)], 'summary': {}}
|
||||
with patch.object(frame_power.sampler, 'stream_report', side_effect=collect) as run:
|
||||
self.observation.start('minute', ('ssh', '-o', 'HostName=old', 'frame-old'), {'alias': 'frame-old'})
|
||||
self.wait()
|
||||
state = self.observation.status()
|
||||
self.assertEqual((state['received'], state['running'], state['stopped']), (12, False, None))
|
||||
for _ in range(3):
|
||||
self.observation.status()
|
||||
self.assertEqual(run.call_count, 1)
|
||||
report, _ = self.observation.export(state['id'], 'json')
|
||||
self.assertEqual(json.loads(report)['summary']['battery.watts']['mean'], 0)
|
||||
html, _ = self.observation.export(state['id'], 'html')
|
||||
csv, _ = self.observation.export(state['id'], 'csv')
|
||||
self.assertIn('Headset: frame-old', html)
|
||||
self.assertIn('collection.state,Completed', csv)
|
||||
self.assertIn('collection.receivedSamples,12', csv)
|
||||
self.assertIn('battery.watts,0,W', csv)
|
||||
|
||||
def test_stop_keeps_received_sample_and_reaps_only_owned_child(self):
|
||||
real_popen = subprocess.Popen
|
||||
children = []
|
||||
def spawn(*a, **kw):
|
||||
proc = real_popen(*a, **kw)
|
||||
children.append(proc)
|
||||
return proc
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
with patch.object(frame_power.sampler.subprocess, 'Popen', side_effect=spawn):
|
||||
state = self.stream(f'import sys,time; sys.stdin.read(); print({event!r}, flush=True); time.sleep(30)')
|
||||
deadline = time.monotonic() + 3
|
||||
while self.observation.status()['received'] < 1 and time.monotonic() < deadline:
|
||||
time.sleep(.01)
|
||||
self.assertEqual(self.observation.status()['received'], 1)
|
||||
with self.assertRaisesRegex(ValueError, 'already running'):
|
||||
self.observation.start('minute', ('ssh', 'frame'), {'alias': 'other'})
|
||||
active, _ = self.observation.export(state['id'], 'json')
|
||||
self.assertIn('in progress', json.loads(active)['stopped'])
|
||||
self.observation.stop()
|
||||
self.wait()
|
||||
self.assertIsNotNone(children[0].poll())
|
||||
data, _ = self.observation.export(state['id'], 'json')
|
||||
self.assertEqual(json.loads(data)['samples'], [SAMPLE])
|
||||
self.assertIn('Stopped by user', json.loads(data)['stopped'])
|
||||
csv, _ = self.observation.export(state['id'], 'csv')
|
||||
self.assertIn('incomplete', csv)
|
||||
|
||||
def test_disconnect_and_oversize_keep_partial_and_incomplete(self):
|
||||
for ending in ('sys.exit(255)', 'print("x"*262145, flush=True)'):
|
||||
with self.subTest(ending=ending):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
state = self.stream(f'import sys; sys.stdin.read(); print({event!r}, flush=True); {ending}')
|
||||
self.wait()
|
||||
data, _ = self.observation.export(state['id'], 'json')
|
||||
report = json.loads(data)
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
self.assertTrue(report['stopped'])
|
||||
|
||||
def test_local_lock_preserves_previous_report_and_invalid_inputs_do_not_spawn(self):
|
||||
with patch.object(frame_power.sampler, 'stream_report') as run:
|
||||
self.lock.mkdir()
|
||||
with self.assertRaisesRegex(ValueError, 'Smoke session'):
|
||||
self.observation.start('minute', ('ssh', 'frame'), {'alias': 'frame'})
|
||||
for preset in ('hour', 1000, None):
|
||||
with self.assertRaises(ValueError):
|
||||
self.observation.start(preset, ('ssh', 'frame'), {'alias': 'frame'})
|
||||
run.assert_not_called()
|
||||
self.assertIsNone(self.observation.status()['id'])
|
||||
|
||||
def test_headset_switch_preserves_original_identity_and_requests_cancel(self):
|
||||
done = threading.Event()
|
||||
def collect(command, payload, count, interval, cancel, received, **kw):
|
||||
received(SAMPLE)
|
||||
done.set()
|
||||
cancel.wait(3)
|
||||
return {'requestedSamples': count, 'intervalSeconds': interval, 'samples': [SAMPLE], 'stopped': None}
|
||||
with patch.object(frame_power.sampler, 'stream_report', side_effect=collect), \
|
||||
patch.object(server, '_power', self.observation):
|
||||
original = server.FRAME, list(server.HOST_OPTS)
|
||||
try:
|
||||
state = self.observation.start('five-minutes', ('ssh', 'old'), {'alias': 'old'})
|
||||
self.assertTrue(done.wait(2))
|
||||
server.route('different-headset', [])
|
||||
self.wait()
|
||||
current = self.observation.status()
|
||||
self.assertEqual(current['headset']['alias'], 'old')
|
||||
self.assertEqual(current['requested'], 60)
|
||||
self.assertIn('Headset switched', current['stopped'])
|
||||
with self.assertRaises(ValueError):
|
||||
self.observation.export('stale-id', 'html')
|
||||
with self.assertRaises(ValueError):
|
||||
self.observation.export(state['id'], '../file')
|
||||
finally:
|
||||
server.route(*original)
|
||||
|
||||
def test_http_guards_stale_target_stop_id_and_download_headers(self):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
conn = http.client.HTTPConnection(*httpd.server_address)
|
||||
def request(method, path, body=None, device=None, key=True):
|
||||
headers = {'X-Frame-UI': server.UI_KEY} if key else {}
|
||||
if device:
|
||||
headers['X-Frame-Device'] = device
|
||||
conn.request(method, path, json.dumps(body) if body is not None else None, headers)
|
||||
r = conn.getresponse()
|
||||
return r.status, dict(r.getheaders()), r.read()
|
||||
try:
|
||||
with patch.object(server, '_power', self.observation), \
|
||||
patch.object(server, 'LINK', SimpleNamespace(active_device=lambda: {'id': 'new'})), \
|
||||
patch.object(frame_power.sampler, 'stream_report') as run:
|
||||
for path in ('/api/power', '/api/power/report?id=x&format=html'):
|
||||
self.assertEqual(request('GET', path, key=False)[0], 403)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, key=False)[0], 403)
|
||||
for device, expected in ((None, 400), ('old', 409)):
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, device)[0], expected)
|
||||
run.assert_not_called()
|
||||
self.observation.id = 'test-id'
|
||||
self.observation.report = {'schemaVersion': 1, 'requestedSamples': 12, 'intervalSeconds': 5,
|
||||
'headset': {'alias': 'old'}, 'stopped': 'Disconnected', 'samples': [SAMPLE]}
|
||||
status, headers, data = request('GET', '/api/power/report?id=test-id&format=html')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('attachment;', headers['Content-Disposition'])
|
||||
self.assertIn('no-store', headers['Cache-Control'])
|
||||
self.assertIn(b'Disconnected', data)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': 'stale'}, 'new')[0], 409)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': 'test-id'}, 'new')[0], 200)
|
||||
received = threading.Event()
|
||||
def collect(command, payload, count, interval, cancel, emit, **kw):
|
||||
emit(SAMPLE)
|
||||
received.set()
|
||||
cancel.wait(3)
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'samples': [SAMPLE], 'stopped': 'Stopped'}
|
||||
run.side_effect = collect
|
||||
code, _, data = request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, 'new')
|
||||
self.assertEqual(code, 200)
|
||||
new_id = json.loads(data)['id']
|
||||
self.assertNotEqual(new_id, 'test-id')
|
||||
self.assertTrue(received.wait(2))
|
||||
self.assertEqual(request('GET', '/api/power/report?id=test-id&format=json')[0], 409)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': new_id}, 'new')[0], 200)
|
||||
self.wait()
|
||||
self.assertEqual(self.observation.status()['received'], 1)
|
||||
self.assertEqual(self.observation.status()['headset']['deviceId'], 'new')
|
||||
finally:
|
||||
conn.close()
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_early_remote_smoke_lock_has_structured_reason_without_sensor_reads(self):
|
||||
self.lock.mkdir()
|
||||
output = io.StringIO()
|
||||
with patch.object(frame_power.sampler, 'snapshot') as sensor, contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(frame_power.sampler.main(['--probe', '--stream']), 75)
|
||||
sensor.assert_not_called()
|
||||
self.assertIn('Smoke session active', json.loads(output.getvalue())['finished']['stopped'])
|
||||
|
||||
def test_phone_can_import_without_desktop_sampler_resources(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
import os
|
||||
import shutil
|
||||
shutil.copyfile(ROOT / 'ui/frame_power.py', Path(tmp) / 'frame_power.py')
|
||||
program = 'import sys; sys.path.insert(0, sys.argv[1]); import frame_power; assert frame_power.Observation().status()["id"] is None'
|
||||
result = subprocess.run([sys.executable, '-I', '-B', '-c', program, tmp],
|
||||
env={**os.environ, 'FRAME_LOCAL': '1'}, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_packaged_resources_under_isolated_python(self):
|
||||
config = json.loads((ROOT / 'app/package.json').read_text())
|
||||
# Materialize exactly the Python resources selected by electron-builder.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
resources = Path(tmp)
|
||||
import shutil
|
||||
for spec in config['build']['extraResources']:
|
||||
if spec.get('to') not in ('ui', 'scripts'):
|
||||
continue
|
||||
source = ROOT / 'app' / spec['from']
|
||||
for pattern in spec['filter']:
|
||||
for file in source.glob(pattern):
|
||||
if file.is_file():
|
||||
dest = resources / spec['to'] / file.name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(file, dest)
|
||||
program = ('import sys; sys.path.insert(0, sys.argv[1]); import frame_power; '
|
||||
'p=frame_power.sampler.remote_payload(); '
|
||||
'assert "def snapshot" in p and "def battery" in p; '
|
||||
'o=frame_power.Observation(); assert not o.status()["running"]')
|
||||
run = subprocess.run([sys.executable, '-I', '-B', '-c', program, str(resources / 'ui')], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 0, run.stderr)
|
||||
run = subprocess.run(['node', '--test', str(ROOT / 'tests/power_desktop.cjs')], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Offline HTML exports and streamed transport, with real local child cleanup."""
|
||||
import sandbox # noqa: F401
|
||||
import base64
|
||||
import contextlib
|
||||
import csv
|
||||
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_csv_metadata_headset_alias_is_plain_text_in_spreadsheets(self):
|
||||
for prefix in ('=', '+', '-', '@', ' =', '\t='):
|
||||
with self.subTest(prefix=prefix):
|
||||
alias = prefix + 'HYPERLINK("https://example.invalid")'
|
||||
report = {'headset': {'alias': alias}, 'requestedSamples': 12,
|
||||
'stopped': 'Stopped', 'samples': []}
|
||||
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
|
||||
self.assertEqual(len(rows), 4)
|
||||
self.assertEqual([row[5] for row in rows[1:]], ["'" + alias] * 3)
|
||||
report['headset']['alias'] = 'living-room-frame'
|
||||
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
|
||||
self.assertEqual([row[5] for row in rows[1:]], ['living-room-frame'] * 3)
|
||||
|
||||
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_canceled_launch_does_not_open_ssh(self):
|
||||
import threading
|
||||
stop = threading.Event()
|
||||
stop.set()
|
||||
with mock.patch.object(power.subprocess, 'Popen') as spawn:
|
||||
report = power.stream_report(['ssh', 'frame'], '', 12, 5, stop_event=stop)
|
||||
spawn.assert_not_called()
|
||||
self.assertIn('Stopped before collection', report['stopped'])
|
||||
|
||||
def test_stop_interrupts_child_that_never_reads_payload(self):
|
||||
import threading
|
||||
stop = threading.Event()
|
||||
timer = threading.Timer(0.1, stop.set)
|
||||
timer.start()
|
||||
try:
|
||||
report = power.stream_report([sys.executable, '-c', 'import time; time.sleep(30)'],
|
||||
'x' * 1048576, 1, 1, stop_event=stop)
|
||||
finally:
|
||||
timer.cancel()
|
||||
self.assertIn('Stopped', report['stopped'])
|
||||
self.assertEqual(report['samples'], [])
|
||||
|
||||
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)
|
||||
@@ -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()
|
||||
|
||||
+11
-7
@@ -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']]},
|
||||
@@ -144,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',
|
||||
@@ -155,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),
|
||||
@@ -214,7 +217,8 @@
|
||||
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('');
|
||||
|
||||
+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.
|
||||
|
||||
+4
-5
@@ -30,9 +30,8 @@ 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
|
||||
# The switch Valve's own devkit tools use (frame/devkit-utils/steamos-delete). Nothing else
|
||||
# is ever run as a session command.
|
||||
SELECTORS = ('holo-session-select', 'steamos-session-select')
|
||||
# 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',
|
||||
@@ -344,10 +343,10 @@ def session_status():
|
||||
|
||||
|
||||
def session_command(selector):
|
||||
"""The switch to Game Mode, exactly as Valve's devkit tools run it."""
|
||||
"""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, 'gamescope'])
|
||||
return shlex.join([selector, 'switch-to-game-mode' if selector == 'steamosctl' else 'gamescope'])
|
||||
|
||||
|
||||
def return_to_game_mode(confirmed=False):
|
||||
|
||||
@@ -373,15 +373,40 @@ def restore(part, stamp, source):
|
||||
# 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 session from these files and switch with holo-session-select or
|
||||
# steamos-session-select, so that's what this reads and suggests.
|
||||
# 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', name], stdout=subprocess.DEVNULL,
|
||||
return subprocess.call(['pgrep', '-f' if full else '-x', pattern], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL) == 0
|
||||
except OSError:
|
||||
return False
|
||||
@@ -397,21 +422,32 @@ def session():
|
||||
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 ('holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
|
||||
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
|
||||
if gamescope:
|
||||
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 plasma or (configured and 'plasma' in configured):
|
||||
elif (game, desktop) == ('inactive', 'active'):
|
||||
mode = 'desktop'
|
||||
else:
|
||||
mode = 'unknown'
|
||||
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}
|
||||
'configured': configured, 'config_file': conf, 'selector': selector,
|
||||
'session_targets': targets, 'mode_source': source}
|
||||
|
||||
|
||||
def main(argv):
|
||||
|
||||
+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):
|
||||
|
||||
@@ -460,6 +460,7 @@ class MacView:
|
||||
windows = self.call("/windows").get("windows", []) if status.get("screen") else []
|
||||
displays = self.call("/displays").get("displays", [])
|
||||
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(),
|
||||
|
||||
@@ -29,6 +29,7 @@ from frame_stream_stats import Stats
|
||||
|
||||
# Viewer fields are untrusted JSON: float('x'), int(None), m['t'] missing.
|
||||
MALFORMED = (ValueError, TypeError, KeyError, OverflowError)
|
||||
WINDOW_CAPTURE_UNAVAILABLE = 'Individual windows are unavailable in this build. Choose a whole screen instead.'
|
||||
|
||||
|
||||
class Grants:
|
||||
@@ -386,6 +387,9 @@ class Agent:
|
||||
self.sessions, self.sources = {}, {}
|
||||
self.next_id, self.selecting, self.selection_error = 1, False, ''
|
||||
self.windows = Windows() if sys.platform == 'win32' else None
|
||||
self.wgc = bool(self.windows) and all(native.has_property('d3d11screencapturesrc', p)
|
||||
for p in ('capture-api', 'window-handle', 'show-border'))
|
||||
self.window_capture_reason = WINDOW_CAPTURE_UNAVAILABLE if self.windows and not self.wgc else ''
|
||||
self.encoder = 'mfh264enc' if self.windows else 'vah264enc' if native.has('vah264enc') else 'x264enc'
|
||||
self.shutting_down = False
|
||||
self.selection_generation = 0
|
||||
@@ -393,6 +397,10 @@ class Agent:
|
||||
def lists(self):
|
||||
if self.windows:
|
||||
windows, displays = self.windows.sources()
|
||||
if not self.wgc:
|
||||
windows = []
|
||||
for source in windows + displays:
|
||||
source['capture_api'] = 'wgc' if self.wgc else 'dxgi'
|
||||
self.sources = {s['src']: s for s in windows + displays}
|
||||
return windows, displays
|
||||
return [{k: v for k, v in s.items() if k not in ('portal', 'fd', 'node')} for s in self.sources.values()], []
|
||||
@@ -400,6 +408,8 @@ class Agent:
|
||||
def source(self, src):
|
||||
if src == 'test':
|
||||
return dict(src='test', title='Test pattern', w=1280, h=720)
|
||||
if self.window_capture_reason and isinstance(src, str) and src.startswith('window:'):
|
||||
raise ValueError(self.window_capture_reason)
|
||||
self.lists()
|
||||
if src not in self.sources:
|
||||
raise ValueError('Choose a window or screen on this computer first')
|
||||
@@ -498,6 +508,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
windows, displays = agent.lists()
|
||||
if method == 'GET' and url.path == '/status':
|
||||
data = dict(version=1, host='windows' if agent.windows else 'linux', screen=True,
|
||||
windowCaptureReason=agent.window_capture_reason,
|
||||
accessibility=True, selecting=agent.selecting, selectionError=agent.selection_error,
|
||||
encoder=agent.encoder, finished=[], streams=[dict(id=i, src=s.src,
|
||||
title=s.source.get('title', ''), stats=s.stats.summary(), controller=s.controller.state())
|
||||
|
||||
+11
-1
@@ -38,6 +38,7 @@ class Native:
|
||||
signatures = {
|
||||
'fc_now': (C.c_int64, []), 'fc_gst_init': (None, []),
|
||||
'fc_has_element': (C.c_int, [C.c_char_p]),
|
||||
'fc_has_property': (C.c_int, [C.c_char_p, C.c_char_p]),
|
||||
'fc_new': (C.c_void_p, [C.c_int, C.c_int]), 'fc_free': (None, [C.c_void_p]),
|
||||
'fc_ceiling': (None, [C.c_void_p, C.c_int]),
|
||||
'fc_gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
|
||||
@@ -69,6 +70,10 @@ class Native:
|
||||
def has(self, name):
|
||||
return bool(self.lib.fc_has_element(name.encode()))
|
||||
|
||||
def has_property(self, element, name):
|
||||
"""Check optional element properties in the initialized capture DLL."""
|
||||
return bool(self.lib.fc_has_property(element.encode(), name.encode()))
|
||||
|
||||
def now(self):
|
||||
return self.lib.fc_now()
|
||||
|
||||
@@ -133,8 +138,13 @@ def pipeline(source, platform, encoder, w, h, fps, bitrate, codec='h264'):
|
||||
kind, ident = source['src'].split(':')
|
||||
if kind not in ('window', 'display') or not re.fullmatch(r'[0-9]+', ident):
|
||||
raise ValueError('Invalid Windows capture source')
|
||||
api = source.get('capture_api', 'wgc')
|
||||
if api not in ('wgc', 'dxgi') or (api == 'dxgi' and kind != 'display'):
|
||||
raise ValueError('This build can only capture whole screens without Windows Graphics Capture')
|
||||
prop = 'window-handle' if kind == 'window' else 'monitor-handle'
|
||||
capture = 'd3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true %s=%d' % (prop, int(ident))
|
||||
# DXGI-only builds don't expose capture-api or show-border at all.
|
||||
mode = ' capture-api=wgc show-border=true' if api == 'wgc' else ''
|
||||
capture = 'd3d11screencapturesrc%s show-cursor=true %s=%d' % (mode, prop, int(ident))
|
||||
else:
|
||||
capture = 'pipewiresrc fd=%d path=%d do-timestamp=true' % (source['fd'], source['node'])
|
||||
# The source gate runs before conversion/encoding. There is no leaky queue
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""One desktop observation using the read-only sampler; reports stay in memory."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
# Shared with the source-checkout CLI; shipped as explicit desktop resources.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
try:
|
||||
import frame_power_sample as sampler
|
||||
from frame_power_report import render
|
||||
except ModuleNotFoundError as exc:
|
||||
# Phone/headset bundles host the same UI without desktop-only resources.
|
||||
if exc.name not in ('frame_power_sample', 'frame_power_report'):
|
||||
raise
|
||||
sampler = render = None
|
||||
|
||||
PRESETS = {'minute': (12, 5), 'five-minutes': (60, 5)}
|
||||
|
||||
|
||||
class Observation:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self.report = None
|
||||
self.running = False
|
||||
self.thread = None
|
||||
self.cancel = None
|
||||
self.reason = None
|
||||
self.id = None
|
||||
self.started = None
|
||||
|
||||
def status(self):
|
||||
with self.lock:
|
||||
return {'id': self.id, 'running': self.running, 'received': len(self.report['samples']) if self.report else 0,
|
||||
'requested': self.report['requestedSamples'] if self.report else 0,
|
||||
'headset': self.report['headset'] if self.report else None,
|
||||
'started': self.started, 'stopped': self.report['stopped'] if self.report else None}
|
||||
|
||||
def start(self, preset, route, headset):
|
||||
if sampler is None:
|
||||
raise ValueError('Desktop power-report resources are missing; reinstall the desktop app')
|
||||
if preset not in PRESETS:
|
||||
raise ValueError('Choose a one-minute or five-minute observation')
|
||||
with self.lock:
|
||||
if self.running:
|
||||
raise ValueError('An observation is already running')
|
||||
if sampler.LOCK.exists():
|
||||
raise ValueError('Smoke session active (/tmp/frame-test.lock); observation not started')
|
||||
count, interval = PRESETS[preset]
|
||||
self.id = uuid.uuid4().hex
|
||||
self.cancel = threading.Event()
|
||||
self.reason = None
|
||||
self.started = time.time()
|
||||
self.report = {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'headset': headset, 'stopped': 'Observation in progress; received samples only',
|
||||
'samples': [], 'summary': {}}
|
||||
self.running = True
|
||||
# Pin the route before the caller releases the server's route lock.
|
||||
command = [*route[:-1], '-T', '-o', 'StrictHostKeyChecking=yes',
|
||||
'-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2', route[-1],
|
||||
'python3', '-', '--probe', '--count', str(count), '--interval', str(interval)]
|
||||
self.thread = threading.Thread(target=self._collect, args=(command, count, interval), daemon=True)
|
||||
self.thread.start()
|
||||
return self.status()
|
||||
|
||||
def _collect(self, command, count, interval):
|
||||
def received(sample):
|
||||
with self.lock:
|
||||
self.report['samples'].append(sample)
|
||||
try:
|
||||
result = sampler.stream_report(command, sampler.remote_payload(), count, interval,
|
||||
self.cancel, received, discard_errors=True)
|
||||
with self.lock:
|
||||
result['headset'] = self.report['headset']
|
||||
result['stopped'] = self.reason or result['stopped']
|
||||
self.report = result
|
||||
except Exception as exc:
|
||||
with self.lock:
|
||||
self.report['stopped'] = self.reason or f'Observation interrupted: {exc}'
|
||||
finally:
|
||||
with self.lock:
|
||||
self.running = False
|
||||
|
||||
def stop(self, reason='Stopped by user; received samples retained'):
|
||||
with self.lock:
|
||||
if self.running:
|
||||
self.reason = reason
|
||||
self.report['stopped'] = reason
|
||||
self.cancel.set()
|
||||
return self.status()
|
||||
|
||||
def export(self, observation_id, format):
|
||||
if format not in ('html', 'json', 'csv'):
|
||||
raise ValueError('Unknown report format')
|
||||
with self.lock:
|
||||
if not self.report or observation_id != self.id:
|
||||
raise ValueError('This report was replaced; refresh the observation')
|
||||
# Independent snapshot, including samples already received while running.
|
||||
report = json.loads(json.dumps(self.report, allow_nan=False))
|
||||
report['summary'] = sampler.summarize(report['samples'])
|
||||
if format == 'json':
|
||||
return json.dumps(report, allow_nan=False, indent=2), 'application/json'
|
||||
csv = sampler.csv_samples(report)
|
||||
return (render(report, csv), 'text/html; charset=utf-8') if format == 'html' else (csv, 'text/csv; charset=utf-8')
|
||||
|
||||
def shutdown(self):
|
||||
self.stop('App closed; received samples retained')
|
||||
if self.thread:
|
||||
self.thread.join(timeout=8)
|
||||
+29
-8
@@ -45,19 +45,40 @@ def num(path, scale=1.0):
|
||||
|
||||
|
||||
def battery():
|
||||
for d in glob.glob("/sys/class/power_supply/*"):
|
||||
# Prefer the documented Frame fuel gauge over controller/peripheral batteries.
|
||||
supplies = sorted(glob.glob("/sys/class/power_supply/*"),
|
||||
key=lambda d: (os.path.basename(d) != "max1720x_bat_7-36", d))
|
||||
for d in supplies:
|
||||
if read(d + "/type") != "Battery":
|
||||
continue
|
||||
cap = read(d + "/capacity")
|
||||
if read(d + "/scope") == "Device" or read(d + "/present") == "0":
|
||||
continue
|
||||
|
||||
def value(name, scale=1, minimum=None, maximum=None):
|
||||
n = num(d + "/" + name, scale)
|
||||
if n is None or not math.isfinite(n):
|
||||
return None
|
||||
if (minimum is not None and n < minimum) or (maximum is not None and n > maximum):
|
||||
return None
|
||||
return n
|
||||
|
||||
# max1720x reports microvolts/microamps; current is positive while charging.
|
||||
volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6)
|
||||
return {"percent": int(cap) if cap and cap.isdigit() else None,
|
||||
volts, amps = value("voltage_now", 1e-6, 0), value("current_now", 1e-6)
|
||||
full = value("charge_full", 1e-3, 0)
|
||||
design = value("charge_full_design", 1e-3, 0)
|
||||
return {"source": d,
|
||||
"percent": value("capacity", minimum=0, maximum=100),
|
||||
"status": read(d + "/status"),
|
||||
"watts": round(volts * amps, 2) if volts is not None and amps is not None else None,
|
||||
"timeToFull": num(d + "/time_to_full_now"),
|
||||
"timeToEmpty": num(d + "/time_to_empty_now"),
|
||||
"tempC": num(d + "/temp", 0.1),
|
||||
"health": read(d + "/health")}
|
||||
# Reject negative/unsigned sentinel estimates and implausible temperatures.
|
||||
"timeToFull": value("time_to_full_now", minimum=1, maximum=604800),
|
||||
"timeToEmpty": value("time_to_empty_now", minimum=1, maximum=604800),
|
||||
"tempC": value("temp", 0.1, -273.15, 200),
|
||||
"health": read(d + "/health"),
|
||||
"cycleCount": value("cycle_count", minimum=0),
|
||||
"fullMah": full if full else None,
|
||||
"designMah": design if design else None,
|
||||
"capacityHealthPercent": round(full / design * 100, 1) if full and design else None}
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Read-only headset storage inventory, piped over SSH as python3 -.
|
||||
|
||||
No recursive size scans, Steam IPC, subprocesses or writes. Manifest sizes are
|
||||
Steam's recorded content sizes, not filesystem allocation measurements.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
|
||||
HOME = Path.home()
|
||||
STEAM = HOME / '.local/share/Steam'
|
||||
MAX_TEXT = 2 * 1024 * 1024
|
||||
TOOLS = re.compile(r'^(Steam Linux Runtime|Proton|Steamworks Common|FEX$|Lepton Development$)')
|
||||
|
||||
|
||||
def state(error):
|
||||
if isinstance(error, PermissionError):
|
||||
return 'permission-denied'
|
||||
if isinstance(error, FileNotFoundError):
|
||||
return 'missing'
|
||||
return 'unavailable'
|
||||
|
||||
|
||||
def read_vdf(path):
|
||||
"""Small strict KeyValues reader; reject truncated/malformed input explicitly."""
|
||||
with open(path, encoding='utf-8', errors='strict') as stream:
|
||||
text = stream.read(MAX_TEXT + 1)
|
||||
if len(text) > MAX_TEXT:
|
||||
raise ValueError('metadata too large')
|
||||
tokens = []
|
||||
pos = 0
|
||||
pattern = re.compile(r'\s+|//[^\n]*|"((?:[^"\\]|\\.)*)"|([{}])')
|
||||
while pos < len(text):
|
||||
match = pattern.match(text, pos)
|
||||
if not match:
|
||||
raise ValueError('invalid KeyValues')
|
||||
pos = match.end()
|
||||
if match.group(1) is not None:
|
||||
tokens.append(('text', re.sub(r'\\(["\\])', r'\1', match.group(1))))
|
||||
elif match.group(2):
|
||||
tokens.append(('brace', match.group(2)))
|
||||
index = 0
|
||||
|
||||
def pairs(depth=0):
|
||||
nonlocal index
|
||||
if depth > 20:
|
||||
raise ValueError('metadata too deep')
|
||||
result = {}
|
||||
while index < len(tokens):
|
||||
key_type, key = tokens[index]
|
||||
index += 1
|
||||
if key_type == 'brace' and key == '}':
|
||||
if not depth:
|
||||
raise ValueError('unexpected brace')
|
||||
return result
|
||||
if key_type == 'brace' or index >= len(tokens):
|
||||
raise ValueError('missing value')
|
||||
value_type, value = tokens[index]
|
||||
index += 1
|
||||
if value_type == 'brace' and value == '{':
|
||||
value = pairs(depth + 1)
|
||||
elif value_type == 'brace':
|
||||
raise ValueError('missing value')
|
||||
if key.lower() in result:
|
||||
raise ValueError('duplicate key')
|
||||
result[key.lower()] = value
|
||||
if depth:
|
||||
raise ValueError('unclosed object')
|
||||
return result
|
||||
return pairs()
|
||||
|
||||
|
||||
def capacity(path):
|
||||
try:
|
||||
s = os.statvfs(path)
|
||||
return {'state': 'ok', 'total': s.f_blocks * s.f_frsize,
|
||||
'free': s.f_bavail * s.f_frsize,
|
||||
'used': (s.f_blocks - s.f_bfree) * s.f_frsize,
|
||||
'filesystem': str(os.stat(path).st_dev)}
|
||||
except (OSError, AttributeError) as error:
|
||||
return {'state': state(error), 'total': None, 'free': None, 'used': None,
|
||||
'filesystem': None}
|
||||
|
||||
|
||||
def inventory(home=None, steam=None):
|
||||
home, steam = Path(home or HOME), Path(steam or STEAM)
|
||||
paths = [steam]
|
||||
discovery = 'ok'
|
||||
try:
|
||||
folders = read_vdf(steam / 'steamapps/libraryfolders.vdf').get('libraryfolders')
|
||||
if not isinstance(folders, dict):
|
||||
raise ValueError('missing libraryfolders')
|
||||
for key, value in folders.items():
|
||||
if not key.isdigit():
|
||||
continue
|
||||
path = value.get('path') if isinstance(value, dict) else value
|
||||
if not isinstance(path, str) or not os.path.isabs(path) or '\x00' in path:
|
||||
raise ValueError('invalid library path')
|
||||
paths.append(Path(path))
|
||||
except OSError as error:
|
||||
discovery = state(error)
|
||||
except (ValueError, UnicodeError):
|
||||
discovery = 'invalid-metadata'
|
||||
libraries, seen = [], set()
|
||||
for path in paths:
|
||||
canonical = os.path.realpath(path)
|
||||
if canonical in seen:
|
||||
continue
|
||||
seen.add(canonical)
|
||||
lib = {'path': str(path), 'capacity': capacity(path), 'state': 'ok',
|
||||
'apps': [], 'issues': [], 'recordedBytes': 0, 'unknownSizes': 0,
|
||||
'gameBytes': 0, 'toolBytes': 0}
|
||||
try:
|
||||
with os.scandir(path / 'steamapps') as entries:
|
||||
manifests = sorted(entry.path for entry in entries
|
||||
if re.fullmatch(r'appmanifest_\d+\.acf', entry.name))
|
||||
for manifest in manifests:
|
||||
try:
|
||||
fields = read_vdf(manifest).get('appstate')
|
||||
if not isinstance(fields, dict):
|
||||
raise ValueError('missing AppState')
|
||||
appid = fields.get('appid', '')
|
||||
if not isinstance(appid, str) or not appid.isdigit() or Path(manifest).name != f'appmanifest_{appid}.acf':
|
||||
raise ValueError('invalid appid')
|
||||
name = fields.get('name') or appid
|
||||
if not isinstance(name, str):
|
||||
raise ValueError('invalid name')
|
||||
raw = fields.get('sizeondisk')
|
||||
size = int(raw) if isinstance(raw, str) and raw.isascii() and raw.isdigit() and len(raw) <= 18 else None
|
||||
kind = 'tool' if TOOLS.match(name) else 'game'
|
||||
lib['apps'].append({'appid': appid, 'name': name, 'bytes': size, 'kind': kind})
|
||||
if size is None:
|
||||
lib['unknownSizes'] += 1
|
||||
else:
|
||||
lib['recordedBytes'] += size
|
||||
lib['toolBytes' if kind == 'tool' else 'gameBytes'] += size
|
||||
except (OSError, ValueError, UnicodeError) as error:
|
||||
lib['issues'].append({'file': Path(manifest).name,
|
||||
'state': state(error) if isinstance(error, OSError) else 'invalid-metadata'})
|
||||
lib['apps'].sort(key=lambda app: (app['bytes'] is None, -(app['bytes'] or 0), app['name'].lower()))
|
||||
if lib['issues']:
|
||||
lib['state'] = 'partial'
|
||||
except OSError as error:
|
||||
lib['state'] = state(error)
|
||||
libraries.append(lib)
|
||||
return {'time': time.time(), 'home': capacity(home), 'discovery': discovery, 'libraries': libraries}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(inventory()))
|
||||
+11
-9
@@ -240,27 +240,29 @@ def _scrub_url(u):
|
||||
return f'{parts.scheme}://{host}/…' if host else '<url>'
|
||||
|
||||
|
||||
def scrub(text, limit=2000):
|
||||
def scrub(text, limit=2000, *, _sub=re.sub):
|
||||
"""Text with URLs, home folders, user names, addresses, hosts, ids and keys replaced."""
|
||||
# The internal substitution hook lets the connection log retain source ranges through
|
||||
# this exact pipeline. Ordinary callers keep the same substitutions and output limit.
|
||||
if text is None:
|
||||
return None
|
||||
t = URL_RE.sub(lambda m: _scrub_url(m.group(0)), str(text)) # first, before anything splits a URL
|
||||
t = _sub(URL_RE, lambda m: _scrub_url(m.group(0)), str(text)) # first, before anything splits a URL
|
||||
home = str(Path.home())
|
||||
if len(home) > 3:
|
||||
t = t.replace(home, '~')
|
||||
t = _sub(re.escape(home), '~', t)
|
||||
# A home folder's whole name ("C:\Users\Jane Doe", "/Users/O'Brien"), up to the next separator.
|
||||
t = re.sub(r'''(?i)(/Users/|/home/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\n"]+''', r'\1<user>', t)
|
||||
t = _sub(r'''(?i)(/Users/|/home/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\n"]+''', r'\1<user>', t)
|
||||
# ssh's "user@host: ..." and "user@host's password:", the whole field: the user even with
|
||||
# spaces or a DOMAIN\ prefix, the host even a full domain name. Before the email rule, which
|
||||
# would otherwise take only the last word of the user.
|
||||
t = re.sub(r'''(?m)(?:^|(?<=: )|(?<=\| ))[^@\n:|"<]{1,64}@[\w.\[\]%:<>-]+?(?=:(?:\s|$)|'s\s)''',
|
||||
t = _sub(r'''(?m)(?:^|(?<=: )|(?<=\| ))[^@\n:|"<]{1,64}@[\w.\[\]%:<>-]+?(?=:(?:\s|$)|'s\s)''',
|
||||
'<user>@<host>', t)
|
||||
for pattern, repl in SCRUBS:
|
||||
t = pattern.sub(repl, t)
|
||||
t = re.sub(r'(?<![\w.+\\<-])[\w.+\\-]+@(?=[A-Za-z\[<])', '<user>@', t) # any other word@host
|
||||
t = IPV6_RE.sub(_ipv6, t)
|
||||
t = _sub(pattern, repl, t)
|
||||
t = _sub(r'(?<![\w.+\\<-])[\w.+\\-]+@(?=[A-Za-z\[<])', '<user>@', t) # any other word@host
|
||||
t = _sub(IPV6_RE, _ipv6, t)
|
||||
for name in _user_names():
|
||||
t = re.sub(r'\b%s\b' % re.escape(name), '<user>', t)
|
||||
t = _sub(r'\b%s\b' % re.escape(name), '<user>', t)
|
||||
return t[:limit]
|
||||
|
||||
|
||||
|
||||
+159
-23
@@ -175,7 +175,8 @@
|
||||
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||
padding: 8px 8px; font: inherit; font-size: 13px; }
|
||||
.addr .t { overflow-wrap: anywhere; }
|
||||
.addr .s, .dev-problem .s { white-space: normal; }
|
||||
.addr .s { white-space: normal; }
|
||||
.item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }
|
||||
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
|
||||
.addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; }
|
||||
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } }
|
||||
@@ -627,6 +628,8 @@
|
||||
.runs { font-size:11px; font-weight:500; padding:3px 8px; border-radius:10px; white-space:nowrap; }
|
||||
.runs.works { color:#c6efb0; background:#70bb5426; } .runs.ready { color:#b7d8ec; background:#6ec9ff1a; }
|
||||
.runs.unknown { color:#a8b5c2; background:#ffffff0d; } .runs.blocked { color:#f1c28a; background:#d9a23a22; }
|
||||
.runs.warning, .runs.issues { color:#f1c28a; background:#d9a23a22; white-space:normal; }
|
||||
.store-compat-badges { display:flex; flex-wrap:wrap; gap:4px; }
|
||||
.store-hero > .store-art { position:absolute; inset:0; }
|
||||
.store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; }
|
||||
.hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; }
|
||||
@@ -773,6 +776,7 @@
|
||||
.detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; }
|
||||
.detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; }
|
||||
.detail-verdict.blocked { color:#e3b77a; }
|
||||
.detail-verdict.warning, .detail-verdict.issues { color:#e3b77a; }
|
||||
.detail-verdict.unknown { color:#adbac7; }
|
||||
.detail-price { font-size:23px; color:white; font-weight:600; }
|
||||
.detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; }
|
||||
@@ -1018,6 +1022,9 @@
|
||||
#appsLinux .row { flex-wrap:wrap!important; }
|
||||
}
|
||||
|
||||
#storage .card { padding:16px; border:1px solid rgba(255,255,255,.1); border-radius:8px; margin-top:16px; }
|
||||
#storage .grow, #storage .sub { overflow-wrap:anywhere; }
|
||||
#storage .item > span { flex-shrink:0; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/control-center.css">
|
||||
</head>
|
||||
@@ -1124,7 +1131,10 @@
|
||||
<div><dt>Battery power · signed</dt><dd data-cc-metric="power">Unavailable</dd></div><div><dt>Charger</dt><dd data-cc-metric="charger">Unavailable</dd></div>
|
||||
<div><dt>Battery temperature</dt><dd data-cc-metric="batteryTemp">Unavailable</dd></div><div><dt>Battery health</dt><dd data-cc-metric="batteryHealth">Unavailable</dd></div>
|
||||
<div><dt>Battery estimate</dt><dd data-cc-metric="estimate">Unavailable</dd></div>
|
||||
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.</p>
|
||||
<div><dt>Charge cycles</dt><dd data-cc-metric="cycles">Unavailable</dd></div><div><dt>Full charge capacity</dt><dd data-cc-metric="fullCapacity">Unavailable</dd></div>
|
||||
<div><dt>Design capacity</dt><dd data-cc-metric="designCapacity">Unavailable</dd></div><div><dt>Full / design capacity</dt><dd data-cc-metric="capacityHealth">Unavailable</dd></div>
|
||||
<div><dt>Battery source</dt><dd data-cc-metric="batterySource">Unavailable</dd></div>
|
||||
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Unavailable sensors are 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.</p>
|
||||
<div class="cc-services" id="ccServices"></div>
|
||||
</details>
|
||||
</section>
|
||||
@@ -1357,10 +1367,10 @@
|
||||
<div class="store-owned" id="storeOwned">
|
||||
<section class="panel" id="androidBackup">
|
||||
<div class="shelf-head"><h2>Import Android game</h2></div>
|
||||
<p class="sub">Have a SideQuest backup? Choose the game's APK and all its .obb game-data files together.
|
||||
<p class="sub">Have a backup? Choose the game's APK and all its .obb files together, or one XAPK/APKS/APKM bundle containing a standalone APK. Split sets and Android/data restore are not supported here.
|
||||
Frame Control installs the app and puts its data in the right place.</p>
|
||||
<button class="action small" id="androidBackupChoose">Choose game files…</button>
|
||||
<input type="file" id="androidBackupInput" accept=".apk,.obb" multiple hidden>
|
||||
<input type="file" id="androidBackupInput" accept=".apk,.obb,.xapk,.apks,.apkm" multiple hidden>
|
||||
<p class="hint desk-only">In the desktop app, drop the backup folder or its APK. Matching game data in the same folder is found automatically.</p>
|
||||
</section>
|
||||
<section class="panel" id="quest">
|
||||
@@ -1420,8 +1430,22 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's windows and screens</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="powerReport">
|
||||
<h2>Power & heat report</h2>
|
||||
<p class="sub">Observe battery, CPU, temperatures and available sensors without changing settings. One observation at a time, every five seconds, for at most five minutes.</p>
|
||||
<label for="powerPreset">Observation length</label>
|
||||
<select id="powerPreset"><option value="minute">1 minute · 12 samples</option><option value="five-minutes">5 minutes · 60 samples</option></select>
|
||||
<div class="row"><button class="action" id="powerStart">Start observation</button><button id="powerStop" disabled>Stop and keep samples</button></div>
|
||||
<progress id="powerProgress" max="12" value="0" aria-label="Samples received" hidden></progress>
|
||||
<p id="powerStatus" role="status" aria-live="polite">No observation yet.</p>
|
||||
<div class="row"><button data-power-format="html" disabled>Download HTML report</button><button data-power-format="json" disabled>Download JSON</button><button data-power-format="csv" disabled>Download CSV</button></div>
|
||||
<p id="powerSaveStatus" role="status" aria-live="polite"></p>
|
||||
<p class="hint">Download before starting again or quitting: only the latest observation is kept in memory. Downloads during collection are incomplete snapshots. Stop, disconnect or switching headsets keeps received samples, labeled with the original headset. A smoke session blocks sensor reads.</p>
|
||||
<p class="hint">Open the saved HTML in your browser; it works offline and includes JSON/CSV downloads. Unknown readings are never treated as zero. Rails overlap; do not add them. Sensor accuracy is unverified. Reports include sensor paths and OS builds: review before sharing.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
<div class="stats" id="vrMetrics"></div>
|
||||
@@ -1667,6 +1691,17 @@
|
||||
Saved spatial layouts are not available yet.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="storage" hidden>
|
||||
<div class="shelf-head"><h2>Game storage</h2><span class="spacer"></span><button class="small" id="storageRefresh">Refresh</button></div>
|
||||
<p class="sub">Read-only snapshot. Steam's recorded install sizes may be stale and exclude saves, shaders, downloads, Android and sideloaded content. No files are scanned recursively or changed.</p>
|
||||
<div id="storageResult" role="status" aria-live="polite">Open this tool to read headset storage.</div>
|
||||
<div class="card" style="margin-top:16px"><h3>Move a game with Steam</h3>
|
||||
<p>On the headset, open Steam → Settings → Storage, choose the source drive, select the game, then choose Move and a destination. If Storage is unavailable in the headset UI, use desktop Steam’s Settings → Storage.</p>
|
||||
<p class="sub">Steam offers destinations it supports. Let Steam finish and handle any errors, then Refresh here. Frame Control cannot verify a supported move API or its recovery behaviour, so it never moves or deletes game files.</p>
|
||||
<a href="https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move" target="_blank" rel="noopener">Steam's storage and moving guide ↗</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="backup" hidden>
|
||||
<div class="shelf-head"><h2>Back up the Frame</h2><span class="spacer"></span>
|
||||
<button class="small" id="backupFolder">Open backups folder</button></div>
|
||||
@@ -1706,9 +1741,9 @@
|
||||
<div class="shelf-head"><h2 id="mvTitle">Mac in the headset</h2><span class="tag" id="mvTag" hidden>Experimental</span><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="mvRefresh">Refresh</button></div>
|
||||
<div id="mvPc" hidden>
|
||||
<p class="hint" style="color:var(--warn)">Experimental. Shows this PC's windows and screens as panels in the
|
||||
headset, with laser and keyboard input. It builds and passes its tests on Windows in CI, but hasn't been tried on a real
|
||||
Windows desktop or with a headset yet: capture, hardware encoding and laser clicks may not work. Clicks and keys
|
||||
<p class="hint" style="color:var(--warn)">Experimental. Shows a whole PC screen as a panel in the
|
||||
headset, with laser and keyboard input. Individual windows are offered only when this build supports them.
|
||||
Tested in a Windows VM; physical PCs, hardware encoding and laser clicks remain unverified. Clicks and keys
|
||||
can't reach administrator apps or the sign-in screen.
|
||||
<a href="https://github.com/saphid/frame-control/blob/main/docs/pc-in-headset.md" target="_blank" rel="noopener noreferrer">What's verified</a></p>
|
||||
<label class="mv-opt"><input type="checkbox" id="mvPcOn"> Turn on PC in the headset
|
||||
@@ -1800,7 +1835,7 @@
|
||||
</main>
|
||||
</div>
|
||||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||||
<span class="sub">Android backups: drop the <code>.apk</code> and its <code>.obb</code> game data together.
|
||||
<span class="sub">Android backups: drop an APK with its OBB files, or one XAPK/APKS/APKM bundle (standalone APK only).
|
||||
Other files go to <code>~/Downloads</code>;
|
||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
|
||||
|
||||
@@ -1981,6 +2016,8 @@
|
||||
<input type="text" id="caLabel" maxlength="60" placeholder="Label, e.g. Home" aria-label="Label for the new address">
|
||||
<button class="small" type="submit">Add</button></form>
|
||||
<details class="conn-more" id="connMore"><summary>Connection steps</summary>
|
||||
<p class="sub">Trouble setting up or pairing? <a href="https://github.com/saphid/frame-control/blob/main/docs/frame-doctor.md" target="_blank" rel="noopener noreferrer">Read-only diagnostic guide</a>
|
||||
explains how to check a connection without sharing passwords or keys.</p>
|
||||
<ol class="stages" id="connStages"></ol>
|
||||
<h3>This computer</h3>
|
||||
<dl class="facts" id="connNet"></dl>
|
||||
@@ -2135,7 +2172,7 @@ const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
|
||||
// then are about the old one, so they're dropped rather than shown.
|
||||
let devGen = 0;
|
||||
// Answers that don't depend on the headset (this computer's data, the catalogue, jobs).
|
||||
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
|
||||
const HEADSET_FREE = /^\/api\/(power|devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
|
||||
async function api(path, body, device = window.connDevice) {
|
||||
const gen = devGen;
|
||||
const checkGeneration = () => {
|
||||
@@ -2422,12 +2459,11 @@ $("castBtn").onclick = () => {
|
||||
// ---- battery ----
|
||||
function battery(b, power) {
|
||||
const pct = b?.percent;
|
||||
const full = b?.status === "Full" || (!!power && pct >= 100);
|
||||
const full = b?.status === "Full";
|
||||
const charging = !full && b?.status === "Charging";
|
||||
// A weak charger can lose to the load: plugged in but still draining.
|
||||
// Topped up on a charger it reports Discharging at ~0 W: that's idle, not draining.
|
||||
const idle = !!power && b?.watts != null && Math.abs(b.watts) < 0.5;
|
||||
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
|
||||
const low = !charging && !full && pct != null && pct <= 15;
|
||||
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
|
||||
|
||||
@@ -2437,7 +2473,8 @@ function battery(b, power) {
|
||||
else if (charging) {
|
||||
label = b.watts > 0 ? `Charging · ${b.watts.toFixed(1)} W` : "Charging"; cls = "charging";
|
||||
if (b.timeToFull > 0) detail.push(`Full in ${dur(b.timeToFull)}`);
|
||||
} else if (!draining) { label = "Plugged in, not charging"; cls = "draining"; }
|
||||
} else if (b.status === "Not charging" || idle) { label = "Not charging"; cls = "draining"; }
|
||||
else if (b.status !== "Discharging") { label = "Charging state unavailable"; cls = "draining"; }
|
||||
else {
|
||||
label = low ? "Battery low" : power ? "Plugged in, still draining" : "On battery";
|
||||
cls = low ? "low" : "draining";
|
||||
@@ -3634,8 +3671,8 @@ function sendFiles(files, dirs = new Set()) {
|
||||
return fileSendQueue;
|
||||
}
|
||||
async function sendFilesOne(files, dirs, dev) {
|
||||
const androidFiles = files.filter((f, i) => !dirs.has(i) && /\.(apk|obb)$/i.test(f.name));
|
||||
const grouped = androidFiles.some(f => /\.obb$/i.test(f.name));
|
||||
const androidFiles = files.filter((f, i) => !dirs.has(i) && /\.(apk|obb|xapk|apks|apkm)$/i.test(f.name));
|
||||
const grouped = androidFiles.length > 1 || androidFiles.some(f => /\.(obb|xapk|apks|apkm)$/i.test(f.name));
|
||||
if (grouped && window.connDevice === dev) await importAndroidFiles(androidFiles, dev);
|
||||
for (const [i, f] of files.entries()) {
|
||||
if (window.connDevice !== dev) {
|
||||
@@ -4221,10 +4258,16 @@ async function loadSources() {
|
||||
} catch (e) { $("sourceError").textContent = e.message; }
|
||||
}
|
||||
// Keep installation confidence explicit, including apps that have not been checked yet.
|
||||
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] };
|
||||
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"], warning: ["Reported not working", "warning"], issues: ["Reported issues", "issues"] };
|
||||
function storeRuns(o) {
|
||||
const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown;
|
||||
return `<span class="runs ${cls}" title="${esc(o.verdict?.label || "Not yet checked on the Frame")}">${text}</span>`;
|
||||
const report=o.compat_report, extra=o.verdict?.tone==='blocked' && ['no','maybe'].includes(report?.verdict);
|
||||
return `<span class="store-compat-badges"><span class="runs ${cls}" title="${esc(o.verdict?.label || "Not yet checked on the Frame")}">${text}</span>${extra ? `<span class="runs warning">${report.verdict==='no' ? 'Reported not working' : 'Reported issues'}</span>` : ''}</span>`;
|
||||
}
|
||||
function storePickEligible(o) { return o.verdict?.tone !== 'blocked' && o.compat_report?.verdict !== 'no'; }
|
||||
function storeReportDetails(o) {
|
||||
const report=o.compat_report;
|
||||
return report ? `<div class="detail-verdict ${report.verdict==='works' ? 'works' : 'warning'}"><strong>Saved compatibility reports</strong>${report.lines.map(line=>`<p>${esc(line)}</p>`).join('')}<p>Reports can cover other versions or runtimes. You can still try installing unless this build has a compatibility blocker.</p></div>` : '';
|
||||
}
|
||||
const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
|
||||
function storeCard(app) {
|
||||
@@ -4252,7 +4295,7 @@ function renderStore() {
|
||||
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
|
||||
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
|
||||
// Home: one featured VR app, then a shelf per category; each app appears once, blocked ones not at all.
|
||||
const ok=apps.filter(a=>a.offers[0].verdict?.tone !== 'blocked'), shown=new Set();
|
||||
const ok=apps.filter(a=>storePickEligible(a.offers[0])), shown=new Set();
|
||||
const day=Math.floor(Date.now()/864e5), heroes=ok.filter(a=>a.offers[0].vr && a.offers[0].artwork?.banner);
|
||||
const featured=heroes[day % (heroes.length || 1)] || ok.find(a=>a.offers[0].artwork?.banner) || ok[0];
|
||||
if (!featured) { $("searchGrid").innerHTML=storeEmpty(); return; } // nothing here runs on the Frame
|
||||
@@ -4308,7 +4351,7 @@ function renderAppDetail() {
|
||||
function renderAppDetailBody() {
|
||||
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
|
||||
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
|
||||
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||||
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p>${storeReportDetails(o)}<button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||||
updateInstall();
|
||||
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
|
||||
}
|
||||
@@ -5425,6 +5468,96 @@ $("panelList").onclick = e => {
|
||||
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
|
||||
};
|
||||
|
||||
// ---- Read-only power observation ----
|
||||
let powerState = null, powerTimer = null, powerSeq = 0;
|
||||
function renderPower(s) {
|
||||
powerState = s;
|
||||
$('powerStart').disabled = !!s.running;
|
||||
$('powerPreset').disabled = !!s.running;
|
||||
$('powerStop').disabled = !s.running;
|
||||
$('powerProgress').max = s.requested || 1;
|
||||
$('powerProgress').value = s.received;
|
||||
$('powerProgress').hidden = !s.id;
|
||||
const identity = s.headset ? `Headset: ${s.headset.alias}. ` : '';
|
||||
$('powerStatus').textContent = s.id ? identity + `${s.received} of ${s.requested} samples received. ` +
|
||||
(s.running ? (s.stopped?.startsWith('Stopped') ? 'Stopping…' : 'Observing…') : s.stopped ? `Incomplete: ${s.stopped}` : 'Completed.') : 'No observation yet.';
|
||||
document.querySelectorAll('[data-power-format]').forEach(b => b.disabled = !s.id);
|
||||
}
|
||||
async function loadPower() {
|
||||
const seq = ++powerSeq;
|
||||
clearTimeout(powerTimer);
|
||||
try {
|
||||
const s = await api('/api/power');
|
||||
if (seq !== powerSeq) return;
|
||||
renderPower(s);
|
||||
if (s.running && location.hash === '#powerReport') powerTimer = setTimeout(loadPower, 1000);
|
||||
} catch (e) {
|
||||
if (seq !== powerSeq) return;
|
||||
$('powerStatus').textContent = `Observation status unavailable: ${e.message}. Received samples remain on the local server while it is running.`;
|
||||
if (location.hash === '#powerReport') powerTimer = setTimeout(loadPower, 3000);
|
||||
}
|
||||
}
|
||||
$('powerStart').onclick = async () => {
|
||||
$('powerStart').disabled = true;
|
||||
try {
|
||||
renderPower(await api('/api/power', {action:'start', preset:$('powerPreset').value}));
|
||||
loadPower();
|
||||
} catch (e) { $('powerStatus').textContent = e.message; $('powerStart').disabled = !!powerState?.running; }
|
||||
};
|
||||
$('powerStop').onclick = async () => {
|
||||
if (!powerState?.id) return;
|
||||
$('powerStop').disabled = true;
|
||||
try { renderPower(await api('/api/power', {action:'stop', id:powerState.id})); loadPower(); }
|
||||
catch (e) { $('powerStatus').textContent = e.message; $('powerStop').disabled = false; }
|
||||
};
|
||||
document.querySelectorAll('[data-power-format]').forEach(b => b.onclick = async () => {
|
||||
if (!powerState?.id) return;
|
||||
const id = powerState.id, format = b.dataset.powerFormat;
|
||||
b.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/power/report?id=${encodeURIComponent(id)}&format=${format}`, {headers:{'X-Frame-UI':UI_KEY}});
|
||||
if (!r.ok) throw new Error((await r.json()).error || `HTTP ${r.status}`);
|
||||
const blob = await r.blob(), a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = `frame-power-${id}.${format}`; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
$('powerSaveStatus').textContent = 'Download requested. Open the saved HTML in your browser to view it offline; it includes JSON and CSV downloads.';
|
||||
} catch (e) { $('powerSaveStatus').textContent = `Download failed: ${e.message}`; }
|
||||
finally { b.disabled = !powerState?.id; }
|
||||
});
|
||||
|
||||
// ---- Read-only game storage ----
|
||||
let storageSeq = 0;
|
||||
const storageState = s => ({'permission-denied':'Permission denied', missing:'Missing or disconnected',
|
||||
unavailable:'Unavailable', 'invalid-metadata':'Invalid Steam metadata', partial:'Partial inventory', ok:'Available'}[s] || 'Unknown');
|
||||
const storageBytes = n => typeof n === 'number' && Number.isFinite(n) && n >= 0 ? sizeText(n) : 'Unknown';
|
||||
function storageCapacity(c) {
|
||||
if (!c || c.state !== 'ok') return esc(storageState(c?.state));
|
||||
return `${storageBytes(c.free)} free / ${storageBytes(c.total)} total · ${storageBytes(c.used)} used`;
|
||||
}
|
||||
function renderStorage(r) {
|
||||
return `<div class="card"><h3>Headset user filesystem</h3><p>${storageCapacity(r.home)}</p>
|
||||
<p class="sub">Filesystem space available to your user; excludes separate system partitions. Reserved space can make used + free less than total.</p></div>` +
|
||||
(r.discovery !== 'ok' ? `<p class="sub">Library discovery: ${esc(storageState(r.discovery))}. Additional libraries may be absent from this view.</p>` : '') +
|
||||
r.libraries.map(lib => `<div class="card" style="margin-top:16px"><h3 style="overflow-wrap:anywhere">${esc(lib.path)}</h3>
|
||||
<p>${storageCapacity(lib.capacity)}</p><p class="sub">Filesystem containing this library path; paths on the same filesystem share this space. Capacities must not be added together.</p>
|
||||
<p>${esc(storageState(lib.state))} · Known games/apps: ${storageBytes(lib.gameBytes)} · Known runtimes / tools (by name): ${storageBytes(lib.toolBytes)}</p>
|
||||
<p class="sub">Known recorded content: ${storageBytes(lib.recordedBytes)} · ${lib.unknownSizes} unknown sizes · ${lib.issues.length} unreadable / invalid manifests. Other usage is unmeasured.</p>
|
||||
<div class="list">${lib.apps.map(app => `<div class="item"><div class="grow">${esc(app.name)}<div class="sub">Steam ${esc(app.appid)} · ${app.kind === 'tool' ? 'Runtime / tool' : 'Game / app'}</div></div><span>${storageBytes(app.bytes)}</span></div>`).join('') || '<p class="sub">No readable Steam manifests found; this does not prove the library is empty.</p>'}</div>
|
||||
${lib.issues.map(i => `<p class="sub">${esc(i.file)}: ${esc(storageState(i.state))}</p>`).join('')}</div>`).join('') +
|
||||
`<p class="sub">Snapshot received ${esc(new Date().toLocaleTimeString())}. Refresh after installs, downloads or moves.</p>`;
|
||||
}
|
||||
async function loadStorage() {
|
||||
const seq = ++storageSeq, gen = devGen;
|
||||
$('storageResult').textContent = 'Reading headset storage…';
|
||||
try {
|
||||
const result = await api('/api/storage');
|
||||
if (seq === storageSeq && gen === devGen) $('storageResult').innerHTML = renderStorage(result);
|
||||
} catch (error) {
|
||||
if (seq === storageSeq && gen === devGen) $('storageResult').textContent = `Storage unavailable: ${error.message}`;
|
||||
}
|
||||
}
|
||||
$('storageRefresh').onclick = loadStorage;
|
||||
|
||||
// ---- Backup and recovery kit (ui/frame_backup.py) ----
|
||||
const BACKUP_KIND = {saves: "Game saves", settings: "Settings", android: "Android app data"};
|
||||
function sizeText(n) {
|
||||
@@ -5622,7 +5755,7 @@ async function loadMacView(restart) {
|
||||
$("mvWinCount").textContent = wins.length ? `${wins.length}` : "";
|
||||
$("mvWindows").innerHTML = !s.screen ? `<div class="sub">Windows appear here once Screen Recording is allowed.</div>`
|
||||
: wins.length ? wins.map(w => mvRow(w.src, w.title || w.app, `${w.title ? w.app + " · " : ""}${w.w}×${w.h}`, w.w, w.h, liveAs(w.src), byStream(liveAs(w.src)))).join("")
|
||||
: `<div class="sub">No windows open on this ${pc ? "PC" : "Mac's current desktop"}.</div>`;
|
||||
: `<div class="sub">${pc && s.windowCaptureReason ? esc(s.windowCaptureReason) : `No windows open on this ${pc ? "PC" : "Mac's current desktop"}.`}</div>`;
|
||||
// Keep the numbers fresh while something is in the headset and the card is on screen.
|
||||
clearTimeout(mvTimer);
|
||||
const tick = () => { mvTimer = document.hidden || !box.offsetParent ? setTimeout(tick, 3000) : (loadMacView(), 0); };
|
||||
@@ -5699,10 +5832,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", backup:"tools"};
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", powerReport:"tools", backup:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", backup:"Backup & recovery"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", powerReport:"Power & heat report", backup:"Backup & recovery"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
let page = "apps";
|
||||
// Reuse the existing live lists and form handlers in their new destinations.
|
||||
@@ -5809,6 +5942,8 @@ function showPage(e) {
|
||||
if (tool === 'macview') loadMacView();
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'backup') loadBackups();
|
||||
if (tool === 'storage') loadStorage();
|
||||
if (tool === 'powerReport') loadPower();
|
||||
if (tool === 'androidSettings') {
|
||||
$('repNew').hidden = false; $('androidSettings').prepend($('repNew')); loadDisplays();
|
||||
}
|
||||
@@ -6565,7 +6700,7 @@ function renderPill() {
|
||||
}
|
||||
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(word)}` +
|
||||
`${via ? `<span class="via"> · ${esc(via)}</span>` : ""}</span></span>`;
|
||||
$("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`;
|
||||
$("conn").title = `${dev}: ${detail.replace(/[.\s]+$/, "")}. Click to see or change its addresses.`;
|
||||
return true;
|
||||
}
|
||||
window.connPill = renderPill;
|
||||
@@ -6787,6 +6922,7 @@ function onConnection(s) {
|
||||
androidApps = []; androidSeq++; androidPending.clear(); $('installedDetail').close(); resetShots(); resetClips();
|
||||
renderHome();
|
||||
titlesSeq++; disp.seq++; runningSeq++; // answers to loads already on their way are ignored
|
||||
$('storageResult').textContent = 'Headset changed. Open Game storage or Refresh to read this headset.';
|
||||
runningGames = []; $("nowRunning").hidden = true; $("runningList").innerHTML = "";
|
||||
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
||||
// Confirmations still open were checked against the other headset.
|
||||
|
||||
+55
-2
@@ -64,6 +64,7 @@ import frame_media # noqa: E402
|
||||
import frame_panels # noqa: E402
|
||||
import frame_quest # noqa: E402
|
||||
import frame_meta # noqa: E402
|
||||
import frame_power # noqa: E402
|
||||
import frame_report # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_telemetry # noqa: E402
|
||||
@@ -109,6 +110,7 @@ HOST_OPTS = []
|
||||
frame_android.SSH_OPTS = SSH[1:]
|
||||
|
||||
|
||||
_power = frame_power.Observation()
|
||||
_route_lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -120,6 +122,7 @@ def route(alias, host_opts):
|
||||
with _route_lock:
|
||||
moved = alias != FRAME
|
||||
if moved:
|
||||
_power.stop('Headset switched; received samples retained for the previous headset')
|
||||
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
|
||||
# Invalidate launches before publishing the new route. send/stop share
|
||||
# a writer lock, so an old process cannot receive events after this.
|
||||
@@ -1990,7 +1993,15 @@ def android_backup(body):
|
||||
entry = _android_backup(token)
|
||||
if action == 'inspect':
|
||||
try:
|
||||
entry['plan'] = frame_android_import.inspect(entry['paths'], discover=not entry['dirs'])
|
||||
paths = entry['paths']
|
||||
bundles = [p for p in paths if Path(p).suffix.lower() in frame_android_import.ARCHIVES]
|
||||
if bundles:
|
||||
if len(paths) != 1:
|
||||
raise frame_android.FrameError('Choose one complete Android bundle, or loose APK/OBB files together.')
|
||||
folder = tempfile.mkdtemp(prefix=f'{ANDROID_BACKUP_TMP}{os.getpid()}-')
|
||||
entry['dirs'].append(folder)
|
||||
paths = frame_android_import.unpack_bundle(bundles[0], folder)
|
||||
entry['plan'] = frame_android_import.inspect(paths, discover=not entry['dirs'])
|
||||
except (frame_android.FrameError, OSError) as error:
|
||||
with _android_backups_lock:
|
||||
_android_backups.pop(token, None)
|
||||
@@ -3001,7 +3012,28 @@ def source_manage(body):
|
||||
raise Failure(str(e), 400)
|
||||
|
||||
|
||||
def power_action(body):
|
||||
action = body.get('action')
|
||||
if LOCAL:
|
||||
raise Failure('Power observations are available on the desktop app', 400)
|
||||
if action == 'stop':
|
||||
# ID prevents an old page stopping a replacement observation.
|
||||
with _power.lock:
|
||||
if body.get('id') != _power.id:
|
||||
raise Failure('This observation was replaced; refresh', 409)
|
||||
return _power.stop()
|
||||
if action != 'start':
|
||||
raise Failure('Unknown observation action', 400)
|
||||
with _route_lock:
|
||||
headset = {'alias': FRAME, 'deviceId': LINK.active_device()['id'] if LINK else None}
|
||||
try:
|
||||
return _power.start(body.get('preset'), tuple([*SSH, FRAME]), headset)
|
||||
except ValueError as exc:
|
||||
raise Failure(str(exc), 409) from exc
|
||||
|
||||
|
||||
POST = {
|
||||
"/api/power": power_action,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
@@ -3185,6 +3217,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif path == "/store":
|
||||
# The Android store as a headset panel (scripts/store-on-frame.sh).
|
||||
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/power":
|
||||
self.send_json(_power.status())
|
||||
elif path == "/api/power/report":
|
||||
args = parse_qs(url.query)
|
||||
try:
|
||||
format = args.get('format', ['html'])[0]
|
||||
report_id = args.get('id', [''])[0]
|
||||
data, mime = _power.export(report_id, format)
|
||||
except ValueError as exc:
|
||||
raise Failure(str(exc), 409) from exc
|
||||
self.send_bytes(data.encode('utf-8'), mime,
|
||||
headers=[('Content-Disposition', f'attachment; filename="frame-power-{report_id}.{format}"')])
|
||||
elif path == "/api/settings/artwork":
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
@@ -3293,6 +3337,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||
elif path == "/api/status":
|
||||
self.send_json(status({}))
|
||||
elif path == "/api/storage":
|
||||
with working(self.headers.get("X-Frame-Device")):
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_storage.py").read_text(), timeout=20)))
|
||||
elif path == "/api/running":
|
||||
self.send_json(running_games())
|
||||
elif path == "/api/steam/owned":
|
||||
@@ -3357,6 +3404,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
if not isinstance(body, dict):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
if path == '/api/power' and body.get('action') == 'start' and LINK and not meant:
|
||||
raise Failure('Observation start needs the intended X-Frame-Device headset', 400)
|
||||
# Not headset work: switching headsets mustn't wait for (or refuse) these.
|
||||
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
|
||||
result = handler(body)
|
||||
@@ -3474,7 +3523,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
backup = None
|
||||
if mode == 'android-backup':
|
||||
backup = _android_backup(self.headers.get('X-Android-Backup'))
|
||||
if (not name.lower().endswith(('.apk', '.obb')) or '/' in name or '\\' in name
|
||||
if (not name.lower().endswith(('.apk', '.obb', '.xapk', '.apks', '.apkm')) or '/' in name or '\\' in name
|
||||
or len(backup['paths']) >= 100 or any(Path(p).name == name for p in backup['paths'])):
|
||||
raise Failure('Choose one APK and its game data, with no duplicate filenames.', 400)
|
||||
prefix = f'{ANDROID_BACKUP_TMP}{os.getpid()}-' if backup is not None else 'frame-ui-'
|
||||
@@ -3590,6 +3639,9 @@ def main():
|
||||
args = ap.parse_args()
|
||||
httpd = LoopbackServer(("127.0.0.1", args.port), Handler)
|
||||
sweep_tmp()
|
||||
if os.environ.get('FRAME_COMPAT_SEARCH_REPORTS') != '0':
|
||||
apk_search.compat_reports = apk_search.ReportCache(
|
||||
frame_compat_db.load, frame_catalog.reports.verdict, ttl=frame_compat_db.TTL).read
|
||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||
frame_telemetry.start()
|
||||
frame_contact.start()
|
||||
@@ -3627,6 +3679,7 @@ def main():
|
||||
# mid-cleanup would abort it and leave the SSH master running.
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
_power.shutdown()
|
||||
_stop_sync.set()
|
||||
webinstall_shutdown()
|
||||
macview.shutdown() # close the headset's viewers before the agent goes
|
||||
|
||||
Reference in new issue
Block a user