Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b726817ac3 | ||
|
|
ae9df35442 | ||
|
|
ce27c0e8f1 | ||
|
|
5277232367 | ||
|
|
3fdc6c46c3 | ||
|
|
9a6b48dea5 | ||
|
|
a3e2b70555 | ||
|
|
092aae24bd | ||
|
|
7adf0805d7 | ||
|
|
7f23acb03f | ||
|
|
d9454ef099 | ||
|
|
71a3a96dd2 | ||
|
|
952b660bf7 | ||
|
|
a4d840a148 |
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://frame-control.pages.dev/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://framecontrol.fun/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://frame-control.pages.dev/feedback/
|
||||
url: https://framecontrol.fun/feedback/
|
||||
about: Bugs, ideas and questions from the website become issues here without being auto-closed.
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Android phone
|
||||
on:
|
||||
pull_request:
|
||||
paths: ['android/**', 'ui/**', 'scripts/**', 'frame/**', 'apk-catalog/**', 'ios/scripts/make_frame_bundle.py', '.github/workflows/android.yml']
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['android/**', 'ui/**', 'scripts/**', 'frame/**', 'apk-catalog/**', 'ios/scripts/make_frame_bundle.py', '.github/workflows/android.yml']
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
- uses: android-actions/setup-android@v3
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
|
||||
- run: ./android/gradlew -p android assembleDebug assembleDebugAndroidTest testDebugUnitTest lintDebug --no-daemon
|
||||
@@ -41,12 +41,8 @@ jobs:
|
||||
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Linux Wireless Adapter doctor (read-only PC smoke)
|
||||
run: |
|
||||
python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json"
|
||||
python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1'
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/obs-source.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: App tests
|
||||
run: node --test app/test/*.test.js
|
||||
- name: Website
|
||||
@@ -71,29 +67,9 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: OBS lifecycle tests
|
||||
run: node app/test/obs-source.test.js
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
# Fast, PC-only Windows query validation, without capture libraries or a headset.
|
||||
wireless-windows:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Wireless doctor fixtures, UI and live read-only Windows inventory
|
||||
run: python -m unittest discover -s tests -p 'test_wireless.py' -v
|
||||
|
||||
# The iPhone app: builds for the Simulator and runs its unit tests.
|
||||
ios:
|
||||
runs-on: macos-latest
|
||||
|
||||
@@ -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://frame-control.pages.dev/feedback/) skips this queue.',
|
||||
'Just want to report a bug or share an idea? The [website feedback form](https://framecontrol.fun/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.',
|
||||
'',
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
@@ -26,9 +26,7 @@ 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. 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
|
||||
# by tag. 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.
|
||||
@@ -42,13 +40,8 @@ jobs:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
# 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
|
||||
# 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)
|
||||
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 \
|
||||
@@ -70,9 +63,6 @@ jobs:
|
||||
- os: macos-latest
|
||||
script: dist
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: macos-15-intel
|
||||
script: dist:mac:x64
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: windows-latest
|
||||
script: dist:win
|
||||
files: app/dist/*.exe app/dist/*.zip
|
||||
@@ -113,20 +103,6 @@ jobs:
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Check the Mac bundle matches its runner
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
bundle=$(find app/dist -maxdepth 2 -name 'Frame Control.app' -type d -print -quit)
|
||||
test -n "$bundle"
|
||||
resources="$bundle/Contents/Resources"
|
||||
for binary in "$bundle/Contents/MacOS/Frame Control" "$resources/python/bin/python3" "$resources/tools/adb" "$resources/mac/bin/frame-mac-view"; do
|
||||
lipo "$binary" -verify_arch "$(uname -m)"
|
||||
done
|
||||
"$resources/python/bin/python3" -I -B -c 'import http.server, ssl, sqlite3; print(ssl.OPENSSL_VERSION)'
|
||||
- name: Launch the built Linux AppImage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
@@ -159,25 +135,8 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# 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
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ 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/') }}
|
||||
@@ -190,24 +149,3 @@ jobs:
|
||||
app/dist/*.AppImage
|
||||
app/dist/*.deb
|
||||
if-no-files-found: ignore
|
||||
|
||||
checksums:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Attach SHA256SUMS after all installers upload
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[]' > releases.jsonl
|
||||
python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" < releases.jsonl > SHA256SUMS
|
||||
# Find the same draft using its exact tag, or its version title if a
|
||||
# draft edit made GitHub temporarily show an untagged-* tag name.
|
||||
id=$(python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" --id < releases.jsonl)
|
||||
old=$(gh api "repos/$GH_REPO/releases/$id" --jq '.assets[] | select(.name == "SHA256SUMS") | .id')
|
||||
for asset in $old; do gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset" >/dev/null; done
|
||||
gh api -X POST "https://uploads.github.com/repos/$GH_REPO/releases/$id/assets?name=SHA256SUMS" \
|
||||
-H "Content-Type: text/plain" --input SHA256SUMS >/dev/null
|
||||
@@ -2,183 +2,6 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix smoke cleanup skipping orphan metadata and Steam shortcuts after a test
|
||||
title directory was already removed. Sweep and verify all six exact reserved
|
||||
smoke IDs; preserve similarly named titles. Make `scripts/frame-smoke.sh`
|
||||
work with POSIX sh and Python 3 without zsh, including from another working
|
||||
directory. Local regressions cover both fixes; no new headset run.
|
||||
|
||||
- Skip backup saves and settings reached through a symbolic link anywhere below
|
||||
the home folder, and recheck paths before packing each file. Local synthetic
|
||||
tests cover redirected Proton prefixes and links changed after discovery;
|
||||
concurrent filesystem changes can still race the checks.
|
||||
[Backup layout and limits](docs/backup-and-recovery.md).
|
||||
|
||||
- Import a base APK with supported native-library or asset splits from a local
|
||||
Android backup or XAPK/APKS/APKM bundle. Check package and version before
|
||||
install; refuse resource, code and feature splits and device-variant sets.
|
||||
This route has local fixture coverage, not a real split-game headset test.
|
||||
|
||||
- Save Steam screenshots under a separate folder for each headset, Steam
|
||||
account and game. Equal screenshot filenames no longer overwrite one another;
|
||||
existing files in the old flat folder are left in place.
|
||||
[Copying guide](docs/recording.md).
|
||||
|
||||
- Keep Steam clip exports separate for each headset, Steam account and nested
|
||||
recording session. Manual export and automatic copying use the same folders;
|
||||
existing flat exports stay untouched and do not mark unrelated clips saved.
|
||||
[Recording guide](docs/recording.md#steam-clips).
|
||||
|
||||
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
|
||||
Highlight exact PnP instances and known state changes after a dropout;
|
||||
incomplete inventories stay unknown. A Forget button clears the comparison.
|
||||
No adapter setting changes or report uploads are made.
|
||||
|
||||
- Add recovery steps to the Windows Wireless Adapter doctor for streams that
|
||||
drop as they start and dongles that will not reconnect. Compare exact PnP IDs
|
||||
and test one connection condition at a time. In-app disable/re-enable remains
|
||||
deferred until an active Steam adapter instance can be identified safely.
|
||||
[Guide and limits](docs/windows-wireless-doctor.md).
|
||||
|
||||
- Add an explicit host window-switch button to whole-display panels: Alt-Tab
|
||||
on Windows and Command-Tab on macOS. Single-window panels keep their own
|
||||
source, so the button is hidden there.
|
||||
|
||||
- Enable the in-app OBS source Start/Stop control on Windows. A control pipe
|
||||
lets the Python relay close its SSH child on Stop, app quit or parent exit;
|
||||
add a short [Discord guide](docs/streaming-to-discord.md).
|
||||
|
||||
- Add a read-only wired connection doctor for a PC using the Frame's observed
|
||||
USB-C network: show the local route, SSH port and pinned-key login result
|
||||
without changing IP, host keys or headset settings.
|
||||
[Usage and limits](docs/wired-connection.md).
|
||||
|
||||
- Start the Android app catalogue without waiting for the headset's installed-app
|
||||
list. Browsing remains available if that status request is slow or fails;
|
||||
installation still waits for a confirmed list to avoid duplicate installs.
|
||||
|
||||
- Verify the packaged OBS Start and Stop control with a real Frame, and record
|
||||
37.600 seconds of headset mirror video and non-silent output audio in OBS.
|
||||
Listening, sync and active passthrough remain unverified.
|
||||
[Test record](docs/evidence/obs-media-source-real-2026-10-25.md).
|
||||
|
||||
- Bundle the OBS source helper in Linux and macOS installers and add a
|
||||
**Capture & Live** Start/Stop control. The app selects the active Frame and
|
||||
closes its SSH stream on a headset switch or app exit. A real Frame stream
|
||||
reached OBS with video and non-silent audio; listening and sync remain
|
||||
unverified. [Setup and limits](docs/obs-headset-source.md).
|
||||
|
||||
- Add a source-checkout helper that sends the Frame's SteamVR mirror and
|
||||
default-output audio to a local OBS Media Source. A real Frame supplied a
|
||||
4.021-second H.264/AAC stream to a Linux receiver; OBS playback and sync
|
||||
remain unverified. [Setup and limits](docs/obs-headset-source.md).
|
||||
|
||||
- Link emulator setup guidance from Store → Emulators. Existing Steam library
|
||||
shortcuts and artwork cover Android apps and sideloaded titles; Android ROM
|
||||
transfer still needs a verified per-instance workflow.
|
||||
[Guide](docs/emulators.md).
|
||||
|
||||
- Record the current Frame OS mirror and charge-limit checks. Active
|
||||
passthrough in the mirror is still unknown; this build has no supported
|
||||
user-writable 80% charge-limit control.
|
||||
[Headset evidence](docs/evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
|
||||
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
|
||||
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
|
||||
visible; no adapter disable action is offered because an unused instance
|
||||
cannot be identified reliably. No headset access or settings changes.
|
||||
[Sources, verification and limits](docs/windows-wireless-doctor.md).
|
||||
|
||||
- Add a Linux PC Wireless Adapter doctor in Settings and Devices, with a source
|
||||
CLI and local JSON report. Select observed hardware without assuming Valve
|
||||
IDs; inspect USB/interface presence, driver/module binding, udev/permissions,
|
||||
rfkill, channel/band and network manager evidence. Missing evidence stays
|
||||
unknown; all checks are read-only and never contact the Frame.
|
||||
[Usage and verification limits](docs/linux-wireless-adapter-doctor.md).
|
||||
|
||||
- Add Linux Tools → Steam desktop sharing: request `steam -pipewire` only after
|
||||
checking Steam is not running, or open KDE Application Permissions with
|
||||
instructions to revoke a verified Steam screen-sharing session manually. No
|
||||
automatic permission reset, Steam restart, or headset access. Frame Control
|
||||
Linux capture remains disabled; live portal behavior is unverified.
|
||||
[Usage and limits](docs/steam-pipewire.md).
|
||||
|
||||
- Use gamescope's input for the keyboard and trackpad, following the focused
|
||||
headset panel without installing an input service. Start KDE Connect only for
|
||||
accents and emoji, keeping queued text ordered and cancelling it on headset
|
||||
switches. Retain intended-headset checks before starting either control and
|
||||
prevent Unicode helper startup from deadlocking with a headset switch.
|
||||
- Keep accepted keyboard text when the Unicode queue is full: reject new batches
|
||||
before sending their clicks or keys, and count in-flight text toward the limit.
|
||||
Retain unsent text after helper failures/timeouts, report the error and resume
|
||||
it with Try again. Clean up failed workers without touching a new headset's queue.
|
||||
[Current verification and device limits](docs/evidence/pad-gamescope-2026-10-24.md).
|
||||
|
||||
- Keep media playback running when SteamVR refuses the optional theatre dark
|
||||
surround, including a retry after standby. Log the refusal and play without
|
||||
the surround. Overlay teardown attempts every owned handle and always calls
|
||||
OpenVR shutdown even if a handle fails to close.
|
||||
|
||||
- Check the desktop server's single-instance lock before starting background
|
||||
downloads, so a second server exits cleanly with the existing "already
|
||||
running" message.
|
||||
|
||||
- Recheck saved spatial layouts on SteamOS 0.5.6 / SteamVR 2.18.2 (issue #31).
|
||||
Read-only probes expose some panel geometry, but safe restoration of
|
||||
Steam/gamescope docking and positions remains unverified. Record exact API
|
||||
results and a reproducible probe; saved layouts remain unavailable.
|
||||
|
||||
- Record packaged v0.4.17 Desktop Capture succeeding on a mapped nested KDE
|
||||
compositor on a real Frame with SteamOS 0.5.6. Headset overlay visibility was
|
||||
not established; this is evidence, not a new fix or resolution of issue #73.
|
||||
|
||||
- Correct the Return to Game Mode help text: Frame Control selects the switch
|
||||
supported by the headset's SteamOS build, including `steamosctl` on current
|
||||
Frames. The button's behavior is unchanged.
|
||||
|
||||
- Distinguish an unreadable gamescope panel list from a confirmed empty list in
|
||||
Desktop Capture and Control (issue #73). Failed discovery now asks users to
|
||||
check SteamVR and report the exact message and SteamOS version, rather than
|
||||
claiming no panel is open. This does not establish a new capture fix.
|
||||
|
||||
- Add an opt-in desktop low-battery alert without starting a family session.
|
||||
Existing status reads confirm low charge and drain; recovery hysteresis and a
|
||||
ten-minute cooldown limit repeat alerts. Off on page open/headset switch;
|
||||
pauses with hidden-page status refreshes. No extra polling or headset changes.
|
||||
|
||||
- Clarify first-time desktop downloads, installation and opening on the website
|
||||
and README, including CPU choices, unsigned-app warnings, pairing fallback
|
||||
and the source-only iOS build. Keep section navigation available on mobile.
|
||||
|
||||
- Treat headset aliases and other CSV metadata as plain text in power and heat
|
||||
exports, so spreadsheet formula prefixes cannot run when a report is opened.
|
||||
|
||||
- Add desktop Tools → Power & heat report: bounded read-only one/five-minute
|
||||
observations with progress, stop and HTML/JSON/CSV downloads. Received samples
|
||||
survive stop, disconnect and headset switch, labeled incomplete and tied to the
|
||||
original headset. Shared sampler/report modules ship with the app; no power or
|
||||
thermal controls are added. Download before replacing a report or quitting.
|
||||
|
||||
- Fix Return to Game Mode misidentifying full Plasma Desktop Mode when its VR
|
||||
overlay also runs gamescope (issue #142). Session targets now determine the
|
||||
mode; conflicting or incomplete evidence stays unknown. Use SteamOS's
|
||||
`steamosctl` switch when available, with older selector commands retained.
|
||||
|
||||
- Export read-only power and heat observations to an offline accessible HTML
|
||||
report with JSON/CSV downloads. `--html FILE` limits sessions to ten minutes;
|
||||
Ctrl+C saves received samples with an explicit incomplete state. Units,
|
||||
unknown counts and sources remain visible; no headset controls are added.
|
||||
|
||||
- Add a guarded `frame-doctor.sh --plan` with read-only, evidence-specific next
|
||||
steps and explicit local baseline reports for before/after diagnostics (issue
|
||||
#34). Restart risk blocks plans/comparisons; no automatic repairs, password
|
||||
changes or headset security changes are performed.
|
||||
|
||||
- Recover a tagged release upload when GitHub temporarily displays its draft
|
||||
as `untagged-…` after an edit, without creating a duplicate draft or
|
||||
silently retrying unrelated upload failures.
|
||||
|
||||
- Import standalone APK/OBB backups from XAPK/APKS/APKM archives through the
|
||||
existing preview and game-data setup flow. Unsafe archives, split sets and
|
||||
Android/data restores are refused with guidance. This does not establish RE4
|
||||
@@ -230,8 +53,3 @@
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
|
||||
- Keep failed automatic screenshot and clip copies visible after three attempts.
|
||||
**Resume** and **Copy everything now** allow another try without restarting;
|
||||
headset switches cannot use another headset's list or saved-file history.
|
||||
[Copying guide](docs/recording.md).
|
||||
@@ -58,9 +58,6 @@ node --test site/test/*.test.mjs # website feedback function
|
||||
|
||||
Say what you tested, and whether you tried it on a real Steam Frame.
|
||||
|
||||
To add a local feature without changing the core app, see the
|
||||
[extension guide](docs/extensions.md) and its working example.
|
||||
|
||||
## Blocking
|
||||
|
||||
If you ignore this document twice, or spam the tracker with agent-generated
|
||||
|
||||
@@ -7,26 +7,24 @@
|
||||
**Manage your Valve Steam Frame from your computer.**<br>
|
||||
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
|
||||
|
||||
[](https://github.com/saphid/frame-control/releases/latest)
|
||||
[](https://github.com/saphid/steam-frame/releases/latest)
|
||||
[](#install)
|
||||
[](https://github.com/saphid/frame-control/actions/workflows/checks.yml)
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[**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)
|
||||
[**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)
|
||||
|
||||
<br>
|
||||
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
|
||||
|
||||
<a id="trailer"></a>
|
||||
<a href="https://github.com/saphid/frame-control/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
|
||||
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
|
||||
|
||||
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
[See what was tested on a real Frame, in a VM, or only in automated checks](docs/current-support-matrix.md).
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -46,9 +44,6 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
For PC-side USB, driver, radio and network manager evidence, use the
|
||||
[Linux Wireless Adapter doctor](docs/linux-wireless-adapter-doctor.md).
|
||||
|
||||
For repeatable read-only battery, power-rail, temperature and fan measurements,
|
||||
use the [power and heat sampler](docs/power-and-heat.md).
|
||||
|
||||
@@ -78,10 +73,10 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots and recordings**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record SteamVR's mirrored headset view, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder. [Recording details and passthrough limits](docs/recording.md#does-record-include-passthrough).
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one. Copy text to the headset's clipboard and back again. Press T to open it.
|
||||
**⌨️ Type on the headset**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -121,49 +116,32 @@ already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
|
||||
## Install
|
||||
|
||||
**Install Frame Control on your computer, not on the headset.** The website is
|
||||
for downloads and instructions; it does not connect to your Frame in a browser.
|
||||
Choose the operating system and processor of the computer you will use.
|
||||
**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/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
| **macOS** (Intel, v0.4.39+) | [Frame-Control-mac-x64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-x64.dmg) | Nothing extra |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
|
||||
### Is it safe? Check your download
|
||||
**iPhone and iPad:** the same features from your phone, with nothing to install on
|
||||
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
|
||||
|
||||
Starting with **v0.4.26**, get the installer and [SHA256SUMS](https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS) from the **same release**. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Use the command for your downloaded file, then compare all 64 characters with its line in `SHA256SUMS`:
|
||||
**Android phones (development):** a native companion is in [`android/`](android).
|
||||
It can download new screenshots during a user-started sync session, with Gallery
|
||||
saving enabled by default. See [build instructions and current testing limits](docs/android-phone.md).
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
|
||||
```
|
||||
|
||||
```sh
|
||||
# macOS: shasum -a 256 Frame-Control-mac-arm64.dmg
|
||||
# Linux: sha256sum Frame-Control-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
The [source](https://github.com/saphid/frame-control) and [release build workflow](.github/workflows/release.yml) are public. The Windows installer is **not code-signed**, so SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.
|
||||
|
||||
日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
|
||||
|
||||
**Which build?** On a Mac, check Apple menu → About This Mac: use `mac-arm64`
|
||||
for Apple Silicon (M-series), or `mac-x64` for Intel (available from v0.4.39). On Windows,
|
||||
check Settings → System → About → System type: the release is x64, not a native
|
||||
Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64`
|
||||
(arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions.
|
||||
|
||||
**iPhone and iPad:** no App Store, TestFlight or `.ipa` download is provided in
|
||||
the current release. It runs from your phone without a computer after setup.
|
||||
Build it from [`ios/`](ios) in Xcode on a Mac with your own signing team; see
|
||||
[docs/iphone.md](docs/iphone.md).
|
||||
|
||||
The app brings its own Python and `adb`. macOS includes SSH; Windows needs the
|
||||
OpenSSH Client optional feature (normally installed).
|
||||
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||
From 0.4 it updates itself: when a new version is published, a banner offers
|
||||
**Update and restart**. It sends anonymous usage statistics, which you can turn
|
||||
off. Sharing compatibility results and error details is opt-in. See
|
||||
@@ -174,44 +152,16 @@ distribution's. If you already have `adb`, the app uses yours.
|
||||
<details>
|
||||
<summary><b>macOS: the app isn't notarized</b></summary>
|
||||
|
||||
The app has an **ad hoc signature**, no Apple Developer ID signature, and no
|
||||
notarization. The DMG is unsigned. First verify the download against the
|
||||
`SHA256SUMS` from the same release, then open the DMG and drag **Frame Control**
|
||||
to **Applications**.
|
||||
|
||||
Check your processor before opening: v0.4.38 and earlier publish only
|
||||
`mac-arm64` for Apple Silicon. On an Intel Mac, this build cannot run;
|
||||
removing quarantine or installing Rosetta does not make an ARM app run on Intel.
|
||||
On a real Intel MacBook Air with macOS 15.7.7, opening v0.4.35 and v0.4.36
|
||||
reported: **“The application cannot be opened because it has an incorrect
|
||||
executable format.”** This is an architecture failure, not an observed
|
||||
Gatekeeper warning. The official **v0.4.39 Intel DMG** was then verified,
|
||||
installed and launched on that Air. Both CLI and T3 browser downloads lacked
|
||||
quarantine metadata; no Gatekeeper dialog appeared, while `spctl` rejected the
|
||||
ad hoc signed app. This does not verify Safari/Chrome warnings or Open Anyway.
|
||||
See the [released Intel test](docs/evidence/mac-v0439-2026-10-26.md) and
|
||||
[earlier architecture failure](docs/evidence/mac-desktop-2026-10-26.md).
|
||||
|
||||
On a compatible Mac, if macOS blocks an unverified developer, try opening the
|
||||
app once, then go to **System Settings → Privacy & Security → Open Anyway**
|
||||
and confirm **Open**. Only do this for a download you verified and trust.
|
||||
[Apple's opening instructions](https://support.apple.com/102445) describe the
|
||||
current security controls. The exact warning and this override flow have not
|
||||
yet been verified here on either Mac architecture.
|
||||
|
||||
If a trusted, verified copy still reports “damaged” and offers no Open Anyway
|
||||
option, the Terminal fallback removes only this app's download quarantine
|
||||
(it does not sign or notarize the app):
|
||||
There's no paid Apple developer account behind it, so macOS says the app is
|
||||
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||
quarantine once:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
|
||||
Then open it from Applications. Do not disable Gatekeeper globally. Depending
|
||||
on the features you use, macOS may also request Local Network access, permission
|
||||
to control Terminal, or Screen Recording; approve only the feature you intend
|
||||
to use. Those permission prompts were not verified in the release test above.
|
||||
|
||||
The first time, macOS also asks to allow local network access (for SSH) and
|
||||
control of Terminal (for the password prompts).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -233,54 +183,26 @@ If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
|
||||
or run it with `--appimage-extract-and-run`.
|
||||
</details>
|
||||
|
||||
## Open the app
|
||||
|
||||
**Optional: open at login.** In the installed desktop app, choose **Frame → Open at Login**. It is off by default. When on, a login launch starts in the tray or menu bar if that control is available; click its icon to open the window, or use its **Quit Frame Control** menu item. Closing the window keeps the app in the tray while this option is on. Turn the same menu option off to remove its OS login item. On Linux this creates or removes only Frame Control's own file in `~/.config/autostart/` (or `$XDG_CONFIG_HOME/autostart/`). If the desktop has no working tray, the window opens at login instead. A source checkout does not offer this setting.
|
||||
|
||||
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
|
||||
open it from Applications. For the Gatekeeper warning, use the macOS steps
|
||||
above. Allow local network access and control of Terminal when prompted.
|
||||
- **Windows:** run the installer, then open **Frame Control** from Start. For
|
||||
the portable download, extract the entire `.zip` first and run
|
||||
`Frame Control.exe` inside it. See the SmartScreen note above.
|
||||
- **Linux:** install the `.deb` with your package installer and launch
|
||||
**Frame Control** from your applications menu; or make the AppImage executable
|
||||
and run it as described above. Install the OpenSSH client if `ssh` is missing.
|
||||
|
||||
## Set up the headset
|
||||
|
||||
Keep the Frame awake and connect it and your computer to the same trusted local
|
||||
network. Guest Wi-Fi/client isolation can prevent discovery and connections.
|
||||
You type one password on the headset, once. Everything else happens on your
|
||||
computer.
|
||||
|
||||
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
|
||||
Developer → **Set User Password**. Choose a password that isn't easy to guess:
|
||||
it also protects `sudo` and remote desktop, even after SSH keys are set up.
|
||||
2. **On your computer:** open Frame Control and choose **Set Up Connection…**.
|
||||
If you dismissed the first-run prompt, use **Frame → Set Up Connection…**.
|
||||
Setup opens a terminal and tries to find the headset. If it can't, use its IP
|
||||
address from Quick Settings or Steam Settings → Internet. Windows/Linux setup
|
||||
prompts for it; macOS exits instead. On macOS, return to the app →
|
||||
**Devices & connection → + Add a headset…**, set SSH alias to `frame` for
|
||||
the default headset, fill in **Address**, then choose **Set Up…**.
|
||||
3. **Pair or enter the password:** setup first tries Valve's devkit pairing.
|
||||
Open Steam Settings → Developer → **Pair new host** in the headset and approve
|
||||
only the request you just started. The service and pairing-mode step are
|
||||
verified on a Frame; approval and key installation through that flow remain
|
||||
unverified. If pairing fails or times out, setup falls back to asking for the
|
||||
Developer Mode password in the computer's terminal. Password typing may show
|
||||
no characters; press Enter when done. See [SSH details](docs/ssh.md#password-free-pairing-steamos-devkit-service).
|
||||
4. After setup succeeds, return to Frame Control. It uses SSH keys for later
|
||||
connections whenever the Frame is awake and reachable. If it stays offline,
|
||||
check Developer Mode, the address and network isolation, then run setup again.
|
||||
For connections away from home, see [Tailscale](docs/tailscale.md).
|
||||
in the Developer section, **Set User Password**. Pick something short:
|
||||
you'll type it once more on your computer and then never again.
|
||||
2. **On your computer:** open Frame Control. It offers to **Set Up
|
||||
Connection**, which finds the headset, creates an SSH key, and asks for that
|
||||
password once in a terminal window. If it can't find the Frame, type the
|
||||
IP address from the Frame's Quick Settings.
|
||||
|
||||
**Does Frame Control lower streaming lag?** No. Frame Control manages the Frame
|
||||
over SSH. SteamVR or Steam Link carries the game stream. Frame Control does not
|
||||
carry that stream, lower ping, or need the Wireless Adapter. It can connect over
|
||||
Wi-Fi, the Wireless Adapter, or USB when your computer has an SSH route to the
|
||||
Frame. The wired doctor checks only that SSH route. A data-capable USB-A-to-C
|
||||
cable has not been tested for the Frame's USB network. See the
|
||||
[connection FAQ and Japanese quick start](docs/connection-faq.md).
|
||||
Before asking for the password it tries Valve's SteamOS devkit pairing: in
|
||||
the headset, open Steam Settings → Developer → **Pair new host** and approve
|
||||
the request, and no password is needed. (The service and the pairing-mode
|
||||
step are verified on a Frame; the approval itself isn't yet. See
|
||||
[SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).)
|
||||
3. That's it. The app now reaches the headset whenever it's awake and on the
|
||||
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
|
||||
|
||||
**What it changes:** only what you click. Installs go to your user account on
|
||||
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
|
||||
@@ -316,23 +238,20 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
|
||||
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [SteamOS October 2026 compatibility](docs/steamos-2026-10-compat.md) | Frame OS 0.5.6 connection changes and SteamOS 3.9.5 PC host notes |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
|
||||
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Steam desktop sharing (Linux)](docs/steam-pipewire.md) | External Steam helper: launch with `-pipewire` after exiting Steam, or open KDE permissions to re-pick a screen manually; no automatic permission reset |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame plugin author guide](docs/frame-plugin-author.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Frame Control for Android phones](docs/android-phone.md) | Development build, pairing, screenshot downloads and foreground sync limits |
|
||||
| [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 |
|
||||
|
||||
@@ -348,8 +267,8 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
|
||||
database key (maintainer-only) is never written to the repo.
|
||||
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
|
||||
you've switched to key auth, that password still protects `sudo` and RDP,
|
||||
so pick one that isn't trivially guessable.
|
||||
you've switched to key auth, a short password still protects `sudo` and
|
||||
RDP, so pick one that isn't trivially guessable.
|
||||
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
|
||||
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
|
||||
**every** Frame port is reachable from your tailnet, including Steam's DevTools
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
*.keystore
|
||||
*.jks
|
||||
@@ -0,0 +1,35 @@
|
||||
plugins { id 'com.android.application' }
|
||||
|
||||
android {
|
||||
namespace 'com.saphid.framecontrol'
|
||||
compileSdk 36
|
||||
defaultConfig {
|
||||
applicationId 'com.saphid.framecontrol'
|
||||
minSdk 29
|
||||
targetSdk 36
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
|
||||
sourceSets.main.assets.srcDir layout.buildDirectory.dir('generated/frameAssets').get().asFile
|
||||
}
|
||||
|
||||
def frameAssets = tasks.register('frameAssets', Exec) {
|
||||
workingDir rootProject.projectDir.parentFile
|
||||
commandLine 'python3', 'android/scripts/make_assets.py', layout.buildDirectory.dir('generated/frameAssets').get().asFile
|
||||
inputs.files(fileTree(rootProject.file('../ui')), fileTree(rootProject.file('../scripts')), fileTree(rootProject.file('../frame')), fileTree(rootProject.file('../apk-catalog')) { exclude 'data/**' })
|
||||
inputs.file(rootProject.file('../ios/scripts/make_frame_bundle.py'))
|
||||
inputs.file(rootProject.file('scripts/make_assets.py'))
|
||||
outputs.dir layout.buildDirectory.dir('generated/frameAssets')
|
||||
}
|
||||
tasks.named('preBuild').configure { dependsOn frameAssets }
|
||||
|
||||
dependencies {
|
||||
implementation 'com.github.mwiede:jsch:2.28.8'
|
||||
implementation 'com.google.code.gson:gson:2.13.2'
|
||||
implementation 'androidx.core:core:1.17.0'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Instrumentation;
|
||||
import android.content.*;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.*;
|
||||
import android.net.Uri;
|
||||
import android.provider.MediaStore;
|
||||
import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
import android.system.OsConstants;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.*;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ScreenshotDeviceTest {
|
||||
private Context context;
|
||||
private final Set<Uri> cleanup = new LinkedHashSet<>();
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
}
|
||||
|
||||
@After
|
||||
public void cleanup() {
|
||||
for (Uri uri : cleanup) context.getContentResolver().delete(uri, null, null);
|
||||
}
|
||||
|
||||
/** Fixture Steam accounts exist on the simulated headset from the first poll. */
|
||||
private static SyncEngine.Listing listing(SyncEngine.Shot... shots) {
|
||||
return new SyncEngine.Listing(Set.of("123", "999999999991"), List.of(shots));
|
||||
}
|
||||
|
||||
private String name(String headset, SyncEngine.Shot shot) throws Exception {
|
||||
String file = shot.id.substring(shot.id.lastIndexOf('/') + 1);
|
||||
int dot = file.lastIndexOf('.');
|
||||
return file.substring(0, dot)
|
||||
+ "_"
|
||||
+ FrameConnection.hash(headset + "/" + shot.id).substring(0, 20)
|
||||
+ file.substring(dot);
|
||||
}
|
||||
|
||||
private Uri row(String headset, SyncEngine.Shot shot) throws Exception {
|
||||
Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
|
||||
@SuppressWarnings("deprecation")
|
||||
Uri includingPending = MediaStore.setIncludePending(collection);
|
||||
try (Cursor c =
|
||||
context
|
||||
.getContentResolver()
|
||||
.query(
|
||||
includingPending,
|
||||
new String[] {"_id"},
|
||||
MediaStore.Images.Media.DISPLAY_NAME
|
||||
+ "=? AND "
|
||||
+ MediaStore.Images.Media.RELATIVE_PATH
|
||||
+ "=?",
|
||||
new String[] {name(headset, shot), "Pictures/Frame Control/"},
|
||||
null)) {
|
||||
if (c != null && c.moveToFirst()) {
|
||||
Uri uri = ContentUris.withAppendedId(collection, c.getLong(0));
|
||||
cleanup.add(uri);
|
||||
assertFalse("Duplicate Gallery row", c.moveToNext());
|
||||
return uri;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void actualGalleryOriginalAndAppOnlySurviveRelaunch() throws Exception {
|
||||
String headset = "instrumented-" + UUID.randomUUID();
|
||||
File root = new File(context.getFilesDir(), "instrumented/" + headset);
|
||||
Object gate = new Object();
|
||||
Bitmap bitmap = Bitmap.createBitmap(48, 32, Bitmap.Config.ARGB_8888);
|
||||
bitmap.eraseColor(Color.rgb(25, 184, 143));
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes);
|
||||
bitmap.recycle();
|
||||
byte[] png = bytes.toByteArray();
|
||||
SyncEngine.Transport transport =
|
||||
new SyncEngine.Transport() {
|
||||
public void download(SyncEngine.Shot shot, File target) throws Exception {
|
||||
Files.write(target.toPath(), png);
|
||||
}
|
||||
|
||||
public boolean validImage(File f) {
|
||||
Bitmap b = BitmapFactory.decodeFile(f.getPath());
|
||||
if (b == null) return false;
|
||||
b.recycle();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
GalleryExporter exporter = new GalleryExporter(context, headset, gate);
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
engine.reconcile(listing(), transport, exporter);
|
||||
SyncEngine.Shot shot =
|
||||
new SyncEngine.Shot("999999999991/108/20261024000000_1.png", png.length, 1792800000);
|
||||
engine.reconcile(listing(shot), transport, exporter);
|
||||
assertNull(row(headset, shot));
|
||||
engine.reconcile(listing(shot), transport, exporter);
|
||||
Uri saved = row(headset, shot);
|
||||
assertNotNull(saved);
|
||||
try (InputStream in = context.getContentResolver().openInputStream(saved)) {
|
||||
assertArrayEquals(png, read(in));
|
||||
}
|
||||
assertEquals("saved", engine.items().get(0).destination);
|
||||
engine = new SyncEngine(root, true);
|
||||
engine.reconcile(listing(shot), transport, exporter);
|
||||
assertEquals(saved, row(headset, shot));
|
||||
engine.setGallery(false);
|
||||
SyncEngine.Shot second =
|
||||
new SyncEngine.Shot("999999999991/108/20261024000001_1.png", png.length, 1792800001);
|
||||
engine.reconcile(listing(shot, second), transport, exporter);
|
||||
engine.reconcile(listing(shot, second), transport, exporter);
|
||||
engine.setGallery(true);
|
||||
engine = new SyncEngine(root, true);
|
||||
engine.reconcile(listing(shot, second), transport, exporter);
|
||||
assertNull(row(headset, second));
|
||||
assertTrue(
|
||||
engine.items().stream()
|
||||
.anyMatch(
|
||||
i ->
|
||||
i.shot.id.equals(second.id)
|
||||
&& i.destination.equals("appOnly")
|
||||
&& i.file.exists()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void interruptedMediaStoreRowIsRewrittenWithoutDuplicate() throws Exception {
|
||||
String headset = "pending-" + UUID.randomUUID();
|
||||
SyncEngine.Shot shot =
|
||||
new SyncEngine.Shot("999999999992/108/20261024000002_1.png", 4, 1792800002);
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.Images.Media.DISPLAY_NAME, name(headset, shot));
|
||||
values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/Frame Control/");
|
||||
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 1);
|
||||
Uri pending =
|
||||
context
|
||||
.getContentResolver()
|
||||
.insert(
|
||||
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values);
|
||||
assertNotNull(pending);
|
||||
cleanup.add(pending);
|
||||
File file = File.createTempFile("gallery-proof", ".png", context.getCacheDir());
|
||||
byte[] original = {1, 2, 3, 4};
|
||||
Files.write(file.toPath(), original);
|
||||
GalleryExporter exporter = new GalleryExporter(context, headset, new Object());
|
||||
assertTrue(exporter.export(shot, file, () -> true));
|
||||
assertEquals(pending, row(headset, shot));
|
||||
assertTrue(exporter.export(shot, file, () -> true));
|
||||
assertEquals(pending, row(headset, shot));
|
||||
try (InputStream in = context.getContentResolver().openInputStream(pending)) {
|
||||
assertArrayEquals(original, read(in));
|
||||
}
|
||||
file.delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop/restart overlap: a stopped session's exporter is still writing its pending row when the
|
||||
* next session exports the same screenshot. The old writer reads its original from a FIFO, so
|
||||
* it stays mid-write, with the row open, until the test supplies the bytes. Without the
|
||||
* process-wide export lock the next session publishes that row first and the old session's
|
||||
* cancellation then deletes it, leaving a "saved" receipt with no Gallery image.
|
||||
*/
|
||||
@Test
|
||||
public void stoppedSessionExportCannotDeleteNextSessionsGalleryImage() throws Exception {
|
||||
String headset = "handoff-" + UUID.randomUUID();
|
||||
Bitmap bitmap = Bitmap.createBitmap(16, 16, Bitmap.Config.ARGB_8888);
|
||||
bitmap.eraseColor(Color.rgb(184, 25, 143));
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes);
|
||||
bitmap.recycle();
|
||||
byte[] png = bytes.toByteArray();
|
||||
SyncEngine.Shot shot =
|
||||
new SyncEngine.Shot("999999999993/108/20261026000000_1.png", png.length, 1792900000);
|
||||
File original = File.createTempFile("handoff", ".png", context.getCacheDir());
|
||||
Files.write(original.toPath(), png);
|
||||
File fifo = new File(context.getCacheDir(), "handoff-" + UUID.randomUUID() + ".fifo");
|
||||
Os.mkfifo(fifo.getPath(), 0600);
|
||||
// Both sessions share one settings lock, as both use SyncService.SETTINGS_LOCK.
|
||||
Object settings = new Object();
|
||||
GalleryExporter stopped = new GalleryExporter(context, headset, settings);
|
||||
GalleryExporter next = new GalleryExporter(context, headset, settings);
|
||||
AtomicBoolean stoppedSessionCurrent = new AtomicBoolean(true);
|
||||
AtomicReference<Object> stoppedResult = new AtomicReference<>();
|
||||
AtomicReference<Object> nextResult = new AtomicReference<>();
|
||||
Thread old =
|
||||
exporting(stoppedResult, () -> stopped.export(shot, fifo, stoppedSessionCurrent::get));
|
||||
Thread successor = null;
|
||||
FileDescriptor writer = null;
|
||||
try {
|
||||
old.start();
|
||||
writer = openWriter(fifo, old);
|
||||
// The old export has inserted its pending row and waits for the original's bytes.
|
||||
stoppedSessionCurrent.set(false); // Stop closed the old engine.
|
||||
successor = exporting(nextResult, () -> next.export(shot, original, () -> true));
|
||||
successor.start();
|
||||
Thread waiting = successor;
|
||||
// Fixed: the successor queues behind the old export. Former code: it publishes and ends.
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
|
||||
while (waiting.isAlive() && !GalleryExporter.EXPORTS.hasQueuedThread(waiting)) {
|
||||
assertTrue("The next session neither waited nor finished", System.nanoTime() < deadline);
|
||||
Thread.sleep(10);
|
||||
}
|
||||
int written = Os.write(writer, png, 0, png.length);
|
||||
assertEquals(png.length, written);
|
||||
} finally {
|
||||
// An exporter blocked opening the FIFO is not interruptible; a writer's EOF releases it.
|
||||
if (writer == null) {
|
||||
try {
|
||||
writer = Os.open(fifo.getPath(), OsConstants.O_WRONLY | OsConstants.O_NONBLOCK, 0);
|
||||
} catch (ErrnoException ignored) {
|
||||
// No reader is waiting.
|
||||
}
|
||||
}
|
||||
if (writer != null) Os.close(writer);
|
||||
join(old);
|
||||
if (successor != null) join(successor);
|
||||
fifo.delete();
|
||||
original.delete();
|
||||
try {
|
||||
row(headset, shot); // Registers whatever row a failed run left for cleanup.
|
||||
} catch (Throwable ignored) {
|
||||
// Cleanup only; the assertions below report the outcome.
|
||||
}
|
||||
}
|
||||
assertEquals("The stopped session must not save", Boolean.FALSE, result(stoppedResult));
|
||||
assertEquals("The next session must save", Boolean.TRUE, result(nextResult));
|
||||
Uri saved = row(headset, shot);
|
||||
assertNotNull("The next session's Gallery image must survive the old callback", saved);
|
||||
@SuppressWarnings("deprecation")
|
||||
Uri includingPending = MediaStore.setIncludePending(saved);
|
||||
try (Cursor c =
|
||||
context
|
||||
.getContentResolver()
|
||||
.query(
|
||||
includingPending,
|
||||
new String[] {MediaStore.Images.Media.IS_PENDING},
|
||||
null,
|
||||
null,
|
||||
null)) {
|
||||
assertTrue(c != null && c.moveToFirst());
|
||||
assertEquals("The Gallery image must be published", 0, c.getInt(0));
|
||||
}
|
||||
try (InputStream in = context.getContentResolver().openInputStream(saved)) {
|
||||
assertArrayEquals(png, read(in));
|
||||
}
|
||||
}
|
||||
|
||||
private interface Export {
|
||||
boolean run() throws Exception;
|
||||
}
|
||||
|
||||
private static Thread exporting(AtomicReference<Object> result, Export export) {
|
||||
return new Thread(
|
||||
() -> {
|
||||
try {
|
||||
result.set(export.run());
|
||||
} catch (Throwable e) {
|
||||
result.set(e);
|
||||
}
|
||||
},
|
||||
"handoff-export");
|
||||
}
|
||||
|
||||
/** Opens the FIFO's write end once the exporter has opened its read end; bounded. */
|
||||
private static FileDescriptor openWriter(File fifo, Thread reader) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
|
||||
while (true) {
|
||||
try {
|
||||
return Os.open(fifo.getPath(), OsConstants.O_WRONLY | OsConstants.O_NONBLOCK, 0);
|
||||
} catch (ErrnoException e) {
|
||||
if (e.errno != OsConstants.ENXIO) throw e; // ENXIO: no reader yet.
|
||||
}
|
||||
assertTrue("The old export ended before reading its original", reader.isAlive());
|
||||
assertTrue("The old export never started reading", System.nanoTime() < deadline);
|
||||
Thread.sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
private static void join(Thread thread) throws InterruptedException {
|
||||
thread.join(TimeUnit.SECONDS.toMillis(60));
|
||||
if (thread.isAlive()) thread.interrupt();
|
||||
assertFalse("Export thread did not finish", thread.isAlive());
|
||||
}
|
||||
|
||||
private static Object result(AtomicReference<Object> result) {
|
||||
Object value = result.get();
|
||||
if (value instanceof Throwable) throw new AssertionError("Export failed", (Throwable) value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void galleryOptOutLatchesPreviouslyPairedLibraries() throws Exception {
|
||||
assertFalse("Instrumentation must own a stopped product", SyncService.running());
|
||||
File files = Files.createTempDirectory(context.getCacheDir().toPath(), "libraries-").toFile();
|
||||
String preferences = "library-test-" + UUID.randomUUID();
|
||||
Context isolated =
|
||||
new ContextWrapper(context) {
|
||||
@Override
|
||||
public File getFilesDir() {
|
||||
return files;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences getSharedPreferences(String name, int mode) {
|
||||
return context.getSharedPreferences(preferences, mode);
|
||||
}
|
||||
};
|
||||
try {
|
||||
SyncEngine.Transport transfer =
|
||||
new SyncEngine.Transport() {
|
||||
public void download(SyncEngine.Shot shot, File target) throws Exception {
|
||||
Files.write(target.toPath(), new byte[] {1, 2, 3, 4});
|
||||
}
|
||||
|
||||
public boolean validImage(File file) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
SyncEngine.Exporter unavailable = (shot, file, eligible) -> false;
|
||||
SyncEngine.Shot shot = new SyncEngine.Shot("123/108/20261024120000_1.png", 4, 100);
|
||||
File a = SyncService.library(isolated, "headset-a");
|
||||
File b = SyncService.library(isolated, "headset-b");
|
||||
// A retained library still in the version 1 format (no account set), never polled since.
|
||||
File legacy = SyncService.library(isolated, "headset-legacy");
|
||||
assertTrue(legacy.mkdirs());
|
||||
Files.write(
|
||||
new File(legacy, "receipts.json").toPath(),
|
||||
("{\"version\":1,\"initialized\":true,\"gallery\":true,\"records\":{\""
|
||||
+ shot.id
|
||||
+ "\":{\"shot\":{\"id\":\""
|
||||
+ shot.id
|
||||
+ "\",\"size\":4,\"time\":100},\"state\":\"queued\",\"observations\":2,"
|
||||
+ "\"destination\":\"pending\"}}}")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
for (File root : List.of(a, b)) {
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
engine.reconcile(listing(), transfer, unavailable);
|
||||
engine.reconcile(listing(shot), transfer, unavailable);
|
||||
engine.reconcile(listing(shot), transfer, unavailable);
|
||||
assertEquals("pending", engine.items().get(0).destination);
|
||||
}
|
||||
assertTrue(
|
||||
SyncService.prefs(isolated)
|
||||
.edit()
|
||||
.putString("pin", "headset-b")
|
||||
.putBoolean("gallery", true)
|
||||
.commit());
|
||||
SyncService.gallery(isolated, false);
|
||||
// The latched legacy journal must stay readable immediately, by the screen and an engine.
|
||||
assertEquals("appOnly", SyncEngine.snapshot(legacy).get(0).destination);
|
||||
assertEquals("appOnly", new SyncEngine(legacy, false).items().get(0).destination);
|
||||
SyncService.gallery(isolated, true);
|
||||
for (File root : List.of(a, b, legacy)) {
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
assertEquals("appOnly", engine.items().get(0).destination);
|
||||
engine.reconcile(
|
||||
listing(shot),
|
||||
transfer,
|
||||
(s, f, eligible) -> {
|
||||
fail("Opted-out items must never be exported after re-pairing");
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
SyncService.prefs(isolated).edit().clear().commit();
|
||||
try (java.util.stream.Stream<java.nio.file.Path> paths = Files.walk(files.toPath())) {
|
||||
for (java.nio.file.Path path :
|
||||
paths.sorted(Comparator.reverseOrder()).collect(java.util.stream.Collectors.toList()))
|
||||
Files.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changingHeadsetsRebindsShareToTheSelectedOriginal() throws Exception {
|
||||
assertFalse(SyncService.running());
|
||||
String aPin = "switch-a-" + UUID.randomUUID(), bPin = "switch-b-" + UUID.randomUUID();
|
||||
File a = SyncService.library(context, aPin), b = SyncService.library(context, bPin);
|
||||
SharedPreferences prefs = SyncService.prefs(context);
|
||||
String previousPin = prefs.getString("pin", null);
|
||||
boolean previousGallery = prefs.getBoolean("gallery", true);
|
||||
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
AtomicReference<Intent> shared = new AtomicReference<>();
|
||||
Instrumentation.ActivityMonitor monitor =
|
||||
new Instrumentation.ActivityMonitor() {
|
||||
@Override
|
||||
public Instrumentation.ActivityResult onStartActivity(Intent intent) {
|
||||
if (!Intent.ACTION_CHOOSER.equals(intent.getAction())) return null;
|
||||
shared.set(intent.getParcelableExtra(Intent.EXTRA_INTENT));
|
||||
return new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null);
|
||||
}
|
||||
};
|
||||
Activity activity = null;
|
||||
try {
|
||||
SyncEngine.Shot shot = new SyncEngine.Shot("123/108/20261024120000_1.png", 4, 100);
|
||||
for (File root : List.of(a, b)) {
|
||||
byte[] original = root.equals(a) ? new byte[] {1, 2, 3, 4} : new byte[] {5, 6, 7, 8};
|
||||
SyncEngine.Transport transfer =
|
||||
new SyncEngine.Transport() {
|
||||
public void download(SyncEngine.Shot s, File file) throws Exception {
|
||||
Files.write(file.toPath(), original);
|
||||
}
|
||||
|
||||
public boolean validImage(File file) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
engine.reconcile(listing(), transfer, (s, f, e) -> false);
|
||||
engine.reconcile(listing(shot), transfer, (s, f, e) -> false);
|
||||
engine.reconcile(listing(shot), transfer, (s, f, e) -> false);
|
||||
}
|
||||
assertTrue(prefs.edit().putString("pin", aPin).putBoolean("gallery", false).commit());
|
||||
activity =
|
||||
instrumentation.startActivitySync(
|
||||
new Intent(context, MainActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK));
|
||||
instrumentation.addMonitor(monitor);
|
||||
Activity screen = activity;
|
||||
// The library loads on a background executor, so wait for it to render, not for idle.
|
||||
Uri first = shareWhenRendered(instrumentation, screen, shared, null);
|
||||
try (InputStream in = context.getContentResolver().openInputStream(first)) {
|
||||
assertArrayEquals(new byte[] {1, 2, 3, 4}, read(in));
|
||||
}
|
||||
assertTrue(prefs.edit().putString("pin", bPin).commit());
|
||||
// Until the screen notices the new pin it may still share A; it then clears A's list
|
||||
// (no button while B loads) and shows only B's original.
|
||||
Uri selected = shareWhenRendered(instrumentation, screen, shared, first);
|
||||
try (InputStream in = context.getContentResolver().openInputStream(selected)) {
|
||||
assertArrayEquals(new byte[] {5, 6, 7, 8}, read(in));
|
||||
}
|
||||
// Once B is shown, A's list is gone: one button, and sharing again still targets B.
|
||||
AtomicReference<Integer> buttons = new AtomicReference<>();
|
||||
shared.set(null);
|
||||
instrumentation.runOnMainSync(
|
||||
() -> {
|
||||
View content = screen.findViewById(android.R.id.content);
|
||||
buttons.set(shareButtons(content));
|
||||
Button button = shareButton(content);
|
||||
if (button != null) assertTrue(button.performClick());
|
||||
});
|
||||
assertEquals("The previous headset's original must be gone", Integer.valueOf(1), buttons.get());
|
||||
assertNotNull(shared.get());
|
||||
assertEquals(selected, shared.get().getParcelableExtra(Intent.EXTRA_STREAM));
|
||||
} finally {
|
||||
instrumentation.removeMonitor(monitor);
|
||||
Activity screen = activity;
|
||||
if (screen != null) instrumentation.runOnMainSync(screen::finish);
|
||||
SharedPreferences.Editor restore = prefs.edit().putBoolean("gallery", previousGallery);
|
||||
if (previousPin == null) restore.remove("pin");
|
||||
else restore.putString("pin", previousPin);
|
||||
assertTrue(restore.commit());
|
||||
for (File root : List.of(a, b)) {
|
||||
if (!root.exists()) continue;
|
||||
try (java.util.stream.Stream<java.nio.file.Path> paths = Files.walk(root.toPath())) {
|
||||
for (java.nio.file.Path path :
|
||||
paths.sorted(Comparator.reverseOrder()).collect(java.util.stream.Collectors.toList()))
|
||||
Files.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the share button once the asynchronously loaded library shows one and the chosen
|
||||
* original differs from {@code previous}. A missing button is the expected loading state.
|
||||
*/
|
||||
private static Uri shareWhenRendered(
|
||||
Instrumentation instrumentation,
|
||||
Activity screen,
|
||||
AtomicReference<Intent> shared,
|
||||
Uri previous)
|
||||
throws InterruptedException {
|
||||
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
|
||||
do {
|
||||
shared.set(null);
|
||||
instrumentation.runOnMainSync(
|
||||
() -> {
|
||||
Button button = shareButton(screen.findViewById(android.R.id.content));
|
||||
if (button != null) assertTrue(button.performClick());
|
||||
});
|
||||
Intent sent = shared.get();
|
||||
if (sent != null) {
|
||||
Uri uri = sent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
assertNotNull(uri);
|
||||
if (!uri.equals(previous)) return uri;
|
||||
}
|
||||
// Re-check after the screen's next refresh or library load; bounded by the deadline.
|
||||
Thread.sleep(50);
|
||||
} while (System.nanoTime() < deadline);
|
||||
fail("The selected headset's original never became shareable");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int shareButtons(View view) {
|
||||
if (view instanceof Button && "Open / share original".contentEquals(((Button) view).getText()))
|
||||
return 1;
|
||||
int count = 0;
|
||||
if (view instanceof ViewGroup) {
|
||||
ViewGroup group = (ViewGroup) view;
|
||||
for (int i = 0; i < group.getChildCount(); i++) count += shareButtons(group.getChildAt(i));
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static Button shareButton(View view) {
|
||||
if (view instanceof Button && "Open / share original".contentEquals(((Button) view).getText()))
|
||||
return (Button) view;
|
||||
if (view instanceof ViewGroup) {
|
||||
ViewGroup group = (ViewGroup) view;
|
||||
for (int i = 0; i < group.getChildCount(); i++) {
|
||||
Button found = shareButton(group.getChildAt(i));
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Uri insert(String name, boolean pending, byte[] bytes) throws Exception {
|
||||
ContentResolver resolver = context.getContentResolver();
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.Images.Media.DISPLAY_NAME, name);
|
||||
values.put(MediaStore.Images.Media.RELATIVE_PATH, GalleryExporter.FOLDER);
|
||||
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 1);
|
||||
Uri uri =
|
||||
resolver.insert(
|
||||
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values);
|
||||
assertNotNull(uri);
|
||||
cleanup.add(uri);
|
||||
try (OutputStream out = resolver.openOutputStream(uri, "wt")) {
|
||||
out.write(bytes);
|
||||
}
|
||||
if (!pending) {
|
||||
ContentValues done = new ContentValues();
|
||||
done.put(MediaStore.Images.Media.IS_PENDING, 0);
|
||||
assertEquals(1, resolver.update(uri, done, null, null));
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private boolean exists(Uri uri) {
|
||||
@SuppressWarnings("deprecation")
|
||||
Uri includingPending = MediaStore.setIncludePending(uri);
|
||||
try (Cursor c =
|
||||
context.getContentResolver().query(includingPending, new String[] {"_id"}, null, null, null)) {
|
||||
return c != null && c.moveToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages() throws Exception {
|
||||
assertFalse(SyncService.running());
|
||||
File files = Files.createTempDirectory(context.getCacheDir().toPath(), "pending-").toFile();
|
||||
String preferences = "pending-test-" + UUID.randomUUID();
|
||||
Context isolated =
|
||||
new ContextWrapper(context) {
|
||||
@Override
|
||||
public File getFilesDir() {
|
||||
return files;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SharedPreferences getSharedPreferences(String name, int mode) {
|
||||
return context.getSharedPreferences(preferences, mode);
|
||||
}
|
||||
};
|
||||
try {
|
||||
Bitmap bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888);
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes);
|
||||
bitmap.recycle();
|
||||
// A pending row with app-only receipts, from a retained (not currently paired) library.
|
||||
SyncEngine.Shot shot = new SyncEngine.Shot("123/108/20261024120003_1.png", 4, 100);
|
||||
SyncEngine engine = new SyncEngine(SyncService.library(isolated, "retained"), true);
|
||||
engine.reconcile(listing(), null, null);
|
||||
Uri interrupted = insert(name("retained", shot), true, bytes.toByteArray());
|
||||
Uri published = insert("published-" + UUID.randomUUID() + ".png", false, bytes.toByteArray());
|
||||
assertTrue(SyncService.prefs(isolated).edit().putBoolean("gallery", true).commit());
|
||||
SyncService.gallery(isolated, false);
|
||||
assertFalse("Hidden pending row must not outlive the opt-out", exists(interrupted));
|
||||
// Proven deleted by the product; deleting it again throws SecurityException on API 30+.
|
||||
cleanup.remove(interrupted);
|
||||
assertTrue("A published Gallery image must stay", exists(published));
|
||||
SyncService.gallery(isolated, true);
|
||||
assertTrue(exists(published));
|
||||
assertFalse(exists(interrupted));
|
||||
} finally {
|
||||
SyncService.prefs(isolated).edit().clear().commit();
|
||||
try (java.util.stream.Stream<java.nio.file.Path> paths = Files.walk(files.toPath())) {
|
||||
for (java.nio.file.Path path :
|
||||
paths.sorted(Comparator.reverseOrder()).collect(java.util.stream.Collectors.toList()))
|
||||
Files.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional real-headset provisioning. Only this test APK exposes the hook. */
|
||||
@Test
|
||||
public void provisionRealHeadsetIfRequested() throws Exception {
|
||||
android.os.Bundle args = InstrumentationRegistry.getArguments();
|
||||
Assume.assumeTrue("Optional real-headset provisioning", "true".equals(args.getString("live")));
|
||||
String host = args.getString("host"), pin = args.getString("pin");
|
||||
assertNotNull(host);
|
||||
assertNotNull(pin);
|
||||
assertEquals(
|
||||
"Probe must return the expected host key before authentication",
|
||||
pin,
|
||||
FrameConnection.probe(host, 22, "steamos"));
|
||||
String publicKey = new PhoneIdentity().authorizedKey(SyncService.install(context));
|
||||
Files.write(
|
||||
new File(context.getFilesDir(), "live-test-public.pub").toPath(),
|
||||
(publicKey + "\n").getBytes(StandardCharsets.US_ASCII));
|
||||
assertTrue(
|
||||
SyncService.prefs(context)
|
||||
.edit()
|
||||
.putString("host", host)
|
||||
.putString("user", "steamos")
|
||||
.putInt("port", 22)
|
||||
.putString("pin", pin)
|
||||
.commit());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyRealHeadsetIfRequested() throws Exception {
|
||||
android.os.Bundle args = InstrumentationRegistry.getArguments();
|
||||
String id = args.getString("verifyId");
|
||||
Assume.assumeNotNull(id);
|
||||
String pin = SyncService.prefs(context).getString("pin", null);
|
||||
assertNotNull(pin);
|
||||
// Read-only: verification must not rewrite the product's journal.
|
||||
SyncEngine.Item item =
|
||||
SyncEngine.snapshot(SyncService.library(context, pin)).stream().filter(i -> i.shot.id.equals(id)).findFirst().orElseThrow();
|
||||
assertTrue(item.file.exists());
|
||||
String expected = args.getString("sha256");
|
||||
assertNotNull(expected);
|
||||
assertEquals(expected, sha(Files.readAllBytes(item.file.toPath())));
|
||||
boolean gallery = "true".equals(args.getString("gallery"));
|
||||
Uri saved = row(pin, item.shot);
|
||||
cleanup.clear();
|
||||
if (gallery) {
|
||||
assertEquals("saved", item.destination);
|
||||
assertNotNull(saved);
|
||||
try (InputStream in = context.getContentResolver().openInputStream(saved)) {
|
||||
assertEquals(expected, sha(read(in)));
|
||||
}
|
||||
} else {
|
||||
assertEquals("appOnly", item.destination);
|
||||
assertNull(saved);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha(byte[] bytes) throws Exception {
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (byte b : java.security.MessageDigest.getInstance("SHA-256").digest(bytes))
|
||||
out.append(String.format(java.util.Locale.ROOT, "%02x", b & 255));
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private static byte[] read(InputStream input) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] b = new byte[4096];
|
||||
int n;
|
||||
while ((n = input.read(b)) != -1) out.write(b, 0, n);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<application android:label="Frame Control" android:theme="@style/AppTheme"
|
||||
android:icon="@drawable/ic_frame" android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction"
|
||||
android:networkSecurityConfig="@xml/network_security" android:supportsRtl="true">
|
||||
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize|keyboardHidden">
|
||||
<intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter>
|
||||
</activity>
|
||||
<service android:name=".SyncService" android:exported="false" android:foregroundServiceType="connectedDevice" />
|
||||
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.files" android:exported="false" android:grantUriPermissions="true">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,461 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import android.content.Context;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.jcraft.jsch.*;
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.regex.*;
|
||||
|
||||
/** All headset traffic goes through one pinned, authenticated SSH connection. */
|
||||
final class FrameConnection implements AutoCloseable, SyncEngine.Transport {
|
||||
private final Context context;
|
||||
private Session session;
|
||||
private ChannelExec server;
|
||||
private PipedOutputStream serverInput;
|
||||
private int localPort;
|
||||
private String apiKey;
|
||||
private static final String HOST_ALGORITHMS = "ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256";
|
||||
|
||||
FrameConnection(Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
}
|
||||
|
||||
static String fingerprint(String pin) throws Exception {
|
||||
return "SHA256:"
|
||||
+ Base64.getEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(
|
||||
MessageDigest.getInstance("SHA-256").digest(Base64.getDecoder().decode(pin)));
|
||||
}
|
||||
|
||||
static String hash(String value) throws Exception {
|
||||
byte[] bytes =
|
||||
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (byte b : bytes) result.append(String.format(Locale.ROOT, "%02x", b & 255));
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
static String quote(String s) {
|
||||
return "'" + s.replace("'", "'\\''") + "'";
|
||||
}
|
||||
|
||||
private static Session session(JSch jsch, String host, int port, String user) throws Exception {
|
||||
if (host.trim().isEmpty()
|
||||
|| host.length() > 253
|
||||
|| host.contains("/")
|
||||
|| host.chars().anyMatch(Character::isWhitespace)
|
||||
|| port < 1
|
||||
|| port > 65535
|
||||
|| !user.matches("[a-zA-Z0-9_-]{1,32}"))
|
||||
throw new IOException("Check the headset address, port and username.");
|
||||
Session s = jsch.getSession(user, host, port);
|
||||
s.setConfig("StrictHostKeyChecking", "yes");
|
||||
s.setConfig("server_host_key", HOST_ALGORITHMS);
|
||||
s.setConfig("PreferredAuthentications", "publickey");
|
||||
s.setTimeout(20000);
|
||||
s.setServerAliveInterval(15000);
|
||||
s.setServerAliveCountMax(2);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static final class PinRepository implements HostKeyRepository {
|
||||
String observed;
|
||||
final String expected;
|
||||
|
||||
PinRepository(String expected) {
|
||||
this.expected = expected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int check(String host, byte[] key) {
|
||||
observed = Base64.getEncoder().encodeToString(key);
|
||||
return observed.equals(expected) ? OK : CHANGED;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void add(HostKey k, UserInfo u) {}
|
||||
|
||||
@Override
|
||||
public void remove(String h, String t) {}
|
||||
|
||||
@Override
|
||||
public void remove(String h, String t, byte[] k) {}
|
||||
|
||||
@Override
|
||||
public String getKnownHostsRepositoryID() {
|
||||
return "Frame Control pinned headset";
|
||||
}
|
||||
|
||||
@Override
|
||||
public HostKey[] getHostKey() {
|
||||
return new HostKey[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public HostKey[] getHostKey(String h, String t) {
|
||||
return new HostKey[0];
|
||||
}
|
||||
}
|
||||
|
||||
/** Intentionally refuses the key exchange before any authentication is sent. */
|
||||
static String probe(String host, int port, String user) throws Exception {
|
||||
JSch jsch = new JSch();
|
||||
PinRepository pin = new PinRepository(null);
|
||||
jsch.setHostKeyRepository(pin);
|
||||
Session s = session(jsch, host, port, user);
|
||||
try {
|
||||
s.connect(10000);
|
||||
} catch (JSchException e) {
|
||||
if (pin.observed == null) throw e;
|
||||
} finally {
|
||||
s.disconnect();
|
||||
}
|
||||
return pin.observed;
|
||||
}
|
||||
|
||||
static void pair(String host, int port, String user, String pin, String password, String install)
|
||||
throws Exception {
|
||||
JSch jsch = new JSch();
|
||||
jsch.setHostKeyRepository(new PinRepository(pin));
|
||||
Session s = session(jsch, host, port, user);
|
||||
s.setConfig("PreferredAuthentications", "password,keyboard-interactive");
|
||||
s.setPassword(password);
|
||||
s.setUserInfo(
|
||||
new UserInfo() {
|
||||
public String getPassphrase() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public boolean promptPassword(String x) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean promptPassphrase(String x) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean promptYesNo(String x) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void showMessage(String x) {}
|
||||
});
|
||||
try {
|
||||
s.connect(15000);
|
||||
run(s, authorizeCommand(new PhoneIdentity().authorizedKey(install)), 10000);
|
||||
} finally {
|
||||
s.disconnect();
|
||||
}
|
||||
// Verify the new key before storing any pairing settings.
|
||||
JSch verified = new JSch();
|
||||
verified.setHostKeyRepository(new PinRepository(pin));
|
||||
verified.addIdentity(new PhoneIdentity(), null);
|
||||
Session check = session(verified, host, port, user);
|
||||
try {
|
||||
check.connect(15000);
|
||||
} finally {
|
||||
check.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently appends {@code key}. A last line without a newline is terminated first, so the
|
||||
* existing key is never merged with the new one (the same guard as the iPhone app).
|
||||
*/
|
||||
static String authorizeCommand(String key) {
|
||||
String file = "~/.ssh/authorized_keys";
|
||||
return "umask 077; mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch "
|
||||
+ file
|
||||
+ " && chmod 600 "
|
||||
+ file
|
||||
+ " && { grep -qxF "
|
||||
+ quote(key)
|
||||
+ " "
|
||||
+ file
|
||||
+ " || { if [ -s "
|
||||
+ file
|
||||
+ " ] && [ -n \"$(tail -c 1 "
|
||||
+ file
|
||||
+ ")\" ]; then printf '\\n' >> "
|
||||
+ file
|
||||
+ "; fi; printf '%s\\n' "
|
||||
+ quote(key)
|
||||
+ " >> "
|
||||
+ file
|
||||
+ "; }; }";
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists Steam account directories, including ones with no screenshots. Prints "absent" when
|
||||
* Steam has not created userdata yet; any other failure exits non-zero.
|
||||
*/
|
||||
static final String ACCOUNTS_COMMAND =
|
||||
"d=\"$HOME/.local/share/Steam/userdata\"; if [ ! -e \"$d\" ]; then echo absent; else"
|
||||
+ " cd \"$d\" || exit 1; echo present; for a in *; do case \"$a\" in ''|*[!0-9]*) ;;"
|
||||
+ " *) if [ -d \"$a\" ]; then printf '%s\\n' \"$a\"; fi ;; esac; done; fi";
|
||||
|
||||
static Set<String> parseAccounts(String output) throws IOException {
|
||||
String[] lines = output.split("\n", -1);
|
||||
Set<String> accounts = new TreeSet<>();
|
||||
if (lines.length == 2 && lines[0].equals("absent") && lines[1].isEmpty()) return accounts;
|
||||
if (lines.length < 2 || !lines[0].equals("present") || !lines[lines.length - 1].isEmpty())
|
||||
throw new IOException("Could not read Steam accounts on the headset.");
|
||||
for (int i = 1; i < lines.length - 1; i++) {
|
||||
if (!lines[i].matches("[0-9]+")) throw new IOException("Unexpected Steam account entry.");
|
||||
// The headset server ignores longer ids; so does the screenshot listing.
|
||||
if (lines[i].length() <= 12) accounts.add(lines[i]);
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
static String run(Session s, String command, int timeout) throws Exception {
|
||||
ChannelExec channel = (ChannelExec) s.openChannel("exec");
|
||||
channel.setCommand(command);
|
||||
channel.setInputStream(null);
|
||||
channel.setErrStream(
|
||||
new OutputStream() {
|
||||
public void write(int b) {}
|
||||
|
||||
public void write(byte[] b, int offset, int length) {}
|
||||
});
|
||||
InputStream input = channel.getInputStream();
|
||||
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeout);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try {
|
||||
channel.connect(timeout);
|
||||
while (true) {
|
||||
while (input.available() > 0) {
|
||||
byte[] b = new byte[Math.min(input.available(), 8192)];
|
||||
int n = input.read(b);
|
||||
if (n > 0) output.write(b, 0, n);
|
||||
if (output.size() > 2 * 1024 * 1024)
|
||||
throw new IOException("Headset command returned too much data.");
|
||||
}
|
||||
if (channel.isClosed() && input.available() == 0) break;
|
||||
if (System.nanoTime() > deadline) throw new IOException("The headset command timed out.");
|
||||
Thread.sleep(25);
|
||||
}
|
||||
if (channel.getExitStatus() != 0)
|
||||
throw new IOException("The headset command failed (" + channel.getExitStatus() + ").");
|
||||
return output.toString(StandardCharsets.UTF_8.name());
|
||||
} finally {
|
||||
channel.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void connect(String host, int port, String user, String pin, String install) throws Exception {
|
||||
JSch jsch = new JSch();
|
||||
jsch.setHostKeyRepository(new PinRepository(pin));
|
||||
jsch.addIdentity(new PhoneIdentity(), null);
|
||||
session = session(jsch, host, port, user);
|
||||
session.connect(15000);
|
||||
String version;
|
||||
try (InputStream in = context.getAssets().open("frame-bundle.version")) {
|
||||
version = new String(readBounded(in, 128), StandardCharsets.US_ASCII).trim();
|
||||
}
|
||||
if (!version.matches("[0-9a-f]{16}")) throw new IOException("Invalid bundled server version.");
|
||||
String dir = ".cache/frame-control/" + version;
|
||||
String present =
|
||||
run(session, "test -f " + dir + "/ui/server.py && printf ready || true", 10000);
|
||||
if (!present.equals("ready")) {
|
||||
run(session, "mkdir -p .cache/frame-control", 10000);
|
||||
String temp = dir + ".android-" + UUID.randomUUID();
|
||||
ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp");
|
||||
try (InputStream in = context.getAssets().open("frame-bundle.tar.gz")) {
|
||||
sftp.connect(10000);
|
||||
sftp.put(in, temp + ".tar.gz");
|
||||
} finally {
|
||||
sftp.disconnect();
|
||||
}
|
||||
run(
|
||||
session,
|
||||
"mkdir "
|
||||
+ temp
|
||||
+ " && tar xzf "
|
||||
+ temp
|
||||
+ ".tar.gz -C "
|
||||
+ temp
|
||||
+ " && "
|
||||
+ "(test -f "
|
||||
+ dir
|
||||
+ "/ui/server.py && rm -rf "
|
||||
+ temp
|
||||
+ " || mv "
|
||||
+ temp
|
||||
+ " "
|
||||
+ dir
|
||||
+ ") && rm -f "
|
||||
+ temp
|
||||
+ ".tar.gz",
|
||||
60000);
|
||||
}
|
||||
apiKey = UUID.randomUUID().toString().replace("-", "");
|
||||
server = (ChannelExec) session.openChannel("exec");
|
||||
serverInput = new PipedOutputStream();
|
||||
server.setInputStream(new PipedInputStream(serverInput));
|
||||
server.setCommand(
|
||||
"cd "
|
||||
+ dir
|
||||
+ " && FRAME_LOCAL=1 FRAME_UI_KEY="
|
||||
+ apiKey
|
||||
+ " FRAME_DEVICE="
|
||||
+ quote(host)
|
||||
+ " FRAME_CLIENT="
|
||||
+ quote(install)
|
||||
+ " exec python3 -I -u -B \"$PWD/ui/server.py\" --port 0 --exit-on-eof 2>&1");
|
||||
InputStream input = server.getInputStream();
|
||||
server.connect(10000);
|
||||
ByteArrayOutputStream startup = new ByteArrayOutputStream();
|
||||
Pattern portPattern = Pattern.compile("Frame Control on http://127\\.0\\.0\\.1:([0-9]+)\\s");
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(40);
|
||||
int portFound = 0;
|
||||
while (System.nanoTime() < deadline) {
|
||||
while (input.available() > 0) {
|
||||
startup.write(input.read());
|
||||
if (startup.size() > 65536) throw new IOException("The headset server did not start.");
|
||||
}
|
||||
Matcher m = portPattern.matcher(startup.toString(StandardCharsets.UTF_8.name()));
|
||||
if (m.find()) {
|
||||
portFound = Integer.parseInt(m.group(1));
|
||||
break;
|
||||
}
|
||||
if (server.isClosed())
|
||||
throw new IOException("The headset server stopped before it was ready.");
|
||||
Thread.sleep(50);
|
||||
}
|
||||
if (portFound < 1 || portFound > 65535)
|
||||
throw new IOException("The headset server did not answer in time.");
|
||||
localPort = session.setPortForwardingL("127.0.0.1", 0, "127.0.0.1", portFound);
|
||||
// Drain the session-bound server output; never log session keys or user data.
|
||||
Thread drain =
|
||||
new Thread(
|
||||
() -> {
|
||||
try {
|
||||
byte[] b = new byte[4096];
|
||||
while (input.read(b) != -1) {}
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
},
|
||||
"frame-server-output");
|
||||
drain.setDaemon(true);
|
||||
drain.start();
|
||||
}
|
||||
|
||||
String page() {
|
||||
return "http://127.0.0.1:" + localPort + "/?key=" + apiKey;
|
||||
}
|
||||
|
||||
boolean connected() {
|
||||
return session != null && session.isConnected() && server != null && !server.isClosed();
|
||||
}
|
||||
|
||||
private HttpURLConnection request(String path) throws Exception {
|
||||
HttpURLConnection c =
|
||||
(HttpURLConnection) new URL("http://127.0.0.1:" + localPort + path).openConnection();
|
||||
c.setConnectTimeout(10000);
|
||||
c.setReadTimeout(20000);
|
||||
c.setInstanceFollowRedirects(false);
|
||||
c.setRequestProperty("X-Frame-UI", apiKey);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Accounts are read first; one that appears before the screenshot query is still new. */
|
||||
SyncEngine.Listing listing() throws Exception {
|
||||
Set<String> accounts = parseAccounts(run(session, ACCOUNTS_COMMAND, 10000));
|
||||
return new SyncEngine.Listing(accounts, shots());
|
||||
}
|
||||
|
||||
List<SyncEngine.Shot> shots() throws Exception {
|
||||
HttpURLConnection c = request("/api/shots");
|
||||
try (InputStream in = c.getInputStream()) {
|
||||
byte[] body = readBounded(in, 2 * 1024 * 1024);
|
||||
JsonArray array =
|
||||
JsonParser.parseString(new String(body, StandardCharsets.UTF_8))
|
||||
.getAsJsonObject()
|
||||
.getAsJsonArray("shots");
|
||||
List<SyncEngine.Shot> shots = new ArrayList<>();
|
||||
for (JsonElement e : array) {
|
||||
var s = e.getAsJsonObject();
|
||||
shots.add(
|
||||
new SyncEngine.Shot(
|
||||
s.get("id").getAsString(), s.get("size").getAsLong(), s.get("time").getAsDouble()));
|
||||
}
|
||||
return shots;
|
||||
} finally {
|
||||
c.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readBounded(InputStream input, int limit) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] b = new byte[8192];
|
||||
int n;
|
||||
while ((n = input.read(b)) != -1) {
|
||||
if (out.size() + n > limit) throw new IOException("The headset response is too large.");
|
||||
out.write(b, 0, n);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void download(SyncEngine.Shot shot, File destination) throws Exception {
|
||||
HttpURLConnection c =
|
||||
request("/api/shots/image?id=" + URLEncoder.encode(shot.id, StandardCharsets.UTF_8.name()));
|
||||
try (InputStream in = c.getInputStream();
|
||||
FileOutputStream out = new FileOutputStream(destination)) {
|
||||
byte[] b = new byte[65536];
|
||||
long count = 0;
|
||||
int n;
|
||||
while ((n = in.read(b)) != -1) {
|
||||
count += n;
|
||||
if (count > shot.size) throw new IOException("Screenshot changed while downloading.");
|
||||
out.write(b, 0, n);
|
||||
}
|
||||
out.getFD().sync();
|
||||
} finally {
|
||||
c.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validImage(File file) {
|
||||
android.graphics.BitmapFactory.Options options = new android.graphics.BitmapFactory.Options();
|
||||
options.inJustDecodeBounds = true;
|
||||
android.graphics.BitmapFactory.decodeFile(file.getPath(), options);
|
||||
if (options.outWidth <= 0
|
||||
|| options.outHeight <= 0
|
||||
|| options.outWidth > 16384
|
||||
|| options.outHeight > 16384
|
||||
|| (long) options.outWidth * options.outHeight > 60_000_000) return false;
|
||||
options.inJustDecodeBounds = false;
|
||||
options.inSampleSize = Math.max(1, Math.max(options.outWidth, options.outHeight) / 2048);
|
||||
android.graphics.Bitmap b = android.graphics.BitmapFactory.decodeFile(file.getPath(), options);
|
||||
if (b == null) return false;
|
||||
b.recycle();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
if (serverInput != null) serverInput.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
if (server != null) server.disconnect();
|
||||
if (session != null) session.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import android.content.*;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.provider.MediaStore;
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/** Deterministic, app-owned MediaStore rows make interrupted exports retryable. */
|
||||
final class GalleryExporter implements SyncEngine.Exporter {
|
||||
static final String FOLDER = "Pictures/Frame Control/";
|
||||
|
||||
/**
|
||||
* Serializes every export in the process, across exporter instances and service sessions. A
|
||||
* stopped session's exporter may still be writing when a new session starts; without this, the
|
||||
* new one could publish the same deterministic row and the old one's cancellation would then
|
||||
* delete it. Lock order is EXPORTS, then the settings lock. Never take EXPORTS while holding
|
||||
* the settings lock: {@link #discardPending} runs under it and must not wait for exports.
|
||||
*/
|
||||
static final ReentrantLock EXPORTS = new ReentrantLock();
|
||||
|
||||
private static final long EXPORT_WAIT_SECONDS = 30;
|
||||
private final Context context;
|
||||
private final String headset;
|
||||
private final Object settingsLock;
|
||||
|
||||
GalleryExporter(Context context, String headset, Object settingsLock) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.headset = headset;
|
||||
this.settingsLock = settingsLock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort removal of this app's hidden pending rows in the Frame Control folder. Only an
|
||||
* interrupted export leaves one; published (non-pending) images are never touched. Callers
|
||||
* hold the settings lock, so no export can be publishing a row at the same time.
|
||||
*/
|
||||
static int discardPending(Context context) {
|
||||
ContentResolver resolver = context.getContentResolver();
|
||||
Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
|
||||
@SuppressWarnings("deprecation")
|
||||
Uri includingPending = MediaStore.setIncludePending(collection);
|
||||
List<Uri> rows = new ArrayList<>();
|
||||
int removed = 0;
|
||||
try {
|
||||
try (Cursor cursor =
|
||||
resolver.query(
|
||||
includingPending,
|
||||
new String[] {"_id"},
|
||||
MediaStore.Images.Media.IS_PENDING
|
||||
+ "=1 AND "
|
||||
+ MediaStore.Images.Media.RELATIVE_PATH
|
||||
+ "=? AND "
|
||||
+ MediaStore.Images.Media.OWNER_PACKAGE_NAME
|
||||
+ "=?",
|
||||
new String[] {FOLDER, context.getPackageName()},
|
||||
null)) {
|
||||
while (cursor != null && cursor.moveToNext())
|
||||
rows.add(ContentUris.withAppendedId(collection, cursor.getLong(0)));
|
||||
}
|
||||
for (Uri row : rows) removed += resolver.delete(row, null, null);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Android 11+ also expires abandoned pending rows; never fail the setting change here.
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean export(SyncEngine.Shot shot, File file, BooleanSupplier eligible)
|
||||
throws Exception {
|
||||
// Interruptible and bounded: Stop still ends a waiting session, and a stuck earlier write
|
||||
// only delays this item to a later poll.
|
||||
if (!EXPORTS.tryLock(EXPORT_WAIT_SECONDS, TimeUnit.SECONDS))
|
||||
throw new IOException("Gallery is still finishing an earlier save.");
|
||||
try {
|
||||
return exportLocked(shot, file, eligible);
|
||||
} finally {
|
||||
EXPORTS.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean exportLocked(SyncEngine.Shot shot, File file, BooleanSupplier eligible)
|
||||
throws Exception {
|
||||
String name = shot.id.substring(shot.id.lastIndexOf('/') + 1);
|
||||
int dot = name.lastIndexOf('.');
|
||||
name =
|
||||
name.substring(0, dot)
|
||||
+ "_"
|
||||
+ FrameConnection.hash(headset + "/" + shot.id).substring(0, 20)
|
||||
+ name.substring(dot);
|
||||
String folder = FOLDER;
|
||||
ContentResolver resolver = context.getContentResolver();
|
||||
Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
|
||||
// API 29 excludes even app-owned pending rows unless explicitly included.
|
||||
@SuppressWarnings("deprecation")
|
||||
Uri recoveryCollection = MediaStore.setIncludePending(collection);
|
||||
Uri row = null;
|
||||
boolean pending = true;
|
||||
try (Cursor cursor =
|
||||
resolver.query(
|
||||
recoveryCollection,
|
||||
new String[] {"_id", MediaStore.Images.Media.IS_PENDING},
|
||||
MediaStore.Images.Media.DISPLAY_NAME
|
||||
+ "=? AND "
|
||||
+ MediaStore.Images.Media.RELATIVE_PATH
|
||||
+ "=? AND "
|
||||
+ MediaStore.Images.Media.OWNER_PACKAGE_NAME
|
||||
+ "=?",
|
||||
new String[] {name, folder, context.getPackageName()},
|
||||
null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
row = ContentUris.withAppendedId(collection, cursor.getLong(0));
|
||||
pending = cursor.getInt(1) != 0;
|
||||
}
|
||||
}
|
||||
// A completed row is the commit receipt, including a crash before JSON save.
|
||||
if (row != null && !pending) return true;
|
||||
if (!eligible.getAsBoolean()) return false;
|
||||
if (row == null) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.Images.Media.DISPLAY_NAME, name);
|
||||
values.put(MediaStore.Images.Media.RELATIVE_PATH, folder);
|
||||
values.put(
|
||||
MediaStore.Images.Media.MIME_TYPE, shot.id.endsWith(".png") ? "image/png" : "image/jpeg");
|
||||
values.put(MediaStore.Images.Media.IS_PENDING, 1);
|
||||
values.put(MediaStore.Images.Media.DATE_TAKEN, (long) (shot.time * 1000));
|
||||
row = resolver.insert(collection, values);
|
||||
if (row == null) throw new IOException("Gallery could not create the screenshot.");
|
||||
}
|
||||
try {
|
||||
try (InputStream in = new FileInputStream(file);
|
||||
OutputStream out = resolver.openOutputStream(row, "wt")) {
|
||||
if (out == null) throw new IOException("Gallery could not open the screenshot.");
|
||||
byte[] b = new byte[65536];
|
||||
int n;
|
||||
while ((n = in.read(b)) != -1) out.write(b, 0, n);
|
||||
}
|
||||
synchronized (settingsLock) {
|
||||
if (!eligible.getAsBoolean()) {
|
||||
// Holding EXPORTS, no other exporter can have published this row since we found it.
|
||||
resolver.delete(row, null, null);
|
||||
return false;
|
||||
}
|
||||
ContentValues done = new ContentValues();
|
||||
done.put(MediaStore.Images.Media.IS_PENDING, 0);
|
||||
if (resolver.update(row, done, null, null) != 1)
|
||||
throw new IOException("Gallery could not finish saving the screenshot.");
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// A pending row is retained and rewritten on retry. A completed row is
|
||||
// discovered before any future insert, even after process death.
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.*;
|
||||
import android.content.*;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Color;
|
||||
import android.net.Uri;
|
||||
import android.os.*;
|
||||
import android.text.InputType;
|
||||
import android.view.*;
|
||||
import android.webkit.*;
|
||||
import android.widget.*;
|
||||
import androidx.core.content.FileProvider;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
public final class MainActivity extends Activity {
|
||||
private final ExecutorService network = Executors.newSingleThreadExecutor();
|
||||
// Journal parsing and file checks stay off the main thread.
|
||||
private final ExecutorService library = Executors.newSingleThreadExecutor();
|
||||
private boolean loading;
|
||||
private String renderedPin;
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private LinearLayout root, downloads;
|
||||
private FrameLayout content;
|
||||
private ScrollView companion;
|
||||
private EditText host, port, user;
|
||||
private TextView status, empty;
|
||||
private Switch gallery;
|
||||
private Button pair, connect, stop;
|
||||
private WebView web;
|
||||
private String loadedPage, librarySignature = "";
|
||||
private int downloadLimit = 100;
|
||||
private boolean busy, visible, controls;
|
||||
private ValueCallback<Uri[]> fileSelection;
|
||||
private static final int INK = Color.rgb(232, 240, 238),
|
||||
MUTED = Color.rgb(164, 183, 179),
|
||||
ACCENT = Color.rgb(124, 216, 195);
|
||||
private static final String EMPTY =
|
||||
"Pair and start syncing. Existing headset screenshots stay on the headset; new captures"
|
||||
+ " will appear here.";
|
||||
private final Runnable refresh =
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
if (!visible) return;
|
||||
update();
|
||||
handler.postDelayed(this, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
private int dp(int n) {
|
||||
return Math.round(getResources().getDisplayMetrics().density * n);
|
||||
}
|
||||
|
||||
private TextView text(String value, int size, int color) {
|
||||
TextView v = new TextView(this);
|
||||
v.setText(value);
|
||||
v.setTextSize(size);
|
||||
v.setTextColor(color);
|
||||
v.setPadding(0, dp(6), 0, dp(6));
|
||||
return v;
|
||||
}
|
||||
|
||||
private Button button(String label, Runnable action) {
|
||||
Button b = new Button(this);
|
||||
b.setText(label);
|
||||
b.setAllCaps(false);
|
||||
b.setOnClickListener(v -> action.run());
|
||||
return b;
|
||||
}
|
||||
|
||||
private EditText field(String label, String value, int type) {
|
||||
companionBody.addView(text(label, 13, MUTED));
|
||||
EditText v = new EditText(this);
|
||||
v.setSingleLine(true);
|
||||
v.setText(value);
|
||||
v.setHint(label);
|
||||
v.setContentDescription(label);
|
||||
v.setInputType(type);
|
||||
v.setTextColor(INK);
|
||||
v.setSaveEnabled(false);
|
||||
companionBody.addView(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
private LinearLayout companionBody;
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(Color.rgb(17, 24, 25));
|
||||
root.setPadding(dp(20), dp(12), dp(20), dp(12));
|
||||
root.setOnApplyWindowInsetsListener(
|
||||
(v, insets) -> {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
android.graphics.Insets i =
|
||||
insets.getInsets(
|
||||
WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
|
||||
v.setPadding(dp(20) + i.left, dp(12) + i.top, dp(20) + i.right, dp(12) + i.bottom);
|
||||
} else
|
||||
v.setPadding(
|
||||
dp(20) + insets.getSystemWindowInsetLeft(),
|
||||
dp(12) + insets.getSystemWindowInsetTop(),
|
||||
dp(20) + insets.getSystemWindowInsetRight(),
|
||||
dp(12) + insets.getSystemWindowInsetBottom());
|
||||
return insets;
|
||||
});
|
||||
root.addView(text("FRAME CONTROL", 13, ACCENT));
|
||||
root.addView(text("Your headset, closer.", 27, INK));
|
||||
status = text(SyncService.state(), 14, MUTED);
|
||||
status.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE);
|
||||
root.addView(status);
|
||||
LinearLayout tabs = new LinearLayout(this);
|
||||
tabs.addView(
|
||||
button("Screenshots", () -> show(false)), new LinearLayout.LayoutParams(0, dp(48), 1));
|
||||
tabs.addView(
|
||||
button("Headset controls", () -> show(true)), new LinearLayout.LayoutParams(0, dp(48), 1));
|
||||
root.addView(tabs);
|
||||
content = new FrameLayout(this);
|
||||
root.addView(content, new LinearLayout.LayoutParams(-1, 0, 1));
|
||||
companion = new ScrollView(this);
|
||||
companion.setFillViewport(true);
|
||||
companionBody = new LinearLayout(this);
|
||||
companionBody.setOrientation(LinearLayout.VERTICAL);
|
||||
companion.addView(companionBody);
|
||||
SharedPreferences p = SyncService.prefs(this);
|
||||
companionBody.addView(text("Connect your Steam Frame", 20, INK));
|
||||
companionBody.addView(
|
||||
text(
|
||||
"Enable Developer Mode and connect to the same network, or use your headset’s Tailscale"
|
||||
+ " address.",
|
||||
14,
|
||||
MUTED));
|
||||
host =
|
||||
field(
|
||||
"Headset address",
|
||||
p.getString("host", "frame.local"),
|
||||
InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
|
||||
port = field("SSH port", Integer.toString(p.getInt("port", 22)), InputType.TYPE_CLASS_NUMBER);
|
||||
user =
|
||||
field(
|
||||
"SSH username",
|
||||
p.getString("user", "steamos"),
|
||||
InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD);
|
||||
pair = button("Pair headset", this::probe);
|
||||
companionBody.addView(pair);
|
||||
LinearLayout actions = new LinearLayout(this);
|
||||
connect = button("Start syncing", this::startSync);
|
||||
stop = button("Stop", () -> stopService(new Intent(this, SyncService.class)));
|
||||
actions.addView(connect, new LinearLayout.LayoutParams(0, dp(52), 1));
|
||||
actions.addView(stop, new LinearLayout.LayoutParams(0, dp(52), 1));
|
||||
companionBody.addView(actions);
|
||||
companionBody.addView(text("Screenshot downloads", 20, INK));
|
||||
gallery = new Switch(this);
|
||||
gallery.setText("Save screenshots to Gallery");
|
||||
gallery.setTextColor(INK);
|
||||
gallery.setTextSize(16);
|
||||
gallery.setMinHeight(dp(56));
|
||||
gallery.setChecked(p.getBoolean("gallery", true));
|
||||
gallery.setOnCheckedChangeListener(
|
||||
(b, value) -> {
|
||||
try {
|
||||
SyncService.gallery(this, value);
|
||||
librarySignature = "";
|
||||
} catch (Exception e) {
|
||||
gallery.setOnCheckedChangeListener(null);
|
||||
gallery.setChecked(!value);
|
||||
error(SyncService.message(e));
|
||||
recreate();
|
||||
}
|
||||
});
|
||||
companionBody.addView(gallery);
|
||||
companionBody.addView(
|
||||
text(
|
||||
"New captures download automatically while sync is running. A notification keeps the"
|
||||
+ " connection visible. Stop syncing to disconnect. Android may delay transfers"
|
||||
+ " while the phone is locked or in battery-saving modes.",
|
||||
14,
|
||||
MUTED));
|
||||
companionBody.addView(
|
||||
text(
|
||||
"Turning Gallery off keeps new downloads in this app. Turning it on again won’t export"
|
||||
+ " earlier app-only downloads.",
|
||||
14,
|
||||
MUTED));
|
||||
empty = text(EMPTY, 14, MUTED);
|
||||
companionBody.addView(empty);
|
||||
downloads = new LinearLayout(this);
|
||||
downloads.setOrientation(LinearLayout.VERTICAL);
|
||||
companionBody.addView(downloads);
|
||||
show(false);
|
||||
setContentView(root);
|
||||
}
|
||||
|
||||
private void show(boolean useControls) {
|
||||
controls = useControls;
|
||||
content.removeAllViews();
|
||||
if (!controls) {
|
||||
content.addView(companion, new FrameLayout.LayoutParams(-1, -1));
|
||||
return;
|
||||
}
|
||||
String page = SyncService.page();
|
||||
if (page == null) {
|
||||
loadedPage = null;
|
||||
if (web != null) web.loadUrl("about:blank");
|
||||
content.addView(text("Start syncing to open your headset’s controls.", 17, MUTED));
|
||||
return;
|
||||
}
|
||||
if (web == null) {
|
||||
web = new WebView(this);
|
||||
web.setBackgroundColor(Color.rgb(17, 24, 25));
|
||||
WebSettings settings = web.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setAllowFileAccess(false);
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
|
||||
settings.setSaveFormData(false);
|
||||
web.setWebViewClient(
|
||||
new WebViewClient() {
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
|
||||
Uri url = request.getUrl();
|
||||
Uri origin = Uri.parse(loadedPage == null ? "" : loadedPage);
|
||||
if ("http".equals(url.getScheme())
|
||||
&& "127.0.0.1".equals(url.getHost())
|
||||
&& url.getPort() == origin.getPort()) return false;
|
||||
if (request.hasGesture()
|
||||
&& ("https".equals(url.getScheme()) || "http".equals(url.getScheme()))) {
|
||||
try {
|
||||
startActivity(new Intent(Intent.ACTION_VIEW, url));
|
||||
} catch (ActivityNotFoundException ignored) {
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
web.setWebChromeClient(
|
||||
new WebChromeClient() {
|
||||
@Override
|
||||
public boolean onShowFileChooser(
|
||||
WebView w, ValueCallback<Uri[]> callback, FileChooserParams params) {
|
||||
if (fileSelection != null) fileSelection.onReceiveValue(null);
|
||||
fileSelection = callback;
|
||||
Intent choose =
|
||||
new Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.setType("*/*")
|
||||
.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
choose.putExtra(
|
||||
Intent.EXTRA_ALLOW_MULTIPLE,
|
||||
params.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE);
|
||||
try {
|
||||
startActivityForResult(choose, 110);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
fileSelection.onReceiveValue(null);
|
||||
fileSelection = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
web.setDownloadListener(
|
||||
(url, ua, disposition, mime, length) -> {
|
||||
show(false);
|
||||
Toast.makeText(
|
||||
this,
|
||||
"New screenshot originals are in Screenshot downloads.",
|
||||
Toast.LENGTH_LONG)
|
||||
.show();
|
||||
});
|
||||
}
|
||||
content.addView(web, new FrameLayout.LayoutParams(-1, -1));
|
||||
if (!page.equals(loadedPage)) {
|
||||
loadedPage = page;
|
||||
web.loadUrl(page);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int request, int result, Intent data) {
|
||||
super.onActivityResult(request, result, data);
|
||||
if (request == 110 && fileSelection != null) {
|
||||
fileSelection.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(result, data));
|
||||
fileSelection = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void update() {
|
||||
if (!busy) status.setText(SyncService.state());
|
||||
boolean paired = SyncService.prefs(this).contains("pin");
|
||||
boolean running = SyncService.running();
|
||||
connect.setEnabled(paired && !busy && !running);
|
||||
pair.setEnabled(!busy && !running);
|
||||
stop.setEnabled(!busy && running);
|
||||
host.setEnabled(!running && !busy);
|
||||
port.setEnabled(!running && !busy);
|
||||
user.setEnabled(!running && !busy);
|
||||
if (controls && !Objects.equals(loadedPage, SyncService.page())) show(true);
|
||||
String pin = SyncService.prefs(this).getString("pin", null);
|
||||
if (!Objects.equals(pin, renderedPin)) {
|
||||
// Never leave another headset's originals shareable while the new library loads.
|
||||
renderedPin = pin;
|
||||
librarySignature = "";
|
||||
downloads.removeAllViews();
|
||||
empty.setText(EMPTY);
|
||||
empty.setVisibility(View.VISIBLE);
|
||||
}
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
library.execute(
|
||||
() -> {
|
||||
List<SyncEngine.Item> items = null;
|
||||
Set<SyncEngine.Item> present = new HashSet<>();
|
||||
Exception failure = null;
|
||||
try {
|
||||
items = SyncService.items(this, pin);
|
||||
for (SyncEngine.Item item : items) if (item.file.exists()) present.add(item);
|
||||
} catch (Exception e) {
|
||||
failure = e;
|
||||
}
|
||||
List<SyncEngine.Item> loaded = items;
|
||||
Exception error = failure;
|
||||
runOnUiThread(
|
||||
() -> {
|
||||
loading = false;
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
// A result for a previously paired headset is discarded; the next tick reloads.
|
||||
if (!Objects.equals(pin, SyncService.prefs(this).getString("pin", null))) return;
|
||||
if (error != null) {
|
||||
librarySignature = "";
|
||||
empty.setVisibility(View.VISIBLE);
|
||||
empty.setText("Could not read screenshot downloads: " + SyncService.message(error));
|
||||
return;
|
||||
}
|
||||
render(loaded, present);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void render(List<SyncEngine.Item> items, Set<SyncEngine.Item> present) {
|
||||
StringBuilder signature = new StringBuilder();
|
||||
for (var item : items)
|
||||
signature
|
||||
.append(item.file.getAbsolutePath())
|
||||
.append('|')
|
||||
.append(item.destination)
|
||||
.append('|')
|
||||
.append(present.contains(item))
|
||||
.append('\n');
|
||||
if (signature.toString().equals(librarySignature)) return;
|
||||
librarySignature = signature.toString();
|
||||
downloads.removeAllViews();
|
||||
empty.setText(EMPTY);
|
||||
empty.setVisibility(items.isEmpty() ? View.VISIBLE : View.GONE);
|
||||
int shown = 0;
|
||||
for (SyncEngine.Item item : items) {
|
||||
if (shown++ >= downloadLimit) {
|
||||
downloads.addView(
|
||||
button(
|
||||
"Show 100 more downloads",
|
||||
() -> {
|
||||
downloadLimit += 100;
|
||||
librarySignature = "";
|
||||
update();
|
||||
}));
|
||||
break;
|
||||
}
|
||||
String label = item.shot.id.substring(item.shot.id.lastIndexOf('/') + 1);
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.setPadding(0, dp(12), 0, dp(12));
|
||||
card.addView(text(label, 16, INK));
|
||||
String where =
|
||||
item.destination.equals("saved")
|
||||
? "Saved to Gallery"
|
||||
: item.destination.equals("appOnly") ? "In Frame Control" : "Gallery save pending";
|
||||
boolean exists = present.contains(item);
|
||||
if (!exists) where = "Download pending";
|
||||
card.addView(text(where + " · Game " + item.shot.id.split("/")[1], 13, MUTED));
|
||||
if (exists) card.addView(button("Open / share original", () -> share(item)));
|
||||
downloads.addView(card);
|
||||
}
|
||||
}
|
||||
|
||||
private void share(SyncEngine.Item item) {
|
||||
Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".files", item.file);
|
||||
Intent send =
|
||||
new Intent(Intent.ACTION_SEND)
|
||||
.setType(item.shot.id.endsWith(".png") ? "image/png" : "image/jpeg")
|
||||
.putExtra(Intent.EXTRA_STREAM, uri)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
startActivity(Intent.createChooser(send, "Screenshot original"));
|
||||
}
|
||||
|
||||
private void startSync() {
|
||||
if (Build.VERSION.SDK_INT >= 33
|
||||
&& checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(new String[] {Manifest.permission.POST_NOTIFICATIONS}, 108);
|
||||
return;
|
||||
}
|
||||
startForegroundService(new Intent(this, SyncService.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int request, String[] permissions, int[] grants) {
|
||||
super.onRequestPermissionsResult(request, permissions, grants);
|
||||
if (request == 108) {
|
||||
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED)
|
||||
startForegroundService(new Intent(this, SyncService.class));
|
||||
else
|
||||
error(
|
||||
"Allow notifications in Android Settings to start a visible screenshot sync session.");
|
||||
}
|
||||
}
|
||||
|
||||
private void probe() {
|
||||
final String address = host.getText().toString().trim(),
|
||||
username = user.getText().toString().trim();
|
||||
final int sshPort;
|
||||
try {
|
||||
sshPort = Integer.parseInt(port.getText().toString());
|
||||
} catch (NumberFormatException e) {
|
||||
error("Enter an SSH port from 1 to 65535.");
|
||||
return;
|
||||
}
|
||||
stopService(new Intent(this, SyncService.class));
|
||||
busy = true;
|
||||
pair.setEnabled(false);
|
||||
status.setText("Checking headset identity…");
|
||||
network.execute(
|
||||
() -> {
|
||||
try {
|
||||
String pin = FrameConnection.probe(address, sshPort, username);
|
||||
String fingerprint = FrameConnection.fingerprint(pin);
|
||||
runOnUiThread(
|
||||
() -> {
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
busy = false;
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Trust this headset?")
|
||||
.setMessage(
|
||||
address
|
||||
+ ":"
|
||||
+ sshPort
|
||||
+ "\n\n"
|
||||
+ fingerprint
|
||||
+ "\n\n"
|
||||
+ "Compare this SSH fingerprint with your headset before entering its"
|
||||
+ " Developer Mode password.")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton(
|
||||
"Trust and pair", (d, w) -> password(address, sshPort, username, pin))
|
||||
.show();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
runOnUiThread(
|
||||
() -> {
|
||||
busy = false;
|
||||
error(SyncService.message(e));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void password(String address, int sshPort, String username, String pin) {
|
||||
EditText password = new EditText(this);
|
||||
password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
password.setHint("Developer Mode password");
|
||||
password.setSaveEnabled(false);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Pair with " + address)
|
||||
.setMessage(
|
||||
"Used once to authorize this phone’s protected SSH key. The password is not saved.")
|
||||
.setView(password)
|
||||
.setNegativeButton("Cancel", (d, w) -> password.setText(""))
|
||||
.setPositiveButton(
|
||||
"Pair",
|
||||
(d, w) -> {
|
||||
String secret = password.getText().toString();
|
||||
password.setText("");
|
||||
busy = true;
|
||||
status.setText("Pairing this phone…");
|
||||
network.execute(
|
||||
() -> {
|
||||
try {
|
||||
FrameConnection.pair(
|
||||
address, sshPort, username, pin, secret, SyncService.install(this));
|
||||
if (!SyncService.prefs(this)
|
||||
.edit()
|
||||
.putString("host", address)
|
||||
.putInt("port", sshPort)
|
||||
.putString("user", username)
|
||||
.putString("pin", pin)
|
||||
.commit()) throw new java.io.IOException("Could not save pairing.");
|
||||
runOnUiThread(
|
||||
() -> {
|
||||
busy = false;
|
||||
status.setText("Paired. Start syncing when you’re ready.");
|
||||
update();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
runOnUiThread(
|
||||
() -> {
|
||||
busy = false;
|
||||
error(SyncService.message(e));
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void error(String message) {
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Frame Control")
|
||||
.setMessage(message)
|
||||
.setPositiveButton("OK", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
visible = true;
|
||||
if (web != null) web.onResume();
|
||||
handler.post(refresh);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
visible = false;
|
||||
handler.removeCallbacks(refresh);
|
||||
if (web != null) web.onPause();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
network.shutdownNow();
|
||||
library.shutdownNow();
|
||||
if (fileSelection != null) fileSelection.onReceiveValue(null);
|
||||
if (web != null) {
|
||||
web.destroy();
|
||||
web = null;
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec;
|
||||
import android.security.keystore.KeyProperties;
|
||||
import com.jcraft.jsch.Identity;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.KeyStore;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.Base64;
|
||||
|
||||
/** The SSH private key is a non-exportable Android Keystore signing key. */
|
||||
final class PhoneIdentity implements Identity {
|
||||
private static final String ALIAS = "frame-control-ssh-v1";
|
||||
private final PrivateKey key;
|
||||
private final byte[] publicBlob;
|
||||
|
||||
PhoneIdentity() throws Exception {
|
||||
KeyStore store = KeyStore.getInstance("AndroidKeyStore");
|
||||
store.load(null);
|
||||
if (!store.containsAlias(ALIAS)) {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", "AndroidKeyStore");
|
||||
generator.initialize(
|
||||
new KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_SIGN)
|
||||
.setKeySize(3072)
|
||||
.setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
|
||||
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
|
||||
.build());
|
||||
generator.generateKeyPair();
|
||||
}
|
||||
key = (PrivateKey) store.getKey(ALIAS, null);
|
||||
RSAPublicKey publicKey = (RSAPublicKey) store.getCertificate(ALIAS).getPublicKey();
|
||||
publicBlob =
|
||||
blob(
|
||||
"ssh-rsa".getBytes(StandardCharsets.US_ASCII),
|
||||
integer(publicKey.getPublicExponent()),
|
||||
integer(publicKey.getModulus()));
|
||||
}
|
||||
|
||||
private static byte[] integer(BigInteger value) {
|
||||
return value.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] blob(byte[]... strings) throws Exception {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream out = new DataOutputStream(bytes);
|
||||
for (byte[] s : strings) {
|
||||
out.writeInt(s.length);
|
||||
out.write(s);
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
String authorizedKey(String install) {
|
||||
return "ssh-rsa "
|
||||
+ Base64.getEncoder().encodeToString(publicBlob)
|
||||
+ " frame-control-android-"
|
||||
+ install;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getPublicKeyBlob() {
|
||||
return publicBlob.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getSignature(byte[] data) {
|
||||
return getSignature(data, "rsa-sha2-512");
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getSignature(byte[] data, String algorithm) {
|
||||
try {
|
||||
if (!algorithm.equals("rsa-sha2-256") && !algorithm.equals("rsa-sha2-512")) return null;
|
||||
Signature signer =
|
||||
Signature.getInstance(
|
||||
algorithm.equals("rsa-sha2-512") ? "SHA512withRSA" : "SHA256withRSA");
|
||||
signer.initSign(key);
|
||||
signer.update(data);
|
||||
return blob(algorithm.getBytes(StandardCharsets.US_ASCII), signer.sign());
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setPassphrase(byte[] ignored) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAlgName() {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return ALIAS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEncrypted() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
/* Keystore owns the key; disconnect never deletes it. */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Owns the visible sync status. Each service instance gets one session token; once a session
|
||||
* ends, its late worker can no longer change the status, page or notification, even after a
|
||||
* newer session has started.
|
||||
*/
|
||||
final class SessionGate {
|
||||
static final String DISCONNECTED = "Disconnected";
|
||||
private long issued;
|
||||
private long current;
|
||||
private String state = DISCONNECTED;
|
||||
private String page;
|
||||
|
||||
synchronized long begin() {
|
||||
current = ++issued;
|
||||
page = null;
|
||||
return current;
|
||||
}
|
||||
|
||||
synchronized boolean active(long session) {
|
||||
return session != 0 && session == current;
|
||||
}
|
||||
|
||||
synchronized boolean running() {
|
||||
return current != 0;
|
||||
}
|
||||
|
||||
synchronized String state() {
|
||||
return state;
|
||||
}
|
||||
|
||||
synchronized String page() {
|
||||
return page;
|
||||
}
|
||||
|
||||
/** Publishes a status and runs {@code show} (the notification update) only while current. */
|
||||
synchronized boolean status(long session, String text, Consumer<String> show) {
|
||||
if (!active(session)) return false;
|
||||
state = text;
|
||||
if (show != null) show.accept(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
synchronized boolean page(long session, String value) {
|
||||
if (!active(session)) return false;
|
||||
page = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Runs {@code action} only while the session is current, serialized against {@link #end}. */
|
||||
synchronized boolean ifActive(long session, Runnable action) {
|
||||
if (!active(session)) return false;
|
||||
action.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Ends only the named session. Progress text becomes Disconnected; a final error stays. */
|
||||
synchronized boolean end(long session) {
|
||||
if (!active(session)) return false;
|
||||
current = 0;
|
||||
page = null;
|
||||
if (state.startsWith("Syncing")
|
||||
|| state.startsWith("Waiting")
|
||||
|| state.startsWith("Connecting")
|
||||
|| state.startsWith("Screenshot pending")) state = DISCONNECTED;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.Strictness;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** One reusable engine per root. The caller supplies an account/headset-specific root. */
|
||||
public final class SyncEngine {
|
||||
private static final long MAX_SIZE = 30L * 1024 * 1024;
|
||||
private static final Pattern ID = Pattern.compile(
|
||||
"^[0-9]{1,12}/[0-9]{1,20}/[0-9]{14}_[0-9]{1,4}\\.(jpg|png)$");
|
||||
private static final Pattern ACCOUNT = Pattern.compile("^[0-9]{1,12}$");
|
||||
private static final Gson GSON = new GsonBuilder().registerTypeAdapter(Shot.class,
|
||||
new TypeAdapter<Shot>() {
|
||||
@Override public void write(JsonWriter writer, Shot shot) throws IOException {
|
||||
writer.beginObject();
|
||||
writer.name("id").value(shot.id);
|
||||
writer.name("size").value(shot.size);
|
||||
writer.name("time").value(shot.time);
|
||||
writer.endObject();
|
||||
}
|
||||
|
||||
@Override public Shot read(JsonReader reader) throws IOException {
|
||||
String id = null;
|
||||
Long size = null;
|
||||
Double time = null;
|
||||
Set<String> fields = new HashSet<>();
|
||||
reader.beginObject();
|
||||
while (reader.hasNext()) {
|
||||
String field = reader.nextName();
|
||||
if (!fields.add(field)) throw new IOException("Duplicate screenshot field");
|
||||
switch (field) {
|
||||
case "id":
|
||||
if (reader.peek() != JsonToken.STRING) throw new IOException("Invalid id");
|
||||
id = reader.nextString();
|
||||
break;
|
||||
case "size":
|
||||
if (reader.peek() != JsonToken.NUMBER) throw new IOException("Invalid size");
|
||||
size = reader.nextLong();
|
||||
break;
|
||||
case "time":
|
||||
if (reader.peek() != JsonToken.NUMBER) throw new IOException("Invalid time");
|
||||
time = reader.nextDouble();
|
||||
break;
|
||||
default: throw new IOException("Unknown screenshot field");
|
||||
}
|
||||
}
|
||||
reader.endObject();
|
||||
if (id == null || size == null || time == null) throw new IOException("Incomplete shot");
|
||||
return new Shot(id, size, time);
|
||||
}
|
||||
}).create();
|
||||
|
||||
public static final class Shot {
|
||||
public final String id;
|
||||
public final long size;
|
||||
public final double time;
|
||||
|
||||
public Shot(String id, long size, double time) {
|
||||
this.id = id;
|
||||
this.size = size;
|
||||
this.time = time;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One headset poll. {@code accounts} names every Steam account directory present on the
|
||||
* headset, including accounts with no screenshots yet. An absent userdata root is an empty set.
|
||||
*/
|
||||
public static final class Listing {
|
||||
public final Set<String> accounts;
|
||||
public final List<Shot> shots;
|
||||
|
||||
public Listing(Set<String> accounts, List<Shot> shots) {
|
||||
this.accounts = accounts;
|
||||
this.shots = shots;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Transport {
|
||||
void download(Shot shot, File destination) throws Exception;
|
||||
boolean validImage(File f) throws Exception;
|
||||
}
|
||||
|
||||
public interface Exporter {
|
||||
boolean export(Shot shot, File original, BooleanSupplier eligible) throws Exception;
|
||||
}
|
||||
|
||||
public static final class Item {
|
||||
public final Shot shot;
|
||||
/** Future original path; may not exist while the shot is observing or queued. */
|
||||
public final File file;
|
||||
public final String destination;
|
||||
|
||||
private Item(Shot shot, File file, String destination) {
|
||||
this.shot = shot;
|
||||
this.file = file;
|
||||
this.destination = destination;
|
||||
}
|
||||
}
|
||||
|
||||
// Boxed fields make missing journal fields distinguishable from legitimate zero values.
|
||||
private static final class Ledger {
|
||||
Integer version;
|
||||
Boolean initialized;
|
||||
Boolean gallery;
|
||||
/** Accounts already baselined. Absent only in version 1 journals. */
|
||||
Set<String> accounts;
|
||||
Map<String, Receipt> records;
|
||||
}
|
||||
|
||||
private static final class Poll {
|
||||
final Map<String, Shot> eligible = new LinkedHashMap<>();
|
||||
final Set<String> accounts = new TreeSet<>();
|
||||
int skipped;
|
||||
}
|
||||
|
||||
private static final class Receipt {
|
||||
Shot shot;
|
||||
String state;
|
||||
Integer observations;
|
||||
String destination;
|
||||
|
||||
Receipt(Shot shot, String state, int observations, String destination) {
|
||||
this.shot = shot;
|
||||
this.state = state;
|
||||
this.observations = observations;
|
||||
this.destination = destination;
|
||||
}
|
||||
}
|
||||
|
||||
/** Only external callbacks and rejected transfer contents are safe to retry per item. */
|
||||
private static final class ItemFailure extends IOException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
ItemFailure(String message, Exception cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
private final Object lock = new Object();
|
||||
private final Path root;
|
||||
private final Path originals;
|
||||
private final Path journal;
|
||||
private Ledger ledger;
|
||||
private long generation;
|
||||
private long active;
|
||||
private boolean closed;
|
||||
private volatile int skipped;
|
||||
|
||||
public SyncEngine(File root, boolean gallery) throws Exception {
|
||||
this(root, Boolean.valueOf(gallery));
|
||||
}
|
||||
|
||||
/** A null gallery opens a read-only view that never creates, upgrades or rewrites anything. */
|
||||
private SyncEngine(File root, Boolean gallery) throws Exception {
|
||||
if (root == null || Files.isSymbolicLink(root.toPath())) {
|
||||
throw new IOException("Unsafe sync root");
|
||||
}
|
||||
this.root = root.getCanonicalFile().toPath();
|
||||
if (gallery == null) {
|
||||
closed = true;
|
||||
} else {
|
||||
List<Path> missingDirectories = new ArrayList<>();
|
||||
for (Path path = this.root; path != null && !Files.exists(path); path = path.getParent()) {
|
||||
missingDirectories.add(path);
|
||||
}
|
||||
Files.createDirectories(this.root);
|
||||
for (Path directory : missingDirectories) syncDirectory(directory.getParent());
|
||||
}
|
||||
originals = this.root.resolve("originals");
|
||||
journal = this.root.resolve("receipts.json");
|
||||
checkPath(originals);
|
||||
checkPath(journal);
|
||||
if (Files.exists(originals, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(originals, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid originals directory");
|
||||
}
|
||||
if (Files.exists(journal, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(journal, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid sync journal");
|
||||
}
|
||||
// Never recover a damaged or incomplete journal by establishing another baseline.
|
||||
try (JsonReader reader = new JsonReader(new InputStreamReader(
|
||||
Files.newInputStream(journal), StandardCharsets.UTF_8))) {
|
||||
reader.setStrictness(Strictness.STRICT);
|
||||
ledger = GSON.fromJson(reader, Ledger.class);
|
||||
if (reader.peek() != JsonToken.END_DOCUMENT) {
|
||||
throw new IOException("Trailing sync journal data");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Corrupt sync journal", e);
|
||||
}
|
||||
validateLedger();
|
||||
if (gallery != null) setGallery(gallery);
|
||||
} else {
|
||||
if (Files.exists(originals, LinkOption.NOFOLLOW_LINKS)) {
|
||||
try (java.util.stream.Stream<Path> paths = Files.walk(originals)) {
|
||||
if (paths.anyMatch(p -> !Files.isDirectory(p, LinkOption.NOFOLLOW_LINKS))) {
|
||||
throw new IOException("Originals exist without a sync journal");
|
||||
}
|
||||
}
|
||||
}
|
||||
ledger = emptyLedger();
|
||||
if (gallery == null) {
|
||||
ledger.gallery = false;
|
||||
} else {
|
||||
ledger.gallery = gallery;
|
||||
persist(ledger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Post-baseline receipts read without locks, directory creation or journal writes. */
|
||||
public static List<Item> snapshot(File root) throws Exception {
|
||||
return new SyncEngine(root, (Boolean) null).items();
|
||||
}
|
||||
|
||||
/** Returns false, without writing, once this engine is closed. */
|
||||
public boolean setGallery(boolean enabled) throws Exception {
|
||||
synchronized (lock) {
|
||||
if (closed) return false;
|
||||
if (ledger.gallery == enabled) return true;
|
||||
Ledger next = copy();
|
||||
next.gallery = enabled;
|
||||
if (!enabled) {
|
||||
for (Receipt receipt : next.records.values()) {
|
||||
if ("pending".equals(receipt.destination)) receipt.destination = "appOnly";
|
||||
}
|
||||
}
|
||||
commit(next);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A second live reconcile is rejected. stop() cancels its generation and permits a new run;
|
||||
* close() cancels it permanently. The journal is rewritten only when a receipt changes.
|
||||
*/
|
||||
public void reconcile(Listing listing, Transport transport, Exporter exporter) throws Exception {
|
||||
final long run;
|
||||
synchronized (lock) {
|
||||
if (closed) throw new IllegalStateException("Sync stopped");
|
||||
if (active != 0) throw new IllegalStateException("Sync already in progress");
|
||||
run = ++generation;
|
||||
active = run;
|
||||
}
|
||||
try {
|
||||
Poll poll = poll(listing);
|
||||
synchronized (lock) {
|
||||
if (active != run) return;
|
||||
skipped = poll.skipped;
|
||||
Ledger next = copy();
|
||||
if (!next.initialized) {
|
||||
for (Shot shot : poll.eligible.values()) {
|
||||
next.records.put(shot.id, new Receipt(shot, "baseline", 0, null));
|
||||
}
|
||||
next.initialized = true;
|
||||
next.version = 2;
|
||||
next.accounts = poll.accounts;
|
||||
commit(next);
|
||||
return;
|
||||
}
|
||||
boolean changed = false;
|
||||
Set<String> known = next.accounts;
|
||||
if (known == null) {
|
||||
// Version 1 journals did not record accounts. Trust only accounts that already
|
||||
// have receipts; any other account present now is history and is baselined.
|
||||
known = new TreeSet<>();
|
||||
for (String id : next.records.keySet()) known.add(account(id));
|
||||
// The account comparison below always records the set with this version.
|
||||
next.version = 2;
|
||||
changed = true;
|
||||
}
|
||||
// Missing observations break consecutiveness, including across restarts.
|
||||
for (Receipt receipt : next.records.values()) {
|
||||
if (!poll.eligible.containsKey(receipt.shot.id) && receipt.observations != 0
|
||||
&& ("observing".equals(receipt.state) || "queued".equals(receipt.state))) {
|
||||
receipt.observations = 0;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (Shot shot : poll.eligible.values()) {
|
||||
Receipt receipt = next.records.get(shot.id);
|
||||
if (receipt == null && !known.contains(account(shot.id))) {
|
||||
// A newly appearing account (or a returning one) starts with its own
|
||||
// baseline, so its existing screenshots are never imported.
|
||||
next.records.put(shot.id, new Receipt(shot, "baseline", 0, null));
|
||||
changed = true;
|
||||
} else if (receipt == null) {
|
||||
// Latch eligibility at discovery, before waiting for a stable second sample.
|
||||
next.records.put(shot.id, new Receipt(shot, "observing", 1,
|
||||
next.gallery ? "pending" : "appOnly"));
|
||||
changed = true;
|
||||
} else if ("observing".equals(receipt.state) || "queued".equals(receipt.state)) {
|
||||
boolean stable = same(receipt.shot, shot);
|
||||
int observations = stable ? Math.min(2, receipt.observations + 1) : 1;
|
||||
if (!stable || observations != receipt.observations) changed = true;
|
||||
receipt.observations = observations;
|
||||
receipt.shot = shot;
|
||||
if (receipt.observations == 2 && "observing".equals(receipt.state)) {
|
||||
receipt.state = "queued";
|
||||
}
|
||||
}
|
||||
}
|
||||
// Accounts absent now are forgotten; if one returns it is baselined again.
|
||||
if (!poll.accounts.equals(next.accounts)) {
|
||||
next.accounts = poll.accounts;
|
||||
changed = true;
|
||||
}
|
||||
// Queue and its disposition reach disk before any download.
|
||||
if (changed) commit(next);
|
||||
}
|
||||
|
||||
List<String> ids;
|
||||
synchronized (lock) {
|
||||
if (active != run) return;
|
||||
ids = new ArrayList<>(ledger.records.keySet());
|
||||
}
|
||||
ItemFailure failure = null;
|
||||
for (String id : ids) {
|
||||
Shot download = null;
|
||||
synchronized (lock) {
|
||||
if (active != run) return;
|
||||
Receipt receipt = ledger.records.get(id);
|
||||
if ("queued".equals(receipt.state) && receipt.observations == 2) {
|
||||
download = receipt.shot;
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (download != null) download(run, download, transport);
|
||||
export(run, id, exporter);
|
||||
} catch (ItemFailure e) {
|
||||
if (failure == null) failure = e;
|
||||
else failure.addSuppressed(e);
|
||||
}
|
||||
}
|
||||
// Surface retryable errors to the service after independent items have progressed.
|
||||
// Untagged local I/O/journal failures escape immediately and halt the run.
|
||||
if (failure != null) throw failure;
|
||||
} catch (Exception e) {
|
||||
synchronized (lock) {
|
||||
if (active != run) return; // A late failed callback belongs to a cancelled run.
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
synchronized (lock) {
|
||||
if (active == run) active = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of all post-baseline receipts, including downloads still pending. */
|
||||
public List<Item> items() {
|
||||
synchronized (lock) {
|
||||
List<Item> result = new ArrayList<>();
|
||||
for (Receipt receipt : ledger.records.values()) {
|
||||
if (!"baseline".equals(receipt.state)) {
|
||||
result.add(new Item(receipt.shot, originals.resolve(receipt.shot.id).toFile(),
|
||||
receipt.destination));
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparingDouble((Item item) -> item.shot.time).reversed()
|
||||
.thenComparing(item -> item.shot.id));
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** Listed files left out of the last poll because they were empty or over 30 MiB. */
|
||||
public int skipped() {
|
||||
return skipped;
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
synchronized (lock) {
|
||||
++generation;
|
||||
active = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently cancels this engine. Once it returns, no in-flight or later call can write the
|
||||
* journal, so another engine may safely take over the same root.
|
||||
*/
|
||||
public void close() {
|
||||
synchronized (lock) {
|
||||
closed = true;
|
||||
++generation;
|
||||
active = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isClosed() {
|
||||
synchronized (lock) {
|
||||
return closed;
|
||||
}
|
||||
}
|
||||
|
||||
private void download(long run, Shot shot, Transport transport) throws Exception {
|
||||
Path temporary;
|
||||
synchronized (lock) {
|
||||
if (active != run) return;
|
||||
temporary = Files.createTempFile(root, ".download-", ".part");
|
||||
}
|
||||
try {
|
||||
try {
|
||||
transport.download(shot, temporary.toFile());
|
||||
} catch (InterruptedException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new ItemFailure("Download failed: " + shot.id + ": " + e.getMessage(), e);
|
||||
}
|
||||
synchronized (lock) {
|
||||
if (active != run) return;
|
||||
}
|
||||
checkTransfer(temporary, shot);
|
||||
boolean valid;
|
||||
try {
|
||||
valid = transport.validImage(temporary.toFile());
|
||||
} catch (InterruptedException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new ItemFailure("Image validation failed: " + shot.id + ": " + e.getMessage(), e);
|
||||
}
|
||||
if (!valid) throw new ItemFailure("Invalid image: " + shot.id, null);
|
||||
checkTransfer(temporary, shot);
|
||||
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
synchronized (lock) {
|
||||
if (active != run) return;
|
||||
Path target = originals.resolve(shot.id);
|
||||
createParents(target.getParent());
|
||||
checkPath(target);
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
syncDirectory(target.getParent());
|
||||
Ledger next = copy();
|
||||
// Read the current destination: setGallery may have changed it during the transfer.
|
||||
next.records.get(shot.id).state = "downloaded";
|
||||
commit(next);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private void export(long run, String id, Exporter exporter) throws Exception {
|
||||
Shot shot;
|
||||
File original;
|
||||
synchronized (lock) {
|
||||
if (!eligible(run, id)) return;
|
||||
Receipt receipt = ledger.records.get(id);
|
||||
shot = receipt.shot;
|
||||
Path path = originals.resolve(id);
|
||||
checkDownload(path, shot);
|
||||
original = path.toFile();
|
||||
}
|
||||
boolean saved;
|
||||
try {
|
||||
saved = exporter.export(shot, original, () -> {
|
||||
synchronized (lock) {
|
||||
return eligible(run, id);
|
||||
}
|
||||
});
|
||||
} catch (InterruptedException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new ItemFailure("Gallery export failed: " + shot.id + ": " + e.getMessage(), e);
|
||||
}
|
||||
synchronized (lock) {
|
||||
if (active != run || !saved) return;
|
||||
Ledger next = copy();
|
||||
// A true result means the exporter actually saved (or recovered) the MediaStore item.
|
||||
next.records.get(id).destination = "saved";
|
||||
commit(next);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean eligible(long run, String id) {
|
||||
Receipt receipt = ledger.records.get(id);
|
||||
return active == run && ledger.gallery && receipt != null
|
||||
&& "downloaded".equals(receipt.state) && "pending".equals(receipt.destination);
|
||||
}
|
||||
|
||||
private static Poll poll(Listing listing) throws IOException {
|
||||
if (listing == null || listing.shots == null || listing.accounts == null) {
|
||||
throw new IOException("Missing screenshot listing");
|
||||
}
|
||||
Poll result = new Poll();
|
||||
for (String account : listing.accounts) {
|
||||
if (account == null || !ACCOUNT.matcher(account).matches()) {
|
||||
throw new IOException("Invalid Steam account");
|
||||
}
|
||||
result.accounts.add(account);
|
||||
}
|
||||
Map<String, Shot> seen = new LinkedHashMap<>();
|
||||
for (Shot shot : listing.shots) {
|
||||
// Unsafe ids, non-finite times and conflicting duplicates reject the whole listing.
|
||||
validateIdentity(shot);
|
||||
Shot previous = seen.put(shot.id, shot);
|
||||
if (previous != null && !same(previous, shot)) {
|
||||
throw new IOException("Conflicting screenshot identity: " + shot.id);
|
||||
}
|
||||
// The account directory may appear between the account and screenshot queries.
|
||||
result.accounts.add(account(shot.id));
|
||||
}
|
||||
for (Shot shot : seen.values()) {
|
||||
// An empty, still-writing or oversized file is only this item's problem: treat it
|
||||
// as absent from this poll so it never blocks other screenshots or the baseline.
|
||||
if (eligibleSize(shot)) result.eligible.put(shot.id, shot);
|
||||
else result.skipped++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String account(String id) {
|
||||
return id.substring(0, id.indexOf('/'));
|
||||
}
|
||||
|
||||
private static void validateIdentity(Shot shot) throws IOException {
|
||||
if (shot == null || shot.id == null || !ID.matcher(shot.id).matches()
|
||||
|| !Double.isFinite(shot.time)) {
|
||||
throw new IOException("Invalid screenshot metadata");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean eligibleSize(Shot shot) {
|
||||
return shot.size > 0 && shot.size <= MAX_SIZE;
|
||||
}
|
||||
|
||||
private static void validateShot(Shot shot) throws IOException {
|
||||
validateIdentity(shot);
|
||||
if (!eligibleSize(shot)) throw new IOException("Invalid screenshot metadata");
|
||||
}
|
||||
|
||||
private static boolean same(Shot a, Shot b) {
|
||||
return a.size == b.size && Double.compare(a.time, b.time) == 0;
|
||||
}
|
||||
|
||||
private void validateLedger() throws IOException {
|
||||
if (ledger == null || ledger.version == null || ledger.initialized == null
|
||||
|| ledger.gallery == null || ledger.records == null
|
||||
|| (ledger.version == 1 && ledger.accounts != null)
|
||||
|| (ledger.version == 2 && ledger.accounts == null)
|
||||
|| (ledger.version != 1 && ledger.version != 2)
|
||||
|| (!ledger.initialized && !ledger.records.isEmpty())
|
||||
|| (!ledger.initialized && ledger.accounts != null && !ledger.accounts.isEmpty())) {
|
||||
throw new IOException("Invalid sync journal header");
|
||||
}
|
||||
if (ledger.accounts != null) {
|
||||
for (String account : ledger.accounts) {
|
||||
if (account == null || !ACCOUNT.matcher(account).matches()) {
|
||||
throw new IOException("Invalid sync journal header");
|
||||
}
|
||||
}
|
||||
ledger.accounts = new TreeSet<>(ledger.accounts);
|
||||
}
|
||||
for (Map.Entry<String, Receipt> entry : ledger.records.entrySet()) {
|
||||
Receipt receipt = entry.getValue();
|
||||
if (receipt == null) throw new IOException("Invalid sync receipt");
|
||||
validateShot(receipt.shot);
|
||||
if (!entry.getKey().equals(receipt.shot.id) || receipt.observations == null
|
||||
|| receipt.observations < 0 || receipt.observations > 2) {
|
||||
throw new IOException("Invalid sync receipt identity");
|
||||
}
|
||||
boolean baseline = "baseline".equals(receipt.state);
|
||||
boolean observing = "observing".equals(receipt.state);
|
||||
boolean queued = "queued".equals(receipt.state);
|
||||
boolean downloaded = "downloaded".equals(receipt.state);
|
||||
if ((!baseline && !observing && !queued && !downloaded)
|
||||
|| (baseline && receipt.destination != null)
|
||||
|| (baseline && receipt.observations != 0)
|
||||
|| (observing && receipt.observations > 1)
|
||||
|| (downloaded && receipt.observations != 2)
|
||||
|| ((observing || queued || downloaded) && !"pending".equals(receipt.destination)
|
||||
&& !"appOnly".equals(receipt.destination) && !"saved".equals(receipt.destination))
|
||||
|| ((observing || queued) && "saved".equals(receipt.destination))
|
||||
|| (!ledger.gallery && "pending".equals(receipt.destination))) {
|
||||
throw new IOException("Invalid sync receipt state");
|
||||
}
|
||||
Path path = originals.resolve(receipt.shot.id);
|
||||
checkPath(path);
|
||||
if (downloaded) checkDownload(path, receipt.shot);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkDownload(Path path, Shot shot) throws IOException {
|
||||
checkPath(path);
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) != shot.size) {
|
||||
throw new IOException("Incomplete original: " + shot.id);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkTransfer(Path path, Shot shot) throws IOException {
|
||||
checkPath(path); // Unsafe paths and local filesystem errors remain fatal.
|
||||
BasicFileAttributes attributes;
|
||||
try {
|
||||
attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
} catch (NoSuchFileException e) {
|
||||
throw new ItemFailure("Missing downloaded image: " + shot.id, e);
|
||||
}
|
||||
if (!attributes.isRegularFile() || attributes.size() != shot.size) {
|
||||
throw new ItemFailure("Incomplete downloaded image: " + shot.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkPath(Path path) throws IOException {
|
||||
if (!path.normalize().startsWith(root)) throw new IOException("Path escaped sync root");
|
||||
Path current = root;
|
||||
if (Files.isSymbolicLink(current)) throw new IOException("Symlink in sync root");
|
||||
for (Path component : root.relativize(path)) {
|
||||
current = current.resolve(component);
|
||||
if (Files.isSymbolicLink(current)) throw new IOException("Symlink in sync path");
|
||||
}
|
||||
}
|
||||
|
||||
private void createParents(Path directory) throws IOException {
|
||||
checkPath(directory);
|
||||
Path current = root;
|
||||
for (Path component : root.relativize(directory)) {
|
||||
Path parent = current;
|
||||
current = current.resolve(component);
|
||||
if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectory(current);
|
||||
syncDirectory(parent);
|
||||
} else if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid originals directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the version: a version 1 journal (no account set) stays version 1 through setting
|
||||
* changes, and only reconcile upgrades it, recording the version and accounts together.
|
||||
*/
|
||||
private Ledger copy() {
|
||||
Ledger next = emptyLedger();
|
||||
next.version = ledger.version;
|
||||
next.initialized = ledger.initialized;
|
||||
next.gallery = ledger.gallery;
|
||||
next.accounts = ledger.accounts == null ? null : new TreeSet<>(ledger.accounts);
|
||||
for (Map.Entry<String, Receipt> entry : ledger.records.entrySet()) {
|
||||
Receipt receipt = entry.getValue();
|
||||
next.records.put(entry.getKey(), new Receipt(receipt.shot, receipt.state,
|
||||
receipt.observations, receipt.destination));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private static Ledger emptyLedger() {
|
||||
Ledger result = new Ledger();
|
||||
result.version = 2;
|
||||
result.initialized = false;
|
||||
result.accounts = new TreeSet<>();
|
||||
result.records = new LinkedHashMap<>();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void commit(Ledger next) throws IOException {
|
||||
if (closed) throw new IOException("Sync journal is read-only");
|
||||
persist(next);
|
||||
ledger = next;
|
||||
}
|
||||
|
||||
private void persist(Ledger next) throws IOException {
|
||||
checkPath(journal);
|
||||
Path temporary = Files.createTempFile(root, ".receipts-", ".tmp");
|
||||
try {
|
||||
try (FileOutputStream output = new FileOutputStream(temporary.toFile());
|
||||
Writer writer = new OutputStreamWriter(output, StandardCharsets.UTF_8)) {
|
||||
GSON.toJson(next, writer);
|
||||
writer.flush();
|
||||
output.getFD().sync();
|
||||
}
|
||||
Files.move(temporary, journal, StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
syncDirectory(root);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void syncDirectory(Path directory) {
|
||||
// Some Android filesystems reject directory channels. File contents were already fsynced;
|
||||
// the rename is atomic either way. Do not report rollback after a committed rename.
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
} catch (IOException | UnsupportedOperationException ignored) {
|
||||
// Best available portable Java directory durability.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import android.app.*;
|
||||
import android.content.*;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.*;
|
||||
import java.io.File;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Explicit user-started connected-device session, with a visible Stop action. */
|
||||
public final class SyncService extends Service {
|
||||
static final String CHANNEL = "headset-sync";
|
||||
static final Object SETTINGS_LOCK = new Object();
|
||||
private static final int NOTIFICATION = 108;
|
||||
private static final SessionGate GATE = new SessionGate();
|
||||
private static volatile SyncService instance;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
private long session;
|
||||
private volatile boolean stopping;
|
||||
private volatile FrameConnection connection;
|
||||
private volatile SyncEngine engine;
|
||||
private volatile String pin;
|
||||
|
||||
static SharedPreferences prefs(Context c) {
|
||||
return c.getSharedPreferences("frame", MODE_PRIVATE);
|
||||
}
|
||||
|
||||
static String state() {
|
||||
return GATE.state();
|
||||
}
|
||||
|
||||
static String page() {
|
||||
return GATE.page();
|
||||
}
|
||||
|
||||
static boolean running() {
|
||||
SyncService service = instance;
|
||||
return service != null && !service.stopping && GATE.active(service.session);
|
||||
}
|
||||
|
||||
static String install(Context c) {
|
||||
SharedPreferences p = prefs(c);
|
||||
String id = p.getString("install", null);
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID().toString();
|
||||
if (!p.edit().putString("install", id).commit())
|
||||
throw new IllegalStateException("Could not save this phone's identity.");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
static File library(Context c, String pin) throws Exception {
|
||||
return new File(c.getFilesDir(), "screenshots/" + FrameConnection.hash(pin));
|
||||
}
|
||||
|
||||
static void gallery(Context c, boolean value) throws Exception {
|
||||
synchronized (SETTINGS_LOCK) {
|
||||
SyncService service = instance;
|
||||
// Journal first: a failed disk write must not appear as a saved setting.
|
||||
String pin = prefs(c).getString("pin", null);
|
||||
SyncEngine live = service == null ? null : service.engine;
|
||||
// A closed engine belongs to a stopped session; update its library from disk instead.
|
||||
if (live != null && !live.setGallery(value)) live = null;
|
||||
if (!value) {
|
||||
// The setting is global. Latch the opt-out in every retained library,
|
||||
// including headsets that are not currently paired or connected.
|
||||
File libraries = new File(c.getFilesDir(), "screenshots");
|
||||
if (libraries.exists()) {
|
||||
File[] roots = libraries.listFiles();
|
||||
if (roots == null) throw new java.io.IOException("Could not read screenshot libraries.");
|
||||
File activeRoot = live != null && pin != null ? library(c, pin) : null;
|
||||
for (File root : roots) {
|
||||
if (!root.getName().matches("[0-9a-f]{64}")) continue;
|
||||
if (activeRoot != null && root.equals(activeRoot)) continue;
|
||||
new SyncEngine(root, false).setGallery(false);
|
||||
}
|
||||
}
|
||||
} else if (live == null && pin != null) {
|
||||
new SyncEngine(library(c, pin), true).setGallery(true);
|
||||
}
|
||||
if (!prefs(c).edit().putBoolean("gallery", value).commit())
|
||||
throw new java.io.IOException("Could not save the Gallery setting.");
|
||||
// Interrupted exports can leave hidden pending rows; app-only receipts never retry them.
|
||||
if (!value) GalleryExporter.discardPending(c);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only, for a background thread. Never creates or rewrites a library. */
|
||||
static List<SyncEngine.Item> items(Context c, String pin) throws Exception {
|
||||
if (pin == null) return Collections.emptyList();
|
||||
SyncService service = instance;
|
||||
SyncEngine live = service == null ? null : service.engine;
|
||||
if (live != null && !live.isClosed() && pin.equals(service.pin)) return live.items();
|
||||
return SyncEngine.snapshot(library(c, pin));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
session = GATE.begin();
|
||||
instance = this;
|
||||
stopping = false;
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
manager.createNotificationChannel(
|
||||
new NotificationChannel(
|
||||
CHANNEL, "Headset screenshot sync", NotificationManager.IMPORTANCE_LOW));
|
||||
}
|
||||
|
||||
private Notification notification(String message) {
|
||||
PendingIntent open =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
new Intent(this, MainActivity.class),
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
PendingIntent stop =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
new Intent(this, SyncService.class).setAction("STOP"),
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
return new Notification.Builder(this, CHANNEL)
|
||||
.setSmallIcon(com.saphid.framecontrol.R.drawable.ic_frame)
|
||||
.setContentTitle("Frame Control")
|
||||
.setContentText(message)
|
||||
.setContentIntent(open)
|
||||
.setOngoing(true)
|
||||
.addAction(new Notification.Action.Builder(null, "Stop syncing", stop).build())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** A stale worker cannot reach the notification once its session has ended. */
|
||||
private void status(String text) {
|
||||
GATE.status(
|
||||
session,
|
||||
text,
|
||||
t -> getSystemService(NotificationManager.class).notify(NOTIFICATION, notification(t)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && "STOP".equals(intent.getAction())) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (connection != null || stopping) return START_NOT_STICKY;
|
||||
String text = "Connecting to headset";
|
||||
GATE.status(session, text, null);
|
||||
if (Build.VERSION.SDK_INT >= 34)
|
||||
startForeground(
|
||||
NOTIFICATION, notification(text), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE);
|
||||
else startForeground(NOTIFICATION, notification(text));
|
||||
connection = new FrameConnection(this);
|
||||
worker.execute(this::runSession);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
private void runSession() {
|
||||
try {
|
||||
SharedPreferences p = prefs(this);
|
||||
String pin = p.getString("pin", null);
|
||||
if (pin == null) throw new java.io.IOException("Pair with your headset first.");
|
||||
SyncEngine created;
|
||||
synchronized (SETTINGS_LOCK) {
|
||||
boolean gallery = p.getBoolean("gallery", true);
|
||||
created = new SyncEngine(library(this, pin), gallery);
|
||||
// Retry cleanup of hidden rows if an earlier opt-out's cleanup was interrupted.
|
||||
if (!gallery) GalleryExporter.discardPending(this);
|
||||
this.pin = pin;
|
||||
engine = created;
|
||||
}
|
||||
// Pairs with onDestroy: it sets stopping before reading engine.
|
||||
if (stopping) created.close();
|
||||
while (!stopping) {
|
||||
try {
|
||||
FrameConnection active = new FrameConnection(this);
|
||||
connection = active;
|
||||
if (stopping) break;
|
||||
status("Connecting to headset");
|
||||
active.connect(
|
||||
p.getString("host", ""),
|
||||
p.getInt("port", 22),
|
||||
p.getString("user", "steamos"),
|
||||
pin,
|
||||
install(this));
|
||||
if (stopping) {
|
||||
active.close();
|
||||
break;
|
||||
}
|
||||
GATE.page(session, active.page());
|
||||
status("Syncing new screenshots");
|
||||
GalleryExporter exporter = new GalleryExporter(this, pin, SETTINGS_LOCK);
|
||||
while (!stopping && active.connected()) {
|
||||
try {
|
||||
SyncEngine.Listing listing = active.listing();
|
||||
created.reconcile(listing, active, exporter);
|
||||
status(progress(listing, created.skipped()));
|
||||
} catch (Exception e) {
|
||||
status("Screenshot pending · " + message(e));
|
||||
}
|
||||
Thread.sleep(4000);
|
||||
}
|
||||
if (!stopping) throw new java.io.IOException("Connection lost. Reconnecting…");
|
||||
} catch (Exception e) {
|
||||
GATE.page(session, null);
|
||||
FrameConnection c = connection;
|
||||
if (c != null) c.close();
|
||||
if (stopping) break;
|
||||
status("Waiting for headset · " + message(e));
|
||||
Thread.sleep(10000);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
GATE.status(session, message(e), null);
|
||||
} finally {
|
||||
GATE.page(session, null);
|
||||
FrameConnection c = connection;
|
||||
if (c != null) c.close();
|
||||
// Only a still-current session may stop the service; Stop already ended a stale one.
|
||||
GATE.ifActive(session, this::stopSelf);
|
||||
}
|
||||
}
|
||||
|
||||
static String progress(SyncEngine.Listing listing, int skipped) {
|
||||
if (listing.accounts.isEmpty()) return "Waiting for a Steam account on the headset";
|
||||
String text = "Syncing new screenshots";
|
||||
if (skipped == 1) text += " · 1 file skipped (empty or over 30 MB)";
|
||||
else if (skipped > 1) text += " · " + skipped + " files skipped (empty or over 30 MB)";
|
||||
return text;
|
||||
}
|
||||
|
||||
static String message(Exception e) {
|
||||
String text = e.getMessage();
|
||||
if (text == null || text.trim().isEmpty()) return "Try reconnecting.";
|
||||
if (text.contains("HostKey")
|
||||
|| text.contains("HostKey has been changed")
|
||||
|| text.contains("reject HostKey")) return "Headset identity changed. Stop and pair again.";
|
||||
if (text.contains("Auth fail")) return "Headset login refused. Pair again.";
|
||||
return text.length() > 180 ? text.substring(0, 180) : text;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
stopping = true;
|
||||
// After this, the old worker cannot post status, page or notification updates.
|
||||
GATE.end(session);
|
||||
SyncEngine live = engine;
|
||||
if (live != null) live.close();
|
||||
FrameConnection c = connection;
|
||||
if (c != null) c.close();
|
||||
worker.shutdownNow();
|
||||
if (instance == this) instance = null;
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
// Also clear a notification posted just before the gate closed.
|
||||
getSystemService(NotificationManager.class).cancel(NOTIFICATION);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
|
||||
<path android:fillColor="#7CD8C3" android:pathData="M8,12L40,12L44,30L34,36L26,28L22,28L14,36L4,30ZM11,17L9,27L14,29L20,23L28,23L34,29L39,27L37,17Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:colorAccent">#7CD8C3</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowBackground">#111819</item>
|
||||
<item name="android:navigationBarColor">#111819</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<data-extraction-rules>
|
||||
<cloud-backup><exclude domain="root" path="." /><exclude domain="file" path="." /><exclude domain="database" path="." /><exclude domain="sharedpref" path="." /><exclude domain="external" path="." /></cloud-backup>
|
||||
<device-transfer><exclude domain="root" path="." /><exclude domain="file" path="." /><exclude domain="database" path="." /><exclude domain="sharedpref" path="." /><exclude domain="external" path="." /></device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1 @@
|
||||
<paths><files-path name="screenshots" path="screenshots/" /></paths>
|
||||
@@ -0,0 +1,4 @@
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true"><domain includeSubdomains="false">127.0.0.1</domain><domain includeSubdomains="false">localhost</domain></domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/** Runs the generated headset shell against a temporary HOME. */
|
||||
public class FrameConnectionTest {
|
||||
@Rule public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
private static final String KEY = "rsa-sha2-256 AAAAB3NzaC1yc2EAAAADAQAB frame-control 'phone'";
|
||||
private static final String MAC = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMac mac";
|
||||
|
||||
private static final class Result {
|
||||
final int exit;
|
||||
final String output;
|
||||
|
||||
Result(int exit, String output) { this.exit = exit; this.output = output; }
|
||||
}
|
||||
|
||||
private static Result sh(File home, String command) throws Exception {
|
||||
ProcessBuilder builder = new ProcessBuilder("/bin/sh", "-c", command);
|
||||
builder.environment().put("HOME", home.getPath());
|
||||
builder.redirectError(ProcessBuilder.Redirect.to(new File("/dev/null")));
|
||||
Process process = builder.start();
|
||||
java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.InputStream in = process.getInputStream()) {
|
||||
byte[] buffer = new byte[4096];
|
||||
for (int n; (n = in.read(buffer)) != -1; ) bytes.write(buffer, 0, n);
|
||||
}
|
||||
String output = new String(bytes.toByteArray(), StandardCharsets.UTF_8);
|
||||
assertTrue("Shell timed out", process.waitFor(10, TimeUnit.SECONDS));
|
||||
return new Result(process.exitValue(), output);
|
||||
}
|
||||
|
||||
private String authorize(String existing) throws Exception {
|
||||
File home = temporary.newFolder();
|
||||
File keys = new File(home, ".ssh/authorized_keys");
|
||||
if (existing != null) {
|
||||
assertTrue(keys.getParentFile().mkdirs());
|
||||
Files.write(keys.toPath(), existing.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
for (int i = 0; i < 2; i++) assertEquals(0, sh(home, FrameConnection.authorizeCommand(KEY)).exit);
|
||||
assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(keys.toPath())));
|
||||
return new String(Files.readAllBytes(keys.toPath()), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test public void authorizingTerminatesALastLineWithoutNewlineAndIsIdempotent() throws Exception {
|
||||
assertEquals(KEY + "\n", authorize(null));
|
||||
assertEquals(KEY + "\n", authorize(""));
|
||||
assertEquals(MAC + "\n" + KEY + "\n", authorize(MAC));
|
||||
assertEquals(MAC + "\n" + KEY + "\n", authorize(MAC + "\n"));
|
||||
assertEquals(MAC + "\nother\n" + KEY + "\n", authorize(MAC + "\nother"));
|
||||
// Already present as an unterminated last line: left exactly as it was.
|
||||
assertEquals(MAC + "\n" + KEY, authorize(MAC + "\n" + KEY));
|
||||
}
|
||||
|
||||
@Test public void accountListingSeparatesAbsentUserdataFromEmptyAccounts() throws Exception {
|
||||
File home = temporary.newFolder();
|
||||
Result absent = sh(home, FrameConnection.ACCOUNTS_COMMAND);
|
||||
assertEquals(0, absent.exit);
|
||||
assertEquals("absent\n", absent.output);
|
||||
assertTrue(FrameConnection.parseAccounts(absent.output).isEmpty());
|
||||
File userdata = new File(home, ".local/share/Steam/userdata");
|
||||
assertTrue(userdata.mkdirs());
|
||||
assertEquals("present\n", sh(home, FrameConnection.ACCOUNTS_COMMAND).output);
|
||||
for (String directory : Arrays.asList("123", "4567890", "anonymous", "0123456789012", "12a"))
|
||||
assertTrue(new File(userdata, directory).mkdir());
|
||||
Files.write(new File(userdata, "999").toPath(), new byte[]{1});
|
||||
Result present = sh(home, FrameConnection.ACCOUNTS_COMMAND);
|
||||
assertEquals(0, present.exit);
|
||||
assertEquals(new TreeSet<>(Arrays.asList("123", "4567890")),
|
||||
FrameConnection.parseAccounts(present.output));
|
||||
// An unreadable root is an error, never an empty account list.
|
||||
File broken = temporary.newFolder();
|
||||
File file = new File(broken, ".local/share/Steam/userdata");
|
||||
assertTrue(file.getParentFile().mkdirs());
|
||||
Files.write(file.toPath(), new byte[]{1});
|
||||
assertNotEquals(0, sh(broken, FrameConnection.ACCOUNTS_COMMAND).exit);
|
||||
}
|
||||
|
||||
@Test public void unexpectedAccountOutputIsRejected() throws Exception {
|
||||
for (String output : Arrays.asList("", "absent", "present", "present\n123", "absent\n123\n",
|
||||
"present\nabc\n", "present\n\n", "ready\n", "present\n12 3\n")) {
|
||||
try {
|
||||
FrameConnection.parseAccounts(output);
|
||||
fail("Accepted " + output);
|
||||
} catch (IOException expected) {
|
||||
// Fail closed: the poll is retried and no baseline changes.
|
||||
}
|
||||
}
|
||||
assertEquals(Collections.emptySet(), FrameConnection.parseAccounts("present\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.saphid.framecontrol;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class SessionGateTest {
|
||||
@Test public void endedSessionCannotPublishPageOrStop() {
|
||||
SessionGate gate = new SessionGate();
|
||||
List<String> shown = new ArrayList<>();
|
||||
long session = gate.begin();
|
||||
assertTrue(gate.status(session, "Syncing new screenshots", shown::add));
|
||||
assertTrue(gate.page(session, "http://127.0.0.1:1/?key=a"));
|
||||
assertTrue(gate.end(session));
|
||||
assertFalse(gate.running());
|
||||
assertNull(gate.page());
|
||||
// The worker finishes its download after Stop and reports the closed tunnel.
|
||||
assertFalse(gate.status(session, "Screenshot pending · Connection reset", shown::add));
|
||||
assertFalse(gate.status(session, "Syncing new screenshots", shown::add));
|
||||
assertFalse(gate.page(session, "http://127.0.0.1:1/?key=a"));
|
||||
AtomicBoolean stopped = new AtomicBoolean();
|
||||
assertFalse(gate.ifActive(session, () -> stopped.set(true)));
|
||||
assertFalse(stopped.get());
|
||||
assertEquals(Collections.singletonList("Syncing new screenshots"), shown);
|
||||
assertEquals(SessionGate.DISCONNECTED, gate.state());
|
||||
}
|
||||
|
||||
@Test public void lateWorkerCannotChangeANewerSession() {
|
||||
SessionGate gate = new SessionGate();
|
||||
List<String> shown = new ArrayList<>();
|
||||
long first = gate.begin();
|
||||
gate.end(first);
|
||||
long second = gate.begin();
|
||||
assertTrue(gate.status(second, "Connecting to headset", shown::add));
|
||||
assertTrue(gate.page(second, "page-2"));
|
||||
assertFalse(gate.status(first, "Syncing new screenshots", shown::add));
|
||||
assertFalse(gate.page(first, null));
|
||||
assertFalse(gate.end(first));
|
||||
assertFalse(gate.ifActive(first, () -> fail("Stale session stopped the new one")));
|
||||
assertEquals("Connecting to headset", gate.state());
|
||||
assertEquals("page-2", gate.page());
|
||||
assertTrue(gate.running());
|
||||
assertEquals(Collections.singletonList("Connecting to headset"), shown);
|
||||
long third = gate.begin(); // Even without an end, a newer session supersedes.
|
||||
assertFalse(gate.status(second, "Syncing new screenshots", null));
|
||||
assertTrue(gate.active(third));
|
||||
assertNull(gate.page());
|
||||
}
|
||||
|
||||
@Test public void endClearsProgressButKeepsFinalError() {
|
||||
for (String progress : Arrays.asList("Syncing new screenshots",
|
||||
"Syncing new screenshots · 1 file skipped (empty or over 30 MB)",
|
||||
"Waiting for headset · Connection refused", "Waiting for a Steam account on the headset",
|
||||
"Connecting to headset", "Screenshot pending · Download failed")) {
|
||||
SessionGate gate = new SessionGate();
|
||||
long session = gate.begin();
|
||||
gate.status(session, progress, null);
|
||||
gate.end(session);
|
||||
assertEquals(progress, SessionGate.DISCONNECTED, gate.state());
|
||||
}
|
||||
SessionGate gate = new SessionGate();
|
||||
long session = gate.begin();
|
||||
gate.status(session, "Pair with your headset first.", null);
|
||||
gate.end(session);
|
||||
assertEquals("Pair with your headset first.", gate.state());
|
||||
}
|
||||
|
||||
@Test public void endWaitsForAnInFlightNotificationAndNothingPostsAfterIt() throws Exception {
|
||||
SessionGate gate = new SessionGate();
|
||||
long session = gate.begin();
|
||||
List<String> events = Collections.synchronizedList(new ArrayList<>());
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
Thread worker = new Thread(() -> gate.status(session, "Syncing new screenshots", text -> {
|
||||
events.add("posting");
|
||||
entered.countDown();
|
||||
try { release.await(5, TimeUnit.SECONDS); } catch (InterruptedException e) { return; }
|
||||
events.add("posted");
|
||||
}));
|
||||
worker.start();
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
Thread stop = new Thread(() -> { gate.end(session); events.add("ended"); });
|
||||
stop.start();
|
||||
stop.join(200);
|
||||
assertTrue("Stop must wait for the notification in flight", stop.isAlive());
|
||||
release.countDown();
|
||||
worker.join(5000);
|
||||
stop.join(5000);
|
||||
assertFalse(gate.status(session, "Syncing new screenshots", text -> events.add("late")));
|
||||
assertEquals(Arrays.asList("posting", "posted", "ended"), events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
plugins { id 'com.android.application' version '9.1.0' apply false }
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,8 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Use the same reproducible headset bundle as the iPhone app."""
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parents[2]
|
||||
out = pathlib.Path(sys.argv[1]).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
version = subprocess.check_output(
|
||||
[sys.executable, str(root / 'ios/scripts/make_frame_bundle.py'),
|
||||
str(out / 'frame-bundle.tar.gz')], cwd=root, text=True).strip().splitlines()[-1]
|
||||
(out / 'frame-bundle.version').write_text(version + '\n')
|
||||
@@ -0,0 +1,4 @@
|
||||
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
|
||||
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } }
|
||||
rootProject.name = 'FrameControl'
|
||||
include ':app'
|
||||
@@ -13,11 +13,6 @@ 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",
|
||||
"scripts/obs-headset-source.py",
|
||||
"ui/frame_android.py",
|
||||
"frame/android/lepton-app.sh",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
|
||||
// packages for every build), into ../frame/kdeconnect/packages as listed in
|
||||
// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
|
||||
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
|
||||
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
@@ -15,7 +15,6 @@ const PY = "3.12.14+20260924";
|
||||
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
|
||||
+ `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
|
||||
const PYTHON = {
|
||||
"mac-x64": ["x86_64-apple-darwin", "7ea9761b9069c10b9a20531d568645849d604c59e9c7f11f6659f1e1790c968e"],
|
||||
"mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
|
||||
"win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
|
||||
"linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// Opt-in OS login item. Linux owns only its marked autostart file; an existing
|
||||
// custom entry is never overwritten or removed.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARKER = "# Managed by Frame Control; use Frame > Open at Login to remove.\n";
|
||||
const FILE = "com.saphid.frame-control.desktop";
|
||||
|
||||
function quoteExec(value) {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`").replace(/%/g, "%%")}"`;
|
||||
}
|
||||
|
||||
function createLoginItem({ platform = process.platform, electronApp, env = process.env,
|
||||
executable = process.execPath, filesystem = fs,
|
||||
home = os.homedir() }) {
|
||||
const config = env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)
|
||||
? env.XDG_CONFIG_HOME : path.join(home, ".config");
|
||||
const file = path.join(config, "autostart", FILE);
|
||||
const command = platform === "linux" && env.APPIMAGE && path.isAbsolute(env.APPIMAGE)
|
||||
? env.APPIMAGE : executable;
|
||||
function owned() {
|
||||
try { return filesystem.readFileSync(file, "utf8").startsWith(MARKER); }
|
||||
catch (err) { if (err.code === "ENOENT") return false; throw err; }
|
||||
}
|
||||
function enabled() {
|
||||
if (platform === "linux") return owned();
|
||||
return !!electronApp.getLoginItemSettings().openAtLogin;
|
||||
}
|
||||
function set(enabledNow) {
|
||||
if (platform !== "linux") {
|
||||
electronApp.setLoginItemSettings({ openAtLogin: !!enabledNow, openAsHidden: !!enabledNow,
|
||||
args: enabledNow ? ["--hidden"] : [] });
|
||||
if (enabled() !== !!enabledNow) throw new Error("The operating system did not save the login item.");
|
||||
return enabled();
|
||||
}
|
||||
if (enabledNow) {
|
||||
if (filesystem.existsSync(file) && !owned()) throw new Error(`A different autostart entry already exists: ${file}`);
|
||||
filesystem.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const body = MARKER + "[Desktop Entry]\nType=Application\nName=Frame Control\n" +
|
||||
`Exec=${quoteExec(command)} --hidden\nTerminal=false\nX-GNOME-Autostart-enabled=true\n`;
|
||||
const tmp = file + `.tmp-${process.pid}`;
|
||||
try { filesystem.writeFileSync(tmp, body, { mode: 0o600 }); filesystem.renameSync(tmp, file); }
|
||||
finally { try { filesystem.unlinkSync(tmp); } catch (err) { if (err.code !== "ENOENT") throw err; } }
|
||||
} else if (owned()) {
|
||||
filesystem.unlinkSync(file);
|
||||
}
|
||||
return enabled();
|
||||
}
|
||||
return { enabled, set, file };
|
||||
}
|
||||
|
||||
module.exports = { createLoginItem, quoteExec };
|
||||
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, Notification, Tray, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -12,7 +12,6 @@ const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { preferenceStore } = require("./preferences");
|
||||
const { createLoginItem } = require("./login-item");
|
||||
const { createMetaSignIn } = require("./meta-signin");
|
||||
|
||||
const run = promisify(execFile);
|
||||
@@ -31,12 +30,10 @@ const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
|
||||
const loginItem = createLoginItem({ electronApp: app });
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
let win = null;
|
||||
let tray = null;
|
||||
let quitting = false;
|
||||
let python = null;
|
||||
|
||||
@@ -307,33 +304,6 @@ ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||
|
||||
// The OBS source is local to this computer. Closing the Python relay's control
|
||||
// pipe lets it stop SSH on every platform, including Windows where SIGTERM
|
||||
// terminates Python without running its cleanup block.
|
||||
const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py");
|
||||
const { createObsSource } = require("./obs-source");
|
||||
const obsSource = createObsSource({
|
||||
spawn,
|
||||
// Keep the button hidden until native Windows/Frame streaming is verified.
|
||||
supported: !IS_WIN,
|
||||
prepare: async alias => {
|
||||
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
|
||||
const env = { ...process.env, PATH: await loginPath() };
|
||||
const executable = python || await findPython(env);
|
||||
if (!executable) throw new Error(PYTHON_HELP);
|
||||
if (quitting || alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
|
||||
return { executable, args: [...PY_FLAGS, OBS_SCRIPT], env };
|
||||
},
|
||||
onState: state => {
|
||||
if (win && !win.isDestroyed()) win.webContents.send("obs:state", state);
|
||||
},
|
||||
});
|
||||
const publicObs = () => obsSource.status();
|
||||
const stopObs = () => obsSource.stop();
|
||||
ipcMain.handle("obs:status", e => fromUi(e) ? publicObs() : null);
|
||||
ipcMain.handle("obs:start", (e, port) => fromUi(e) ? obsSource.start(activeAlias(), port) : null);
|
||||
ipcMain.handle("obs:stop", e => fromUi(e) ? stopObs() : null);
|
||||
|
||||
// The page reports the headsets it knows (the server's Devices tab), so the Frame
|
||||
// menu can switch between them. Only plain names and ids go into the menu.
|
||||
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
@@ -344,7 +314,6 @@ ipcMain.on("devices:changed", (e, list) => {
|
||||
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
|
||||
if (JSON.stringify(next) === JSON.stringify(devices)) return;
|
||||
devices = next;
|
||||
if (obsSource.status().alias && obsSource.status().alias !== activeAlias()) stopObs();
|
||||
buildMenu();
|
||||
});
|
||||
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
|
||||
@@ -547,60 +516,7 @@ function registerScheme() {
|
||||
}
|
||||
}
|
||||
|
||||
function showWindow() {
|
||||
if (!win) return createWindow(true);
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
function ensureTray() {
|
||||
if (tray) return true;
|
||||
try {
|
||||
const icon = nativeImage.createFromPath(path.join(__dirname, "build", "icon.png"));
|
||||
if (icon.isEmpty()) return false;
|
||||
tray = new Tray(icon);
|
||||
// Some Linux desktops accept the Tray object but expose no status icon.
|
||||
// Keep a visible login window rather than hiding the only way back in.
|
||||
if (process.platform === "linux") {
|
||||
const bounds = tray.getBounds();
|
||||
if (!bounds.width || !bounds.height) { tray.destroy(); tray = null; return false; }
|
||||
}
|
||||
tray.setToolTip("Frame Control");
|
||||
tray.on("click", showWindow);
|
||||
updateTrayMenu();
|
||||
return true;
|
||||
} catch {
|
||||
if (tray) { try { tray.destroy(); } catch {} tray = null; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: "Open Frame Control", click: showWindow },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(), click: toggleLoginItem },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit Frame Control", click: () => app.quit() },
|
||||
]));
|
||||
}
|
||||
|
||||
function toggleLoginItem() {
|
||||
try {
|
||||
const on = loginItem.set(!loginItem.enabled());
|
||||
if (on) ensureTray();
|
||||
else if (tray) { tray.destroy(); tray = null; }
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox("Open at Login", String(err.message || err));
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray) {
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
@@ -609,10 +525,7 @@ function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray)
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
|
||||
preload: path.join(__dirname, "preload.js") },
|
||||
});
|
||||
win.once("ready-to-show", () => { if (showOnReady) win.show(); });
|
||||
win.on("close", (event) => {
|
||||
if (!quitting && tray && loginItem.enabled()) { event.preventDefault(); win.hide(); }
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||
// External links open in the default browser; the app never navigates away.
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
@@ -666,8 +579,6 @@ function buildMenu() {
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(),
|
||||
enabled: app.isPackaged, click: toggleLoginItem },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
|
||||
{ type: "separator" },
|
||||
...(devices.length > 1 ? [{
|
||||
@@ -716,7 +627,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
|
||||
// Windows and Linux start a second instance with the link as an argument.
|
||||
app.on("second-instance", (_e, argv) => {
|
||||
if (!argv.includes("--hidden")) showWindow();
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
const link = linkFromArgv(argv);
|
||||
if (link) openInstallLink(link);
|
||||
});
|
||||
@@ -724,41 +635,12 @@ if (!app.requestSingleInstanceLock()) {
|
||||
if (firstLink) openInstallLink(firstLink);
|
||||
app.whenReady().then(() => {
|
||||
registerScheme();
|
||||
if (loginItem.enabled()) ensureTray();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
scheduleUpdateChecks();
|
||||
});
|
||||
app.on("activate", showWindow);
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
let obsQuitWaiting = false;
|
||||
let obsQuitReady = false;
|
||||
app.on("before-quit", (event) => {
|
||||
quitting = true;
|
||||
stopServer();
|
||||
if (!obsSource.child || obsQuitReady) { stopObs(); return; }
|
||||
event.preventDefault();
|
||||
if (obsQuitWaiting) return;
|
||||
obsQuitWaiting = true;
|
||||
const child = obsSource.child;
|
||||
let timer;
|
||||
child.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
obsQuitReady = true;
|
||||
app.quit();
|
||||
});
|
||||
stopObs();
|
||||
timer = setTimeout(() => {
|
||||
if (child.exitCode === null) {
|
||||
if (IS_WIN) {
|
||||
// Only the helper and its SSH child: never enumerate unrelated SSH jobs.
|
||||
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"],
|
||||
{ stdio: "ignore", windowsHide: true, detached: true }).unref();
|
||||
} else child.kill("SIGKILL");
|
||||
}
|
||||
obsQuitReady = true;
|
||||
app.quit();
|
||||
}, 8000);
|
||||
});
|
||||
process.on("exit", () => { stopObs(); stopServer(); });
|
||||
app.on("before-quit", () => { quitting = true; stopServer(); });
|
||||
process.on("exit", stopServer);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Own one Python relay. Async preparation can be cancelled before a process exists.
|
||||
const ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
function createObsSource({ prepare, spawn, onState = () => {}, supported = true }) {
|
||||
let child = null, attempt = 0;
|
||||
let state = { state: 'idle', alias: '', port: 15099, error: '', supported };
|
||||
const status = () => ({ ...state });
|
||||
function set(next, alias = state.alias, port = state.port, error = '') {
|
||||
state = { state: next, alias, port, error, supported };
|
||||
onState(status()); return status();
|
||||
}
|
||||
function stop() {
|
||||
++attempt;
|
||||
if (!child) return set('idle');
|
||||
set('stopping');
|
||||
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
||||
return status();
|
||||
}
|
||||
async function start(alias, port = 15099) {
|
||||
if (!supported) return status();
|
||||
if (!ALIAS.test(alias || '')) throw new Error('Select a valid headset first.');
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('OBS port must be a whole number from 1024 to 65535.');
|
||||
if (child || state.state === 'starting') return status();
|
||||
const id = ++attempt;
|
||||
set('starting', alias, port);
|
||||
try {
|
||||
const { executable, args = [], env } = await prepare(alias);
|
||||
if (id !== attempt) return status();
|
||||
const proc = spawn(executable, [...args, '--frame', alias, '--port', String(port), '--control-stdin'],
|
||||
{ env, stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
|
||||
child = proc;
|
||||
// The helper may exit between a click and closing its control pipe.
|
||||
proc.stdin.on('error', () => {});
|
||||
let output = '';
|
||||
proc.stderr.on('data', chunk => {
|
||||
output = (output + chunk.toString('utf8')).slice(-2048);
|
||||
if (child === proc && state.state === 'starting' && output.includes('Frame Control OBS stream is ready'))
|
||||
set('running', alias, port);
|
||||
});
|
||||
proc.once('error', error => {
|
||||
if (child !== proc) return;
|
||||
child = null; set('failed', alias, port, `Could not start OBS source: ${error.message}`);
|
||||
});
|
||||
// close follows stderr EOF, so the final failure message is available.
|
||||
proc.once('close', (code, signal) => {
|
||||
if (child !== proc) return;
|
||||
const stopping = state.state === 'stopping';
|
||||
child = null;
|
||||
set(stopping ? 'idle' : 'failed', alias, port,
|
||||
stopping ? '' : `OBS source stopped (${signal || code}). ${output.trim().split('\n').pop() || 'Check SteamVR and SSH.'}`);
|
||||
});
|
||||
return status();
|
||||
} catch (error) {
|
||||
if (id !== attempt) return status();
|
||||
return set('failed', alias, port, error.message);
|
||||
}
|
||||
}
|
||||
return { start, stop, status, get child() { return child; } };
|
||||
}
|
||||
module.exports = { createObsSource };
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.39",
|
||||
"version": "0.4.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.39",
|
||||
"version": "0.4.13",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.39",
|
||||
"version": "0.4.13",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -9,11 +9,10 @@
|
||||
"scripts": {
|
||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
|
||||
"dist:mac:x64": "FRAME_MAC_ARCH=x86_64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac x64 && electron-builder --mac --x64 --publish never"
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
@@ -37,12 +36,10 @@
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"obs-source.js",
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"login-item.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
@@ -56,8 +53,7 @@
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json",
|
||||
"framey-compatibility.json"
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -72,10 +68,7 @@
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"obs-headset-source.py",
|
||||
"frame_power_sample.py",
|
||||
"frame_power_report.py"
|
||||
"*.sh"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,15 +21,6 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
obs: {
|
||||
status: () => ipcRenderer.invoke("obs:status"),
|
||||
start: (port) => ipcRenderer.invoke("obs:start", port),
|
||||
stop: () => ipcRenderer.invoke("obs:stop"),
|
||||
onState: (cb) => {
|
||||
ipcRenderer.removeAllListeners("obs:state");
|
||||
ipcRenderer.on("obs:state", (_e, state) => cb(state));
|
||||
},
|
||||
},
|
||||
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||
onUseDevice: (cb) => {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createLoginItem } = require("../login-item");
|
||||
|
||||
test("Linux login toggle creates and removes only its own desktop entry", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-login-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root },
|
||||
executable: "/opt/Frame Control/frame-control" });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
const body = fs.readFileSync(login.file, "utf8");
|
||||
assert.match(body, /^# Managed by Frame Control;/);
|
||||
assert.match(body, /Exec="\/opt\/Frame Control\/frame-control" --hidden/);
|
||||
assert.equal(fs.statSync(login.file).mode & 0o777, 0o600);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.equal(fs.existsSync(login.file), false);
|
||||
fs.mkdirSync(path.dirname(login.file), { recursive: true });
|
||||
fs.writeFileSync(login.file, "[Desktop Entry]\nName=My custom launcher\n");
|
||||
assert.throws(() => login.set(true), /different autostart entry/);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /My custom launcher/);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("AppImage autostart launches the AppImage, with safe desktop escaping", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-appimage-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root,
|
||||
APPIMAGE: '/home/user/Frame Control 100%.AppImage' },
|
||||
executable: "/tmp/.mount_extracted/AppRun" });
|
||||
login.set(true);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /Exec="\/home\/user\/Frame Control 100%%\.AppImage" --hidden/);
|
||||
login.set(false);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
for (const platform of ["win32", "darwin"]) {
|
||||
test(`${platform} login toggle uses the OS API and starts hidden`, () => {
|
||||
let openAtLogin = false;
|
||||
const calls = [];
|
||||
const electronApp = {
|
||||
getLoginItemSettings: () => ({ openAtLogin }),
|
||||
setLoginItemSettings: opts => { calls.push(opts); openAtLogin = opts.openAtLogin; },
|
||||
};
|
||||
const login = createLoginItem({ platform, electronApp });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
assert.deepEqual(calls.at(-1).args, ["--hidden"]);
|
||||
assert.equal(calls.at(-1).openAsHidden, true);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.deepEqual(calls.at(-1).args, []);
|
||||
});
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createObsSource } = require('../obs-source');
|
||||
|
||||
function fixture(prepare) {
|
||||
const states = [], children = [];
|
||||
const source = createObsSource({
|
||||
prepare: prepare || (async () => ({ executable: 'python', args: ['helper.py'], env: {} })),
|
||||
spawn(executable, args, options) {
|
||||
const child = new EventEmitter();
|
||||
child.stderr = new EventEmitter(); child.stdin = new EventEmitter();
|
||||
child.stdin.end = () => { child.stdin.writableEnded = true; };
|
||||
children.push({ child, executable, args, options }); return child;
|
||||
},
|
||||
onState: s => states.push(s),
|
||||
});
|
||||
return { source, states, children };
|
||||
}
|
||||
|
||||
test('Stop during Python discovery cancels the pending start', async () => {
|
||||
let resolve;
|
||||
const f = fixture(() => new Promise(r => { resolve = r; }));
|
||||
const pending = f.source.start('frame', 15100);
|
||||
assert.equal(f.source.status().state, 'starting');
|
||||
f.source.stop();
|
||||
resolve({ executable: 'python', args: ['helper.py'], env: {} });
|
||||
await pending;
|
||||
assert.equal(f.children.length, 0);
|
||||
assert.equal(f.source.status().state, 'idle');
|
||||
});
|
||||
|
||||
test('late stderr cannot undo Stop; closing allows a fresh start on another port', async () => {
|
||||
const f = fixture(); await f.source.start('frame', 15100);
|
||||
const { child, args } = f.children[0];
|
||||
assert.deepEqual(args.slice(-5), ['--frame', 'frame', '--port', '15100', '--control-stdin']);
|
||||
child.stderr.emit('data', Buffer.from('Frame Control OBS stream is '));
|
||||
child.stderr.emit('data', Buffer.from('ready\n'));
|
||||
assert.equal(f.source.status().state, 'running');
|
||||
f.source.stop();
|
||||
child.stderr.emit('data', Buffer.from('last ffmpeg warning\n'));
|
||||
assert.equal(f.source.status().state, 'stopping');
|
||||
child.emit('close', 0, null);
|
||||
assert.equal(f.source.status().state, 'idle');
|
||||
await f.source.start('other-frame', 15101);
|
||||
assert.equal(f.source.status().port, 15101);
|
||||
// An old child cannot overwrite the replacement's state.
|
||||
child.emit('close', 255, null);
|
||||
assert.equal(f.source.status().state, 'starting');
|
||||
});
|
||||
|
||||
test('concurrent starts spawn once and preparation failures are retryable', async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture(async () => {
|
||||
if (++attempts === 1) throw new Error('Python missing');
|
||||
return { executable: 'python', args: [], env: {} };
|
||||
});
|
||||
assert.equal((await f.source.start('frame')).state, 'failed');
|
||||
assert.match(f.source.status().error, /Python missing/);
|
||||
await Promise.all([f.source.start('frame'), f.source.start('frame')]);
|
||||
assert.equal(f.children.length, 1);
|
||||
});
|
||||
|
||||
test('invalid ports and aliases cannot spawn; errors and closed pipes remain visible', async () => {
|
||||
const f = fixture();
|
||||
for (const port of [0, 1023, 65536, 15099.5, '15100']) {
|
||||
await assert.rejects(f.source.start('frame', port), /port/i);
|
||||
}
|
||||
await assert.rejects(f.source.start('-bad'), /headset/i);
|
||||
assert.equal(f.children.length, 0);
|
||||
await f.source.start('frame');
|
||||
const { child } = f.children[0];
|
||||
child.stdin.emit('error', Object.assign(new Error('closed pipe'), { code: 'EPIPE' }));
|
||||
child.stderr.emit('data', Buffer.from('network lost\n'));
|
||||
child.emit('close', 255, null);
|
||||
assert.equal(f.source.status().state, 'failed');
|
||||
assert.match(f.source.status().error, /network lost/);
|
||||
});
|
||||
|
||||
test('an unverified host hides the controls and cannot spawn a relay', async () => {
|
||||
let spawned = false;
|
||||
const source = createObsSource({ supported: false, prepare: async () => ({}), spawn: () => { spawned = true; } });
|
||||
assert.equal(source.status().supported, false);
|
||||
await source.start('frame');
|
||||
assert.equal(spawned, false);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ A SideQuest backup can contain a small APK and much larger `main.…obb` and
|
||||
`patch.…obb` files. All of them belong to the same game. Installing only the APK
|
||||
can leave the game unable to start because its assets are missing.
|
||||
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the base APK, any supported splits, and its OBB files together.
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
|
||||
You can also drop them anywhere in the window. In the desktop app, dropping the
|
||||
APK alone finds matching OBB files beside it; dropping a folder containing the
|
||||
APK and OBBs also works. Browsers can read only the files you explicitly choose.
|
||||
@@ -37,13 +37,9 @@ are discarded after use or cancellation; abandoned imports expire after an hour
|
||||
when the importer is next used, and dead-server staging folders are swept at
|
||||
startup.
|
||||
|
||||
This flow handles one base APK, supported native-library or asset splits, and
|
||||
standard OBB files for one game at a time. It checks the package and version in
|
||||
each split manifest before touching the headset. The existing Quest merger
|
||||
rejects resource, code and feature splits and sets with multiple device
|
||||
variants. A merged APK is re-signed; that can affect a game's licence or login.
|
||||
XAPK/APKS/APKM archives use the same checks. Nested Android/data folders and
|
||||
private save archives remain unsupported. PC ZIP/folder/EXE imports keep their
|
||||
This flow handles one ordinary APK and standard OBB files for one game at a time.
|
||||
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
|
||||
archives need their own import handling. PC ZIP/folder/EXE imports keep their
|
||||
existing route. Quest USB and Meta library imports keep their existing workflow.
|
||||
|
||||
Validation covers actual APK parsing, the three-file backup layout, package
|
||||
@@ -59,5 +55,3 @@ released, and the battery remained at 99%. See [device evidence](evidence/androi
|
||||
The headset was unworn; this checks transfer and persistence, not rendering,
|
||||
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
|
||||
flow does not change APK compatibility or remove licence checks.
|
||||
The supported-split backup route was checked with generated local fixtures and
|
||||
existing merger tests, not a real split game on the Frame.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Frame Control for Android phones
|
||||
|
||||
The native Android companion lives in `android/`. It requires Android 10 or
|
||||
later and a Steam Frame with Developer Mode and SSH enabled. This is a new
|
||||
implementation, separate from the headset's `frame/android` Lepton tools.
|
||||
|
||||
## Pair and sync
|
||||
|
||||
1. Enter the headset address, SSH port and username (normally `steamos`).
|
||||
2. Choose **Pair headset**, compare the displayed SSH fingerprint with the
|
||||
headset's identity, and accept it. Enter the Developer Mode password once.
|
||||
The app installs this phone's SSH public key and verifies key login before
|
||||
storing the pairing. The private key stays in Android Keystore; the
|
||||
password is not saved. A changed headset host key is refused.
|
||||
3. Choose **Start syncing** and allow notifications. The persistent notification
|
||||
shows the active connected-device session and offers **Stop syncing**.
|
||||
4. Take a new screenshot. Its original downloads into Frame Control. **Save
|
||||
screenshots to Gallery** is enabled by default and adds it to
|
||||
`Pictures/Frame Control`. Android 10+ lets an app create its own Gallery
|
||||
items without broad photo-reading permission.
|
||||
|
||||
The first successful listing establishes a history baseline. Existing screenshots
|
||||
are not imported automatically. The baseline is kept per Steam account: the app
|
||||
also lists the headset's account folders, so an account that is present (even
|
||||
with no screenshots yet) is established, and its later captures download. If
|
||||
Steam's userdata folder is missing, or an account folder appears later (a new
|
||||
sign-in, or one removed and restored), that account's screenshots at that moment
|
||||
become its baseline instead of being imported. The status reads "Waiting for a
|
||||
Steam account on the headset" while no account exists.
|
||||
|
||||
The app waits for two equal size/time listings, checks the original's byte count
|
||||
and image decode, and saves it atomically. Identity includes the paired headset,
|
||||
Steam account, game and filename. An empty (still being written) or over-30 MB
|
||||
file is skipped for that poll only and does not hold up other screenshots; the
|
||||
status shows how many were skipped. A capture still being written when the
|
||||
baseline is taken downloads once it is complete. Unsafe identifiers, non-finite
|
||||
times and conflicting duplicate rows still reject the whole listing. The receipt
|
||||
journal is rewritten only when a receipt changes, not on every poll.
|
||||
|
||||
Turning Gallery off keeps automatic downloads inside Frame Control. Pending
|
||||
exports become app-only; enabling it again does not export those earlier items.
|
||||
Turning it off also removes this app's hidden, unfinished Gallery rows left by an
|
||||
interrupted export, for every retained headset library (best effort; retried
|
||||
when sync starts with Gallery off). Published Gallery images are never removed.
|
||||
Previously saved Gallery items stay there. Original files remain available from
|
||||
**Open / share original** when disconnected. The app keeps each headset's
|
||||
library separately.
|
||||
|
||||
Gallery export uses a deterministic app-owned MediaStore row. A pending row is
|
||||
rewritten after interruption, and a completed row is recognized before inserting
|
||||
another. A failed export leaves the app original available for retry. Preferences
|
||||
and the final Gallery commit are serialized so a disabled setting is respected.
|
||||
Gallery exports are also serialized across sync sessions, so an export still
|
||||
finishing after **Stop syncing** cannot remove or overwrite an image that a newer
|
||||
session saves.
|
||||
|
||||
**Headset controls** opens the shared Frame Control web interface through a
|
||||
loopback-only SSH tunnel. The server has a fresh session key and listens only on
|
||||
the headset's loopback interface. The tunnel and server end with the phone's
|
||||
SSH session. This initial Android shell does not implement all iPhone-specific
|
||||
web bridges; native screenshot originals use the downloads screen.
|
||||
|
||||
## Background behavior and release boundary
|
||||
|
||||
Sync is an explicit, user-started Android `connectedDevice` foreground service.
|
||||
It can continue after leaving the app while its notification is present and
|
||||
reconnects when the headset returns. It is not a Firebase push receiver.
|
||||
Android battery-saving/Doze, a locked phone with its screen off, loss of
|
||||
network, force-stop or process termination can delay or end the session. The app
|
||||
holds no wake lock; downloads may wait until the phone wakes. The app does not request battery-optimization
|
||||
exemption, use unrelated background modes, or restart itself after force-stop.
|
||||
Restart sync from the app if the session ends.
|
||||
|
||||
The full cross-platform request still requires actual locked-phone background
|
||||
proof. iOS APNs delivery is a separate unimplemented dependency. An emulator
|
||||
test is not evidence that either physical platform meets that requirement.
|
||||
Stopping sync ends that session's status and notification; a transfer still
|
||||
finishing in the background cannot bring them back or change a newer session.
|
||||
Do not publish this as a verified always-on screenshot feature.
|
||||
|
||||
## Build and verify
|
||||
|
||||
Use JDK 17 and an Android SDK containing platform 36/build-tools 36.0.0:
|
||||
|
||||
```sh
|
||||
export JAVA_HOME=/path/to/jdk17
|
||||
export ANDROID_HOME=/path/to/android-sdk
|
||||
./android/gradlew -p android assembleDebug testDebugUnitTest assembleDebugAndroidTest lintDebug
|
||||
```
|
||||
|
||||
The Gradle wrapper pins version 9.4.1 and its distribution checksum. The build
|
||||
generates the same reproducible headset bundle as iOS, including the existing
|
||||
SHA-256-checked KDE Connect package fetch. Generated assets and build outputs
|
||||
are ignored. No signing key, headset password or machine-specific SDK path is
|
||||
committed. `assembleDebug` produces `android/app/build/outputs/apk/debug/app-debug.apk`.
|
||||
It uses a development signing identity, not a configured Play release key.
|
||||
|
||||
`assembleDebugAndroidTest` builds instrumentation for real MediaStore round-trip,
|
||||
retry recovery and app-only behavior. Optional real-headset hooks exist only in
|
||||
the separate test APK (`ScreenshotDeviceTest`), not in the product app. They
|
||||
provision a public test key and permit original/Gallery hash readback without
|
||||
adding a product authentication bypass. Remove the temporary headset key and
|
||||
fixtures after live tests.
|
||||
|
||||
No Play app creation, store upload, invitation or public release is part of a
|
||||
debug build. Preserve a dedicated release signing identity and verify physical
|
||||
devices before a production release.
|
||||
@@ -436,13 +436,13 @@ permissions remain unverified. They do not change APK install or launch behavior
|
||||
## Local APK/OBB backups and Android bundles
|
||||
|
||||
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
|
||||
base APK, any supported splits, and its matching `main.<version>.<package>.obb` and/or
|
||||
standalone APK and its matching `main.<version>.<package>.obb` and/or
|
||||
`patch.<version>.<package>.obb` files together. The desktop app also discovers
|
||||
matching OBB files beside a lone selected APK. Browser uploads use only the
|
||||
files you selected. Installed apps can receive OBB-only imports.
|
||||
|
||||
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
|
||||
one base APK, optional supported splits and matching OBBs. These extensions describe
|
||||
one standalone APK and optional matching OBBs. These extensions describe
|
||||
containers, not a guarantee that their contents are supported. OBBs may be
|
||||
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
|
||||
one enclosing backup folder is allowed. Known manifest/icon/signature
|
||||
@@ -450,7 +450,7 @@ sidecars are ignored; the APK manifest determines the package. Unknown files,
|
||||
unsafe paths, links, encrypted entries, duplicate paths (including case
|
||||
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
|
||||
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
|
||||
100-file/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
extract an Android ZIP yourself and select its APK/OBBs.
|
||||
|
||||
The importer previews the app and files before **Import game & data**.
|
||||
@@ -463,14 +463,12 @@ data setup. Uploaded and extracted staging is removed on cancellation,
|
||||
inspection failure, or job completion/failure; abandoned imports expire
|
||||
through the existing one-hour staging lifecycle. Original local files stay intact.
|
||||
|
||||
**Limits:** this backup importer requires exactly one base APK. For split sets,
|
||||
each split must identify the same package and version, and pass the existing
|
||||
[Quest split merger](quest-import.md#split-apks). It refuses resource, code and
|
||||
feature splits and does not choose among device variants. Merging re-signs the
|
||||
APK, which can affect licences. It does not restore `Android/data`, private app
|
||||
data, saves or entitlements. These split imports have local fixture and merger
|
||||
tests, but no real split game has been installed with this backup route on the
|
||||
Frame.
|
||||
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
|
||||
bases marked as requiring splits. It does not select device variants or restore
|
||||
`Android/data`, private app data, saves, or entitlements. For the separate
|
||||
restricted native-library/asset split workflow see
|
||||
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
|
||||
code and feature splits are not supported by that merger.
|
||||
|
||||
For issue #102, the RE4 report only establishes that an APK attempt failed.
|
||||
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
|
||||
|
||||
@@ -12,41 +12,15 @@ with no sign-in files or keys in the archives. Restoring one game's saves on top
|
||||
of identical files wrote nothing and left nothing behind. The session check
|
||||
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
|
||||
headset: a restore after a real factory reset, a restore that replaces files,
|
||||
Android data restore. Unit tests and the fake Frame cover those (see
|
||||
[Verification](#verification)).
|
||||
On 2026-10-24, the real-device reproduction in [issue #142](https://github.com/saphid/frame-control/issues/142)
|
||||
confirmed full Plasma Desktop Mode on the same build: the Plasma session target
|
||||
was active and the Game Mode target inactive. Desktop Mode also ran gamescope
|
||||
for `steamvr-plasma.service`, so the old process check incorrectly reported
|
||||
Game Mode. SteamOS's `steamosctl switch-to-game-mode` restored Game Mode and
|
||||
reversed the target states; battery stayed at 99%, and the rollback timer and
|
||||
shared lock were cleaned up. After #143 passed CI, the corrected code was
|
||||
tested in another full Plasma session under a three-minute rollback timer.
|
||||
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
|
||||
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
|
||||
the button. Game Mode became active and a second call made no switch. Battery
|
||||
remained at 99%; the timer and lock were removed. That test used the production
|
||||
CLI rather than clicking the desktop button.
|
||||
|
||||
Later on 2026-10-24, the published **v0.4.17 Linux x86_64 AppImage** passed the
|
||||
literal packaged-button check on the same Frame/build. With the shared lock and
|
||||
a verified three-minute rollback timer, the headset entered full Plasma Desktop
|
||||
Mode. In the packaged Electron window, **Tools → Backup & recovery → Check**
|
||||
reported Desktop Mode despite gamescope. A trusted automated pointer click on
|
||||
the visible **Return to Game Mode** button, accepting its confirmation, returned
|
||||
`switched: true` and `steamosctl switch-to-game-mode`. The systemd targets then
|
||||
confirmed Game Mode. A second confirmed button click returned `switched: false`;
|
||||
the Game Mode service invocation and SteamVR PID stayed unchanged. **Check**
|
||||
then reported Game Mode and disabled Return. The test app/server and virtual
|
||||
display were stopped; the timer and shared lock were removed. Steam and SteamVR remained running
|
||||
at 99% battery. [Commands, responses, cleanup and limits](evidence/return-game-mode-packaged-2026-10-24.json),
|
||||
[before](evidence/return-game-mode-packaged-2026-10-24-before.png) and
|
||||
[after](evidence/return-game-mode-packaged-2026-10-24-after.png).
|
||||
|
||||
This used the unchanged extracted AppImage payload with its bundled Python on
|
||||
Xvfb (`--no-sandbox --disable-gpu`), following the packaged Export test in #156.
|
||||
FUSE mounting, desktop integration, hardware rendering and the view inside a
|
||||
worn headset were not checked. Older SteamOS builds remain fixture-tested.
|
||||
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.
|
||||
|
||||
## Back up
|
||||
|
||||
@@ -100,21 +74,7 @@ copy one by hand if you need it.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links, including any ancestor below the home folder of a selected
|
||||
save or settings path, and single files over 2 GiB. Discovery checks each
|
||||
component, and packing checks it again before opening each file.
|
||||
|
||||
Steam backups use the normal `~/.local/share/Steam` directory tree. A separate
|
||||
`~/.steam/steam` alias does not affect this route. A library, Proton prefix or
|
||||
Wine save folder redirected by a symlink below the home folder is skipped,
|
||||
even if its target is inside home; it is not resolved and backed up elsewhere.
|
||||
The home folder itself is trusted and may use the host's normal path aliases.
|
||||
|
||||
These are path checks, not a filesystem snapshot: a concurrent rename or link
|
||||
replacement between a check and a read can still race it. Close games and
|
||||
avoid moving or relinking these folders during backup. This fix rejects links
|
||||
already present when checked, including ones introduced after discovery; it
|
||||
does not provide atomic protection against concurrent filesystem changes.
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
@@ -158,28 +118,29 @@ normally runs *inside* it as one panel (`steamos-nested-desktop`, see
|
||||
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
|
||||
is what you see, you're already in Game Mode: close it from the Steam dashboard.
|
||||
|
||||
Full Desktop Mode is a different session. **Check** reads the main user's
|
||||
systemd session targets over SSH: `gamescope-session.target` and
|
||||
`plasma-workspace-wayland.target`. Game Mode requires the former active and the
|
||||
latter inactive; Desktop Mode requires the reverse. This takes precedence over
|
||||
process names: full Desktop Mode can run gamescope for its VR overlay, and
|
||||
Game Mode can run a nested Plasma panel. Check changes nothing.
|
||||
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)).
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
@@ -190,9 +151,7 @@ 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 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`.
|
||||
`ssh frame steamos-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
|
||||
@@ -206,23 +165,10 @@ If the button can't help, or the headset isn't reachable:
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links in save/settings roots and their ancestors refused, the manifest
|
||||
and its checksums, restore checks
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
Synthetic symlink fixtures cover every component of a Proton save root below
|
||||
home, including `compatdata/<appid>` and `steamuser/AppData`, plus directory
|
||||
and file links introduced between discovery and packing. Ordinary Proton and
|
||||
non-Steam saves, Linux saves, controller layouts, reference settings and home
|
||||
aliases still pack with their original bytes. These are local fixture checks;
|
||||
this path fix has not been tested on a headset.
|
||||
`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`.
|
||||
@@ -9,11 +9,6 @@ only control the Frame has is a vendor charger attribute that Valve's own
|
||||
charging daemon also manages. Research as of 2026-10-23, confirmed with the
|
||||
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
|
||||
|
||||
Rechecked read-only on build `20261022.6153909` on 2026-10-25: the
|
||||
`BatteryChargeLimit1` interface still returned `UnknownInterface`, no
|
||||
`charge_control_*` sysfs attribute existed, and the vendor charging switches
|
||||
were root-owned mode `644`. See [Round 3 headset evidence](evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
|
||||
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
|
||||
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Connection and streaming lag FAQ
|
||||
|
||||
## Does Frame Control lower my streaming lag?
|
||||
|
||||
No. Frame Control uses SSH to manage the Frame. SteamVR or Steam Link sends the
|
||||
game stream. Frame Control does not carry the game stream. It cannot lower your
|
||||
game-stream ping or fix a stream that stops on the PC. A 40–75 ms ping in a VR
|
||||
game is not a Frame Control measurement.
|
||||
|
||||
You do **not** need the Steam Wireless Adapter to use Frame Control. The app can
|
||||
connect over Wi-Fi, the Wireless Adapter, or USB if the computer has an SSH
|
||||
route to the Frame. Use the route that lets the app connect. To improve game
|
||||
streaming, check the SteamVR or Steam Link connection on the PC and the Frame.
|
||||
The [wired connection doctor](wired-connection.md) checks only Frame Control's
|
||||
SSH route. A successful doctor check does not prove that the game stream uses
|
||||
the cable or has low lag.
|
||||
|
||||
## Can I use a USB-A-to-C cable?
|
||||
|
||||
A Mac reached the Frame over a USB-C connection on the
|
||||
`10.86.200.232/29` USB network. That test used a USB-C host port. We have
|
||||
**not tested a USB-A-to-C cable** with the Frame. A data-capable USB-A-to-C
|
||||
cable might show a USB network interface on your PC, but this is unknown on
|
||||
the tested Frame OS and PC hosts. Check whether your PC shows that interface
|
||||
and whether Frame Control's wired doctor can reach the Frame at
|
||||
`10.86.200.233`. A charge-only cable cannot carry USB data. You can use Wi-Fi
|
||||
for Frame Control if the USB route does not work.
|
||||
|
||||
## 日本語: 5 ステップで接続
|
||||
|
||||
1. [公式リリース](https://github.com/saphid/frame-control/releases/latest)から PC の OS 用ファイルをダウンロードします。Windows は `.exe`、macOS は `.dmg`、Linux は `.AppImage` または `.deb` です。同じリリースの `SHA256SUMS` でファイルを確認してください。
|
||||
2. PC に Frame Control をインストールして起動します。Windows はスタートメニュー、macOS はアプリケーション、Linux はアプリ一覧から開きます。AppImage の場合は実行権限を付けて開きます。
|
||||
3. Frame の Steam 設定 → システムで開発者モードを有効にし、開発者設定でユーザーパスワードを設定します。Frame と PC を同じ信頼できるネットワークに接続し、Frame を起こしておきます。
|
||||
4. PC の Frame Control で **Set Up Connection…** を選びます。Frame が見つからない場合は、Frame の設定で IP アドレスを確認して入力します。macOS では **Devices & connection → + Add a headset…** からアドレスを設定できます。
|
||||
5. Frame に **Pair new host** が出たら、自分が開始した要求だけを承認します。ペアリングに失敗した場合は、PC のターミナルで開発者モードのパスワードを入力します。接続後、Frame Control に戻って状態を確認します。
|
||||
|
||||
Frame Control は管理用の SSH 接続を使います。ゲーム映像は SteamVR / Steam Link が送ります。Frame Control を入れてもゲームの ping は下がりません。無線アダプターは必須ではありません。USB-A-to-C ケーブルで Frame の USB ネットワークが使えるかは、まだ確認できていません。
|
||||
@@ -1,30 +0,0 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.39**, 26 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG verified, installed and launched. Native UI, preference persistence across restart (original file restored), bundled Python logs, update check, offline OBS Start/Stop and 116-entry Decky store checked. A 40-byte relay upload/readback/delete passed under coordinator/headset locks | No | No quarantined Safari/Chrome download or Gatekeeper override test; native Open at Login/menu automation unavailable. Direct frame.local had no route and the app refused a stale SSH identity. Relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Quit exposed a server cleanup race; fix #284 has physical-Mac regression coverage, not a released fix | 26 Oct, official v0.4.39 tag `fd0b0a9`: [Intel release evidence](evidence/mac-v0439-2026-10-26.md); [earlier ARM64 failure](evidence/mac-desktop-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 installed and removed reserved native/Proton titles on the real Frame; all 13 steps passed. ARM64 and Windows titles launched | No | The x86_64 title could not launch because Steam Linux Runtime 4.0 was not installed. The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, v0.4.38 exact tag `d87b863`, Frame OS 0.5.6 build `20261022.6153909`: [latest release smoke](evidence/v0438-release-smoke-2026-10-26.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30.0.2 on a Linux host: 187.367 s, 720p30 H.264/AAC stereo, 5,621 frames, no encoder skips; room passthrough appeared in the recording | No | Synthetic tone was measured, not real game audio or audible listening. Default audio led by about 0.43 s in this setup; a measured +480 ms OBS offset removed residual skew at test resolution. Source-image latency was 1.431 s in one trial; other hosts and long sessions are unverified | 25 Oct, v0.4.30 package plus PR #219 runtime: [validation](evidence/obs-validation-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | Windows 11 VM helper fixture passed, but a native bridge to the real Frame sent no packets | In-app OBS controls remain hidden; native Windows OBS and firewall path are unverified | 25 Oct, PR #219: [validation](evidence/obs-validation-2026-10-25.md), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | Physical Intel Air: official v0.4.39 native Start/Stop returned to idle offline | No | Real headset stream, OBS receiver and audio/video remain unverified; the app rejected its stale SSH identity | 26 Oct, v0.4.39: [Mac evidence](evidence/mac-v0439-2026-10-26.md), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
|
||||
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
|
||||
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
|
||||
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
|
||||
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.39: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [new standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. The exact-version official catalog covers 116 plugins: 1 works (Notebook default flow only), 45 partial, 59 broken and 11 not applicable. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Three official audio plugins had local real-backend controls and independent virtual-audio readbacks, but their ARM64 Frame audio sessions and physical output remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.39: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; 98 local Chromium initializers were ready and 18 failed. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.39: [latest unworn sample evidence](evidence/framey-vr-standby-gate-2026-10-26.md), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [worn checklist](framey-vr-check.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -1,237 +0,0 @@
|
||||
# Decky Loader on Steam Frame
|
||||
|
||||
For the modding-path survey, licence review and update limits, see
|
||||
[plugin and modding research](plugins-research.md).
|
||||
|
||||
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
|
||||
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
|
||||
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
|
||||
verified. Frame Control therefore labels its user-service manager experimental.
|
||||
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
|
||||
VR user interface has not been seen or used.
|
||||
|
||||
## Official store plugin audit, 2026-10-25
|
||||
|
||||
On a real Frame with Frame OS 0.5.6 build `20261022.6153909`, SteamVR 2.18.2
|
||||
reported `activity_level` 3 (standby). The battery was 99% before and after.
|
||||
The test downloaded the official store's current archives, checked each SHA-256
|
||||
against store metadata, and inspected their manifests and binaries. For seven
|
||||
non-root candidates, the removable user-service loader started each plugin in
|
||||
turn. It stopped between plugins. Logs showed `Loaded <name>` for all seven.
|
||||
The service and test folders were removed after the run; the owned shared lock
|
||||
was released. **A load message is not proof that a plugin's page, settings, or
|
||||
action works in VR.** No plugin was tried by a wearer.
|
||||
|
||||
| Official store plugin (tested version) | Result | Evidence and limit |
|
||||
| --- | --- | --- |
|
||||
| ProtonDB Badges 1.3.4 | Partial | ARM64 backend loaded. Earlier 1.1.0 also registered in the shared Gamepad UI; badges in the VR shell were not seen. |
|
||||
| CSS Loader 2.1.3 | Partial | Backend loaded; one `SyntaxWarning`. Theme controls and CSS application in VR were not checked. |
|
||||
| SteamGridDB 1.8.1 | Partial | Backend loaded. Artwork selection and its UI were not checked. |
|
||||
| Animation Changer 1.3.2 | Partial | Backend loaded. Boot animation replacement was not attempted. |
|
||||
| Storage Cleaner 1.4.0 | Partial | Backend loaded. No files were cleaned; no UI check. |
|
||||
| HLTB for Deck 2.0.12 | Partial | Passive plugin loaded. Game page content in VR was not checked. |
|
||||
| Bluetooth 3.1.1 | Partial | Backend logged `Bluetooth plugin loaded`. Pairing and VR controls were not checked. |
|
||||
| PowerTools 2.0.3 | Not applicable | Manifest asks for root; archive includes x86_64 Ryzen binaries and targets Steam Deck hardware. The Frame user service blocks it. |
|
||||
| Audio Loader 1.6.1 | Not applicable | Manifest asks for `_root`; the Frame user service blocks it. |
|
||||
| Wine Cellar 0.2.1 | Broken on ARM64 | Archive includes an x86_64 backend binary. The store installer blocks it before execution. |
|
||||
| MagicPods 2.0.21 | Broken on ARM64 | Archive includes an x86_64 `magicpodscore` binary. The store installer blocks it before execution. |
|
||||
| Decky Recorder | Not in current official store | No matching plugin appeared in the official store list on this date; no archive was installed. Frame Control's own headset panel has a separate recorder. |
|
||||
|
||||
The table reflects the tested versions only. Other releases can change their
|
||||
files or behavior. The official store's digest checks the archive in transit;
|
||||
it does not certify plugin code. Plugins run with the headset user's access.
|
||||
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
|
||||
|
||||
### Which of these plugins can open a Framey panel?
|
||||
|
||||
The separate Framey panel accepts a Frame Control store install with a supported
|
||||
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
|
||||
A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
|
||||
catalogue. All 11 had a frontend bundle, but their manifests differed:
|
||||
|
||||
| Manifest in the checked archive | Plugins | Framey panel result |
|
||||
| --- | --- | --- |
|
||||
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
|
||||
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
|
||||
|
||||
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
|
||||
has each store ID, exact version, SHA-256, manifest field, and bundle check.
|
||||
The archive audit itself is static PC inspection. Later local host and fake
|
||||
Frame tests cover the Storage Cleaner, HLTB, CSS Loader and SteamGridDB cases noted above; none shows
|
||||
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
|
||||
remain the only device evidence for these versions. A new store version needs a
|
||||
new check because its manifest and binaries can change.
|
||||
|
||||
Pinned CSS Loader 2.1.3 and SteamGridDB 1.8.1 archives now pass the fake Frame
|
||||
managed install/remove flow. CSS Loader's `plugin.json` has no `api_version` and
|
||||
its older IIFE bundle passes the Framey packaging check added in
|
||||
[#261](https://github.com/saphid/frame-control/pull/261). The fixture does not
|
||||
render its theme controls. SteamGridDB declares API v1 and its bundle passes Framey's packaging
|
||||
check, but it looks up Steam library modules while loading and patches game
|
||||
context menus. Those modules and menus do not exist in Framey's isolated
|
||||
iframe, so its artwork picker cannot be called a usable VR panel. Framey does
|
||||
not apply its Steam library changes. These checks did not run CSS themes or
|
||||
modify game artwork on the real Frame.
|
||||
|
||||
The new store transaction was also exercised on the same real Frame:
|
||||
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
|
||||
store update → store remove → managed loader remove**. Status reported the
|
||||
managed store ID and version while the plugin was installed. The service
|
||||
remained active after each store change. The battery was 99% before and after.
|
||||
The test removed its plugin folder, loader, and owned lock. This validates the
|
||||
store file and service path over SSH, not a visible VR plugin page.
|
||||
|
||||
Frame Control's desktop app and opt-in headset panel now show official store
|
||||
entries. Install, update, and remove need an explicit confirmation. They only
|
||||
change Frame Control owned plugin folders under
|
||||
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
|
||||
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
|
||||
back an update if the service restart fails. An update is also refused if its
|
||||
new plugin name is already used by another installed plugin, because Decky
|
||||
routes backend calls by name. This collision check has local tests only.
|
||||
For a managed plugin with a valid recorded SHA-256, Update and Remove recheck
|
||||
that installed receipt under the headset transaction lock. If another store
|
||||
action changed it after the list was read, refresh the installed list and
|
||||
review the action again. This race check has local transaction tests; older
|
||||
receipts without a valid hash still use the existing ownership check.
|
||||
The current VR shell does not provide Decky's Steam Deck Quick Access Menu.
|
||||
Frame Control's headset panel
|
||||
shows installed plugins and store actions. Its separate Framey iframe hosts
|
||||
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
|
||||
with settings, local routes, and panel toasts where supported.
|
||||
Steam route patches and system notifications remain unavailable. The panel
|
||||
still needs a worn-headset visual and controller check. See the
|
||||
[current compatibility table](frame-plugin-author.md#framey-decky-api-compatibility).
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
The headset was SteamOS `0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`,
|
||||
`aarch64` (SteamOS 3.9.x family). SSH reached it through the existing `frame`
|
||||
alias. `/` was mounted read-write as Btrfs; no root filesystem change was
|
||||
needed. `systemctl --user` was running. The Steam client launched with
|
||||
`-cef-enable-debugging`, `-vrgamepadui`, and `-steamdeck`; its web helper served
|
||||
CEF remote debugging on `127.0.0.1:8080`. The usual
|
||||
`.cef-enable-remote-debugging` marker was absent, yet debugging was active.
|
||||
|
||||
CEF listed a `SharedJSContext` target at `steamloopback.host/routes/library/home`
|
||||
and a separate `SteamVR` target for the VR overlay. The Decky 3.3.0 ARM64
|
||||
binary started from `/tmp` as the normal user: port `127.0.0.1:1337` answered
|
||||
HTTP 200, the log reported `Loading Decky frontend!`, and CEF showed
|
||||
`window.deckyHasLoaded === true` and one registered Decky tab in
|
||||
`SharedJSContext`. The `SteamVR` target had neither that global nor a Decky tab;
|
||||
its current body text did not contain Decky. The Steam web helper had
|
||||
`--disable-quick-menu`. This is evidence of backend and shared-context
|
||||
injection, not proof of an accessible Decky plugin menu inside VR. A QAM
|
||||
interaction in the headset and a plugin UI test remain necessary.
|
||||
|
||||
Frame Control's per-user manager was then exercised on the real Frame:
|
||||
**install → status → update → status → remove → status**. The service became
|
||||
active, the binary matched Decky's published SHA-256, and removal left no
|
||||
service, binary or listening port 1337. Decky's already injected JavaScript
|
||||
remained in Steam's running web helper until that helper was restarted. After a
|
||||
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
|
||||
temporary trial files and the test's empty managed data folder were removed.
|
||||
|
||||
## ProtonDB Badges compatibility test
|
||||
|
||||
The [ProtonDB Badges v1.1.0 release](https://github.com/OMGDuke/protondb-decky/releases/tag/v1.1.0)
|
||||
was tested on the same Frame OS build. It is a popular, passive Decky plugin
|
||||
(133 GitHub stars at test time) that adds badges to game pages. The published
|
||||
archive SHA-256 was
|
||||
`b4c72ff0ffe60b1a14abafe75a62f644cbb6689f398a753a1905c281151d9173`.
|
||||
Its six archive entries had no links or path traversal, and its manifest declared
|
||||
`flags: []` and `api_version: 2`. The release contains a frontend bundle and
|
||||
no backend `main.py`; its source backend only manages its own settings.
|
||||
|
||||
The test copied the archive into Frame Control's managed Decky plugin folder,
|
||||
then installed the official Decky 3.3.0 ARM64 binary as a user service. Decky's
|
||||
log said `Plugin ProtonDB Badges is passive` and `Loaded ProtonDB Badges`.
|
||||
`DeckyPluginLoader.plugins` in Steam's `SharedJSContext` contained
|
||||
`ProtonDB Badges`; `deckyHasLoaded` was true there. The separate `SteamVR`
|
||||
CEF target had no Decky globals or plugin text, and SteamVR listed no Decky
|
||||
overlay. The headset was in standby, so no in-headset plugin page or badge
|
||||
interaction was verified. The backend's unauthenticated root URL returned
|
||||
HTTP 403, as expected.
|
||||
|
||||
The test removed the managed Decky service and binary, the test plugin folder,
|
||||
and its shared-device lock. The service was no longer running, the plugin list
|
||||
was empty, the binary and unit were gone, and port 1337 was closed. Decky's
|
||||
already injected frontend may remain in the running Steam web helper until
|
||||
Steam normally restarts; the test did not restart Steam or SteamVR.
|
||||
|
||||
## Why the official Decky installer is not used
|
||||
|
||||
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
|
||||
selects `PluginLoader-arm64` on `aarch64`, creates `~/homebrew/plugins`, writes
|
||||
the CEF marker, installs `/etc/systemd/system/plugin_loader.service`, and uses
|
||||
`sudo systemctl` to enable a **root** service. Its
|
||||
[uninstaller](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/uninstall.sh)
|
||||
also uses sudo. These scripts do not call `steamos-readonly`, but they do write
|
||||
under `/etc`; their root service has broad control over the headset. The
|
||||
current [Decky loader](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/backend/decky_loader/injector.py)
|
||||
targets Steam's shared Gamepad UI CEF page, injects a frontend served on port
|
||||
1337, and patches the standard Quick Access Menu. On Frame, Steam supplies
|
||||
the CEF target, while visible VR shell integration is unresolved.
|
||||
|
||||
Frame Control downloads only Decky's official latest ARM64 release, requires
|
||||
its published SHA-256 digest, and checks the ELF architecture. It installs a
|
||||
**user** service named `frame-control-decky.service` in
|
||||
`~/.config/systemd/user`, with the binary and plugin folder under
|
||||
`~/.local/share/frame-control/decky`. It never asks for a sudo password, edits
|
||||
`/etc`, or changes CEF flags. Decky warns that running without root is not
|
||||
officially supported. Plugins that require root, assume `~/homebrew`, or rely
|
||||
on Steam Deck hardware may fail even if the loader runs. Frame Control lists
|
||||
plugins already present in its managed folder and the upstream `~/homebrew`
|
||||
folder. The opt-in store manager installs compatible archives only into its
|
||||
own folder. It cannot make Steam Deck QAM plugin pages visible in Frame VR.
|
||||
The experimental [Framey panel host](framey-loader.md) can render compatible
|
||||
plugin settings inside Frame Control's separate SteamVR dashboard panel.
|
||||
The real headset still needs a worn-user visibility and controller check.
|
||||
|
||||
The latest tested release was
|
||||
[Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0),
|
||||
`PluginLoader-arm64` SHA-256
|
||||
`8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6`.
|
||||
This digest comes from that release's GitHub API asset metadata; it detects a
|
||||
download mismatch, but is not an independent signature.
|
||||
|
||||
## Opt in and remove
|
||||
|
||||
In Frame Control, open **Tools → Decky Loader on Frame**, read the experimental
|
||||
notice, check the acknowledgement box, and choose **Install**. **Update**
|
||||
downloads and verifies the latest ARM64 release again. **Remove managed
|
||||
Decky** disables and removes only Frame Control's user service and binary.
|
||||
It preserves plugin files and settings so unrelated user data is not deleted.
|
||||
Open **Official plugin store** to review a plugin, then confirm each install,
|
||||
update, or remove. Root and x86-only plugins are blocked. The manager only
|
||||
removes plugin folders that it installed; it leaves other Decky folders alone.
|
||||
The desktop app and headset panel share one lock for managed Decky changes.
|
||||
If another change is in progress, wait for it to finish and try again.
|
||||
Restart Steam or the headset to clear the frontend already injected into its
|
||||
current web helper process. No action wipes the headset.
|
||||
|
||||
Manual removal if Frame Control is unavailable:
|
||||
|
||||
```sh
|
||||
ssh frame 'systemctl --user disable --now frame-control-decky.service; rm -f ~/.config/systemd/user/frame-control-decky.service ~/.local/share/frame-control/decky/PluginLoader ~/.local/share/frame-control/decky/.frame-control/receipt.json; systemctl --user daemon-reload; systemctl --user reset-failed frame-control-decky.service'
|
||||
```
|
||||
|
||||
That command intentionally keeps `~/.local/share/frame-control/decky/plugins`
|
||||
and `settings`. Check those folders before deleting them yourself. An upstream
|
||||
Decky installation is separate; use its own installer to remove it. Frame
|
||||
Control refuses to install alongside an upstream system service.
|
||||
|
||||
## Next compatibility test
|
||||
|
||||
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
|
||||
Quick Access Menu after an opt-in install. If none appears, inspect whether
|
||||
the VR shell exposes a different menu component and whether Decky's frontend
|
||||
can target it without patching Steam globally. Confirm the plugin's visible
|
||||
UI, controller interaction, and persistence across a normal Steam restart.
|
||||
Until those checks pass,
|
||||
Frame Control should not describe Decky plugins as usable in VR yet. A worn
|
||||
headset check is needed for both the Decky menu and the Frame Control SteamVR
|
||||
panel; only then can their controller interaction and plugin presentation be
|
||||
confirmed.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Emulators and your own games
|
||||
|
||||
Frame Control's **Store → Emulators** lists Android emulators from its open
|
||||
app sources. Pick an app, read its compatibility result, and install it. An
|
||||
installed Android app gets its own Steam library shortcut and artwork; **Add
|
||||
artwork** in its library menu can fill missing images later. The artwork
|
||||
workflow also covers native Linux and Windows titles added through **Send to
|
||||
Frame**. An optional SteamGridDB key under **Settings → Library artwork**
|
||||
can supply exact-title community images; source images and generated art are
|
||||
fallbacks. See [VR APKs → In the Steam library](vr-apks.md#in-the-steam-library)
|
||||
for the limits and tested behavior.
|
||||
|
||||
## Set up an emulator
|
||||
|
||||
1. In **Store → Emulators**, choose an emulator that supports the console and
|
||||
file format you want. Check its compatibility result before installing;
|
||||
an app listed in the store is not automatically verified on the Frame.
|
||||
2. Launch the new shortcut from Steam. Open the emulator's own settings to
|
||||
choose a gamepad layout, core or system, and game folder. These controls
|
||||
differ by emulator; Frame Control does not change them.
|
||||
3. Use game files you own and any firmware that emulator's instructions
|
||||
require. Frame Control does not provide ROMs or firmware.
|
||||
|
||||
**Android apps run in separate Lepton instances.** Copying a ROM to the
|
||||
Frame's Linux `~/Downloads` through **Send to Frame** does not automatically
|
||||
put it in an Android app's `/sdcard/Download`. A one-click ROM transfer is
|
||||
deferred until an emulator and its file picker can be tested end to end on a
|
||||
real Frame. An app's own import or sync feature may be enough. Advanced users
|
||||
can use [Lepton's per-instance ADB port](apks.md)
|
||||
to copy into that instance, after confirming the correct container and port.
|
||||
Do not assume that port 5555 is the emulator: it is Lepton Development, while
|
||||
installed apps receive their own ports.
|
||||
|
||||
For a native Linux emulator that can read the Frame's home directory, use
|
||||
**Send to Frame → Copy to ~/Downloads instead** for your files, then open them
|
||||
from the emulator. Whether a particular emulator has a Frame-compatible ARM64
|
||||
build, correct controller mapping and readable storage must be checked for
|
||||
that app. **Linux apps** in Frame Control lists Flatpak options; a Windows or
|
||||
native game archive can instead be added through
|
||||
[Send to Frame](sideloading.md#using-it), which registers a Steam title and
|
||||
artwork. Each ROM is usually opened *inside* its emulator; Frame Control does
|
||||
not create one Steam shortcut per ROM.
|
||||
|
||||
## If something does not appear
|
||||
|
||||
- Check that the emulator is running and is the same app instance to which
|
||||
you sent the file. Android instances do not share `/sdcard` storage.
|
||||
- Check the emulator's supported extension and whether it expects an archive
|
||||
or an extracted file. Some systems need firmware before games appear.
|
||||
- For an empty Steam tile, use **Add artwork** in the installed app or
|
||||
sideloaded title menu. This changes artwork, not emulator data or ROMs.
|
||||
|
||||
The Frame's microSD/USB path into Android apps remains unverified without an
|
||||
inserted medium and an app readback test; see [issue #169](https://github.com/saphid/frame-control/issues/169).
|
||||
@@ -0,0 +1,97 @@
|
||||
# Android phone companion: review fixes (2026-10-26)
|
||||
|
||||
Base: `ae9df354`. This evidence covers fixes for seven findings from an
|
||||
independent review of the Android companion (issue #108).
|
||||
|
||||
## Status of earlier evidence
|
||||
|
||||
The APK, emulator and physical-device evidence in
|
||||
`android-phone-screenshot-sync-2026-10-24.md` was captured from `ae9df354`.
|
||||
It is **historical**. It does not show that the fixes below work on a device.
|
||||
No emulator, phone or headset was used for the initial implementation round.
|
||||
Later parent verification is recorded below.
|
||||
|
||||
## What changed
|
||||
|
||||
| Finding | Fix | Covered by |
|
||||
| --- | --- | --- |
|
||||
| F1 Stop could leave an ongoing notification or wrong status | `SessionGate` gives each service instance one session token, so an ended session cannot post status, page or notification updates. `SyncEngine.close()` stops all writes before a new session opens the library. `onDestroy` also cancels notification 108. | `SessionGateTest` (4), `closedEngineCannotWriteAfterAnotherEngineTakesOver` |
|
||||
| F2 Pairing could merge a key into an unterminated last line | `FrameConnection.authorizeCommand` uses the same newline guard as the iPhone app. | `FrameConnectionTest` runs the generated shell against a temporary HOME |
|
||||
| F3 Every 4-second poll rewrote and fsynced the journal | `reconcile` commits only when a receipt or the account set changes. | 3 tests that turn any journal write into a failure |
|
||||
| F4 One empty or over-30 MiB file blocked every listing | Out-of-range sizes are skipped for that poll only. Unsafe ids, non-finite times and conflicting duplicates still reject the whole listing. | `emptyOrOversizedFiles…` |
|
||||
| F5 The screen parsed, and could write, the journal on the UI thread | `SyncEngine.snapshot` is a read-only view. The screen loads it on a background thread, drops results for a headset that is no longer paired, and clears the list when the pairing changes. | `readOnlySnapshotNeverCreatesUpgradesOrRewrites`; existing native `changingHeadsetsRebindsShareToTheSelectedOriginal` compiles |
|
||||
| F6 An opt-out after an interrupted export left a hidden pending row | `GalleryExporter.discardPending` removes this app's pending rows in `Pictures/Frame Control/` when Gallery is turned off, and again when sync starts with Gallery off. Published rows are kept. | Native `optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages` (compiled, not run) |
|
||||
| F7 Missing userdata, or a new account, could import its history | Each Steam account gets its own baseline. The app lists the account folders over SSH, so an account with no screenshots yet still counts as established. Version 1 journals upgrade to version 2 and trust only the accounts they already have receipts for. `ui/server.py` is unchanged. | 6 JVM tests plus `accountListingSeparatesAbsentUserdataFromEmptyAccounts` |
|
||||
|
||||
Nonblocking items: the workflow's `push` trigger is now `main` only, and the
|
||||
docs and app text now say that a locked phone can delay transfers.
|
||||
|
||||
## Verification
|
||||
|
||||
Command (JDK 17, Android SDK platform 36):
|
||||
|
||||
```sh
|
||||
./android/gradlew -p android clean assembleDebug testDebugUnitTest assembleDebugAndroidTest lintDebug --no-daemon
|
||||
```
|
||||
|
||||
- Exit 0. There are 53 JVM tests (34 before), all passing, none skipped.
|
||||
- Lint reports 0 errors and 12 warnings. All 12 are pre-existing kinds (version hints, i18n, WebView JavaScript).
|
||||
- Mutation check: restoring the old unconditional commit and the old key append made 4 tests fail.
|
||||
- `app-debug.apk` SHA-256: `e4a1f498fef6fbc9919ccb4077b8e15b609caea6a2192d3f1bd2b13ea78bcb15`
|
||||
- `app-debug-androidTest.apk` SHA-256: `f9acb6bc002665cff6a31d0aaf2d528f4b1eaaaa82ead9fb383867e1154f6053`
|
||||
|
||||
## Round 2: fixes for the independent Sol review
|
||||
|
||||
An independent review (GPT-6.1 Sol, high reasoning) of the fixes above found two
|
||||
product defects and one test timing defect. All three are addressed below.
|
||||
|
||||
| Finding | Fix | Covered by |
|
||||
| --- | --- | --- |
|
||||
| R1 A Gallery change before the first poll on a version 1 journal wrote an unreadable journal | A setting-only write now keeps the journal's version, so a version 1 journal stays a valid version 1 journal. Only the first poll upgrades it, and it writes version 2 and the account set together. The baseline policy is unchanged. | JVM `versionOneJournalStaysValidWhenGalleryChangesBeforeFirstPoll` (constructor and toggle paths, immediate reopen and snapshot, then the upgrade). Native `galleryOptOutLatchesPreviouslyPairedLibraries` now includes a retained version 1 library. |
|
||||
| R2 After Stop, a late export from the old session could delete the Gallery image that the next session had just saved | `GalleryExporter.EXPORTS` is one process-wide lock. Each export holds it from the row lookup through publish or cancellation, across exporter instances and sessions. It is taken before the settings lock and never while holding it. The wait is interruptible and limited to 30 seconds. After that, the item fails for this poll and is retried. | Native `stoppedSessionExportCannotDeleteNextSessionsGalleryImage`. A JVM harness outside the repository runs the real exporter and engine with an in-memory resolver: with the lock, 10 of 10 runs keep the image; with the lock removed, 10 of 10 runs lose it. |
|
||||
| R3 The headset-switch share test assumed the background library load had finished | The test waits, up to a time limit, until the expected original is shareable. It accepts no button while a library loads. It then checks that only one share button remains and that it shares the new headset's original. | `changingHeadsetsRebindsShareToTheSelectedOriginal` |
|
||||
|
||||
The parent's first native run (API 36, APK `e4a1f498…`) ran 5 tests: 4 passed.
|
||||
`optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages` reached and
|
||||
passed its assertions, then its cleanup threw `SecurityException` deleting a
|
||||
row the product had already removed. The test now drops that row from cleanup
|
||||
after it proves the row is gone.
|
||||
|
||||
The parent also ran the account-listing command read-only against the real
|
||||
headset. It exited 0 and listed one account. That is not proof of the phone's
|
||||
download pipeline.
|
||||
|
||||
Round 2 verification (same command, JDK 17, platform 36):
|
||||
|
||||
- Exit 0. There are 54 JVM tests, all passing: `SyncEngineTest` 47, `SessionGateTest` 4, `FrameConnectionTest` 3.
|
||||
- Lint reports 0 errors and 12 warnings, the same pre-existing kinds as round 1.
|
||||
- Mutation check for R1: removing the version-preserving line made only the new R1 test fail, with the invalid version 2 header the review described.
|
||||
- Two clean builds gave the same hashes. They replace the round-1 APKs:
|
||||
- `app-debug.apk` SHA-256: `139443c8f3246527ed629731b4f2a099939a2ba7590b628cde415f3f0d7ab996`
|
||||
- `app-debug-androidTest.apk` SHA-256: `3a84246a7b40436fd5fc521154541603050805270e21e052e26b31a5fa1e0cc2`
|
||||
|
||||
## Parent verification of the round-2 APKs
|
||||
|
||||
The parent installed both round-2 APKs above on the isolated API 36 emulator
|
||||
and ran the six non-live instrumentation methods explicitly. All six passed,
|
||||
none skipped; the supervisor exited 0. This includes actual MediaStore
|
||||
round-trip and retry, retained version 1 library opt-out, asynchronous
|
||||
headset-switch sharing, hidden pending-row removal, and the FIFO-controlled
|
||||
Stop/restart export overlap. The chooser was intercepted; no original was
|
||||
sent to another app.
|
||||
|
||||
The native overlap test exercised real MediaProvider rows: after both old and
|
||||
new exporters finished, the successor's image remained published with matching
|
||||
original bytes. This verifies the overlap mechanism, rather than a complete
|
||||
service Stop/restart against a live headset.
|
||||
|
||||
The parent also checked the 54 passing JVM XML results, the build's recorded
|
||||
exit 0, whitespace, and both APK hashes. Native output and sanitized metadata
|
||||
are in `android-phone-review-fixes-2026-10-26/` beside this document.
|
||||
|
||||
## Not verified
|
||||
|
||||
- API 29 runtime behavior and physical Android locked/Doze behavior remain unverified.
|
||||
- The account-listing command ran against the real headset; the final phone download pipeline and password-pairing UI have not been rerun against it.
|
||||
- A complete service Stop/restart during a live headset download is not proven by the exporter overlap test.
|
||||
- iOS APNs delivery, physical locked-phone Photos export, production signing, and release remain open.
|
||||
@@ -0,0 +1,101 @@
|
||||
|
||||
|
||||
|
||||
1
|
||||
Performing Streamed Install
|
||||
Success
|
||||
Performing Streamed Install
|
||||
Success
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=1
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=
|
||||
com.saphid.framecontrol.ScreenshotDeviceTest:
|
||||
INSTRUMENTATION_STATUS: test=changingHeadsetsRebindsShareToTheSelectedOriginal
|
||||
INSTRUMENTATION_STATUS_CODE: 1
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=1
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=.
|
||||
INSTRUMENTATION_STATUS: test=changingHeadsetsRebindsShareToTheSelectedOriginal
|
||||
INSTRUMENTATION_STATUS_CODE: 0
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=2
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=
|
||||
INSTRUMENTATION_STATUS: test=stoppedSessionExportCannotDeleteNextSessionsGalleryImage
|
||||
INSTRUMENTATION_STATUS_CODE: 1
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=2
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=.
|
||||
INSTRUMENTATION_STATUS: test=stoppedSessionExportCannotDeleteNextSessionsGalleryImage
|
||||
INSTRUMENTATION_STATUS_CODE: 0
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=3
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=
|
||||
INSTRUMENTATION_STATUS: test=optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages
|
||||
INSTRUMENTATION_STATUS_CODE: 1
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=3
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=.
|
||||
INSTRUMENTATION_STATUS: test=optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages
|
||||
INSTRUMENTATION_STATUS_CODE: 0
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=4
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=
|
||||
INSTRUMENTATION_STATUS: test=galleryOptOutLatchesPreviouslyPairedLibraries
|
||||
INSTRUMENTATION_STATUS_CODE: 1
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=4
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=.
|
||||
INSTRUMENTATION_STATUS: test=galleryOptOutLatchesPreviouslyPairedLibraries
|
||||
INSTRUMENTATION_STATUS_CODE: 0
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=5
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=
|
||||
INSTRUMENTATION_STATUS: test=actualGalleryOriginalAndAppOnlySurviveRelaunch
|
||||
INSTRUMENTATION_STATUS_CODE: 1
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=5
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=.
|
||||
INSTRUMENTATION_STATUS: test=actualGalleryOriginalAndAppOnlySurviveRelaunch
|
||||
INSTRUMENTATION_STATUS_CODE: 0
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=6
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=
|
||||
INSTRUMENTATION_STATUS: test=interruptedMediaStoreRowIsRewrittenWithoutDuplicate
|
||||
INSTRUMENTATION_STATUS_CODE: 1
|
||||
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
|
||||
INSTRUMENTATION_STATUS: current=6
|
||||
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
|
||||
INSTRUMENTATION_STATUS: numtests=6
|
||||
INSTRUMENTATION_STATUS: stream=.
|
||||
INSTRUMENTATION_STATUS: test=interruptedMediaStoreRowIsRewrittenWithoutDuplicate
|
||||
INSTRUMENTATION_STATUS_CODE: 0
|
||||
INSTRUMENTATION_RESULT: stream=
|
||||
|
||||
Time: 3.171
|
||||
|
||||
OK (6 tests)
|
||||
|
||||
|
||||
INSTRUMENTATION_CODE: -1
|
||||
@@ -0,0 +1,51 @@
|
||||
# Independent Android fixes review — 2026-10-26, round 2
|
||||
|
||||
**Verdict: no actionable findings in the frozen Android hardening delta.** The previous Sol findings R1, R2 and R3 are addressed. This is a source-slice review, not a release-readiness verdict.
|
||||
|
||||
Reviewer: current T3 Codex runtime, `gpt-6.1-sol`, high reasoning. I performed the review myself, without delegation.
|
||||
|
||||
## Exact target and boundaries
|
||||
|
||||
- Worktree: `/Users/saphid/.t3/worktrees/steam-frame/feat-phone-screenshot-autosync-108`.
|
||||
- Branch: `feat/phone-screenshot-autosync-108`.
|
||||
- HEAD: `ae9df3544239afc1f2cab17ec10fe07f6fe9e657`, plus the frozen uncommitted delta.
|
||||
- All **15 paths** in `/tmp/frame108-android-fixes-review-20261026-r2.json` matched their SHA-256 values before and after review. Manifest SHA-256: `e3417c56926d08faecb6e5beacaf081c9e4fc395ef494c4de999a4b2282900ad`.
|
||||
- I inspected the complete tracked delta, the untracked SessionGate and test files, the evidence files, and surrounding engine, exporter, service, activity and connection logic. I checked callers and lock order as well as the changed lines.
|
||||
- I read the original Opus review, first Opus fixes report, previous Sol review and reproductions, latest Opus fixes report, and `docs/evidence/android-phone-review-fixes-2026-10-26.md`.
|
||||
- No `CODING_STANDARDS.md` exists in the worktree, its ancestor directories, or the primary checkout root. I read `CONTRIBUTING.md`; no relevant violated rule was found.
|
||||
|
||||
I made no repository edits, did not read `.frame_pwd` or unrelated dirty primary-checkout files, and used no device, headset or browser. I made no external writes, commits, pushes, PR changes or releases. Review outputs and local test classes are under `/tmp`.
|
||||
|
||||
## Previous Sol findings
|
||||
|
||||
- **R1 — addressed.** `SyncEngine.java:668-673` now preserves both the existing journal version and its account-set representation in `copy()`. Setting-only writes on v1 stay valid v1. Reconcile upgrades to v2 alongside its account set at `:285-304` and `:337-343`. The new JVM regression covers constructor preference reconciliation and an explicit toggle before any poll, immediate snapshot/reopen, repeated toggles, and the subsequent migration/baseline behavior. It passed in my independent run. The retained-v1 instrumentation path is present and passed in the parent's supplied API 36 log.
|
||||
- **R2 — addressed.** `GalleryExporter.java:74-84` acquires the process-wide `EXPORTS` lock interruptibly with a 30-second timeout, and releases it in `finally`. It covers deterministic row lookup, byte copying, publication and cancellation deletion across exporter instances. The order is EXPORTS → settings monitor → engine monitor. Neither Gallery toggle nor startup cleanup acquires EXPORTS while holding the settings monitor; I found no inverse acquisition path. The old exporter must finish its row work before its successor recovers/inserts a row. I independently recompiled the frozen exporter and engine and ran the existing bounded HandoffRace reproduction with an in-memory resolver: the successor waited, the old cancellation deleted only its pending row, and the successor's published row survived with a saved receipt. The native FIFO test separately checks actual published-row existence and original bytes; its passing result is parent-supplied evidence, not my own device run.
|
||||
- **R3 — addressed.** `ScreenshotDeviceTest.java:487-517` tolerates a missing button while the background library loads and waits up to 15 seconds for a share URI different from the previous one. The test verifies A and B bytes, then checks exactly one final share button targeting B. It no longer treats main-queue idleness as library completion. The cleanup adjustment removes only the fixture URI already proven deleted; other cleanup errors remain visible. I inspected this code and the parent's passing instrumentation result; I did not independently execute instrumentation.
|
||||
|
||||
## Fresh pass over the earlier fixes
|
||||
|
||||
- **F1:** SessionGate serializes visible status/page/notification effects against session end. Permanent engine close rejects subsequent reconciles and setting writes, and invalidates in-flight journal commits. Late engine assignment and stale-worker finalization are guarded. Gate and takeover tests passed; complete live-service lifecycle behavior remains outside this review.
|
||||
- **F2:** the generated authorized_keys command terminates an unterminated existing line before appending and remains idempotent. The actual shell fixture cases passed locally.
|
||||
- **F3:** unchanged listings no longer persist the journal, while retryable downloads/exports continue. Observation resets and account-set changes still commit. The write-blocking regression tests passed.
|
||||
- **F4:** invalid sizes are excluded per item; unsafe identities, non-finite times and conflicting duplicates still reject a poll. Stability resets and completion after a skipped observation are covered by passing JVM tests.
|
||||
- **F5:** library snapshots do not create or rewrite storage. Activity parsing/file checks run on its library executor, live-engine selection matches the requested pin, pin changes clear the old list, and results for a no-longer-selected pin are discarded. I found no further actionable sharing regression.
|
||||
- **F6:** cleanup selects app-owned pending rows in the expected folder, serialized against final publication, and keeps published images. It remains best effort. Provider failure can leave hidden appOnly bytes, particularly on API 29 if Gallery is re-enabled before another cleanup. This documented residual limit is not a newly discovered blocker.
|
||||
- **F7:** per-account baselines handle absent userdata, established empty accounts, newly appearing accounts, and accounts observed between the SSH and screenshot queries. The migration preserves the intended baseline policy. The shell/parser and account-baseline tests passed.
|
||||
|
||||
The workflow trigger and documentation changes are consistent with this scope. I found no new concrete regression requiring a fix.
|
||||
|
||||
## Actual verification
|
||||
|
||||
All review-generated artifacts are in `/tmp/frame108-android-sol-review-fixes-20261026-r2-evidence/`.
|
||||
|
||||
1. **Independent JVM execution:** compiled current SyncEngine, SessionGate, FrameConnection, PhoneIdentity and all three JVM test classes with JDK 17, cached declared dependencies and SDK 36 into a fresh `/tmp` classes directory. Direct JUnit run: **54 tests passed**, exit 0. No Gradle task or repository build output was changed. Commands, compile log/exit and JUnit log/exit are saved there.
|
||||
2. **Independent local handoff execution:** compiled the current GalleryExporter against those classes and the existing HandoffRace Android substitutes. Compile and execution both exited 0. Output ended with `exists=true pending=false receipt=saved` and `RESULT: published row survived`. This reuses the inspected existing harness and proves the application-level interleaving; it is not MediaProvider instrumentation. See `race.log` and `race.rc`.
|
||||
3. **Parent evidence inspected:** final clean build log and exit file record success; existing XML totals are 47 engine + 4 gate + 3 connection tests, all zero failures/errors/skips. The frozen API 36 instrumentation log records **six passing tests, no skips**, and supervisor exit 0. I did not rerun assemble, lint or instrumentation. The parent's lint result is 0 errors and 12 existing warnings.
|
||||
4. **APK hashes independently recomputed:** app `139443c8f3246527ed629731b4f2a099939a2ba7590b628cde415f3f0d7ab996`; test `3a84246a7b40436fd5fc521154541603050805270e21e052e26b31a5fa1e0cc2`. Both match the supplied round-2 evidence.
|
||||
5. **Freeze/whitespace:** all 15 source/evidence hashes matched again after checks; branch and HEAD matched; `git diff --check` exited 0. Final hashes and whitespace result are saved in the evidence directory.
|
||||
|
||||
## Verification limits
|
||||
|
||||
Physical locked/Doze behavior, API 29 runtime behavior, complete service Stop/restart during a live headset download, the final phone-to-headset pipeline, iOS APNs/locked Photos behavior, production signing and release are not established here. A stuck old provider write can delay successor exports; the timed lock makes those attempts retryable but does not cancel the stuck I/O. Gallery-toggle I/O on the main thread is an existing disclosed limitation.
|
||||
|
||||
The successful read-only headset account listing supports that command only. Historical device evidence from the committed ae9df354 implementation does not establish the frozen fixes. These limits do not create an actionable defect in this reviewed slice or justify a release claim.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"taskId": "node:delegated-task:command%3Amcp%3A3ef9db23-13cf-4959-afe1-b003a3f2d950%3Adelegate-task%3Aframe108-android-sol-review-fixes-20261026-r2",
|
||||
"providerInstanceId": "codex_secondary",
|
||||
"model": "gpt-6.1-sol",
|
||||
"reasoningEffort": "high",
|
||||
"status": "completed",
|
||||
"findings": "No actionable findings in the frozen Android hardening delta; R1-R3 addressed.",
|
||||
"frozenFiles": {
|
||||
".github/workflows/android.yml": "aaea48149abd1030f18a844d9f245d9a0ad249152e5df838cd53c8863f7f2220",
|
||||
"android/app/src/androidTest/java/com/saphid/framecontrol/ScreenshotDeviceTest.java": "87f9629858a75a2ba4ced1a0f4ccc792d03a40bd39fed7e25e211cd6c1d15cbf",
|
||||
"android/app/src/main/java/com/saphid/framecontrol/FrameConnection.java": "edb7b62b4d9a01e8e6802e8758504044c965f6d1b2939536617a3f57832b13a9",
|
||||
"android/app/src/main/java/com/saphid/framecontrol/GalleryExporter.java": "b0c0c5331ea1184baf780f67329908d481491e7383bfb98c09733ada84582b5d",
|
||||
"android/app/src/main/java/com/saphid/framecontrol/MainActivity.java": "8069a9c6ac3ab0e5e6bb315055dd2bef2a28b6f1c0084f8355db237857b736c1",
|
||||
"android/app/src/main/java/com/saphid/framecontrol/SyncEngine.java": "0096ae5002c7ba5d1c7b8128aeee920eb4f29d8d94ad71d885ce86c5be70c854",
|
||||
"android/app/src/main/java/com/saphid/framecontrol/SyncService.java": "dd852b8ef7f45fc472a8d790ba7e335040355889ef8d082ba8f1e80a1d4fd690",
|
||||
"android/app/src/test/java/com/saphid/framecontrol/SyncEngineTest.java": "df1fa6ce8873098ea78fca3f5069e31712eae5eedde54824dd83216b6ac67f09",
|
||||
"docs/android-phone.md": "a274daed1118be64b83313eaad03ddbf080fbbc7dad6e39ce601843bc227c1a3",
|
||||
"android/app/src/main/java/com/saphid/framecontrol/SessionGate.java": "470a164820ececcbe897f55b3f4c90498c215d9dd1b93554e08d15fcc22a9fa2",
|
||||
"android/app/src/test/java/com/saphid/framecontrol/FrameConnectionTest.java": "05010052ef11e9d9e019ae4ccb2959a8931a03b2d23528832d4821d184623727",
|
||||
"android/app/src/test/java/com/saphid/framecontrol/SessionGateTest.java": "5e1e88bee54c82188ce0a1ac8c37d081565fc41c827c67b7d5ef0070b00ab15e",
|
||||
"docs/evidence/android-phone-review-fixes-2026-10-26.md": "eb2a961abe935541c04e65d43ad8d61036b73afe8f63696891771f5e8bc6b7b1",
|
||||
"docs/evidence/android-phone-review-fixes-2026-10-26/api36-instrumentation.txt": "d31e729b455ddd8ff3122e5a68bd2221361e27ef3553ba737447598a39d89bd9",
|
||||
"docs/evidence/android-phone-review-fixes-2026-10-26/verification.json": "2bd1644b0c77c83f3ce85ae1713baba1ea7c0cedce0161bc35e3eff587bce7a7"
|
||||
},
|
||||
"manifestSha256": "e3417c56926d08faecb6e5beacaf081c9e4fc395ef494c4de999a4b2282900ad",
|
||||
"independentVerification": {
|
||||
"jvmTests": 54,
|
||||
"jvmFailures": 0,
|
||||
"localExportHandoff": "passed"
|
||||
},
|
||||
"productionReleaseReady": false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"platform": "Android API 36 emulator",
|
||||
"nativeTests": 6,
|
||||
"nativeFailures": 0,
|
||||
"nativeSkipped": 0,
|
||||
"supervisorExit": 0,
|
||||
"jvmTests": 54,
|
||||
"jvmFailures": 0,
|
||||
"jvmErrors": 0,
|
||||
"jvmSkipped": 0,
|
||||
"buildExit": 0,
|
||||
"headsetAccountListing": {
|
||||
"sshExit": 0,
|
||||
"state": "present",
|
||||
"accountCount": 1,
|
||||
"numericEntriesOnly": true
|
||||
},
|
||||
"apkSha256": {
|
||||
"android/app/build/outputs/apk/debug/app-debug.apk": "139443c8f3246527ed629731b4f2a099939a2ba7590b628cde415f3f0d7ab996",
|
||||
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk": "3a84246a7b40436fd5fc521154541603050805270e21e052e26b31a5fa1e0cc2"
|
||||
},
|
||||
"productionReleased": false
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# Android phone screenshot sync evidence — 2026-10-24
|
||||
|
||||
Work: [#108](https://github.com/saphid/frame-control/issues/108). The user
|
||||
authorized a new Android companion after the earlier APK could not be located.
|
||||
This is a development build, not a production or Play release.
|
||||
|
||||
## Implemented
|
||||
|
||||
Native Android 10+ companion in `android/`, with pinned SSH transport and a
|
||||
non-exportable Android Keystore phone key. Pairing checks the host fingerprint
|
||||
before accepting a password and verifies key login before saving the pairing.
|
||||
The shared headset server runs through a session-bound loopback tunnel.
|
||||
|
||||
All retained libraries receive Gallery opt-out, including previously paired
|
||||
headsets. Paging keeps older offline originals reachable beyond the first 100.
|
||||
A failed image or Gallery export retries independently without starving later
|
||||
downloads; local persistence errors still halt immediately.
|
||||
|
||||
The user starts a connected-device foreground service with an ongoing Stop
|
||||
notification. New screenshot originals download automatically. Gallery is ON
|
||||
by default; turning it off latches pending and newly discovered items to
|
||||
app-only, including across relaunch and later re-enabling. Historical captures
|
||||
are baselined instead of imported. Receipts, original files, and MediaStore
|
||||
recovery support interrupted downloads/exports without duplicate Gallery rows.
|
||||
|
||||
See [usage and background limits](../android-phone.md).
|
||||
|
||||
## Build and automated checks
|
||||
|
||||
- JDK 17, Gradle 9.4.1, AGP 9.1.0; compile/target API 36, minimum API 29.
|
||||
- `assembleDebug testDebugUnitTest assembleDebugAndroidTest lintDebug` exited
|
||||
zero after review fixes: `/tmp/frame-108-android-build-final-r5.log`.
|
||||
- 34 JVM sync-engine tests, zero failures/errors/skips. Tests cover baseline,
|
||||
stability, identity, metadata limits, destination latching, retries, atomic
|
||||
receipts, corrupt state, unsafe paths, late cancellation callbacks, per-item
|
||||
failure isolation and fatal persistence errors.
|
||||
- API 36 arm64 emulator MediaStore tests passed: original byte round-trip,
|
||||
app-only across relaunch, interrupted pending row recovery and retry dedup.
|
||||
The final rebuilt APK passed **five selected native checks, no skips**:
|
||||
three MediaStore/multiple-library cases plus the actual headset fixture
|
||||
Gallery hash (`/tmp/frame-108-android-final-device-r4.log`, four tests), then
|
||||
app-only original/no-Gallery readback
|
||||
(`/tmp/frame-108-android-final-apponly-device-r4.log`, one test).
|
||||
- Existing server suite: 1,333 tests, 11 skips, exit zero,
|
||||
`/tmp/frame-108-android-server-tests.log`. Website: 29 passed, zero skips,
|
||||
`/tmp/frame-108-android-site-tests.log`.
|
||||
- Lint has no errors. Warnings include dependency/target freshness, English UI
|
||||
literals, backup-version compatibility, and the shared controls' JavaScript.
|
||||
|
||||
## Actual headset → Android emulator → Gallery
|
||||
|
||||
Target: task-owned headless API 36 emulator `emulator-5588`, with the actual
|
||||
Steam Frame reached over SSH. Initial baseline contained nine historical
|
||||
captures and imported none. An emulator-generated public test key was installed
|
||||
temporarily through the existing trusted SSH alias; no password was exposed.
|
||||
The unauthenticated native host-key probe matched the trusted headset key.
|
||||
|
||||
Synthetic fixture IDs use a separate test account/game namespace; this verifies
|
||||
new-file delivery from the real headset, not the hardware capture shortcut.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Gallery default ON | `999999999990/108/20261024100101_1.png` automatically downloaded and receipt became `saved` |
|
||||
| Source / app / Gallery SHA-256 | `c9411d718c8a3477b7c7a6eed5ab827570605fce977bdeef5aaf11d29ba88316` |
|
||||
| MediaStore dedup | Exactly one matching Gallery row; native test reads its original bytes |
|
||||
| Shared headset controls | Actual authenticated WebView loaded the headset app library |
|
||||
| Gallery OFF, app on Home | Second fixture automatically downloaded in 4.293 seconds (one synthetic trial) |
|
||||
| App-only source / app SHA-256 | `6a1513b2dc3459b457f7dd86adcde7298d99dccc31c73904d7c6698703edb961` |
|
||||
| App-only independent readback | One selected native test passed: correct app original and no matching Gallery row |
|
||||
| Re-enable and restart | App-only receipt and absent Gallery row preserved; first image remains saved, no duplicate |
|
||||
| Open/share original | Native chooser opened for the app-only original and was canceled; nothing sent |
|
||||
| Final r5 APK, Gallery ON while Home | Third new fixture `999999999990/108/20261024101413_1.png` reached Gallery in 5.288 seconds (one synthetic trial); same source/app/Gallery hash as the first fixture, one matching row |
|
||||
|
||||
Background readback: `/tmp/frame-108-android-background-device-r1.log`.
|
||||
Home was foreground with the app service active; the screen stayed on. This
|
||||
is not a locked/Doze or long-duration physical-phone test.
|
||||
|
||||
The final r5 background Gallery readback passed separately (one selected native
|
||||
test, no skip): `/tmp/frame-108-android-final-background-gallery-r5.log`.
|
||||
|
||||
Sanitized [machine readback](android-phone-screenshot-sync-2026-10-24/live-proof.json)
|
||||
is committed; local full readback: `/tmp/frame-108-android-live-proof.json`. Fixture source
|
||||
metadata: `/tmp/frame-108-android-fixtures.json`. The optional provisioning test
|
||||
passed separately. Earlier full-class instrumentation runs included two
|
||||
optional skipped hooks; those are not counted as four executed scenarios.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Verification boundaries
|
||||
|
||||
No physical Android phone, Doze reliability, hardware screenshot latency,
|
||||
password-pairing UI, Play signing identity or production upload has been
|
||||
verified. Android sync uses an explicit foreground session, not Firebase.
|
||||
The iOS foreground implementation has separate
|
||||
[evidence](phone-screenshot-sync-2026-10-24.md); APNs, its delivery service,
|
||||
notification extension and locked-phone Photos export remain unimplemented or
|
||||
unverified. The user's full-feature tested-and-working release condition is
|
||||
not satisfied.
|
||||
|
||||
T3's device tools incorrectly inferred the Android SDK from the platform-tools
|
||||
Cask and could not open this emulator. The Astra worker used the app-owned
|
||||
agent-device launcher with a task-local session/config and the correct SDK.
|
||||
No global SDK/T3 settings or physical devices were changed.
|
||||
|
||||
UI verification: provider `codex`, model `gpt-6-astra`, completed task
|
||||
`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-ui-astra-20261024-r1`.
|
||||
Report: `/tmp/frame-108-android-ui-report.md`.
|
||||
|
||||
## Review and final artifacts
|
||||
|
||||
The APK is `android/app/build/outputs/apk/debug/app-debug.apk`, signed with the
|
||||
development identity. No store upload or release has been performed.
|
||||
|
||||
Final r6 APK SHA-256: `e6a99a35154c21293975ea165eb967d8cc7ad84565f063255c3848ada1169f6e`.
|
||||
|
||||
Preferred different-model review (`claudeAgent` / `claude-opus-5-5`) failed
|
||||
before work with `Claude API rate limit reached. Try again later.` Task:
|
||||
`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-review-opus-20261024-r1`.
|
||||
Implementation and independent fresh fallback reviews used Sol; they are not
|
||||
different-model evidence.
|
||||
|
||||
Provider `codex_secondary`, model `gpt-6.1-sol`, high, completed round 1:
|
||||
`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-review-sol-20261024-r1`.
|
||||
All four findings were accepted and fixed: failed-item starvation, API-29
|
||||
pending-row recovery query, opt-out for other retained headset libraries, and
|
||||
inaccessible originals beyond the latest 100. Report:
|
||||
`/tmp/frame-108-android-review-sol-r1.md`. No CODING_STANDARDS.md was found in
|
||||
the task or primary worktree; CONTRIBUTING.md was included in review context.
|
||||
|
||||
Provider `codex_secondary`, model `gpt-6.1-sol`, high, completed round 2:
|
||||
`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-review-sol-20261024-r2`.
|
||||
It cleared all four prior fixes and independently reran 34 JVM tests, then found
|
||||
a library-switch cache identity issue: matching receipt fields could retain a
|
||||
Share action bound to the previous headset. The refresh signature now includes
|
||||
the absolute original path, including the headset library root.
|
||||
|
||||
The final r6 build (`assembleDebug assembleDebugAndroidTest lintDebug`) exited
|
||||
zero: `/tmp/frame-108-android-build-r6.log`. A new native regression launches
|
||||
the real activity, switches between two libraries with matching receipt fields
|
||||
but different original bytes, and verifies that Share now selects B’s bytes.
|
||||
The chooser is intercepted; nothing is sent externally. This regression and
|
||||
the three existing MediaStore/multiple-library cases passed together: **four
|
||||
selected tests, no skips**, `/tmp/frame-108-android-final-device-r6.log`. Core
|
||||
code is unchanged from the 34 independently passing JVM tests. The r6 change
|
||||
does not alter the r5 transfer/storage path exercised against the real headset.
|
||||
|
||||
Provider `codex_secondary`, model `gpt-6.1-sol`, high, completed round 3:
|
||||
`node:delegated-task:command%3Amcp%3Ad5d74833-0742-471e-af70-a23afbe44dab%3Adelegate-task%3Aframe-108-android-review-sol-20261024-r3`.
|
||||
**No actionable findings** in the frozen 25-file executable target (`android/`
|
||||
and `.github/workflows/android.yml`), with the remaining share-binding finding
|
||||
resolved. All executable fingerprints stayed unchanged during review. Report:
|
||||
`/tmp/frame-108-android-review-sol-r3.md`. The review inspected the passing r6
|
||||
native/build logs and unchanged core covered by round 2’s independent 34-test
|
||||
run; it did not claim new device runs or release validation.
|
||||
|
||||
Cleanup removed the one exact temporary public key and all three exact synthetic
|
||||
headset fixture paths, verified absent. Other authorized keys and real captures
|
||||
were preserved. Readback: `/tmp/frame-108-android-cleanup.json`. The task-owned
|
||||
`emulator-5588` was shut down after the live tests, then restarted only for
|
||||
the r6 share-binding regression and shut down again;
|
||||
no other device was stopped. Build artifacts, the AVD and local test logs remain
|
||||
available for follow-up verification.
|
||||
@@ -0,0 +1 @@
|
||||
{"removedTestKeys": 1, "removedFixtures": ["999999999990/108/20261024100101_1.png", "999999999990/108/20261024100102_1.png", "999999999990/108/20261024101413_1.png"], "testKeyAbsent": true, "allFixturesAbsent": true}
|
||||
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 177 KiB |
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"gallery": {
|
||||
"id": "999999999990/108/20261024100101_1.png",
|
||||
"sha256": "c9411d718c8a3477b7c7a6eed5ab827570605fce977bdeef5aaf11d29ba88316",
|
||||
"bytes": 3620,
|
||||
"createdAt": 1792836168.6100879,
|
||||
"receipt": {
|
||||
"destination": "saved",
|
||||
"observations": 2,
|
||||
"shot": {
|
||||
"id": "999999999990/108/20261024100101_1.png",
|
||||
"size": 3620,
|
||||
"time": 1792836168.6217859
|
||||
},
|
||||
"state": "downloaded"
|
||||
},
|
||||
"readbackAt": 1792836209.819923
|
||||
},
|
||||
"appOnly": {
|
||||
"id": "999999999990/108/20261024100102_1.png",
|
||||
"sha256": "6a1513b2dc3459b457f7dd86adcde7298d99dccc31c73904d7c6698703edb961",
|
||||
"bytes": 3623,
|
||||
"createdAt": 1792836368.616906,
|
||||
"background": "Home foreground; connectedDevice service active",
|
||||
"receipt": {
|
||||
"destination": "appOnly",
|
||||
"observations": 2,
|
||||
"shot": {
|
||||
"id": "999999999990/108/20261024100102_1.png",
|
||||
"size": 3623,
|
||||
"time": 1792836368.6332257
|
||||
},
|
||||
"state": "downloaded"
|
||||
},
|
||||
"readbackAt": 1792836372.909552,
|
||||
"elapsedSeconds": 4.293
|
||||
},
|
||||
"finalBackgroundGallery": {
|
||||
"id": "999999999990/108/20261024101413_1.png",
|
||||
"sha256": "c9411d718c8a3477b7c7a6eed5ab827570605fce977bdeef5aaf11d29ba88316",
|
||||
"bytes": 3620,
|
||||
"createdAt": 1792836871.5926661,
|
||||
"background": "Home foreground; connectedDevice service active; final r5 APK",
|
||||
"receipt": {
|
||||
"destination": "saved",
|
||||
"observations": 2,
|
||||
"shot": {
|
||||
"id": "999999999990/108/20261024101413_1.png",
|
||||
"size": 3620,
|
||||
"time": 1792836871.6072419
|
||||
},
|
||||
"state": "downloaded"
|
||||
},
|
||||
"readbackAt": 1792836876.8808918,
|
||||
"elapsedSeconds": 5.288
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 238 KiB |
@@ -1,79 +0,0 @@
|
||||
# Auto-sync retry and headset-selection reliability
|
||||
|
||||
Date: 25 October 2026. Base: `d79efb4`, Frame Control v0.4.30.
|
||||
Issue: [#214](https://github.com/saphid/frame-control/issues/214).
|
||||
Environment: Linux x86_64, Python 3.12.3, Node 24.15.0.
|
||||
|
||||
## Reproduced problems
|
||||
|
||||
The existing 19 auto-sync tests passed on the base revision. Added regressions
|
||||
failed before the implementation: an exhausted clip lost its error, explicit
|
||||
retry did not recover failed screenshots or clips, and switches around listing
|
||||
or copying continued the old pass. The original callback interface also had no
|
||||
way to carry the tick's captured device into the server list reservation.
|
||||
|
||||
The final fake-source tests exercise these boundaries:
|
||||
|
||||
- Fail screenshots or clips three times, keep checking without further copy
|
||||
attempts, and retain the source error plus an explicit retry instruction.
|
||||
- Recover the source and use either Resume or Copy everything now without
|
||||
restarting. Successful copies stay deduplicated; Resume retains the ledger
|
||||
even after a local file is deleted. Copy everything now retains its documented
|
||||
ability to restore deleted files and include older media.
|
||||
- Reset an exhausted budget while the source still fails: another three
|
||||
attempts are allowed, rather than unlimited background attempts.
|
||||
- Save an exhausted item by hand and clear its stale failure on the next list.
|
||||
- Switch after tick captures A, during a list, after screenshot bookkeeping,
|
||||
between media kinds, before a transfer, or before transfer bookkeeping.
|
||||
Interrupted passes do not mark the other headset's IDs or continue copying.
|
||||
- Switch to B exactly at callback entry through the production `_sync_list`
|
||||
adapter. `working(A)` refuses before calling the source list, leaving both
|
||||
ledgers and copy callbacks untouched. The same production reservation blocks
|
||||
a device switch during a list and rejects stale screenshot/clip transfers.
|
||||
- Execute the production Settings renderer in Node: Resume is available for
|
||||
copy errors and disk-full pauses, and hidden for a healthy state.
|
||||
|
||||
## Implementation boundary
|
||||
|
||||
Each list callback now receives the captured device, like the copy callbacks.
|
||||
The existing server `working(meant)` mechanism checks that intended headset
|
||||
under `_work_lock` while reserving work; `devices_post` uses the same lock to
|
||||
refuse switches during that work. The worker also checks for interruption
|
||||
around lists and transfers. No new device lock or clip destination logic is
|
||||
introduced. The server diff is confined to `_sync_list`.
|
||||
|
||||
The failed-attempt and error maps are in-memory and reset on explicit retry or
|
||||
restart. The successful-copy ledger and settings persistence retain their
|
||||
existing format. Disk-full pauses, offline backoff, clip settling and the
|
||||
three-attempt limit remain covered by the original tests.
|
||||
|
||||
## Checks
|
||||
|
||||
- `python3 -m unittest discover -s tests -p test_autosync.py -v`: 33 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_shot_copy.py -v`: 2 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_clips.py -v`: 34 passed,
|
||||
including local ffmpeg coverage.
|
||||
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
|
||||
36 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_page_scripts.py -v`: 5 passed.
|
||||
- `node --test site/test/*.test.mjs`: 2 test files passed.
|
||||
- `python3 -m py_compile ui/frame_autosync.py ui/server.py tests/test_autosync.py`
|
||||
and `git diff --check`: passed.
|
||||
|
||||
- `python3 -m unittest discover -s tests -v`: 1,525 tests run in 184.876
|
||||
seconds, passed with 16 skips. Ran with sandbox escalation for loopback
|
||||
sockets; test isolation still used throwaway app/SSH data and mocked headset
|
||||
transport. Final output:
|
||||
|
||||
```text
|
||||
Ran 1525 tests in 184.876s
|
||||
OK (skipped=16)
|
||||
```
|
||||
|
||||
## Limits and handoff
|
||||
|
||||
All new regressions use fake sources and mocked transport; they do not prove
|
||||
real-headset transfer or UI rendering. No headset was accessed: its shared test
|
||||
lock was held by another task. The Docker fake-Frame end-to-end suite and
|
||||
Windows/macOS runs were not performed locally. The coordinator owns review and
|
||||
merge; this task does not release, change PR #113, or publish announcements.
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"date_utc": "2026-10-24T07:54:37Z",
|
||||
"frame_control_tag": "v0.4.16",
|
||||
"steamos_version": "0.5.6",
|
||||
"steamos_build": "20261015.6141207",
|
||||
"host": "lxso2",
|
||||
"interface": "source UI served by a temporary private Frame Control server, opened in T3 Code's desktop browser",
|
||||
"action": "Clicked the visible Export button for Steam's retained real saved clip in Capture & Live",
|
||||
"clip_id": "10230640/clips/clip_13009719723623972864_20261024_072115",
|
||||
"api_observed": {
|
||||
"post_clips_export_http": 200,
|
||||
"get_job_http": 200,
|
||||
"get_clips_http": 200
|
||||
},
|
||||
"ui_result": "Saved clip_13009719723623972864_20261024_072115.mp4 to ~/Videos/SteamFrame; button changed to Export again",
|
||||
"output": {
|
||||
"path": "~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4",
|
||||
"bytes": 159829,
|
||||
"sha256": "f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1",
|
||||
"matches_previously_fully_decoded_export": true,
|
||||
"duration_seconds": 10.288532,
|
||||
"video": "H.264 1920x1080",
|
||||
"audio": "AAC stereo"
|
||||
},
|
||||
"cleanup": {
|
||||
"temporary_server_stopped": true,
|
||||
"shared_device_lock_released": true,
|
||||
"battery_percent_after": 99,
|
||||
"desktop_mode_active_after": false,
|
||||
"test_title_created": false,
|
||||
"steam_source_clip_retained": true
|
||||
},
|
||||
"limits": [
|
||||
"The test used the source UI, not a packaged desktop installer.",
|
||||
"The retained source clip was nearly black; visible gameplay capture remains unverified.",
|
||||
"Perceptual audio/video sync, long clips, trims and automatic copying were not tested."
|
||||
]
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"date_utc": "2026-10-24",
|
||||
"version": "v0.4.17",
|
||||
"steam_os_version": "0.5.6",
|
||||
"steam_os_build": "20261015.6141207",
|
||||
"result": "PASS: the real Steam-saved clip exported beyond 60 seconds",
|
||||
"method": "Steam manual recording of a reserved test title, then Frame Control's production frame_clips.export_clip path; no packaged GUI button click",
|
||||
"source": {
|
||||
"kind": "saved Steam clip",
|
||||
"duration_seconds": 100.801,
|
||||
"chunks": 68,
|
||||
"gaps": 0
|
||||
},
|
||||
"export": {
|
||||
"format": "MP4",
|
||||
"bytes": 1377849,
|
||||
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
|
||||
"duration_seconds_ffprobe": 100.833335,
|
||||
"video": "H.264, 1920x1080, 6049 frames",
|
||||
"audio": "AAC, 48000 Hz, stereo",
|
||||
"full_ffmpeg_decode": "passed without errors"
|
||||
},
|
||||
"cleanup": "Restored Steam recording mode to its prior off setting; stopped and removed only the reserved title and its processes; no recording or running Steam apps remained; Game Mode active, battery 100%, shared test lock released",
|
||||
"limits": [
|
||||
"This exercised the production export code path, not a packaged GUI Export click.",
|
||||
"Visible gameplay quality, perceptual audio/video sync, trims and longer recordings were not checked.",
|
||||
"The newly saved Steam source clip was retained; no user recording was deleted."
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"date_utc": "2026-10-24T09:08:35.523Z",
|
||||
"result": "PASS: packaged Electron visible Export click saved the retained real Steam clip beyond 60 seconds",
|
||||
"frame_control_tag": "v0.4.17",
|
||||
"documentation_base_commit": "4e456480aefbe541e334dbd247328537f535bd94",
|
||||
"host": "lxso2",
|
||||
"steamos_version": "0.5.6",
|
||||
"steamos_build": "20261015.6141207",
|
||||
"package": {
|
||||
"asset": "Frame-Control-linux-x86_64.AppImage",
|
||||
"url": "https://github.com/saphid/frame-control/releases/download/v0.4.17/Frame-Control-linux-x86_64.AppImage",
|
||||
"sha256": "b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d",
|
||||
"launch": "Verified release asset; --appimage-extract, then unmodified squashfs-root/AppRun. Electron used the bundled server and Python, not checkout source.",
|
||||
"display": "Xvfb :137, 1440x1000x24; Electron window 1400x950 including native chrome",
|
||||
"flags": [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--remote-debugging-port=49371",
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--user-data-dir=/tmp/frame-packaged-export-20261024/electron-profile"
|
||||
],
|
||||
"isolation": "Throwaway XDG_CONFIG_HOME, FRAME_CONTROL_DATA_DIR and Electron profile; telemetry and update checks disabled; existing SSH identity and normal home Videos destination used."
|
||||
},
|
||||
"ui": {
|
||||
"navigation": "Dismissed optional email prompt with No thanks; clicked Tools, then Capture & Live; scrolled Steam clips into view.",
|
||||
"row": "Steam recording \u00b7 Clip; Oct 24, 8:53 AM \u00b7 1:40 \u00b7 2 MB",
|
||||
"action": "Playwright connected to this Electron instance over local CDP; locator.click() clicked its visible button named exactly Export. The test did not directly invoke an export function or API POST.",
|
||||
"button": "button.small[data-clip-export=\"0\"]",
|
||||
"click_is_trusted": true,
|
||||
"button_bounds_css_pixels": {
|
||||
"x": 1300.390625,
|
||||
"y": 701.15625,
|
||||
"width": 61.609375,
|
||||
"height": 28
|
||||
},
|
||||
"post_clips_export_http": 200,
|
||||
"job": "0ab20b1788adce63",
|
||||
"job_poll_http": 200,
|
||||
"saved_message": "Saved clip_15658413054201167872_20261024_085343.mp4 to ~/Videos/SteamFrame",
|
||||
"final_row": "ON PC; Export again",
|
||||
"screenshots": [
|
||||
"clip-packaged-export-2026-10-24-before.png",
|
||||
"clip-packaged-export-2026-10-24-after.png"
|
||||
],
|
||||
"screenshot_scope": "Cropped to Steam clip rows and saved notification; unrelated screenshot thumbnails and host connection identity excluded."
|
||||
},
|
||||
"source": {
|
||||
"clip_id": "10230640/clips/clip_15658413054201167872_20261024_085343",
|
||||
"duration_seconds": 100.801,
|
||||
"duration_provenance": "Retained real Steam clip from clip-long-2026-10-24.json; current packaged UI displays 1:40."
|
||||
},
|
||||
"output": {
|
||||
"path": "~/Videos/SteamFrame/clip_15658413054201167872_20261024_085343.mp4",
|
||||
"bytes": 1377849,
|
||||
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
|
||||
"matches_prior_production_export_size_and_sha256": true,
|
||||
"duration_seconds_ffprobe": 100.833335,
|
||||
"duration_difference_from_steam_seconds": 0.032335,
|
||||
"video": "H.264, 1920x1080, 6049 decoded frames",
|
||||
"audio": "AAC, 48000 Hz, stereo, 4724 decoded audio frames",
|
||||
"validation": [
|
||||
"ffprobe -v error -count_frames -show_entries format=duration,size:stream=codec_name,codec_type,width,height,sample_rate,channels,nb_read_frames -of json <output>: exit 0",
|
||||
"sha256sum <output>: exact match to prior production export",
|
||||
"ffmpeg -v error -xerror -i <output> -f null -: exit 0, no errors"
|
||||
]
|
||||
},
|
||||
"cleanup": {
|
||||
"shared_lock": "Acquired with ssh frame mkdir /tmp/frame-test.lock after read-only battery/build/lock checks; released with rmdir after app shutdown; absence read back.",
|
||||
"battery_percent_before": 99,
|
||||
"battery_percent_after": 99,
|
||||
"battery_status": "Not charging; charger Full",
|
||||
"packaged_app_and_server_exited": true,
|
||||
"test_xvfb_stopped": true,
|
||||
"frame_export_cache_directories_remaining": 0,
|
||||
"ffmpeg_processes_after": 0,
|
||||
"game_mode_and_steamvr_running_after": true,
|
||||
"steam_source_clip_retained": true,
|
||||
"user_recordings_deleted": false,
|
||||
"headset_install_or_settings_changes": false,
|
||||
"local_export_retained": true
|
||||
},
|
||||
"limits": [
|
||||
"The unchanged AppImage payload ran after extraction on a virtual X desktop, with Chromium sandbox and GPU disabled. FUSE mounting, desktop integration and hardware rendering were not tested.",
|
||||
"The click was automated trusted pointer input in a visible Electron window on Xvfb, not a human click on a physical monitor. T3 collaborative preview opened successfully but drives separate browser tabs; Electron-native automation was used for this window.",
|
||||
"No new game or Steam recording was started. Visible gameplay quality, audible content and perceptual audio/video sync were not assessed.",
|
||||
"Clips longer than this retained 100-second clip, trims and automatic copying remain unverified.",
|
||||
"No release, tag or application code was changed."
|
||||
],
|
||||
"validation_local": {
|
||||
"clip_regression_suite": "python3 -m unittest discover -s tests -p test_clips.py -v: 34 tests passed in 20.173s, including real ffmpeg long-recording export",
|
||||
"json": "python3 -m json.tool: passed",
|
||||
"diff": "git diff --check: passed"
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"command": "scripts/frame-power-sample.sh --count 2 --interval 1",
|
||||
"exitCode": 0,
|
||||
"stopped": null,
|
||||
"lockPolicy": "Existing sampler checked local and remote /tmp/frame-test.lock before reading; no lock acquired, bypassed or removed. Read-only run only.",
|
||||
"samples": [
|
||||
{
|
||||
"time": 1792828506.4317346,
|
||||
"osBuild": "20261015.6141207",
|
||||
"elapsedSeconds": 0.057539,
|
||||
"battery": {
|
||||
"battery.percent": {
|
||||
"value": 99,
|
||||
"unit": "%",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.status": {
|
||||
"value": "Not charging",
|
||||
"unit": "state",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.watts": {
|
||||
"value": -0.06,
|
||||
"unit": "W",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.tempC": {
|
||||
"value": 24.0,
|
||||
"unit": "C",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": 1792828507.4357972,
|
||||
"osBuild": "20261015.6141207",
|
||||
"elapsedSeconds": 1.061612,
|
||||
"battery": {
|
||||
"battery.percent": {
|
||||
"value": 99,
|
||||
"unit": "%",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.status": {
|
||||
"value": "Not charging",
|
||||
"unit": "state",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.watts": {
|
||||
"value": -0.05,
|
||||
"unit": "W",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.tempC": {
|
||||
"value": 24.0,
|
||||
"unit": "C",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"No real low-charge or thermal condition induced.",
|
||||
"No native notification delivery verified.",
|
||||
"No worn-headset or native package tests.",
|
||||
"Read-only parser/transport check; no background monitor or power-saving claim."
|
||||
],
|
||||
"browserCheck": {
|
||||
"page": "Production ui/index.html served statically; API calls intentionally unavailable",
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
"bridge": "Synthetic desktop notification bridge; no native OS notification sent",
|
||||
"result": "Initially unchecked and visible only after desktop host/bridge setup. Checkbox enabled through browser. Synthetic 12%, Discharging, -4 W samples at 0/30/60 seconds produced one bridge request, an in-page status and a toast. No horizontal overflow.",
|
||||
"apiLimit": "Static server returned expected 404/501 API errors; not an end-to-end live desktop connection test."
|
||||
},
|
||||
"offlineValidation": [
|
||||
"test_desktop_battery_comfort.py: 2 tests, production JS with monotonic fake clock and notification bridge",
|
||||
"test_comfort*.py: 25 tests",
|
||||
"test_battery.py: 7 tests",
|
||||
"test_power*.py: 36 tests (loopback HTTP test run outside socket-restricted sandbox)",
|
||||
"test_ui_workspace.py: 1 Python wrapper / Node workspace suite",
|
||||
"test_ui_review.py: 1 Python wrapper / 9 Node regression tests, including switch reset and retained cooldown"
|
||||
]
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
{
|
||||
"date_utc": "2026-10-24",
|
||||
"base_commit": "de931bc",
|
||||
"issue": 73,
|
||||
"result": "Packaged Desktop Capture passed for a mapped nested KDE compositor on the real Frame; wearer-visible overlay condition was not established. Do not close #73 from this test.",
|
||||
"package": {
|
||||
"tag": "v0.4.17",
|
||||
"asset": "Frame-Control-linux-x86_64.AppImage",
|
||||
"sha256": "b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d",
|
||||
"release_digest_matched": true,
|
||||
"panel_helper_matches_base": true,
|
||||
"unmodified_payload": true,
|
||||
"host": "lxso2",
|
||||
"display": "Xvfb :139",
|
||||
"flags": [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu"
|
||||
],
|
||||
"private_profile_and_data": true,
|
||||
"telemetry_and_update_checks_disabled": true
|
||||
},
|
||||
"preflight": {
|
||||
"steamos_version": "0.5.6",
|
||||
"build_id": "20261015.6141207",
|
||||
"battery_percent": 99,
|
||||
"battery_status": "Not charging",
|
||||
"game_mode": true,
|
||||
"full_plasma_session": false,
|
||||
"nested_desktop_running": false,
|
||||
"nested_runtime_present": false,
|
||||
"lock_present": false,
|
||||
"steam_pid": 297826,
|
||||
"vrserver_pid": 297506,
|
||||
"vrcompositor_pid": 297620,
|
||||
"gamescope_service_invocation": "e8c1085b3bbd4e79a323b3e4a9bc6355",
|
||||
"focusable_windows": [],
|
||||
"focused_windows": []
|
||||
},
|
||||
"session": {
|
||||
"started_utc": "2026-10-24T09:43:48Z",
|
||||
"lock_acquired": true,
|
||||
"launch": [
|
||||
"systemd-run",
|
||||
"--user",
|
||||
"--unit=fc-issue73-desktop-20261024",
|
||||
"--collect",
|
||||
"--property=RuntimeMaxSec=180s",
|
||||
"--property=TimeoutStopSec=15s",
|
||||
"--property=KillMode=control-group",
|
||||
"--setenv=DISPLAY=:0",
|
||||
"--setenv=XDG_RUNTIME_DIR=/run/user/1000",
|
||||
"--setenv=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus",
|
||||
"--setenv=XDG_SESSION_TYPE=x11",
|
||||
"--setenv=XDG_CURRENT_DESKTOP=gamescope",
|
||||
"/usr/bin/steamos-nested-desktop"
|
||||
],
|
||||
"response": "Running as unit: fc-issue73-desktop-20261024.service; invocation ID: 92cff1e57c794212997d46c0a68877ec\n"
|
||||
},
|
||||
"discovery": {
|
||||
"focus": 62914560,
|
||||
"focus_display": ":0",
|
||||
"panels": [
|
||||
{
|
||||
"window": 62914560,
|
||||
"display": ":0",
|
||||
"name": "KDE Wayland Compositor - Press right control key to grab input",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"app": 0,
|
||||
"focused": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"window_evidence": {
|
||||
"xwininfo_map_state": "IsViewable",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"focused_after_show_request": true,
|
||||
"steamvr_overlay_key": "valve.steam.desktopgame.0",
|
||||
"steamvr_overlay_visibility_after_show_request": "not_visible"
|
||||
},
|
||||
"capture": {
|
||||
"repeat_utc": "2026-10-24T09:44:47.323Z",
|
||||
"route": "/api/screenshot?view=panel&window=62914560&display=%3A0",
|
||||
"http": 200,
|
||||
"content_type": "image/png",
|
||||
"content_length": 1209980,
|
||||
"source_header": "panel",
|
||||
"decoded": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"source": "panel",
|
||||
"selected": {
|
||||
"window": 62914560,
|
||||
"display": ":0",
|
||||
"name": "KDE Wayland Compositor - Press right control key to grab input",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"app": 0,
|
||||
"focused": true
|
||||
},
|
||||
"stamp": "9:44:48 AM",
|
||||
"badge": "Desktop \u00b7 KDE Wayland Compositor - Press right control key to grab input"
|
||||
},
|
||||
"canvas_png_bytes": 2011278,
|
||||
"first_capture_pixel_statistics": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"min": 2.3333333333333335,
|
||||
"max": 255,
|
||||
"hidden": false
|
||||
},
|
||||
"first_capture_click_event": {
|
||||
"isTrusted": true,
|
||||
"utc": "2026-10-24T09:44:12.738Z"
|
||||
},
|
||||
"actions": "Two Playwright locator.click() actions on the visible #shotBtn in the actual packaged Electron window; first click recorded isTrusted=true. No direct screenshot API invocation by harness."
|
||||
},
|
||||
"device_cleanup": {
|
||||
"utc": "2026-10-24T09:45:33Z",
|
||||
"before_stop": {
|
||||
"exit": 0,
|
||||
"out": "MainPID=300739\nControlGroup=/user.slice/user-1000.slice/user@1000.service/app.slice/fc-issue73-desktop-20261024.service\nActiveState=active\n",
|
||||
"err": ""
|
||||
},
|
||||
"hide_dashboard": {
|
||||
"exit": 0,
|
||||
"out": "HideDashboard",
|
||||
"err": ""
|
||||
},
|
||||
"stop_owned_unit": {
|
||||
"exit": 0,
|
||||
"out": "",
|
||||
"err": ""
|
||||
},
|
||||
"after_stop": {
|
||||
"exit": 0,
|
||||
"out": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
|
||||
"err": ""
|
||||
},
|
||||
"desktop_processes": {
|
||||
"exit": 1,
|
||||
"out": "",
|
||||
"err": ""
|
||||
},
|
||||
"nested_runtime_exists": false,
|
||||
"session": {
|
||||
"exit": 0,
|
||||
"out": "Id=gamescope-session.target\nActiveState=active\nInvocationID=a2fee8ebf55c42c7b546390d14cd985c\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=\n\nId=gamescope-session.service\nActiveState=active\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n",
|
||||
"err": ""
|
||||
},
|
||||
"steam": {
|
||||
"exit": 0,
|
||||
"out": "297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n",
|
||||
"err": ""
|
||||
},
|
||||
"steamvr": {
|
||||
"exit": 0,
|
||||
"out": "297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n297620 /opt/steamvr/bin/linuxarm64/vrcompositor\n",
|
||||
"err": ""
|
||||
},
|
||||
"xprops": {
|
||||
"exit": 0,
|
||||
"out": "GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \nGAMESCOPE_FOCUSED_WINDOW(CARDINAL) = \n",
|
||||
"err": ""
|
||||
},
|
||||
"battery": {
|
||||
"capacity": "99",
|
||||
"status": "Not charging"
|
||||
},
|
||||
"owned_lock_released": true
|
||||
},
|
||||
"host_cleanup": {
|
||||
"electron": {
|
||||
"pid": 1578431,
|
||||
"exists_after": false
|
||||
},
|
||||
"xvfb": {
|
||||
"pid": 1578428,
|
||||
"exists_after": false
|
||||
},
|
||||
"x_socket_absent": true
|
||||
},
|
||||
"delayed_final_readback": "2026-10-24T09:46:39Z\nshared-lock-absent\nLoadState=not-found\nActiveState=inactive\nId=gamescope-session.target\nActiveState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n297620 /opt/steamvr/bin/linuxarm64/vrcompositor\nnested-runtime-absent\n100\nFull\nGAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \nGAMESCOPE_FOCUSED_WINDOW(CARDINAL) = \n",
|
||||
"validation": {
|
||||
"focused_panel_suite": "42 tests passed in 14.805s (test_touch.py), including production-page Node behavior tests",
|
||||
"website": "2 tests passed (node --test site/test/*.test.mjs)",
|
||||
"node_panels": "Passed independently (node tests/page/desk_panels.mjs)",
|
||||
"metadata_and_links": "JSON parsed; result/cleanup assertions and relative evidence/streaming/testing links passed",
|
||||
"diff": "git diff --check passed"
|
||||
},
|
||||
"limits": [
|
||||
"SteamVR continued reporting not_visible after ShowDashboard, despite mapped X window and confirmed focus. No worn-headset visual assessment; does not establish the reporter's visible-overlay condition.",
|
||||
"SteamOS 0.5.6 only; reporter used 0.4.5 with unknown Frame Control version and no reproduction steps.",
|
||||
"Nested desktop launched with SteamOS steamos-nested-desktop from a bounded transient user unit using Steam display/session variables; not launched through a wearer's Steam UI.",
|
||||
"Extracted unchanged AppImage on a virtual X display with Chromium sandbox and GPU disabled; no FUSE, physical monitor or hardware-rendering test.",
|
||||
"No Live video, Control input or full Desktop Mode test.",
|
||||
"Nested-desktop journal included gamescope WSI/Vulkan-hook warnings. No warning was dismissed; captured compositor pixels were nonblank.",
|
||||
"Initial CDP response.body() yielded zero bytes despite successful image decode. Repeat verified Content-Length, fresh decoded image, panel selection and PNG export from canvas; the initial raw-body size is not treated as capture failure or proof.",
|
||||
"No private desktop pixels, issue screenshot or raw desktop journal committed."
|
||||
]
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
# Packaged Desktop Capture, 2026-10-24
|
||||
|
||||
Base: `de931bc`. Package: Linux x86_64 **v0.4.17**. Device: real Frame,
|
||||
SteamOS **0.5.6**, BUILD_ID **20261015.6141207**. Host: lxso2.
|
||||
[Sanitized state and response evidence](desktop-capture-packaged-2026-10-24.json).
|
||||
|
||||
## Result and issue boundary
|
||||
|
||||
The packaged app discovered the nested KDE compositor and both automated
|
||||
Desktop **Capture** button clicks succeeded. The repeat returned HTTP 200,
|
||||
`Content-Type: image/png`, `X-Capture-Source: panel`, and Content-Length
|
||||
1,209,980. The app decoded a fresh, nonblank 1920×1080 image and displayed
|
||||
the Desktop panel badge. No further product failure was reproduced or fixed.
|
||||
|
||||
This does **not** establish the full visible-desktop condition in
|
||||
[#73](https://github.com/saphid/frame-control/issues/73). `xwininfo` reported
|
||||
the desktop window `IsViewable`, and gamescope confirmed focus after
|
||||
`vrcmd --showdashboard valve.steam.desktopgame.0`. However, SteamVR still
|
||||
reported that overlay `not_visible`. There was no worn-headset visual check.
|
||||
The observation proves capture of a mapped nested compositor, not its visible
|
||||
presentation to a wearer. Do not close #73 from this evidence.
|
||||
|
||||
The issue screenshot shows a desktop panel in the headset view while Desktop
|
||||
Capture logs “no app panel is open in the headset.” The reporter used SteamOS
|
||||
0.4.5, gave no Frame Control version or reproduction steps, and has not replied
|
||||
to the maintainer's 0.4.3 update request. The duplicate page function was already
|
||||
fixed; [PR #152](https://github.com/saphid/frame-control/pull/152) subsequently
|
||||
improved unknown-state diagnostics. Its [earlier investigation](panel-discovery-2026-10-24.md)
|
||||
was blocked from starting a desktop by another task's lock. This run adds a
|
||||
current-package capture observation, without reproducing the older failure.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Read-only preflight found Game Mode active, full Plasma inactive, Steam and
|
||||
SteamVR running, battery 99%, no nested desktop/runtime and no test lock.
|
||||
2. Compared the retained AppImage's SHA-256 with the live v0.4.17 release asset
|
||||
digest: `b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d`.
|
||||
Extracted an independent copy; `cmp` confirmed its `frame_touch.py` matches
|
||||
the base checkout. No payload was edited.
|
||||
3. Started the package's own AppRun, bundled server and Python on Xvfb `:139`
|
||||
with private config/cache/data/profile, telemetry and update checks disabled,
|
||||
and `--no-sandbox --disable-gpu`. Used the existing authorized `ssh frame`
|
||||
identity. T3 preview opened the package server page but does not attach to
|
||||
the existing Electron window. The literal Electron button test used the
|
||||
same Playwright-over-loopback-CDP workflow as the earlier packaged tests.
|
||||
4. Acquired `/tmp/frame-test.lock` with atomic `mkdir` and wrote an owner marker.
|
||||
Rechecked no nested runtime or Plasma was present. Started SteamOS's
|
||||
`/usr/bin/steamos-nested-desktop` in the owned transient user unit
|
||||
`fc-issue73-desktop-20261024.service`, using Steam's display/session variables.
|
||||
`RuntimeMaxSec=180s`, `TimeoutStopSec=15s` and `KillMode=control-group` bounded
|
||||
the session. This launches a desktop panel inside Game Mode; no full Desktop
|
||||
Mode switch was sent.
|
||||
5. In the actual packaged Electron window, navigated Tools → Capture & Live →
|
||||
Desktop and clicked Capture with `locator.click()`. The first click event
|
||||
recorded `isTrusted=true`. The page selected the KDE compositor on `:0`;
|
||||
the production screenshot path used window `62914560`. Requested the desktop
|
||||
dashboard, read back focus/visibility, and clicked Capture again. Recorded
|
||||
response metadata, fresh decoded image dimensions and canvas pixel range.
|
||||
No direct screenshot API call was substituted for these button clicks.
|
||||
6. Ran the packaged `frame_touch.py panels` read-only over SSH to retain its
|
||||
exact discovery result: one focused KDE compositor panel, app ID 0, 1920×1080,
|
||||
display `:0`, with no diagnostic `why` field.
|
||||
|
||||
## Cleanup and limits
|
||||
|
||||
The owned desktop session ran **09:43:48–09:45:33 UTC** (105 seconds). Hid the
|
||||
dashboard to restore its initial hidden state, stopped only the owned unit,
|
||||
and verified it was unloaded/inactive with MainPID 0. No Plasma/KWin desktop
|
||||
process or nested runtime remained; gamescope's focusable/focused lists were
|
||||
empty again. Released only this task's lock, after checking its owner marker.
|
||||
|
||||
Delayed readback at **09:46:39 UTC** confirmed the lock absent, Game Mode active,
|
||||
full Plasma inactive and the original Steam, vrserver and vrcompositor PIDs
|
||||
still running. The Game Mode service invocation was unchanged. Battery was
|
||||
99% at cleanup and 100%/Full on the delayed check. Closed both owned preview
|
||||
tabs, Electron and Xvfb; their processes and X socket were gone. No test server
|
||||
remained. No reboot, installation, security/global setting change or full
|
||||
Desktop Mode transition was performed.
|
||||
|
||||
The nested desktop journal included gamescope WSI/Vulkan-hook warnings. No
|
||||
warning was dismissed. Capture produced nonblank compositor pixels, but this
|
||||
run does not assess their usability to a wearer. Private image files stayed
|
||||
outside the repository; only state and response metadata are published.
|
||||
|
||||
The initial CDP `response.body()` read yielded zero bytes despite successful
|
||||
decoding in the page. The repeat verified the PNG Content-Length, fresh image
|
||||
dimensions/source/selection and a 2,011,278-byte PNG exported from the canvas.
|
||||
The initial raw-body measurement is a harness limitation, not evidence of a
|
||||
product capture failure. No Live video, Control input, physical monitor, FUSE
|
||||
mounting or hardware rendering was tested. Older SteamOS and other packages
|
||||
remain outside this observation.
|
||||
|
||||
## Offline verification
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_touch.py -v`: **42 tests passed**,
|
||||
including the production page's Node panel behavior tests, duplicate-function
|
||||
guard, absent versus empty panel diagnostics, alternate displays, capture/input
|
||||
target validation and discovery deadlines. JSON parsing and `git diff --check`
|
||||
were also checked. No new regression test was added because this is an
|
||||
evidence-only change with no reproduced product defect.
|
||||
@@ -1,20 +0,0 @@
|
||||
# API v1 panel check, 25 October 2026
|
||||
|
||||
The unmodified official Storage Cleaner 1.4.0 archive (store ID 50, SHA-256
|
||||
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`)
|
||||
declares `api_version: 1`, but its bundled frontend connects to Decky's modern
|
||||
frontend API. Framey admitted that bundle from a local store receipt and
|
||||
rendered it in Chromium inside an iframe with `sandbox="allow-scripts
|
||||
allow-popups"`.
|
||||
|
||||
A test parent returned empty lists and `0B` for the plugin's two list and two
|
||||
size backend calls. Chromium reported `framey:ready`, displayed Shader Cache
|
||||
and Compatibility Data sections, and showed all four cleanup buttons disabled.
|
||||
No cleanup action was invoked. The backend was a stub; the test did not run the
|
||||
plugin's Python code or connect to a headset.
|
||||
|
||||
This confirms a panel shell for this exact archive with empty data. Storage
|
||||
Cleaner may remove compatibility data and local game saves when used with a
|
||||
real backend. Its cleanup actions, game names, SteamClient app details, VR
|
||||
visibility, and controller input remain unverified on Frame. Do not infer that
|
||||
its deletion controls are safe or complete in VR from this browser check.
|
||||
@@ -1,677 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"timeUtc": "2026-10-26T01:30:56.939937+00:00",
|
||||
"baseCommit": "442b5ec",
|
||||
"hostChange": "SliderField editableValue numeric input",
|
||||
"verification": "local Chromium + real Decky 3.3.0 amd64 container; no real Frame/worn VR",
|
||||
"decky": {
|
||||
"tag": "v3.3.0",
|
||||
"name": "PluginLoader",
|
||||
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
|
||||
},
|
||||
"audioVersions": {
|
||||
"pipewire": "1.4.2",
|
||||
"wireplumber": "0.5.8",
|
||||
"pactl": "17.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": 38,
|
||||
"name": "Volume Mixer",
|
||||
"version": "1.2.1-1",
|
||||
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
|
||||
"folder": "VolumeMixer-decky"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"name": "Volume Boost",
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
|
||||
"folder": "volume-boost"
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"name": "Mixer",
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
|
||||
"folder": "decky-volume-mixer"
|
||||
}
|
||||
],
|
||||
"isolation": {
|
||||
"network": "none",
|
||||
"devices": "none",
|
||||
"hostAudioSockets": "none",
|
||||
"audio": "private PipeWire/Pulse null sink; two silent streams; WirePlumber policy-only"
|
||||
},
|
||||
"fixtureBoundaries": [
|
||||
"Outer headset status/installed-plugin responses were fixture-shaped. Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe and unmodified official bundles ran in Chromium.",
|
||||
"The relay used Decky 3.3.0 official websocket RPC routes directly inside the isolated container; Steam CEF and the production SSH/CDP method transport were not part of this audio run.",
|
||||
"Actual published amd64 Decky binary and official unmodified Python backends executed. No ARM64 or real Frame audio claim.",
|
||||
"Previous full-catalog fake-Frame evidence tests archive/install/remove transport only; its backends remain stubbed."
|
||||
],
|
||||
"sharedHeadsetLock": {
|
||||
"acquired": false,
|
||||
"owner": "delightvr-r4-a56d43b4c0",
|
||||
"headsetReadsOrChanges": false
|
||||
},
|
||||
"audioBefore": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "45%",
|
||||
"front-right": "50%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mutatingCalls": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 75,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "75%",
|
||||
"front-right": "75%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 74,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "74%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 73,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "73%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 75,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "75%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 74,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 46,
|
||||
"volume": 0.5
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "50%",
|
||||
"front-right": "50%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 46,
|
||||
"volume": 0.45
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "45%",
|
||||
"front-right": "45%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
37
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "37%",
|
||||
"front-right": "37%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
38
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 42,
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 42,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "42%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "save_settings",
|
||||
"args": [
|
||||
{
|
||||
"filters": [
|
||||
"FrameySilent0"
|
||||
],
|
||||
"boostLimit": 0
|
||||
}
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
39
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "42%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "39%",
|
||||
"front-right": "39%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"volumeMixer38": "Discovery, slider click and ArrowDown: only FrameySilent1 changed 100\u219250\u219245%; FrameySilent0 stayed100%.",
|
||||
"volumeBoost58": "Sync both channels 75\u219274\u219273%; unsynced right 75\u219274% preserved left73%; reopen restored unchecked sync and73/74. Numeric left42 preserved right74; re-sync set right42 and disabled both right inputs.",
|
||||
"mixer144": "Numeric37\u219238\u219239 changed FrameySilent1 only; server readbacks37/38/39%. Invalid101 and off-step37.5 produced no set_volume RPC. Filter FrameySilent0 saved with boostLimit0, survived actual Decky shutdown/restart and reopened showing only FrameySilent1 at38%.",
|
||||
"invalidNumericBrowserMessages": {
|
||||
"inputs": [
|
||||
"101",
|
||||
"37.5",
|
||||
"39"
|
||||
],
|
||||
"setVolumeMessages": [
|
||||
{
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
39
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"notRun": [
|
||||
"boost above100%",
|
||||
"Bluetooth playback commands",
|
||||
"reset_volumes",
|
||||
"physical headset audio",
|
||||
"worn VR",
|
||||
"VR keyboard/controller input"
|
||||
]
|
||||
},
|
||||
"pipewireConfig": "context.properties = { core.daemon = true core.name = framey-audio }\ncontext.spa-libs = { audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }\ncontext.modules = [\n { name = libpipewire-module-protocol-native }\n { name = libpipewire-module-access }\n { name = libpipewire-module-spa-node-factory }\n { name = libpipewire-module-client-node }\n { name = libpipewire-module-client-device }\n { name = libpipewire-module-adapter }\n { name = libpipewire-module-metadata }\n { name = libpipewire-module-link-factory }\n { name = libpipewire-module-session-manager }\n]\ncontext.objects = [\n\n { factory = spa-node-factory args = { factory.name = support.node.driver node.name = Dummy-Driver node.group = pipewire.dummy priority.driver = 20000 } }\n { factory = adapter args = { factory.name = support.null-audio-sink node.name = framey-silent-test node.description = \"Framey Silent Test\" media.class = Audio/Sink audio.position = [ FL FR ] } }\n]",
|
||||
"remaining": "All three remain partial until real ARM64 Frame dependencies and audio-session mapping are verified under the shared lock.",
|
||||
"cleanup": {
|
||||
"testContainerRemoved": true,
|
||||
"browserRelayStopped": true,
|
||||
"headsetUntouched": true,
|
||||
"testServicesOnHeadset": false
|
||||
},
|
||||
"catalogRefresh": {
|
||||
"checkedAtUtc": "2026-10-26T01:34:07.257386+00:00",
|
||||
"plugins": 116,
|
||||
"idsVersionsDigestsUnchanged": true
|
||||
},
|
||||
"regression": {
|
||||
"serverTests": {
|
||||
"count": 1612,
|
||||
"skipped": 16,
|
||||
"result": "OK"
|
||||
},
|
||||
"siteTests": {
|
||||
"passed": 2,
|
||||
"failed": 0
|
||||
},
|
||||
"focusedFrameyTests": 38,
|
||||
"officialFrontendAudit": {
|
||||
"attempted": 116,
|
||||
"ready": 98,
|
||||
"failed": 18,
|
||||
"readyStateChanges": [],
|
||||
"initializerOnly": true,
|
||||
"backendAndNetworkDenied": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
# Official audio plugins: local real-backend checks
|
||||
|
||||
Volume Mixer 1.2.1-1 (38), Volume Boost 0.0.7 (58) and Mixer 0.1.0 (144)
|
||||
passed useful audio controls in local Chromium with their **unmodified official
|
||||
Python backends running in actual Decky 3.3.0**. All three remain **partial**:
|
||||
this run used amd64 Linux, and Frame ARM64 dependencies/audio-session mapping
|
||||
are unverified. The shared headset lock was occupied; no headset reads or
|
||||
changes occurred. No worn-VR result is claimed.
|
||||
|
||||
The private test container and browser relay were removed after verification.
|
||||
|
||||
The [machine-readable observations](framey-audio-backends-2026-10-26.json)
|
||||
pin all three official archives and the published Decky binary digest, and
|
||||
record each browser-triggered write with independent `pactl` channel readbacks.
|
||||
|
||||
| Plugin | Useful behavior checked | Still unverified on Frame |
|
||||
| --- | --- | --- |
|
||||
| Volume Mixer | Discover two playback streams; slider click and ArrowDown change only the selected stream, 100 → 50 → 45%; other stream remains 100% | ARM64 libpulse/libdbus, actual user audio session, Bluetooth playback controls |
|
||||
| Volume Boost | Both-channel slider; separate right-channel change preserves left; reopen restores sync preference and actual values; exact numeric left entry; syncing disables both right controls | `pactl`, default sink, hard-coded runtime UID, physical output, boost above 100% |
|
||||
| Mixer | Exact stream volume 37 → 38%, independently read back; invalid 101 and off-step 37.5 produce no write; playback filter saved, survives actual Decky restart, hides the intended stream on reopen | `pw-dump`/`wpctl`, actual Frame session, physical output, worn VR |
|
||||
|
||||
## Reproduced host gap
|
||||
|
||||
Both Volume Boost and Mixer request `SliderField.editableValue`. Framey
|
||||
previously ignored that prop and rendered only a range slider. It now supplies
|
||||
an accessible numeric input with the same minimum, maximum, step and disabled
|
||||
state, and emits numeric `onChange` values. Empty, non-finite, out-of-range and
|
||||
browser-invalid drafts do not reach a backend. No official archive was patched.
|
||||
This also makes the control available to other plugins requesting that prop;
|
||||
it does not establish their compatibility.
|
||||
|
||||
## Runtime and isolation
|
||||
|
||||
The disposable Debian 13 container ran PipeWire 1.4.2, WirePlumber 0.5.8 and
|
||||
PulseAudio client tools 17.0. Docker used `--network none --cap-drop ALL
|
||||
--security-opt no-new-privileges`, no host audio sockets and no sound devices.
|
||||
Only the archive cache and test inputs were mounted, read-only. Debian 12 was
|
||||
insufficient for the published Decky binary's GLIBC requirement; that initial
|
||||
runtime did not start.
|
||||
|
||||
Private environment:
|
||||
|
||||
```text
|
||||
XDG_RUNTIME_DIR=/tmp/framey-audio-runtime
|
||||
PIPEWIRE_REMOTE=framey-audio
|
||||
PULSE_SERVER=unix:/tmp/framey-audio-runtime/pulse-native
|
||||
DBUS_SYSTEM_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
|
||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
|
||||
```
|
||||
|
||||
XDG config/data/state and both Decky paths were below the private test base.
|
||||
Decky ran unprivileged with `CHOWN_PLUGIN_PATH=0`, `LIVE_RELOAD=0`,
|
||||
`USE_CEF_CLOSE_WORKAROUND=0` and `KEEP_SYSTEMD_SERVICE=1`. A private D-Bus
|
||||
session satisfied Volume Mixer's import; there was no real BlueZ service.
|
||||
Its fixed `/tmp/VolumeMixer-decky.log` existed only inside the container.
|
||||
|
||||
The JSON includes the complete PipeWire server configuration: only a dummy
|
||||
driver and null stereo sink, no hardware factories. A second PipeWire process
|
||||
loaded protocol-native, client-node, adapter, metadata and protocol-pulse with
|
||||
the private absolute socket. `wireplumber --profile policy` managed this server;
|
||||
that installed profile contains no hardware monitors. Only private default-sink
|
||||
metadata was selected. Two `paplay --raw --format=s16le --rate=48000 --channels=2
|
||||
/dev/zero` clients supplied silence named `FrameySilent0` and `FrameySilent1`.
|
||||
Without policy, successful volume RPCs initially left readbacks unchanged; those
|
||||
fixture attempts are excluded from the passing observations.
|
||||
|
||||
The browser relay served actual `ui/headset.html`, vendored React 18.3.1,
|
||||
`ui/framey-host.js` and the exact archived frontends in an opaque `allow-scripts`
|
||||
iframe. Outer headset/installed-plugin responses were fixture-shaped. Backend
|
||||
RPC used the official Decky websocket protocol inside the isolated container:
|
||||
`loader/call_legacy_plugin_method` with keyword objects for 38/58 and
|
||||
`loader/call_plugin_method` with positional arguments for 144. This bypassed
|
||||
Steam CEF and the production SSH/CDP transport; neither is an audio verification
|
||||
claim from this run.
|
||||
|
||||
The relay allowed discovery/settings reads, bounded volume writes only to
|
||||
identified `FrameySilent` streams/the private default sink, and Mixer filters
|
||||
`[]` or `["FrameySilent0"]` with boost limit 0. It denied Bluetooth commands,
|
||||
`reset_volumes` and boosts. Every volume write was followed by a separate
|
||||
`pactl -f json list sinks` / `list sink-inputs` read. A clean Decky shutdown and
|
||||
restart retained Mixer's actual settings file and filter behavior.
|
||||
|
||||
Earlier full-catalog fake-Frame evidence for these exact digests covers
|
||||
install/receipt/frontend/removal transport, with plugin execution stubbed.
|
||||
The new evidence adds actual local backend execution; it does not turn that
|
||||
older fake-Frame result into an ARM64 backend test.
|
||||
|
||||
## Regression checks
|
||||
|
||||
The required server suite passed 1,612 tests (16 skips); all 38 focused Framey
|
||||
tests and both website tests passed. The 116-plugin browser audit retained
|
||||
98 initializer-ready / 18 failed, with no change to any ready state. That audit
|
||||
keeps backend/network actions denied. A fresh official catalog fetch confirmed
|
||||
all 116 IDs, versions and digests unchanged.
|
||||
|
||||
## Next device check
|
||||
|
||||
Acquire the normal atomic `/tmp/frame-test.lock`; refuse an existing owner.
|
||||
First inspect available tools/libraries and audio runtime UID. If supported,
|
||||
repeat with a private ARM64 Decky user service, isolated audio socket, dummy
|
||||
sink/streams and hardware discovery disabled. Check Volume Mixer's fixed log
|
||||
path for collisions before loading it. Never attach the fixture to the normal
|
||||
headset output/default server. Remove only test-owned services/processes/files,
|
||||
read back original service/battery state and release only the matching lock.
|
||||
Keep all three partial until that check passes; physical output, Bluetooth and
|
||||
worn-VR controls need separate evidence.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Bluetooth 3.1.1 compatibility fixture, 25 October 2026
|
||||
|
||||
The unmodified official Decky store archive for Bluetooth 3.1.1 (store ID 18,
|
||||
SHA-256 `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5`)
|
||||
passes Frame Control's archive rules. The fake Frame e2e harness installed it
|
||||
through the store API, verified the managed receipt and frontend bytes, then
|
||||
removed it. An earlier real Frame check recorded the backend's `Bluetooth
|
||||
plugin loaded` line while SteamVR was in standby.
|
||||
|
||||
In a local Node host test, its official frontend starts and builds an initial
|
||||
panel tree. Framey supplies empty Steam token maps and readable English
|
||||
fallbacks for the three Bluetooth labels that lack plugin-owned fallbacks.
|
||||
The test leaves React effects and click
|
||||
handlers inert and sends no backend calls. It does not inspect paired devices,
|
||||
change Bluetooth state, exercise a controller, or prove the panel is visible in
|
||||
VR. No worn-headset check has passed for this plugin.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Framey loader evidence, 25 October 2026
|
||||
|
||||
## Local browser and fake Frame
|
||||
|
||||
The official Decky store returned 116 validated entries. ProtonDB Badges was
|
||||
entry 14, release 1.3.4, SHA-256
|
||||
`90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358`.
|
||||
Its published ZIP passed Frame Control's store manifest and path validator. The current
|
||||
`dist/index.js` bundle was loaded in an opaque-origin iframe in local Chromium
|
||||
with the Framey React/DFL shim. The [browser screenshot](framey-browser-2026-10-25.png)
|
||||
shows the plugin's settings panel. A pointer click on **Enable Badge on
|
||||
Library** issued `set_setting('settings', …)` through the iframe's request
|
||||
bridge. The local test parent returned a stub backend reply; it did not use a
|
||||
real headset or Decky backend. A separate fake Frame CEF fixture returned
|
||||
`get_setting` and `set_setting` responses through Frame Control's CDP bridge;
|
||||
that fixture does not model the real Decky process.
|
||||
|
||||
The disposable fake Frame end-to-end harness passed the store catalogue,
|
||||
acknowledgement and HTTP auth checks, hash-verified install, update, and
|
||||
removal through the real Frame Control HTTP server and SSH transaction. The
|
||||
full fake Frame suite passed 48 tests; the full unit suite passed 1,559 tests
|
||||
(16 skipped). Unit tests covered unsafe archives,
|
||||
unmanaged-folder preservation, asset isolation, backend-call scope,
|
||||
and safe aborts of the worn-headset check.
|
||||
|
||||
## Real headset status
|
||||
|
||||
Read-only SSH on Frame OS 0.5.6 found battery at 98–99%. SteamVR activity was
|
||||
initially 3 (standby), briefly 0, then 3 again. During the active probe the
|
||||
shared `/tmp/frame-test.lock` belonged to `obs-aacapv3evh-20261025`. It later
|
||||
became free after SteamVR returned to standby. Framey did not acquire, replace,
|
||||
or remove it. The active probe found no installed Frame Control VR panel or
|
||||
Framey plugin; the scripted worn check exited before acquiring a lock or
|
||||
altering the headset. There is **no** wearer-visible VR
|
||||
menu screenshot, controller-input proof, or live settings-persistence result
|
||||
from this round.
|
||||
|
Before Width: | Height: | Size: 62 KiB |
@@ -1,19 +0,0 @@
|
||||
# Framey cache check, 25 October 2026
|
||||
|
||||
The official Steam Status 1.2.3 ZIP pinned in
|
||||
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
|
||||
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
|
||||
allow-popups"`, so its native storage remained unavailable. The new Framey
|
||||
storage facade accepted a set/get probe without a `SecurityError`. Steam
|
||||
Status rendered and issued four `get_settings` calls to a stub parent backend.
|
||||
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
|
||||
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
|
||||
snapshot and the plugin wrote the same cache key again. The browser log had
|
||||
zero `SecurityError` or `localStorage` errors.
|
||||
|
||||
The parent was a local test page with in-memory Decky backend replies. One
|
||||
public status fetch also failed in the test browser, so this check does not
|
||||
establish offline correctness or the freshness of Steam Status data. The
|
||||
per-plugin save/reopen protocol, stale panel session rejection, and cache
|
||||
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
|
||||
live Decky settings, VR visibility, and controller input remain unverified.
|
||||
@@ -1,418 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:12:47.296778+00:00",
|
||||
"method": "Local Chromium actual Framey headset page and opaque official plugin iframe; production method/event bridge relayed by SSH to real ARM64 Decky 3.3.0 in a private removable user service. Test relay restricts RPC methods and timer durations. Backend code is unmodified, no fake plugin replies. Only file paths to private install and outer status/data APIs differ. Not worn VR.",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"hostSourceSha256": {
|
||||
"framey-host.js": "3bad52546c0b78c20e6479c91899f1f3864b74687c6fce37e776abededfb5f1c",
|
||||
"framey-events.js": "89046f4c89bdab0f578b910170a044d5ada540db0f7b71d6a370c68b4b008c3f",
|
||||
"framey_panel.py": "8ca4b3ba5f0d51c51bb3601f9ccd24c4af397fbf8f63c383a3042e1ae3f7b39b",
|
||||
"headset.html": "ebaf1711639a929ec7287c01dbbbe8eb08f000b852fdc877f31c9d99e5fe69fc"
|
||||
},
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"powerSettingsChanged": false,
|
||||
"plugins": [
|
||||
{
|
||||
"id": 19,
|
||||
"name": "Notebook",
|
||||
"version": "0.1.5",
|
||||
"sha256": "c35b09de4bba4b4157fbe84cb7b0d20fc3924f78fa3c48ae17686b4314c1cc0c",
|
||||
"calls": [
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 0,
|
||||
"empty": true,
|
||||
"data": ""
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "savePage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"timestamp": 1792973680303
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [
|
||||
{
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [],
|
||||
"checks": {
|
||||
"pointerStrokeSaved": true,
|
||||
"sameStrokeLoadedAfterBackendRestart": true,
|
||||
"defaultAppId": 0,
|
||||
"existingNotesTouched": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312",
|
||||
"calls": [
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
300
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "cancel_timer",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
5
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
294.9945251941681
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
289.99201130867004
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
284.9877219200134
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
-0.005679607391357422
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"event": "simple_timer_event",
|
||||
"args": [
|
||||
"Your session has ended!",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"uiStart300Seconds": true,
|
||||
"uiCancel": true,
|
||||
"shortTimer5Seconds": true,
|
||||
"completionToastVisible": true,
|
||||
"recentTimersAndSubtleModeSurvivedRestart": true,
|
||||
"subscriptionsAfterPanelClose": 0
|
||||
},
|
||||
"upstreamError": "Simple Timer 1.0.0 _main after restarting with an expired timer raises AttributeError: 'Plugin' object has no attribute 'timer_task' (main.py:94,129). Frontend read requests still load recent timers and subtle mode."
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"serviceActive": false,
|
||||
"independentlyRechecked": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
"regressions": {
|
||||
"unitTests": 1606,
|
||||
"unitSkips": 16,
|
||||
"browserPlugins": 116,
|
||||
"browserReady": 96,
|
||||
"browserFailed": 20,
|
||||
"fakeFrameInstalled": 60,
|
||||
"fakeFrameRejected": 56
|
||||
},
|
||||
"limitations": [
|
||||
"Timer sound asset and suspend action unsupported; no suspend/power action run.",
|
||||
"Closing a Framey panel removes its event channel; it is not a global background notification service.",
|
||||
"Notebook default notebook only (appid 0): no per-game auto-selection or background artwork.",
|
||||
"No worn VR, physical controller, keyboard, or optical check."
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
# Legacy Decky frontend bridge
|
||||
|
||||
The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures)
|
||||
has no `api_version` field and returns its initializer from a Decky IIFE.
|
||||
Framey now wraps that expression in an ES module inside the existing
|
||||
opaque-origin iframe. It passes a legacy `serverAPI` to the initializer.
|
||||
|
||||
Local Chromium ran the unchanged official frontend, including React effects,
|
||||
with all backend calls rejected and network access blocked. It emitted
|
||||
`framey:ready` without a JavaScript startup error during the observation window.
|
||||
This proves initialization, not playback, MPRIS access, or a useful populated UI.
|
||||
|
||||
The fake Frame installed, packed, checked the frontend bytes and removed this
|
||||
exact archive. Its synthetic Sample Panel also read a legacy keyword argument,
|
||||
wrote a setting, and read that setting through the modern API. This exercised
|
||||
the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was
|
||||
checked against Decky's `handle_plugin_method_call_legacy`: it calls with a
|
||||
keyword object and returns `{success, result}`. Invalid argument shapes and
|
||||
unmanaged plugin IDs are rejected.
|
||||
|
||||
Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'`
|
||||
(21 passed), `node tests/page/framey_actions.mjs`, and
|
||||
`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or
|
||||
headset state was changed by these checks. Real Frame backend behavior,
|
||||
controller input, playback and worn VR use remain unverified.
|
||||
@@ -1,326 +0,0 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:23:08.939647+00:00",
|
||||
"theme": "Detached read-only Steam library and current-user context",
|
||||
"baselineMain": "3921e59",
|
||||
"hostFileSha256": "3f70e9769c6c95d39cfa329ccf3f822df85d411e9295bdf888508b4738679e9d",
|
||||
"realFrame": {
|
||||
"platform": "Frame OS 0.5.6 ARM64",
|
||||
"path": "SSH via lxso2; Steam SharedJSContext CDP",
|
||||
"lock": "/tmp/frame-test.lock atomic mkdir; matching owner released in finally after every probe",
|
||||
"apps": 1106,
|
||||
"games": 895,
|
||||
"compactBytesWithoutUser": 88451,
|
||||
"invalidRows": 0,
|
||||
"missingGameOverviews": 0,
|
||||
"currentUser": "17-digit string verified; value withheld",
|
||||
"mutations": "none; no wake, service, setting, screenshot or library changes; no device artifacts created",
|
||||
"limits": "read-only CEF projection, not an official backend or worn-VR test"
|
||||
},
|
||||
"browser": {
|
||||
"mode": "opaque iframe Chromium; detached real Frame snapshot replay; all backend/network actions denied",
|
||||
"attempted": 116,
|
||||
"initializerReady": 101,
|
||||
"failed": 15,
|
||||
"initializerGains": [
|
||||
46,
|
||||
53,
|
||||
66
|
||||
],
|
||||
"regressions": [],
|
||||
"focused": [
|
||||
{
|
||||
"id": 37,
|
||||
"name": "Discord Status",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "TypeError: saveInfos.map is not a function"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"set_account_id",
|
||||
"get_saveinfos"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"name": "Shotty",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"set_id_map_fronend"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Failed to set current user: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Error: Read-only compatibility audit: backend method blocked"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"set_current_user",
|
||||
"fetch_playtime_information",
|
||||
"statistics_for_last_two_weeks",
|
||||
"daily_statistics_for_period"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: Error: Read-only compatibility audit: live Steam API blocked"
|
||||
}
|
||||
],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 132,
|
||||
"name": "SuggestMe",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load config: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load library status: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load play next list: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load excluded games: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load filter presets: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load history: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load non-steam info: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load collections: Error: Read-only compatibility audit: backend method blocked"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"log_message",
|
||||
"get_config",
|
||||
"get_library_status",
|
||||
"get_play_next_list",
|
||||
"get_excluded_games",
|
||||
"get_filter_presets",
|
||||
"get_suggestion_history",
|
||||
"get_non_steam_games",
|
||||
"get_collections",
|
||||
"get_available_genres",
|
||||
"get_available_tags",
|
||||
"get_available_community_tags"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 134,
|
||||
"name": "Deck Progress Tracker",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_settings",
|
||||
"get_tag_statistics"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 135,
|
||||
"name": "Deck Shelves",
|
||||
"ready": false,
|
||||
"error": "Failed to resolve module specifier \"./chunks/pluginApi-B8sV7970.js\". Invalid relative url or base scheme isn't hierarchical.",
|
||||
"logs": [],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fakeFrame": {
|
||||
"attempted": 116,
|
||||
"installedRemoved": 60,
|
||||
"expectedBlockers": 56,
|
||||
"backendExecution": "stubbed",
|
||||
"cleanup": "private Compose containers/network/volumes removed"
|
||||
},
|
||||
"localBackends": {
|
||||
"timeUtc": "2026-10-26T02:18:51.848256+00:00",
|
||||
"verification": "local Chromium + actual Decky 3.3.0 amd64, detached real Frame SSH snapshot; no worn VR",
|
||||
"calls": [
|
||||
{
|
||||
"id": 46,
|
||||
"method": "set_account_id",
|
||||
"resultType": "dict",
|
||||
"resultCount": 2
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"method": "get_saveinfos",
|
||||
"resultType": "dict",
|
||||
"resultCount": 2
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "set_current_user",
|
||||
"resultType": "NoneType"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "statistics_for_last_two_weeks",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "fetch_playtime_information",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "daily_statistics_for_period",
|
||||
"resultType": "dict",
|
||||
"resultCount": 3
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "fetch_playtime_information",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "statistics_for_last_two_weeks",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"tag": "v3.3.0",
|
||||
"name": "PluginLoader",
|
||||
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
|
||||
},
|
||||
"legacyReadback": {
|
||||
"plugin": 46,
|
||||
"method": "get_saveinfos",
|
||||
"replyType": 1,
|
||||
"success": true,
|
||||
"saveInfoCount": 0
|
||||
}
|
||||
},
|
||||
"uiChecks": {
|
||||
"Steamback": "Supported games panel, actual private backend set_account_id and get_saveinfos returned successfully; empty isolated Steam save data",
|
||||
"PlayTime": "Statistics panel (empty database) and routed General settings/tabs rendered; actual private backend selected user and returned statistics",
|
||||
"Deck Settings": "Installed game picker rendered real Frame install-folder/name snapshot; search modal rendered",
|
||||
"Shotty": "Initializer repaired; actual Decky 3.3.0 backend fails importing unused click.get_app_dir: ModuleNotFoundError; no screenshot methods invoked"
|
||||
},
|
||||
"notVerified": [
|
||||
"Real ARM64 official backends in this theme",
|
||||
"Worn VR/controllers/keyboard",
|
||||
"Live MobX or running-game observations",
|
||||
"Steamback backups/restores and automatic notifications",
|
||||
"PlayTime automatic recording, existing databases or account switches while panel remains open",
|
||||
"Deck Settings remote reports/proxy fetch and settings changes",
|
||||
"Shotty screenshot aggregation/rescue"
|
||||
],
|
||||
"cleanup": {
|
||||
"localContainer": "stopped/removed; docker ps -a matching name empty",
|
||||
"headsetLockReleased": true,
|
||||
"httpHelpers": "three task-local HTTP servers stopped"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": 37,
|
||||
"name": "Discord Status",
|
||||
"version": "1.5.0",
|
||||
"sha256": "00a45dd919abb6e050685e1e408ce8023b9c4a09d6e91ba528d50aed2a63bf45"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"version": "1.0.0-1",
|
||||
"sha256": "ed5f2fbb47cdfc3aa9a4349a139ab20c29976117a3be0de7e8a588bb265ee7f3"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"name": "Shotty",
|
||||
"version": "0.1.0",
|
||||
"sha256": "ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"version": "3.2.5",
|
||||
"sha256": "10fc3beea48991c77cb02867e919b618d4cd6d5e9c6a6d0ccd5266c13214039f"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"version": "1.2.6",
|
||||
"sha256": "23c2a933a6883e2622f0184c33a76ce4a4e88c0a7dcd730f9bab71b8c3702132"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"serverTests": 1617,
|
||||
"serverSkips": 16,
|
||||
"siteTests": 18,
|
||||
"fakeFramePanel": "2 targeted panel/store flows passed; private Compose resources removed",
|
||||
"focusedFrameyTests": 40
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
# Framey detached Steam library context — 2026-10-26
|
||||
|
||||
Framey now reads a bounded library snapshot before importing official frontends
|
||||
that reference `appStore`, `collectionStore` or `m_CurrentUser`. It supplies app
|
||||
IDs/names/types/game IDs, sorting/playtime scalars, the actual all-games
|
||||
collection and the current public Steam ID. It does not copy credentials,
|
||||
balances, account names, filesystem paths or executable Steam objects.
|
||||
|
||||
The projection is a fixed, parameterless `GetLibrarySnapshot` read through the
|
||||
existing installed-store-plugin boundary. The parent checks the iframe source,
|
||||
session, method and empty argument list. The host validates rows before exposing
|
||||
detached Maps and lookup methods. Plugin patches remain local to that iframe.
|
||||
Missing or invalid data leaves the globals unavailable and logs a clear warning;
|
||||
there is no invented account or empty-library fallback. Reopening the panel
|
||||
refreshes the snapshot. Live MobX observations, account-switch notifications and
|
||||
running-game events are outside this change.
|
||||
|
||||
Library-sized backend arguments now have a 256 KiB UTF-8 JSON budget. The old
|
||||
8 KiB ceiling rejected normal app-name maps. Payloads travel as quoted literals
|
||||
on SSH stdin, avoiding operating-system limits on a single command argument.
|
||||
Store receipt ownership, method validation and legacy keyword envelopes still
|
||||
apply. The library read has a separate 2 MiB / 10,000-app limit; other Steam
|
||||
reads retain their 256 KiB response limit.
|
||||
|
||||
## Observations
|
||||
|
||||
| Plugin | Verified behavior | Remaining limit |
|
||||
| --- | --- | --- |
|
||||
| Steamback 1.0.0-1 | Panel opens; account setup and empty save-list reads succeed through actual Decky 3.3.0 amd64 | Private container had no Steam saves. ARM64 backend, backup/restore and automatic hooks unverified. Partial. |
|
||||
| PlayTime 3.2.5 | Actual backend selects the user, returns empty statistics; statistics panel and routed settings/tabs render | No sessions were created. ARM64 backend, existing databases, live observations and automatic recording unverified. Partial. |
|
||||
| Deck Settings 1.2.6 | Installed game picker renders the real Frame folder/name snapshot; search modal opens | Remote reports/proxy fetch, settings changes and ARM64 backend unverified. Partial. |
|
||||
| Shotty 0.1.0 | Library-based initializer now completes | Exact backend fails importing `click.get_app_dir`: `ModuleNotFoundError: No module named 'click'`. Archive does not bundle it. Broken. |
|
||||
| Discord Status 1.5.0 | Detached overview lookups available | Live running-app observations and artwork helpers remain absent; Discord activity unverified. Broken. |
|
||||
|
||||
Exact plugin versions/digests, runtime digest, scoped calls and counts are in the
|
||||
[redacted observations](framey-library-context-2026-10-26.json). The canonical
|
||||
table/store labels use the same digest-scoped JSON. None of these plugins is
|
||||
promoted to **works** by this pass.
|
||||
|
||||
## Verification boundaries
|
||||
|
||||
- **Real Frame SSH:** atomic `/tmp/frame-test.lock` acquisition for every probe,
|
||||
matching-owner release in `finally`. Frame OS 0.5.6 ARM64 Steam CEF returned
|
||||
1,106 apps and 895 game IDs; no missing collection members or invalid rows.
|
||||
The compact library-only response was 88,451 bytes. Current-user field shape
|
||||
was separately inspected. Values remain private; only aggregate counts are
|
||||
published. No wake, power/service changes or device artifacts were needed.
|
||||
- **Local Chromium:** production `headset.html`, current host, opaque iframe and
|
||||
unmodified digest-checked official frontends. Library/user and install-folder
|
||||
data were detached snapshots read from the Frame, replayed locally. This is
|
||||
not a deployed headset HTTP or worn-VR test.
|
||||
- **Actual local backends:** published Decky 3.3.0 amd64 executable, private
|
||||
unprivileged container, network disabled, capabilities dropped, no devices,
|
||||
host home, saves or Steam files mounted. Only account setup within private
|
||||
plugin data and read methods were allowed. Backup, restore, aggregation,
|
||||
screenshot rescue and game actions were denied. Shotty's import failure was
|
||||
observed from the runtime log before invoking screenshot methods.
|
||||
- **Catalog regression:** 116 exact frontends attempted, 101 initializer-ready
|
||||
and 15 failed, compared with 98 ready before this theme. Steamback, Shotty and
|
||||
PlayTime gained initializer readiness; no previous ready state regressed.
|
||||
Backend/network calls stayed denied in this sweep. Readiness alone does not
|
||||
establish useful operation.
|
||||
- **Fake Frame:** 116 archive/transport cases; 60 install/remove cycles and 56
|
||||
expected blockers. Official backend execution was stubbed. Its private
|
||||
Compose containers, network and volumes were removed.
|
||||
|
||||
The browser audit accepts `--library-snapshot /private/library.json` to reproduce
|
||||
the detached replay. This flag does not convert the audit into live Steam or
|
||||
backend evidence. Never commit personal library snapshots or account values.
|
||||
|
||||
No worn-VR, physical controller, keyboard, automatic recording or backup claim
|
||||
is made. Further device backend checks still require the shared hardware lock.
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26",
|
||||
"plugin": {
|
||||
"id": 125,
|
||||
"name": "Decky Localsend",
|
||||
"officialArchiveStatus": "x86_64; blocked; no substitutions or safeguard changes"
|
||||
},
|
||||
"source": {
|
||||
"url": "https://github.com/MoYoez/LocalSend-Go",
|
||||
"revision": "66edc509a28b9f877e7374504892dcbc653a4da4",
|
||||
"license": "MIT",
|
||||
"licenseUrl": "https://github.com/MoYoez/LocalSend-Go/blob/66edc509a28b9f877e7374504892dcbc653a4da4/LICENSE",
|
||||
"pluginLicense": "BSD-3-Clause; distinct from the MIT helper",
|
||||
"officialElfBuildInfo": "Go 1.25.7, CGO_ENABLED=0, GOARCH=amd64; no embedded VCS revision. Exact source correspondence unproven."
|
||||
},
|
||||
"build": {
|
||||
"toolchain": "go1.25.7",
|
||||
"environment": {
|
||||
"GOTOOLCHAIN": "go1.25.7",
|
||||
"CGO_ENABLED": "0",
|
||||
"GOOS": "linux",
|
||||
"GOARCH": "arm64"
|
||||
},
|
||||
"command": "go build -trimpath -o localsend-core .",
|
||||
"elfMachine": 183,
|
||||
"architecture": "aarch64",
|
||||
"staticallyLinked": true,
|
||||
"sha256": "e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9",
|
||||
"rebuildSha256Matches": true
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6 ARM64",
|
||||
"sharedLockOwner": "kp0z857q58-live-steam-reads-20261026",
|
||||
"helpExitCode": 0,
|
||||
"officialBackendFlagsAllRecognized": true,
|
||||
"helpFlags": [
|
||||
"-doNotMakeSessionFolder",
|
||||
"-log",
|
||||
"-scanTimeout",
|
||||
"-skipNotify",
|
||||
"-useAlias",
|
||||
"-useAutoSave",
|
||||
"-useAutoSaveFromFavorites",
|
||||
"-useConfigPath",
|
||||
"-useDefaultUploadFolder",
|
||||
"-useDownload",
|
||||
"-useHttp",
|
||||
"-useMultcastAddress",
|
||||
"-useMultcastPort",
|
||||
"-usePin",
|
||||
"-useReferNetworkInterface",
|
||||
"-useWebOutPath"
|
||||
],
|
||||
"runtimeExitCode": 0,
|
||||
"isolation": "unshare -Urn; loopback and private veth pair only; no external interface/default route",
|
||||
"receiving": "-useAutoSave=false and -useAutoSaveFromFavorites=false; no upload/transfer requests",
|
||||
"notifications": "-skipNotify",
|
||||
"readOnlyRequest": "GET http://127.0.0.1:53317/api/localsend/v2/info",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"protocolVersion": "2.0",
|
||||
"deviceType": "headless",
|
||||
"responseKeys": [
|
||||
"alias",
|
||||
"deviceModel",
|
||||
"deviceType",
|
||||
"download",
|
||||
"fingerprint",
|
||||
"version"
|
||||
],
|
||||
"aliasMatches": true
|
||||
},
|
||||
"cleanup": {
|
||||
"helperExited": true,
|
||||
"filesCreated": [
|
||||
"config.yaml",
|
||||
"helper.log"
|
||||
]
|
||||
},
|
||||
"privateDirectoryAbsent": true,
|
||||
"servicesInstalledOrStarted": false,
|
||||
"powerChanges": false,
|
||||
"sharedLockReleasedAfterTests": true,
|
||||
"independentReacquireAudit": "Deferred: another job had acquired the shared lock; it was left untouched. The helper process exited and TemporaryDirectory removal completed within the original locked probe."
|
||||
},
|
||||
"failedProbe": {
|
||||
"interface": "loopback only",
|
||||
"result": "Upstream rejects lo as a discovery interface and exits. Repeated with a veth pair wholly inside the private namespace; no source changes."
|
||||
},
|
||||
"unverified": [
|
||||
"Exact source correspondence to shipped official ELF",
|
||||
"Unmodified official Python backend invoking this ARM64 artifact",
|
||||
"HTTPS, discovery on real network, pairing and file transfers",
|
||||
"Framey frontend/proxy protocol integration",
|
||||
"Worn VR/controller/keyboard"
|
||||
]
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
# LocalSend ARM64 build and isolated Frame probe
|
||||
|
||||
The official Decky Localsend archive remains **broken/blocked**: its helper is
|
||||
x86_64. A source rebuild is now proven to execute on the ARM64 Frame and answer
|
||||
the LocalSend 2.0 info endpoint. This is a core runtime result, not a working
|
||||
official-plugin or file-transfer claim. [Machine-readable evidence](framey-localsend-arm64-2026-10-26.json)
|
||||
records the exact scope and cleanup.
|
||||
|
||||
## Reproduce the build
|
||||
|
||||
The helper is MIT-licensed; the separate official Python plugin is BSD-3-Clause.
|
||||
Use the pinned helper source and retain its license for any redistribution.
|
||||
No binary is redistributed in Frame Control, and no installer digest or
|
||||
architecture safeguard was changed.
|
||||
|
||||
```sh
|
||||
framey_build_dir=$(mktemp -d)
|
||||
git clone https://github.com/MoYoez/LocalSend-Go "$framey_build_dir/source"
|
||||
git -C "$framey_build_dir/source" checkout --detach 66edc509a28b9f877e7374504892dcbc653a4da4
|
||||
cd "$framey_build_dir/source"
|
||||
env GOTOOLCHAIN=go1.25.7 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
||||
go build -trimpath -o "$framey_build_dir/localsend-core" .
|
||||
sha256sum "$framey_build_dir/localsend-core"
|
||||
```
|
||||
|
||||
Two builds produced the same static aarch64 ELF SHA-256:
|
||||
`e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9`.
|
||||
The official ELF uses Go 1.25.7 too, but embeds no VCS revision, so the exact
|
||||
source correspondence is still unproven. The rebuilt helper recognizes every
|
||||
CLI flag supplied by the official Python backend.
|
||||
|
||||
## Real Frame scope
|
||||
|
||||
Tests ran under the normal atomic `/tmp/frame-test.lock` with a private temporary
|
||||
directory. `-h` exited before configuration or networking. For the health probe,
|
||||
`unshare -Urn` created a private user/network namespace with loopback and a veth
|
||||
pair entirely inside it: no external interface or default route. The veth used
|
||||
the documentation-only `192.0.2.1/24` address. Upstream rejects a loopback-only
|
||||
discovery interface, which is why the second isolated attempt used a veth.
|
||||
|
||||
The helper used a private config/upload path, `-skipNotify`,
|
||||
`-useAutoSave=false`, `-useAutoSaveFromFavorites=false`, `-useHttp=true`,
|
||||
`-useReferNetworkInterface probe0` and `-scanTimeout 1`. The test client sent only
|
||||
`GET /api/localsend/v2/info` over loopback; it returned version `2.0`, device type
|
||||
`headless` and the private test alias. No upload, transfer, pairing or external
|
||||
discovery request ran. The helper exited; its private config/log and executable
|
||||
directory were removed; no service or power setting changed. The lock was
|
||||
released. An independent cleanup reacquisition found another job's lock and
|
||||
left it alone; cleanup success comes from the original probe's process wait and
|
||||
temporary-directory exit, not that deferred audit.
|
||||
|
||||
Before enabling an ARM64 official package, verify the unmodified Python backend
|
||||
and frontend/proxy protocol with this source revision, then HTTPS, discovery and
|
||||
an explicitly safe private transfer. The official store must publish a matching
|
||||
ARM64 archive or Frame Control must gain a separately reviewed, digest-scoped
|
||||
build/package path. Compiling the core does not justify accepting the current
|
||||
x86 archive or marking the full plugin works.
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-25",
|
||||
"source": "https://plugins.deckbrew.xyz/plugins",
|
||||
"method": "Downloaded exact current official store versions; local SHA-256 matched each store hash; inspected ZIP plugin.json and dist/index.js without running plugin code.",
|
||||
"limit": "Static inspection on PC. Prior backend load results are documented in docs/decky-frame.md; VR visibility and controller input remain unverified.",
|
||||
"plugins": [
|
||||
{
|
||||
"storeId": 7,
|
||||
"name": "CSS Loader",
|
||||
"storeVersion": "2.1.3",
|
||||
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 14,
|
||||
"name": "ProtonDB Badges",
|
||||
"storeVersion": "1.3.4",
|
||||
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
|
||||
"manifestApiVersion": 2,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 16,
|
||||
"name": "Audio Loader",
|
||||
"storeVersion": "1.6.1",
|
||||
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [
|
||||
"_root"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 18,
|
||||
"name": "Bluetooth",
|
||||
"storeVersion": "3.1.1",
|
||||
"sha256": "f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 21,
|
||||
"name": "PowerTools",
|
||||
"storeVersion": "2.0.3",
|
||||
"sha256": "47614f53b8c538c4caa15f89a01e4ab106fa328e89f78545bacb3166d104d964",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [
|
||||
"root",
|
||||
"global-dfl"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 23,
|
||||
"name": "Animation Changer",
|
||||
"storeVersion": "1.3.2",
|
||||
"sha256": "f2c62b90ca60d8a80b6d0f75d8027552b1509c7a05842c6f4a24a9072846d133",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 26,
|
||||
"name": "HLTB for Deck",
|
||||
"storeVersion": "2.0.12",
|
||||
"sha256": "bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 36,
|
||||
"name": "SteamGridDB",
|
||||
"storeVersion": "1.8.1",
|
||||
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [
|
||||
"debug"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 50,
|
||||
"name": "Storage Cleaner",
|
||||
"storeVersion": "1.4.0",
|
||||
"sha256": "1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 68,
|
||||
"name": "Wine Cellar",
|
||||
"storeVersion": "0.2.1",
|
||||
"sha256": "7e077b933c234414d172b5c6d1c804b026bf39fa4e18e6663495a282637031e8",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [
|
||||
"debug"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 82,
|
||||
"name": "MagicPods",
|
||||
"storeVersion": "2.0.21",
|
||||
"sha256": "abe2eef5788cb3ff6e73fcb9783e30a54b127e28c761a9bf99e64ab5fc4be9ab",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"timeUtc": "2026-10-25T23:35:22.022586+00:00",
|
||||
"method": "Real Frame ARM64 Decky 3.3.0, isolated removable user service; explicit read-only plugin methods only",
|
||||
"plugins": [
|
||||
{
|
||||
"id": 14,
|
||||
"version": "1.3.4",
|
||||
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
|
||||
"method": "get_setting",
|
||||
"result": {
|
||||
"result": "default"
|
||||
},
|
||||
"errors": [
|
||||
"Transient CEF reconnect during Decky frontend injection; succeeded on retry."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 123,
|
||||
"version": "1.2.3",
|
||||
"sha256": "9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e",
|
||||
"method": "get_settings",
|
||||
"result": {
|
||||
"keys": [
|
||||
"enable_notification_antiflood",
|
||||
"enable_notifications",
|
||||
"gateway_api_key",
|
||||
"gateway_url",
|
||||
"refresh_interval_seconds",
|
||||
"show_history",
|
||||
"show_regions",
|
||||
"show_trending_games"
|
||||
],
|
||||
"defaultRefreshInterval": 120
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"service": {
|
||||
"created": true
|
||||
},
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"passed": true,
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"lockReleased": true,
|
||||
"cleanupErrors": []
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
# Framey session cache browser check, 25 October 2026
|
||||
|
||||
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
|
||||
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
|
||||
used a per-panel memory substitute. It did **not** change the iframe's sandbox
|
||||
flags or give plugin code Frame Control's UI key.
|
||||
|
||||
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
|
||||
from `tests/fixtures/framey-official/123.zip` (SHA-256
|
||||
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
|
||||
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
|
||||
four `get_settings` calls. That reply was a stub, not the Decky backend.
|
||||
|
||||
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
|
||||
The parent could not read the iframe document (`contentDocument` was null).
|
||||
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
|
||||
Managers and Players Online. Its public status request returned HTTP 200.
|
||||
No cache `SecurityError` appeared in the captured console, while the
|
||||
same archive had logged that error with the v0.4.34 host.
|
||||
|
||||
Node host tests also checked item updates, removal, clear, key order, string
|
||||
conversion, quota rejection, and preservation of an existing item after a
|
||||
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
|
||||
values **in memory**. Closing or reloading the panel loses this cache. This
|
||||
check did not test offline behavior, persistent plugin settings, a live Decky
|
||||
backend, SteamVR visibility, controller input, or headset performance.
|
||||
@@ -1,99 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:40:00Z",
|
||||
"scope": "Actual SteamClient parameterless reads through Steam CEF on ARM64 Frame. Official frontends ran in local Chromium with plugin backend/network calls denied. Private loopback relay used the production read bridge with fixture installed-plugin status and the real frame_steam module supplied in memory because the optional headset service was not deployed. This does not verify a deployed real-Frame HTTP endpoint.",
|
||||
"allowlist": [
|
||||
"User.GetIPCountry",
|
||||
"InstallFolder.GetInstallFolders",
|
||||
"System.GetSystemInfo"
|
||||
],
|
||||
"probeResults": {
|
||||
"GetIPCountry": {
|
||||
"ok": true,
|
||||
"type": "string",
|
||||
"length": 2,
|
||||
"value": "redacted"
|
||||
},
|
||||
"GetInstallFolders": {
|
||||
"ok": true,
|
||||
"folders": 1,
|
||||
"apps": 29,
|
||||
"pathsAndTitles": "redacted"
|
||||
},
|
||||
"GetSystemInfo": {
|
||||
"ok": true,
|
||||
"fields": 27,
|
||||
"identifiers": "redacted"
|
||||
}
|
||||
},
|
||||
"officialPanels": {
|
||||
"IsThereAnyDeal for Deck": {
|
||||
"observation": {
|
||||
"id": 81,
|
||||
"name": "IsThereAnyDeal for Deck",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"settings_load",
|
||||
"settings_save"
|
||||
]
|
||||
},
|
||||
"countryDropdownMatchesLiveFrameValue": true,
|
||||
"backendSettingsSave": "attempted by official frontend; denied; no setting written",
|
||||
"dealLookups": "not run"
|
||||
},
|
||||
"Deck Settings": {
|
||||
"observation": {
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: TypeError: Cannot read properties of undefined (reading 'allGamesCollection')"
|
||||
}
|
||||
],
|
||||
"methods": []
|
||||
},
|
||||
"remainingBlocker": "collectionStore.allGamesCollection"
|
||||
}
|
||||
},
|
||||
"boundariesTested": [
|
||||
"Only three explicit zero-argument APIs",
|
||||
"Installed store plugin required",
|
||||
"Headset-service gate",
|
||||
"Iframe source and current session required",
|
||||
"Late replies dropped after reopen",
|
||||
"256 KiB UTF-8 response cap and socket cleanup"
|
||||
],
|
||||
"deviceState": {
|
||||
"sharedLock": "owned normally; released after testing",
|
||||
"servicesInstalledOrStarted": false,
|
||||
"powerChanges": false,
|
||||
"deviceFilesForSteamProbe": false
|
||||
},
|
||||
"unverified": [
|
||||
"Deployed headset HTTP endpoint",
|
||||
"Plugin backend settings/deal lookups",
|
||||
"Steam page patches and live library context",
|
||||
"Worn VR/controller/keyboard"
|
||||
],
|
||||
"catalogRefresh": {
|
||||
"source": "https://plugins.deckbrew.xyz/plugins",
|
||||
"checkedAtUtc": "2026-10-26T00:44:00Z",
|
||||
"visiblePlugins": 116,
|
||||
"idVersionDigestChanges": 0
|
||||
},
|
||||
"localRegressionAudit": {
|
||||
"exactArchivedFrontends": 116,
|
||||
"ready": 98,
|
||||
"failed": 18,
|
||||
"initializationRegressions": 0,
|
||||
"pluginBackendAndNetworkActions": "denied, including live Steam read APIs"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# Steam Status in the local Framey browser, 25 October 2026
|
||||
|
||||
This check used the unmodified official Steam Status 1.2.3 archive already
|
||||
pinned in `tests/fixtures/framey-official/123.zip`. Its SHA-256 is
|
||||
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`.
|
||||
The archive declares Decky API v2. No headset was contacted for this check.
|
||||
|
||||
On a Linux PC, we extracted the archive to a disposable plugin folder with a
|
||||
Frame Control store receipt. A local HTTP server served the page from
|
||||
`framey_panel.panel(123)`, Frame Control's React 18.3.1 runtime and its
|
||||
`framey-host.js`. A parent page put the panel in an iframe with
|
||||
`sandbox="allow-scripts"` and returned `{}` for the plugin's `get_settings`
|
||||
backend calls. This was a **stub**, not the Decky service.
|
||||
|
||||
Chromium reported `framey:ready` and four `get_settings` calls. The page showed
|
||||
Steam Status, Store, Community, WebAPI, Connection Managers, Players Online,
|
||||
and other controls. Its own public `https://steamstatus.schelstraete.org/api/v1/status`
|
||||
request returned HTTP 200 and populated the status view. A click on **Refresh**
|
||||
sent Framey's trusted pointer event to the parent; this test did not prove a
|
||||
fresh status fetch or settings write. The [browser screenshot](framey-steam-status-browser-2026-10-25.png)
|
||||
contains only the local test page and public service data.
|
||||
|
||||
The plugin logged `SecurityError` while reading and writing `localStorage` in
|
||||
the opaque-origin iframe. It caught the errors and rendered anyway. Its cache
|
||||
and offline behavior in Framey are **not verified**. The real Decky backend,
|
||||
live settings, SteamVR visibility, controller input and headset performance
|
||||
were not tested. This result supports only local frontend rendering for this
|
||||
exact archive and Frame Control v0.4.34 host code.
|
||||
|
Before Width: | Height: | Size: 69 KiB |
@@ -1,107 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:29:00Z",
|
||||
"scope": "Exact archived frontends only. Production Framey host with bounded SteamClient JSON cache, image fallback and inert Steam subscriptions/context. Opaque iframe; all backend calls and network denied. No headset or plugin backends used in this follow-up.",
|
||||
"baseHostCommit": "52a0974",
|
||||
"before": {
|
||||
"ready": 96,
|
||||
"failed": 20
|
||||
},
|
||||
"after": {
|
||||
"ready": 98,
|
||||
"failed": 18
|
||||
},
|
||||
"regressions": [],
|
||||
"observations": [
|
||||
{
|
||||
"id": 13,
|
||||
"name": "Pause Games",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"load_settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"name": "Tailscale Control",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_tailscale_state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_CurrentUser')",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_mapApps')",
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"fetch_playtime_information",
|
||||
"statistics_for_last_two_weeks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 120,
|
||||
"name": "Reroll",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_setting",
|
||||
"version"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tailscaleFocusedCheck": {
|
||||
"rendered": [
|
||||
"Settings",
|
||||
"Toggle Tailscale",
|
||||
"Exit Node IP",
|
||||
"Toggle LAN Access",
|
||||
"Advanced Settings",
|
||||
"Device Status",
|
||||
"Tailscale Setup"
|
||||
],
|
||||
"assetError": "Hardcoded localhost:1337 QR SVG blocked by image CSP",
|
||||
"actions": "Inspected only; no toggles, exit nodes, installs or connection actions"
|
||||
},
|
||||
"pauseGamesFocusedCheck": {
|
||||
"rendered": [
|
||||
"Pause before Suspend",
|
||||
"Pause on focus loss"
|
||||
],
|
||||
"actions": "Inspected only; no process signaling or suspend actions"
|
||||
},
|
||||
"storageChecks": [
|
||||
"Per-plugin cache survives panel reopen",
|
||||
"Missing key rejects Not found",
|
||||
"JSON serialization and cache quota enforced",
|
||||
"Malformed and stale-session cache updates rejected"
|
||||
],
|
||||
"unverified": [
|
||||
"Real backends for these plugins",
|
||||
"Tailscale network state/actions",
|
||||
"Live games and pause/resume",
|
||||
"Worn VR/controller/keyboard"
|
||||
]
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
# Framey unattended VR evidence, 25 October 2026 UTC
|
||||
|
||||
The committed runner exited **0** on the real Frame, using the existing
|
||||
`ssh lxso2 'ssh frame …'` route from lxso1. No person wore the headset.
|
||||
Frame OS **0.5.6**, build **20261022.6153909**, ARM64, SteamVR **2.18.2**,
|
||||
Decky **3.3.0**. The run began at **23:27:34 UTC**; the Frame's own clock/logs
|
||||
use UTC+11 and therefore show 26 October.
|
||||
|
||||
```sh
|
||||
python3 scripts/framey-vr-check.py --via lxso2 --exercise --output /tmp/framey-vr-verified
|
||||
```
|
||||
|
||||
[Full machine result](result.json) records source commit
|
||||
`cb9ad1b6cb7302008783d5f7fe42fa77a4dd43af`, bundle digest, before/after
|
||||
SteamVR listings, power settings, input observations, and private image hashes.
|
||||
The checked app code includes the sample settings-path and iframe-reopen fixes
|
||||
and main through `d77e2bd`. This is source-bundle testing, not a newly published
|
||||
release/AppImage test. The sample has a random test name and store-shaped local
|
||||
receipt; it is not presented as an official catalogue listing.
|
||||
|
||||
| Check | Observed | Scope |
|
||||
| --- | --- | --- |
|
||||
| Overlay registration and visibility | `IVROverlay_027.IsOverlayVisible=true` | Actual Frame Control gamescope overlay, not a browser fixture |
|
||||
| Transform | type **5**, DashboardTab; absolute getter returns **18**, WrongTransformType | Dashboard owns placement; no transform/dock setters used |
|
||||
| Texture queries | main size 0×0; image read **24**, InvalidTexture | Direct texture refresh not established; compositor capture used instead |
|
||||
| Stereo compositor | Before/after SHA-256 differ; final image visibly contains Framey tabs, quick action and saved result | Actual `IVRScreenshots_001` production screenshot path |
|
||||
| OBS mirror | Current run's random name and “Saved setting is on” appear after draining 60 frames | `/dev/video99` pixels; OBS app itself was not launched in this task |
|
||||
| OpenVR click | Move/down/up reaches intended control; iframe pointer event is trusted | `IVROverlayView_003.PostOverlayEvent`, no DOM `.click()` |
|
||||
| Live setting | off → on; real backend file `{"enabled":true}`; fresh iframe reads on | Actual Framey HTTP → Steam CEF → Decky websocket → Python backend; no stubs |
|
||||
| Tabs/quick action | Settings/Panel clicked; “Saved setting is on” returned | Actual shipped host controls and sample action |
|
||||
| Slider | 10 → **50** | Native OpenVR click on actual DFL slider, diagnostic UI-only value |
|
||||
| Text | **framey-ci** | OpenVR pointer focus, then CDP `Input.insertText`; not SteamVR keyboard proof |
|
||||
| Smooth scroll attempt | `scrollBefore=0`, `scrollAfter=0` | No scrolling established by this attempt; do not count it as a pass |
|
||||
| Battery | **99% → 99%** | Probed only under the owned lock |
|
||||
| Power restoration | original pause **false**, timeout **5.000000**, exact values read back | Temporary values false/120; no idle-suspend timer or proximity changes |
|
||||
| Cleanup | `cleanupErrors=[]`, `powerRecoveryRemoved=true`, `lockReleased=true` | Temporary managed services, sample/settings/profile, timer and restore script removed |
|
||||
|
||||
`IsDashboardVisible` and `IsActiveDashboardOverlay` are false while the panel is
|
||||
visible in these captures: this is an existing placed/docked panel. They cannot
|
||||
be used alone as a Framey visibility verdict. Activity changes from standby 3
|
||||
to active 0 without wearing the headset, so activity alone is not worn proof.
|
||||
Native OpenVR events also produce trusted pointers, so `event.isTrusted` alone
|
||||
is not physical-controller proof. The worn script now requires explicit
|
||||
`--worn-confirmed` and reports trusted pointer input with that narrower name.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
These are inset panel crops from the raw captures, with all other pixels
|
||||
replaced by solid black. No UI content was synthesized. [Image provenance](images.json)
|
||||
contains source hashes, mask polygons, crops and published hashes. Raw images
|
||||
remain private under `/tmp/framey-vr-verified` on lxso1. They are not committed.
|
||||
|
||||
## Bugs found and fixed
|
||||
|
||||
1. The author sample used `decky.decky_SETTINGS_DIR`, absent from real Decky
|
||||
3.3.0. The actual iframe displayed its backend `AttributeError`. Decky's
|
||||
[pinned module](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/plugin/imports/decky.py)
|
||||
exports `DECKY_PLUGIN_SETTINGS_DIR`. The sample, docs and Python test fixture
|
||||
now use that name. The old fixture invented the same wrong attribute and
|
||||
masked the failure. The live fixed backend save/reopen is in `result.json`.
|
||||
2. Reopening the same plugin changed only `#session`, so Chromium retained the
|
||||
old document/host/cache session. It could retain an error and eventually
|
||||
show a false “No compatible panel loaded” message. A fresh query parameter
|
||||
forces a new document while keeping the fragment session. The cache
|
||||
regression now checks document URLs, rather than manually assuming a new
|
||||
iframe. The real reopen with the saved setting passed.
|
||||
|
||||
## Methods tried and limits
|
||||
|
||||
Read-only `vrcmd` stats/overlay listings, the shipped OpenVR ABI, compositor
|
||||
logs and actual CEF/Chromium targets were inspected first. A simple
|
||||
`--handlewakeup` initially produced the Frame Control status panel in a stereo
|
||||
screenshot. The full test uses a bounded screen timeout so it stays active.
|
||||
Decky's service startup reloads Steam's web helper. During exploratory service
|
||||
recreation, SteamVR logged `UpdateInputFocus: Failed to give focus to panel`
|
||||
and returned a hidden, zero-sized main overlay despite a successful focus
|
||||
request. Retagging only the test browser's `STEAM_GAME` property recovered it;
|
||||
no Steam, SteamVR, gamescope or headset restart was needed. The final run did
|
||||
not need this recovery. The runner records it when used.
|
||||
|
||||
The first mirror image was demonstrably stale: it contained a previous test's
|
||||
random plugin name while the contemporaneous stereo image contained the current
|
||||
one. Reading 60 frames before selecting the mirror image fixed that observation;
|
||||
the final two published images contain the same current run name.
|
||||
|
||||
The diagnostic itself initially failed its cleanup check because an idle
|
||||
systemd watchdog service was already unloaded. It restored power and removed
|
||||
the test services, retained the lock, then the exact owned remainder was cleaned
|
||||
up. Cleanup now checks `LoadState` and tolerates an already-unloaded transient
|
||||
unit. Subsequent full runs, including the committed run here, exited 0 with no
|
||||
cleanup errors. A four-minute power-recovery timer was created and cancelled;
|
||||
forced host death or network loss was not fault injected.
|
||||
|
||||
A null driver or virtual tracked controller was not needed for pointer delivery
|
||||
and was not installed. No root/system files, tracking/proximity configuration,
|
||||
Steam global binaries, or spatial transforms were edited. PR #113 was untouched.
|
||||
This check does not establish every official plugin, sustained frame rate,
|
||||
backend persistence across Steam/Decky reboot, physical controller tracking or
|
||||
haptics, a functioning virtual keyboard, native scrolling, comfort or legibility
|
||||
through the optics. Follow the [three-minute worn checklist](../../framey-vr-check.md#three-minute-worn-checklist).
|
||||
|
||||
## Local checks
|
||||
|
||||
- Full Python suite after integrating current main and worn-confirmation gate:
|
||||
**1,595 tests, 16 skipped, OK**. The final added scroll/mirror helpers also
|
||||
passed the **24 focused Framey tests** and the real-device run above.
|
||||
- App and website test commands passed under **Node 24.22.1**, matching CI's
|
||||
Node major. System Node 22 cancelled unrelated Meta sign-in timer tests;
|
||||
those files were not changed. Node 24 was used for final app verification.
|
||||
- `git diff --check` and Python compilation passed. Hosted CI status belongs to
|
||||
the PR; local checks do not substitute for its macOS/Windows/ARM64 jobs.
|
||||
|
||||
A separate [locked cleanup audit](cleanup-verification.json) confirms both test
|
||||
services inactive, no panel bundle or Decky binary, no test plugins/profiles or
|
||||
remaining timers, power restored to false/5, battery 99→99, and its own lock
|
||||
released. It also removed the stale DevTools discovery file created during the
|
||||
earlier exploratory use of the original browser profile.
|
||||
@@ -1,2 +0,0 @@
|
||||
{"batteryBefore": [99], "headsetService": {"code": 4, "out": "inactive", "err": ""}, "deckyService": {"code": 4, "out": "inactive", "err": ""}, "panelBundleExists": false, "deckyBinaryExists": false, "testPluginFolders": [], "testProfiles": [], "timers": {"code": 0, "out": "", "err": ""}, "pause": {"code": 0, "out": "power.pauseCompositorOnStandby=false", "err": ""}, "timeout": {"code": 0, "out": "power.turnOffScreensTimeout=5.000000", "err": ""}, "removedOwnedStaleDevToolsDiscovery": true, "batteryAfter": [99], "lockReleased": true}
|
||||
|
||||