mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 09:01:40 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a148e02d39 | ||
|
|
0fb9174c23 | ||
|
|
c7489a88eb | ||
|
|
be74850a8e | ||
|
|
dc21428f85 | ||
|
|
fa43c83b06 |
No files matched your search
@@ -7,7 +7,7 @@ body:
|
||||
value: |
|
||||
**Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md).
|
||||
|
||||
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. The [website feedback form](https://framecontrol.fun/feedback/) skips that queue.
|
||||
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. The [website feedback form](https://frame-control.pages.dev/feedback/) skips that queue.
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feedback form (no GitHub account needed, skips the queue)
|
||||
url: https://framecontrol.fun/feedback/
|
||||
url: https://frame-control.pages.dev/feedback/
|
||||
about: Bugs, ideas and questions from the website become issues here without being auto-closed.
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
'',
|
||||
`Maintainers review auto-closed issues regularly and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
|
||||
'',
|
||||
'Just want to report a bug or share an idea? The [website feedback form](https://framecontrol.fun/feedback/) skips this queue.',
|
||||
'Just want to report a bug or share an idea? The [website feedback form](https://frame-control.pages.dev/feedback/) skips this queue.',
|
||||
'',
|
||||
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.',
|
||||
'',
|
||||
|
||||
@@ -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/') }}
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -12,7 +12,7 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[**Get started**](https://framecontrol.fun) · [**Download**](https://framecontrol.fun/#download) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||
[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||
|
||||
<br>
|
||||
|
||||
@@ -116,17 +116,6 @@ already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
|
||||
## Install
|
||||
|
||||
**New here? [Get Frame Control and follow the setup guide on the website](https://framecontrol.fun/#download).**
|
||||
Install it on your **computer, not the headset**. You don't need to clone this
|
||||
repository or install Python. Download for your computer, install and open the
|
||||
app, then choose **Set Up Connection** with the Frame awake on the same network.
|
||||
|
||||
- **macOS:** open the `.dmg`, drag Frame Control to Applications, then open it there.
|
||||
- **Windows:** run the installer and launch Frame Control from Start, or extract the portable `.zip` and run `Frame Control.exe`.
|
||||
- **Linux:** make the AppImage executable and open it, or install the `.deb` and launch Frame Control.
|
||||
|
||||
If the app is blocked, see the first-launch notes below before proceeding.
|
||||
|
||||
| | Download | Needs |
|
||||
|---|---|---|
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
@@ -247,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 |
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ const path = require("path");
|
||||
|
||||
const REQUIRED = [
|
||||
"ui/server.py",
|
||||
"ui/frame_power.py",
|
||||
"ui/frame_status.py",
|
||||
"scripts/frame_power_sample.py",
|
||||
"scripts/frame_power_report.py",
|
||||
"ui/frame_android.py",
|
||||
"frame/android/lepton-app.sh",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.13",
|
||||
"version": "0.4.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.13",
|
||||
"version": "0.4.14",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.13",
|
||||
"version": "0.4.14",
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+44
-31
@@ -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`.
|
||||
@@ -1,118 +0,0 @@
|
||||
# iOS foreground screenshot downloads — 2026-10-24
|
||||
|
||||
This is implementation and proof for the foreground iOS portion of
|
||||
[#108](https://github.com/saphid/frame-control/issues/108). It is not the full
|
||||
requested background/cross-platform feature and is not released.
|
||||
|
||||
## Source and merge
|
||||
|
||||
Dedicated worktree: `/Users/saphid/.t3/worktrees/steam-frame/feat-phone-screenshot-autosync-108`.
|
||||
Branch: `feat/phone-screenshot-autosync-108`.
|
||||
|
||||
First merged fetched main
|
||||
[1d4c57d6](https://github.com/saphid/frame-control/commit/1d4c57d6)
|
||||
into the existing Mac-space parent
|
||||
[3fdc6c46](https://github.com/saphid/frame-control/commit/3fdc6c46).
|
||||
Merge commit: [52772323](https://github.com/saphid/frame-control/commit/52772323).
|
||||
The dirty primary checkout was left untouched. Review found and fixed the PC
|
||||
helper's missing public route for the extracted `mac-stream.js` dependency.
|
||||
Independent merge re-review ran 45 tests successfully, one skipped, and found
|
||||
no remaining actionable findings in its seven-file target.
|
||||
|
||||
## Implemented behavior
|
||||
|
||||
- Native polling every four seconds while active, independent of the visible web tab.
|
||||
- First successful listing becomes the durable baseline; only new captures queue.
|
||||
- Two equal metadata observations, exact byte count and complete ImageIO decode
|
||||
precede an atomic original write outside purgeable caches.
|
||||
- Receipts and originals distinguish pinned headset identity, Steam account,
|
||||
app ID and filename. Reconnect/relaunch resumes pending work.
|
||||
- Photos is enabled by default. Add-only permission is requested in foreground.
|
||||
Denied/failed exports keep originals; app-only captures never automatically
|
||||
export after re-enabling the setting.
|
||||
- Native downloads sheet exposes retained files through ShareLink, including
|
||||
during connection failure/relaunch. Ambiguous interrupted Photos commits need
|
||||
explicit recovery rather than risking an automatic duplicate.
|
||||
- Backgrounding stops polling; no unrelated background mode is used.
|
||||
|
||||
## Automated verification
|
||||
|
||||
Normal signed Simulator build and full native test suite: **20 tests passed,
|
||||
zero failures**, including eight screenshot tests. Result bundle:
|
||||
`/tmp/frame-108-ios-all-r3.xcresult`; log: `/tmp/frame-108-ios-all-r3.log`.
|
||||
Both command exit status and xcresult test count were checked. Private
|
||||
DerivedData cleanup completed. Test target was the independently created
|
||||
Simulator `F250F91E-2E7D-4833-B029-A275F11FDC7D` on iOS 26.5.
|
||||
|
||||
Screenshot tests cover baseline/stability, truncated data, restart dedup,
|
||||
Photos failure/retry, changes during download/consent, cancellation,
|
||||
account/app identity, corrupt-ledger failure, uncertain export handling,
|
||||
late success and failure across journal replacement, default preference and
|
||||
offline app-only originals. Download/export dependencies in these tests are
|
||||
injected; the live test below exercises actual transport and PhotoKit.
|
||||
|
||||
Python iOS packaging/Foundation contracts: 5 passed. Page scripts: 5 passed.
|
||||
`swiftc -typecheck` for the journal and scoped `git diff --check` passed.
|
||||
|
||||
## Actual headset → Simulator → Photos proof
|
||||
|
||||
Ran the production signed app against the real headset over its pinned SSH
|
||||
connection and the actual deployed `/api/shots` and `/api/shots/image` endpoints.
|
||||
Created two temporary valid PNGs under a unique synthetic Steam account/app
|
||||
directory after the app had baselined the existing nine screenshots.
|
||||
|
||||
| Check | Observed result |
|
||||
|---|---|
|
||||
| Default Photos ON | Original downloaded and receipt marked `saved`; Photos contained one byte-identical original |
|
||||
| Source / app copy / Photos SHA-256 | `a36eb2d497e6ecce17c767189a70b3b7e732a22309d2421cdc715cf13a7668ac` |
|
||||
| Photos OFF | Second original downloaded with `appOnly`; no matching Photos original |
|
||||
| Re-enable + relaunch | Second receipt stayed `appOnly`; first still had exactly one Photos original |
|
||||
|
||||
Machine readback: `/tmp/frame-108-live-proof.json`. Photos readback hashed files
|
||||
in this dedicated Simulator's own Media/DCIM directory, independently of the
|
||||
app receipt. No personal phone Photos database was accessed. This is a
|
||||
synthetic capture-file test, not proof of the headset screenshot-button path,
|
||||
capture latency, physical-device behavior, or background delivery.
|
||||
|
||||
The OFF preference was applied through Simulator defaults on restart, not a
|
||||
UI tap. The production setting setter and persisted opt-out were exercised in
|
||||
the native tests. Astra inspected the real native sheet in both states and
|
||||
found no major clipping, overlap or actionable visual defect.
|
||||
|
||||

|
||||
|
||||
An initial unsigned test build could not retain a stable Keychain identity;
|
||||
the real transport test used normal Simulator signing. App launches with a
|
||||
console attached were kept alive for the live checks.
|
||||
|
||||
## Review provenance
|
||||
|
||||
Preferred Opus routes failed before work with API rate limits. Implementation
|
||||
used the current GPT-6.1 Sol session; reviews are disclosed as fresh same-model
|
||||
checks, not different-model evidence.
|
||||
|
||||
Provider `codex`, model `gpt-6.1-sol`, high, completed:
|
||||
|
||||
- r1: `node:delegated-task:command%3Amcp%3Ac03980f6-1e71-4cc4-af1a-1d0961b9aaad%3Adelegate-task%3Aframe-108-ios-sync-review-20261024-r1`.
|
||||
Found stale export callback persistence and missing offline access. Both fixed.
|
||||
- r2: `node:delegated-task:command%3Amcp%3Ac03980f6-1e71-4cc4-af1a-1d0961b9aaad%3Adelegate-task%3Aframe-108-ios-sync-review-20261024-r2`.
|
||||
No actionable findings in the complete executable diff against `52772323`
|
||||
plus the four new Swift files. Report: `/tmp/frame-108-ios-sync-review-r2.md`.
|
||||
|
||||
Visual inspection used provider `codex`, `gpt-6-astra`, completed task
|
||||
`frame-108-downloads-proof-inspection-20261024-r3`; report:
|
||||
`/tmp/frame-108-downloads-proof-inspection.md`.
|
||||
|
||||
## Remaining release requirements
|
||||
|
||||
- Actual APNs delivery, notification extension/shared state, persistent headset
|
||||
event source and authenticated delivery service are still unimplemented.
|
||||
Main-app push capability is not configured; no usable provider credential
|
||||
location was found in the bounded checks. Existing App Store Connect signing
|
||||
access is not an APNs provider key.
|
||||
- Physical locked/background iPhone download and Photos export remain unproven.
|
||||
- No Android phone project was found in the inspected repository/main/iPhone
|
||||
branch. Its location or scope for a new companion remains unresolved.
|
||||
Android emulator tooling is unavailable in this environment.
|
||||
- No TestFlight upload, store submission or release was performed. The user's
|
||||
release condition requires the full behavior to be tested and working.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 403 KiB |
@@ -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"
|
||||
]
|
||||
}
|
||||
+61
-5
@@ -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.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
+2
-171
@@ -57,25 +57,6 @@ makes.
|
||||
| Sleep, restart, shut down ask in a terminal | The page asks for the Developer Mode password |
|
||||
| Compatibility reports kept on the computer | Kept on the Frame (`~/.local/share/Frame Control`) |
|
||||
|
||||
## Automatic screenshot downloads (in development)
|
||||
|
||||
The screenshot-download implementation on this branch copies new Steam screenshots
|
||||
while Frame Control is open and connected. It starts with the first successful
|
||||
screenshot listing as its baseline; older captures still use the existing Save
|
||||
action. Originals remain inside the app, and **Save screenshots to Photos** is
|
||||
enabled by default, subject to add-only Photos permission.
|
||||
|
||||
Open **Downloads & Photos** on the Screenshots page, or **Screenshot downloads**
|
||||
on the native connection screen. Turn the Photos setting off to keep new downloads
|
||||
only in Frame Control. Re-enabling it does not export those app-only captures.
|
||||
The local list and its Share buttons work without a headset connection. Failed
|
||||
Photos exports retain the original and retry while the app is open; an interrupted
|
||||
Photos transaction needs a manual check before saving again to avoid duplicates.
|
||||
|
||||
Background notification delivery and Android support are not implemented by this
|
||||
change, and it has not been released. See the [verification record](evidence/phone-screenshot-sync-2026-10-24.md)
|
||||
and [remaining work](https://github.com/saphid/frame-control/issues/108).
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
@@ -88,88 +69,6 @@ The build packs the Frame bundle from the checkout, so the phone always runs
|
||||
the page and server from the same commit. Running on a phone needs your own
|
||||
signing team in Xcode (Signing & Capabilities).
|
||||
|
||||
## TestFlight
|
||||
|
||||
Join the [Frame Control beta](https://testflight.apple.com/join/9xjW91g6),
|
||||
install Apple's TestFlight app, then tap **Install** or **Update** next to Frame
|
||||
Control. Internal account testers can also use their invitation. The first
|
||||
working phone build was version 0.1.0, build 202610140516, uploaded on
|
||||
2026-10-14. On 2026-10-16, build **202610160824** was uploaded and approved for
|
||||
internal and external testing. App Store Connect confirmed both builds were in
|
||||
the external **Public Beta** group, with its public link enabled and a limit of
|
||||
50 testers. The new build declares the encryption-compliance answer in the app
|
||||
and preserves the previous build's exact server bundle.
|
||||
|
||||
### Upload a build
|
||||
|
||||
You need Xcode, XcodeGen, a paid Apple Developer Program team, an App Store
|
||||
Connect app record for `com.saphid.framecontrol`, and an App Store Connect team
|
||||
API key with the signing access your release setup needs. This Mac relies on
|
||||
Apple's cloud-managed distribution certificates: an **App Manager** key was
|
||||
rejected with `Cloud signing permission error`, so this setup needs **Admin**
|
||||
access. A setup with a local distribution signing identity may be able to use
|
||||
a narrower role. Create the key under **Users and Access →
|
||||
Integrations → App Store Connect API**. Download it directly to
|
||||
`~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8`, outside the repository,
|
||||
and restrict it to its owner (`chmod 600`). Apple allows one download. Keep
|
||||
the private key out of chat, screenshots, logs and Git.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
ASC_KEY_ID=YOUR_KEY_ID \
|
||||
ASC_ISSUER_ID=YOUR_ISSUER_UUID \
|
||||
ASC_KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_YOUR_KEY_ID.p8" \
|
||||
TEAM_ID=YOUR_TEAM_ID \
|
||||
ios/scripts/testflight.sh
|
||||
```
|
||||
|
||||
The script regenerates the Xcode project, archives with automatic signing,
|
||||
checks the archived build number, and uploads to App Store Connect. `TEAM_ID`
|
||||
defaults to this project's team, `NJZUMEA4BN`. `BUILD_NUMBER` defaults to UTC
|
||||
`YYYYMMDDHHMM`; wait for a new minute between uploads, or supply a unique,
|
||||
higher build number. The archive, export options and export output are under
|
||||
`ios/build/testflight/<BUILD_NUMBER>/` (ignored by Git).
|
||||
|
||||
Build from a reviewed, clean checkout. The build packs the server, web pages
|
||||
and helpers from the current checkout, including uncommitted changes; it does
|
||||
not limit the bundle to the last commit.
|
||||
|
||||
An upload succeeding means Apple received the build. Wait for it to finish
|
||||
processing in **App Store Connect → Frame Control → TestFlight** and check
|
||||
that it is ready for testing. `ios/project.yml` declares
|
||||
`ITSAppUsesNonExemptEncryption: false`, so each generated app declares that it
|
||||
uses no non-exempt encryption, matching the previous builds' export-compliance
|
||||
answers. Reassess that declaration if the app's cryptography changes.
|
||||
|
||||
To replace a key, create and securely download the replacement, verify that it
|
||||
can access this app, then revoke the old key in Users and Access. Update the
|
||||
upload environment to use the replacement. Revocation does not remove an
|
||||
already uploaded TestFlight build from testers' phones.
|
||||
|
||||
### Add testers
|
||||
|
||||
**Internal:** add an existing App Store Connect user with an eligible role to
|
||||
an internal testing group, and add the build to that group (or enable automatic
|
||||
distribution). New account users must first accept their account invitation.
|
||||
Internal testing grants App Store Connect access; use external testing for
|
||||
people who only need to try the app.
|
||||
|
||||
**External:** create an external testing group, complete Beta App Information
|
||||
and review contact details, add a build and its **What to Test** notes, then
|
||||
submit it for TestFlight App Review when required. Use
|
||||
<https://framecontrol.fun/privacy/> for the privacy policy and
|
||||
<https://framecontrol.fun/feedback/> for feedback. Review notes must explain
|
||||
that the app requires a Steam Frame with Developer Mode enabled and a user
|
||||
password; do not provide your own headset credentials to reviewers. Apple may
|
||||
need a way to evaluate features that require the hardware.
|
||||
|
||||
After Apple approves the build for external testing, enable the group's public
|
||||
link and choose a tester limit. Share that link to let testers join without an
|
||||
App Store Connect account. Creating a group alone does not make a build public.
|
||||
Do not describe the link as ready until the approved build is in the group and
|
||||
the link is enabled.
|
||||
|
||||
## Verified
|
||||
|
||||
<img src="img/iphone-tabs.jpg" alt="The four tabs in the iPhone app, connected to a Frame" width="900">
|
||||
@@ -207,76 +106,8 @@ Sending text to the Frame's clipboard, or copying the Frame's to the phone
|
||||
(**Type on the headset → Copy the headset's clipboard**), needs the desktop
|
||||
panel open in the headset, as on the desktop app.
|
||||
|
||||
On a physical iPhone, 2026-10-14: Alex reported build 202610140516 was working
|
||||
after pairing and trying the password visibility button. A server process
|
||||
started by the phone and its SSH key on the Frame confirmed the connection.
|
||||
This was a setup/connection smoke check; it did not verify every app feature.
|
||||
|
||||
On a physical iPhone 16 Pro running iOS 27.2, 2026-10-16: the same build launched
|
||||
and automatically reconnected to the Frame. A device screenshot showed the
|
||||
green connection indicator, 100% headset battery and the loaded Android
|
||||
catalog. This confirms connection and rendering, not status refresh or actions
|
||||
inside the app. A separate signed automation runner was installed and passed
|
||||
its health check. Initial interaction attempts were blocked by the phone's
|
||||
lock; testing resumed once Alex unlocked it and kept the screen awake.
|
||||
|
||||
<img src="img/iphone-physical-20261016.jpg" alt="Frame Control connected to the Frame on a physical iPhone, showing battery and the Android catalog" width="360">
|
||||
|
||||
In that continuation, still on build **202610140516**, the status header
|
||||
refreshed, the Games tab loaded 14 installed titles with sizes and Play
|
||||
controls, and a uniquely named 114-byte text file was selected through the
|
||||
iPhone's Files picker and sent to `~/Downloads` on the Frame. An SSH readback
|
||||
matched the file's contents exactly; this verifies phone-to-Frame upload,
|
||||
not a download back to the phone. Both the iCloud source fixture and the
|
||||
uploaded test file were removed and checked absent.
|
||||
|
||||
Capture returned a blank headset frame with an asleep/unworn explanation.
|
||||
Save opened the native share sheet and reached the Photos permission prompt;
|
||||
no completed Photos save was verified. A final Settings check showed
|
||||
**Add Photos Only** enabled, without photo-library read access. A test string
|
||||
sent through the clipboard control was refused with the expected prerequisite:
|
||||
`plasmashell` was not running, so the desktop panel must be opened in the
|
||||
headset. The phone's existing clipboard was not read. Live mode started and
|
||||
reported **30 fps**, but its visible frames remained blank, so meaningful
|
||||
video playback is still unverified. Android listed 10 installed apps; a
|
||||
separate SSH check found zero running containers and zero Android ADB
|
||||
listeners, so display-setting changes were not exercised.
|
||||
|
||||
<img src="img/iphone-games-physical-20261016.jpg" alt="Frame Control's Games tab on the physical iPhone, showing the installed library and Play controls" width="360">
|
||||
|
||||
<img src="img/iphone-live-physical-20261016.jpg" alt="Live mode reporting 30 fps on the physical iPhone with blank headset content and the page horizontally clipped" width="360">
|
||||
|
||||
Testing also exposed an enlarged, horizontally clipped page after text entry
|
||||
and navigation. Its cause has not yet been isolated. Restarting Frame Control
|
||||
restored the normal layout and automatically reconnected to the headset.
|
||||
|
||||
### Mac follow-up
|
||||
|
||||
On 2026-10-17, the installed Mac app **0.4.6** was tested with the headset
|
||||
unworn. Connection/status worked, and its connection test reported
|
||||
`Answered in 90 ms · SSH works`. Still capture completed but both eyes were
|
||||
blank. Live video failed with `Not a video capture device` when opening
|
||||
`/dev/video99`, then fell back to blank still captures. No visible scene or
|
||||
video playback was verified. The desktop was inactive; no headset services
|
||||
or settings were changed. Live was stopped and the existing app left on Home.
|
||||
These are Mac results; they do not verify the iPhone build or establish the
|
||||
cause of its earlier blank output.
|
||||
|
||||
<img src="img/mac-live-error-20261017.png" alt="Mac Frame Control activity log reporting Live video failed: Not a video capture device" width="900">
|
||||
|
||||
### Physical-phone follow-up checklist
|
||||
|
||||
- With the headset awake and worn, capture visible headset content, save it to
|
||||
Photos, then verify live video.
|
||||
- Send test text to the Frame's clipboard with its desktop panel open.
|
||||
- With an Android app running, change and restore its display settings.
|
||||
- Reproduce and fix the page enlargement and horizontal clipping after text
|
||||
entry and navigation.
|
||||
- After saving headset work, deliberately test sleep, restart and shut down.
|
||||
|
||||
These remaining checks are pending on a physical phone. Android display
|
||||
changes through podman and real sleep/restart/shut down also remain unverified
|
||||
by the Simulator checks above.
|
||||
Not yet exercised: Android display changes through podman (no Android app was
|
||||
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
||||
|
||||
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
|
||||
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# Mac space: the Mac's desktop and its windows, placed in the room
|
||||
|
||||
Design notes, started 2026-09-28. The goal is Apple Vision Pro's Mac Virtual
|
||||
Display, plus something Vision Pro can't do: pulling any Mac window out
|
||||
to float on its own. Labels as in [ssh.md](ssh.md): **verified**,
|
||||
**documented**, **reported**, **inferred**.
|
||||
|
||||
## What to match (Vision Pro's Mac Virtual Display)
|
||||
|
||||
Researched 2026-09-28. Sources: Apple Support
|
||||
[118521](https://support.apple.com/en-us/118521) and the
|
||||
[Vision Pro guide](https://support.apple.com/guide/apple-vision-pro/use-mac-virtual-display-tan357ede966/visionos);
|
||||
[9to5Mac](https://9to5mac.com/2024/01/30/heres-what-its-like-using-vision-pro-as-an-external-mac-display/),
|
||||
[UploadVR](https://www.uploadvr.com/visionos-2-2-ultrawide-mac-virtual-display/)
|
||||
and [AppleInsider](https://appleinsider.com/inside/apple-vision-pro/tips/how-to-use-mac-virtual-display-for-apple-vision-pro-with-desktop-macs-and-macbooks).
|
||||
|
||||
- **Connecting.** Look at the Mac and a **Connect** button floats above it.
|
||||
There's also a Control Center route. The Mac's own screen goes dark
|
||||
(**documented**/**reported**).
|
||||
- **Placement.** The display appears where you were looking, near the Mac,
|
||||
at a large-monitor size. After that it stays put in the room: moving the
|
||||
laptop doesn't move it. Holding the Digital Crown recenters everything
|
||||
(**documented**).
|
||||
- **Handling.**
|
||||
- A **window bar** under the display moves it, with pinch and drag, and
|
||||
also moves it nearer or farther.
|
||||
- Its bottom **corners** resize it.
|
||||
- **Close** sits to the left of the bar (**documented**).
|
||||
- **Modes.** Standard (16:9), Wide (21:9) and Ultrawide (32:9, about 10K
|
||||
pixels across). All are curved since visionOS 2.2 (**documented**).
|
||||
- **Input.**
|
||||
- The Mac's keyboard and trackpad drive it.
|
||||
- The pointer moves to whichever window you look at (Universal Control).
|
||||
- Mac audio plays in the headset (**documented**).
|
||||
- **What it can't do.** You can't pull a single Mac window out of the
|
||||
virtual display (**reported**, several sources). That's our
|
||||
differentiator.
|
||||
|
||||
## What the Frame allows
|
||||
|
||||
- **Moving SteamVR's panels from our own process: no** (**verified**
|
||||
2026-09-28). `FindOverlay("valve.steam.desktopgame.<id>")` works from a
|
||||
separate OpenVR app, but `SetOverlayTransformAbsolute` returns 12
|
||||
(`VROverlayError_PermissionDenied`). The panel reported transform type 5
|
||||
(dashboard tab). Only SteamVR's dashboard, which runs in `vrwebhelper`,
|
||||
moves them, through its privileged `VRHTML.VROverlay`, and it has no
|
||||
DevTools port. See `/tmp/fmv-space/ovprobe.py` for the probe, which stays
|
||||
outside the repo.
|
||||
- **WebXR in Chromium XR: yes** (**verified** 2026-09-26/27,
|
||||
[webxr-chromium.md](webxr-chromium.md)).
|
||||
- `immersive-vr` runs at 72–75 fps.
|
||||
- Passthrough shows wherever the page leaves pixels transparent.
|
||||
- Controllers are `tracked-pointer`s with `xr-standard` gamepads, and
|
||||
squeeze events arrive.
|
||||
- Hands are tracked with 25 joints.
|
||||
- **Our own OpenVR overlays: possible, but most work.** We would own their
|
||||
transforms and get laser input as mouse events for free. But the Frame
|
||||
would need native H.264 decoding and texture upload.
|
||||
|
||||
## Decision: a WebXR "Mac space" page first
|
||||
|
||||
The WebXR page (`ui/mac-space.html`) holds everything, with passthrough
|
||||
behind it:
|
||||
|
||||
- **The desktop.** A large curved panel shows the Mac's main display,
|
||||
placed above the laptop. The first time, you place it yourself; after
|
||||
that it's remembered in the room.
|
||||
- **Windows.** Each separated window gets its own panel.
|
||||
- **Grabbing.** A bar under each panel moves it (squeeze, or pinch with a
|
||||
hand). Its corners resize it, and there's a close button to the bar's
|
||||
left, as on Vision Pro.
|
||||
- **Pulling a window out.** Grab a window's title bar on the desktop panel
|
||||
and drag it off the edge. The page starts a `separate:<window id>`
|
||||
stream, the window moves to its own display on the Mac, and its new
|
||||
panel follows your hand until you let go. Dropping a panel back onto the
|
||||
desktop panel stops the stream, and the window goes back.
|
||||
- **Input.** The laser and trigger click and drag, the thumbstick scrolls,
|
||||
and the Mac's keyboard types, as today.
|
||||
|
||||
Trade-offs:
|
||||
|
||||
- It's the headset's active app while you use it. Other VR apps pause, but
|
||||
SteamVR's own overlays still draw on top.
|
||||
- Text sharpness depends on how the page renders. WebXR quad layers (the
|
||||
`layers` feature) would let SteamVR's compositor sample each panel
|
||||
directly, so they're worth checking first.
|
||||
|
||||
## Building blocks
|
||||
|
||||
- **One module for the protocol.** `ui/mac-stream.js` holds the stream
|
||||
protocol (connect, clock sync, decoding, acks and reports, input), shared
|
||||
by `mac-view.html` (the 2D panel) and `mac-space.html`.
|
||||
- **A space key from the agent.** Frame Control gets a ticket with
|
||||
`src=space`, and the page trades it once for a **space key**, kept in
|
||||
memory. That key lists the windows and displays (with their positions,
|
||||
for picking the one under the pointer) and opens streams for any of
|
||||
them. Stop All revokes it.
|
||||
- **Anchoring.**
|
||||
- v1: manual placement, remembered in the room (to be checked: whether
|
||||
`local-floor` stays put between sessions on the Frame).
|
||||
- v2: the Mac shows a marker on its screen for a moment, and the headset
|
||||
finds it. That needs camera access, which is **unknown** on the Frame:
|
||||
the passthrough cameras are monochrome, and `IVRTrackedCamera` hasn't
|
||||
been tried.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Frame rate with several software-decoded streams at once.
|
||||
- Whether `local-floor` placements survive restarts.
|
||||
- Whether WebXR quad layers work in Chromium XR's OpenXR path.
|
||||
- Whether Chromium grants `requestSession` without a click in the page
|
||||
(needed for a one-click "Open Mac space").
|
||||
+102
-2
@@ -24,6 +24,87 @@ 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
|
||||
@@ -109,14 +190,33 @@ 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_sample.py -v
|
||||
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
|
||||
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).
|
||||
+4
-1
@@ -14,7 +14,10 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
|
||||
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
|
||||
attaches everything to a **draft** release for that tag. Nobody is
|
||||
offered a draft.
|
||||
offered a draft. Editing a draft while installers are uploading can make
|
||||
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
|
||||
draft by its exact version title, restores the tag association and retries
|
||||
the affected upload; it refuses ambiguous matches.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
+3
-20
@@ -1,28 +1,11 @@
|
||||
# Tailscale: advanced remote-access setup
|
||||
# Tailscale: use the Frame from anywhere
|
||||
|
||||
Tailscale is intended to make the `frame` SSH alias available off the home LAN,
|
||||
including to Frame Control and the scripts. Off-LAN access has not yet been
|
||||
verified; the checks below cover the direct LAN path only. Read the exposure
|
||||
section before enabling it: services normally restricted to loopback become
|
||||
reachable by other devices on your tailnet.
|
||||
With Tailscale on the Frame, the `frame` SSH alias works off the home LAN, and
|
||||
so does everything built on it: Frame Control, the scripts and the Mac app.
|
||||
When the Mac and the Frame are on the same network, Tailscale connects them
|
||||
directly, so there's no relay in the way (`tailscale ping frame` → `via
|
||||
192.168.1.50:41641`, 20 ms).
|
||||
|
||||
These are source-checkout commands for macOS, not packaged-app menu actions
|
||||
or Windows PowerShell commands. The connection script currently uses macOS
|
||||
netcat options; this route is not supported on Linux. Install Git, OpenSSH,
|
||||
zsh, Python 3 and curl on your Mac; the desktop app's bundled Python is not
|
||||
available to these scripts. Then obtain the scripts:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/saphid/frame-control.git
|
||||
cd frame-control
|
||||
```
|
||||
|
||||
Enable Developer Mode on the awake headset and establish working SSH access
|
||||
first using the [connection guide](frame-control.md). Run from the checkout:
|
||||
|
||||
```sh
|
||||
scripts/tailscale-on-frame.sh # install or update, then approve the login URL
|
||||
scripts/connect.sh frame.<tailnet>.ts.net # point the alias at Tailscale (the script prints this)
|
||||
|
||||
+5
-2
@@ -60,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
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0CF0B365CE5974753E8C3C2B /* ScreenshotJournal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D48FCBB02F0F2212C7DE3C1 /* ScreenshotJournal.swift */; };
|
||||
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1234567890ABCDEF0123456 /* NativeSessionTests.swift */; };
|
||||
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1234567890ABCDEF0123456 /* NativeSessionState.swift */; };
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
|
||||
41A697B9924018DA48F24A1F /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237D9AF04EEA257AB382F60E /* Keys.swift */; };
|
||||
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */; };
|
||||
46EF7595BE5B36664A11AEB5 /* NativeSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F1CAE943C2556A960890079 /* NativeSessionTests.swift */; };
|
||||
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */; };
|
||||
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24E1BCD4F69A24C7DEF02F /* RootView.swift */; };
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
|
||||
@@ -21,12 +21,8 @@
|
||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
|
||||
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */; };
|
||||
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
|
||||
C046F310BA857BCEC98BE00E /* ScreenshotDownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EBD10B73DF1E95F67A3B074 /* ScreenshotDownloadsView.swift */; };
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
||||
DFD89170E502260F005EEC73 /* ScreenshotDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07617FD9B214626526FB1F03 /* ScreenshotDownloads.swift */; };
|
||||
E2FD56BB5BC1D9AF5CDBF210 /* ScreenshotSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8610920C7D797E5963CF88 /* ScreenshotSyncTests.swift */; };
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
|
||||
F0ECE06D5DD29F19CC41FDFB /* NativeSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E8BE9A8C421AD40A0F4105B /* NativeSessionState.swift */; };
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -41,23 +37,19 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
07617FD9B214626526FB1F03 /* ScreenshotDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotDownloads.swift; sourceTree = "<group>"; };
|
||||
0D48FCBB02F0F2212C7DE3C1 /* ScreenshotJournal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotJournal.swift; sourceTree = "<group>"; };
|
||||
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionTests.swift; sourceTree = "<group>"; };
|
||||
B1234567890ABCDEF0123456 /* NativeSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionState.swift; sourceTree = "<group>"; };
|
||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
|
||||
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
|
||||
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = "<group>"; };
|
||||
5EBD10B73DF1E95F67A3B074 /* ScreenshotDownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotDownloadsView.swift; sourceTree = "<group>"; };
|
||||
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||
8F1CAE943C2556A960890079 /* NativeSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionTests.swift; sourceTree = "<group>"; };
|
||||
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlApp.swift; sourceTree = "<group>"; };
|
||||
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
9E8BE9A8C421AD40A0F4105B /* NativeSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionState.swift; sourceTree = "<group>"; };
|
||||
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortForwarder.swift; sourceTree = "<group>"; };
|
||||
AB8610920C7D797E5963CF88 /* ScreenshotSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotSyncTests.swift; sourceTree = "<group>"; };
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
|
||||
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
|
||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
||||
@@ -112,11 +104,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */,
|
||||
B1234567890ABCDEF0123456 /* NativeSessionState.swift */,
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
|
||||
9E8BE9A8C421AD40A0F4105B /* NativeSessionState.swift */,
|
||||
07617FD9B214626526FB1F03 /* ScreenshotDownloads.swift */,
|
||||
0D48FCBB02F0F2212C7DE3C1 /* ScreenshotJournal.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -126,8 +116,7 @@
|
||||
children = (
|
||||
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||
8F1CAE943C2556A960890079 /* NativeSessionTests.swift */,
|
||||
AB8610920C7D797E5963CF88 /* ScreenshotSyncTests.swift */,
|
||||
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */,
|
||||
);
|
||||
path = FrameControlTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -136,7 +125,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */,
|
||||
5EBD10B73DF1E95F67A3B074 /* ScreenshotDownloadsView.swift */,
|
||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
@@ -278,18 +266,15 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
||||
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */,
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
|
||||
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
|
||||
41A697B9924018DA48F24A1F /* Keys.swift in Sources */,
|
||||
F0ECE06D5DD29F19CC41FDFB /* NativeSessionState.swift in Sources */,
|
||||
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */,
|
||||
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */,
|
||||
DFD89170E502260F005EEC73 /* ScreenshotDownloads.swift in Sources */,
|
||||
C046F310BA857BCEC98BE00E /* ScreenshotDownloadsView.swift in Sources */,
|
||||
0CF0B365CE5974753E8C3C2B /* ScreenshotJournal.swift in Sources */,
|
||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */,
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */,
|
||||
);
|
||||
@@ -301,8 +286,7 @@
|
||||
files = (
|
||||
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||
46EF7595BE5B36664A11AEB5 /* NativeSessionTests.swift in Sources */,
|
||||
E2FD56BB5BC1D9AF5CDBF210 /* ScreenshotSyncTests.swift in Sources */,
|
||||
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -22,8 +22,6 @@ final class AppModel: ObservableObject {
|
||||
/// Native recovery remains reachable even when WebKit cannot draw a page.
|
||||
@Published private(set) var webRecoveryMessage: String?
|
||||
@Published private(set) var webReloadID = 0
|
||||
let screenshots = ScreenshotDownloads()
|
||||
@Published var showScreenshotDownloads = false
|
||||
|
||||
func webContentFailed(at url: URL, message: String) {
|
||||
guard phase == .ready(url) else { return }
|
||||
@@ -47,7 +45,6 @@ final class AppModel: ObservableObject {
|
||||
let saved = UserDefaults.standard.data(forKey: Self.settingsKey).flatMap { try? JSONDecoder().decode(FrameSettings.self, from: $0) }
|
||||
settings = saved
|
||||
phase = saved == nil ? .setup : .connecting("Connecting")
|
||||
if let identity = UserDefaults.standard.string(forKey: Self.hostKeyKey) { screenshots.load(identity: identity) }
|
||||
}
|
||||
|
||||
var deviceName: String { UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" }
|
||||
@@ -149,7 +146,6 @@ final class AppModel: ObservableObject {
|
||||
/// Every connection attempt has a number; anything that finishes after a newer
|
||||
/// attempt started (or the user went back to setup) closes what it made and stops.
|
||||
private func invalidate() {
|
||||
screenshots.stop()
|
||||
attempt += 1
|
||||
retrying = false
|
||||
webRecoveryMessage = nil
|
||||
@@ -204,7 +200,6 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
phase = .ready(URL(string: page)!)
|
||||
startScreenshots()
|
||||
// Runs at once if it stopped in the moment since the check above.
|
||||
server.whenExited { [weak self] tail in
|
||||
Task { @MainActor in self?.lost(mine, "Frame Control on the headset stopped. \(tail.suffix(200))") }
|
||||
@@ -259,7 +254,6 @@ final class AppModel: ObservableObject {
|
||||
/// Called when the app comes back to the foreground: iOS may have dropped the
|
||||
/// connection, or left it looking open, while it was in the background.
|
||||
func resume() {
|
||||
startScreenshots()
|
||||
switch phase {
|
||||
case .ready:
|
||||
let mine = attempt
|
||||
@@ -290,7 +284,6 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func teardown() async {
|
||||
screenshots.stop()
|
||||
forwarder?.stop()
|
||||
forwarder = nil
|
||||
server = nil
|
||||
@@ -305,11 +298,4 @@ final class AppModel: ObservableObject {
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func suspend() { screenshots.stop() }
|
||||
|
||||
private func startScreenshots() {
|
||||
guard case .ready(let url) = phase, let hostKey else { return }
|
||||
screenshots.start(url: url, identity: hostKey)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ struct FrameControlApp: App {
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { model.resume() }
|
||||
else if phase == .background { model.suspend() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import CryptoKit
|
||||
import ImageIO
|
||||
import Photos
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class ScreenshotDownloads: ObservableObject {
|
||||
struct Item: Identifiable {
|
||||
let id: String
|
||||
let file: URL
|
||||
let date: Date
|
||||
let status: String
|
||||
}
|
||||
@Published private(set) var items: [Item] = []
|
||||
@Published private(set) var message = "New screenshots download while Frame Control is open."
|
||||
@Published private(set) var saveToPhotos: Bool
|
||||
private let defaults: UserDefaults
|
||||
private let storageRoot: URL?
|
||||
private static let preference = "frame.screenshots.saveToPhotos"
|
||||
private var engine: ScreenshotSyncEngine?
|
||||
private var task: Task<Void, Never>?
|
||||
private var session: URL?
|
||||
private var identity: String?
|
||||
|
||||
init(defaults: UserDefaults = .standard, storageRoot: URL? = nil) {
|
||||
self.defaults = defaults
|
||||
self.storageRoot = storageRoot
|
||||
saveToPhotos = defaults.object(forKey: Self.preference) == nil || defaults.bool(forKey: Self.preference)
|
||||
}
|
||||
|
||||
func setPhotos(_ enabled: Bool) {
|
||||
do {
|
||||
try engine?.setPhotos(enabled)
|
||||
defaults.set(enabled, forKey: Self.preference)
|
||||
saveToPhotos = enabled
|
||||
refresh()
|
||||
if enabled { Task { _ = await Self.photoPermission() } }
|
||||
} catch { message = "Couldn't save the screenshot setting: \(error.localizedDescription)" }
|
||||
}
|
||||
|
||||
/// Local files and settings remain available without a live SSH session.
|
||||
func load(identity: String) {
|
||||
guard self.identity != identity || engine == nil else { return }
|
||||
do {
|
||||
let digest = SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
let base = try storageRoot ?? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask,
|
||||
appropriateFor: nil, create: true)
|
||||
self.engine = try ScreenshotSyncEngine(root: base.appendingPathComponent("Screenshots/\(digest)", isDirectory: true),
|
||||
photos: saveToPhotos)
|
||||
self.identity = identity
|
||||
refresh()
|
||||
} catch {
|
||||
engine = nil
|
||||
self.identity = nil
|
||||
items = []
|
||||
message = "Screenshot downloads need attention: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func start(url: URL, identity: String) {
|
||||
if session == url, task != nil { return }
|
||||
stop()
|
||||
load(identity: identity)
|
||||
if let engine {
|
||||
session = url
|
||||
task = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self, self.session == url else { return }
|
||||
if UIApplication.shared.applicationState == .active {
|
||||
await self.poll(url: url, engine: engine)
|
||||
}
|
||||
do { try await Task.sleep(nanoseconds: 4_000_000_000) }
|
||||
catch { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
engine?.stop()
|
||||
session = nil
|
||||
}
|
||||
|
||||
private func poll(url: URL, engine: ScreenshotSyncEngine) async {
|
||||
do {
|
||||
let data = try await Self.get("/api/shots", from: url)
|
||||
struct Listing: Decodable { let shots: [HeadsetScreenshot] }
|
||||
let shots = try JSONDecoder().decode(Listing.self, from: data).shots
|
||||
guard !Task.isCancelled, session == url else { return }
|
||||
try await engine.reconcile(shots, download: { shot in
|
||||
try await Self.get("/api/shots/image", id: shot.id, from: url)
|
||||
}, validate: { data in
|
||||
guard let image = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
CGImageSourceGetStatus(image) == .statusComplete else { return false }
|
||||
return CGImageSourceCreateImageAtIndex(image, 0, nil) != nil
|
||||
}, export: { file, allowed in
|
||||
guard !Task.isCancelled, UIApplication.shared.applicationState == .active else { throw CancellationError() }
|
||||
guard await Self.photoPermission() else {
|
||||
throw NSError(domain: "FrameScreenshots", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Originals are kept in Frame Control. Allow Photos access in iOS Settings to save them to Photos."])
|
||||
}
|
||||
// Cancellation or a toggle change while the permission prompt was
|
||||
// visible must not start a Photos transaction.
|
||||
guard !Task.isCancelled, allowed() else { throw CancellationError() }
|
||||
try await PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .photo, fileURL: file, options: nil)
|
||||
}
|
||||
})
|
||||
guard !Task.isCancelled, session == url else { return }
|
||||
message = "New screenshots download while Frame Control is open."
|
||||
} catch {
|
||||
guard !Task.isCancelled, session == url else { return }
|
||||
message = "\(error.localizedDescription) Downloads retry while the app is open."
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
private static func get(_ path: String, id: String? = nil, from page: URL) async throws -> Data {
|
||||
let original = URLComponents(url: page, resolvingAgainstBaseURL: false)
|
||||
let key = original?.queryItems?.first(where: { $0.name == "key" })?.value
|
||||
var parts = original!
|
||||
parts.path = path
|
||||
parts.queryItems = id.map { [URLQueryItem(name: "id", value: $0)] }
|
||||
parts.fragment = nil
|
||||
var request = URLRequest(url: parts.url!, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
|
||||
request.setValue(key, forHTTPHeaderField: "X-Frame-UI")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw URLError(.badServerResponse) }
|
||||
return data
|
||||
}
|
||||
|
||||
private static func photoPermission() async -> Bool {
|
||||
var status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
if status == .notDetermined, UIApplication.shared.applicationState == .active {
|
||||
status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||
}
|
||||
return status == .authorized || status == .limited
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
guard let engine else { return }
|
||||
items = engine.journal.receipts.values.filter(\.downloaded).map { receipt in
|
||||
let status: String
|
||||
switch receipt.destination {
|
||||
case .saved: status = "Saved to Photos"
|
||||
case .appOnly: status = "In Frame Control"
|
||||
case .pending: status = "In Frame Control · waiting for Photos"
|
||||
case .committing: status = "In Frame Control · Photos save interrupted; check Photos before saving again"
|
||||
}
|
||||
return Item(id: receipt.shot.id, file: engine.file(for: receipt.shot.id),
|
||||
date: Date(timeIntervalSince1970: receipt.shot.time), status: status)
|
||||
}.sorted { $0.date > $1.date }
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct HeadsetScreenshot: Codable, Equatable {
|
||||
let id: String
|
||||
let size: Int
|
||||
let time: Double
|
||||
|
||||
var valid: Bool {
|
||||
id.range(of: #"^[0-9]{1,12}/[0-9]{1,20}/[0-9]{14}_[0-9]{1,4}\.(jpg|png)$"#, options: .regularExpression) != nil
|
||||
&& size > 0 && size <= 30 * 1024 * 1024 && time.isFinite
|
||||
}
|
||||
}
|
||||
|
||||
/// All receipt state lives beside the originals, outside purgeable caches.
|
||||
/// An interrupted Photos transaction is deliberately not retried automatically:
|
||||
/// add-only access cannot check whether its asset was committed before a crash.
|
||||
struct ScreenshotJournal: Codable {
|
||||
enum Export: String, Codable { case pending, appOnly, committing, saved }
|
||||
struct Receipt: Codable {
|
||||
var shot: HeadsetScreenshot
|
||||
var downloaded = false
|
||||
var destination: Export
|
||||
}
|
||||
var baseline: Set<String>?
|
||||
var receipts: [String: Receipt] = [:]
|
||||
|
||||
mutating func observe(_ shots: [HeadsetScreenshot], photos: Bool) {
|
||||
let shots = shots.filter(\.valid)
|
||||
guard let baseline else {
|
||||
self.baseline = Set(shots.map(\.id))
|
||||
return
|
||||
}
|
||||
for shot in shots where !baseline.contains(shot.id) {
|
||||
if var receipt = receipts[shot.id] {
|
||||
if !receipt.downloaded { receipt.shot = shot; receipts[shot.id] = receipt }
|
||||
} else {
|
||||
receipts[shot.id] = Receipt(shot: shot, destination: photos ? .pending : .appOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutating func keepPendingInApp() {
|
||||
for id in receipts.keys where receipts[id]?.destination == .pending || receipts[id]?.destination == .committing {
|
||||
receipts[id]?.destination = .appOnly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized reconciler shared by foreground polling and future event delivery.
|
||||
/// Network and Photos operations are injected so failures and late callbacks can
|
||||
/// be exercised against the actual persistence and state transitions.
|
||||
@MainActor
|
||||
final class ScreenshotSyncEngine {
|
||||
let root: URL
|
||||
private(set) var journal: ScreenshotJournal
|
||||
private var previous: [String: HeadsetScreenshot] = [:]
|
||||
private var generation = UUID()
|
||||
private var running = false
|
||||
private(set) var saveToPhotos: Bool
|
||||
|
||||
init(root: URL, photos: Bool) throws {
|
||||
self.root = root
|
||||
saveToPhotos = photos
|
||||
let ledger = root.appendingPathComponent("receipts.json")
|
||||
if FileManager.default.fileExists(atPath: ledger.path) {
|
||||
// Corrupt state must never silently become a fresh receipt ledger.
|
||||
journal = try JSONDecoder().decode(ScreenshotJournal.self, from: Data(contentsOf: ledger))
|
||||
} else { journal = ScreenshotJournal() }
|
||||
if !photos { journal.keepPendingInApp() }
|
||||
try persist()
|
||||
}
|
||||
|
||||
func stop() { generation = UUID() }
|
||||
|
||||
func setPhotos(_ enabled: Bool) throws {
|
||||
let oldJournal = journal
|
||||
let oldSetting = saveToPhotos
|
||||
saveToPhotos = enabled
|
||||
if !enabled { journal.keepPendingInApp() }
|
||||
do { try persist() }
|
||||
catch { journal = oldJournal; saveToPhotos = oldSetting; throw error }
|
||||
}
|
||||
|
||||
func file(for id: String) -> URL { root.appendingPathComponent(id) }
|
||||
|
||||
private func persist() throws {
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
try JSONEncoder().encode(journal).write(to: root.appendingPathComponent("receipts.json"), options: .atomic)
|
||||
}
|
||||
|
||||
func reconcile(_ shots: [HeadsetScreenshot],
|
||||
download: (HeadsetScreenshot) async throws -> Data,
|
||||
validate: (Data) -> Bool,
|
||||
export: (URL, () -> Bool) async throws -> Void) async throws {
|
||||
guard !running else { return }
|
||||
running = true
|
||||
defer { running = false }
|
||||
let mine = generation
|
||||
func current() -> Bool { mine == generation && !Task.isCancelled }
|
||||
guard current() else { return }
|
||||
let valid = shots.filter(\.valid)
|
||||
let observed = Dictionary(valid.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
|
||||
let stable = previous
|
||||
previous = observed
|
||||
journal.observe(valid, photos: saveToPhotos)
|
||||
try persist() // Queue before touching an original or the photo library.
|
||||
var firstError: Error?
|
||||
for id in journal.receipts.keys.sorted() {
|
||||
guard current(), var receipt = journal.receipts[id], receipt.shot.valid else { continue }
|
||||
do {
|
||||
let file = file(for: id)
|
||||
if !receipt.downloaded {
|
||||
// Steam may still be writing. Require two equal observations.
|
||||
guard observed[id] == receipt.shot, stable[id] == receipt.shot else { continue }
|
||||
let data = try await download(receipt.shot)
|
||||
guard current() else { return }
|
||||
guard data.count == receipt.shot.size, validate(data) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: file, options: .atomic)
|
||||
// Re-read destination: the user can change it during download.
|
||||
receipt = journal.receipts[id]!
|
||||
receipt.downloaded = true
|
||||
journal.receipts[id] = receipt
|
||||
try persist()
|
||||
}
|
||||
if receipt.destination == .pending, saveToPhotos {
|
||||
journal.receipts[id]?.destination = .committing
|
||||
try persist()
|
||||
do {
|
||||
try await export(file, { current() && self.saveToPhotos && self.journal.receipts[id]?.destination == .committing })
|
||||
} catch {
|
||||
guard current() else { return }
|
||||
// A reported transaction failure is safe to retry. A crash
|
||||
// leaves committing, which needs explicit user recovery.
|
||||
if journal.receipts[id]?.destination == .committing {
|
||||
journal.receipts[id]?.destination = saveToPhotos ? .pending : .appOnly
|
||||
}
|
||||
try persist()
|
||||
throw error
|
||||
}
|
||||
guard current() else { return }
|
||||
journal.receipts[id]?.destination = .saved
|
||||
try persist()
|
||||
}
|
||||
} catch { if firstError == nil { firstError = error } }
|
||||
}
|
||||
if let firstError { throw firstError }
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,6 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>ssh</string>
|
||||
@@ -52,7 +50,7 @@
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Frame Control finds your Steam Frame on your network and connects to it.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Frame Control automatically saves new headset screenshots to Photos when Save screenshots to Photos is enabled.</string>
|
||||
<string>Frame Control saves headset captures and screenshots to your photo library when you ask it to.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
|
||||
@@ -39,15 +39,6 @@ struct RootView: View {
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(.frameBlue)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if case .ready = model.phase {} else {
|
||||
Button("Screenshot downloads") { model.showScreenshotDownloads = true }
|
||||
.padding().frame(maxWidth: .infinity).background(Color.frameBackground)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $model.showScreenshotDownloads) {
|
||||
ScreenshotDownloadsView(downloads: model.screenshots)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ScreenshotDownloadsView: View {
|
||||
@ObservedObject var downloads: ScreenshotDownloads
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Toggle("Save screenshots to Photos", isOn: Binding(get: { downloads.saveToPhotos }, set: downloads.setPhotos))
|
||||
} footer: {
|
||||
Text("Enabled by default. Turn off to keep new downloads only in Frame Control. Existing Photos are kept. App-only downloads stay app-only when this is turned on again.")
|
||||
}
|
||||
Section {
|
||||
Text(downloads.message)
|
||||
Text("Automatic downloads begin with new screenshots after the first connection. Keep the app open and connected; background delivery is not available yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Downloaded from this headset") {
|
||||
if downloads.items.isEmpty { Text("No new screenshots downloaded yet.").foregroundStyle(.secondary) }
|
||||
ForEach(downloads.items) { item in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.date, format: .dateTime.month().day().hour().minute())
|
||||
Text(item.status).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
ShareLink(item: item.file) { Image(systemName: "square.and.arrow.up") }
|
||||
.accessibilityLabel("Share screenshot")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Screenshot downloads")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ struct SetupView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@StateObject private var finder = FrameFinder()
|
||||
@State private var password = ""
|
||||
@State private var showPassword = false
|
||||
@State private var manualHost = ""
|
||||
@State private var user = "steamos"
|
||||
@State private var showOther = false
|
||||
@@ -93,35 +92,13 @@ struct SetupView: View {
|
||||
// MARK: step 2
|
||||
|
||||
@ViewBuilder private var passwordStep: some View {
|
||||
HStack(spacing: 8) {
|
||||
Group {
|
||||
if showPassword {
|
||||
TextField("Developer Mode password", text: $password)
|
||||
} else {
|
||||
SecureField("Developer Mode password", text: $password)
|
||||
}
|
||||
}
|
||||
SecureField("Developer Mode password", text: $password)
|
||||
.textContentType(.password)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.go)
|
||||
.focused($passwordFocused)
|
||||
.onSubmit(connect)
|
||||
.padding(.vertical, 12)
|
||||
Button {
|
||||
showPassword.toggle()
|
||||
passwordFocused = true // keep typing after the field swaps
|
||||
} label: {
|
||||
Image(systemName: showPassword ? "eye.slash" : "eye").foregroundStyle(Color.frameMuted)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(showPassword ? "Hide password" : "Show password")
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.trailing, 2)
|
||||
.background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10))
|
||||
.padding(12)
|
||||
.background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10))
|
||||
Text("Haven't set one? On the Frame: Steam Settings → Developer → Set User Password. It's only used now, to let this \(model.deviceName) in; it isn't saved.")
|
||||
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
Button(action: connect) {
|
||||
|
||||
@@ -59,7 +59,6 @@ struct WebShell: UIViewRepresentable {
|
||||
setUpConnection: () => call("setUpConnection"),
|
||||
open: (what) => call("open", what),
|
||||
saveImages: (images) => call("saveImages", images),
|
||||
screenshotDownloads: () => call("screenshotDownloads"),
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady", window.__frameDocumentID); },
|
||||
};
|
||||
window.__frameInstallLink = (req) => { if (!installCb) return false; installCb(req); return true; };
|
||||
@@ -163,9 +162,6 @@ struct WebShell: UIViewRepresentable {
|
||||
guard !images.isEmpty else { return replyHandler(nil, "No images to save") }
|
||||
share(images)
|
||||
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
|
||||
case "screenshotDownloads":
|
||||
model.showScreenshotDownloads = true
|
||||
replyHandler(true, nil)
|
||||
case "installLinkReady":
|
||||
guard let id = arg as? String, let web,
|
||||
let data = try? JSONSerialization.data(withJSONObject: id, options: .fragmentsAllowed),
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
@testable import Frame_Control
|
||||
|
||||
@MainActor
|
||||
final class ScreenshotSyncTests: XCTestCase {
|
||||
private let old = HeadsetScreenshot(id: "1/10/20261024010000_1.jpg", size: 3, time: 1)
|
||||
private let new = HeadsetScreenshot(id: "1/10/20261024010001_1.jpg", size: 3, time: 2)
|
||||
|
||||
private func temporary() throws -> URL {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
||||
return root
|
||||
}
|
||||
|
||||
func testBaselineStabilityAtomicOriginalAndRestartDedup() async throws {
|
||||
let root = try temporary()
|
||||
let engine = try ScreenshotSyncEngine(root: root, photos: true)
|
||||
var downloads = 0, exports = 0
|
||||
let fetch: (HeadsetScreenshot) async throws -> Data = { _ in downloads += 1; return Data([1, 2, 3]) }
|
||||
let save: (URL, () -> Bool) async throws -> Void = { file, allowed in
|
||||
XCTAssertTrue(allowed())
|
||||
XCTAssertEqual(try Data(contentsOf: file), Data([1, 2, 3]))
|
||||
exports += 1
|
||||
}
|
||||
try await engine.reconcile([old], download: fetch, validate: { _ in true }, export: save)
|
||||
try await engine.reconcile([old, new], download: fetch, validate: { _ in true }, export: save)
|
||||
XCTAssertEqual(downloads, 0, "Do not import history or read a file still being written")
|
||||
try await engine.reconcile([old, new, new], download: fetch, validate: { _ in true }, export: save)
|
||||
XCTAssertEqual(downloads, 1)
|
||||
XCTAssertEqual(exports, 1)
|
||||
let restarted = try ScreenshotSyncEngine(root: root, photos: true)
|
||||
try await restarted.reconcile([old, new], download: fetch, validate: { _ in true }, export: save)
|
||||
XCTAssertEqual(downloads, 1)
|
||||
XCTAssertEqual(exports, 1)
|
||||
XCTAssertEqual(restarted.journal.receipts[new.id]?.destination, .saved)
|
||||
}
|
||||
|
||||
func testInterruptedDownloadRetriesAndAppOnlyNeverExportsLater() async throws {
|
||||
let root = try temporary()
|
||||
let engine = try ScreenshotSyncEngine(root: root, photos: false)
|
||||
let fetch: (HeadsetScreenshot) async throws -> Data = { _ in Data([1, 2, 3]) }
|
||||
let noExport: (URL, () -> Bool) async throws -> Void = { _, _ in XCTFail("App-only item exported") }
|
||||
try await engine.reconcile([], download: fetch, validate: { _ in true }, export: noExport)
|
||||
try await engine.reconcile([new], download: fetch, validate: { _ in true }, export: noExport)
|
||||
do {
|
||||
try await engine.reconcile([new], download: { _ in Data([1]) }, validate: { _ in true }, export: noExport)
|
||||
XCTFail("Truncated original accepted")
|
||||
} catch {}
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: engine.file(for: new.id).path))
|
||||
let restarted = try ScreenshotSyncEngine(root: root, photos: true)
|
||||
try await restarted.reconcile([new], download: fetch, validate: { _ in true }, export: noExport)
|
||||
try await restarted.reconcile([new], download: fetch, validate: { _ in true }, export: noExport)
|
||||
XCTAssertEqual(restarted.journal.receipts[new.id]?.destination, .appOnly)
|
||||
XCTAssertTrue(restarted.journal.receipts[new.id]!.downloaded)
|
||||
}
|
||||
|
||||
func testDestinationChangeDuringDownloadAndPermissionPrompt() async throws {
|
||||
let engine = try ScreenshotSyncEngine(root: temporary(), photos: true)
|
||||
let noExport: (URL, () -> Bool) async throws -> Void = { _, _ in XCTFail("Destination changed while downloading") }
|
||||
try await engine.reconcile([], download: { _ in Data() }, validate: { _ in true }, export: noExport)
|
||||
try await engine.reconcile([new], download: { _ in Data() }, validate: { _ in true }, export: noExport)
|
||||
try await engine.reconcile([new], download: { _ in
|
||||
try engine.setPhotos(false)
|
||||
try engine.setPhotos(true)
|
||||
return Data([1, 2, 3])
|
||||
}, validate: { _ in true }, export: noExport)
|
||||
XCTAssertEqual(engine.journal.receipts[new.id]?.destination, .appOnly)
|
||||
|
||||
let other = HeadsetScreenshot(id: "2/10/20261024010001_1.jpg", size: 3, time: 2)
|
||||
try await engine.reconcile([new, other], download: { _ in Data() }, validate: { _ in true }, export: noExport)
|
||||
do {
|
||||
try await engine.reconcile([new, other], download: { _ in Data([1, 2, 3]) }, validate: { _ in true }, export: { _, allowed in
|
||||
try engine.setPhotos(false)
|
||||
try engine.setPhotos(true)
|
||||
XCTAssertFalse(allowed(), "Re-enabling must not revive an export cancelled during consent")
|
||||
throw CancellationError()
|
||||
})
|
||||
} catch {}
|
||||
XCTAssertEqual(engine.journal.receipts[other.id]?.destination, .appOnly)
|
||||
}
|
||||
|
||||
func testCancellationRejectsLateOriginalAndIdentityIncludesAccountAndApp() async throws {
|
||||
let engine = try ScreenshotSyncEngine(root: temporary(), photos: false)
|
||||
let variants = [new, HeadsetScreenshot(id: "2/10/20261024010001_1.jpg", size: 3, time: 2),
|
||||
HeadsetScreenshot(id: "1/20/20261024010001_1.jpg", size: 3, time: 2)]
|
||||
let save: (URL, () -> Bool) async throws -> Void = { _, _ in XCTFail("Unexpected export") }
|
||||
try await engine.reconcile([], download: { _ in Data() }, validate: { _ in true }, export: save)
|
||||
try await engine.reconcile(variants, download: { _ in Data() }, validate: { _ in true }, export: save)
|
||||
try await engine.reconcile(variants, download: { _ in engine.stop(); return Data([1, 2, 3]) }, validate: { _ in true }, export: save)
|
||||
XCTAssertEqual(engine.journal.receipts.count, 3)
|
||||
XCTAssertTrue(engine.journal.receipts.values.allSatisfy { !$0.downloaded })
|
||||
XCTAssertNotEqual(engine.file(for: variants[0].id), engine.file(for: variants[1].id))
|
||||
XCTAssertNotEqual(engine.file(for: variants[0].id), engine.file(for: variants[2].id))
|
||||
}
|
||||
|
||||
func testUncertainPhotosCommitIsNotDuplicatedAndCorruptLedgerFailsClosed() async throws {
|
||||
let root = try temporary()
|
||||
var journal = ScreenshotJournal()
|
||||
journal.observe([], photos: true)
|
||||
journal.observe([new], photos: true)
|
||||
journal.receipts[new.id]?.downloaded = true
|
||||
journal.receipts[new.id]?.destination = .committing
|
||||
try JSONEncoder().encode(journal).write(to: root.appendingPathComponent("receipts.json"))
|
||||
let engine = try ScreenshotSyncEngine(root: root, photos: true)
|
||||
try await engine.reconcile([new], download: { _ in XCTFail(); return Data() }, validate: { _ in true }, export: { _, _ in XCTFail("Possible duplicate Photos asset") })
|
||||
XCTAssertEqual(engine.journal.receipts[new.id]?.destination, .committing)
|
||||
try Data("broken".utf8).write(to: root.appendingPathComponent("receipts.json"))
|
||||
XCTAssertThrowsError(try ScreenshotSyncEngine(root: root, photos: true))
|
||||
XCTAssertFalse(HeadsetScreenshot(id: "../../private.jpg", size: 3, time: 2).valid)
|
||||
}
|
||||
|
||||
func testPhotosFailureRetainsOriginalAndRetriesOnlyExport() async throws {
|
||||
let root = try temporary()
|
||||
let engine = try ScreenshotSyncEngine(root: root, photos: true)
|
||||
var downloads = 0, exports = 0
|
||||
let fetch: (HeadsetScreenshot) async throws -> Data = { _ in downloads += 1; return Data([1, 2, 3]) }
|
||||
let denied: (URL, () -> Bool) async throws -> Void = { _, _ in throw CocoaError(.fileWriteNoPermission) }
|
||||
try await engine.reconcile([], download: fetch, validate: { _ in true }, export: denied)
|
||||
try await engine.reconcile([new], download: fetch, validate: { _ in true }, export: denied)
|
||||
do {
|
||||
try await engine.reconcile([new], download: fetch, validate: { _ in true }, export: denied)
|
||||
XCTFail("Denied export reported success")
|
||||
} catch {}
|
||||
XCTAssertEqual(downloads, 1)
|
||||
XCTAssertEqual(engine.journal.receipts[new.id]?.destination, .pending)
|
||||
XCTAssertEqual(try Data(contentsOf: engine.file(for: new.id)), Data([1, 2, 3]))
|
||||
let restarted = try ScreenshotSyncEngine(root: root, photos: true)
|
||||
try await restarted.reconcile([], download: fetch, validate: { _ in true }, export: { _, allowed in
|
||||
XCTAssertTrue(allowed()); exports += 1
|
||||
})
|
||||
XCTAssertEqual(downloads, 1)
|
||||
XCTAssertEqual(exports, 1)
|
||||
XCTAssertEqual(restarted.journal.receipts[new.id]?.destination, .saved)
|
||||
}
|
||||
|
||||
func testLateExportSuccessAndFailureCannotOverwriteReplacementJournal() async throws {
|
||||
for succeeds in [true, false] {
|
||||
let root = try temporary()
|
||||
let oldEngine = try ScreenshotSyncEngine(root: root, photos: true)
|
||||
let fetch: (HeadsetScreenshot) async throws -> Data = { _ in Data([1, 2, 3]) }
|
||||
let noop: (URL, () -> Bool) async throws -> Void = { _, _ in }
|
||||
try await oldEngine.reconcile([], download: fetch, validate: { _ in true }, export: noop)
|
||||
try await oldEngine.reconcile([new], download: fetch, validate: { _ in true }, export: noop)
|
||||
var suspended: CheckedContinuation<Void, Never>?
|
||||
let task = Task {
|
||||
try await oldEngine.reconcile([new], download: fetch, validate: { _ in true }, export: { _, allowed in
|
||||
await withCheckedContinuation { suspended = $0 }
|
||||
XCTAssertFalse(allowed(), "Stopped export must lose eligibility")
|
||||
if !succeeds { throw CocoaError(.fileWriteNoPermission) }
|
||||
})
|
||||
}
|
||||
while suspended == nil { await Task.yield() }
|
||||
oldEngine.stop()
|
||||
let replacement = try ScreenshotSyncEngine(root: root, photos: false)
|
||||
let other = HeadsetScreenshot(id: "2/10/20261024010001_1.jpg", size: 3, time: 3)
|
||||
try await replacement.reconcile([new, other], download: fetch, validate: { _ in true }, export: noop)
|
||||
try await replacement.reconcile([new, other], download: fetch, validate: { _ in true }, export: noop)
|
||||
suspended!.resume()
|
||||
try await task.value
|
||||
let reloaded = try ScreenshotSyncEngine(root: root, photos: true)
|
||||
XCTAssertEqual(reloaded.journal.receipts.count, 2)
|
||||
XCTAssertEqual(reloaded.journal.receipts[other.id]?.destination, .appOnly)
|
||||
XCTAssertTrue(reloaded.journal.receipts[other.id]!.downloaded)
|
||||
XCTAssertEqual(reloaded.journal.receipts[new.id]?.destination, .appOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func testDefaultPreferenceAndOfflineAppOnlyLibraryAfterRelaunch() throws {
|
||||
let root = try temporary()
|
||||
let suite = "frame-screenshot-test-\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let downloads = ScreenshotDownloads(defaults: defaults, storageRoot: root)
|
||||
XCTAssertTrue(downloads.saveToPhotos)
|
||||
downloads.setPhotos(false)
|
||||
let identity = "test-headset-pinned-key"
|
||||
let digest = SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
let folder = root.appendingPathComponent("Screenshots/\(digest)")
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
var ledger = ScreenshotJournal()
|
||||
ledger.observe([], photos: false)
|
||||
ledger.observe([new], photos: false)
|
||||
ledger.receipts[new.id]?.downloaded = true
|
||||
try JSONEncoder().encode(ledger).write(to: folder.appendingPathComponent("receipts.json"))
|
||||
let file = folder.appendingPathComponent(new.id)
|
||||
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try Data([1, 2, 3]).write(to: file)
|
||||
let restarted = ScreenshotDownloads(defaults: defaults, storageRoot: root)
|
||||
XCTAssertFalse(restarted.saveToPhotos)
|
||||
restarted.load(identity: identity) // No URL, connection or network task.
|
||||
XCTAssertEqual(restarted.items.count, 1)
|
||||
XCTAssertEqual(restarted.items[0].status, "In Frame Control")
|
||||
XCTAssertEqual(try Data(contentsOf: restarted.items[0].file), Data([1, 2, 3]))
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -34,7 +34,6 @@ targets:
|
||||
path: FrameControl/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Frame Control
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
UILaunchScreen:
|
||||
UIColorName: ""
|
||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||
@@ -42,7 +41,7 @@ targets:
|
||||
UIUserInterfaceStyle: Dark
|
||||
NSLocalNetworkUsageDescription: Frame Control finds your Steam Frame on your network and connects to it.
|
||||
NSBonjourServices: [_steamos-devkit._tcp]
|
||||
NSPhotoLibraryAddUsageDescription: Frame Control automatically saves new headset screenshots to Photos when Save screenshots to Photos is enabled.
|
||||
NSPhotoLibraryAddUsageDescription: Frame Control saves headset captures and screenshots to your photo library when you ask it to.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsLocalNetworking: true
|
||||
LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/bin/zsh
|
||||
# Archive Frame Control and upload it to TestFlight.
|
||||
#
|
||||
# Needs a paid Apple Developer Program team and an App Store Connect API key
|
||||
# (Users and Access → Integrations → App Store Connect API). This Mac needs
|
||||
# Admin access for Apple's cloud-managed distribution signing; App Manager
|
||||
# alone was rejected with "Cloud signing permission error". See docs/iphone.md.
|
||||
#
|
||||
# ASC_KEY_ID=ABC123 ASC_ISSUER_ID=uuid ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABC123.p8 \
|
||||
# ios/scripts/testflight.sh
|
||||
#
|
||||
# TEAM_ID defaults to the team in this Mac's signing certificate. The build
|
||||
# number is the current UTC time, so every upload is higher than the last.
|
||||
set -euo pipefail
|
||||
|
||||
: "${ASC_KEY_ID:?set ASC_KEY_ID}" "${ASC_ISSUER_ID:?set ASC_ISSUER_ID}" "${ASC_KEY_PATH:?set ASC_KEY_PATH}"
|
||||
TEAM_ID="${TEAM_ID:-NJZUMEA4BN}"
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-$(date -u +%Y%m%d%H%M)}"
|
||||
# Resolve the key before changing directory; expand a quoted leading ~/.
|
||||
key_path="${ASC_KEY_PATH/#\~\//$HOME/}"
|
||||
key_path="${key_path:A}"
|
||||
[[ -f "$key_path" ]] || { echo "no API key at $key_path" >&2; exit 1; }
|
||||
|
||||
cd "${0:A:h}/.."
|
||||
out="build/testflight/$BUILD_NUMBER"
|
||||
mkdir -p "$out"
|
||||
|
||||
auth=(-allowProvisioningUpdates
|
||||
-authenticationKeyPath "$key_path"
|
||||
-authenticationKeyID "$ASC_KEY_ID"
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID")
|
||||
|
||||
xcodegen generate
|
||||
xcodebuild archive -project FrameControl.xcodeproj -scheme FrameControl \
|
||||
-configuration Release -destination generic/platform=iOS \
|
||||
-archivePath "$out/FrameControl.xcarchive" \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" CODE_SIGN_STYLE=Automatic \
|
||||
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" "${auth[@]}"
|
||||
|
||||
# Check the archived build number, not just the argument passed.
|
||||
archived=$(plutil -extract CFBundleVersion raw \
|
||||
"$out/FrameControl.xcarchive/Products/Applications/Frame Control.app/Info.plist")
|
||||
[[ "$archived" == "$BUILD_NUMBER" ]] || { echo "archived build $archived, expected $BUILD_NUMBER" >&2; exit 1; }
|
||||
|
||||
cat > "$out/ExportOptions.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>method</key><string>app-store-connect</string>
|
||||
<key>destination</key><string>upload</string>
|
||||
<key>teamID</key><string>$TEAM_ID</string>
|
||||
<key>signingStyle</key><string>automatic</string>
|
||||
<key>manageAppVersionAndBuildNumber</key><false/>
|
||||
</dict></plist>
|
||||
EOF
|
||||
|
||||
xcodebuild -exportArchive -archivePath "$out/FrameControl.xcarchive" \
|
||||
-exportOptionsPlist "$out/ExportOptions.plist" -exportPath "$out/export" "${auth[@]}"
|
||||
|
||||
echo "Uploaded build $BUILD_NUMBER. It shows in TestFlight once App Store Connect finishes processing."
|
||||
@@ -18,15 +18,11 @@ final class VirtualDisplay {
|
||||
static let refreshRate = Double(ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_VD_HZ"] ?? "") ?? 60
|
||||
private var display: CGVirtualDisplay?
|
||||
let id: CGDirectDisplayID
|
||||
/// The size asked for, in points (the display starts in another mode and
|
||||
/// switches a moment later).
|
||||
let size: (width: Int, height: Int)
|
||||
/// Removal is deferred while the Mac's screen sleeps, and an identity
|
||||
/// can't be reused until it's gone, so each display gets a fresh serial.
|
||||
private static var serial = (UInt32(truncatingIfNeeded: ProcessInfo.processInfo.processIdentifier) & 0xFFFF) << 12
|
||||
|
||||
init?(name: String, width: Int, height: Int) {
|
||||
size = (width, height)
|
||||
var made: CGVirtualDisplay?
|
||||
for _ in 0..<8 where made == nil {
|
||||
VirtualDisplay.serial &+= 1
|
||||
@@ -44,9 +40,6 @@ final class VirtualDisplay {
|
||||
let s = CGVirtualDisplaySettings()
|
||||
s.hiDPI = 1
|
||||
let hz = VirtualDisplay.refreshRate
|
||||
// Both modes: macOS then starts the display at 2x by itself, as long
|
||||
// as it's at least 800x600 points; smaller, its 2x mode is never
|
||||
// usable (probed 2026-09-28 on macOS 26.5.2).
|
||||
s.modes = [CGVirtualDisplayMode(width: UInt32(2 * width), height: UInt32(2 * height), refreshRate: hz)!,
|
||||
CGVirtualDisplayMode(width: UInt32(width), height: UInt32(height), refreshRate: hz)!]
|
||||
if vd.apply(s), vd.displayID != 0 { made = vd }
|
||||
@@ -54,89 +47,23 @@ final class VirtualDisplay {
|
||||
guard let made else { return nil }
|
||||
display = made
|
||||
id = made.displayID
|
||||
}
|
||||
|
||||
/// Where it should sit: left of everything else, touching the leftmost
|
||||
/// display. Otherwise macOS slots each new display in next to the main
|
||||
/// one and pushes earlier ones along, and a window placed a moment before
|
||||
/// ends up on the wrong display (seen 2026-09-28 with two windows).
|
||||
private var wantOrigin: CGPoint?
|
||||
private var modesSeen = -1
|
||||
private var modeAskedAt = Date.distantPast
|
||||
|
||||
/// Applies the HiDPI mode and the position once macOS has registered the
|
||||
/// display (right after creation both fail: no modes yet, and error 1001
|
||||
/// for the origin). True once both are in place. Call until it is.
|
||||
func settle() -> Bool {
|
||||
if wantOrigin == nil {
|
||||
var ids = [CGDirectDisplayID](repeating: 0, count: 32)
|
||||
var n: UInt32 = 0
|
||||
CGGetOnlineDisplayList(32, &ids, &n)
|
||||
let others = ids.prefix(Int(n)).filter { $0 != id }.map { CGDisplayBounds($0) }
|
||||
let left = others.map(\.minX).min() ?? 0
|
||||
let top = others.first(where: { $0.minX == left })?.minY ?? 0
|
||||
wantOrigin = CGPoint(x: left - CGFloat(size.width), y: top)
|
||||
}
|
||||
let placed = bounds.origin == wantOrigin
|
||||
if isReady && placed { return true }
|
||||
let (width, height) = size
|
||||
// The HiDPI mode: `width` points drawn with twice the pixels.
|
||||
// Pick the HiDPI mode: `width` points drawn with twice the pixels.
|
||||
let modes = (CGDisplayCopyAllDisplayModes(id, [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary)
|
||||
as? [CGDisplayMode]) ?? []
|
||||
let hidpi = modes.first(where: {
|
||||
if let hidpi = modes.first(where: {
|
||||
$0.width == width && $0.height == height && $0.pixelWidth == 2 * width && $0.refreshRate == VirtualDisplay.refreshRate
|
||||
}) ?? modes.first(where: { $0.width == width && $0.height == height && $0.pixelWidth == 2 * width })
|
||||
// Separately: a refused position would take the mode change down with it.
|
||||
// A mode switch takes a moment; asking again restarts it.
|
||||
if !isReady, let hidpi, Date().timeIntervalSince(modeAskedAt) > 1.5 {
|
||||
modeAskedAt = Date()
|
||||
let r = CGDisplaySetDisplayMode(id, hidpi, nil) // only if macOS didn't start it at 2x
|
||||
agentLog("display \(id): set \(hidpi.width)x\(hidpi.height)@\(hidpi.pixelWidth) -> \(r.rawValue), "
|
||||
+ "usable \(hidpi.isUsableForDesktopGUI()), now \(CGDisplayCopyDisplayMode(id).map { "\($0.width)x\($0.height)@\($0.pixelWidth)" } ?? "?")")
|
||||
}
|
||||
if !placed, let o = wantOrigin {
|
||||
}) ?? modes.first(where: { $0.width == width && $0.height == height && $0.pixelWidth == 2 * width }) {
|
||||
var cfg: CGDisplayConfigRef?
|
||||
CGBeginDisplayConfiguration(&cfg)
|
||||
CGConfigureDisplayOrigin(cfg, id, Int32(o.x), Int32(o.y))
|
||||
CGConfigureDisplayWithDisplayMode(cfg, id, hidpi, nil)
|
||||
CGCompleteDisplayConfiguration(cfg, .forSession)
|
||||
}
|
||||
if modesSeen != modes.count {
|
||||
modesSeen = modes.count
|
||||
let cur = CGDisplayCopyDisplayMode(id).map { "\($0.width)x\($0.height)@\($0.pixelWidth)" } ?? "?"
|
||||
agentLog("display \(id): \(modes.count) modes [\(modes.map { "\($0.width)x\($0.height)@\($0.pixelWidth)\($0.isUsableForDesktopGUI() ? "u" : "")" }.joined(separator: " "))], "
|
||||
+ "hidpi \(hidpi.map { "\($0.width)x\($0.height)@\($0.pixelWidth)" } ?? "none"), current \(cur), now \(bounds)")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var bounds: CGRect { CGDisplayBounds(id) }
|
||||
|
||||
/// Moves several of our displays at once (one configuration, so they
|
||||
/// never land on each other half way), retrying for a second until
|
||||
/// macOS has them all where asked.
|
||||
static func arrange(_ origins: [(VirtualDisplay, CGPoint)], attempt: Int = 0) {
|
||||
let pending = origins.filter { $0.0.bounds.origin != $0.1 }
|
||||
guard !pending.isEmpty else { return }
|
||||
for (vd, p) in origins { vd.wantOrigin = p }
|
||||
var cfg: CGDisplayConfigRef?
|
||||
CGBeginDisplayConfiguration(&cfg)
|
||||
for (vd, p) in origins { CGConfigureDisplayOrigin(cfg, vd.id, Int32(p.x), Int32(p.y)) }
|
||||
CGCompleteDisplayConfiguration(cfg, .forSession)
|
||||
guard attempt < 10 else {
|
||||
agentLog("arrange: \(pending.count) displays not where asked")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { arrange(origins, attempt: attempt + 1) }
|
||||
}
|
||||
|
||||
var isHiDPI: Bool { CGDisplayCopyDisplayMode(id).map { $0.pixelWidth > $0.width } ?? false }
|
||||
|
||||
/// In the mode asked for: HiDPI at the requested size.
|
||||
var isReady: Bool {
|
||||
guard let m = CGDisplayCopyDisplayMode(id) else { return false }
|
||||
return m.width == size.width && m.height == size.height && m.pixelWidth == 2 * size.width
|
||||
}
|
||||
|
||||
var screen: NSScreen? {
|
||||
NSScreen.screens.first {
|
||||
($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == id
|
||||
@@ -223,7 +150,6 @@ final class SeparateSource: CaptureSource {
|
||||
|
||||
var frameRect: CGRect { inner?.frameRect ?? .zero }
|
||||
var displayID: CGDirectDisplayID? { display?.id }
|
||||
var virtualDisplay: VirtualDisplay? { display }
|
||||
|
||||
func start(maxLong: Int, fps: Int, completion: @escaping (String?) -> Void) {
|
||||
guard AXIsProcessTrusted() else {
|
||||
@@ -244,18 +170,14 @@ final class SeparateSource: CaptureSource {
|
||||
if w.isFullScreen { return completion("take the window out of full screen first") }
|
||||
// A display the window's size, plus room for the menu bar, in
|
||||
// points; within what a panel can show sharply.
|
||||
// At least 800x600 points: smaller displays don't get a usable 2x
|
||||
// (sharp) mode on macOS 26. A smaller window is resized to fill it,
|
||||
// or cropped if it keeps its own size.
|
||||
let width = min(max(Int(frame.width.rounded()), 800), 2560) / 2 * 2
|
||||
let height = min(max(Int(frame.height.rounded()) + 40, 600), 1600) / 2 * 2
|
||||
let width = min(max(Int(frame.width.rounded()), 640), 2560) / 2 * 2
|
||||
let height = min(max(Int(frame.height.rounded()) + 40, 480), 1600) / 2 * 2
|
||||
guard let vd = VirtualDisplay(name: app.isEmpty ? "Frame Control" : "\(app) (Frame)", width: width, height: height) else {
|
||||
return completion("macOS wouldn't create a display for this window")
|
||||
}
|
||||
display = vd
|
||||
window = w
|
||||
original = frame
|
||||
agentLog("separate \(windowID) (\(app)): display \(vd.id) \(width)x\(height) pt for window \(frame)")
|
||||
// The new screen shows up in NSScreen a moment later; its menu bar
|
||||
// decides where the window can go.
|
||||
placeWindow(attempt: 0, maxLong: maxLong, fps: fps, completion: completion)
|
||||
@@ -266,22 +188,14 @@ final class SeparateSource: CaptureSource {
|
||||
guard !stopped, let vd = display, let w = window else { return }
|
||||
// Wait for the screen to appear, and for its HiDPI mode, so the
|
||||
// first frames are captured sharp.
|
||||
if !vd.settle() || vd.screen == nil, attempt < 30 {
|
||||
if vd.screen == nil || !vd.isHiDPI, attempt < 30 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self.placeWindow(attempt: attempt + 1, maxLong: maxLong, fps: fps, completion: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
let target = vd.usableRect // read now: displays may have moved meanwhile
|
||||
let target = vd.usableRect
|
||||
w.setFrame(target)
|
||||
if let now = w.frame, !vd.bounds.contains(CGPoint(x: now.midX, y: now.minY + 10)), attempt < 30 {
|
||||
// The arrangement changed under us: try again in a moment.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self.placeWindow(attempt: attempt + 1, maxLong: maxLong, fps: fps, completion: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
agentLog("separate \(windowID): screen after \(attempt) waits, ready \(vd.isReady), display \(vd.bounds), target \(target), window now \(w.frame.map { "\($0)" } ?? "?")")
|
||||
guard let now = w.frame, vd.bounds.intersects(now) else {
|
||||
restore()
|
||||
return completion("macOS didn't let the window move to its own display (Stage Manager can prevent this)")
|
||||
|
||||
@@ -36,8 +36,8 @@ func windowsJSON() -> [[String: Any]] {
|
||||
$0.layer == 0 && $0.pid != me && !skip.contains($0.app) && $0.bounds.width >= 120 && $0.bounds.height >= 80
|
||||
}.map {
|
||||
["id": $0.id, "src": "window:\($0.id)", "pid": $0.pid, "app": $0.app, "title": $0.title,
|
||||
"w": Int($0.bounds.width), "h": Int($0.bounds.height), "x": Int($0.bounds.minX), "y": Int($0.bounds.minY)]
|
||||
} // front to back, as CGWindowList gives them
|
||||
"w": Int($0.bounds.width), "h": Int($0.bounds.height)]
|
||||
}
|
||||
}
|
||||
|
||||
func displaysJSON() -> [[String: Any]] {
|
||||
@@ -50,7 +50,7 @@ func displaysJSON() -> [[String: Any]] {
|
||||
}.map { id in
|
||||
let b = CGDisplayBounds(id)
|
||||
return ["id": id, "src": "display:\(id)", "name": displayName(id), "w": Int(b.width), "h": Int(b.height),
|
||||
"x": Int(b.minX), "y": Int(b.minY), "main": CGDisplayIsMain(id) != 0]
|
||||
"main": CGDisplayIsMain(id) != 0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +106,6 @@ final class Session {
|
||||
var onEnd: ((Session) -> Void)?
|
||||
var onAck: (() -> Void)?
|
||||
var onFinished: ((String) -> Void)?
|
||||
/// The Mac space's layout in the room (message "arrange").
|
||||
var onArrange: (([[String: Any]]) -> Void)?
|
||||
private let encodeQueue = DispatchQueue(label: "frame-mac-view.encode", qos: .userInteractive)
|
||||
|
||||
init(id: Int, source: Source, capture: CaptureSource, ws: WebSocket, codec: Codec, fps: Int, bitsPerPixel: Double,
|
||||
@@ -247,7 +245,6 @@ final class Session {
|
||||
capture.onFrame = { [weak self] pb, pts, shown in self?.offer(pb, pts: pts, capture: shown) }
|
||||
capture.onEnded = { [weak self] reason in
|
||||
guard let self else { return }
|
||||
agentLog("stream \(self.id) \(self.source.key) ended: \(reason)")
|
||||
self.ws.sendJSON(["t": "closed", "reason": reason])
|
||||
self.onFinished?(self.source.key)
|
||||
self.end()
|
||||
@@ -278,12 +275,8 @@ final class Session {
|
||||
}
|
||||
t.resume()
|
||||
statsTimer = t
|
||||
agentLog("stream \(id) \(source.key) starting")
|
||||
capture.start(maxLong: maxLong, fps: fps) { [weak self] error in
|
||||
guard let self else { return }
|
||||
agentLog("stream \(self.id) \(self.source.key) " + (error.map { "failed: \($0) (gone: \(self.capture.gone))" }
|
||||
?? "capturing \(Int(self.capture.frameRect.width))x\(Int(self.capture.frameRect.height)) pt"
|
||||
+ ((self.capture as? SeparateSource)?.displayID.map { " on display \($0)" } ?? "")))
|
||||
if let error {
|
||||
if self.capture.gone {
|
||||
// Final, like a window closing mid-stream: the viewer closes
|
||||
@@ -396,10 +389,6 @@ final class Session {
|
||||
guard !isStopped, let d = text.data(using: .utf8),
|
||||
let m = try? JSONSerialization.jsonObject(with: d) as? [String: Any], let t = m["t"] as? String else { return }
|
||||
if report(t, m) { return }
|
||||
if t == "arrange", let list = m["panels"] as? [[String: Any]] {
|
||||
onArrange?(list)
|
||||
return
|
||||
}
|
||||
let x = m["x"] as? Double ?? -1, y = m["y"] as? Double ?? -1
|
||||
if t == "ack" {
|
||||
onAck?()
|
||||
@@ -449,22 +438,13 @@ final class Session {
|
||||
case "release":
|
||||
Input.releaseAll(owner: id)
|
||||
case "focus":
|
||||
// Before keys meant for this window (the Mac space's text-size buttons).
|
||||
if case .window(let wid) = source, let pid = capture.pid { Input.focus(window: wid, pid: pid) }
|
||||
if case .separate(let wid) = source, let pid = capture.pid { Input.focus(window: wid, pid: pid) }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line to stderr (the agent's log), with the time.
|
||||
func agentLog(_ s: String) {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss.SSS"
|
||||
FileHandle.standardError.write(Data("\(f.string(from: Date())) \(s)\n".utf8))
|
||||
}
|
||||
|
||||
func randomKey() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 24)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
@@ -492,47 +472,11 @@ final class Agent {
|
||||
private var tickets: [String: (src: String, expiry: Date, key: String?)] = [:]
|
||||
/// reconnect key -> source, until Stop for that source.
|
||||
private var reconnectKeys: [String: String] = [:]
|
||||
/// Space keys (ui/mac-space.html): each lists the Mac's windows and
|
||||
/// displays and opens streams for any of them, until Stop All (or Stop
|
||||
/// for "space"). Handed out once, for a ticket for "space".
|
||||
private var spaceKeys = Set<String>()
|
||||
/// Sources that ended for good (the window closed), for Frame Control.
|
||||
private var finished = Set<String>()
|
||||
|
||||
/// Ends every stream, then exits once separated windows are back on the
|
||||
/// Mac's own screens (they're moved back, then their displays go 0.3 s later).
|
||||
/// Lays the separated windows' displays out around the main display the
|
||||
/// way their panels sit around the desktop panel in the room, so the
|
||||
/// Mac's own pointer crosses from one to the next where the panels are
|
||||
/// next to each other. `list`: [{src, x}], x in metres from the desktop
|
||||
/// panel's centre, right positive.
|
||||
func arrange(_ list: [[String: Any]]) {
|
||||
lock.lock()
|
||||
let bySrc = Dictionary(sessions.values.map { ($0.source.key, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
lock.unlock()
|
||||
var sources: [(SeparateSource, Double)] = []
|
||||
for e in list {
|
||||
guard let src = e["src"] as? String, let x = num(e["x"]),
|
||||
let sep = bySrc[src]?.capture as? SeparateSource else { continue }
|
||||
sources.append((sep, x))
|
||||
}
|
||||
DispatchQueue.main.async { // Separate mode's state lives on main
|
||||
let items: [(vd: VirtualDisplay, x: Double)] = sources.compactMap { sep, x in sep.virtualDisplay.map { (vd: $0, x: x) } }
|
||||
let main = CGDisplayBounds(CGMainDisplayID())
|
||||
var origins: [(VirtualDisplay, CGPoint)] = []
|
||||
var right = main.maxX, left = main.minX
|
||||
for (vd, _) in items.filter({ $0.x >= 0 }).sorted(by: { $0.x < $1.x }) {
|
||||
origins.append((vd, CGPoint(x: right, y: main.minY)))
|
||||
right += CGFloat(vd.size.width)
|
||||
}
|
||||
for (vd, _) in items.filter({ $0.x < 0 }).sorted(by: { $0.x > $1.x }) {
|
||||
left -= CGFloat(vd.size.width)
|
||||
origins.append((vd, CGPoint(x: left, y: main.minY)))
|
||||
}
|
||||
VirtualDisplay.arrange(origins)
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotent: SIGTERM and the parent going away can both call it, and a
|
||||
/// session already ended by /close may still be putting its window back.
|
||||
private var quitting = false
|
||||
@@ -580,26 +524,9 @@ final class Agent {
|
||||
return key
|
||||
}
|
||||
if let r = req.query["r"], reconnectKeys[r] == src { return r }
|
||||
if let s = req.query["s"], spaceKeys.contains(s) {
|
||||
let key = randomKey()
|
||||
reconnectKeys[key] = src
|
||||
return key
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isSpace(_ req: Request) -> Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return spaceKeys.contains(req.query["s"] ?? "")
|
||||
}
|
||||
|
||||
/// A page next to the viewer (the Mac space and its shared script); no secrets in them.
|
||||
private func sibling(_ name: String, _ type: String, _ c: HTTPConnection) {
|
||||
guard let page, let body = try? Data(contentsOf: page.deletingLastPathComponent().appendingPathComponent(name))
|
||||
else { return c.respond(404, text: "not found") }
|
||||
c.respond(200, body: body, type: type)
|
||||
}
|
||||
|
||||
/// The viewer has its reconnect key, so the ticket that led to it can't
|
||||
/// be used again (also when the ack comes over a reconnection).
|
||||
func acknowledged(key: String) {
|
||||
@@ -614,25 +541,6 @@ final class Agent {
|
||||
case ("GET", "/view"):
|
||||
guard let page, let body = try? Data(contentsOf: page) else { return c.respond(404, text: "no viewer page") }
|
||||
return c.respond(200, body: body, type: "text/html; charset=utf-8")
|
||||
case ("GET", "/space"): return sibling("mac-space.html", "text/html; charset=utf-8", c)
|
||||
case ("GET", "/mac-stream.js"): return sibling("mac-stream.js", "text/javascript; charset=utf-8", c)
|
||||
case ("GET", "/space/key"):
|
||||
// A ticket for "space", spent here: the page keeps the key in memory.
|
||||
lock.lock()
|
||||
let now = Date()
|
||||
tickets = tickets.filter { $0.value.expiry > now }
|
||||
guard let t = req.query["t"], let entry = tickets[t], entry.src == "space" else {
|
||||
lock.unlock()
|
||||
return c.respond(403, text: "forbidden")
|
||||
}
|
||||
tickets[t] = nil
|
||||
let key = randomKey()
|
||||
spaceKeys.insert(key)
|
||||
lock.unlock()
|
||||
return c.respond(json: ["key": key])
|
||||
case ("GET", "/space/windows"):
|
||||
guard isSpace(req) else { return c.respond(403, text: "forbidden") }
|
||||
return c.respond(json: ["windows": windowsJSON(), "displays": displaysJSON(), "input": Input.allowed])
|
||||
case ("GET", "/stream"):
|
||||
guard let src = Source(req.query["src"] ?? "") else { return c.respond(400, text: "bad src") }
|
||||
guard let key = mayStream(req, src: src.key) else { return c.respond(403, text: "forbidden") }
|
||||
@@ -663,13 +571,11 @@ final class Agent {
|
||||
case ("GET", "/displays"):
|
||||
c.respond(json: ["displays": displaysJSON()])
|
||||
case ("POST", "/ticket"):
|
||||
let isSpaceTicket = req.query["src"] == "space"
|
||||
guard isSpaceTicket || Source(req.query["src"] ?? "") != nil else { return c.respond(400, text: "bad src") }
|
||||
let key = isSpaceTicket ? "space" : Source(req.query["src"] ?? "")!.key
|
||||
guard let src = Source(req.query["src"] ?? "") else { return c.respond(400, text: "bad src") }
|
||||
let t = randomKey()
|
||||
lock.lock()
|
||||
tickets[t] = (key, Date().addingTimeInterval(60), nil)
|
||||
finished.remove(key)
|
||||
tickets[t] = (src.key, Date().addingTimeInterval(60), nil)
|
||||
finished.remove(src.key)
|
||||
lock.unlock()
|
||||
c.respond(json: ["ticket": t])
|
||||
case ("POST", "/close"):
|
||||
@@ -680,7 +586,6 @@ final class Agent {
|
||||
let matching = sessions.values.filter { src == nil || $0.source.key == src }
|
||||
reconnectKeys = reconnectKeys.filter { src != nil && $0.value != src }
|
||||
tickets = tickets.filter { src != nil && $0.value.src != src } // not yet used ones too
|
||||
if src == nil || src == "space" { spaceKeys.removeAll() }
|
||||
lock.unlock()
|
||||
for s in matching { s.ws.sendJSON(["t": "close"]) }
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { for s in matching { s.end() } }
|
||||
@@ -755,7 +660,6 @@ final class Agent {
|
||||
self.lock.unlock()
|
||||
}
|
||||
session.onAck = { [weak self] in self?.acknowledged(key: key) }
|
||||
session.onArrange = { [weak self] list in self?.arrange(list) }
|
||||
session.start(maxLong: maxLong, fps: fps)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" "$@"
|
||||
+124
-5
@@ -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__':
|
||||
|
||||
@@ -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>'''
|
||||
+154
-10
@@ -10,8 +10,11 @@ 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')
|
||||
@@ -117,7 +120,7 @@ def summarize(samples):
|
||||
return result
|
||||
|
||||
|
||||
def collect(status, count, interval):
|
||||
def collect(status, count, interval, emit=None):
|
||||
samples, before = [], None
|
||||
started = time.monotonic()
|
||||
stopped = None
|
||||
@@ -131,6 +134,8 @@ def collect(status, count, interval):
|
||||
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)}
|
||||
|
||||
@@ -139,6 +144,12 @@ 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.
|
||||
@@ -156,7 +167,9 @@ def parser():
|
||||
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
|
||||
|
||||
|
||||
@@ -171,35 +184,166 @@ def remote_payload():
|
||||
'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
|
||||
report = collect(frame_status, args.count, args.interval)
|
||||
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:
|
||||
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)
|
||||
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.csv:
|
||||
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:
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
# Website
|
||||
|
||||
The Frame Control website, <https://framecontrol.fun>, on Cloudflare Pages.
|
||||
The Pages project is `frame-control`; its fallback hostname is `frame-control.pages.dev`.
|
||||
The Frame Control website, <https://frame-control.pages.dev>, on Cloudflare Pages.
|
||||
|
||||
- `public/`: static pages. `/` is the landing page, `/feedback/` the feedback form, `/privacy/` the privacy note.
|
||||
- `functions/api/feedback.js`: `POST /api/feedback`, which turns the form into a GitHub issue labelled `feedback`.
|
||||
|
||||
@@ -25,16 +25,12 @@ async function overLimit(kv, key, limit, ttl) {
|
||||
|
||||
export async function onRequestPost({ request, env }) {
|
||||
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
|
||||
return json(503, { error: "Feedback isn't connected to GitHub yet. Use the GitHub link instead.", retrySafe: true });
|
||||
return json(503, { error: "Feedback isn't connected to GitHub yet. Use the GitHub link instead." });
|
||||
}
|
||||
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin) {
|
||||
try {
|
||||
if (new URL(origin).origin !== new URL(request.url).origin) throw new Error("Origin mismatch");
|
||||
} catch {
|
||||
return json(403, { error: "Send feedback from the website's form." });
|
||||
}
|
||||
if (origin && new URL(origin).host !== new URL(request.url).host) {
|
||||
return json(403, { error: "Send feedback from the website's form." });
|
||||
}
|
||||
|
||||
let input;
|
||||
@@ -67,31 +63,24 @@ export async function onRequestPost({ request, env }) {
|
||||
console.log(`Rate limit check failed: ${err}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
||||
accept: "application/vnd.github+json",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
"user-agent": "frame-control-website",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildIssue(checked.value)),
|
||||
});
|
||||
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
||||
accept: "application/vnd.github+json",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
"user-agent": "frame-control-website",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(buildIssue(checked.value)),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return json(502, { error: "GitHub did not confirm delivery. Check recent issues before sending again.", retrySafe: false });
|
||||
}
|
||||
const issue = await res.json();
|
||||
if (!Number.isInteger(issue.number) || typeof issue.html_url !== "string"
|
||||
|| issue.html_url !== `https://github.com/${env.GITHUB_REPO}/issues/${issue.number}`) {
|
||||
throw new Error("Invalid issue receipt");
|
||||
}
|
||||
return json(201, { ok: true, number: issue.number, url: issue.html_url });
|
||||
} catch {
|
||||
return json(502, { error: "Delivery could not be confirmed. Check recent issues before sending again.", retrySafe: false });
|
||||
if (!res.ok) {
|
||||
console.log(`GitHub answered ${res.status}: ${(await res.text()).slice(0, 500)}`);
|
||||
return json(502, { error: "GitHub didn't accept it just now. Try again, or use the GitHub link." });
|
||||
}
|
||||
const issue = await res.json();
|
||||
return json(201, { ok: true, number: issue.number, url: issue.html_url });
|
||||
}
|
||||
|
||||
export const onRequest = () => json(405, { error: "POST only." });
|
||||
@@ -79,8 +79,8 @@ export function buildIssue(value) {
|
||||
// Our own lines go first, so nothing in the sender's text can hide them.
|
||||
const lines = [
|
||||
value.github
|
||||
? `> Sent from the website feedback form. Provided GitHub username (not verified): @${value.github}.`
|
||||
: "> Sent from the website feedback form. No GitHub username provided; bookmark or subscribe to this issue to follow replies.",
|
||||
? `> Sent from the website feedback form by @${value.github}.`
|
||||
: "> Sent from the website feedback form. The sender left no GitHub username, so they won't see replies here.",
|
||||
"",
|
||||
];
|
||||
if (details.length) {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<meta name="theme-color" content="#0e141b">
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/css/site.css?v=20260930-deep1">
|
||||
<script src="/js/site.js?v=20260930-deep1" defer></script>
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
<script src="/js/site.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
--bg: #0e141b; --bg-2: #131c26; --panel: #17222e; --panel-2: #1d2a38; --line: rgba(143, 152, 160, .16);
|
||||
--text: #c7d0d8; --bright: #fff; --muted: #8f98a0; --dim: #5e6873;
|
||||
--blue: #1a9fff; --link: #66c0f4; --green: #75b022; --warn: #d9a23a; --bad: #e0573c;
|
||||
--action: linear-gradient(to right, #12699e 5%, #1a44c2 95%);
|
||||
--action-hi: linear-gradient(to right, #1676ad 5%, #2458d6 95%);
|
||||
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
|
||||
--action-hi: linear-gradient(to right, #5fcaff 5%, #2458d6 95%);
|
||||
--radius: 14px; --wrap: 1160px;
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
color-scheme: dark;
|
||||
@@ -38,19 +38,8 @@ code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rg
|
||||
.top nav a { color: var(--muted); font-size: 14.5px; font-weight: 500; }
|
||||
.top nav a:hover, .top nav a[aria-current] { color: var(--bright); }
|
||||
.top .end { margin-left: auto; display: flex; gap: 10px; align-items: center; }
|
||||
@media (max-width: 880px) {
|
||||
.top { position: static; }
|
||||
.top .wrap { flex-wrap: wrap; height: auto; gap: 12px; padding-top: 12px; padding-bottom: 12px; }
|
||||
.top nav { order: 3; width: 100%; margin-left: 0; gap: 16px; flex-wrap: wrap; }
|
||||
.top nav a { padding: 6px 0; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.top .end .ghost { display: none; }
|
||||
.top .wrap { padding-left: 16px; padding-right: 16px; gap: 8px; }
|
||||
.top .brand { font-size: 12px; letter-spacing: 1px; gap: 8px; }
|
||||
.top .brand img { width: 24px; height: 24px; }
|
||||
.top .end .btn { padding: 0 10px; }
|
||||
}
|
||||
@media (max-width: 880px) { .top nav { display: none; } }
|
||||
@media (max-width: 480px) { .top .end .ghost { display: none; } }
|
||||
|
||||
/* ---- buttons ---- */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 9px; height: 46px; padding: 0 22px; border-radius: 10px;
|
||||
@@ -64,8 +53,8 @@ code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rg
|
||||
.btn.ghost { background: transparent; border-color: var(--line); }
|
||||
.btn.ghost:hover { border-color: rgba(143,152,160,.4); background: rgba(255,255,255,.03); }
|
||||
.btn.small { height: 36px; padding: 0 14px; font-size: 14px; border-radius: 8px; }
|
||||
.btn.coffee { background: #ff5e5b; color: #0e141b; box-shadow: 0 8px 24px rgba(255, 94, 91, .22); }
|
||||
.btn.coffee:hover { background: #ff7471; color: #0e141b; }
|
||||
.btn.coffee { background: #ff5e5b; box-shadow: 0 8px 24px rgba(255, 94, 91, .22); }
|
||||
.btn.coffee:hover { background: #ff7471; }
|
||||
.btn svg { width: 18px; height: 18px; flex: none; }
|
||||
.btn[disabled] { opacity: .6; cursor: progress; transform: none; }
|
||||
|
||||
@@ -75,7 +64,6 @@ code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rg
|
||||
background: radial-gradient(600px 380px at 30% 30%, rgba(26,159,255,.22), transparent 70%),
|
||||
radial-gradient(520px 360px at 72% 20%, rgba(111,66,193,.2), transparent 70%); }
|
||||
.hero > * { position: relative; }
|
||||
.hero .cta .btn { max-width: 100%; min-height: 46px; height: auto; padding-top: 12px; padding-bottom: 12px; white-space: normal; }
|
||||
.eyebrow { display: inline-block; max-width: 100%; padding: 6px 14px; border-radius: 999px; font-size: 13.5px;
|
||||
color: var(--link); background: rgba(26,159,255,.1); border: 1px solid rgba(102,192,244,.22); margin-bottom: 26px; }
|
||||
.eyebrow b { color: var(--bright); font-weight: 600; }
|
||||
@@ -143,13 +131,10 @@ section.alt { background: var(--bg-2); border-block: 1px solid var(--line); }
|
||||
.dl .need { color: var(--muted); font-size: 14px; }
|
||||
.dl .links { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
|
||||
.dl .links .btn { width: 100%; }
|
||||
.dl > p { margin-top: 12px; font-size: 14px; }
|
||||
.dl code { overflow-wrap: anywhere; }
|
||||
.dl .tag { margin-left: auto; font-size: 11.5px; font-weight: 650; letter-spacing: 1px; text-transform: uppercase; color: var(--link); }
|
||||
|
||||
.faq { max-width: 780px; margin: 0 auto; display: grid; gap: 10px; }
|
||||
.faq details { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 0 22px; }
|
||||
.faq code { overflow-wrap: anywhere; }
|
||||
.faq summary { cursor: pointer; list-style: none; padding: 18px 0; color: var(--bright); font-weight: 600; display: flex; justify-content: space-between; gap: 16px; }
|
||||
.faq summary::-webkit-details-marker { display: none; }
|
||||
.faq summary::after { content: "+"; color: var(--muted); font-size: 22px; line-height: 1; transition: transform .2s; }
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
<meta name="theme-color" content="#0e141b">
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/css/site.css?v=20260930-deep1">
|
||||
<script src="/js/site.js?v=20260930-deep1" defer></script>
|
||||
<script src="/js/feedback.js?v=20260930-deep1" defer></script>
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
<script src="/js/site.js" defer></script>
|
||||
<script src="/js/feedback.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="github">GitHub username <small>Optional and not verified. Subscribe to the created issue on GitHub to follow replies.</small></label>
|
||||
<label for="github">GitHub username <small>Optional. Add it to get notified when someone replies.</small></label>
|
||||
<input type="text" id="github" name="github" maxlength="40" autocomplete="username" placeholder="@yourname">
|
||||
</div>
|
||||
|
||||
|
||||
+22
-39
@@ -11,12 +11,10 @@
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Frame Control">
|
||||
<meta property="og:description" content="Manage your Valve Steam Frame from your computer. Free and open source.">
|
||||
<meta property="og:image" content="https://framecontrol.fun/img/og.jpg">
|
||||
<link rel="canonical" href="https://framecontrol.fun/">
|
||||
<meta property="og:url" content="https://framecontrol.fun/">
|
||||
<meta property="og:image" content="/img/og.jpg">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="stylesheet" href="/css/site.css?v=20260930-deep1">
|
||||
<script src="/js/site.js?v=20260930-deep1" defer></script>
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
<script src="/js/site.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -43,21 +41,20 @@
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow"><b>Free and open source</b><span class="sep"> · </span><span class="plats">macOS · Windows · Linux</span></span>
|
||||
<span class="eyebrow"><b>Free and open source</b><span class="sep"> · </span><span class="plats">macOS · Windows · Linux · iPhone</span></span>
|
||||
<h1>Your Steam Frame, <span>managed from your desk.</span></h1>
|
||||
<p class="lede">Install Frame Control on your computer, not your headset. See what the Frame sees, install games and Android apps, move files and text across, and check battery and status.</p>
|
||||
<p class="lede">See what the headset sees, install games and Android apps, move files and text across, and keep an eye on battery and status. All over SSH, with nothing to install on the Frame.</p>
|
||||
<div class="cta">
|
||||
<a class="btn primary" id="hero-download" href="#download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 20h16"/></svg>
|
||||
<span data-label>Get started — download the app</span>
|
||||
<span data-label>Download Frame Control</span>
|
||||
</a>
|
||||
<a class="btn ghost" href="#trailer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg>
|
||||
Watch the trailer
|
||||
</a>
|
||||
</div>
|
||||
<p class="fine">Download → install and open → connect your headset. <a href="#setup">Setup guide</a></p>
|
||||
<p class="fine"><span hidden>Latest: <span data-version></span> · </span><a href="#download">All desktop downloads</a> · <a href="#iphone">iPhone developer build</a> · Not affiliated with Valve</p>
|
||||
<p class="fine"><span hidden>Latest: <span data-version></span> · </span><a href="#download">All platforms</a> · MIT licence · Not affiliated with Valve</p>
|
||||
<div class="shot">
|
||||
<div class="bar"><i></i><i></i><i></i></div>
|
||||
<img src="/img/home-live.jpg" width="1600" height="1000" alt="Frame Control's Home tab: a live view of the headset beside battery, storage, temperature and Wi-Fi.">
|
||||
@@ -171,12 +168,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="iphone">
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="kicker">iPhone and iPad</span>
|
||||
<h2>iPhone: a separate developer build</h2>
|
||||
<p>Not an App Store download. This option requires a Mac and Xcode, and runs a server on the Frame instead of your computer. <a href="https://github.com/saphid/frame-control/blob/main/docs/iphone.md">Read the iPhone build and pairing guide</a>. For the simplest start, use the desktop app below.</p>
|
||||
<h2>Also in your pocket</h2>
|
||||
<p>The same features on iPhone and iPad, served from the Frame itself, so there's nothing to run on a computer. Build it from the repo in Xcode.</p>
|
||||
</div>
|
||||
<div class="phones">
|
||||
<img src="/img/phone-home.jpg" width="600" height="1304" loading="lazy" alt="Frame Control on iPhone: headset view and device status.">
|
||||
@@ -189,22 +186,21 @@
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<span class="kicker">Setup</span>
|
||||
<h2>From download to connected</h2>
|
||||
<p>You'll need a computer and a Steam Frame, both on the same trusted network. Keep the headset awake while connecting.</p>
|
||||
<h2>One password, typed once</h2>
|
||||
<p>Everything else happens on your computer.</p>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="card">
|
||||
<h3>Install and open the app</h3>
|
||||
<p><a href="#download">Choose your computer's download below</a>, install it, then open <b>Frame Control</b>. You don't install the desktop app on the headset, and you don't need to clone GitHub or install Python.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Turn on Developer Mode</h3>
|
||||
<p>On the Frame: Steam Settings → System → <b>Enable Developer Mode</b>, then <b>Set User Password</b> in the Developer section.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Set up the connection</h3>
|
||||
<p>In Frame Control, choose <b>Set Up Connection</b> and enter the headset's password when asked. It creates an SSH key so you don't have to type it next time.</p>
|
||||
<p>Headset not found? Enter its IP address from the Frame's Quick Settings. For remote access, use <a href="https://github.com/saphid/frame-control/blob/main/docs/tailscale.md">Tailscale</a>.</p>
|
||||
<p>Open Frame Control and choose <b>Set Up Connection</b>. It finds the headset, makes an SSH key, and asks for that password once.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>That's it</h3>
|
||||
<p>The app reaches the headset whenever it's awake and on the same network. For anywhere else, use <a href="https://github.com/saphid/frame-control/blob/main/docs/tailscale.md">Tailscale</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,7 +211,7 @@
|
||||
<div class="head">
|
||||
<span class="kicker">Download</span>
|
||||
<h2>Get Frame Control</h2>
|
||||
<p>Download on your computer, then follow the opening steps on your card. Python is included; adb is included except on arm64 Linux. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
|
||||
<p>Free, with Python and adb built in. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
|
||||
</div>
|
||||
<div class="downloads">
|
||||
<div class="dl" data-os="mac">
|
||||
@@ -224,8 +220,6 @@
|
||||
<div class="links">
|
||||
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg">Download .dmg</a>
|
||||
</div>
|
||||
<p><b>Open:</b> open the .dmg, drag Frame Control to Applications, then open it from Applications.</p>
|
||||
<p class="need">Apple Silicon only, not Intel Macs. Unsigned and not notarized: <a href="#mac-first-launch">read the first-launch instructions</a> if macOS blocks it.</p>
|
||||
</div>
|
||||
<div class="dl" data-os="windows">
|
||||
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5 10 4.5v7H3zM11 4.35 21 3v8.5H11zM3 12.5h7v7L3 18.5zM11 12.5h10V21l-10-1.4z"/></svg><h3>Windows</h3><span class="tag" hidden>Your system</span></div>
|
||||
@@ -234,21 +228,15 @@
|
||||
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe">Download installer</a>
|
||||
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip">Portable .zip</a>
|
||||
</div>
|
||||
<p><b>Open:</b> run the installer, then launch Frame Control from Start. For the portable .zip, extract it first and run <code>Frame Control.exe</code>.</p>
|
||||
<p class="need">Unsigned: <a href="#windows-first-launch">read about SmartScreen</a> before proceeding past a warning.</p>
|
||||
</div>
|
||||
<div class="dl" data-os="linux">
|
||||
<div class="os"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/></svg><h3>Linux</h3><span class="tag" hidden>Your system</span></div>
|
||||
<span class="need">x64 or arm64 · needs <code>ssh</code></span>
|
||||
<p>On arm64 Linux, install <code>adb</code> from your distribution for Android apps: on Debian or Ubuntu, run <code>sudo apt install adb</code>.</p>
|
||||
<div class="links">
|
||||
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage">AppImage (x64)</a>
|
||||
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb">.deb (x64)</a>
|
||||
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage">AppImage (arm64)</a>
|
||||
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb">.deb (arm64)</a>
|
||||
</div>
|
||||
<p><b>Open:</b> for an AppImage, use your file manager's Properties → Permissions to allow executing it, then open it. For a .deb, install it with your package manager and launch Frame Control.</p>
|
||||
<p class="need"><a href="#linux-first-launch">AppImage won't open?</a> Then <a href="#setup">connect your headset</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,27 +249,22 @@
|
||||
<h2>Good questions</h2>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<details id="mac-first-launch">
|
||||
<details>
|
||||
<summary>macOS says the app is damaged or can't be checked</summary>
|
||||
<div><p>The macOS download isn't notarized. Drag it to Applications, then clear the download quarantine once in Terminal:</p>
|
||||
<div><p>The app isn't notarized, because there's no paid Apple developer account behind it. Drag it to Applications, then clear the download quarantine once in Terminal:</p>
|
||||
<p><code>xattr -dr com.apple.quarantine "/Applications/Frame Control.app"</code></p></div>
|
||||
</details>
|
||||
<details id="windows-first-launch">
|
||||
<details>
|
||||
<summary>Windows SmartScreen says it protected my PC</summary>
|
||||
<div><p>The installer isn't code-signed. Choose <b>More info → Run anyway</b>, or use the portable .zip: unzip it anywhere and run <code>Frame Control.exe</code>.</p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary id="linux-first-launch">Linux: the AppImage won't open</summary>
|
||||
<div><p>Make the file executable in your file manager, or in Terminal run <code>chmod +x Frame-Control-linux-x86_64.AppImage</code> and then <code>./Frame-Control-linux-x86_64.AppImage</code>. On arm64, use the arm64 filename instead.</p>
|
||||
<p>If FUSE is missing, use the .deb package or run the AppImage with <code>--appimage-extract-and-run</code>.</p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>What does it change on my headset?</summary>
|
||||
<div><p>Only what you click. Installs go to your user account on the Frame, and nothing needs <code>sudo</code> except the power buttons. On your computer it adds a <code>Host frame</code> entry to <code>~/.ssh/config</code> and two SSH keys.</p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Is it safe to leave Developer Mode on?</summary>
|
||||
<div><p>With Developer Mode on, SSH, ADB and remote desktop are reachable on your local network. Use trusted networks, turn Developer Mode off when you don't need it, and never port-forward those ports from your router. Tailscale is an advanced option; off-LAN access has not been verified. Read the <a href="https://github.com/saphid/frame-control/blob/main/docs/tailscale.md#exposure-every-port-is-on-the-tailnet">Tailscale exposure notes</a> before enabling it.</p></div>
|
||||
<div><p>With Developer Mode on, SSH, ADB and remote desktop are reachable on your local network. Use trusted networks, turn Developer Mode off when you don't need it, and never port-forward those ports from your router. For remote access, use Tailscale. <a href="https://github.com/saphid/frame-control#readme">Security notes</a></p></div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Is this made by Valve?</summary>
|
||||
|
||||
@@ -27,21 +27,19 @@ form.addEventListener("change", (e) => {
|
||||
function githubUrl(data) {
|
||||
const params = data.kind === "idea"
|
||||
? new URLSearchParams({ template: "idea.yml", title: data.title, what: data.message })
|
||||
: data.kind === "bug"
|
||||
? new URLSearchParams({ template: "bug.yml", title: data.title, description: data.message,
|
||||
version: data.version, os: [data.os, data.steamos && `SteamOS ${data.steamos}`].filter(Boolean).join(", ") })
|
||||
: new URLSearchParams({ title: data.title, body: data.message });
|
||||
: new URLSearchParams({ template: "bug.yml", title: data.title, description: data.message,
|
||||
version: data.version, os: [data.os, data.steamos && `SteamOS ${data.steamos}`].filter(Boolean).join(", ") });
|
||||
return `https://github.com/saphid/frame-control/issues/new?${params}`;
|
||||
}
|
||||
|
||||
function showError(message, data, uncertain = false) {
|
||||
function showError(message, data) {
|
||||
errorBox.textContent = message + " ";
|
||||
if (data) {
|
||||
const a = document.createElement("a");
|
||||
a.href = uncertain ? "https://github.com/saphid/frame-control/issues" : githubUrl(data);
|
||||
a.href = githubUrl(data);
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener";
|
||||
a.textContent = uncertain ? "Check recent issues before sending again" : "Open it on GitHub instead";
|
||||
a.textContent = "Open it on GitHub instead";
|
||||
errorBox.append(a);
|
||||
}
|
||||
errorBox.hidden = false;
|
||||
@@ -64,7 +62,6 @@ form.addEventListener("submit", async (e) => {
|
||||
|
||||
send.disabled = true;
|
||||
send.textContent = "Sending…";
|
||||
let uncertain = true;
|
||||
try {
|
||||
// The server treats anything sent sooner as a script (site/lib/feedback.js MIN_FILL_MS).
|
||||
const wait = 3100 - (performance.now() - started);
|
||||
@@ -75,11 +72,7 @@ form.addEventListener("submit", async (e) => {
|
||||
body: JSON.stringify({ ...data, elapsed: Math.round(performance.now() - started) }),
|
||||
});
|
||||
const reply = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
uncertain = reply.retrySafe === false || (reply.retrySafe !== true && res.status >= 500);
|
||||
throw new Error(reply.error || "Something went wrong sending that.");
|
||||
}
|
||||
if (!reply.ok) throw new Error("Delivery could not be confirmed.");
|
||||
if (!res.ok) throw new Error(reply.error || "Something went wrong sending that.");
|
||||
|
||||
form.hidden = true;
|
||||
document.getElementById("done").hidden = false;
|
||||
@@ -88,11 +81,11 @@ form.addEventListener("submit", async (e) => {
|
||||
link.href = reply.url;
|
||||
link.hidden = false;
|
||||
document.getElementById("done-text").textContent = `Your feedback is now issue #${reply.number} on GitHub.`
|
||||
+ " Bookmark it or subscribe on GitHub to follow replies.";
|
||||
+ (data.github ? " You'll be notified when someone replies." : " Bookmark it to follow along.");
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} catch (err) {
|
||||
showError(err.message || "Couldn't confirm delivery.", data, uncertain);
|
||||
showError(err.message || "Couldn't reach the server.", data);
|
||||
} finally {
|
||||
send.disabled = false;
|
||||
send.textContent = "Send feedback";
|
||||
|
||||
+5
-15
@@ -5,6 +5,8 @@ const SITE = {
|
||||
kofi: "alexsouthwell",
|
||||
};
|
||||
|
||||
const RELEASE = `https://github.com/${SITE.repo}/releases/latest/download/`;
|
||||
|
||||
// Donate buttons.
|
||||
for (const el of document.querySelectorAll("[data-kofi]")) {
|
||||
if (SITE.kofi) el.href = `https://ko-fi.com/${SITE.kofi}`;
|
||||
@@ -26,7 +28,7 @@ function detectPlatform() {
|
||||
}
|
||||
|
||||
const PLATFORMS = {
|
||||
mac: { name: "macOS (Apple Silicon)", file: "Frame-Control-mac-arm64.dmg" },
|
||||
mac: { name: "macOS", file: "Frame-Control-mac-arm64.dmg" },
|
||||
windows: { name: "Windows", file: "Frame-Control-Setup-x64.exe" },
|
||||
linux: { name: "Linux", file: "Frame-Control-linux-x86_64.AppImage" },
|
||||
"linux-arm": { name: "Linux (arm64)", file: "Frame-Control-linux-arm64.AppImage" },
|
||||
@@ -35,7 +37,8 @@ const PLATFORMS = {
|
||||
const platform = detectPlatform();
|
||||
const hero = document.getElementById("hero-download");
|
||||
if (hero && PLATFORMS[platform]) {
|
||||
hero.querySelector("[data-label]").textContent = `Get started on ${PLATFORMS[platform].name}`;
|
||||
hero.href = RELEASE + PLATFORMS[platform].file;
|
||||
hero.querySelector("[data-label]").textContent = `Download for ${PLATFORMS[platform].name}`;
|
||||
}
|
||||
const card = platform && document.querySelector(`.dl[data-os="${platform.replace("-arm", "")}"]`);
|
||||
if (card) {
|
||||
@@ -58,16 +61,3 @@ if (versionEls.length) {
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-year]").forEach((el) => (el.textContent = new Date().getFullYear()));
|
||||
|
||||
function revealLinkedAnswer(hash = location.hash) {
|
||||
const target = document.getElementById(hash.slice(1));
|
||||
const answer = target?.closest("details");
|
||||
if (answer) answer.open = true;
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", () => revealLinkedAnswer());
|
||||
document.addEventListener("click", (event) => {
|
||||
const link = event.target?.closest?.('a[href^="#"]');
|
||||
if (link) revealLinkedAnswer(link.getAttribute("href"));
|
||||
});
|
||||
revealLinkedAnswer();
|
||||
@@ -8,8 +8,8 @@
|
||||
<meta name="theme-color" content="#0e141b">
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/css/site.css?v=20260930-deep1">
|
||||
<script src="/js/site.js?v=20260930-deep1" defer></script>
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
<script src="/js/site.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -38,14 +38,12 @@
|
||||
<div class="head">
|
||||
<span class="kicker">Privacy</span>
|
||||
<h1>Privacy</h1>
|
||||
<p>Short version: the released app sends anonymous usage statistics after a first-run notice. You can turn them off. Website feedback becomes public on GitHub.</p>
|
||||
<p>Short version: the app collects nothing, and the website keeps only what you choose to send.</p>
|
||||
</div>
|
||||
<div class="faq notes">
|
||||
<section><h2>App usage statistics</h2><p>In v0.4.0, anonymous usage statistics are on by default after the first-run notice has been shown. They go to PostHog's US cloud and include a random persistent installation ID, app version, operating system, CPU architecture and Python version; app opens, updates, tabs used, headset SteamOS builds, and install outcomes. Public catalogue installs may include a Steam app ID, Flathub ID or F-Droid package name. Usage events exclude local filenames, paths and account data. Person profiles and location lookup are disabled, and events use a placeholder IP address; the receiving service still handles the network connection.</p><p>Turn statistics off in <b>Privacy & updates</b>. <b>Show what's been sent</b> lets you inspect sent events. Turning a level off removes its unsent events, not events already received. Source checkouts do not send automatic telemetry by default. <code>DO_NOT_TRACK=1</code> or <code>FRAME_CONTROL_TELEMETRY=0</code> blocks automatic telemetry, not reports you deliberately send through <b>Report a problem</b>.</p></section>
|
||||
<section><h2>Optional app reports</h2><p>Compatibility sharing and detailed error reports are off by default. Enabling compatibility sharing sends reports and tests, including earlier reports on this computer. Enabling error details sends scrubbed error messages and tracebacks. Review free text before sharing: automatic scrubbing is not a guarantee that every personal detail is removed. The app's <b>Report a problem</b> dialog deliberately sends a private report to the maintainer's PostHog project, independently of the usage-statistics setting. This differs from the public website form below. See the <a href="https://github.com/saphid/frame-control/blob/v0.4.0/docs/privacy.md">v0.4.0 privacy guide</a> for exact fields and controls.</p></section>
|
||||
<section><h2>Other app connections</h2><p>Frame Control connects to your headset and to services used by the features you choose, including GitHub for releases, Steam, F-Droid and Flathub for listings or downloads, and the sources of apps you choose to install. It does not require a Frame Control account.</p></section>
|
||||
<section><h2>The website feedback form</h2><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>, including any username and diagnostic details you provide. Do not include passwords, tokens or private information. To stop abuse, the form keeps a token-salted, one-way hash of your IP address in an hourly counter for up to 65 minutes, plus a non-identifying daily counter for up to 25 hours. The form does not store or publish the raw address; Cloudflare still processes network requests.</p></section>
|
||||
<section><h2>This website</h2><p>Hosted on Cloudflare Pages. The site itself sets no cookies and includes no analytics or tracking scripts. Cloudflare processes requests to serve and protect it. The download section asks GitHub for the latest release. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></section>
|
||||
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no analytics and no accounts.</p></section>
|
||||
<section><h2>The feedback form</h2><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>. To stop abuse, the form keeps a one-way hash of your IP address for about an hour to count submissions. The address itself isn't stored or published.</p></section>
|
||||
<section><h2>This website</h2><p>Hosted on Cloudflare Pages. No cookies, no analytics, no trackers. The download section asks GitHub for the latest version number. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const script = await readFile(new URL("../public/js/feedback.js", import.meta.url), "utf8");
|
||||
|
||||
function load(fetchResponse) {
|
||||
const handlers = {};
|
||||
const links = [];
|
||||
const form = { elements: { os: { value: "" } }, addEventListener: (name, handler) => { handlers[name] = handler; } };
|
||||
const errorBox = { hidden: true, append: (link) => links.push(link), scrollIntoView: () => {} };
|
||||
const send = { disabled: false, textContent: "Send feedback" };
|
||||
let elapsed = 0;
|
||||
const context = {
|
||||
navigator: { userAgent: "Windows" },
|
||||
performance: { now: () => elapsed },
|
||||
URLSearchParams,
|
||||
fetch: fetchResponse,
|
||||
window: { scrollTo: () => {} },
|
||||
document: {
|
||||
getElementById: (id) => ({ feedback: form, error: errorBox, send })[id] || {},
|
||||
createElement: () => ({}),
|
||||
},
|
||||
FormData: class extends Map {
|
||||
constructor() { super(Object.entries({ kind: "question", title: "A local question", message: "This test never sends real feedback." })); }
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(script, context);
|
||||
elapsed = 4000;
|
||||
return { context, handlers, links, send };
|
||||
}
|
||||
|
||||
test("questions and general feedback are not mislabelled as bugs in GitHub fallback", () => {
|
||||
const page = load();
|
||||
for (const kind of ["question", "other"]) {
|
||||
const url = new URL(page.context.githubUrl({ kind, title: "A local question", message: "Please help" }));
|
||||
assert.equal(url.searchParams.has("template"), false);
|
||||
assert.equal(url.searchParams.get("body"), "Please help");
|
||||
}
|
||||
assert.equal(new URL(page.context.githubUrl({ kind: "bug", title: "A bug", message: "Details" })).searchParams.get("template"), "bug.yml");
|
||||
assert.equal(new URL(page.context.githubUrl({ kind: "idea", title: "An idea", message: "Details" })).searchParams.get("template"), "idea.yml");
|
||||
});
|
||||
|
||||
test("an unconfirmed send points to recent issues rather than creating a duplicate", async () => {
|
||||
const page = load(async () => ({ ok: false, status: 502, json: async () => ({ error: "Could not confirm delivery", retrySafe: false }) }));
|
||||
await page.handlers.submit({ preventDefault: () => {} });
|
||||
assert.equal(page.links[0].href, "https://github.com/saphid/frame-control/issues");
|
||||
assert.equal(page.send.disabled, false);
|
||||
});
|
||||
|
||||
test("a browser network failure also points to recent issues", async () => {
|
||||
const page = load(async () => { throw new Error("Local offline test"); });
|
||||
await page.handlers.submit({ preventDefault: () => {} });
|
||||
assert.equal(page.links[0].href, "https://github.com/saphid/frame-control/issues");
|
||||
});
|
||||
|
||||
test("known-unsent integration errors retain the prefilled issue fallback", async () => {
|
||||
const page = load(async () => ({ ok: false, status: 503, json: async () => ({ error: "Not connected", retrySafe: true }) }));
|
||||
await page.handlers.submit({ preventDefault: () => {} });
|
||||
const url = new URL(page.links[0].href);
|
||||
assert.equal(url.pathname, "/saphid/frame-control/issues/new");
|
||||
assert.equal(url.searchParams.get("body"), "This test never sends real feedback.");
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { onRequestPost } from "../functions/api/feedback.js";
|
||||
|
||||
const env = { GITHUB_TOKEN: "local-test-placeholder", GITHUB_REPO: "example/local-test" };
|
||||
const input = { kind: "bug", title: "Local test report", message: "No external requests are made by these tests.", elapsed: 4000 };
|
||||
const request = (origin, body = input) => new Request("https://framecontrol.fun/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...(origin === undefined ? {} : { origin }) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
test("malformed and opaque Origins are rejected without throwing or contacting GitHub", async (testContext) => {
|
||||
const upstream = testContext.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected external request"); });
|
||||
for (const origin of ["not-a-url", "null", "https://other.example", "http://framecontrol.fun"]) {
|
||||
const response = await onRequestPost({ request: request(origin), env });
|
||||
assert.equal(response.status, 403, origin);
|
||||
}
|
||||
assert.equal(upstream.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("same-origin invalid JSON content is rejected before contacting GitHub", async (testContext) => {
|
||||
const upstream = testContext.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected external request"); });
|
||||
const response = await onRequestPost({ request: request("https://framecontrol.fun", null), env });
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(upstream.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("missing integration settings fail without contacting GitHub", async (testContext) => {
|
||||
const upstream = testContext.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected external request"); });
|
||||
const response = await onRequestPost({ request: request("https://framecontrol.fun"), env: {} });
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal((await response.json()).retrySafe, true);
|
||||
assert.equal(upstream.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test("GitHub network failure produces an uncertainty-aware response instead of throwing", async (testContext) => {
|
||||
testContext.mock.method(globalThis, "fetch", async () => { throw new Error("Local simulated outage"); });
|
||||
const response = await onRequestPost({ request: request("https://framecontrol.fun"), env });
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal((await response.json()).retrySafe, false);
|
||||
});
|
||||
|
||||
test("an unreadable successful GitHub response does not encourage duplicate submission", async (testContext) => {
|
||||
testContext.mock.method(globalThis, "fetch", async () => new Response("not JSON", { status: 201 }));
|
||||
const response = await onRequestPost({ request: request("https://framecontrol.fun"), env });
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal((await response.json()).retrySafe, false);
|
||||
});
|
||||
|
||||
test("successful feedback returns the created issue", async (testContext) => {
|
||||
testContext.mock.method(globalThis, "fetch", async () => Response.json({ number: 7, html_url: "https://github.com/example/local-test/issues/7" }, { status: 201 }));
|
||||
const response = await onRequestPost({ request: request("https://framecontrol.fun"), env });
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), { ok: true, number: 7, url: "https://github.com/example/local-test/issues/7" });
|
||||
});
|
||||
@@ -37,20 +37,20 @@ test("unknown kinds become other feedback", () => {
|
||||
assert.equal(validate(form({ kind: "__proto__" })).value.kind, "other");
|
||||
});
|
||||
|
||||
test("builds a labelled issue with an explicitly unverified GitHub username", () => {
|
||||
test("builds a labelled issue that credits a GitHub user", () => {
|
||||
const { value } = validate(form({ github: "@octocat", version: "0.3.1", steamos: "20260922" }));
|
||||
const issue = buildIssue(value);
|
||||
assert.equal(issue.title, "Bug report: Live view freezes");
|
||||
assert.deepEqual(issue.labels, ["feedback", "bug"]);
|
||||
assert.match(issue.body, /\| Frame Control version \| 0\.3\.1 \|/);
|
||||
assert.match(issue.body, /Provided GitHub username \(not verified\): @octocat\./);
|
||||
assert.match(issue.body, /^> Sent from the website feedback form by @octocat\./);
|
||||
assert.ok(issue.body.endsWith(value.message));
|
||||
});
|
||||
|
||||
test("anonymous feedback explains how to follow replies", () => {
|
||||
test("anonymous feedback says replies won't reach the sender", () => {
|
||||
const issue = buildIssue(validate(form({ kind: "other" })).value);
|
||||
assert.deepEqual(issue.labels, ["feedback"]);
|
||||
assert.match(issue.body, /bookmark or subscribe/);
|
||||
assert.match(issue.body, /won't see replies/);
|
||||
});
|
||||
|
||||
test("breaks mentions, issue refs and table cells in user text", () => {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const script = await readFile(new URL("../public/js/site.js", import.meta.url), "utf8");
|
||||
const html = await readFile(new URL("../public/index.html", import.meta.url), "utf8");
|
||||
|
||||
function visit(platform, userAgent = "", maxTouchPoints = 0, hash = "") {
|
||||
const label = { textContent: "Get started — download the app" };
|
||||
const hero = { href: "#download", querySelector: () => label };
|
||||
const answer = { open: false };
|
||||
const listeners = {};
|
||||
const context = {
|
||||
navigator: { platform, userAgent, maxTouchPoints },
|
||||
location: { hash },
|
||||
window: { addEventListener: (event, handler) => { listeners[event] = handler; } },
|
||||
document: {
|
||||
addEventListener: (event, handler) => { listeners[`document-${event}`] = handler; },
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
getElementById: (id) => id === "hero-download" ? hero : id === "mac-first-launch" ? { closest: () => answer } : null,
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(script, context);
|
||||
return { hero, label, answer, listeners, context };
|
||||
}
|
||||
|
||||
test("desktop hero leads to opening instructions rather than a bare package", () => {
|
||||
for (const [platform, name] of [["MacIntel", "macOS (Apple Silicon)"], ["Win32", "Windows"], ["Linux x86_64", "Linux"], ["Linux aarch64", "Linux (arm64)"]]) {
|
||||
const page = visit(platform);
|
||||
assert.equal(page.hero.href, "#download");
|
||||
assert.equal(page.label.textContent, `Get started on ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("phones and touch-enabled iPads are not offered a desktop package", () => {
|
||||
for (const [platform, userAgent, touches] of [["iPhone", "iPhone", 1], ["MacIntel", "Macintosh", 5], ["Linux armv8l", "Android", 1]]) {
|
||||
const page = visit(platform, userAgent, touches);
|
||||
assert.equal(page.hero.href, "#download");
|
||||
assert.equal(page.label.textContent, "Get started — download the app");
|
||||
}
|
||||
});
|
||||
|
||||
test("first-launch links open the answer on arrival and hash navigation", () => {
|
||||
assert.equal(visit("", "", 0, "#mac-first-launch").answer.open, true);
|
||||
const page = visit("");
|
||||
page.context.location.hash = "#mac-first-launch";
|
||||
page.listeners.hashchange();
|
||||
assert.equal(page.answer.open, true);
|
||||
assert.doesNotThrow(() => visit("", "", 0, "#missing"));
|
||||
});
|
||||
|
||||
test("clicking the same first-launch link reopens a manually closed answer", () => {
|
||||
const page = visit("", "", 0, "#mac-first-launch");
|
||||
page.answer.open = false;
|
||||
page.listeners["document-click"]({ target: { closest: () => ({ getAttribute: () => "#mac-first-launch" }) } });
|
||||
assert.equal(page.answer.open, true);
|
||||
assert.doesNotThrow(() => page.listeners["document-click"]({ target: {} }));
|
||||
});
|
||||
|
||||
test("landing page keeps unique anchors and opening instructions for every desktop", () => {
|
||||
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
for (const match of html.matchAll(/href="#([^"]+)"/g)) assert.ok(ids.includes(match[1]), match[1]);
|
||||
assert.match(html, /Install Frame Control on your computer, not your headset/);
|
||||
assert.match(html, /drag Frame Control to Applications/);
|
||||
assert.match(html, /launch Frame Control from Start/);
|
||||
assert.match(html, /Properties → Permissions/);
|
||||
assert.match(html, /Frame-Control-linux-arm64\.deb/);
|
||||
assert.match(html, /Not an App Store download/);
|
||||
});
|
||||
|
||||
test("sharing metadata points to the custom domain", () => {
|
||||
assert.match(html, /rel="canonical" href="https:\/\/framecontrol\.fun\/"/);
|
||||
assert.match(html, /property="og:url" content="https:\/\/framecontrol\.fun\/"/);
|
||||
assert.match(html, /property="og:image" content="https:\/\/framecontrol\.fun\/img\/og\.jpg"/);
|
||||
});
|
||||
|
||||
test("Linux arm64 download explains the system adb prerequisite", () => {
|
||||
assert.match(html, /On arm64 Linux, install <code>adb<\/code>/);
|
||||
});
|
||||
|
||||
test("privacy describes released telemetry and distinguishes private app reports from public feedback", async () => {
|
||||
const privacy = await readFile(new URL("../public/privacy/index.html", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(privacy, /app collects nothing|has no analytics/);
|
||||
for (const text of ["on by default", "PostHog", "random persistent installation ID", "Privacy & updates", "off by default", "public GitHub issue", "v0.4.0/docs/privacy.md"]) {
|
||||
assert.ok(privacy.includes(text), text);
|
||||
}
|
||||
});
|
||||
|
||||
test("compact headers retain navigation and high-contrast support text", async () => {
|
||||
const css = await readFile(new URL("../public/css/site.css", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(css, /\.top nav\s*\{\s*display:\s*none/);
|
||||
assert.match(css, /\.top nav \{ order: 3; width: 100%/);
|
||||
assert.match(css, /@media \(max-width: 880px\) \{\s*\.top \{ position: static; \}/);
|
||||
assert.match(css, /\.btn\.coffee \{[^}]*color: #0e141b/);
|
||||
});
|
||||
|
||||
test("primary button gradients keep white text above 4.5:1 contrast", async () => {
|
||||
const css = await readFile(new URL("../public/css/site.css", import.meta.url), "utf8");
|
||||
const luminance = (channels) => channels.map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
}).reduce((total, channel, index) => total + channel * [0.2126, 0.7152, 0.0722][index], 0);
|
||||
for (const variable of ["action", "action-hi"]) {
|
||||
const value = css.match(new RegExp(`--${variable}: ([^;]+);`))[1];
|
||||
const colors = [...value.matchAll(/#([0-9a-f]{6})/g)].map((match) => [0, 2, 4].map((offset) => parseInt(match[1].slice(offset, offset + 2), 16)));
|
||||
assert.equal(colors.length, 2);
|
||||
for (let step = 0; step <= 100; step++) {
|
||||
const channels = colors[0].map((channel, index) => channel + (colors[1][index] - channel) * step / 100);
|
||||
assert.ok(1.05 / (luminance(channels) + 0.05) >= 4.5, `${variable} at ${step}%`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every page versions shared assets to avoid stale onboarding behavior", async () => {
|
||||
for (const file of ["index.html", "feedback/index.html", "privacy/index.html", "404.html"]) {
|
||||
const page = await readFile(new URL(`../public/${file}`, import.meta.url), "utf8");
|
||||
assert.match(page, /href="\/css\/site\.css\?v=/, file);
|
||||
assert.match(page, /src="\/js\/site\.js\?v=/, 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
@@ -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))
|
||||
|
||||
@@ -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});}
|
||||
});
|
||||
+104
-18
@@ -5,6 +5,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -203,23 +204,106 @@ class Remote(unittest.TestCase):
|
||||
self.assertEqual(result['written'], 0)
|
||||
self.assertEqual(result['held'], {'refused': 1, 'reference': 1})
|
||||
|
||||
def test_session_reads_the_mode(self):
|
||||
conf = write(self.home, 'zz-steamos-autologin.conf', '[Autologin]\nSession=plasma-steamos-wayland-oneshot.desktop\n')
|
||||
running = set()
|
||||
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
|
||||
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
|
||||
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n == 'steamos-session-select' else None):
|
||||
state = remote.session()
|
||||
self.assertEqual((state['mode'], state['selector']), ('desktop', 'steamos-session-select'))
|
||||
self.assertEqual(state['configured'], 'plasma-steamos-wayland-oneshot.desktop')
|
||||
running.update({'gamescope', 'plasmashell'}) # the nested desktop panel
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
running.clear()
|
||||
running.add('gamescope-wl') # gamescope's process name on the Frame, with no desktop panel open
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
running.clear()
|
||||
running.add('start-gamescope-session')
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
|
||||
class Session(unittest.TestCase):
|
||||
def state(self, targets, running=(), configured=None):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
conf = write(folder, 'sddm.conf', 'Session=%s\n' % (configured or 'gamescope.desktop'))
|
||||
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
|
||||
patch.object(remote, '_session_target', side_effect=targets), \
|
||||
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
|
||||
patch.object(remote.shutil, 'which', return_value='/usr/bin/steamosctl'):
|
||||
return remote.session()
|
||||
|
||||
def test_full_plasma_with_vr_gamescope_is_desktop(self):
|
||||
# Issue #142, SteamOS 0.5.6: steamvr-plasma also starts nested scripts.
|
||||
state = self.state(['inactive', 'active'],
|
||||
{'gamescope-wl', 'plasmashell', 'steamos-nested-desktop'},
|
||||
configured='gamescope.desktop')
|
||||
self.assertEqual(state['mode'], 'desktop')
|
||||
self.assertTrue(state['gamescope'])
|
||||
self.assertTrue(state['nested_desktop'])
|
||||
self.assertEqual(state['selector'], 'steamosctl')
|
||||
self.assertEqual(state['session_targets']['plasma-workspace-wayland.target'], 'active')
|
||||
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
|
||||
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_called_once_with('steamosctl switch-to-game-mode', timeout=60)
|
||||
|
||||
def test_game_target_wins_with_or_without_nested_desktop(self):
|
||||
for running in ((), {'gamescope-wl'}, {'gamescope', 'plasmashell', 'steamos-nested-desktop'}):
|
||||
state = self.state(['active', 'inactive'], running, configured='plasma.desktop')
|
||||
self.assertEqual(state['mode'], 'game')
|
||||
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
|
||||
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_conflicting_incomplete_and_transitional_targets_block_return(self):
|
||||
for targets in (['active', 'active'], ['inactive', 'inactive'], ['active', 'unknown'],
|
||||
['not-found', 'active'], ['unknown', 'unknown'], ['inactive', 'activating'],
|
||||
['deactivating', 'active'], ['failed', 'active']):
|
||||
with self.subTest(targets=targets):
|
||||
state = self.state(targets, {'gamescope', 'plasmashell'}, 'plasma.desktop')
|
||||
self.assertEqual(state['mode'], 'unknown')
|
||||
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
|
||||
with self.assertRaisesRegex(backup.BackupError, 'nothing was changed'):
|
||||
backup.return_to_game_mode(confirmed=True)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_older_builds_use_only_unambiguous_running_processes(self):
|
||||
cases = [((), 'unknown'), ({'plasmashell'}, 'desktop'),
|
||||
({'gamescope'}, 'game'), ({'gamescope-wl'}, 'game'),
|
||||
({'start-gamescope-session'}, 'game'),
|
||||
({'gamescope', 'plasmashell'}, 'unknown'),
|
||||
({'gamescope', 'steamos-nested-desktop'}, 'unknown')]
|
||||
for running, expected in cases:
|
||||
with self.subTest(running=running):
|
||||
state = self.state(['not-found', 'not-found'], running, 'plasma.desktop')
|
||||
self.assertEqual(state['mode'], expected)
|
||||
self.assertEqual(state['mode_source'], 'legacy-processes')
|
||||
|
||||
def test_target_probe_parses_systemd_and_uses_main_user_bus(self):
|
||||
cases = [('LoadState=loaded\nActiveState=active\n', 0, 'active'),
|
||||
('ActiveState=inactive\nLoadState=loaded\n', 0, 'inactive'),
|
||||
('LoadState=not-found\nActiveState=inactive\n', 0, 'not-found'),
|
||||
('LoadState=error\nActiveState=active\n', 0, 'unknown'),
|
||||
('garbage', 0, 'unknown'), ('LoadState=loaded\nActiveState=active', 1, 'unknown')]
|
||||
for stdout, code, expected in cases:
|
||||
with self.subTest(stdout=stdout, code=code), \
|
||||
patch.object(remote.os, 'getuid', return_value=1000, create=True), \
|
||||
patch.object(remote.subprocess, 'run', return_value=subprocess.CompletedProcess([], code, stdout)) as run:
|
||||
self.assertEqual(remote._session_target('gamescope-session.target'), expected)
|
||||
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'show',
|
||||
'gamescope-session.target', '--property=LoadState', '--property=ActiveState'])
|
||||
self.assertEqual(run.call_args.kwargs['timeout'], 5)
|
||||
self.assertEqual(run.call_args.kwargs['env']['DBUS_SESSION_BUS_ADDRESS'], 'unix:path=/run/user/1000/bus')
|
||||
self.assertEqual(run.call_args.kwargs['env']['XDG_RUNTIME_DIR'], '/run/user/1000')
|
||||
for error in (OSError('no systemctl'), subprocess.TimeoutExpired('systemctl', 5)):
|
||||
with patch.object(remote.os, 'getuid', return_value=1000, create=True), \
|
||||
patch.object(remote.subprocess, 'run', side_effect=error):
|
||||
self.assertEqual(remote._session_target('gamescope-session.target'), 'unknown')
|
||||
|
||||
def test_selector_preference_and_older_builds(self):
|
||||
for available, expected in [({'steamosctl', 'steamos-session-select'}, 'steamosctl'),
|
||||
({'holo-session-select', 'steamos-session-select'}, 'holo-session-select'),
|
||||
({'steamos-session-select'}, 'steamos-session-select'), (set(), None)]:
|
||||
with patch.object(remote, 'SDDM_CONFS', ()), \
|
||||
patch.object(remote, '_session_target', return_value='unknown'), \
|
||||
patch.object(remote, '_running', return_value=False), \
|
||||
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n in available else None):
|
||||
self.assertEqual(remote.session()['selector'], expected)
|
||||
|
||||
def test_script_probe_does_not_match_names_in_transmitted_python_source(self):
|
||||
with patch.object(remote.subprocess, 'call', return_value=1) as call:
|
||||
self.assertFalse(remote._running('steamos-nested-desktop', full=True))
|
||||
pattern = call.call_args.args[0][-1]
|
||||
for command in ('/usr/bin/steamos-nested-desktop',
|
||||
'/bin/sh /usr/bin/steamos-nested-desktop',
|
||||
'bash steamos-nested-desktop --flag'):
|
||||
self.assertIsNotNone(re.search(pattern, command), command)
|
||||
for command in ('python3 -c ' + backup.REMOTE.read_text(),
|
||||
'/bin/sh -c pgrep -f steamos-nested-desktop',
|
||||
'/usr/bin/steamos-nested-desktop-other'):
|
||||
self.assertIsNone(re.search(pattern, command), command)
|
||||
|
||||
|
||||
def fake_ssh(home, os_release):
|
||||
@@ -393,9 +477,11 @@ class Ssh(unittest.TestCase):
|
||||
|
||||
class GameMode(unittest.TestCase):
|
||||
def test_command_is_only_ever_the_session_switch(self):
|
||||
self.assertEqual(backup.session_command('steamosctl'), 'steamosctl switch-to-game-mode')
|
||||
self.assertEqual(backup.session_command('steamos-session-select'), 'steamos-session-select gamescope')
|
||||
self.assertEqual(backup.session_command('holo-session-select'), 'holo-session-select gamescope')
|
||||
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot'):
|
||||
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot',
|
||||
'steamosctl switch-to-desktop-mode', 'steamosctl; reboot'):
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.session_command(bad)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-41
@@ -113,17 +113,6 @@ class Helpers(unittest.TestCase):
|
||||
self.assertIn("Chromium", str(cm.exception))
|
||||
self.assertTrue(calls[0].startswith("bash -s -- "))
|
||||
|
||||
def test_the_mac_space_opens_with_a_space_ticket(self):
|
||||
calls, asked = [], []
|
||||
mv = frame_macview.MacView(["ssh"], lambda remote, **kw: calls.append(remote) or "panel x", "frame")
|
||||
mv.call = lambda path, **kw: asked.append((path, kw)) or {"screen": True, "ticket": "tk1"}
|
||||
mv.ensure_tunnel = lambda **kw: None
|
||||
mv.remote_port = 47900
|
||||
out = mv.show("space")
|
||||
self.assertIn(("/ticket", {"method": "POST", "src": "space"}), asked)
|
||||
self.assertIn("http://127.0.0.1:47900/space?t=tk1", calls[0])
|
||||
self.assertEqual(out["src"], "space")
|
||||
|
||||
def test_separate_windows_need_accessibility(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda *a, **k: self.fail("no ssh"), "frame")
|
||||
mv.call = lambda path, **kw: {"screen": True, "accessibility": False}
|
||||
@@ -295,10 +284,7 @@ class Agent(unittest.TestCase):
|
||||
def test_serves_the_viewer_page(self):
|
||||
status, body = self.get(f"/view?k={self.token}&src=test")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(b'<script src="/mac-stream.js">', body)
|
||||
status, script = self.get("/mac-stream.js")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(b"VideoDecoder", script)
|
||||
self.assertIn(b"VideoDecoder", body)
|
||||
|
||||
def test_snapshot_needs_the_key(self):
|
||||
self.assertEqual(self.get("/snapshot?display=1")[0], 403)
|
||||
@@ -359,32 +345,6 @@ class Agent(unittest.TestCase):
|
||||
self.get(f"/close?k={self.token}&src=test", method="POST")
|
||||
self.assertEqual(WS(self.port, f"/stream?src=test&r={hello['r']}").status, 403)
|
||||
|
||||
def test_a_space_key_opens_any_source_until_stop_all(self):
|
||||
t = self.ticket("space")
|
||||
status, body = self.get(f"/space/key?t={t}")
|
||||
self.assertEqual(status, 200)
|
||||
key = json.loads(body)["key"]
|
||||
self.assertEqual(self.get(f"/space/key?t={t}")[0], 403) # a ticket gives one key
|
||||
self.assertEqual(self.get("/space/windows?s=nope")[0], 403)
|
||||
listing = json.loads(self.get(f"/space/windows?s={key}")[1])
|
||||
self.assertIn("windows", listing)
|
||||
self.assertTrue(all("x" in d and "y" in d for d in listing["displays"]))
|
||||
ws = WS(self.port, f"/stream?src=test&s={key}")
|
||||
self.assertEqual(ws.status, 101)
|
||||
hello = json.loads(ws.recv()[1])
|
||||
self.assertEqual(hello["t"], "hello")
|
||||
ws.close()
|
||||
self.assertEqual(WS(self.port, f"/stream?src=test&t={t}").status, 403) # not a stream ticket
|
||||
self.get(f"/close?k={self.token}", method="POST") # Stop All
|
||||
self.assertEqual(self.get(f"/space/windows?s={key}")[0], 403)
|
||||
self.assertEqual(WS(self.port, f"/stream?src=test&s={key}").status, 403)
|
||||
self.assertEqual(WS(self.port, f"/stream?src=test&r={hello['r']}").status, 403)
|
||||
|
||||
def test_the_space_page_and_its_script_are_served(self):
|
||||
for path in ("/space", "/mac-stream.js"):
|
||||
status, _ = self.get(path)
|
||||
self.assertEqual(status, 200, path)
|
||||
|
||||
def test_stop_revokes_tickets_not_yet_used(self):
|
||||
t = self.ticket("test")
|
||||
self.get(f"/close?k={self.token}&src=test", method="POST")
|
||||
|
||||
@@ -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'''
|
||||
|
||||
@@ -9,7 +9,6 @@ failure; FRAME_PC_INSTALLER_BUNDLE=1 checks the installer's bundle instead.
|
||||
"""
|
||||
import ctypes
|
||||
import http.client
|
||||
from http.server import ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -32,35 +31,6 @@ from frame_stream_stats import Stats
|
||||
from test_macview import WS
|
||||
|
||||
|
||||
class ViewerResourcesTests(unittest.TestCase):
|
||||
def test_public_viewer_can_load_its_script_without_master_credentials(self):
|
||||
httpd = ThreadingHTTPServer(('127.0.0.1', 0), agent.Handler)
|
||||
httpd.agent = mock.Mock(page=ROOT / 'ui/mac-view.html', grants=agent.Grants('master'))
|
||||
worker = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
try:
|
||||
for path, content_type, expected in [
|
||||
('/view', 'text/html', b'<script src="/mac-stream.js">'),
|
||||
('/mac-stream.js', 'application/javascript', b'class MacStream'),
|
||||
('/status', 'application/json', b'forbidden'),
|
||||
('/../mac-stream.js', 'application/json', b'forbidden'),
|
||||
]:
|
||||
with self.subTest(path=path):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', httpd.server_port, timeout=3)
|
||||
try:
|
||||
conn.request('GET', path)
|
||||
response = conn.getresponse()
|
||||
self.assertEqual(response.status, 200 if path in ('/view', '/mac-stream.js') else 403)
|
||||
self.assertIn(content_type, response.getheader('Content-Type'))
|
||||
self.assertIn(expected, response.read())
|
||||
finally:
|
||||
conn.close()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
worker.join(timeout=3)
|
||||
|
||||
|
||||
class GrantsTests(unittest.TestCase):
|
||||
def test_source_bound_retry_ack_and_stop(self):
|
||||
g = agent.Grants('master')
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Desktop observation lifecycle, guarded HTTP API and packaged resource contract."""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import io
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_power
|
||||
import server
|
||||
|
||||
STREAM = frame_power.sampler.stream_report
|
||||
|
||||
SAMPLE = {'time': 1, 'elapsedSeconds': 0, 'osBuild': 'test', 'readings': {
|
||||
'battery.watts': {'value': 0, 'unit': 'W', 'source': '/sys/test', 'state': 'available'}},
|
||||
'unavailableGroups': ['fan']}
|
||||
|
||||
|
||||
class DesktopPower(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.observation = frame_power.Observation()
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.lock = Path(self.temp.name) / 'frame-test.lock'
|
||||
self.patches = [patch.object(frame_power.sampler, 'LOCK', self.lock)]
|
||||
for p in self.patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.addCleanup(self.observation.shutdown)
|
||||
|
||||
def wait(self):
|
||||
self.observation.thread.join(4)
|
||||
self.assertFalse(self.observation.thread.is_alive())
|
||||
|
||||
def stream(self, program, preset='minute'):
|
||||
real = STREAM
|
||||
def collect(command, payload, count, interval, stop, received, **kw):
|
||||
return real([sys.executable, '-u', '-c', program], '', count, interval, stop, received, **kw)
|
||||
mocked_stream = patch.object(frame_power.sampler, 'stream_report', side_effect=collect)
|
||||
mocked_stream.start()
|
||||
self.addCleanup(mocked_stream.stop)
|
||||
return self.observation.start(preset, ('ssh', '-o', 'HostName=old', 'frame-old'),
|
||||
{'alias': 'frame-old', 'deviceId': 'old'})
|
||||
|
||||
def test_complete_exports_match_and_no_background_collection_on_status(self):
|
||||
def collect(command, payload, count, interval, stop, received, **kw):
|
||||
self.assertEqual(count, 12)
|
||||
self.assertEqual(interval, 5)
|
||||
self.assertIn('HostName=old', command)
|
||||
self.assertIn('StrictHostKeyChecking=yes', command)
|
||||
self.assertIn('frame-old', command)
|
||||
self.assertNotIn('pkill', ' '.join(command))
|
||||
for _ in range(count):
|
||||
received(SAMPLE.copy())
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'stopped': None, 'samples': [SAMPLE.copy() for _ in range(count)], 'summary': {}}
|
||||
with patch.object(frame_power.sampler, 'stream_report', side_effect=collect) as run:
|
||||
self.observation.start('minute', ('ssh', '-o', 'HostName=old', 'frame-old'), {'alias': 'frame-old'})
|
||||
self.wait()
|
||||
state = self.observation.status()
|
||||
self.assertEqual((state['received'], state['running'], state['stopped']), (12, False, None))
|
||||
for _ in range(3):
|
||||
self.observation.status()
|
||||
self.assertEqual(run.call_count, 1)
|
||||
report, _ = self.observation.export(state['id'], 'json')
|
||||
self.assertEqual(json.loads(report)['summary']['battery.watts']['mean'], 0)
|
||||
html, _ = self.observation.export(state['id'], 'html')
|
||||
csv, _ = self.observation.export(state['id'], 'csv')
|
||||
self.assertIn('Headset: frame-old', html)
|
||||
self.assertIn('collection.state,Completed', csv)
|
||||
self.assertIn('collection.receivedSamples,12', csv)
|
||||
self.assertIn('battery.watts,0,W', csv)
|
||||
|
||||
def test_stop_keeps_received_sample_and_reaps_only_owned_child(self):
|
||||
real_popen = subprocess.Popen
|
||||
children = []
|
||||
def spawn(*a, **kw):
|
||||
proc = real_popen(*a, **kw)
|
||||
children.append(proc)
|
||||
return proc
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
with patch.object(frame_power.sampler.subprocess, 'Popen', side_effect=spawn):
|
||||
state = self.stream(f'import sys,time; sys.stdin.read(); print({event!r}, flush=True); time.sleep(30)')
|
||||
deadline = time.monotonic() + 3
|
||||
while self.observation.status()['received'] < 1 and time.monotonic() < deadline:
|
||||
time.sleep(.01)
|
||||
self.assertEqual(self.observation.status()['received'], 1)
|
||||
with self.assertRaisesRegex(ValueError, 'already running'):
|
||||
self.observation.start('minute', ('ssh', 'frame'), {'alias': 'other'})
|
||||
active, _ = self.observation.export(state['id'], 'json')
|
||||
self.assertIn('in progress', json.loads(active)['stopped'])
|
||||
self.observation.stop()
|
||||
self.wait()
|
||||
self.assertIsNotNone(children[0].poll())
|
||||
data, _ = self.observation.export(state['id'], 'json')
|
||||
self.assertEqual(json.loads(data)['samples'], [SAMPLE])
|
||||
self.assertIn('Stopped by user', json.loads(data)['stopped'])
|
||||
csv, _ = self.observation.export(state['id'], 'csv')
|
||||
self.assertIn('incomplete', csv)
|
||||
|
||||
def test_disconnect_and_oversize_keep_partial_and_incomplete(self):
|
||||
for ending in ('sys.exit(255)', 'print("x"*262145, flush=True)'):
|
||||
with self.subTest(ending=ending):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
state = self.stream(f'import sys; sys.stdin.read(); print({event!r}, flush=True); {ending}')
|
||||
self.wait()
|
||||
data, _ = self.observation.export(state['id'], 'json')
|
||||
report = json.loads(data)
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
self.assertTrue(report['stopped'])
|
||||
|
||||
def test_local_lock_preserves_previous_report_and_invalid_inputs_do_not_spawn(self):
|
||||
with patch.object(frame_power.sampler, 'stream_report') as run:
|
||||
self.lock.mkdir()
|
||||
with self.assertRaisesRegex(ValueError, 'Smoke session'):
|
||||
self.observation.start('minute', ('ssh', 'frame'), {'alias': 'frame'})
|
||||
for preset in ('hour', 1000, None):
|
||||
with self.assertRaises(ValueError):
|
||||
self.observation.start(preset, ('ssh', 'frame'), {'alias': 'frame'})
|
||||
run.assert_not_called()
|
||||
self.assertIsNone(self.observation.status()['id'])
|
||||
|
||||
def test_headset_switch_preserves_original_identity_and_requests_cancel(self):
|
||||
done = threading.Event()
|
||||
def collect(command, payload, count, interval, cancel, received, **kw):
|
||||
received(SAMPLE)
|
||||
done.set()
|
||||
cancel.wait(3)
|
||||
return {'requestedSamples': count, 'intervalSeconds': interval, 'samples': [SAMPLE], 'stopped': None}
|
||||
with patch.object(frame_power.sampler, 'stream_report', side_effect=collect), \
|
||||
patch.object(server, '_power', self.observation):
|
||||
original = server.FRAME, list(server.HOST_OPTS)
|
||||
try:
|
||||
state = self.observation.start('five-minutes', ('ssh', 'old'), {'alias': 'old'})
|
||||
self.assertTrue(done.wait(2))
|
||||
server.route('different-headset', [])
|
||||
self.wait()
|
||||
current = self.observation.status()
|
||||
self.assertEqual(current['headset']['alias'], 'old')
|
||||
self.assertEqual(current['requested'], 60)
|
||||
self.assertIn('Headset switched', current['stopped'])
|
||||
with self.assertRaises(ValueError):
|
||||
self.observation.export('stale-id', 'html')
|
||||
with self.assertRaises(ValueError):
|
||||
self.observation.export(state['id'], '../file')
|
||||
finally:
|
||||
server.route(*original)
|
||||
|
||||
def test_http_guards_stale_target_stop_id_and_download_headers(self):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
conn = http.client.HTTPConnection(*httpd.server_address)
|
||||
def request(method, path, body=None, device=None, key=True):
|
||||
headers = {'X-Frame-UI': server.UI_KEY} if key else {}
|
||||
if device:
|
||||
headers['X-Frame-Device'] = device
|
||||
conn.request(method, path, json.dumps(body) if body is not None else None, headers)
|
||||
r = conn.getresponse()
|
||||
return r.status, dict(r.getheaders()), r.read()
|
||||
try:
|
||||
with patch.object(server, '_power', self.observation), \
|
||||
patch.object(server, 'LINK', SimpleNamespace(active_device=lambda: {'id': 'new'})), \
|
||||
patch.object(frame_power.sampler, 'stream_report') as run:
|
||||
for path in ('/api/power', '/api/power/report?id=x&format=html'):
|
||||
self.assertEqual(request('GET', path, key=False)[0], 403)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, key=False)[0], 403)
|
||||
for device, expected in ((None, 400), ('old', 409)):
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, device)[0], expected)
|
||||
run.assert_not_called()
|
||||
self.observation.id = 'test-id'
|
||||
self.observation.report = {'schemaVersion': 1, 'requestedSamples': 12, 'intervalSeconds': 5,
|
||||
'headset': {'alias': 'old'}, 'stopped': 'Disconnected', 'samples': [SAMPLE]}
|
||||
status, headers, data = request('GET', '/api/power/report?id=test-id&format=html')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('attachment;', headers['Content-Disposition'])
|
||||
self.assertIn('no-store', headers['Cache-Control'])
|
||||
self.assertIn(b'Disconnected', data)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': 'stale'}, 'new')[0], 409)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': 'test-id'}, 'new')[0], 200)
|
||||
received = threading.Event()
|
||||
def collect(command, payload, count, interval, cancel, emit, **kw):
|
||||
emit(SAMPLE)
|
||||
received.set()
|
||||
cancel.wait(3)
|
||||
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
|
||||
'samples': [SAMPLE], 'stopped': 'Stopped'}
|
||||
run.side_effect = collect
|
||||
code, _, data = request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, 'new')
|
||||
self.assertEqual(code, 200)
|
||||
new_id = json.loads(data)['id']
|
||||
self.assertNotEqual(new_id, 'test-id')
|
||||
self.assertTrue(received.wait(2))
|
||||
self.assertEqual(request('GET', '/api/power/report?id=test-id&format=json')[0], 409)
|
||||
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': new_id}, 'new')[0], 200)
|
||||
self.wait()
|
||||
self.assertEqual(self.observation.status()['received'], 1)
|
||||
self.assertEqual(self.observation.status()['headset']['deviceId'], 'new')
|
||||
finally:
|
||||
conn.close()
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
thread.join()
|
||||
|
||||
def test_early_remote_smoke_lock_has_structured_reason_without_sensor_reads(self):
|
||||
self.lock.mkdir()
|
||||
output = io.StringIO()
|
||||
with patch.object(frame_power.sampler, 'snapshot') as sensor, contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(frame_power.sampler.main(['--probe', '--stream']), 75)
|
||||
sensor.assert_not_called()
|
||||
self.assertIn('Smoke session active', json.loads(output.getvalue())['finished']['stopped'])
|
||||
|
||||
def test_phone_can_import_without_desktop_sampler_resources(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
import os
|
||||
import shutil
|
||||
shutil.copyfile(ROOT / 'ui/frame_power.py', Path(tmp) / 'frame_power.py')
|
||||
program = 'import sys; sys.path.insert(0, sys.argv[1]); import frame_power; assert frame_power.Observation().status()["id"] is None'
|
||||
result = subprocess.run([sys.executable, '-I', '-B', '-c', program, tmp],
|
||||
env={**os.environ, 'FRAME_LOCAL': '1'}, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_packaged_resources_under_isolated_python(self):
|
||||
config = json.loads((ROOT / 'app/package.json').read_text())
|
||||
# Materialize exactly the Python resources selected by electron-builder.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
resources = Path(tmp)
|
||||
import shutil
|
||||
for spec in config['build']['extraResources']:
|
||||
if spec.get('to') not in ('ui', 'scripts'):
|
||||
continue
|
||||
source = ROOT / 'app' / spec['from']
|
||||
for pattern in spec['filter']:
|
||||
for file in source.glob(pattern):
|
||||
if file.is_file():
|
||||
dest = resources / spec['to'] / file.name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(file, dest)
|
||||
program = ('import sys; sys.path.insert(0, sys.argv[1]); import frame_power; '
|
||||
'p=frame_power.sampler.remote_payload(); '
|
||||
'assert "def snapshot" in p and "def battery" in p; '
|
||||
'o=frame_power.Observation(); assert not o.status()["running"]')
|
||||
run = subprocess.run([sys.executable, '-I', '-B', '-c', program, str(resources / 'ui')], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 0, run.stderr)
|
||||
run = subprocess.run(['node', '--test', str(ROOT / 'tests/power_desktop.cjs')], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
|
||||
@@ -0,0 +1,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('<script>', markup)
|
||||
self.assertIn('<td>0</td>', markup)
|
||||
self.assertIn('<td>Unknown</td>', markup)
|
||||
self.assertIn('<td>C</td>', markup)
|
||||
self.assertIn('scope="row"', markup)
|
||||
self.assertEqual(json.loads(parser.links['frame-power.json']), report)
|
||||
self.assertEqual(parser.links['frame-power.csv'], power.csv_samples(report))
|
||||
self.assertIn(power.UNAVAILABLE, parser.links['frame-power.csv'])
|
||||
|
||||
def test_transport_failure_retains_received_samples_as_incomplete(self):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
report = self.collect(f'import sys; sys.stdin.read(); print({event!r}); sys.exit(255)', 2)
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
self.assertIn('SSH exit 255', report['stopped'])
|
||||
|
||||
def test_lock_stop_retains_samples(self):
|
||||
event = json.dumps({'sample': SAMPLE})
|
||||
end = json.dumps({'finished': {'stopped': 'Smoke session active'}})
|
||||
report = self.collect(f'import sys; sys.stdin.read(); print({event!r}); print({end!r}); sys.exit(75)', 2)
|
||||
self.assertEqual(report['stopped'], 'Smoke session active')
|
||||
self.assertEqual(len(report['samples']), 1)
|
||||
|
||||
def test_user_stop_terminates_only_local_child_and_retains_samples(self):
|
||||
children = []
|
||||
real_popen = subprocess.Popen
|
||||
def spawn(*args, **kwargs):
|
||||
proc = real_popen(*args, **kwargs)
|
||||
children.append(proc)
|
||||
return proc
|
||||
with mock.patch.object(power.subprocess, 'Popen', side_effect=spawn), \
|
||||
mock.patch.object(power.queue.Queue, 'get', side_effect=[json.dumps({'sample': SAMPLE}), KeyboardInterrupt]):
|
||||
report = self.collect('import sys,time; sys.stdin.read(); time.sleep(30)', 2)
|
||||
self.assertEqual(report['samples'], [SAMPLE])
|
||||
self.assertIn('Stopped by user', report['stopped'])
|
||||
self.assertIsNotNone(children[0].poll())
|
||||
|
||||
def test_canceled_launch_does_not_open_ssh(self):
|
||||
import threading
|
||||
stop = threading.Event()
|
||||
stop.set()
|
||||
with mock.patch.object(power.subprocess, 'Popen') as spawn:
|
||||
report = power.stream_report(['ssh', 'frame'], '', 12, 5, stop_event=stop)
|
||||
spawn.assert_not_called()
|
||||
self.assertIn('Stopped before collection', report['stopped'])
|
||||
|
||||
def test_stop_interrupts_child_that_never_reads_payload(self):
|
||||
import threading
|
||||
stop = threading.Event()
|
||||
timer = threading.Timer(0.1, stop.set)
|
||||
timer.start()
|
||||
try:
|
||||
report = power.stream_report([sys.executable, '-c', 'import time; time.sleep(30)'],
|
||||
'x' * 1048576, 1, 1, stop_event=stop)
|
||||
finally:
|
||||
timer.cancel()
|
||||
self.assertIn('Stopped', report['stopped'])
|
||||
self.assertEqual(report['samples'], [])
|
||||
|
||||
def test_invalid_stream_is_not_completed(self):
|
||||
report = self.collect('import sys; sys.stdin.read(); print("not json")')
|
||||
self.assertIn('interrupted', report['stopped'])
|
||||
self.assertEqual(report['samples'], [])
|
||||
|
||||
def test_deadline_terminates_child(self):
|
||||
with mock.patch.object(power.time, 'monotonic', side_effect=[0, 40]):
|
||||
report = self.collect('import sys,time; sys.stdin.read(); time.sleep(30)')
|
||||
self.assertIn('Timed out', report['stopped'])
|
||||
|
||||
def test_export_bounds_and_existing_file_fail_before_ssh(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
existing = Path(temp) / 'report.html'
|
||||
existing.write_text('keep')
|
||||
for args in (['--html', str(existing)], ['--html', 'new.html', '--count', '11', '--interval', '60'],
|
||||
['--html', 'new.html', '--csv']):
|
||||
with mock.patch.object(power.subprocess, 'Popen') as spawn, contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
power.main(args)
|
||||
spawn.assert_not_called()
|
||||
self.assertEqual(existing.read_text(), 'keep')
|
||||
|
||||
def test_cli_writes_incomplete_report_and_returns_nonzero(self):
|
||||
report = {'schemaVersion': 1, 'requestedSamples': 2, 'intervalSeconds': 1,
|
||||
'stopped': 'Stopped by user', 'samples': [SAMPLE], 'summary': power.summarize([SAMPLE])}
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / 'report.html'
|
||||
with mock.patch.object(power, 'stream_report', return_value=report), \
|
||||
mock.patch.object(power, 'LOCK', Path(temp) / 'no-lock'), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(power.main(['--html', str(path)]), 75)
|
||||
self.assertIn('Stopped by user', path.read_text())
|
||||
|
||||
def test_collect_emits_each_sample_before_completion(self):
|
||||
status = mock.Mock()
|
||||
with mock.patch.object(power, 'snapshot', return_value=(SAMPLE.copy(), None)):
|
||||
events = []
|
||||
report = power.collect(status, 1, 1, events.append)
|
||||
self.assertEqual(events, [{'sample': report['samples'][0]}])
|
||||
|
||||
def test_failed_save_preserves_json_on_stdout(self):
|
||||
report = {'schemaVersion': 1, 'requestedSamples': 1, 'intervalSeconds': 1,
|
||||
'stopped': None, 'samples': [SAMPLE], 'summary': power.summarize([SAMPLE])}
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(power, 'stream_report', return_value=report), \
|
||||
mock.patch.object(power, 'LOCK', Path(temp) / 'no-lock'), \
|
||||
contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(output):
|
||||
code = power.main(['--html', str(Path(temp) / 'missing' / 'report.html')])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(json.loads(output.getvalue()), report)
|
||||
|
||||
def test_premature_completion_is_incomplete_even_with_clean_ssh_exit(self):
|
||||
report = self.collect('import sys; sys.stdin.read(); print(\'{"finished": {"stopped": null}}\')')
|
||||
self.assertIn('Incomplete', report['stopped'])
|
||||
self.assertEqual(report['samples'], [])
|
||||
@@ -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()
|
||||
+4
-5
@@ -30,9 +30,8 @@ ARCHIVE = re.compile(r'^(saves|settings|android)/[A-Za-z0-9._-]{1,200}\.tar\.gz$
|
||||
PART = re.compile(r'^(saves:(\d{1,10}|linux)|settings:(steam|system)|android:[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+)$')
|
||||
KINDS = ('saves', 'settings', 'android')
|
||||
FORMAT = 1
|
||||
# The switch Valve's own devkit tools use (frame/devkit-utils/steamos-delete). Nothing else
|
||||
# is ever run as a session command.
|
||||
SELECTORS = ('holo-session-select', 'steamos-session-select')
|
||||
# Fixed SteamOS switches only; never execute a command supplied by remote status.
|
||||
SELECTORS = ('steamosctl', 'holo-session-select', 'steamos-session-select')
|
||||
REASONS = {
|
||||
'not_installed': 'Install the game first, then restore its saves',
|
||||
'no_prefix': 'Start the game once so Proton sets up its folders, then restore',
|
||||
@@ -344,10 +343,10 @@ def session_status():
|
||||
|
||||
|
||||
def session_command(selector):
|
||||
"""The switch to Game Mode, exactly as Valve's devkit tools run it."""
|
||||
"""Allow only fixed Game Mode commands, including older devkit selectors."""
|
||||
if selector not in SELECTORS:
|
||||
raise BackupError('this SteamOS build has no session switch command; use the steps in the guide')
|
||||
return shlex.join([selector, 'gamescope'])
|
||||
return shlex.join([selector, 'switch-to-game-mode' if selector == 'steamosctl' else 'gamescope'])
|
||||
|
||||
|
||||
def return_to_game_mode(confirmed=False):
|
||||
|
||||
@@ -373,15 +373,40 @@ def restore(part, stamp, source):
|
||||
# The Frame normally runs gamescope (Game Mode) with Plasma nested inside it as one panel
|
||||
# (steamos-nested-desktop). A full Desktop Mode session is SDDM autologin into Plasma
|
||||
# instead. Valve's own devkit tools (frame/devkit-utils: steamos-get-status, steamos-delete)
|
||||
# read the session from these files and switch with holo-session-select or
|
||||
# steamos-session-select, so that's what this reads and suggests.
|
||||
# read the login preference from these files. It is not the running session.
|
||||
|
||||
SDDM_CONFS = ('/etc/sddm.conf.d/zz-steamos-autologin.conf', '/etc/sddm.conf.d/steamos.conf')
|
||||
SESSION_TARGETS = ('gamescope-session.target', 'plasma-workspace-wayland.target')
|
||||
|
||||
|
||||
def _session_target(unit):
|
||||
"""Read the main user's manager, not a nested Plasma session bus over SSH."""
|
||||
runtime = '/run/user/%d' % os.getuid()
|
||||
env = dict(os.environ, XDG_RUNTIME_DIR=runtime,
|
||||
DBUS_SESSION_BUS_ADDRESS='unix:path=%s/bus' % runtime)
|
||||
try:
|
||||
result = subprocess.run(['systemctl', '--user', 'show', unit,
|
||||
'--property=LoadState', '--property=ActiveState'],
|
||||
capture_output=True, text=True, timeout=5, env=env)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 'unknown'
|
||||
if result.returncode:
|
||||
return 'unknown'
|
||||
properties = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
||||
if properties.get('LoadState') == 'not-found':
|
||||
return 'not-found'
|
||||
if properties.get('LoadState') == 'loaded':
|
||||
return properties.get('ActiveState', 'unknown')
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _running(name, full=False):
|
||||
# This module is sent as `python3 -c <source>`: unanchored pgrep -f would
|
||||
# match the script names inside our own source, even when no script runs.
|
||||
pattern = (r'^([^ ]*/)?((ba|da|z)?sh +([^ ]*/)?)?%s( |$)' % re.escape(name)
|
||||
if full else name)
|
||||
try:
|
||||
return subprocess.call(['pgrep', '-f' if full else '-x', name], stdout=subprocess.DEVNULL,
|
||||
return subprocess.call(['pgrep', '-f' if full else '-x', pattern], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL) == 0
|
||||
except OSError:
|
||||
return False
|
||||
@@ -397,21 +422,32 @@ def session():
|
||||
m = re.search(r'^\s*Session\s*=\s*(\S+)', text, re.M)
|
||||
conf, configured = path, (m.group(1) if m else None)
|
||||
break
|
||||
selector = next((s for s in ('holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
|
||||
selector = next((s for s in ('steamosctl', 'holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
|
||||
# On the Frame gamescope's process is named gamescope-wl (seen on build 20261015.6141207),
|
||||
# under the start-gamescope-session script.
|
||||
gamescope = (_running('gamescope') or _running('gamescope-wl') or
|
||||
_running('start-gamescope-session', full=True))
|
||||
plasma = _running('plasmashell')
|
||||
nested = _running('steamos-nested-desktop', full=True) # a script: its process name is the shell's
|
||||
if gamescope:
|
||||
targets = {unit: _session_target(unit) for unit in SESSION_TARGETS}
|
||||
game, desktop = (targets[unit] for unit in SESSION_TARGETS)
|
||||
mode = 'unknown'
|
||||
source = 'session-targets'
|
||||
if (game, desktop) == ('active', 'inactive'):
|
||||
mode = 'game'
|
||||
elif plasma or (configured and 'plasma' in configured):
|
||||
elif (game, desktop) == ('inactive', 'active'):
|
||||
mode = 'desktop'
|
||||
else:
|
||||
mode = 'unknown'
|
||||
elif game == desktop == 'not-found':
|
||||
# Older images have neither target. A compositor plus Plasma is ambiguous:
|
||||
# Desktop Mode's steamvr-plasma.service also runs gamescope and nested scripts.
|
||||
source = 'legacy-processes'
|
||||
if gamescope and not plasma and not nested:
|
||||
mode = 'game'
|
||||
elif plasma and not gamescope and not nested:
|
||||
mode = 'desktop'
|
||||
return {'mode': mode, 'gamescope': gamescope, 'plasma': plasma, 'nested_desktop': nested,
|
||||
'configured': configured, 'config_file': conf, 'selector': selector}
|
||||
'configured': configured, 'config_file': conf, 'selector': selector,
|
||||
'session_targets': targets, 'mode_source': source}
|
||||
|
||||
|
||||
def main(argv):
|
||||
|
||||
+5
-13
@@ -386,7 +386,7 @@ class MacView:
|
||||
self.launching -= 1
|
||||
|
||||
def _show(self, src, quality, width, height):
|
||||
if src not in ("test", "space") and not src.startswith(("window:", "display:", "separate:")):
|
||||
if src != "test" and not src.startswith(("window:", "display:", "separate:")):
|
||||
raise MacViewError("Pick a window or display to show.")
|
||||
self.shows += 1
|
||||
q = QUALITY.get(quality) or QUALITY["balanced"]
|
||||
@@ -411,19 +411,11 @@ class MacView:
|
||||
# A single-use ticket for this source, not Frame Control's key: the URL
|
||||
# is visible in the Frame's process list.
|
||||
ticket = self.call("/ticket", method="POST", src=src)["ticket"]
|
||||
if src == "space":
|
||||
# The Mac space (ui/mac-space.html): one WebXR page that opens the
|
||||
# Mac's display and any windows pulled out of it (docs/mac-space.md).
|
||||
url = f"http://127.0.0.1:{self.remote_port}/space?{urlencode({'t': ticket, 'tag': tag})}"
|
||||
else:
|
||||
params = {"src": src, "t": ticket, "tag": tag, "codec": q["codec"], "max": q["max"], "fps": q["fps"],
|
||||
"bpp": q["bpp"]}
|
||||
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
|
||||
params = {"src": src, "t": ticket, "tag": tag, "codec": q["codec"], "max": q["max"], "fps": q["fps"],
|
||||
"bpp": q["bpp"]}
|
||||
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
|
||||
w, h = fit(width or 1280, height or 720)
|
||||
# WebXR needs Chromium XR's OpenXR feature, and its XR process needs
|
||||
# the seccomp filter off on arm64 (docs/webxr-chromium.md).
|
||||
xr = ["--enable-features=OpenXR", "--disable-seccomp-filter-sandbox"] if src == "space" else []
|
||||
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, self.viewer_profile, *self.browser_flags, *xr))
|
||||
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, self.viewer_profile, *self.browser_flags))
|
||||
try:
|
||||
out = self.run("bash -s -- " + args, stdin=LAUNCH, timeout=60)
|
||||
except Exception as e: # noqa: BLE001 - the server's Failure carries the Frame's words
|
||||
|
||||
@@ -498,11 +498,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.reply(b'frame-mac-view', content='text/plain')
|
||||
if method == 'GET' and url.path == '/view':
|
||||
return self.reply(agent.page.read_bytes(), content='text/html; charset=utf-8')
|
||||
if method == 'GET' and url.path == '/mac-stream.js':
|
||||
script = agent.page.with_name('mac-stream.js')
|
||||
if not script.is_file():
|
||||
return self.reply({'error': 'not found'}, 404)
|
||||
return self.reply(script.read_bytes(), content='application/javascript; charset=utf-8')
|
||||
if method == 'GET' and url.path == '/stream':
|
||||
return self.stream(q)
|
||||
if not agent.grants.master(q.get('k', self.headers.get('X-Token'))):
|
||||
|
||||
@@ -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)
|
||||
+77
-17
@@ -494,8 +494,6 @@
|
||||
.mv-perm .row { margin-top: 8px; }
|
||||
.mv-live { color: var(--green, #7fd67f); font-size: 12px; }
|
||||
.mv-opt { display: block; font-size: 13px; margin: 0 0 12px; }
|
||||
.mv-space { align-items: center; gap: 12px; margin: 0 0 14px; }
|
||||
.mv-space .sub { font-size: 13px; }
|
||||
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
|
||||
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||
padding: 8px 10px; font: inherit; font-size: 13px; }
|
||||
@@ -1256,7 +1254,6 @@
|
||||
<section id="shots">
|
||||
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="shotsRefresh">Refresh</button>
|
||||
<button class="small" id="phoneShotDownloads" hidden>Downloads & Photos</button>
|
||||
<button class="small" id="shotsFolder" title="Open the SteamFrame folder in your Pictures">Show folder</button>
|
||||
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||||
</div>
|
||||
@@ -1433,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 & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="powerReport">
|
||||
<h2>Power & heat report</h2>
|
||||
<p class="sub">Observe battery, CPU, temperatures and available sensors without changing settings. One observation at a time, every five seconds, for at most five minutes.</p>
|
||||
<label for="powerPreset">Observation length</label>
|
||||
<select id="powerPreset"><option value="minute">1 minute · 12 samples</option><option value="five-minutes">5 minutes · 60 samples</option></select>
|
||||
<div class="row"><button class="action" id="powerStart">Start observation</button><button id="powerStop" disabled>Stop and keep samples</button></div>
|
||||
<progress id="powerProgress" max="12" value="0" aria-label="Samples received" hidden></progress>
|
||||
<p id="powerStatus" role="status" aria-live="polite">No observation yet.</p>
|
||||
<div class="row"><button data-power-format="html" disabled>Download HTML report</button><button data-power-format="json" disabled>Download JSON</button><button data-power-format="csv" disabled>Download CSV</button></div>
|
||||
<p id="powerSaveStatus" role="status" aria-live="polite"></p>
|
||||
<p class="hint">Download before starting again or quitting: only the latest observation is kept in memory. Downloads during collection are incomplete snapshots. Stop, disconnect or switching headsets keeps received samples, labeled with the original headset. A smoke session blocks sensor reads.</p>
|
||||
<p class="hint">Open the saved HTML in your browser; it works offline and includes JSON/CSV downloads. Unknown readings are never treated as zero. Rails overlap; do not add them. Sensor accuracy is unverified. Reports include sensor paths and OS builds: review before sharing.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="vrTools">
|
||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||
<div class="stats" id="vrMetrics"></div>
|
||||
@@ -1740,8 +1751,6 @@
|
||||
<div class="sub" id="mvPcState" role="status" aria-live="polite" hidden></div>
|
||||
</div>
|
||||
<div id="mvMain">
|
||||
<div class="row mv-space"><button class="action" data-mv="show" data-src="space" data-title="Mac space">Open Mac space</button>
|
||||
<span class="sub">Your Mac's screen in the room, like Vision Pro. Drag a window's title bar off its edge to give it a place of its own.</span></div>
|
||||
<label class="mv-opt"><input type="checkbox" id="mvSeparate" checked> Give each window its own display
|
||||
<span class="sub">its menus and sheets come along, nothing covers it, and it goes back when you stop</span></label>
|
||||
<div class="row" style="margin:0 0 12px"><label for="mvQuality">Picture quality</label>
|
||||
@@ -2163,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 = () => {
|
||||
@@ -5079,10 +5088,6 @@ api("/api/host").then(applyHostWording).catch(() => {});
|
||||
// thumbs: id -> promise of an object URL, kept across reloads so a refresh
|
||||
// that finds a new shot fetches only that one's thumbnail.
|
||||
const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0, fill: 0 };
|
||||
if (window.frameApp?.screenshotDownloads) {
|
||||
$("phoneShotDownloads").hidden = false;
|
||||
$("phoneShotDownloads").onclick = () => window.frameApp.screenshotDownloads();
|
||||
}
|
||||
const STEAMVR_APPID = "250820";
|
||||
const SHOTS_POLL_MS = 8000;
|
||||
function shotApp(appid) {
|
||||
@@ -5463,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',
|
||||
@@ -5676,7 +5738,6 @@ async function loadMacView(restart) {
|
||||
$("mvMain").hidden = false;
|
||||
$("mvPcState").hidden = true;
|
||||
$("mvSeparate").closest("label").hidden = pc; // separate virtual displays are a Mac feature
|
||||
$("macview").querySelector(".mv-space").hidden = pc;
|
||||
const live = new Set((s.streams || []).map(x => x.src));
|
||||
const byStream = src => (s.streams || []).find(x => x.src === src);
|
||||
$("mvCount").textContent = live.size ? `${live.size} showing${s.route === "usb" ? " · over USB-C" : ""}` : "";
|
||||
@@ -5754,9 +5815,7 @@ $("macview").onclick = async e => {
|
||||
await act(`Show ${b.dataset.title} in the headset`, async () => {
|
||||
const r = await api("/api/macview", { action: "show", src: as, quality: $("mvQuality").value,
|
||||
w: +b.dataset.w || undefined, h: +b.dataset.h || undefined });
|
||||
const message = as === "space" ? "The Mac space is ready in the headset: press Enter Mac space there."
|
||||
: `${b.dataset.title} is a panel in the headset now. Float it with the SteamVR dashboard.`;
|
||||
return { message, ...r };
|
||||
return { message: `${b.dataset.title} is a panel in the headset now. Float it with the SteamVR dashboard.`, ...r };
|
||||
}, b);
|
||||
} else if (b.dataset.mv === "stop") {
|
||||
await act("Stop showing it", () => api("/api/macview", { action: "stop", src }), b);
|
||||
@@ -5773,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.
|
||||
@@ -5884,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();
|
||||
}
|
||||
|
||||
@@ -1,687 +0,0 @@
|
||||
<!doctype html>
|
||||
<!-- Frame Control: the Mac space. The Mac's main display as a large panel in
|
||||
the room, and any Mac window pulled out of it as a panel of its own, in
|
||||
one WebXR session with passthrough behind (docs/mac-space.md). Served by
|
||||
the Mac's frame-mac-view agent; streams come from ui/mac-stream.js. Without
|
||||
WebXR (a desktop browser) the same scene shows in the page, with the
|
||||
mouse as the laser: left button = trigger, right button = grab. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mac space</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #101216; color: #eee; font: 16px/1.45 system-ui, sans-serif; overflow: hidden; }
|
||||
#gl { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#ui { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px;
|
||||
text-align: center; padding: 24px; background: rgba(16,18,22,.92); }
|
||||
#ui[hidden] { display: none; }
|
||||
#enter { font: 600 28px system-ui, sans-serif; padding: 22px 44px; border-radius: 18px; border: 0; color: #fff;
|
||||
background: #2f6fed; cursor: pointer; }
|
||||
#enter:disabled { background: #444; cursor: default; }
|
||||
#msg { max-width: 34em; color: #bbb; }
|
||||
#hint { position: fixed; left: 12px; bottom: 10px; color: #9aa; font-size: 13px; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gl"></canvas>
|
||||
<div id="ui">
|
||||
<div style="font-size:22px">Mac space</div>
|
||||
<button id="enter" disabled>Connecting…</button>
|
||||
<div id="msg">Your Mac's screen appears in front of you. Drag a window's title bar off its edge to give that window a
|
||||
place of its own; drop it back on the screen to put it back.</div>
|
||||
</div>
|
||||
<div id="hint" hidden>Preview: left button clicks, right button (or Shift) drags a panel by its bar, wheel scrolls.</div>
|
||||
<script src="/mac-stream.js"></script>
|
||||
<script>
|
||||
"use strict";
|
||||
const q = new URLSearchParams(location.search);
|
||||
const tag = q.get("tag") || "";
|
||||
document.title = tag ? `Mac space [${tag}]` : "Mac space";
|
||||
const $ = id => document.getElementById(id);
|
||||
const say = t => { $("msg").textContent = t; };
|
||||
|
||||
// ---------- small 3D math (column-major 4x4, like WebGL) ----------
|
||||
const V = {
|
||||
add: (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]],
|
||||
sub: (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]],
|
||||
scale: (a, s) => [a[0] * s, a[1] * s, a[2] * s],
|
||||
dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
|
||||
len: a => Math.hypot(a[0], a[1], a[2]),
|
||||
norm: a => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; },
|
||||
};
|
||||
const M = {
|
||||
mul(a, b) {
|
||||
const o = new Float32Array(16);
|
||||
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
|
||||
let s = 0;
|
||||
for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
|
||||
o[c * 4 + r] = s;
|
||||
}
|
||||
return o;
|
||||
},
|
||||
// Rigid transform (rotation about Y by yaw, then position), scaled by sx, sy.
|
||||
panel(pos, yaw, sx = 1, sy = 1) {
|
||||
const c = Math.cos(yaw), s = Math.sin(yaw);
|
||||
return new Float32Array([c * sx, 0, -s * sx, 0, 0, sy, 0, 0, s, 0, c, 0, pos[0], pos[1], pos[2], 1]);
|
||||
},
|
||||
point: (m, p) => [m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
|
||||
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]],
|
||||
dir: (m, d) => [m[0] * d[0] + m[4] * d[1] + m[8] * d[2], m[1] * d[0] + m[5] * d[1] + m[9] * d[2], m[2] * d[0] + m[6] * d[1] + m[10] * d[2]],
|
||||
perspective(fovy, aspect, near, far) {
|
||||
const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far);
|
||||
return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0]);
|
||||
},
|
||||
// Inverse of a rigid transform (rotation + translation only).
|
||||
invRigid(m) {
|
||||
const o = new Float32Array(16);
|
||||
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) o[c * 4 + r] = m[r * 4 + c];
|
||||
const t = [m[12], m[13], m[14]];
|
||||
o[12] = -(o[0] * t[0] + o[4] * t[1] + o[8] * t[2]);
|
||||
o[13] = -(o[1] * t[0] + o[5] * t[1] + o[9] * t[2]);
|
||||
o[14] = -(o[2] * t[0] + o[6] * t[1] + o[10] * t[2]);
|
||||
o[15] = 1;
|
||||
return o;
|
||||
},
|
||||
};
|
||||
|
||||
// ---------- panels ----------
|
||||
// A panel is a Mac display or window: a video quad, a bar under it to grab,
|
||||
// and a close button to the bar's left (as on Vision Pro).
|
||||
const BAR_GAP = 0.03, BAR_H = 0.018, CLOSE_R = 0.013;
|
||||
const panels = [];
|
||||
let desktop = null;
|
||||
function barOf(p) {
|
||||
const w = Math.min(0.28, p.w * 0.3);
|
||||
return { cx: 0, cy: -p.h / 2 - BAR_GAP, w, h: BAR_H };
|
||||
}
|
||||
function closeOf(p) { const b = barOf(p); return { cx: -b.w / 2 - 0.04, cy: b.cy, r: CLOSE_R }; }
|
||||
// Text size buttons (A−, A+) to the bar's right, on window panels.
|
||||
function zoomOf(p, dir) { const b = barOf(p); return { cx: b.w / 2 + (dir < 0 ? 0.04 : 0.075), cy: b.cy, r: CLOSE_R }; }
|
||||
function placeFacing(p, pos, head) {
|
||||
p.pos = pos;
|
||||
// Upright, turned to face the viewer (yaw only), as windows do on Vision Pro.
|
||||
p.yaw = Math.atan2(head[0] - pos[0], head[2] - pos[2]);
|
||||
}
|
||||
function matrixOf(p) { return M.panel(p.pos, p.yaw); }
|
||||
// Where a ray meets the panel's plane, in panel metres (x right, y up), or null.
|
||||
function hitPanel(p, origin, dir) {
|
||||
const inv = M.invRigid(matrixOf(p));
|
||||
const o = M.point(inv, origin), d = M.dir(inv, dir);
|
||||
if (Math.abs(d[2]) < 1e-6) return null;
|
||||
const t = -o[2] / d[2];
|
||||
if (t <= 0) return null;
|
||||
return { x: o[0] + d[0] * t, y: o[1] + d[1] * t, t };
|
||||
}
|
||||
// What a ray points at: the nearest panel part.
|
||||
function pick(origin, dir) {
|
||||
let best = null;
|
||||
for (const p of panels) {
|
||||
const h = hitPanel(p, origin, dir);
|
||||
if (!h) continue;
|
||||
let part = null;
|
||||
const b = barOf(p), c = closeOf(p);
|
||||
if (Math.abs(h.x) <= p.w / 2 && Math.abs(h.y) <= p.h / 2) part = "content";
|
||||
else if (Math.abs(h.x - b.cx) <= b.w / 2 + 0.01 && Math.abs(h.y - b.cy) <= b.h / 2 + 0.012) part = "bar";
|
||||
else if (Math.hypot(h.x - c.cx, h.y - c.cy) <= c.r + 0.008) part = "close";
|
||||
else if (p.kind === "window") {
|
||||
for (const dir of [-1, 1]) {
|
||||
const z = zoomOf(p, dir);
|
||||
if (Math.hypot(h.x - z.cx, h.y - z.cy) <= z.r + 0.008) part = dir < 0 ? "zoomOut" : "zoomIn";
|
||||
}
|
||||
}
|
||||
if (part && (!best || h.t < best.t)) best = { panel: p, part, t: h.t, x: h.x, y: h.y, u: h.x / p.w + 0.5, v: 0.5 - h.y / p.h };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
let spaceKey = "", layout = { windows: [], displays: [] }, mainDisplay = null;
|
||||
async function refreshLayout() {
|
||||
try {
|
||||
const r = await fetch(`/space/windows?s=${encodeURIComponent(spaceKey)}`);
|
||||
if (r.ok) layout = await r.json();
|
||||
} catch (e) {}
|
||||
}
|
||||
// Replaceable in tests (the Mac's real stacking decides otherwise).
|
||||
const hooks = { windowAt: (u, v) => windowAt(u, v) };
|
||||
// The topmost Mac window under a point of the main display (u, v in 0..1).
|
||||
function windowAt(u, v) {
|
||||
const d = mainDisplay;
|
||||
if (!d) return null;
|
||||
const x = d.x + u * d.w, y = d.y + v * d.h;
|
||||
return layout.windows.find(w => x >= w.x && x < w.x + w.w && y >= w.y && y < w.y + w.h) || null;
|
||||
}
|
||||
|
||||
function openPanel(src, opts) {
|
||||
const p = { src, kind: opts.kind, title: opts.title || "", w: opts.w || 1.2, h: opts.h || 0.7, pos: [0, 1.4, -1.2], yaw: 0,
|
||||
zoom: { auto: true, base: 0, steps: 0 },
|
||||
tex: null, texW: 0, texH: 0, pending: null, lastMove: 0, pendingMove: null };
|
||||
p.stream = new MacStream({
|
||||
src, space: spaceKey, max: opts.max || "2560", fps: "60",
|
||||
onFrame(img, w, h, seq, decodedAt) {
|
||||
// Keep only the newest picture until the next rendered frame.
|
||||
if (p.pending) { p.stream.replaced(p.pending.seq, p.pending.decodedAt); p.pending.img.close(); }
|
||||
p.pending = { img, w, h, seq, decodedAt };
|
||||
},
|
||||
onInfo(m) { if (m.title) p.title = m.title; },
|
||||
onClosed() { removePanel(p, false); },
|
||||
});
|
||||
p.stream.start();
|
||||
panels.push(p);
|
||||
return p;
|
||||
}
|
||||
function removePanel(p, stop = true) {
|
||||
const i = panels.indexOf(p);
|
||||
if (i < 0) return;
|
||||
panels.splice(i, 1);
|
||||
if (stop) p.stream.stop(); // the Mac ends the stream; a separated window goes back
|
||||
if (p.pending) { p.pending.img.close(); p.pending = null; }
|
||||
if (p.tex && gl) gl.deleteTexture(p.tex);
|
||||
if (p === desktop) desktop = null;
|
||||
else sendArrange();
|
||||
}
|
||||
|
||||
// Tell the Mac where the window panels are, left or right of the desktop, so
|
||||
// it lays their displays out the same way: the Mac's own pointer then moves
|
||||
// from the desktop into the panel beside it, as it looks in the room.
|
||||
let arrangeTimer = 0;
|
||||
function sendArrange() {
|
||||
clearTimeout(arrangeTimer);
|
||||
arrangeTimer = setTimeout(() => {
|
||||
if (!desktop) return;
|
||||
const inv = M.invRigid(matrixOf(desktop));
|
||||
const list = panels.filter(p => p.kind === "window").map(p => ({ src: p.src, x: +M.point(inv, p.pos)[0].toFixed(3) }));
|
||||
desktop.stream.send({ t: "arrange", panels: list });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Text size: the window's own zoom (Cmd+= / Cmd+-), which browsers, editors,
|
||||
// terminals and chat apps all have. Automatic by default: after a window
|
||||
// panel is moved, its text follows its distance, a step per 12% nearer or
|
||||
// farther than where it was first let go; the A−/A+ buttons take over by hand.
|
||||
const ZOOM_STEP = 1.12;
|
||||
function textSize(p, steps) {
|
||||
if (!steps || !panels.includes(p)) return;
|
||||
p.stream.focus();
|
||||
const code = steps > 0 ? "Equal" : "Minus", key = steps > 0 ? "=" : "-";
|
||||
for (let i = 0; i < Math.abs(steps); i++) {
|
||||
setTimeout(() => {
|
||||
p.stream.key(true, code, key, ["meta"]);
|
||||
setTimeout(() => p.stream.key(false, code, key, ["meta"]), 30);
|
||||
}, 120 + i * 90);
|
||||
}
|
||||
p.zoom.steps += steps;
|
||||
}
|
||||
function autoTextSize(p) {
|
||||
if (p.kind !== "window" || !p.zoom.auto || !p.zoom.base) return;
|
||||
const d = V.len(V.sub(p.pos, headNow.pos));
|
||||
const want = Math.max(-6, Math.min(8, Math.round(Math.log(d / p.zoom.base) / Math.log(ZOOM_STEP))));
|
||||
textSize(p, want - p.zoom.steps);
|
||||
}
|
||||
|
||||
// Remembered placement of the desktop panel, in the room.
|
||||
function saveDesktop() {
|
||||
if (desktop) localStorage.setItem("macspace.desktop", JSON.stringify({ pos: desktop.pos, yaw: desktop.yaw, w: desktop.w }));
|
||||
}
|
||||
function restoreDesktop(head) {
|
||||
try {
|
||||
const s = JSON.parse(localStorage.getItem("macspace.desktop") || "null");
|
||||
if (s && s.pos && s.pos.length === 3) { desktop.pos = s.pos; desktop.yaw = s.yaw; desktop.w = s.w; return; }
|
||||
} catch (e) {}
|
||||
// First time: a large screen a little below eye level, 1.2 m ahead.
|
||||
const fwd = head.forward;
|
||||
placeFacing(desktop, V.add(head.pos, [fwd[0] * 1.2, -0.12, fwd[2] * 1.2]), head.pos);
|
||||
}
|
||||
|
||||
// ---------- steadying the laser ----------
|
||||
// A One Euro filter (Casiez et al. 2012) per axis: heavy smoothing while the
|
||||
// pointer moves slowly (hand tremor), little lag when it moves fast.
|
||||
class OneEuro {
|
||||
constructor(minCutoff = 1.2, beta = 0.35, dCutoff = 1) { Object.assign(this, { minCutoff, beta, dCutoff, x: null, dx: 0, t: 0 }); }
|
||||
alpha(cutoff, dt) { const tau = 1 / (2 * Math.PI * cutoff); return 1 / (1 + tau / dt); }
|
||||
filter(v, t) {
|
||||
if (this.x === null) { this.x = v; this.t = t; return v; }
|
||||
const dt = Math.max(1e-3, (t - this.t) / 1000);
|
||||
this.t = t;
|
||||
const d = (v - this.x) / dt;
|
||||
this.dx += this.alpha(this.dCutoff, dt) * (d - this.dx);
|
||||
const cutoff = this.minCutoff + this.beta * Math.abs(this.dx);
|
||||
this.x += this.alpha(cutoff, dt) * (v - this.x);
|
||||
return this.x;
|
||||
}
|
||||
}
|
||||
// Smooths a pointer's ray (direction in radians-ish units, origin in metres)
|
||||
// and keeps the last 200 ms, so a click can use where it pointed just before
|
||||
// the trigger jerked it.
|
||||
function steady(pt, origin, dir, t) {
|
||||
if (!pt.f) pt.f = { d: [0, 1, 2].map(() => new OneEuro(1.0, 0.6)), o: [0, 1, 2].map(() => new OneEuro(1.5, 0.8)), hist: [] };
|
||||
pt.origin = origin.map((v, i) => pt.f.o[i].filter(v, t));
|
||||
pt.dir = V.norm(dir.map((v, i) => pt.f.d[i].filter(v, t)));
|
||||
pt.f.hist.push({ t, origin: pt.origin, dir: pt.dir });
|
||||
while (pt.f.hist.length && t - pt.f.hist[0].t > 200) pt.f.hist.shift();
|
||||
}
|
||||
// Where the pointer was `ago` ms ago (or now).
|
||||
function pointedAgo(pt, ago) {
|
||||
const h = pt.f && pt.f.hist;
|
||||
if (!h || !h.length) return { origin: pt.origin, dir: pt.dir };
|
||||
const t = h[h.length - 1].t - ago;
|
||||
return h.find(e => e.t >= t) || h[h.length - 1];
|
||||
}
|
||||
// Click stabilisation: after the trigger goes down, the Mac's pointer stays
|
||||
// put until the ray has clearly moved (1.2 degrees), so a click doesn't turn
|
||||
// into a small drag.
|
||||
const CLICK_HOLD_RAD = 1.2 * Math.PI / 180;
|
||||
|
||||
// ---------- pointers: controllers, hands, or the mouse ----------
|
||||
// Each pointer has a ray; the trigger (select) clicks, squeeze grabs.
|
||||
const pointers = new Map();
|
||||
function pointerState(key) {
|
||||
if (!pointers.has(key)) pointers.set(key, { key, origin: [0, 0, 0], dir: [0, 0, -1], hit: null, selecting: false,
|
||||
grab: null, press: null, lastWheel: 0 });
|
||||
return pointers.get(key);
|
||||
}
|
||||
// Called whenever a pointer's ray moves.
|
||||
function pointerMoved(pt, head) {
|
||||
pt.hit = pick(pt.origin, pt.dir);
|
||||
if (pt.grab) return dragGrab(pt, head);
|
||||
const press = pt.press;
|
||||
if (press && press.panel === desktop && press.win && panels.includes(desktop)) {
|
||||
// Dragging a window's title bar off the edge of the desktop: detach it.
|
||||
const h = hitPanel(desktop, pt.origin, pt.dir);
|
||||
const outside = !h || Math.abs(h.x) > desktop.w / 2 || Math.abs(h.y) > desktop.h / 2;
|
||||
if (outside) return detach(pt, press, head);
|
||||
}
|
||||
const target = press ? press.panel : pt.hit && pt.hit.part === "content" ? pt.hit.panel : null;
|
||||
if (!target) return;
|
||||
if (press && pt.hold) {
|
||||
if (Math.acos(Math.min(1, V.dot(pt.hold.dir, pt.dir))) < CLICK_HOLD_RAD) return;
|
||||
pt.hold = null; // moved on purpose: it's a drag
|
||||
}
|
||||
const h = press ? hitPanel(target, pt.origin, pt.dir) : pt.hit;
|
||||
if (!h) return;
|
||||
const u = Math.min(1, Math.max(0, h.x / target.w + 0.5)), v = Math.min(1, Math.max(0, 0.5 - h.y / target.h));
|
||||
if (press) press.last = { u, v };
|
||||
sendMove(target, u, v);
|
||||
}
|
||||
// Moves go at most every 8 ms per panel.
|
||||
function sendMove(p, u, v) {
|
||||
const now = performance.now();
|
||||
if (now - p.lastMove >= 8) { p.stream.move(u, v); p.lastMove = now; p.pendingMove = null; }
|
||||
else p.pendingMove = { u, v };
|
||||
}
|
||||
function selectStart(pt, head) {
|
||||
// Where it pointed just before the trigger pull jerked the controller.
|
||||
const before = pointedAgo(pt, 80);
|
||||
const hit = pick(before.origin, before.dir) || pt.hit;
|
||||
if (!hit) return;
|
||||
pt.hold = { dir: before.dir, until: true };
|
||||
if (hit.part === "close") {
|
||||
if (hit.panel === desktop) return leave();
|
||||
return removePanel(hit.panel);
|
||||
}
|
||||
if (hit.part === "bar") return startGrab(pt, hit.panel, head);
|
||||
if (hit.part === "zoomOut" || hit.part === "zoomIn") {
|
||||
hit.panel.zoom.auto = false; // chosen by hand from now on
|
||||
return textSize(hit.panel, hit.part === "zoomIn" ? 1 : -1);
|
||||
}
|
||||
// A click on the Mac. On the desktop, remember which window it started on,
|
||||
// and whether it was on its title bar (the top 32 points).
|
||||
const p = hit.panel, u = hit.u, v = hit.v;
|
||||
let win = null;
|
||||
if (p === desktop) {
|
||||
const w = hooks.windowAt(u, v);
|
||||
if (w && mainDisplay && (mainDisplay.y + v * mainDisplay.h) - w.y <= 32) win = w;
|
||||
}
|
||||
pt.press = { panel: p, win, last: { u, v }, down: { u, v } };
|
||||
p.stream.down(u, v, 0);
|
||||
}
|
||||
function selectEnd(pt) {
|
||||
const held = !!pt.hold;
|
||||
pt.hold = null;
|
||||
if (pt.grab && pt.grab.byTrigger) return endGrab(pt);
|
||||
const press = pt.press;
|
||||
pt.press = null;
|
||||
// A clean click lets go where it went down.
|
||||
if (press && panels.includes(press.panel)) press.panel.stream.up(held ? press.down.u : press.last.u, held ? press.down.v : press.last.v, 0);
|
||||
}
|
||||
function squeezeStart(pt, head) {
|
||||
if (pt.hit) startGrab(pt, pt.hit.panel, head, false);
|
||||
}
|
||||
function squeezeEnd(pt) { if (pt.grab && !pt.grab.byTrigger) endGrab(pt); }
|
||||
|
||||
// Grabbing: the panel keeps its distance along the ray and its offset from
|
||||
// the hit point, and turns to face the viewer. Thumbstick up/down pushes and
|
||||
// pulls; left/right resizes.
|
||||
function startGrab(pt, p, head, byTrigger = true) {
|
||||
const h = hitPanel(p, pt.origin, pt.dir);
|
||||
const dist = h ? h.t : V.len(V.sub(p.pos, pt.origin));
|
||||
const at = V.add(pt.origin, V.scale(pt.dir, dist));
|
||||
pt.grab = { panel: p, dist, offset: V.sub(p.pos, at), byTrigger };
|
||||
panels.push(panels.splice(panels.indexOf(p), 1)[0]); // draw it last
|
||||
}
|
||||
function dragGrab(pt, head) {
|
||||
const g = pt.grab;
|
||||
if (!panels.includes(g.panel)) { pt.grab = null; return; }
|
||||
const at = V.add(pt.origin, V.scale(pt.dir, g.dist));
|
||||
placeFacing(g.panel, V.add(at, g.offset), head.pos);
|
||||
}
|
||||
function endGrab(pt) {
|
||||
const g = pt.grab;
|
||||
pt.grab = null;
|
||||
if (!g || !panels.includes(g.panel)) return;
|
||||
const p = g.panel;
|
||||
sendArrange();
|
||||
if (p === desktop) return saveDesktop();
|
||||
// A window dropped back onto the desktop goes home, at its old text size.
|
||||
if (desktop) {
|
||||
const h = hitPanel(desktop, pt.origin, pt.dir);
|
||||
if (h && Math.abs(h.x) <= desktop.w / 2 && Math.abs(h.y) <= desktop.h / 2 && h.t < g.dist + 0.3) {
|
||||
textSize(p, -p.zoom.steps);
|
||||
return setTimeout(() => removePanel(p), 150 + Math.abs(p.zoom.steps) * 90 + 200);
|
||||
}
|
||||
}
|
||||
if (!p.zoom.base) p.zoom.base = V.len(V.sub(p.pos, headNow.pos)); // where it was pulled out to
|
||||
else autoTextSize(p);
|
||||
}
|
||||
function thumbstick(pt, x, y, dt) {
|
||||
if (pt.grab) {
|
||||
const g = pt.grab;
|
||||
g.dist = Math.min(6, Math.max(0.3, g.dist - y * dt * 1.5));
|
||||
const s = 1 + x * dt * 0.8;
|
||||
g.panel.w = Math.min(4, Math.max(0.2, g.panel.w * s));
|
||||
g.panel.h = g.panel.w * (g.panel.aspect || 0.6);
|
||||
return;
|
||||
}
|
||||
const hit = pt.hit;
|
||||
if (!hit || hit.part !== "content" || (Math.abs(y) < 0.2 && Math.abs(x) < 0.2)) return;
|
||||
const now = performance.now();
|
||||
if (now - pt.lastWheel < 16) return;
|
||||
pt.lastWheel = now;
|
||||
hit.panel.stream.wheel(x * 24, y * 24, hit.u, hit.v);
|
||||
}
|
||||
|
||||
// Pulling a window out of the desktop: finish the Mac-side drag, open the
|
||||
// window as a panel of its own (on its own display on the Mac), and let it
|
||||
// follow the pointer until the trigger is released.
|
||||
function detach(pt, press, head) {
|
||||
pt.press = null;
|
||||
desktop.stream.up(press.last.u, press.last.v, 0);
|
||||
const win = press.win, d = mainDisplay;
|
||||
const scale = desktop.w / d.w; // metres per Mac point, as on the desktop panel
|
||||
const p = openPanel(`separate:${win.id}`, { kind: "window", title: win.title || win.app, w: win.w * scale, h: (win.h + 40) * scale });
|
||||
p.aspect = p.h / p.w;
|
||||
const dist = Math.max(0.5, (hitPanel(desktop, pt.origin, pt.dir) || { t: 1.2 }).t - 0.15);
|
||||
const at = V.add(pt.origin, V.scale(pt.dir, dist));
|
||||
placeFacing(p, V.add(at, [0, -p.h / 2 + 0.03, 0]), head.pos);
|
||||
pt.grab = { panel: p, dist, offset: V.sub(p.pos, at), byTrigger: true };
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
let gl = null, prog = null, quad = null, loc = {}, isGL2 = false, aniso = null;
|
||||
const VS = `attribute vec2 a; uniform mat4 mvp; uniform vec2 size; varying vec2 uv; varying vec2 pm;
|
||||
void main() { uv = vec2(a.x + 0.5, 0.5 - a.y); pm = a * size; gl_Position = mvp * vec4(a * size, 0.0, 1.0); }`;
|
||||
const FS = `precision highp float; varying vec2 uv; varying vec2 pm; uniform sampler2D tex; uniform vec4 color;
|
||||
uniform float useTex; uniform vec2 size; uniform float radius;
|
||||
void main() {
|
||||
vec2 d = abs(pm) - (size * 0.5 - vec2(radius));
|
||||
float dist = length(max(d, 0.0)) - radius;
|
||||
float a = clamp(0.5 - dist / fwidth(dist), 0.0, 1.0);
|
||||
vec4 c = useTex > 0.5 ? texture2D(tex, uv) : color;
|
||||
gl_FragColor = vec4(c.rgb, c.a * a * color.a);
|
||||
}`;
|
||||
function initGL(canvas, xrCompatible) {
|
||||
// WebGL 2: mipmaps for textures of any size, so a panel smaller than its
|
||||
// pixels in view is filtered down cleanly instead of shimmering.
|
||||
gl = canvas.getContext("webgl2", { alpha: true, antialias: true, premultipliedAlpha: false, xrCompatible })
|
||||
|| canvas.getContext("webgl", { alpha: true, antialias: true, premultipliedAlpha: false, xrCompatible });
|
||||
isGL2 = typeof WebGL2RenderingContext !== "undefined" && gl instanceof WebGL2RenderingContext;
|
||||
if (!isGL2) gl.getExtension("OES_standard_derivatives");
|
||||
aniso = gl.getExtension("EXT_texture_filter_anisotropic");
|
||||
const sh = (type, src) => {
|
||||
const s = gl.createShader(type);
|
||||
gl.shaderSource(s, type === gl.FRAGMENT_SHADER && !isGL2 ? "#extension GL_OES_standard_derivatives : enable\n" + src : src);
|
||||
gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
|
||||
return s;
|
||||
};
|
||||
// WebGL 2 needs GLSL ES 3.00 for fwidth (the rounded corners).
|
||||
const v3 = src => "#version 300 es\n" + src.replace(/attribute /g, "in ").replace(/varying /g, "out ");
|
||||
const f3 = src => "#version 300 es\n" + src.replace(/varying /g, "in ").replace(/texture2D\(/g, "texture(")
|
||||
.replace("void main() {", "out vec4 fragColor;\n void main() {").replace(/gl_FragColor/g, "fragColor");
|
||||
prog = gl.createProgram();
|
||||
gl.attachShader(prog, sh(gl.VERTEX_SHADER, isGL2 ? v3(VS) : VS));
|
||||
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, isGL2 ? f3(FS) : FS));
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
|
||||
for (const n of ["mvp", "size", "tex", "color", "useTex", "radius"]) loc[n] = gl.getUniformLocation(prog, n);
|
||||
quad = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), gl.STATIC_DRAW);
|
||||
}
|
||||
function upload(p) {
|
||||
const f = p.pending;
|
||||
if (!f) return null;
|
||||
p.pending = null;
|
||||
if (!p.tex) {
|
||||
p.tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, p.tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, isGL2 ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, p.tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, f.img);
|
||||
if (isGL2) gl.generateMipmap(gl.TEXTURE_2D);
|
||||
f.img.close();
|
||||
// Keep the panel's shape the picture's shape (a separated window can change size).
|
||||
p.aspect = f.h / f.w;
|
||||
p.h = p.w * p.aspect;
|
||||
p.texW = f.w;
|
||||
p.texH = f.h;
|
||||
return { seq: f.seq, decodedAt: f.decodedAt, drawnAt: performance.now() };
|
||||
}
|
||||
function drawRect(vp, model, w, h, color, tex, radius) {
|
||||
gl.uniformMatrix4fv(loc.mvp, false, M.mul(vp, model));
|
||||
gl.uniform2f(loc.size, w, h);
|
||||
gl.uniform4fv(loc.color, color);
|
||||
gl.uniform1f(loc.useTex, tex ? 1 : 0);
|
||||
gl.uniform1f(loc.radius, radius);
|
||||
if (tex) gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
}
|
||||
function drawScene(vp) {
|
||||
gl.useProgram(prog);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
|
||||
const a = gl.getAttribLocation(prog, "a");
|
||||
gl.enableVertexAttribArray(a);
|
||||
gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.uniform1i(loc.tex, 0);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
const hovered = new Set([...pointers.values()].filter(p => p.hit).map(p => p.hit.panel.src + ":" + p.hit.part));
|
||||
for (const p of panels) {
|
||||
const m = matrixOf(p);
|
||||
// A soft shadow behind, then the picture (grey until the first frame).
|
||||
drawRect(vp, M.mul(m, M.panel([0.004, -0.006, -0.004], 0)), p.w + 0.012, p.h + 0.012, [0, 0, 0, 0.35], null, 0.022);
|
||||
drawRect(vp, m, p.w, p.h, p.tex ? [1, 1, 1, 1] : [0.18, 0.19, 0.22, 0.95], p.tex, 0.016);
|
||||
const b = barOf(p), c = closeOf(p), bh = hovered.has(p.src + ":bar"), ch = hovered.has(p.src + ":close");
|
||||
drawRect(vp, M.mul(m, M.panel([b.cx, b.cy, 0], 0)), b.w, b.h, [1, 1, 1, bh ? 0.95 : 0.55], null, b.h / 2);
|
||||
drawRect(vp, M.mul(m, M.panel([c.cx, c.cy, 0], 0)), c.r * 2, c.r * 2, ch ? [0.95, 0.3, 0.3, 0.95] : [1, 1, 1, 0.45], null, c.r);
|
||||
if (p.kind === "window") {
|
||||
for (const dir of [-1, 1]) {
|
||||
const z = zoomOf(p, dir), on = hovered.has(p.src + (dir < 0 ? ":zoomOut" : ":zoomIn"));
|
||||
const zm = M.mul(m, M.panel([z.cx, z.cy, 0], 0));
|
||||
drawRect(vp, zm, z.r * 2, z.r * 2, [1, 1, 1, on ? 0.9 : 0.45], null, z.r);
|
||||
const glyph = M.mul(zm, M.panel([0, 0, 0.001], 0));
|
||||
drawRect(vp, glyph, z.r * 1.1, z.r * 0.22, [0.1, 0.1, 0.12, 1], null, 0);
|
||||
if (dir > 0) drawRect(vp, glyph, z.r * 0.22, z.r * 1.1, [0.1, 0.1, 0.12, 1], null, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rays, ending where they hit.
|
||||
for (const pt of pointers.values()) {
|
||||
const len = pt.hit ? pt.hit.t : 2;
|
||||
const mid = V.add(pt.origin, V.scale(pt.dir, len / 2));
|
||||
const yaw = Math.atan2(pt.dir[0], pt.dir[2]);
|
||||
// A thin strip along the ray: x across (horizontal), y along.
|
||||
const along = pt.dir, across = V.norm([Math.cos(yaw), 0, -Math.sin(yaw)]);
|
||||
const normal = [along[1] * across[2] - along[2] * across[1], along[2] * across[0] - along[0] * across[2], along[0] * across[1] - along[1] * across[0]];
|
||||
const model = new Float32Array([across[0], across[1], across[2], 0, along[0], along[1], along[2], 0,
|
||||
normal[0], normal[1], normal[2], 0, mid[0], mid[1], mid[2], 1]);
|
||||
drawRect(vp, model, 0.003, len, [1, 1, 1, 0.35], null, 0);
|
||||
if (pt.hit) {
|
||||
const m = M.mul(matrixOf(pt.hit.panel), M.panel([pt.hit.x, pt.hit.y, 0.002], 0));
|
||||
drawRect(vp, m, 0.012, 0.012, [1, 1, 1, 0.9], null, 0.006);
|
||||
}
|
||||
}
|
||||
}
|
||||
function flushMoves() {
|
||||
for (const p of panels) if (p.pendingMove && performance.now() - p.lastMove >= 8) sendMove(p, p.pendingMove.u, p.pendingMove.v);
|
||||
}
|
||||
|
||||
// ---------- WebXR ----------
|
||||
let xrSession = null, xrRef = null;
|
||||
async function enterXR() {
|
||||
const mode = (await navigator.xr.isSessionSupported("immersive-ar").catch(() => false)) ? "immersive-ar" : "immersive-vr";
|
||||
xrSession = await navigator.xr.requestSession(mode, { optionalFeatures: ["local-floor", "hand-tracking"] });
|
||||
const canvas = document.createElement("canvas");
|
||||
initGL(canvas, true);
|
||||
await gl.makeXRCompatible();
|
||||
xrSession.updateRenderState({ baseLayer: new XRWebGLLayer(xrSession, gl, { alpha: true, antialias: true }) });
|
||||
xrRef = await xrSession.requestReferenceSpace("local-floor").catch(() => xrSession.requestReferenceSpace("local"));
|
||||
const on = (type, fn) => xrSession.addEventListener(type, ev => fn(pointerState(ev.inputSource), headNow));
|
||||
on("selectstart", selectStart);
|
||||
on("selectend", pt => selectEnd(pt));
|
||||
on("squeezestart", squeezeStart);
|
||||
on("squeezeend", pt => squeezeEnd(pt));
|
||||
xrSession.addEventListener("end", () => { xrSession = null; $("ui").hidden = false; $("enter").disabled = false; say("Left the Mac space."); });
|
||||
$("ui").hidden = true;
|
||||
let placed = false, last = 0;
|
||||
const frame = (t, xf) => {
|
||||
if (!xrSession) return;
|
||||
xrSession.requestAnimationFrame(frame);
|
||||
const pose = xf.getViewerPose(xrRef);
|
||||
if (!pose) return;
|
||||
const hm = pose.transform.matrix;
|
||||
headNow = { pos: [hm[12], hm[13], hm[14]], forward: V.norm([-hm[8], 0, -hm[10]]) };
|
||||
if (!placed && desktop) { restoreDesktop(headNow); placed = true; }
|
||||
const dt = last ? Math.min(0.1, (t - last) / 1000) : 0;
|
||||
last = t;
|
||||
for (const src of xrSession.inputSources) {
|
||||
if (!src.targetRaySpace) continue;
|
||||
const rp = xf.getPose(src.targetRaySpace, xrRef);
|
||||
if (!rp) continue;
|
||||
const m = rp.transform.matrix, pt = pointerState(src);
|
||||
steady(pt, [m[12], m[13], m[14]], V.norm([-m[8], -m[9], -m[10]]), t);
|
||||
pointerMoved(pt, headNow);
|
||||
const axes = src.gamepad && src.gamepad.axes;
|
||||
if (axes && axes.length >= 4) thumbstick(pt, axes[2], axes[3], dt);
|
||||
}
|
||||
for (const k of [...pointers.keys()]) if (typeof k === "object" && ![...xrSession.inputSources].includes(k)) pointers.delete(k);
|
||||
flushMoves();
|
||||
const shown = [];
|
||||
for (const p of panels) { const f = upload(p); if (f) shown.push([p, f]); }
|
||||
const layer = xrSession.renderState.baseLayer;
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, layer.framebuffer);
|
||||
gl.clearColor(0, 0, 0, 0); // transparent: passthrough shows through
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
for (const view of pose.views) {
|
||||
const vp = layer.getViewport(view);
|
||||
gl.viewport(vp.x, vp.y, vp.width, vp.height);
|
||||
drawScene(M.mul(view.projectionMatrix, view.transform.inverse.matrix));
|
||||
}
|
||||
const now = performance.now();
|
||||
for (const [p, f] of shown) p.stream.presented(f.seq, f.decodedAt, f.drawnAt, now);
|
||||
};
|
||||
xrSession.requestAnimationFrame(frame);
|
||||
}
|
||||
let headNow = { pos: [0, 1.6, 0], forward: [0, 0, -1] };
|
||||
|
||||
// ---------- preview in a normal page (no headset) ----------
|
||||
function preview() {
|
||||
const canvas = $("gl");
|
||||
initGL(canvas, false);
|
||||
$("hint").hidden = false;
|
||||
const head = { pos: [0, 1.6, 0.4], forward: [0, 0, -1] };
|
||||
headNow = head;
|
||||
const mouse = pointerState("mouse");
|
||||
let placed = false;
|
||||
const toRay = e => {
|
||||
const r = canvas.getBoundingClientRect(), x = (e.clientX - r.left) / r.width * 2 - 1, y = 1 - (e.clientY - r.top) / r.height * 2;
|
||||
const f = 1 / Math.tan(Math.PI / 6), aspect = r.width / r.height;
|
||||
mouse.origin = head.pos.slice();
|
||||
mouse.dir = V.norm([x * aspect / f, y / f, -1]);
|
||||
pointerMoved(mouse, head);
|
||||
};
|
||||
canvas.addEventListener("pointermove", toRay);
|
||||
canvas.addEventListener("pointerdown", e => {
|
||||
toRay(e);
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
if (e.button === 2 || e.shiftKey) squeezeStart(mouse, head); else selectStart(mouse, head);
|
||||
});
|
||||
canvas.addEventListener("pointerup", e => {
|
||||
toRay(e);
|
||||
if (mouse.grab && !mouse.grab.byTrigger) squeezeEnd(mouse); else selectEnd(mouse);
|
||||
});
|
||||
canvas.addEventListener("contextmenu", e => e.preventDefault());
|
||||
canvas.addEventListener("wheel", e => {
|
||||
e.preventDefault();
|
||||
if (mouse.grab) { thumbstick(mouse, 0, -Math.sign(e.deltaY) * 0.5, 0.1); return; }
|
||||
if (mouse.hit && mouse.hit.part === "content") mouse.hit.panel.stream.wheel(e.deltaX, e.deltaY, mouse.hit.u, mouse.hit.v);
|
||||
}, { passive: false });
|
||||
const loop = () => {
|
||||
requestAnimationFrame(loop);
|
||||
if (!placed && desktop) { restoreDesktop(head); placed = true; }
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
if (canvas.width !== w * devicePixelRatio) { canvas.width = w * devicePixelRatio; canvas.height = h * devicePixelRatio; }
|
||||
flushMoves();
|
||||
const shown = [];
|
||||
for (const p of panels) { const f = upload(p); if (f) shown.push([p, f]); }
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.clearColor(0.06, 0.07, 0.09, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
const view = M.invRigid(M.panel(head.pos, 0));
|
||||
drawScene(M.mul(M.perspective(Math.PI / 3, w / h, 0.05, 50), view));
|
||||
for (const [p, f] of shown) p.stream.drawn(f.seq, f.decodedAt, f.drawnAt);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
function leave() {
|
||||
for (const p of [...panels]) removePanel(p);
|
||||
if (xrSession) xrSession.end();
|
||||
}
|
||||
|
||||
// ---------- start ----------
|
||||
(async () => {
|
||||
const ticket = q.get("t") || "";
|
||||
try {
|
||||
const r = await fetch(`/space/key?t=${encodeURIComponent(ticket)}`);
|
||||
if (!r.ok) throw new Error(r.status === 403 ? "This link has expired. Open the Mac space again from Frame Control." : `HTTP ${r.status}`);
|
||||
spaceKey = (await r.json()).key;
|
||||
} catch (e) { say(String(e.message || e)); $("enter").textContent = "Can't connect"; return; }
|
||||
await refreshLayout();
|
||||
setInterval(refreshLayout, 2000);
|
||||
mainDisplay = layout.displays.find(d => d.main) || layout.displays[0] || null;
|
||||
const src = q.get("src") || (mainDisplay ? `display:${mainDisplay.id}` : "test");
|
||||
desktop = openPanel(src, { kind: "desktop", w: 1.25, h: mainDisplay ? 1.25 * mainDisplay.h / mainDisplay.w : 0.7, max: "2560" });
|
||||
desktop.aspect = desktop.h / desktop.w;
|
||||
// For tests over DevTools: drive a pointer the way a controller would.
|
||||
window.space = { panels, pointers, layout: () => layout, desktop: () => desktop, head: () => headNow, V, M, matrixOf,
|
||||
pointerState, pointerMoved, selectStart, selectEnd, squeezeStart, squeezeEnd, mainDisplay: () => mainDisplay, hooks };
|
||||
const xr = navigator.xr && await navigator.xr.isSessionSupported("immersive-vr").catch(() => false);
|
||||
if (!xr || q.get("preview") === "1") {
|
||||
$("ui").hidden = true;
|
||||
return preview();
|
||||
}
|
||||
$("enter").disabled = false;
|
||||
$("enter").textContent = "Enter Mac space";
|
||||
$("enter").onclick = () => enterXR().catch(e => { say(`Couldn't start: ${e.message || e}`); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,321 +0,0 @@
|
||||
// Frame Control: one stream from the Mac's frame-mac-view agent, for the
|
||||
// viewer pages (mac-view.html: one panel; mac-space.html: the Mac space).
|
||||
// It connects over the agent's WebSocket, keeps a clock synced with the
|
||||
// Mac's, decodes H.264 with WebCodecs (or JPEG), and acknowledges and
|
||||
// reports on every frame: the agent paces itself on the acks
|
||||
// (Controller.swift) and measures each frame's journey from the reports
|
||||
// (Stats.swift). Input goes back the same way. Plain script, no modules:
|
||||
// it defines window.MacStream.
|
||||
"use strict";
|
||||
|
||||
// What this browser draws with, and how often it gives an animation frame
|
||||
// when nothing else is going on (only for the numbers).
|
||||
const MacEnv = (() => {
|
||||
const env = { gl: "", rafHz: 0, decoder: "" };
|
||||
try {
|
||||
const gl = document.createElement("canvas").getContext("webgl");
|
||||
const ext = gl && gl.getExtension("WEBGL_debug_renderer_info");
|
||||
env.gl = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl ? "webgl" : "no webgl";
|
||||
} catch (e) { env.gl = "?"; }
|
||||
let n = 0, t0 = 0;
|
||||
const tick = t => {
|
||||
if (!t0) t0 = t;
|
||||
if (t - t0 < 1000) { n++; return requestAnimationFrame(tick); }
|
||||
env.rafHz = Math.round(n * 1000 / (t - t0));
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
return env;
|
||||
})();
|
||||
|
||||
async function macPickCodec(want) {
|
||||
if (want === "jpeg" || !("VideoDecoder" in window)) return "jpeg";
|
||||
try {
|
||||
const r = await VideoDecoder.isConfigSupported({ codec: "avc1.640028", optimizeForLatency: true });
|
||||
if (!r.supported) return "jpeg";
|
||||
// Whether this browser has a hardware H.264 decoder (for the numbers).
|
||||
const hw = await VideoDecoder.isConfigSupported({ codec: "avc1.640028", hardwareAcceleration: "prefer-hardware" })
|
||||
.catch(() => ({ supported: false }));
|
||||
MacEnv.decoder = hw.supported ? "h264 hardware" : "h264 software";
|
||||
return "h264";
|
||||
} catch (e) { return "jpeg"; }
|
||||
}
|
||||
|
||||
function macStartCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && (b[i + 2] === 1 || (b[i + 2] === 0 && b[i + 3] === 1)); }
|
||||
function macSpsCodec(au) {
|
||||
for (let i = 0; i + 7 < au.length; i++) {
|
||||
if (!macStartCode(au, i)) continue;
|
||||
const n = au[i + 2] === 1 ? i + 3 : i + 4;
|
||||
if ((au[n] & 0x1f) === 7) return "avc1." + [au[n + 1], au[n + 2], au[n + 3]].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
i = n;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
class MacStream {
|
||||
// opts: src; one of ticket (single use, from Frame Control) or space (a
|
||||
// Mac space key); codec, max, fps, bpp (optional); and callbacks:
|
||||
// onFrame(image, width, height, seq, decodedAt): a VideoFrame or
|
||||
// ImageBitmap, now the caller's to draw and close(). Call drawn() or
|
||||
// presented() once it's on screen.
|
||||
// onInfo(m), onStatus(text, sticky), onError(message), onClosed(reason),
|
||||
// onStats(m), onBench(m)
|
||||
constructor(opts) {
|
||||
this.o = opts;
|
||||
this.src = opts.src || "test";
|
||||
this.stats = { frames: 0, keyFrames: 0, bytes: 0, dropped: 0, codec: "", errors: [], info: null };
|
||||
this.clock = { off: null, rtt: 0, samples: [], reported: 0 };
|
||||
this.reconnectKey = "";
|
||||
this.ws = null;
|
||||
this.dec = null;
|
||||
this.codec = "";
|
||||
this.codecString = "";
|
||||
this.needKey = true;
|
||||
this.closing = false;
|
||||
this.retry = 0;
|
||||
this.failedStarts = 0;
|
||||
this.lastError = "";
|
||||
this.lastKeyAsk = 0;
|
||||
this.reports = [];
|
||||
this.dropsToReport = 0;
|
||||
this.shownPending = null;
|
||||
this.rafQueued = false;
|
||||
this.inputId = 0;
|
||||
this.warmMs = 0;
|
||||
this.warmUntil = 0;
|
||||
this.warmTimer = 0;
|
||||
this.jpegBusy = false;
|
||||
this.pingTimer = 0;
|
||||
// Decoded, drawn and shown times go back in batches.
|
||||
this.reportTimer = setInterval(() => {
|
||||
if (!this.reports.length && !this.dropsToReport) return;
|
||||
this.send({ t: "fd", f: this.reports, drop: this.dropsToReport });
|
||||
this.reports = [];
|
||||
this.dropsToReport = 0;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
// ---- clock: the Mac measures each frame's journey on its own clock ----
|
||||
// NTP-style: the Mac's time minus ours, from the ping with the shortest
|
||||
// round trip lately (the least queueing, so the least error).
|
||||
toMac(ms) { return this.clock.off === null ? null : Math.round(ms * 1000 + this.clock.off); }
|
||||
onPong(m) {
|
||||
const now = performance.now(), rtt = now - m.c, clock = this.clock;
|
||||
clock.samples.push({ rtt, off: m.a - (m.c + now) / 2 * 1000 });
|
||||
if (clock.samples.length > 16) clock.samples.shift();
|
||||
const best = clock.samples.reduce((a, b) => (b.rtt < a.rtt ? b : a));
|
||||
clock.off = best.off;
|
||||
clock.rtt = best.rtt;
|
||||
if (now - clock.reported > 1000) {
|
||||
clock.reported = now;
|
||||
this.send({ t: "clock", rtt: +best.rtt.toFixed(2), dec: `${MacEnv.decoder}; ${MacEnv.gl}; raf ${MacEnv.rafHz} Hz` });
|
||||
}
|
||||
}
|
||||
startPings() {
|
||||
clearInterval(this.pingTimer);
|
||||
this.clock.samples = [];
|
||||
for (let i = 0; i < 10; i++) setTimeout(() => this.send({ t: "ping", c: performance.now() }), i * 40);
|
||||
this.pingTimer = setInterval(() => this.send({ t: "ping", c: performance.now() }), 1000);
|
||||
}
|
||||
|
||||
// ---- reports ----
|
||||
dropped() { this.stats.dropped++; this.dropsToReport++; }
|
||||
// A frame drawn on a page: it's on screen from the next animation frame,
|
||||
// unless another replaces it first (then it was never seen).
|
||||
drawn(seq, decodedAt, drawnAt = performance.now()) {
|
||||
this.stats.frames++;
|
||||
if (this.shownPending) {
|
||||
const p = this.shownPending;
|
||||
this.reports.push([p.seq, this.toMac(p.dec), this.toMac(p.drawn), 0]);
|
||||
}
|
||||
this.shownPending = { seq, dec: decodedAt, drawn: drawnAt };
|
||||
if (this.rafQueued) return;
|
||||
this.rafQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.rafQueued = false;
|
||||
const p = this.shownPending, now = performance.now();
|
||||
this.shownPending = null;
|
||||
if (p && this.clock.off !== null) this.reports.push([p.seq, this.toMac(p.dec), this.toMac(p.drawn), this.toMac(now)]);
|
||||
});
|
||||
}
|
||||
// A decoded frame a newer one replaced before it was drawn.
|
||||
replaced(seq, decodedAt) {
|
||||
if (this.clock.off !== null) this.reports.push([seq, this.toMac(decodedAt), 0, 0]);
|
||||
}
|
||||
// A frame drawn into a WebXR frame: shown when that frame is.
|
||||
presented(seq, decodedAt, drawnAt, shownAt) {
|
||||
this.stats.frames++;
|
||||
if (this.clock.off !== null) this.reports.push([seq, this.toMac(decodedAt), this.toMac(drawnAt), this.toMac(shownAt)]);
|
||||
}
|
||||
|
||||
// ---- connection ----
|
||||
send(obj) { if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(obj)); }
|
||||
askKeyFrame() {
|
||||
const now = performance.now();
|
||||
if (now - this.lastKeyAsk < 500) return;
|
||||
this.lastKeyAsk = now;
|
||||
this.send({ t: "key-frame" });
|
||||
}
|
||||
status(text, sticky) { if (this.o.onStatus) this.o.onStatus(text, sticky); }
|
||||
|
||||
async start() {
|
||||
this.codec = await macPickCodec(this.o.codec);
|
||||
this.stats.codec = this.codec;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.codec === "h264") this.makeDecoder();
|
||||
this.needKey = true;
|
||||
const o = this.o;
|
||||
const p = new URLSearchParams({ src: this.src, codec: this.codec, max: o.max || "1920", fps: o.fps || "60" });
|
||||
if (this.reconnectKey) p.set("r", this.reconnectKey);
|
||||
else if (o.space) p.set("s", o.space);
|
||||
else p.set("t", o.ticket || "");
|
||||
if (o.bpp) p.set("bpp", o.bpp);
|
||||
const ws = this.ws = new WebSocket(`ws://${location.host}/stream?${p}`);
|
||||
ws.binaryType = "arraybuffer";
|
||||
ws.onopen = () => { this.retry = 0; this.lastError = ""; this.startPings(); };
|
||||
ws.onmessage = ev => {
|
||||
if (typeof ev.data === "string") return this.control(JSON.parse(ev.data));
|
||||
const now = performance.now(), b = new Uint8Array(ev.data);
|
||||
this.stats.bytes += b.length;
|
||||
if (b.length < 18) return;
|
||||
// flags (1 = keyframe), pts µs, sequence number, input echoed; big-endian.
|
||||
const v = new DataView(ev.data), isKey = (b[0] & 1) === 1, seq = v.getUint32(9);
|
||||
this.send({ t: "rx", s: seq, r: this.toMac(now) });
|
||||
const payload = b.subarray(17);
|
||||
if (this.codec === "h264") this.onH264(isKey, seq, payload); else this.onJPEG(seq, payload);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (this.ws === ws) this.ws = null;
|
||||
clearInterval(this.pingTimer);
|
||||
if (this.closing) return;
|
||||
// Refused before ever getting a key: the ticket expired or was revoked,
|
||||
// and retrying can't help.
|
||||
if (!this.reconnectKey && ++this.failedStarts >= 3) {
|
||||
this.status("This view has expired. Press Show in Frame Control on the Mac to open it again.", true);
|
||||
if (o.onClosed) o.onClosed("expired");
|
||||
return;
|
||||
}
|
||||
// The Mac may be asleep or the tunnel restarting: keep trying.
|
||||
this.retry = Math.min(this.retry + 1, 6);
|
||||
// Keep the Mac's reason (a missing permission, a closed window) on screen.
|
||||
this.status(this.lastError ? `${this.lastError} Retrying…` : "Lost the Mac. Reconnecting…", true);
|
||||
setTimeout(() => { if (!this.closing) this.connect(); }, 500 * 2 ** this.retry);
|
||||
};
|
||||
}
|
||||
|
||||
control(m) {
|
||||
const o = this.o;
|
||||
if (m.t === "pong") return this.onPong(m);
|
||||
if (m.t === "stats") return o.onStats && o.onStats(m);
|
||||
if (m.t === "bench") return o.onBench && o.onBench(m);
|
||||
if (m.t === "hello") {
|
||||
this.reconnectKey = m.r;
|
||||
this.send({ t: "ack" }); // the ticket is spent only now
|
||||
} else if (m.t === "info") {
|
||||
this.stats.info = m;
|
||||
this.warmMs = Math.max(0, +m.warm || 0);
|
||||
if (o.onInfo) o.onInfo(m);
|
||||
} else if (m.t === "error") {
|
||||
this.stats.errors.push(m.message);
|
||||
this.lastError = m.message.replace(/\.?$/, ".");
|
||||
if (o.onError) o.onError(m.message);
|
||||
} else if (m.t === "close" || m.t === "closed") {
|
||||
this.stop();
|
||||
if (o.onClosed) o.onClosed(m.reason || "");
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.closing = true;
|
||||
clearInterval(this.pingTimer);
|
||||
clearInterval(this.reportTimer);
|
||||
clearInterval(this.warmTimer);
|
||||
if (this.ws) try { this.ws.close(); } catch (e) {}
|
||||
if (this.dec) try { this.dec.close(); } catch (e) {}
|
||||
}
|
||||
|
||||
// ---- H.264 ----
|
||||
makeDecoder() {
|
||||
if (this.dec) try { this.dec.close(); } catch (e) {}
|
||||
this.codecString = "";
|
||||
this.dec = new VideoDecoder({
|
||||
// The chunk's timestamp is the Mac's sequence number, to match reports up.
|
||||
output: frame => {
|
||||
if (this.closing) return frame.close();
|
||||
this.o.onFrame(frame, frame.displayWidth, frame.displayHeight, frame.timestamp, performance.now());
|
||||
},
|
||||
error: e => {
|
||||
this.stats.errors.push(String(e.message || e));
|
||||
this.needKey = true;
|
||||
if (this.closing) return;
|
||||
this.makeDecoder();
|
||||
this.askKeyFrame();
|
||||
},
|
||||
});
|
||||
}
|
||||
onH264(isKey, seq, au) {
|
||||
const dec = this.dec;
|
||||
if (isKey) {
|
||||
const cs = macSpsCodec(au);
|
||||
if (cs && (cs !== this.codecString || dec.state !== "configured")) {
|
||||
if (dec.state === "closed") this.makeDecoder();
|
||||
this.dec.configure({ codec: cs, optimizeForLatency: true, hardwareAcceleration: "no-preference" });
|
||||
this.codecString = this.stats.codec = cs;
|
||||
}
|
||||
this.stats.keyFrames++;
|
||||
}
|
||||
if (this.dec.state !== "configured" || (this.needKey && !isKey)) { this.dropped(); this.askKeyFrame(); return; }
|
||||
// Far behind: skip to the next keyframe rather than show old pictures late.
|
||||
// A few queued frames are fine: decoding catches up faster than a keyframe
|
||||
// crosses a slow link, and only the newest decoded frame gets drawn.
|
||||
if (!isKey && this.dec.decodeQueueSize > 10) { this.needKey = true; this.dropped(); this.askKeyFrame(); return; }
|
||||
this.needKey = false;
|
||||
this.dec.decode(new EncodedVideoChunk({ type: isKey ? "key" : "delta", timestamp: seq, data: au }));
|
||||
}
|
||||
|
||||
// ---- JPEG ----
|
||||
onJPEG(seq, data) {
|
||||
if (this.jpegBusy) { this.dropped(); return; }
|
||||
this.jpegBusy = true;
|
||||
createImageBitmap(new Blob([data], { type: "image/jpeg" }))
|
||||
.then(bmp => { if (this.closing) bmp.close(); else this.o.onFrame(bmp, bmp.width, bmp.height, seq, performance.now()); })
|
||||
.catch(e => this.stats.errors.push(String(e)))
|
||||
.finally(() => { this.jpegBusy = false; });
|
||||
}
|
||||
|
||||
// ---- input ----
|
||||
// x, y are 0..1 across the picture. Discrete input carries an id and when
|
||||
// it happened (the Mac's clock), so the Mac can tag the first frame that
|
||||
// could show its effect.
|
||||
stamp(m, at) {
|
||||
this.keepWarm();
|
||||
m.i = ++this.inputId;
|
||||
m.tv = this.toMac(at || performance.now());
|
||||
return m;
|
||||
}
|
||||
// Keep the Frame's Wi-Fi awake for a few seconds after input, when the agent asks.
|
||||
keepWarm() {
|
||||
if (!this.warmMs) return;
|
||||
this.warmUntil = performance.now() + 5000;
|
||||
if (this.warmTimer) return;
|
||||
this.warmTimer = setInterval(() => {
|
||||
if (performance.now() > this.warmUntil) { clearInterval(this.warmTimer); this.warmTimer = 0; return; }
|
||||
this.send({ t: "w" });
|
||||
}, this.warmMs);
|
||||
}
|
||||
move(x, y) { this.send({ t: "m", e: "move", x, y }); }
|
||||
down(x, y, button = 0, at) { this.send(this.stamp({ t: "m", e: "down", b: button, x, y }, at)); }
|
||||
up(x, y, button = 0) { this.send({ t: "m", e: "up", b: button, x, y }); }
|
||||
wheel(dx, dy, x, y, at) { this.send(this.stamp({ t: "wheel", dx, dy, x, y }, at)); }
|
||||
key(down, code, key, mods, at) {
|
||||
const m = { t: "k", e: down ? "down" : "up", code, key, mods };
|
||||
this.send(down ? this.stamp(m, at) : m);
|
||||
}
|
||||
text(s, at) { this.send(this.stamp({ t: "text", s }, at)); }
|
||||
release() { this.send({ t: "release" }); }
|
||||
focus() { this.send({ t: "focus" }); }
|
||||
}
|
||||
window.MacStream = MacStream;
|
||||
window.MacEnv = MacEnv;
|
||||
+265
-47
@@ -24,22 +24,83 @@
|
||||
<canvas id="c" width="16" height="9"></canvas>
|
||||
<div id="note">Connecting to your computer…</div>
|
||||
<div id="hud" hidden></div>
|
||||
<script src="/mac-stream.js"></script>
|
||||
<script>
|
||||
"use strict";
|
||||
const q = new URLSearchParams(location.search);
|
||||
// t: a single-use ticket from Frame Control. After the first connection the
|
||||
// Mac sends a reconnect key, which the stream keeps in memory only.
|
||||
const src = q.get("src") || "test", tag = q.get("tag") || "";
|
||||
// Mac sends a reconnect key, kept only in memory.
|
||||
const src = q.get("src") || "test", ticket = q.get("t") || "", tag = q.get("tag") || "";
|
||||
let reconnectKey = "";
|
||||
const c = document.getElementById("c"), ctx = c.getContext("2d", { alpha: false, desynchronized: true });
|
||||
const note = document.getElementById("note");
|
||||
let noteTimer = 0, hideNoteOnFrame = true; // "Connecting…"/"Reconnecting…" go once video flows again
|
||||
function show(text, ms) {
|
||||
note.textContent = text;
|
||||
note.hidden = false;
|
||||
clearTimeout(noteTimer);
|
||||
if (ms) noteTimer = setTimeout(() => { note.hidden = true; }, ms);
|
||||
// Counters for Frame Control's tests (read over DevTools).
|
||||
const stats = window.stats = { frames: 0, keyFrames: 0, bytes: 0, dropped: 0, codec: "", errors: [], info: null };
|
||||
|
||||
// ---- timing: the Mac measures each frame's journey on its own clock ----
|
||||
// Clock sync, NTP-style: the Mac's time minus ours, from the ping with the
|
||||
// shortest round trip lately (the least queueing, so the least error).
|
||||
const clock = { off: null, rtt: 0, samples: [], reported: 0 };
|
||||
function toMac(ms) { return clock.off === null ? null : Math.round(ms * 1000 + clock.off); }
|
||||
function onPong(m) {
|
||||
const now = performance.now(), rtt = now - m.c;
|
||||
clock.samples.push({ rtt, off: m.a - (m.c + now) / 2 * 1000 });
|
||||
if (clock.samples.length > 16) clock.samples.shift();
|
||||
const best = clock.samples.reduce((a, b) => (b.rtt < a.rtt ? b : a));
|
||||
clock.off = best.off;
|
||||
clock.rtt = best.rtt;
|
||||
if (now - clock.reported > 1000) {
|
||||
clock.reported = now;
|
||||
send({ t: "clock", rtt: +best.rtt.toFixed(2), dec: `${decoderInfo}; ${glInfo}; raf ${rafHz} Hz` });
|
||||
}
|
||||
}
|
||||
let pingTimer = 0;
|
||||
function startPings() {
|
||||
clearInterval(pingTimer);
|
||||
clock.samples = [];
|
||||
for (let i = 0; i < 10; i++) setTimeout(() => send({ t: "ping", c: performance.now() }), i * 40);
|
||||
pingTimer = setInterval(() => send({ t: "ping", c: performance.now() }), 1000);
|
||||
}
|
||||
// Decoded, drawn and next-animation-frame times go back in batches.
|
||||
let reports = [], dropsToReport = 0, shownPending = null, rafQueued = false;
|
||||
setInterval(() => {
|
||||
if (!reports.length && !dropsToReport) return;
|
||||
send({ t: "fd", f: reports, drop: dropsToReport });
|
||||
reports = [];
|
||||
dropsToReport = 0;
|
||||
}, 250);
|
||||
function dropped() { stats.dropped++; dropsToReport++; }
|
||||
// A drawn frame is on screen from the next animation frame, unless another
|
||||
// replaces it first (then it was never seen).
|
||||
function shown(seq, decodedAt, drawnAt) {
|
||||
if (shownPending) reports.push([shownPending.seq, toMac(shownPending.dec), toMac(shownPending.drawn), 0]);
|
||||
shownPending = { seq, dec: decodedAt, drawn: drawnAt };
|
||||
if (rafQueued) return;
|
||||
rafQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
rafQueued = false;
|
||||
const p = shownPending, now = performance.now();
|
||||
shownPending = null;
|
||||
if (p && clock.off !== null) reports.push([p.seq, toMac(p.dec), toMac(p.drawn), toMac(now)]);
|
||||
});
|
||||
}
|
||||
let decoderInfo = "";
|
||||
// What this browser draws with, and how often it gives an animation frame
|
||||
// when nothing else is going on (both only for the numbers).
|
||||
let glInfo = "", rafHz = 0;
|
||||
try {
|
||||
const gl = document.createElement("canvas").getContext("webgl");
|
||||
const ext = gl && gl.getExtension("WEBGL_debug_renderer_info");
|
||||
glInfo = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl ? "webgl" : "no webgl";
|
||||
} catch (e) { glInfo = "?"; }
|
||||
(function measureRaf() {
|
||||
let n = 0, t0 = 0;
|
||||
const tick = t => {
|
||||
if (!t0) t0 = t;
|
||||
if (t - t0 < 1000) { n++; return requestAnimationFrame(tick); }
|
||||
rafHz = Math.round(n * 1000 / (t - t0));
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
})();
|
||||
const hud = document.getElementById("hud");
|
||||
function showHud(on) { hud.hidden = !on; }
|
||||
function ms(o) { return o && o.p50 !== undefined ? `${o.p50}/${o.p95}` : "–"; }
|
||||
@@ -50,41 +111,177 @@ function onStats(m) {
|
||||
`\nlatency p50/p95 ms total ${ms(m.total)}\n capture ${ms(m.capture)} queue ${ms(m.queue)} encode ${ms(m.encode)}` +
|
||||
`\n network ${ms(m.network)} decode ${ms(m.decode)} draw ${ms(m.draw)}` +
|
||||
`\ninput→shown p50 ${m.input ? m.input.p50.toFixed(0) + " ms" : "–"} rtt ${m.rtt} ms` +
|
||||
`\nskipped ${m.skipped}/${m.captured} dropped ${m.dropped} ${MacEnv.decoder}`;
|
||||
`\nskipped ${m.skipped}/${m.captured} dropped ${m.dropped} ${decoderInfo}`;
|
||||
}
|
||||
showHud(q.get("stats") === "1");
|
||||
// Frame Control finds this window on the Frame by the tag in its title.
|
||||
document.title = tag ? `Mac [${tag}]` : "Mac";
|
||||
let failedStarts = 0;
|
||||
let ws = null, dec = null, codecString = "", needKey = true, closing = false, retry = 0, noteTimer = 0, lastError = "";
|
||||
|
||||
const stream = new MacStream({
|
||||
src, ticket: q.get("t") || "", codec: q.get("codec"), max: q.get("max"), fps: q.get("fps"), bpp: q.get("bpp"),
|
||||
onFrame(img, w, h, seq, decodedAt) {
|
||||
if (c.width !== w || c.height !== h) { c.width = w; c.height = h; }
|
||||
ctx.drawImage(img, 0, 0);
|
||||
img.close();
|
||||
stream.drawn(seq, decodedAt);
|
||||
if (hideNoteOnFrame) { hideNoteOnFrame = false; note.hidden = true; }
|
||||
},
|
||||
onStatus(text) { hideNoteOnFrame = true; show(text); },
|
||||
onInfo(m) {
|
||||
function show(text, ms) {
|
||||
note.textContent = text;
|
||||
note.hidden = false;
|
||||
clearTimeout(noteTimer);
|
||||
if (ms) noteTimer = setTimeout(() => { note.hidden = true; }, ms);
|
||||
}
|
||||
|
||||
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
|
||||
let lastKeyAsk = 0;
|
||||
function askKeyFrame() {
|
||||
const now = performance.now();
|
||||
if (now - lastKeyAsk < 500) return;
|
||||
lastKeyAsk = now;
|
||||
send({ t: "key-frame" });
|
||||
}
|
||||
|
||||
let hideNoteOnFrame = true; // "Connecting…"/"Reconnecting…" go once video flows again
|
||||
function drawFrame(img, w, h, seq, decodedAt) {
|
||||
if (c.width !== w || c.height !== h) { c.width = w; c.height = h; }
|
||||
ctx.drawImage(img, 0, 0);
|
||||
shown(seq, decodedAt, performance.now());
|
||||
stats.frames++;
|
||||
if (hideNoteOnFrame) { hideNoteOnFrame = false; note.hidden = true; }
|
||||
}
|
||||
|
||||
// ---- H.264 ----
|
||||
function startCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && (b[i + 2] === 1 || (b[i + 2] === 0 && b[i + 3] === 1)); }
|
||||
function spsCodec(au) {
|
||||
for (let i = 0; i + 7 < au.length; i++) {
|
||||
if (!startCode(au, i)) continue;
|
||||
const n = au[i + 2] === 1 ? i + 3 : i + 4;
|
||||
if ((au[n] & 0x1f) === 7) return "avc1." + [au[n + 1], au[n + 2], au[n + 3]].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
i = n;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function makeDecoder() {
|
||||
if (dec) try { dec.close(); } catch (e) {}
|
||||
codecString = "";
|
||||
dec = new VideoDecoder({
|
||||
// The chunk's timestamp is the Mac's sequence number, to match reports up.
|
||||
output: frame => { drawFrame(frame, frame.displayWidth, frame.displayHeight, frame.timestamp, performance.now()); frame.close(); },
|
||||
error: e => {
|
||||
stats.errors.push(String(e.message || e));
|
||||
needKey = true;
|
||||
makeDecoder();
|
||||
askKeyFrame();
|
||||
},
|
||||
});
|
||||
}
|
||||
function onH264(isKey, seq, au) {
|
||||
if (isKey) {
|
||||
const cs = spsCodec(au);
|
||||
if (cs && (cs !== codecString || dec.state !== "configured")) {
|
||||
if (dec.state === "closed") makeDecoder();
|
||||
dec.configure({ codec: cs, optimizeForLatency: true, hardwareAcceleration: "no-preference" });
|
||||
codecString = stats.codec = cs;
|
||||
}
|
||||
stats.keyFrames++;
|
||||
}
|
||||
if (dec.state !== "configured" || (needKey && !isKey)) { dropped(); askKeyFrame(); return; }
|
||||
// Far behind: skip to the next keyframe rather than show old pictures late.
|
||||
// A few queued frames are fine: decoding catches up faster than a keyframe
|
||||
// crosses a slow link, and only the newest decoded frame gets drawn.
|
||||
if (!isKey && dec.decodeQueueSize > 10) { needKey = true; dropped(); askKeyFrame(); return; }
|
||||
needKey = false;
|
||||
dec.decode(new EncodedVideoChunk({ type: isKey ? "key" : "delta", timestamp: seq, data: au }));
|
||||
}
|
||||
|
||||
// ---- JPEG ----
|
||||
let jpegBusy = false;
|
||||
function onJPEG(seq, data) {
|
||||
if (jpegBusy) { dropped(); return; }
|
||||
jpegBusy = true;
|
||||
createImageBitmap(new Blob([data], { type: "image/jpeg" })).then(bmp => {
|
||||
drawFrame(bmp, bmp.width, bmp.height, seq, performance.now());
|
||||
bmp.close();
|
||||
}).catch(e => stats.errors.push(String(e))).finally(() => { jpegBusy = false; });
|
||||
}
|
||||
|
||||
async function pickCodec() {
|
||||
const want = q.get("codec");
|
||||
if (want === "jpeg") return "jpeg";
|
||||
if (!("VideoDecoder" in window)) return "jpeg";
|
||||
try {
|
||||
const r = await VideoDecoder.isConfigSupported({ codec: "avc1.640028", optimizeForLatency: true });
|
||||
if (!r.supported) return "jpeg";
|
||||
// Whether this browser has a hardware H.264 decoder (for the numbers).
|
||||
const hw = await VideoDecoder.isConfigSupported({ codec: "avc1.640028", hardwareAcceleration: "prefer-hardware" })
|
||||
.catch(() => ({ supported: false }));
|
||||
decoderInfo = hw.supported ? "h264 hardware" : "h264 software";
|
||||
return "h264";
|
||||
} catch (e) { return "jpeg"; }
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const codec = await pickCodec();
|
||||
stats.codec = codec;
|
||||
if (codec === "h264") makeDecoder();
|
||||
needKey = true;
|
||||
const p = new URLSearchParams({ src, codec, max: q.get("max") || "1920", fps: q.get("fps") || "60" });
|
||||
if (reconnectKey) p.set("r", reconnectKey); else p.set("t", ticket);
|
||||
if (q.get("bpp")) p.set("bpp", q.get("bpp"));
|
||||
ws = new WebSocket(`ws://${location.host}/stream?${p}`);
|
||||
ws.binaryType = "arraybuffer";
|
||||
ws.onopen = () => { retry = 0; lastError = ""; startPings(); };
|
||||
ws.onmessage = ev => {
|
||||
if (typeof ev.data === "string") return control(JSON.parse(ev.data));
|
||||
const now = performance.now(), b = new Uint8Array(ev.data);
|
||||
stats.bytes += b.length;
|
||||
if (b.length < 18) return;
|
||||
// flags (1 = keyframe), pts µs, sequence number, input echoed; big-endian.
|
||||
const v = new DataView(ev.data), isKey = (b[0] & 1) === 1, seq = v.getUint32(9);
|
||||
send({ t: "rx", s: seq, r: toMac(now) });
|
||||
const payload = b.subarray(17);
|
||||
if (codec === "h264") onH264(isKey, seq, payload); else onJPEG(seq, payload);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
clearInterval(pingTimer);
|
||||
if (closing) return;
|
||||
// Refused before ever getting a key: the ticket expired or was revoked,
|
||||
// and retrying can't help.
|
||||
if (!reconnectKey && ++failedStarts >= 3) {
|
||||
show("This view has expired. Press Show in Frame Control on your computer to open it again.");
|
||||
return;
|
||||
}
|
||||
hideNoteOnFrame = true;
|
||||
// The Mac may be asleep or the tunnel restarting: keep trying.
|
||||
retry = Math.min(retry + 1, 6);
|
||||
// Keep the Mac's reason (a missing permission, a closed window) on screen.
|
||||
show(lastError ? `${lastError} Retrying…` : "Lost the computer. Reconnecting…");
|
||||
setTimeout(connect, 500 * 2 ** retry);
|
||||
};
|
||||
}
|
||||
|
||||
function control(m) {
|
||||
if (m.t === "pong") return onPong(m);
|
||||
if (m.t === "stats") return onStats(m);
|
||||
if (m.t === "bench") return bench(m);
|
||||
if (m.t === "hello") {
|
||||
reconnectKey = m.r;
|
||||
send({ t: "ack" }); // the ticket is spent only now
|
||||
} else if (m.t === "info") {
|
||||
stats.info = m;
|
||||
warmMs = Math.max(0, +m.warm || 0);
|
||||
const name = m.title && m.app && m.title !== m.app ? `${m.title} — ${m.app}` : (m.title || m.app || "Mac");
|
||||
document.title = tag ? `${name} [${tag}]` : name;
|
||||
if (!m.input) {
|
||||
hideNoteOnFrame = false; // a warning, not a connection notice: let it stay its 8 s
|
||||
show(m.inputMessage || "Clicks and keys need Accessibility permission on the Mac (Frame Control asks for it).", 8000);
|
||||
}
|
||||
},
|
||||
onError(message) { show(message); },
|
||||
onClosed(reason) {
|
||||
if (reason === "expired") return;
|
||||
show(reason ? `Stopped: ${reason}.` : "Stopped.");
|
||||
} else if (m.t === "error") {
|
||||
stats.errors.push(m.message);
|
||||
lastError = m.message.replace(/\.?$/, ".");
|
||||
show(m.message);
|
||||
} else if (m.t === "close" || m.t === "closed") {
|
||||
closing = true;
|
||||
if (ws) ws.close();
|
||||
show(m.reason ? `Stopped: ${m.reason}.` : "Stopped.");
|
||||
window.close();
|
||||
},
|
||||
onStats,
|
||||
onBench: bench,
|
||||
});
|
||||
// Counters for Frame Control's tests (read over DevTools).
|
||||
window.stats = stream.stats;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- input ----
|
||||
// Where the picture sits inside the window (object-fit: contain letterboxes it).
|
||||
@@ -94,19 +291,39 @@ function toPicture(e) {
|
||||
const x = (e.clientX - left) / w, y = (e.clientY - top) / h;
|
||||
return { x, y, inside: x >= 0 && x <= 1 && y >= 0 && y <= 1 };
|
||||
}
|
||||
// Discrete input carries an id and when it happened (the Mac's clock), so
|
||||
// the Mac can tag the first frame that could show its effect.
|
||||
let inputId = 0;
|
||||
// Keep the Frame's Wi-Fi awake for a few seconds after input, when the agent asks.
|
||||
let warmMs = 0, warmUntil = 0, warmTimer = 0;
|
||||
function keepWarm() {
|
||||
if (!warmMs) return;
|
||||
warmUntil = performance.now() + 5000;
|
||||
if (warmTimer) return;
|
||||
warmTimer = setInterval(() => {
|
||||
if (performance.now() > warmUntil) { clearInterval(warmTimer); warmTimer = 0; return; }
|
||||
send({ t: "w" });
|
||||
}, warmMs);
|
||||
}
|
||||
function stamp(m, e) {
|
||||
keepWarm();
|
||||
m.i = ++inputId;
|
||||
m.tv = toMac(e && e.timeStamp ? e.timeStamp : performance.now());
|
||||
return m;
|
||||
}
|
||||
// Moves go at most every 8 ms, on a timer rather than the next animation frame:
|
||||
// the Frame throttles a panel's animation frames to 15-36 Hz when it thinks
|
||||
// nobody is looking, which would hold a move back by up to 60 ms.
|
||||
let pendingMove = null, moveQueued = false, lastMove = 0;
|
||||
function flushMove() {
|
||||
moveQueued = false;
|
||||
if (pendingMove) { stream.move(pendingMove.x, pendingMove.y); lastMove = performance.now(); }
|
||||
if (pendingMove) { send(pendingMove); lastMove = performance.now(); }
|
||||
pendingMove = null;
|
||||
}
|
||||
addEventListener("pointermove", e => {
|
||||
const p = toPicture(e);
|
||||
if (!p.inside && !e.buttons) return;
|
||||
pendingMove = p;
|
||||
pendingMove = { t: "m", e: "move", x: p.x, y: p.y };
|
||||
if (moveQueued) return;
|
||||
const wait = lastMove + 8 - performance.now();
|
||||
if (wait <= 0) return flushMove();
|
||||
@@ -118,19 +335,19 @@ addEventListener("pointerdown", e => {
|
||||
if (!p.inside) return;
|
||||
if (e.pointerId !== undefined) try { c.setPointerCapture(e.pointerId); } catch (err) {}
|
||||
pendingMove = null;
|
||||
stream.down(p.x, p.y, e.button < 0 ? 0 : e.button, e.timeStamp);
|
||||
send(stamp({ t: "m", e: "down", b: e.button < 0 ? 0 : e.button, x: p.x, y: p.y }, e));
|
||||
e.preventDefault();
|
||||
});
|
||||
addEventListener("pointerup", e => {
|
||||
const p = toPicture(e);
|
||||
stream.up(p.x, p.y, e.button < 0 ? 0 : e.button);
|
||||
send({ t: "m", e: "up", b: e.button < 0 ? 0 : e.button, x: p.x, y: p.y });
|
||||
});
|
||||
// Let go of any held button where the pointer is on the Mac, not at a corner.
|
||||
addEventListener("pointercancel", () => stream.release());
|
||||
addEventListener("pointercancel", () => send({ t: "release" }));
|
||||
addEventListener("contextmenu", e => e.preventDefault());
|
||||
addEventListener("wheel", e => {
|
||||
const p = toPicture(e), unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1;
|
||||
stream.wheel(e.deltaX * unit, e.deltaY * unit, p.x, p.y, e.timeStamp);
|
||||
send(stamp({ t: "wheel", dx: e.deltaX * unit, dy: e.deltaY * unit, x: p.x, y: p.y }, e));
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
function mods(e) {
|
||||
@@ -142,7 +359,8 @@ function onKey(e, down) {
|
||||
if (down) showHud(hud.hidden);
|
||||
return e.preventDefault();
|
||||
}
|
||||
stream.key(down, e.code, e.key, mods(e), e.timeStamp);
|
||||
const m = { t: "k", e: down ? "down" : "up", code: e.code, key: e.key, mods: mods(e) };
|
||||
send(down ? stamp(m, e) : m);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
@@ -155,26 +373,26 @@ function keyCode(ch) {
|
||||
function bench(m) {
|
||||
if (m.action === "overlay") return showHud(m.on !== 0 && m.on !== "0");
|
||||
if (m.action === "click") {
|
||||
const x = +m.x || 0.5, y = +m.y || 0.5;
|
||||
stream.down(x, y, 0);
|
||||
setTimeout(() => stream.up(x, y, 0), 40);
|
||||
const at = { x: +m.x || 0.5, y: +m.y || 0.5 };
|
||||
send(stamp({ t: "m", e: "down", b: 0, ...at }));
|
||||
setTimeout(() => send({ t: "m", e: "up", b: 0, ...at }), 40);
|
||||
} else if (m.action === "type") {
|
||||
const text = String(m.text || ""), gap = +m.interval || 150;
|
||||
[...text].forEach((ch, n) => setTimeout(() => {
|
||||
const code = keyCode(ch);
|
||||
if (!code) return stream.text(ch);
|
||||
if (!code) return send(stamp({ t: "text", s: ch }));
|
||||
const mods = ch !== ch.toLowerCase() ? ["shift"] : [];
|
||||
stream.key(true, code, ch, mods);
|
||||
setTimeout(() => stream.key(false, code, ch, mods), 30);
|
||||
send(stamp({ t: "k", e: "down", code, key: ch, mods }));
|
||||
setTimeout(() => send({ t: "k", e: "up", code, key: ch, mods }), 30);
|
||||
}, n * gap));
|
||||
}
|
||||
}
|
||||
addEventListener("keydown", e => onKey(e, true));
|
||||
addEventListener("keyup", e => onKey(e, false));
|
||||
addEventListener("blur", () => stream.release());
|
||||
addEventListener("blur", () => send({ t: "release" }));
|
||||
|
||||
show("Connecting to your computer…");
|
||||
stream.start();
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
@@ -3009,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,
|
||||
@@ -3193,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":
|
||||
@@ -3368,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)
|
||||
@@ -3641,6 +3679,7 @@ def main():
|
||||
# mid-cleanup would abort it and leave the SSH master running.
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
_power.shutdown()
|
||||
_stop_sync.set()
|
||||
webinstall_shutdown()
|
||||
macview.shutdown() # close the headset's viewers before the agent goes
|
||||
|
||||
Reference in new issue
Block a user