Compare commits

..
Author SHA1 Message Date
SAPHID 7dc7dfa9ef Bump Frame Control to 0.4.15 2026-10-24 06:59:21 +00:00
Alex SouthwellandSAPHID a148e02d39 Add bounded read-only power and heat reports to desktop Tools (#144)
* Add bounded desktop power and heat observations with offline downloads

* Fix desktop power tests on Python 3.9

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:58:19 +00:00
Alex SouthwellandSAPHID 0fb9174c23 Fix Return to Game Mode detection in full Plasma (#143)
* Fix Frame session detection for full Plasma Desktop Mode

* Document real Frame Desktop Mode return verification

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:46:17 +00:00
Alex SouthwellandSAPHID c7489a88eb Bump Frame Control to 0.4.14 (#141)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:09:59 +00:00
Alex SouthwellandSAPHID be74850a8e Add accessible offline power sampler exports with partial results (#140)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:59:53 +00:00
Alex SouthwellandSAPHID dc21428f85 Add guarded read-only doctor plans and before/after reports (#139)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:51:49 +00:00
Alex SouthwellandSAPHID fa43c83b06 Recover release uploads from untagged drafts (#138)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:35:49 +00:00
Alex Southwell 1d4c57d648 Merge pull request #137 from saphid/release/0.4.13
Bump Frame Control to 0.4.13
2026-10-24 05:11:37 +00:00
SAPHID c6215712fe Bump Frame Control to 0.4.13 2026-10-24 05:02:27 +00:00
Alex SouthwellandSAPHID cd7ab2d9f7 Import standalone APK/OBB bundles through the current backup flow (#136)
* feat(android): inspect standalone APK and OBB bundles safely

* test: decode Android page fixture as UTF-8

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:01:32 +00:00
Alex SouthwellandSAPHID 4b68971383 Add read-only power and heat sampler for issue #52 (#135)
* Add read-only power and heat sampler for issue 52

* Make power sampler IIO fixture portable on Windows

* test: normalize Windows fixture paths for power sampler

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 04:43:47 +00:00
Alex Southwell 54c001d537 Merge pull request #134 from saphid/release/0.4.12
Bump Frame Control to 0.4.12
2026-10-24 04:29:09 +00:00
SAPHID 5ce5c023b6 Merge remote-tracking branch 'origin/main' into release/0.4.12 2026-10-24 04:20:33 +00:00
Alex Southwell 709fef0dbd Merge pull request #133 from saphid/t3/android-compat-verdict-search
Android store: show compatibility reports without blocking search
2026-10-24 04:18:57 +00:00
SAPHID 49408dc171 Bump Frame Control to 0.4.12 2026-10-24 04:12:30 +00:00
SAPHID c728e64fbd Merge remote-tracking branch 'origin/main' into coord/resolve-133
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:11:32 +00:00
Alex Southwell 0626c52e12 Merge pull request #132 from saphid/t3/fix-log-scrubber-edge-cases
Fix display-name overlaps in optional log redaction
2026-10-24 04:10:58 +00:00
SAPHID b9b4a00485 Merge remote-tracking branch 'origin/main' into coord/resolve-132
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:00:26 +00:00
Alex Southwell eb125b59c7 Merge pull request #131 from saphid/fix/issue-87-recovery-polish
Fix remaining host-key recovery UI clipping and punctuation
2026-10-24 03:59:21 +00:00
SAPHID 7e53fe32cb Show Android compatibility reports without blocking search 2026-10-24 03:58:37 +00:00
SAPHID ebae409632 Fix overlapping display-name redaction in optional logs 2026-10-24 03:56:45 +00:00
SAPHID 08cb267ce4 Fix remaining host-key recovery clipping and tooltip punctuation 2026-10-24 03:52:38 +00:00
50 changed files with 3468 additions and 149 deletions

No files matched your search

+28 -4
View File
@@ -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/') }}
+49
View File
@@ -2,6 +2,55 @@
## Unreleased
- 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
+4
View File
@@ -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>
@@ -233,6 +236,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [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 |
+4
View File
@@ -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",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.11",
"version": "0.4.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.11",
"version": "0.4.15",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.11",
"version": "0.4.15",
"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"
]
},
{
+75
View File
@@ -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 -31
View File
@@ -12,15 +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)).
On 2026-10-24 a read-only session check on the same build again reported Game
Mode (`gamescope=true`, `plasma=false`, `nested_desktop=false`) and the
`steamos-session-select` command was present. Opening the nested desktop for a
clipboard test did not enter full Desktop Mode. A full session switch was not
attempted because it restarts the headset session and a reliable return path
was not established; the **Return to Game Mode** branch for a real full
Desktop Mode session remains unverified.
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
@@ -118,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
@@ -151,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
@@ -172,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`.
+6
View File
@@ -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,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"
]
}
+149
View File
@@ -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."
]
}
+61 -5
View File
@@ -58,6 +58,44 @@ 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
@@ -89,15 +127,16 @@ 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.
It does not implement issue #34's automatic fixes, before/after repair runs,
or full recovery automation. Review build-specific recovery instructions with
the person using the headset; a repair can remove data.
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 report. Nothing needs restoring after its checks. Manual/device tests must
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
@@ -107,7 +146,9 @@ 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 saves a report only if you choose it.
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
@@ -119,3 +160,18 @@ 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.
+222
View File
@@ -0,0 +1,222 @@
# 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. 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
View File
@@ -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
+4 -1
View File
@@ -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.
+26 -2
View File
@@ -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
```
@@ -54,8 +60,11 @@ 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 fake's doctor-only `systemctl show` responses assume stable restart
counters and inactive keep-awake; unsupported queries fail. These tests do not
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
@@ -437,3 +446,18 @@ 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.
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Read-only setup and health diagnostics; see docs/frame-doctor.md.
# 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" "$@"
+5
View File
@@ -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" "$@"
+124 -5
View File
@@ -4,6 +4,7 @@ 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
@@ -215,16 +216,100 @@ def report(data):
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. No report is saved.')
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
@@ -234,19 +319,42 @@ def main(argv=None):
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 isinstance(data, dict):
if not valid_data(data):
raise ValueError()
except 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.')
return report(data)
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
@@ -268,7 +376,18 @@ def main(argv=None):
def messages():
import ast
tree = ast.parse(Path(__file__).read_text())
return {node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str)}
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__':
+54
View File
@@ -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>'''
+355
View File
@@ -0,0 +1,355 @@
"""Bounded read-only Frame measurements; no device files are written.
The host sends this and frame_status.py over one trusted SSH connection. Only
battery() and CPU tick helpers are reused: no OpenVR, services or analytics.
"""
import argparse
import csv
import glob
import io
import json
import math
from pathlib import Path
import queue
import subprocess
import sys
import tempfile
import threading
import time
LOCK = Path('/tmp/frame-test.lock')
UNAVAILABLE = 'unavailable: not reported, unreadable or invalid'
def read(path):
try:
return Path(path).read_text().strip()
except (OSError, UnicodeError):
return None
def number(path, scale=1, minimum=None, maximum=None):
try:
value = int(read(path)) * scale
if not math.isfinite(value):
return None
if minimum is not None and value < minimum or maximum is not None and value > maximum:
return None
return round(value, 6)
except (TypeError, ValueError, OverflowError):
return None
def snapshot(status, before):
if LOCK.exists():
raise RuntimeError('Smoke session active (/tmp/frame-test.lock); no sensors read.')
ticks = status.cpu_ticks()
readings = {}
def add(key, value, unit, source):
readings[key] = {'value': value, 'unit': unit, 'source': source,
'state': 'available' if value is not None else UNAVAILABLE}
battery = status.battery() or {}
for field, unit in (('percent', '%'), ('status', 'state'), ('watts', 'W'),
('tempC', 'C')):
value = battery.get(field)
if field == 'status' and value not in ('Charging', 'Discharging', 'Not charging', 'Full'):
value = None
add('battery.' + field, value, unit, battery.get('source'))
add('cpu.percent', status.cpu_percent(before, ticks), '%', '/proc/stat')
if before is None:
readings['cpu.percent']['state'] = 'unavailable: needs two valid CPU tick samples'
add('gpu.clock', number('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6, 0),
'MHz', '/sys/class/devfreq/3d00000.gpu/cur_freq')
# Discover by driver name/label, never assume a hwmon index. Keep source
# paths and channel numbers to distinguish duplicate labels.
for node in sorted(glob.glob('/sys/class/hwmon/hwmon*')):
name = read(node + '/name') or Path(node).name
for path in sorted(glob.glob(node + '/power*_input')):
channel = Path(path).name[:-6]
label = read(node + '/' + channel + '_label') or channel
add(f'rail.{name}.{label}.{channel}', number(path, 1e-6, 0), 'W', path)
for path in sorted(glob.glob(node + '/fan*_input')):
add(f'fan.{name}.{Path(path).stem}', number(path, minimum=0), 'RPM', path)
# pwm1 on Frame's driver is percent; leave raw to avoid assuming the
# generic hwmon 0..255 scale on other drivers or later firmware.
if name == 'slg4ax46073v':
add('fan.pwmRaw', number(node + '/pwm1', minimum=0), 'raw', node + '/pwm1')
for node in sorted(glob.glob('/sys/class/thermal/thermal_zone*')):
name = read(node + '/type') or Path(node).name
add(f'thermal.{name}.{Path(node).name}', number(node + '/temp', 0.001, -273.15, 200),
'C', node + '/temp')
for node in sorted(glob.glob('/sys/bus/iio/devices/iio:device*')):
for path in sorted(glob.glob(node + '/in_temp_*_input')):
add(f'board.{Path(node).name}.{Path(path).stem}', number(path, 0.001, -273.15, 200),
'C', path)
for field in ('iin', 'ichg'):
path = node + '/in_current_pm8550b_' + field + '_fb_input'
# Known names, but no fixed IIO index; absent channels are covered
# by group availability below.
if Path(path).exists():
add(f'charger.{Path(node).name}.{field}', number(path, 1e-6), 'A', path)
if read(node + '/name') == 'vcnl4000':
add('proximity.raw', number(node + '/in_proximity_raw', minimum=0),
'raw', node + '/in_proximity_raw')
unavailable = [group for group in ('rail', 'fan', 'thermal', 'board', 'charger', 'proximity')
if not any(k.startswith(group + '.') and v['value'] is not None
for k, v in readings.items())]
os_info = dict(line.split('=', 1) for line in (read('/etc/os-release') or '').splitlines()
if '=' in line)
return {'time': time.time(), 'osBuild': os_info.get('BUILD_ID', '').strip('"') or None,
'readings': readings, 'unavailableGroups': unavailable}, ticks
def summarize(samples):
result = {}
keys = sorted({key for sample in samples for key in sample['readings']})
for key in keys:
rows = [s['readings'][key] for s in samples if key in s['readings']]
values = [r['value'] for r in rows if r['value'] is not None]
item = {'unit': rows[0]['unit'], 'sources': sorted({r['source'] for r in rows if r['source']}),
'available': len(values), 'unavailable': len(samples) - len(values)}
if values and all(isinstance(v, (int, float)) for v in values):
item.update(min=min(values), max=max(values), mean=round(sum(values) / len(values), 6),
first=values[0], last=values[-1])
else:
item['states'] = sorted(set(values))
result[key] = item
return result
def collect(status, count, interval, emit=None):
samples, before = [], None
started = time.monotonic()
stopped = None
for index in range(count):
if index:
time.sleep(max(0, started + index * interval - time.monotonic()))
try:
sample, before = snapshot(status, before)
except RuntimeError as exc:
stopped = str(exc)
break
sample['elapsedSeconds'] = round(time.monotonic() - started, 6)
samples.append(sample)
if emit:
emit({'sample': sample})
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
'stopped': stopped, 'samples': samples, 'summary': summarize(samples)}
def csv_samples(report):
output = io.StringIO(newline='')
writer = csv.writer(output)
writer.writerow(('time', 'elapsedSeconds', 'metric', 'value', 'unit', 'source', 'state'))
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(('', '', key, value, '', report['headset']['alias'],
'incomplete' if report['stopped'] else 'completed'))
for sample in report['samples']:
for key, row in sample['readings'].items():
# csv quoting does not prevent spreadsheet formulas in labels.
def cell(value):
return "'" + value if isinstance(value, str) and value.startswith(('=', '+', '-', '@')) else value
writer.writerow([cell(v) for v in (sample['time'], sample['elapsedSeconds'], key,
row['value'], row['unit'], row['source'], row['state'])])
for group in sample['unavailableGroups']:
writer.writerow((sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE))
return output.getvalue()
def parser():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--count', type=int, default=12, help='samples, 1..720 (default 12)')
ap.add_argument('--interval', type=float, default=5, help='seconds, 1..60 (default 5)')
ap.add_argument('--csv', action='store_true', help='CSV samples on stdout; JSON summary on stderr')
ap.add_argument('--html', type=Path, metavar='FILE', help='save an offline accessible report with JSON/CSV downloads; Ctrl+C retains received samples (max 10 minutes); never overwrites')
ap.add_argument('--probe', action='store_true', help=argparse.SUPPRESS)
ap.add_argument('--stream', action='store_true', help=argparse.SUPPRESS)
return ap
def remote_payload():
root = Path(__file__).resolve().parents[1]
source = (root / 'ui/frame_status.py').read_text()
# Executing as a module prevents frame_status.main() from probing unrelated
# status. Nothing is installed, cached or logged on the headset.
return ('import types, sys\n'
'status = types.ModuleType("frame_status")\n'
f'exec(compile({source!r}, "frame_status.py", "exec"), status.__dict__)\n'
'sys.modules["frame_status"] = status\n' + Path(__file__).read_text())
def stream_report(command, payload, count, interval, 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())
+18 -2
View File
@@ -1,6 +1,8 @@
"""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
@@ -9,9 +11,9 @@ harness.require()
class Doctor(harness.FrameTestCase):
def doctor(self):
def doctor(self, *args):
return subprocess.run([sys.executable, str(ROOT / 'scripts/frame_doctor.py'),
'--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST],
'--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST, *args],
capture_output=True, text=True, timeout=120)
def test_disk_full(self):
@@ -41,3 +43,17 @@ class Doctor(harness.FrameTestCase):
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)
+15 -2
View File
@@ -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))
+30 -3
View File
@@ -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 &lt;unsafe&gt; 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");
+77
View File
@@ -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});}
});
+1
View File
@@ -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
+152
View File
@@ -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()
+3
View File
@@ -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()
+169
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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)
+87 -1
View File
@@ -4,6 +4,7 @@ import io
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch, Mock
@@ -25,7 +26,7 @@ class FrameDoctor(unittest.TestCase):
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([])
code = doctor.main(kwargs.get('argv', []))
return code, output.getvalue(), run
def test_unreachable_is_actionable_without_exposing_stderr(self):
@@ -105,6 +106,91 @@ class FrameDoctor(unittest.TestCase):
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
+4 -1
View File
@@ -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):
+151
View File
@@ -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()
+1 -1
View File
@@ -9,7 +9,7 @@ import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadTitles', 'showAndroidTab',
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
RUN = r'''
+256
View File
@@ -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)
+168
View File
@@ -0,0 +1,168 @@
"""Offline HTML exports and streamed transport, with real local child cleanup."""
import sandbox # noqa: F401
import base64
import contextlib
from html.parser import HTMLParser
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'scripts'))
import frame_power_sample as power
import frame_power_report as page
SAMPLE = {'time': 1, 'elapsedSeconds': 0, 'osBuild': 'test',
'readings': {'battery.watts': {'value': 0, 'unit': 'W', 'source': '/sys/battery', 'state': 'available'},
'thermal.<script>alert(1)</script>': {'value': None, 'unit': 'C', 'source': '/sys/temp', 'state': power.UNAVAILABLE}},
'unavailableGroups': ['proximity']}
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.links = {}
self.scripts = 0
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'script':
self.scripts += 1
if tag == 'a':
self.links[attrs['download']] = base64.b64decode(attrs['href'].split(',', 1)[1]).decode()
class Report(unittest.TestCase):
def collect(self, program, count=1):
with contextlib.redirect_stderr(io.StringIO()):
return power.stream_report([sys.executable, '-c', program], '', count, 1)
def test_completed_stream_preserves_zero_unknown_units_and_downloads(self):
event = json.dumps({'sample': SAMPLE})
program = f'import sys; sys.stdin.read(); print({event!r}); print(\'{{"finished": {{"stopped": null}}}}\')'
report = self.collect(program)
self.assertIsNone(report['stopped'])
self.assertEqual(report['samples'], [SAMPLE])
markup = page.render(report, power.csv_samples(report))
parser = Links()
parser.feed(markup)
self.assertEqual(parser.scripts, 0)
self.assertIn('&lt;script&gt;', 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'], [])
+209
View File
@@ -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(), '')
+66 -2
View File
@@ -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()
+56 -5
View File
@@ -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()
+142 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+45 -9
View File
@@ -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
View File
@@ -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):
+111
View File
@@ -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)
+11 -9
View File
@@ -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]
+97 -15
View File
@@ -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; }
@@ -1363,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">
@@ -1426,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 screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#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 &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div class="grid-3" id="toolWorkspace">
<section class="panel" id="powerReport">
<h2>Power &amp; 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>
@@ -1817,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>
@@ -2154,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 = () => {
@@ -3653,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) {
@@ -4240,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) {
@@ -4271,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
@@ -4327,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();
}
@@ -5444,6 +5468,63 @@ $("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',
@@ -5751,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", storage:"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", storage:"Game storage", 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.
@@ -5862,6 +5943,7 @@ function showPage(e) {
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();
}
@@ -6618,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;
+52 -2
View File
@@ -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":
@@ -3360,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)
@@ -3477,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-'
@@ -3593,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()
@@ -3630,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