Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae9df35442 | ||
|
|
ce27c0e8f1 | ||
|
|
5277232367 | ||
|
|
1d4c57d648 | ||
|
|
c6215712fe | ||
|
|
cd7ab2d9f7 | ||
|
|
4b68971383 | ||
|
|
54c001d537 | ||
|
|
5ce5c023b6 | ||
|
|
709fef0dbd | ||
|
|
49408dc171 | ||
|
|
c728e64fbd | ||
|
|
0626c52e12 | ||
|
|
b9b4a00485 | ||
|
|
eb125b59c7 | ||
|
|
7e53fe32cb | ||
|
|
ebae409632 | ||
|
|
08cb267ce4 | ||
|
|
0119544c3e | ||
|
|
2a9e7dcabf | ||
|
|
5a9099dddb | ||
|
|
ec4086db69 | ||
|
|
5a7c3541f8 | ||
|
|
525040162e | ||
|
|
21e771d16e | ||
|
|
2cd095a8c4 | ||
|
|
bb9236b2b7 | ||
|
|
1bc090d00d | ||
|
|
63ba25ffdd | ||
|
|
98e6e90d84 | ||
|
|
7c061846bd | ||
|
|
a91d55151f | ||
|
|
7f25e87b42 | ||
|
|
ff245953ac | ||
|
|
46665d51c0 | ||
|
|
67615e85c3 | ||
|
|
9e3c3cd31d | ||
|
|
537754ebce | ||
|
|
573a9e2730 | ||
|
|
8b91b58336 | ||
|
|
12c60ec024 | ||
|
|
60261f093f | ||
|
|
3120a3cacf | ||
|
|
fecb226e43 | ||
|
|
e9cbb8fd55 | ||
|
|
d72be95f25 | ||
|
|
65fedbf01e | ||
|
|
311ee70783 | ||
|
|
f832d0b912 | ||
|
|
361bcf8a1b | ||
|
|
10f86d3e7a | ||
|
|
5fea1526ca | ||
|
|
7a7dca70de | ||
|
|
bf8c500a87 | ||
|
|
71fe208fea | ||
|
|
2b838ede9e | ||
|
|
8402a671ef | ||
|
|
310e778d00 | ||
|
|
a909beb39e | ||
|
|
e2601965bd | ||
|
|
96502e6e4d | ||
|
|
d5f26efc1a | ||
|
|
eac9557286 | ||
|
|
4aa9f18fd1 | ||
|
|
fa40e30f3c | ||
|
|
26206e85b4 | ||
|
|
d7a887b98c | ||
|
|
30cbe1c901 | ||
|
|
f4efefa958 | ||
|
|
a5980d8f98 | ||
|
|
e179616ad0 | ||
|
|
8f245cede7 | ||
|
|
359407bd06 | ||
|
|
810ef1822d | ||
|
|
3dd8e982a3 | ||
|
|
fce1800dcf | ||
|
|
3f377d8f56 | ||
|
|
9060142f5d | ||
|
|
172851971c | ||
|
|
48e9ce58ee | ||
|
|
9a5d697579 | ||
|
|
71ef6a57c7 | ||
|
|
be9e7b5cda | ||
|
|
5c44d56f53 | ||
|
|
fed2249776 | ||
|
|
1e44a74165 | ||
|
|
2a3e52f708 | ||
|
|
b2485500e1 | ||
|
|
c5521a917f | ||
|
|
d546568a11 | ||
|
|
57a9d8c717 | ||
|
|
3fdc6c46c3 | ||
|
|
9a6b48dea5 | ||
|
|
a3e2b70555 | ||
|
|
092aae24bd | ||
|
|
7adf0805d7 | ||
|
|
baaddd7622 | ||
|
|
07fc7ca3ff | ||
|
|
e885e9adba | ||
|
|
7aa7c55be9 | ||
|
|
2554309c7d | ||
|
|
adc1e44cf1 | ||
|
|
7f46cd54a4 | ||
|
|
7f23acb03f | ||
|
|
d9454ef099 | ||
|
|
71a3a96dd2 | ||
|
|
952b660bf7 | ||
|
|
a4d840a148 |
No files matched your search
@@ -22,6 +22,7 @@ desktop or panels.
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
|
||||
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
|
||||
@@ -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, 'feat/phone-screenshot-autosync-108']
|
||||
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
|
||||
@@ -43,8 +43,8 @@ jobs:
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
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: Updater tests
|
||||
run: node --test app/test/updater.test.js
|
||||
- name: App tests
|
||||
run: node --test app/test/*.test.js
|
||||
- name: Website
|
||||
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
||||
|
||||
|
||||
@@ -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.',
|
||||
'',
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
name: PC host libraries
|
||||
# PC in the headset (Experimental). The Windows job builds the native adapter
|
||||
# and runs the real-encoder protocol tests; the installer's own bundle is
|
||||
# tested in release.yml. The Linux adapter from PR #38 isn't shipped or offered
|
||||
# in the app, but shares capture.c and controller.c, so it's still built here.
|
||||
# These builds add the GPL x264 encoder for headless tests only: they're never
|
||||
# uploaded or shipped.
|
||||
on:
|
||||
pull_request:
|
||||
paths: ["desktop/**", "ui/**", "tests/**", "mac/frame-mac-view/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
|
||||
paths: ["desktop/**", "ui/**", "tests/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
linux:
|
||||
@@ -16,15 +22,12 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
|
||||
python3 desktop/build.py
|
||||
python3 desktop/build.py --with-x264
|
||||
- name: Tests (real x264, fake capture consent/input)
|
||||
env:
|
||||
FRAME_PC_REQUIRE_NATIVE: "1"
|
||||
FRAME_PC_REQUIRE_X264: "1"
|
||||
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pc-host-${{ matrix.os }}
|
||||
path: desktop/bundle
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
@@ -33,21 +36,18 @@ jobs:
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-python
|
||||
mingw-w64-ucrt-x86_64-gstreamer
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-base
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-good
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-bad
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-ugly
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
python desktop/build.py
|
||||
FRAME_PC_REQUIRE_NATIVE=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pc-host-windows
|
||||
path: desktop/bundle
|
||||
python desktop/build.py --with-x264
|
||||
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
@@ -3,7 +3,7 @@ name: release
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# native PC helpers, macOS helper, or this workflow) build the same installers as
|
||||
# Windows PC helper libraries, the macOS helper, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
@@ -69,38 +69,31 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
script: dist:linux
|
||||
files: app/dist/*.AppImage app/dist/*.deb
|
||||
- os: ubuntu-24.04-arm
|
||||
script: dist:linux:arm64
|
||||
files: app/dist/*.AppImage app/dist/*.deb
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Linux streaming libraries
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
|
||||
arch=x64; if [ "$(uname -m)" = aarch64 ]; then arch=arm64; fi
|
||||
python3 desktop/build.py --out "app/build/desktop/linux-$arch"
|
||||
# PC in the headset (Experimental, Windows only): our capture adapter and
|
||||
# the GStreamer libraries it uses, bundled into resources/desktop/bundle.
|
||||
- uses: msys2/setup-msys2@v2
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-python
|
||||
mingw-w64-ucrt-x86_64-gstreamer
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-base
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-good
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-bad
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-ugly
|
||||
- name: Windows streaming libraries
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Windows PC streaming libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: python desktop/build.py --out app/build/desktop/win-x64
|
||||
@@ -110,21 +103,6 @@ jobs:
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Test the bundled Python with the native PC libraries
|
||||
if: runner.os != 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Windows ]; then
|
||||
platform=win; arch=x64
|
||||
python="$PWD/app/build/deps/win-x64/python/python.exe"
|
||||
native="$(cygpath -w "$PWD/app/build/desktop/win-x64")"
|
||||
else
|
||||
platform=linux; arch=x64
|
||||
if [ "$(uname -m)" = aarch64 ]; then arch=arm64; fi
|
||||
python="$PWD/app/build/deps/linux-$arch/python/bin/python3"
|
||||
native="$PWD/app/build/desktop/linux-$arch"
|
||||
fi
|
||||
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 "$python" -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
# 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.
|
||||
@@ -139,6 +117,19 @@ jobs:
|
||||
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
|
||||
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
# From the installed app, so this also proves the installer carries the
|
||||
# libraries. After the check above, and without writing __pycache__,
|
||||
# so it can't change what that check compares.
|
||||
- name: Test the installed app's Python with the Windows PC libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
run: |
|
||||
resources="$(cygpath -u "$LOCALAPPDATA")/Programs/Frame Control/resources"
|
||||
python="$resources/python/python.exe"
|
||||
native="$(cygpath -w "$resources/desktop/bundle")"
|
||||
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_INSTALLER_BUNDLE=1 "$python" -B -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- name: Upload to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Import standalone APK/OBB backups from XAPK/APKS/APKM archives through the
|
||||
existing preview and game-data setup flow. Unsafe archives, split sets and
|
||||
Android/data restores are refused with guidance. This does not establish RE4
|
||||
compatibility or diagnose its reported failure (issue #102).
|
||||
|
||||
- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for
|
||||
battery, CPU, named temperatures, fan and available power rails (issue #52).
|
||||
JSON samples/summaries and CSV exports retain explicit unavailable states;
|
||||
active smoke-test locks stop measurement. No power settings are changed.
|
||||
|
||||
- Show saved Android compatibility reports on store search cards and app details.
|
||||
Reports refresh in the background, so a stalled database or credential lookup
|
||||
cannot delay search. Warnings allow installation; build compatibility blockers
|
||||
still apply. Public copies use this computer's reports, not private shared ratings.
|
||||
|
||||
- Fix two cases where optional diagnostic logs could retain part of a headset
|
||||
display name or an SSH host name, while preserving existing privacy redaction
|
||||
(issue #81).
|
||||
|
||||
- Keep the full host-key recovery explanation readable in Devices, including
|
||||
long addresses, and remove doubled punctuation from the connection tooltip
|
||||
(issue #87). Refresh stays within the header and switching headsets selects
|
||||
the new headset's details.
|
||||
|
||||
- Fix experimental PC screen capture on Windows builds whose streaming libraries
|
||||
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
|
||||
windows are hidden with an explanation instead of failing when shown. A Windows
|
||||
VM streamed a real desktop to the Frame; physical input remains unverified.
|
||||
|
||||
- Add read-only battery health in Tools: charging state, temperature, cycle count,
|
||||
and available capacity with explicit unknown states. The parser was checked
|
||||
on a real Frame; the connected desktop UI was tested with synthetic readings.
|
||||
|
||||
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
|
||||
library/game install-size breakdown, with unknown sizes, partial inventories
|
||||
and permission/missing-drive states. Includes instructions for Steam's own
|
||||
Move UI; Frame Control does not move or delete game files. The read-only
|
||||
parser was checked on a real Frame; the connected UI and game moves were not.
|
||||
|
||||
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
|
||||
with verified SSH identity, private summaries and actionable next steps
|
||||
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
|
||||
manual; restart-loop evidence stops deeper diagnostics. The real Frame
|
||||
verified the SSH connection and safety stop; deeper checks were skipped.
|
||||
- Fixed headset smoke cleanup reporting success while a smoke-title process
|
||||
could remain running after its files were removed. Cleanup now stops and
|
||||
rechecks processes tied to exact reserved test-title paths, uses PID handles
|
||||
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
@@ -12,7 +12,7 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[**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>
|
||||
|
||||
@@ -44,13 +44,16 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
For repeatable read-only battery, power-rail, temperature and fan measurements,
|
||||
use the [power and heat sampler](docs/power-and-heat.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**🎮 Steam games**<br>
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
@@ -63,17 +66,17 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**📁 Files, games and clipboard**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
|
||||
**📁 Files and games**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
**📸 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 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 Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
|
||||
**⌨️ 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>
|
||||
@@ -89,6 +92,14 @@ Install desktop apps like Moonlight or VLC, and set each Android app's resolutio
|
||||
**⚡ One-click tools**<br>
|
||||
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
**💾 Backup and recovery**<br>
|
||||
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -105,6 +116,17 @@ already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
|
||||
## Install
|
||||
|
||||
**New here? [Get Frame Control and follow the setup guide on the website](https://framecontrol.fun/#download).**
|
||||
Install it on your **computer, not the headset**. You don't need to clone this
|
||||
repository or install Python. Download for your computer, install and open the
|
||||
app, then choose **Set Up Connection** with the Frame awake on the same network.
|
||||
|
||||
- **macOS:** open the `.dmg`, drag Frame Control to Applications, then open it there.
|
||||
- **Windows:** run the installer and launch Frame Control from Start, or extract the portable `.zip` and run `Frame Control.exe`.
|
||||
- **Linux:** make the AppImage executable and open it, or install the `.deb` and launch Frame Control.
|
||||
|
||||
If the app is blocked, see the first-launch notes below before proceeding.
|
||||
|
||||
| | Download | Needs |
|
||||
|---|---|---|
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
@@ -115,6 +137,10 @@ already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
**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).
|
||||
|
||||
**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).
|
||||
|
||||
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
|
||||
@@ -218,11 +244,12 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Windows and Linux host implementation and test coverage |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [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 |
|
||||
| [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 |
|
||||
@@ -267,3 +294,5 @@ into a draft release, which reaches users once published. See
|
||||
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||
|
||||
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
|
||||
@@ -10,6 +10,8 @@ ship other people's software, unchanged, each under its own licence:
|
||||
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
|
||||
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
|
||||
| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
|
||||
| GStreamer 1.x, a few of its plugins and the libraries they need, as [MSYS2](https://packages.msys2.org/) builds them | Windows app only, for the experimental [PC in the headset](docs/pc-in-headset.md) | LGPL-2.1-or-later and others (per package); the GPL x264 encoder is left out | `libraries.json` (exact package versions) and the `licenses` folder in the app's `desktop/bundle` folder |
|
||||
|
||||
Frame Control starts KDE Connect and talks to it over its network protocol;
|
||||
it doesn't link to it or include its code.
|
||||
it doesn't link to it or include its code. On Windows, Frame Control's own
|
||||
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
|
||||
@@ -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,409 @@
|
||||
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.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.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);
|
||||
}
|
||||
|
||||
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(Collections.emptyList(), transport, exporter);
|
||||
SyncEngine.Shot shot =
|
||||
new SyncEngine.Shot("999999999991/108/20261024000000_1.png", png.length, 1792800000);
|
||||
engine.reconcile(List.of(shot), transport, exporter);
|
||||
assertNull(row(headset, shot));
|
||||
engine.reconcile(List.of(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(List.of(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(List.of(shot, second), transport, exporter);
|
||||
engine.reconcile(List.of(shot, second), transport, exporter);
|
||||
engine.setGallery(true);
|
||||
engine = new SyncEngine(root, true);
|
||||
engine.reconcile(List.of(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();
|
||||
}
|
||||
|
||||
@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");
|
||||
for (File root : List.of(a, b)) {
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
engine.reconcile(Collections.emptyList(), transfer, unavailable);
|
||||
engine.reconcile(List.of(shot), transfer, unavailable);
|
||||
engine.reconcile(List.of(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);
|
||||
SyncService.gallery(isolated, true);
|
||||
for (File root : List.of(a, b)) {
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
assertEquals("appOnly", engine.items().get(0).destination);
|
||||
engine.reconcile(
|
||||
List.of(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(Collections.emptyList(), transfer, (s, f, e) -> false);
|
||||
engine.reconcile(List.of(shot), transfer, (s, f, e) -> false);
|
||||
engine.reconcile(List.of(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);
|
||||
instrumentation.waitForIdleSync();
|
||||
Activity screen = activity;
|
||||
Runnable share =
|
||||
() -> {
|
||||
Button button = shareButton(screen.findViewById(android.R.id.content));
|
||||
assertNotNull("The downloaded original must be reachable", button);
|
||||
assertTrue(button.performClick());
|
||||
};
|
||||
instrumentation.runOnMainSync(share);
|
||||
assertNotNull(shared.get());
|
||||
Uri first = shared.get().getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
try (InputStream in = context.getContentResolver().openInputStream(first)) {
|
||||
assertArrayEquals(new byte[] {1, 2, 3, 4}, read(in));
|
||||
}
|
||||
assertTrue(prefs.edit().putString("pin", bPin).commit());
|
||||
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
|
||||
Uri selected;
|
||||
do {
|
||||
instrumentation.runOnMainSync(share);
|
||||
selected = shared.get().getParcelableExtra(Intent.EXTRA_STREAM);
|
||||
if (!first.equals(selected)) break;
|
||||
Thread.sleep(100);
|
||||
} while (System.nanoTime() < deadline);
|
||||
assertNotEquals("Share must no longer target the previous headset", first, selected);
|
||||
try (InputStream in = context.getContentResolver().openInputStream(selected)) {
|
||||
assertArrayEquals(new byte[] {5, 6, 7, 8}, read(in));
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
SyncEngine engine =
|
||||
new SyncEngine(
|
||||
SyncService.library(context, pin),
|
||||
SyncService.prefs(context).getBoolean("gallery", true));
|
||||
SyncEngine.Item item =
|
||||
engine.items().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,414 @@
|
||||
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);
|
||||
String key = new PhoneIdentity().authorizedKey(install);
|
||||
run(
|
||||
s,
|
||||
"umask 077; mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod"
|
||||
+ " 600 ~/.ssh/authorized_keys && (grep -qxF "
|
||||
+ quote(key)
|
||||
+ " ~/.ssh/authorized_keys || printf '%s\\n' "
|
||||
+ quote(key)
|
||||
+ " >> ~/.ssh/authorized_keys)",
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,96 @@
|
||||
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.function.BooleanSupplier;
|
||||
|
||||
/** Deterministic, app-owned MediaStore rows make interrupted exports retryable. */
|
||||
final class GalleryExporter implements SyncEngine.Exporter {
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean export(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 = "Pictures/Frame Control/";
|
||||
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()) {
|
||||
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,512 @@
|
||||
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();
|
||||
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 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 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(
|
||||
"Pair and start syncing. Existing headset screenshots stay on the headset; new captures"
|
||||
+ " will appear here.",
|
||||
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);
|
||||
try {
|
||||
List<SyncEngine.Item> items = SyncService.items(this);
|
||||
StringBuilder signature = new StringBuilder();
|
||||
for (var item : items)
|
||||
signature
|
||||
.append(item.file.getAbsolutePath())
|
||||
.append('|')
|
||||
.append(item.destination)
|
||||
.append('|')
|
||||
.append(item.file.exists())
|
||||
.append('\n');
|
||||
if (signature.toString().equals(librarySignature)) return;
|
||||
librarySignature = signature.toString();
|
||||
downloads.removeAllViews();
|
||||
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";
|
||||
if (!item.file.exists()) where = "Download pending";
|
||||
card.addView(text(where + " · Game " + item.shot.id.split("/")[1], 13, MUTED));
|
||||
if (item.file.exists()) card.addView(button("Open / share original", () -> share(item)));
|
||||
downloads.addView(card);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
empty.setVisibility(View.VISIBLE);
|
||||
empty.setText("Could not read screenshot downloads: " + SyncService.message(e));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
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,570 @@
|
||||
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.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 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
Map<String, Receipt> records;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
public 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();
|
||||
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();
|
||||
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();
|
||||
ledger.gallery = gallery;
|
||||
persist(ledger);
|
||||
}
|
||||
}
|
||||
|
||||
public void setGallery(boolean enabled) throws Exception {
|
||||
synchronized (lock) {
|
||||
if (ledger.gallery == enabled) return;
|
||||
Ledger next = copy();
|
||||
next.gallery = enabled;
|
||||
if (!enabled) {
|
||||
for (Receipt receipt : next.records.values()) {
|
||||
if ("pending".equals(receipt.destination)) receipt.destination = "appOnly";
|
||||
}
|
||||
}
|
||||
commit(next);
|
||||
}
|
||||
}
|
||||
|
||||
/** A second live reconcile is rejected. stop() cancels its generation and permits a new run. */
|
||||
public void reconcile(List<Shot> shots, Transport transport, Exporter exporter) throws Exception {
|
||||
final long run;
|
||||
synchronized (lock) {
|
||||
if (active != 0) throw new IllegalStateException("Sync already in progress");
|
||||
run = ++generation;
|
||||
active = run;
|
||||
}
|
||||
try {
|
||||
Map<String, Shot> listing = listing(shots);
|
||||
synchronized (lock) {
|
||||
if (active != run) return;
|
||||
Ledger next = copy();
|
||||
if (!next.initialized) {
|
||||
for (Shot shot : listing.values()) {
|
||||
next.records.put(shot.id, new Receipt(shot, "baseline", 0, null));
|
||||
}
|
||||
next.initialized = true;
|
||||
commit(next);
|
||||
return;
|
||||
}
|
||||
// Missing observations break consecutiveness, including across restarts.
|
||||
for (Receipt receipt : next.records.values()) {
|
||||
if (!listing.containsKey(receipt.shot.id)
|
||||
&& ("observing".equals(receipt.state) || "queued".equals(receipt.state))) {
|
||||
receipt.observations = 0;
|
||||
}
|
||||
}
|
||||
for (Shot shot : listing.values()) {
|
||||
Receipt receipt = next.records.get(shot.id);
|
||||
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"));
|
||||
} else if ("observing".equals(receipt.state) || "queued".equals(receipt.state)) {
|
||||
receipt.observations = same(receipt.shot, shot)
|
||||
? Math.min(2, receipt.observations + 1) : 1;
|
||||
receipt.shot = shot;
|
||||
if (receipt.observations == 2 && "observing".equals(receipt.state)) {
|
||||
receipt.state = "queued";
|
||||
}
|
||||
}
|
||||
}
|
||||
commit(next); // Queue and its disposition reach disk before any download.
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
synchronized (lock) {
|
||||
++generation;
|
||||
active = 0;
|
||||
}
|
||||
}
|
||||
|
||||
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 Map<String, Shot> listing(List<Shot> shots) throws IOException {
|
||||
if (shots == null) throw new IOException("Missing screenshot listing");
|
||||
Map<String, Shot> result = new LinkedHashMap<>();
|
||||
for (Shot shot : shots) {
|
||||
validateShot(shot);
|
||||
Shot previous = result.put(shot.id, shot);
|
||||
if (previous != null && !same(previous, shot)) {
|
||||
throw new IOException("Conflicting screenshot identity: " + shot.id);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void validateShot(Shot shot) throws IOException {
|
||||
if (shot == null || shot.id == null || !ID.matcher(shot.id).matches()
|
||||
|| shot.size <= 0 || shot.size > MAX_SIZE || !Double.isFinite(shot.time)) {
|
||||
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.version != 1
|
||||
|| ledger.initialized == null || ledger.gallery == null || ledger.records == null
|
||||
|| (!ledger.initialized && !ledger.records.isEmpty())) {
|
||||
throw new IOException("Invalid sync journal header");
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Ledger copy() {
|
||||
Ledger next = emptyLedger();
|
||||
next.initialized = ledger.initialized;
|
||||
next.gallery = ledger.gallery;
|
||||
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 = 1;
|
||||
result.initialized = false;
|
||||
result.records = new LinkedHashMap<>();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void commit(Ledger next) throws IOException {
|
||||
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,234 @@
|
||||
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 volatile SyncService instance;
|
||||
private static volatile String state = "Disconnected";
|
||||
private static volatile String page;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
private volatile boolean stopping;
|
||||
private volatile FrameConnection connection;
|
||||
private volatile SyncEngine engine;
|
||||
|
||||
static SharedPreferences prefs(Context c) {
|
||||
return c.getSharedPreferences("frame", MODE_PRIVATE);
|
||||
}
|
||||
|
||||
static String state() {
|
||||
return state;
|
||||
}
|
||||
|
||||
static String page() {
|
||||
return page;
|
||||
}
|
||||
|
||||
static boolean running() {
|
||||
SyncService service = instance;
|
||||
return service != null && !service.stopping;
|
||||
}
|
||||
|
||||
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;
|
||||
if (live != null) live.setGallery(value);
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
static List<SyncEngine.Item> items(Context c) throws Exception {
|
||||
SyncService service = instance;
|
||||
if (service != null && service.engine != null) return service.engine.items();
|
||||
String pin = prefs(c).getString("pin", null);
|
||||
if (pin == null) return Collections.emptyList();
|
||||
synchronized (SETTINGS_LOCK) {
|
||||
return new SyncEngine(library(c, pin), prefs(c).getBoolean("gallery", true)).items();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
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();
|
||||
}
|
||||
|
||||
private void status(String text) {
|
||||
state = text;
|
||||
getSystemService(NotificationManager.class).notify(108, notification(text));
|
||||
}
|
||||
|
||||
@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;
|
||||
state = "Connecting to headset";
|
||||
if (Build.VERSION.SDK_INT >= 34)
|
||||
startForeground(
|
||||
108, notification(state), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE);
|
||||
else startForeground(108, notification(state));
|
||||
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.");
|
||||
synchronized (SETTINGS_LOCK) {
|
||||
engine = new SyncEngine(library(this, pin), p.getBoolean("gallery", true));
|
||||
}
|
||||
while (!stopping) {
|
||||
try {
|
||||
FrameConnection active = new FrameConnection(this);
|
||||
connection = active;
|
||||
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;
|
||||
}
|
||||
page = active.page();
|
||||
status("Syncing new screenshots");
|
||||
GalleryExporter exporter = new GalleryExporter(this, pin, SETTINGS_LOCK);
|
||||
while (!stopping && active.connected()) {
|
||||
try {
|
||||
engine.reconcile(active.shots(), active, exporter);
|
||||
status("Syncing new screenshots");
|
||||
} catch (Exception e) {
|
||||
status("Screenshot pending · " + message(e));
|
||||
}
|
||||
Thread.sleep(4000);
|
||||
}
|
||||
if (!stopping) throw new java.io.IOException("Connection lost. Reconnecting…");
|
||||
} catch (Exception e) {
|
||||
page = null;
|
||||
if (connection != null) connection.close();
|
||||
if (stopping) break;
|
||||
status("Waiting for headset · " + message(e));
|
||||
Thread.sleep(10000);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
state = message(e);
|
||||
} finally {
|
||||
page = null;
|
||||
if (connection != null) connection.close();
|
||||
stopSelf();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
page = null;
|
||||
if (engine != null) engine.stop();
|
||||
if (connection != null) connection.close();
|
||||
worker.shutdownNow();
|
||||
instance = null;
|
||||
if (state.equals("Syncing new screenshots")
|
||||
|| state.startsWith("Waiting")
|
||||
|| state.startsWith("Connecting")) state = "Disconnected";
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
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,789 @@
|
||||
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.io.RandomAccessFile;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
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.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class SyncEngineTest {
|
||||
@Rule public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
private static final String FIRST = "123/456/20261024120000_1.jpg";
|
||||
private static final String SECOND = "123/456/20261024120001_2.png";
|
||||
private static final SyncEngine.Shot A = shot(FIRST);
|
||||
private static final SyncEngine.Shot B = shot(SECOND);
|
||||
|
||||
private static SyncEngine.Shot shot(String id) { return new SyncEngine.Shot(id, 4, 100.5); }
|
||||
|
||||
private static class Transfer implements SyncEngine.Transport {
|
||||
final List<String> downloads = new ArrayList<>();
|
||||
boolean valid = true;
|
||||
boolean truncated;
|
||||
|
||||
@Override public void download(SyncEngine.Shot shot, File destination) throws Exception {
|
||||
downloads.add(shot.id);
|
||||
try (RandomAccessFile file = new RandomAccessFile(destination, "rw")) {
|
||||
file.setLength(truncated ? shot.size - 1 : shot.size);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public boolean validImage(File file) throws Exception { return valid; }
|
||||
}
|
||||
|
||||
private static class Gallery implements SyncEngine.Exporter {
|
||||
int attempts;
|
||||
int saves;
|
||||
boolean deny;
|
||||
boolean fail;
|
||||
|
||||
@Override public boolean export(SyncEngine.Shot shot, File original,
|
||||
java.util.function.BooleanSupplier eligible) throws Exception {
|
||||
attempts++;
|
||||
assertTrue(original.isFile());
|
||||
if (fail) throw new IOException("Gallery unavailable");
|
||||
if (deny || !eligible.getAsBoolean()) return false;
|
||||
saves++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private interface Checked { void run() throws Exception; }
|
||||
|
||||
private static IOException fails(Checked operation) throws Exception {
|
||||
try {
|
||||
operation.run();
|
||||
} catch (IOException expected) {
|
||||
return expected;
|
||||
}
|
||||
throw new AssertionError("Expected operation to reject invalid or unavailable state");
|
||||
}
|
||||
|
||||
private static void reconcile(SyncEngine engine, Transfer transfer, Gallery gallery,
|
||||
SyncEngine.Shot... shots) throws Exception {
|
||||
engine.reconcile(Arrays.asList(shots), transfer, gallery);
|
||||
}
|
||||
|
||||
private static void observed(SyncEngine engine, Transfer transfer, Gallery gallery,
|
||||
SyncEngine.Shot shot) throws Exception {
|
||||
reconcile(engine, transfer, gallery);
|
||||
reconcile(engine, transfer, gallery, shot);
|
||||
}
|
||||
|
||||
private static void assertDownloadPending(SyncEngine engine, String destination) {
|
||||
assertEquals(1, engine.items().size());
|
||||
assertEquals(destination, engine.items().get(0).destination);
|
||||
assertFalse(engine.items().get(0).file.exists());
|
||||
}
|
||||
|
||||
private static SyncEngine.Item item(SyncEngine engine, String id) {
|
||||
return engine.items().stream().filter(i -> i.shot.id.equals(id)).findFirst().get();
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) throws InterruptedException {
|
||||
assertTrue("Timed out waiting for callback", latch.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
private static final class Async implements AutoCloseable {
|
||||
private final AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
private final Thread thread;
|
||||
|
||||
Async(Checked operation) {
|
||||
thread = new Thread(() -> {
|
||||
try { operation.run(); } catch (Throwable e) { failure.set(e); }
|
||||
}, "sync-core-test");
|
||||
thread.start();
|
||||
}
|
||||
|
||||
void finish() throws Exception {
|
||||
thread.join(5000);
|
||||
assertFalse("Reconcile did not finish", thread.isAlive());
|
||||
if (failure.get() != null) throw new AssertionError("Background reconcile failed", failure.get());
|
||||
}
|
||||
|
||||
@Override public void close() throws Exception {
|
||||
if (thread.isAlive()) thread.interrupt();
|
||||
thread.join(5000);
|
||||
assertFalse("Leaked reconcile thread", thread.isAlive());
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void firstListingIsDurableBaselineAndNeverDownloadsHistory() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
assertTrue(new File(root, "receipts.json").isFile());
|
||||
engine = new SyncEngine(root, true);
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
reconcile(engine, transfer, gallery, new SyncEngine.Shot(FIRST, 7, 999));
|
||||
assertTrue(transfer.downloads.isEmpty());
|
||||
assertTrue(engine.items().isEmpty());
|
||||
}
|
||||
|
||||
@Test public void emptyBaselineRequiresTwoIdenticalNewObservations() throws Exception {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), true);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
assertTrue(transfer.downloads.isEmpty());
|
||||
assertDownloadPending(engine, "pending");
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
assertEquals(Collections.singletonList(FIRST), transfer.downloads);
|
||||
assertEquals(1, gallery.saves);
|
||||
assertEquals("saved", engine.items().get(0).destination);
|
||||
assertEquals(4, engine.items().get(0).file.length());
|
||||
}
|
||||
|
||||
@Test public void sizeAndMtimeChangesResetStabilityAndMissingPollBreaksIt() throws Exception {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), false);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
SyncEngine.Shot size = new SyncEngine.Shot(FIRST, 5, A.time);
|
||||
SyncEngine.Shot time = new SyncEngine.Shot(FIRST, 5, A.time + 1);
|
||||
reconcile(engine, transfer, gallery, size);
|
||||
reconcile(engine, transfer, gallery, time);
|
||||
reconcile(engine, transfer, gallery);
|
||||
reconcile(engine, transfer, gallery, time);
|
||||
assertTrue(transfer.downloads.isEmpty());
|
||||
reconcile(engine, transfer, gallery, time);
|
||||
assertEquals(1, transfer.downloads.size());
|
||||
assertEquals(5, engine.items().get(0).file.length());
|
||||
}
|
||||
|
||||
@Test public void identityIncludesAccountAndAppAndDuplicateRowsAreOnlyOneObservation() throws Exception {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), false);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
SyncEngine.Shot otherAccount = shot("124/456/20261024120000_1.jpg");
|
||||
SyncEngine.Shot otherApp = shot("123/457/20261024120000_1.jpg");
|
||||
reconcile(engine, transfer, gallery, A, otherAccount, otherAccount, otherApp);
|
||||
assertTrue(transfer.downloads.isEmpty());
|
||||
reconcile(engine, transfer, gallery, otherAccount, otherApp);
|
||||
assertEquals(2, transfer.downloads.size());
|
||||
assertEquals(2, engine.items().size());
|
||||
reconcile(engine, transfer, gallery, otherAccount, otherApp);
|
||||
assertEquals(2, transfer.downloads.size());
|
||||
}
|
||||
|
||||
@Test public void truncatedAndInvalidImagesStayQueuedUntilValidRetry() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
transfer.truncated = true;
|
||||
fails(() -> reconcile(engine, transfer, gallery, A));
|
||||
assertDownloadPending(engine, "pending");
|
||||
assertFalse(new File(root, "originals/" + FIRST).exists());
|
||||
transfer.truncated = false; transfer.valid = false;
|
||||
fails(() -> reconcile(engine, transfer, gallery, A));
|
||||
assertDownloadPending(engine, "pending");
|
||||
assertEquals(0, gallery.attempts);
|
||||
transfer.valid = true;
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
assertEquals(3, transfer.downloads.size());
|
||||
assertEquals("saved", engine.items().get(0).destination);
|
||||
assertEquals(0, root.listFiles((dir, name) -> name.startsWith(".download-")).length);
|
||||
}
|
||||
|
||||
@Test public void deniedExportRetriesWithoutDownloadingAndSavedReceiptDeduplicates() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
gallery.deny = true;
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
assertEquals("pending", engine.items().get(0).destination);
|
||||
engine = new SyncEngine(root, true);
|
||||
gallery.deny = false;
|
||||
reconcile(engine, transfer, gallery); // Export retry does not need the remote file still listed.
|
||||
assertEquals(1, transfer.downloads.size());
|
||||
assertEquals(1, gallery.saves);
|
||||
reconcile(new SyncEngine(root, true), transfer, gallery, A);
|
||||
assertEquals(2, gallery.attempts);
|
||||
}
|
||||
|
||||
@Test public void thrownExportRetainsPendingReceiptAcrossRelaunch() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
gallery.fail = true;
|
||||
fails(() -> reconcile(engine, transfer, gallery, A));
|
||||
assertEquals("pending", engine.items().get(0).destination);
|
||||
gallery.fail = false;
|
||||
reconcile(new SyncEngine(root, true), transfer, gallery);
|
||||
assertEquals(1, gallery.saves);
|
||||
assertEquals(1, transfer.downloads.size());
|
||||
}
|
||||
|
||||
@Test public void queuedShotAndStabilitySurviveRelaunch() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
transfer.truncated = true;
|
||||
fails(() -> reconcile(engine, transfer, gallery, A));
|
||||
SyncEngine relaunched = new SyncEngine(root, true);
|
||||
transfer.truncated = false;
|
||||
reconcile(relaunched, transfer, gallery, A);
|
||||
assertEquals("appOnly", relaunched.items().get(0).destination);
|
||||
assertEquals(0, gallery.attempts);
|
||||
}
|
||||
|
||||
@Test public void firstObservationSurvivesRelaunch() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(new SyncEngine(root, false), transfer, gallery, A);
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
assertEquals(1, engine.items().size());
|
||||
}
|
||||
|
||||
@Test public void offThenOnNeverRetroExportsDownloadedOrQueuedItems() throws Exception {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), true);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
gallery.deny = true;
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
engine.setGallery(false);
|
||||
engine.setGallery(true);
|
||||
gallery.deny = false;
|
||||
reconcile(engine, transfer, gallery, A, B);
|
||||
assertEquals("appOnly", engine.items().get(0).destination);
|
||||
reconcile(engine, transfer, gallery, A, B);
|
||||
assertEquals(1, gallery.saves); // Only the later B item.
|
||||
assertEquals("appOnly", engine.items().stream().filter(i -> i.shot.id.equals(FIRST))
|
||||
.findFirst().get().destination);
|
||||
}
|
||||
|
||||
@Test public void switchingOffWhileDownloadBlockedChangesItsDisposition() throws Exception {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), true);
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
Transfer transfer = new Transfer() {
|
||||
@Override public void download(SyncEngine.Shot shot, File file) throws Exception {
|
||||
entered.countDown(); await(release); super.download(shot, file);
|
||||
}
|
||||
};
|
||||
Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
try (Async async = new Async(() -> reconcile(engine, transfer, gallery, A))) {
|
||||
try {
|
||||
await(entered);
|
||||
engine.setGallery(false); // Must finish while network is blocked.
|
||||
engine.setGallery(true);
|
||||
} finally { release.countDown(); }
|
||||
async.finish();
|
||||
}
|
||||
assertEquals("appOnly", engine.items().get(0).destination);
|
||||
assertEquals(0, gallery.attempts);
|
||||
}
|
||||
|
||||
@Test public void switchingOffWhileExporterBlockedInvalidatesEligibility() throws Exception {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), true);
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
Gallery gallery = new Gallery() {
|
||||
@Override public boolean export(SyncEngine.Shot shot, File original,
|
||||
java.util.function.BooleanSupplier eligible) throws Exception {
|
||||
assertTrue(eligible.getAsBoolean());
|
||||
entered.countDown(); await(release);
|
||||
assertFalse(eligible.getAsBoolean());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Transfer transfer = new Transfer();
|
||||
observed(engine, transfer, gallery, A);
|
||||
try (Async async = new Async(() -> reconcile(engine, transfer, gallery, A))) {
|
||||
try { await(entered); engine.setGallery(false); engine.setGallery(true); }
|
||||
finally { release.countDown(); }
|
||||
async.finish();
|
||||
}
|
||||
assertEquals("appOnly", engine.items().get(0).destination);
|
||||
}
|
||||
|
||||
@Test public void lateDownloadAfterStopCannotOverwriteFreshRunOrJournal() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
Transfer slow = new Transfer() {
|
||||
@Override public void download(SyncEngine.Shot shot, File file) throws Exception {
|
||||
entered.countDown(); await(release);
|
||||
Files.write(file.toPath(), new byte[]{9, 9, 9, 9});
|
||||
}
|
||||
};
|
||||
Transfer fresh = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, fresh, gallery, A);
|
||||
try (Async async = new Async(() -> reconcile(engine, slow, gallery, A))) {
|
||||
byte[] journal;
|
||||
try {
|
||||
await(entered); engine.stop(); engine.setGallery(false);
|
||||
reconcile(engine, fresh, gallery, A);
|
||||
journal = Files.readAllBytes(new File(root, "receipts.json").toPath());
|
||||
} finally { release.countDown(); }
|
||||
async.finish();
|
||||
assertArrayEquals(journal, Files.readAllBytes(new File(root, "receipts.json").toPath()));
|
||||
}
|
||||
assertArrayEquals(new byte[4], Files.readAllBytes(engine.items().get(0).file.toPath()));
|
||||
assertEquals("appOnly", new SyncEngine(root, true).items().get(0).destination);
|
||||
}
|
||||
|
||||
@Test public void lateExportAfterStopDoesNotMarkFreshAppOnlyReceiptSaved() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
Transfer transfer = new Transfer();
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
Gallery delayed = new Gallery() {
|
||||
@Override public boolean export(SyncEngine.Shot shot, File original,
|
||||
java.util.function.BooleanSupplier eligible) throws Exception {
|
||||
entered.countDown(); await(release);
|
||||
assertFalse(eligible.getAsBoolean());
|
||||
return true; // Model a pre-stop external save whose callback arrived late.
|
||||
}
|
||||
};
|
||||
observed(engine, transfer, delayed, A);
|
||||
try (Async async = new Async(() -> reconcile(engine, transfer, delayed, A))) {
|
||||
byte[] journal;
|
||||
try {
|
||||
await(entered); engine.stop(); engine.setGallery(false);
|
||||
reconcile(engine, transfer, new Gallery(), A);
|
||||
journal = Files.readAllBytes(new File(root, "receipts.json").toPath());
|
||||
} finally { release.countDown(); }
|
||||
async.finish();
|
||||
assertArrayEquals(journal, Files.readAllBytes(new File(root, "receipts.json").toPath()));
|
||||
}
|
||||
assertEquals("appOnly", engine.items().get(0).destination);
|
||||
}
|
||||
|
||||
@Test public void concurrentReconcileIsRejectedWithoutBlockingOnDownload() throws Exception {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), false);
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
Transfer slow = new Transfer() {
|
||||
@Override public void download(SyncEngine.Shot shot, File file) throws Exception {
|
||||
entered.countDown(); await(release); super.download(shot, file);
|
||||
}
|
||||
};
|
||||
Gallery gallery = new Gallery(); observed(engine, slow, gallery, A);
|
||||
try (Async async = new Async(() -> reconcile(engine, slow, gallery, A))) {
|
||||
try {
|
||||
await(entered);
|
||||
try { reconcile(engine, new Transfer(), gallery, A); fail("Concurrent run accepted"); }
|
||||
catch (IllegalStateException expected) { assertDownloadPending(engine, "appOnly"); }
|
||||
} finally { release.countDown(); }
|
||||
async.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void corruptedAndIncompleteJournalsFailClosed() throws Exception {
|
||||
for (String content : new String[]{"", "{", "null", "{}", "{\"gallery\":true}",
|
||||
"{\"version\":2,\"initialized\":true,\"gallery\":true,\"records\":{}}",
|
||||
"{\"version\":1,\"initialized\":true,\"gallery\":true,\"records\":{}} trailing"}) {
|
||||
File root = temporary.newFolder();
|
||||
File journal = new File(root, "receipts.json");
|
||||
Files.write(journal.toPath(), content.getBytes(StandardCharsets.UTF_8));
|
||||
fails(() -> new SyncEngine(root, true));
|
||||
assertEquals(content, new String(Files.readAllBytes(journal.toPath()), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void damagedReceiptAndMissingOriginalFailClosed() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
observed(engine, transfer, gallery, A); reconcile(engine, transfer, gallery, A);
|
||||
File journal = new File(root, "receipts.json");
|
||||
String json = new String(Files.readAllBytes(journal.toPath()), StandardCharsets.UTF_8);
|
||||
for (String damaged : new String[]{json.replace("downloaded", "unknown"),
|
||||
json.replace("\"time\":100.5", "\"otherTime\":100.5"),
|
||||
json.replace("\"size\":4", "\"size\":0"),
|
||||
json.replace("\"time\":100.5", "\"time\":1e999")}) {
|
||||
Files.write(journal.toPath(), damaged.getBytes(StandardCharsets.UTF_8));
|
||||
fails(() -> new SyncEngine(root, false));
|
||||
}
|
||||
Files.write(journal.toPath(), json.getBytes(StandardCharsets.UTF_8));
|
||||
Files.delete(engine.items().get(0).file.toPath());
|
||||
fails(() -> new SyncEngine(root, false));
|
||||
Files.delete(journal.toPath());
|
||||
Files.write(new File(root, "originals/orphan").toPath(), new byte[]{1});
|
||||
fails(() -> new SyncEngine(root, false));
|
||||
}
|
||||
|
||||
@Test public void invalidListingCannotEstablishBaselineOrPartiallyAdvanceObservations() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
fails(() -> reconcile(engine, transfer, gallery, A, shot("../bad")));
|
||||
reconcile(engine, transfer, gallery, A); // A is still baseline, not newly discovered.
|
||||
reconcile(engine, transfer, gallery, A, B);
|
||||
fails(() -> reconcile(engine, transfer, gallery, B, shot("../bad")));
|
||||
assertTrue(transfer.downloads.isEmpty());
|
||||
reconcile(engine, transfer, gallery, B);
|
||||
assertEquals(Collections.singletonList(SECOND), transfer.downloads);
|
||||
}
|
||||
|
||||
@Test public void metadataBoundsAndPathTraversalAreRejected() throws Exception {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), false);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
for (String id : new String[]{"../" + FIRST, "/" + FIRST, "123/../" + FIRST,
|
||||
"123\\456\\20261024120000_1.jpg", "123/456/20261024120000_1.JPG",
|
||||
"1234567890123/456/20261024120000_1.jpg", "123/123456789012345678901/20261024120000_1.jpg",
|
||||
"123/456/2026102412000_1.jpg", "123/456/20261024120000_12345.jpg",
|
||||
FIRST + "\n", FIRST + "/extra", "123/456/20261024120000_1.gif"}) {
|
||||
fails(() -> reconcile(engine, transfer, gallery, shot(id)));
|
||||
}
|
||||
for (long size : new long[]{0, -1, 30L * 1024 * 1024 + 1, Long.MAX_VALUE}) {
|
||||
fails(() -> reconcile(engine, transfer, gallery, new SyncEngine.Shot(FIRST, size, 1)));
|
||||
}
|
||||
for (double time : new double[]{Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) {
|
||||
fails(() -> reconcile(engine, transfer, gallery, new SyncEngine.Shot(FIRST, 1, time)));
|
||||
}
|
||||
fails(() -> engine.reconcile(null, transfer, gallery));
|
||||
fails(() -> reconcile(engine, transfer, gallery, (SyncEngine.Shot) null));
|
||||
fails(() -> reconcile(engine, transfer, gallery, A, new SyncEngine.Shot(FIRST, 5, 1)));
|
||||
assertTrue(transfer.downloads.isEmpty());
|
||||
}
|
||||
|
||||
@Test public void exactMaximumMetadataAndThirtyMebibyteOriginalAreAccepted() throws Exception {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), false);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
SyncEngine.Shot maximum = new SyncEngine.Shot(
|
||||
"123456789012/12345678901234567890/20261024123456_1234.png", 30L * 1024 * 1024,
|
||||
Double.MAX_VALUE);
|
||||
observed(engine, transfer, gallery, maximum);
|
||||
reconcile(engine, transfer, gallery, maximum);
|
||||
assertEquals(maximum.size, engine.items().get(0).file.length());
|
||||
assertEquals("appOnly", engine.items().get(0).destination);
|
||||
}
|
||||
|
||||
@Test public void symbolicLinksCannotEscapeOriginalsOrReplaceJournal() throws Exception {
|
||||
File root = temporary.newFolder(), outside = temporary.newFolder();
|
||||
Files.createSymbolicLink(new File(root, "originals").toPath(), outside.toPath());
|
||||
fails(() -> new SyncEngine(root, true));
|
||||
Files.delete(new File(root, "originals").toPath());
|
||||
File target = new File(outside, "receipts"); Files.write(target.toPath(), new byte[]{1});
|
||||
Files.createSymbolicLink(new File(root, "receipts.json").toPath(), target.toPath());
|
||||
fails(() -> new SyncEngine(root, true));
|
||||
assertArrayEquals(new byte[]{1}, Files.readAllBytes(target.toPath()));
|
||||
}
|
||||
|
||||
@Test public void nestedSymlinkIntroducedDuringDownloadCannotEscapeRoot() throws Exception {
|
||||
File root = temporary.newFolder(), outside = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
Transfer transfer = new Transfer() {
|
||||
@Override public void download(SyncEngine.Shot shot, File file) throws Exception {
|
||||
super.download(shot, file);
|
||||
Files.createDirectory(new File(root, "originals").toPath());
|
||||
Files.createSymbolicLink(new File(root, "originals/123").toPath(), outside.toPath());
|
||||
}
|
||||
};
|
||||
Gallery gallery = new Gallery(); observed(engine, transfer, gallery, A);
|
||||
fails(() -> reconcile(engine, transfer, gallery, A));
|
||||
assertDownloadPending(engine, "appOnly");
|
||||
assertEquals(0, outside.list().length);
|
||||
}
|
||||
|
||||
@Test public void failedPreferenceWriteRollsBackGalleryAndPendingDisposition() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A); gallery.deny = true;
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
File journal = new File(root, "receipts.json"); byte[] saved = Files.readAllBytes(journal.toPath());
|
||||
Files.delete(journal.toPath()); Files.createDirectory(journal.toPath());
|
||||
fails(() -> engine.setGallery(false));
|
||||
assertEquals("pending", engine.items().get(0).destination);
|
||||
Files.delete(journal.toPath()); Files.write(journal.toPath(), saved);
|
||||
gallery.deny = false; reconcile(engine, transfer, gallery, A);
|
||||
assertEquals(1, gallery.saves);
|
||||
assertEquals("saved", engine.items().get(0).destination);
|
||||
}
|
||||
|
||||
@Test public void failedQueueWriteDoesNotStartDownloadOrChangeMemory() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
File journal = new File(root, "receipts.json"); byte[] saved = Files.readAllBytes(journal.toPath());
|
||||
Files.delete(journal.toPath()); Files.createDirectory(journal.toPath());
|
||||
fails(() -> reconcile(engine, transfer, gallery, A));
|
||||
assertTrue(transfer.downloads.isEmpty());
|
||||
assertDownloadPending(engine, "appOnly");
|
||||
Files.delete(journal.toPath()); Files.write(journal.toPath(), saved);
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
assertEquals(1, engine.items().size());
|
||||
}
|
||||
|
||||
@Test public void exportedImageWithFailedReceiptWriteRecoversThroughIdempotentExporter() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
File journal = new File(root, "receipts.json");
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
Transfer transfer = new Transfer();
|
||||
AtomicReference<byte[]> pendingJournal = new AtomicReference<>();
|
||||
Gallery gallery = new Gallery() {
|
||||
private boolean exists;
|
||||
|
||||
@Override public boolean export(SyncEngine.Shot shot, File file,
|
||||
java.util.function.BooleanSupplier eligible) throws Exception {
|
||||
attempts++;
|
||||
if (exists) return true; // Recover the previous external save without another insert.
|
||||
assertTrue(eligible.getAsBoolean());
|
||||
exists = true; saves++;
|
||||
pendingJournal.set(Files.readAllBytes(journal.toPath()));
|
||||
Files.delete(journal.toPath()); Files.createDirectory(journal.toPath());
|
||||
return true;
|
||||
}
|
||||
};
|
||||
observed(engine, transfer, gallery, A);
|
||||
fails(() -> reconcile(engine, transfer, gallery, A));
|
||||
assertEquals("pending", engine.items().get(0).destination);
|
||||
Files.delete(journal.toPath()); Files.write(journal.toPath(), pendingJournal.get());
|
||||
SyncEngine relaunched = new SyncEngine(root, true);
|
||||
reconcile(relaunched, transfer, gallery);
|
||||
assertEquals("saved", relaunched.items().get(0).destination);
|
||||
assertEquals(1, gallery.saves);
|
||||
assertEquals(2, gallery.attempts);
|
||||
assertEquals(1, transfer.downloads.size());
|
||||
}
|
||||
|
||||
@Test public void failedBaselineWriteCannotTurnHistoryIntoNewScreenshots() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
File journal = new File(root, "receipts.json");
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
byte[] initialJournal = Files.readAllBytes(journal.toPath());
|
||||
Files.delete(journal.toPath()); Files.createDirectory(journal.toPath());
|
||||
fails(() -> reconcile(engine, transfer, gallery, A));
|
||||
Files.delete(journal.toPath()); Files.write(journal.toPath(), initialJournal);
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
assertTrue(engine.items().isEmpty());
|
||||
assertTrue(transfer.downloads.isEmpty());
|
||||
}
|
||||
|
||||
@Test public void firstObservedOffStaysAppOnlyWhenEnabledBeforeStabilityIncludingRelaunch() throws Exception {
|
||||
for (boolean restart : new boolean[]{false, true}) {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
assertDownloadPending(engine, "appOnly");
|
||||
engine.setGallery(true);
|
||||
if (restart) engine = new SyncEngine(root, true);
|
||||
reconcile(engine, transfer, gallery, A);
|
||||
assertTrue(engine.items().get(0).file.isFile());
|
||||
assertEquals("appOnly", engine.items().get(0).destination);
|
||||
assertEquals(0, gallery.attempts);
|
||||
reconcile(new SyncEngine(root, true), transfer, gallery, A);
|
||||
assertEquals(0, gallery.attempts);
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void observingOnOffOnStaysAppOnlyBeforeStabilityIncludingRelaunch() throws Exception {
|
||||
for (boolean restart : new boolean[]{false, true}) {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
Transfer transfer = new Transfer(); Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
assertDownloadPending(engine, "pending");
|
||||
engine.setGallery(false);
|
||||
assertDownloadPending(engine, "appOnly");
|
||||
if (restart) engine = new SyncEngine(root, false);
|
||||
engine.setGallery(true);
|
||||
if (restart) engine = new SyncEngine(root, true);
|
||||
// An intervening metadata change must preserve the first observation's disposition.
|
||||
SyncEngine.Shot changed = new SyncEngine.Shot(FIRST, 5, A.time + 1);
|
||||
reconcile(engine, transfer, gallery, changed);
|
||||
assertDownloadPending(engine, "appOnly");
|
||||
reconcile(engine, transfer, gallery, changed);
|
||||
assertTrue(engine.items().get(0).file.isFile());
|
||||
assertEquals("appOnly", engine.items().get(0).destination);
|
||||
assertEquals(0, gallery.attempts);
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void permanentGalleryFailureAllowsLaterOriginalAndExportInSamePoll() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
Transfer transfer = new Transfer();
|
||||
IOException unavailable = new IOException("permanent Gallery failure");
|
||||
Gallery gallery = new Gallery() {
|
||||
@Override public boolean export(SyncEngine.Shot shot, File file,
|
||||
java.util.function.BooleanSupplier eligible) throws Exception {
|
||||
if (shot.id.equals(FIRST)) throw unavailable;
|
||||
return super.export(shot, file, eligible);
|
||||
}
|
||||
};
|
||||
observed(engine, transfer, gallery, A);
|
||||
assertSame(unavailable, fails(() -> reconcile(engine, transfer, gallery, A)).getCause());
|
||||
fails(() -> reconcile(engine, transfer, gallery, A, B));
|
||||
assertFalse(item(engine, SECOND).file.exists()); // B has only one observation.
|
||||
assertSame(unavailable, fails(() -> reconcile(engine, transfer, gallery, A, B)).getCause());
|
||||
assertEquals(Arrays.asList(FIRST, SECOND), transfer.downloads);
|
||||
assertTrue(item(engine, SECOND).file.isFile());
|
||||
assertEquals("saved", item(engine, SECOND).destination);
|
||||
assertEquals("pending", item(engine, FIRST).destination);
|
||||
fails(() -> reconcile(engine, transfer, gallery, A, B));
|
||||
assertEquals(2, transfer.downloads.size());
|
||||
assertEquals(1, gallery.saves);
|
||||
SyncEngine relaunched = new SyncEngine(root, true);
|
||||
assertEquals("pending", item(relaunched, FIRST).destination);
|
||||
assertEquals("saved", item(relaunched, SECOND).destination);
|
||||
}
|
||||
|
||||
@Test public void permanentTransferFailureAllowsLaterOriginalInSamePollAndStaysQueued() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
IOException unavailable = new IOException("permanent transfer failure");
|
||||
Transfer transfer = new Transfer() {
|
||||
@Override public void download(SyncEngine.Shot shot, File file) throws Exception {
|
||||
if (shot.id.equals(FIRST)) { downloads.add(shot.id); throw unavailable; }
|
||||
super.download(shot, file);
|
||||
}
|
||||
};
|
||||
Gallery gallery = new Gallery();
|
||||
observed(engine, transfer, gallery, A);
|
||||
fails(() -> reconcile(engine, transfer, gallery, A));
|
||||
fails(() -> reconcile(engine, transfer, gallery, A, B));
|
||||
assertSame(unavailable, fails(() -> reconcile(engine, transfer, gallery, A, B)).getCause());
|
||||
assertFalse(item(engine, FIRST).file.exists());
|
||||
assertTrue(item(engine, SECOND).file.isFile());
|
||||
assertEquals("appOnly", item(engine, SECOND).destination);
|
||||
assertEquals(Arrays.asList(FIRST, FIRST, FIRST, SECOND), transfer.downloads);
|
||||
SyncEngine relaunched = new SyncEngine(root, false);
|
||||
fails(() -> reconcile(relaunched, transfer, gallery, A, B));
|
||||
assertEquals(1, Collections.frequency(transfer.downloads, SECOND));
|
||||
assertFalse(item(relaunched, FIRST).file.exists());
|
||||
assertTrue(item(relaunched, SECOND).file.isFile());
|
||||
}
|
||||
|
||||
@Test public void permanentTruncatedInvalidOrUndecodableImageDoesNotStarveLaterOriginal() throws Exception {
|
||||
for (String mode : Arrays.asList("truncated", "invalid", "decoder")) {
|
||||
SyncEngine engine = new SyncEngine(temporary.newFolder(), false);
|
||||
Transfer transfer = new Transfer() {
|
||||
private boolean first;
|
||||
|
||||
@Override public void download(SyncEngine.Shot shot, File file) throws Exception {
|
||||
super.download(shot, file);
|
||||
first = shot.id.equals(FIRST);
|
||||
if (first && mode.equals("truncated")) {
|
||||
try (RandomAccessFile contents = new RandomAccessFile(file, "rw")) {
|
||||
contents.setLength(shot.size - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override public boolean validImage(File file) throws Exception {
|
||||
if (first && mode.equals("decoder")) throw new IOException("decoder failure");
|
||||
return !first;
|
||||
}
|
||||
};
|
||||
Gallery gallery = new Gallery();
|
||||
reconcile(engine, transfer, gallery);
|
||||
reconcile(engine, transfer, gallery, A, B);
|
||||
IOException error = fails(() -> reconcile(engine, transfer, gallery, A, B));
|
||||
assertTrue(error.getMessage().contains(FIRST));
|
||||
assertEquals(Arrays.asList(FIRST, SECOND), transfer.downloads);
|
||||
assertFalse(item(engine, FIRST).file.exists());
|
||||
assertTrue(item(engine, SECOND).file.isFile());
|
||||
fails(() -> reconcile(engine, transfer, gallery, A, B));
|
||||
assertEquals(1, Collections.frequency(transfer.downloads, SECOND));
|
||||
assertEquals(2, Collections.frequency(transfer.downloads, FIRST));
|
||||
}
|
||||
}
|
||||
|
||||
@Test public void fatalJournalCommitAfterRetryableFailureHaltsLaterDownloadsUntilRecovered() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
File journal = new File(root, "receipts.json");
|
||||
SyncEngine engine = new SyncEngine(root, true);
|
||||
Transfer transfer = new Transfer();
|
||||
SyncEngine.Shot third = shot("123/456/20261024120002_3.jpg");
|
||||
IOException unavailable = new IOException("permanent Gallery failure");
|
||||
AtomicReference<byte[]> pendingJournal = new AtomicReference<>();
|
||||
Gallery gallery = new Gallery() {
|
||||
@Override public boolean export(SyncEngine.Shot shot, File file,
|
||||
java.util.function.BooleanSupplier eligible) throws Exception {
|
||||
if (shot.id.equals(FIRST)) throw unavailable;
|
||||
if (shot.id.equals(SECOND) && pendingJournal.get() == null) {
|
||||
assertTrue(eligible.getAsBoolean());
|
||||
pendingJournal.set(Files.readAllBytes(journal.toPath()));
|
||||
Files.delete(journal.toPath()); Files.createDirectory(journal.toPath());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
reconcile(engine, transfer, gallery);
|
||||
reconcile(engine, transfer, gallery, A, B, third);
|
||||
IOException fatal = fails(() -> reconcile(engine, transfer, gallery, A, B, third));
|
||||
assertNotSame(unavailable, fatal.getCause()); // Journal failure supersedes deferred Gallery failure.
|
||||
assertTrue(journal.isDirectory());
|
||||
assertEquals(Arrays.asList(FIRST, SECOND), transfer.downloads);
|
||||
assertEquals("pending", item(engine, SECOND).destination);
|
||||
assertFalse(item(engine, third.id).file.exists());
|
||||
Files.delete(journal.toPath()); Files.write(journal.toPath(), pendingJournal.get());
|
||||
SyncEngine relaunched = new SyncEngine(root, true);
|
||||
assertSame(unavailable, fails(() -> reconcile(relaunched, transfer, gallery, A, B, third)).getCause());
|
||||
assertEquals(Arrays.asList(FIRST, SECOND, third.id), transfer.downloads);
|
||||
assertEquals("saved", item(relaunched, SECOND).destination);
|
||||
assertEquals("saved", item(relaunched, third.id).destination);
|
||||
assertEquals("pending", item(relaunched, FIRST).destination);
|
||||
}
|
||||
|
||||
@Test public void fatalOriginalInstallationFailureHaltsLaterDownloads() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
Transfer transfer = new Transfer() {
|
||||
@Override public void download(SyncEngine.Shot shot, File file) throws Exception {
|
||||
super.download(shot, file);
|
||||
if (shot.id.equals(FIRST)) Files.write(new File(root, "originals").toPath(), new byte[]{1});
|
||||
}
|
||||
};
|
||||
Gallery gallery = new Gallery();
|
||||
reconcile(engine, transfer, gallery);
|
||||
reconcile(engine, transfer, gallery, A, B);
|
||||
IOException fatal = fails(() -> reconcile(engine, transfer, gallery, A, B));
|
||||
assertEquals("Invalid originals directory", fatal.getMessage());
|
||||
assertEquals(Collections.singletonList(FIRST), transfer.downloads);
|
||||
assertFalse(item(engine, FIRST).file.exists());
|
||||
assertFalse(item(engine, SECOND).file.exists());
|
||||
}
|
||||
|
||||
@Test public void lateRetryableFailureAfterStopCannotChangeFreshJournalOrSurfaceAnError() throws Exception {
|
||||
File root = temporary.newFolder();
|
||||
File journal = new File(root, "receipts.json");
|
||||
SyncEngine engine = new SyncEngine(root, false);
|
||||
Transfer fresh = new Transfer(); Gallery gallery = new Gallery();
|
||||
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
|
||||
Transfer slow = new Transfer() {
|
||||
@Override public void download(SyncEngine.Shot shot, File file) throws Exception {
|
||||
entered.countDown(); await(release);
|
||||
throw new IOException("late transfer failure");
|
||||
}
|
||||
};
|
||||
reconcile(engine, fresh, gallery);
|
||||
reconcile(engine, fresh, gallery, A, B);
|
||||
try (Async async = new Async(() -> reconcile(engine, slow, gallery, A, B))) {
|
||||
byte[] saved;
|
||||
try {
|
||||
await(entered); engine.stop();
|
||||
reconcile(engine, fresh, gallery, A, B);
|
||||
saved = Files.readAllBytes(journal.toPath());
|
||||
} finally { release.countDown(); }
|
||||
async.finish();
|
||||
assertArrayEquals(saved, Files.readAllBytes(journal.toPath()));
|
||||
}
|
||||
assertEquals(Arrays.asList(FIRST, SECOND), fresh.downloads);
|
||||
assertTrue(item(engine, FIRST).file.isFile());
|
||||
assertTrue(item(engine, SECOND).file.isFile());
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -11,6 +11,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { preferenceStore } = require("./preferences");
|
||||
const { createMetaSignIn } = require("./meta-signin");
|
||||
|
||||
const run = promisify(execFile);
|
||||
@@ -28,6 +29,7 @@ const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Contr
|
||||
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"));
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
@@ -280,6 +282,14 @@ ipcMain.handle("server:restart", (e) => {
|
||||
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
|
||||
});
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
// Text from the headset's clipboard onto this computer's.
|
||||
ipcMain.handle("clipboard:write", (e, text) => {
|
||||
if (!fromUi(e) || typeof text !== "string") return false;
|
||||
clipboard.writeText(text);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
|
||||
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
|
||||
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
||||
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
||||
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.7",
|
||||
"version": "0.4.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.7",
|
||||
"version": "0.4.13",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.7",
|
||||
"version": "0.4.13",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -11,9 +11,8 @@
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"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 && electron-builder --linux --x64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
|
||||
"dist:linux:arm64": "node build/fetch-deps.js linux arm64 && electron-builder --linux --arm64 --publish never"
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
@@ -40,6 +39,7 @@
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
@@ -185,16 +185,7 @@
|
||||
"entry": {
|
||||
"StartupWMClass": "frame-control"
|
||||
}
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/desktop/${os}-${arch}",
|
||||
"to": "desktop/bundle",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deb": {
|
||||
"depends": [
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Client choices live outside the local server's changing URL/origin.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const KEYS = ["mvSeparate", "padUsed", "pcView"];
|
||||
const valid = value => value === "0" || value === "1";
|
||||
|
||||
function preferenceStore(file) {
|
||||
function read() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
|
||||
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
|
||||
} catch { return {}; }
|
||||
}
|
||||
function write(key, value) {
|
||||
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
|
||||
const saved = { ...read(), [key]: value };
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = file + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
return true;
|
||||
}
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
module.exports = { preferenceStore };
|
||||
@@ -1,5 +1,5 @@
|
||||
// Lets the page read this computer's clipboard through Electron, so sending it
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// Lets the page read and write this computer's clipboard through Electron, so sending
|
||||
// it to the Frame, and copying the Frame's back, needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
||||
@@ -11,8 +11,13 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
preferences: {
|
||||
read: () => ipcRenderer.invoke("preferences:read"),
|
||||
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
|
||||
},
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
|
||||
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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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 { preferenceStore } = require("../preferences");
|
||||
|
||||
function fixture(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return path.join(dir, "client-preferences.json");
|
||||
}
|
||||
|
||||
test("desktop packaging includes every local application module dependency", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const packaged = new Set(require("../package.json").build.files);
|
||||
for (const file of ["main.js", "preload.js"]) {
|
||||
const source = fs.readFileSync(path.join(root, file), "utf8");
|
||||
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
|
||||
const target = match[1].slice(2) + ".js";
|
||||
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("client choices survive a new native session and local server origin", t => {
|
||||
const file = fixture(t);
|
||||
const first = preferenceStore(file);
|
||||
assert.deepEqual(first.read(), {});
|
||||
first.write("mvSeparate", "0");
|
||||
first.write("padUsed", "1");
|
||||
// A new process/session reads the same client store, regardless of its URL.
|
||||
const second = preferenceStore(file);
|
||||
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
|
||||
second.write("mvSeparate", "1");
|
||||
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("only the non-sensitive binary choices can enter the native store", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
|
||||
assert.throws(() => store.write(key, value), /Invalid preference/);
|
||||
}
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
|
||||
assert.deepEqual(store.read(), { mvSeparate: "0" });
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("a missing or damaged preference file retains usable defaults", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.deepEqual(store.read(), {});
|
||||
for (const bad of ["{broken", "null", "[]", '"text"']) {
|
||||
fs.writeFileSync(file, bad);
|
||||
assert.deepEqual(store.read(), {});
|
||||
}
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(store.read(), { padUsed: "1" });
|
||||
});
|
||||
|
||||
test("the PC in the headset opt-in is remembered and off unless saved", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.equal(store.read().pcView, undefined);
|
||||
store.write("pcView", "1");
|
||||
assert.deepEqual(preferenceStore(file).read(), { pcView: "1" });
|
||||
assert.throws(() => store.write("pcView", "on"), /Invalid preference/);
|
||||
});
|
||||
@@ -4,6 +4,10 @@
|
||||
Run on the target architecture after installing GStreamer development packages.
|
||||
No GStreamer executable or desktop streaming application is shipped or invoked.
|
||||
Linux libc, display/GPU drivers and the portal service remain platform pieces.
|
||||
|
||||
x264 is GPL, so the bundle the Windows installer ships leaves it out: Windows
|
||||
encodes with Media Foundation. --with-x264 adds it for CI's headless protocol
|
||||
tests, which have no GPU or desktop to encode with.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@@ -21,7 +25,7 @@ def output(*args):
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
|
||||
def build(destination):
|
||||
def build(destination, with_x264=False):
|
||||
win = sys.platform == 'win32'
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
|
||||
@@ -39,7 +43,9 @@ def build(destination):
|
||||
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
|
||||
plugins_out.mkdir(parents=True, exist_ok=True)
|
||||
libs_out.mkdir(parents=True, exist_ok=True)
|
||||
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad', 'x264']
|
||||
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad']
|
||||
if with_x264:
|
||||
names.append('x264')
|
||||
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
|
||||
pending = [lib]
|
||||
for name in names:
|
||||
@@ -108,11 +114,13 @@ def build(destination):
|
||||
for path in Path('/usr/share/doc').glob('*/copyright'):
|
||||
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
|
||||
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
|
||||
libraries=sorted(copied), packages=packages), indent=2))
|
||||
plugins=names, libraries=sorted(copied), packages=packages), indent=2))
|
||||
print(lib)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
|
||||
build(parser.parse_args().out.resolve())
|
||||
parser.add_argument('--with-x264', action='store_true', help='add the GPL x264 encoder (CI tests only, never shipped)')
|
||||
args = parser.parse_args()
|
||||
build(args.out.resolve(), args.with_x264)
|
||||
@@ -22,6 +22,15 @@ FC_API int fc_has_element(const char *name) {
|
||||
GstElementFactory *f=gst_element_factory_find(name);
|
||||
if(!f)return 0;gst_object_unref(f);return 1;
|
||||
}
|
||||
FC_API int fc_has_property(const char *element, const char *property) {
|
||||
/* Probe the same initialized GStreamer instance that capture uses. The
|
||||
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
|
||||
GstElement *source=gst_element_factory_make(element,NULL);
|
||||
if(!source)return 0;
|
||||
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
|
||||
gst_object_unref(source);
|
||||
return found;
|
||||
}
|
||||
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
|
||||
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
|
||||
if(!b)return GST_PAD_PROBE_OK;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* Extracted from the Mac agent's RateController. Same gate, demand protection,
|
||||
* bitrate recovery and tier hysteresis for every host. Bounded sample buffers. */
|
||||
/* A C port of the Mac agent's RateController (mac/frame-mac-view/Sources/
|
||||
* Controller.swift) for the Windows host. Same gate, demand protection, bitrate
|
||||
* recovery and tier hysteresis; bounded sample buffers. The Mac agent keeps its
|
||||
* Swift original, so a change to either must be made to both:
|
||||
* tests/test_pc_controller.py checks this one against the Swift trace. */
|
||||
#include "controller.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Import an Android game backup
|
||||
|
||||
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 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.
|
||||
|
||||
The import screen shows the game app and its game-data files together. **Import
|
||||
game & data** installs the APK, starts its own Steam library instance, waits for
|
||||
Android and the installed package, copies the OBBs, and closes the game. Open it
|
||||
again from the Steam library to play. The screen explains this before import;
|
||||
choosing files alone never starts a game.
|
||||
|
||||
Files are matched using the package inside the APK and the standard
|
||||
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
|
||||
patch files are preserved. OBB version numbers need not equal the APK's current
|
||||
version code: updates can reuse older expansion files. The copy uses the existing
|
||||
SHA-256 transfer check inside Android and writes to that game's own
|
||||
`/sdcard/Android/obb/<package>` folder.
|
||||
|
||||
If no data files are selected, the screen reminds you to include any OBBs in the
|
||||
backup before offering **Install game app**. An APK cannot reliably tell Frame
|
||||
Control whether separate expansion files are required. Game-data files are never
|
||||
silently sent to Downloads by this flow.
|
||||
|
||||
If setup fails, the import screen keeps the error and retry instructions visible.
|
||||
The APK may already be installed, but the game is not reported ready. Import the
|
||||
same backup again, or choose just its OBB files to add data to an existing install.
|
||||
The importer checks the installed package and version before writing data and
|
||||
closes the instance after a failed copy. Unrelated or duplicate files, running
|
||||
games that would be replaced, and downgrades are rejected. Uploaded staging files
|
||||
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 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
|
||||
matching, sibling discovery, browser upload staging and cleanup, instance/version
|
||||
guards and failure recovery with mocked headset operations. Browser verification
|
||||
uses the real page and upload/inspection endpoints with simulated installation.
|
||||
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
|
||||
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
|
||||
install/start/copy/close sequence. Reopening its persistent instance confirmed
|
||||
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
|
||||
shortcut and instance were removed afterward, the shared device lock was
|
||||
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
|
||||
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.
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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 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.
|
||||
|
||||
Turning Gallery off keeps automatic downloads inside Frame Control. Pending
|
||||
exports become app-only; enabling it again does not export those earlier items.
|
||||
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.
|
||||
|
||||
**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, loss of network, force-stop or process termination
|
||||
can delay or end the session. 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.
|
||||
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 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.
|
||||
@@ -183,6 +183,92 @@ See
|
||||
[compat-db/README.md](../compat-db/README.md) and
|
||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||
|
||||
## Store and Library in Frame Control
|
||||
|
||||
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
|
||||
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
|
||||
Frame as cards, with Play and a menu for Test, Report and Remove; the display
|
||||
settings and recent reports are below it.
|
||||
|
||||
Store search cards and app details also show **Reported not working** or
|
||||
**Reported issues** when saved compatibility reports say so. Details include
|
||||
the report date, version and notes. These are advisories: Install remains
|
||||
available unless the selected build has an existing compatibility blocker
|
||||
(for example, no arm64-v8a build or Android API above 30). Reports never clear
|
||||
those blockers. Apps reported not working are excluded from Home's featured
|
||||
app and recommendation shelves; search and category results still show them.
|
||||
An unrated install failure caused by the wrong APK architecture or Android API
|
||||
is ignored, using the same rules as the catalogue. A person's rating takes
|
||||
priority over automated results, with the latest rating winning.
|
||||
|
||||
Reports warm in a background worker at server start. Search and details read
|
||||
only the last complete in-memory snapshot and schedule a single background
|
||||
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
|
||||
the report database. During initial loading they show the usual build verdict;
|
||||
after a refresh, the next search or detail request sees the reports. Failed
|
||||
refreshes keep the previous snapshot and retry after another 60 seconds.
|
||||
|
||||
**Availability:** public copies benefit from their own saved reports, including
|
||||
reports for apps outside the bundled catalogue, across every source with the
|
||||
same package name. This does not give public users the private shared ratings.
|
||||
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
|
||||
report verdicts, so it cannot supply a useful public report snapshot. No private
|
||||
reports, free-text notes or credentials are exported or published. The existing
|
||||
maintainer loader still uses its configured database access in the background;
|
||||
the search feature adds no sharing, endpoint or network destination. Tests leave
|
||||
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
|
||||
server subprocesses) and use synthetic reports to exercise refresh behavior.
|
||||
|
||||
The store groups apps by what a headset is good for, not by F-Droid's list:
|
||||
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
|
||||
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
|
||||
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
|
||||
*Social Network* is Web & chat).
|
||||
|
||||
Apps built around a phone are hidden from browsing unless **Show phone-only
|
||||
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
|
||||
a category that only makes sense in a phone's own system (launchers, keyboards,
|
||||
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
|
||||
fitness, alarms, system tools) unless a useful category says otherwise
|
||||
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
|
||||
part (a widget, a launcher, a battery tool), and apps that need hardware the
|
||||
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
|
||||
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
|
||||
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
|
||||
|
||||
Without a search, each source offers its best 300 for the chosen category:
|
||||
apps with a banner first, then with screenshots, then the newest.
|
||||
|
||||
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
|
||||
|
||||
The **APK Store** panel is the Store above, drawn for the laser: the same
|
||||
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
|
||||
its own floating panel:
|
||||
|
||||
```sh
|
||||
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
|
||||
scripts/store-on-frame.sh close
|
||||
```
|
||||
|
||||
`open` copies the same bundle the iPhone app uses
|
||||
(`ios/scripts/make_frame_bundle.py`) to
|
||||
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
|
||||
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
|
||||
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
|
||||
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
|
||||
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
|
||||
all run on the Frame, so the Mac can go away after `open`.
|
||||
|
||||
The page has a category rail (Home, the store's categories, Installed), eight
|
||||
art cards a page, and a featured VR app on Home. It turns pages instead of
|
||||
scrolling, because a tagged panel gets mouse clicks and a drag would select
|
||||
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
|
||||
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
|
||||
the real sources): Home, categories, Installed, the detail sheet. **Still to
|
||||
check in the headset:** that the panel opens and takes laser clicks, that an
|
||||
install completes when run on the Frame, and that **Open** brings up the app's
|
||||
panel.
|
||||
|
||||
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
|
||||
|
||||
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
|
||||
@@ -345,3 +431,49 @@ app instance and back up/restore a stopped instance's private app data. See
|
||||
[SideQuest features and limits](sidequest.md) for commands, archive scope and
|
||||
verification status. These paths have offline coverage; real Frame storage and
|
||||
permissions remain unverified. They do not change APK install or launch behavior.
|
||||
|
||||
|
||||
## Local APK/OBB backups and Android bundles
|
||||
|
||||
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
|
||||
standalone APK and its matching `main.<version>.<package>.obb` and/or
|
||||
`patch.<version>.<package>.obb` files together. The desktop app also discovers
|
||||
matching OBB files beside a lone selected APK. Browser uploads use only the
|
||||
files you selected. Installed apps can receive OBB-only imports.
|
||||
|
||||
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
|
||||
one standalone APK and optional matching OBBs. These extensions describe
|
||||
containers, not a guarantee that their contents are supported. OBBs may be
|
||||
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
|
||||
one enclosing backup folder is allowed. Known manifest/icon/signature
|
||||
sidecars are ignored; the APK manifest determines the package. Unknown files,
|
||||
unsafe paths, links, encrypted entries, duplicate paths (including case
|
||||
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
|
||||
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
|
||||
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
extract an Android ZIP yourself and select its APK/OBBs.
|
||||
|
||||
The importer previews the app and files before **Import game & data**.
|
||||
Confirming authorizes installing the app, starting it to prepare Android,
|
||||
checking the running package/version, copying OBBs through the existing
|
||||
verified data helper, and closing the game. Open it from Steam afterwards.
|
||||
If setup fails, read the error and retry the same backup; do not uninstall
|
||||
first, since that deletes saves. An app that cannot start may also prevent
|
||||
data setup. Uploaded and extracted staging is removed on cancellation,
|
||||
inspection failure, or job completion/failure; abandoned imports expire
|
||||
through the existing one-hour staging lifecycle. Original local files stay intact.
|
||||
|
||||
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
|
||||
bases marked as requiring splits. It does not select device variants or restore
|
||||
`Android/data`, private app data, saves, or entitlements. For the separate
|
||||
restricted native-library/asset split workflow see
|
||||
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
|
||||
code and feature splits are not supported by that merger.
|
||||
|
||||
For issue #102, the RE4 report only establishes that an APK attempt failed.
|
||||
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
|
||||
and runtime compatibility remain unknown. Record those details, Frame Control
|
||||
and SteamOS versions, and sanitized server/crash errors before diagnosing it.
|
||||
`python3 ui/frame_android.py info /path/to/game.apk` inspects the APK locally.
|
||||
Successful installation or fixture tests do not establish RE4 compatibility;
|
||||
OBBs cannot fix ABI/API, VrApi/OpenXR, entitlement or Android-service failures.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Backup and recovery
|
||||
|
||||
**Tools → Backup & recovery** copies what a factory reset would lose from the
|
||||
Steam Frame to your computer, puts it back afterwards, and gets the headset out
|
||||
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
|
||||
app or the Frame itself.
|
||||
|
||||
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
|
||||
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
|
||||
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
|
||||
with no sign-in files or keys in the archives. Restoring one game's saves on top
|
||||
of identical files wrote nothing and left nothing behind. The session check
|
||||
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
|
||||
headset: a restore after a real factory reset, a restore that replaces files,
|
||||
Android data restore, 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
|
||||
|
||||
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
|
||||
(all three by default), then **Back up now**. **See what's included** lists each
|
||||
part and its size first. Backing up only reads the headset.
|
||||
|
||||
Each backup is a folder named after the time it started:
|
||||
|
||||
```
|
||||
~/Documents/Frame Control Backups/2026-10-23-142530/
|
||||
manifest.json
|
||||
saves/saves-1234.tar.gz one per game
|
||||
saves/saves-linux.tar.gz
|
||||
settings/settings-steam.tar.gz
|
||||
settings/settings-system.tar.gz
|
||||
android/org.example.game.tar.gz one per Android app
|
||||
```
|
||||
|
||||
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
|
||||
file with its size and SHA-256, and each archive's SHA-256. It also lists what
|
||||
was skipped and why. A backup is written under a hidden `.…partial` name and
|
||||
renamed when complete, so a cut-off backup never shows up as one.
|
||||
|
||||
### What's saved
|
||||
|
||||
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|
||||
|---|---|---|
|
||||
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
|
||||
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
|
||||
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
|
||||
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
|
||||
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
|
||||
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
|
||||
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
|
||||
|
||||
"Kept for reference" files are in the backup but not put back automatically:
|
||||
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
|
||||
point at apps that aren't installed after a reset. Open the backup folder to
|
||||
copy one by hand if you need it.
|
||||
|
||||
### What's never copied
|
||||
|
||||
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
|
||||
`registry.vdf` and `ssfn*` files.
|
||||
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
|
||||
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
|
||||
(`secrets.env`). These are refused by name wherever they turn up, including
|
||||
inside a game's folder, and a restore refuses them too.
|
||||
- Games, Proton and the Steam runtimes: reinstall them from Steam.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
|
||||
Android apps that are open are skipped (the helper needs the app closed); close
|
||||
them and back up again.
|
||||
|
||||
## Restore after a factory reset
|
||||
|
||||
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
|
||||
Steam with the same account.
|
||||
2. Reinstall your games, and **start each once** so Proton creates its folders.
|
||||
3. Reinstall your Android apps from Frame Control.
|
||||
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
|
||||
each part against the headset and says what it would do: how many files it
|
||||
replaces and how many are new.
|
||||
5. Tick the parts to restore and **Restore selected**. It asks before replacing
|
||||
anything.
|
||||
|
||||
Parts that can't go back yet are skipped, not failed, with the reason:
|
||||
|
||||
| Reason | What to do |
|
||||
|---|---|
|
||||
| Install the game first | Install it from Steam, then restore |
|
||||
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
|
||||
| Sign in to Steam first | Controller layouts go under your account's folder |
|
||||
| Install the app first / close the app first | Android app data needs the app installed and closed |
|
||||
| The backup file doesn't match its checksum | The archive changed since it was made |
|
||||
|
||||
A restore writes only the files in the parts you chose, and only inside that
|
||||
part's folders: it never deletes a file, and a save archive can't write outside
|
||||
its game. Before replacing a file it copies the current one to
|
||||
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
|
||||
already identical are left alone. Android app data goes through the existing
|
||||
`restore-data` helper, which keeps the previous data folder beside it.
|
||||
|
||||
## Stuck in Desktop Mode
|
||||
|
||||
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
|
||||
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
|
||||
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
|
||||
is what you see, you're already in Game Mode: close it from the Steam dashboard.
|
||||
|
||||
Full Desktop Mode is a different session. 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)).
|
||||
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
||||
If the button can't help, or the headset isn't reachable:
|
||||
|
||||
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
|
||||
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
|
||||
Desktop Mode session, so a restart brings Game Mode back. That the Frame
|
||||
behaves the same is **inferred**, not tested.
|
||||
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
|
||||
`ssh frame 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
|
||||
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
|
||||
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
|
||||
`Erase User Data` is a factory reset.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
|
||||
the server, deletes it and restores it. Neither involves a real headset.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Battery and health monitoring
|
||||
|
||||
Open **Control Center → Battery & thermal health**, or expand **System
|
||||
information**, to read the Frame's charge, charging state, signed battery
|
||||
power, battery temperature and driver-reported health. Optional readings show
|
||||
charge cycles, full charge capacity, design capacity and full / design capacity.
|
||||
The existing battery chip also shows charging state and temperature.
|
||||
|
||||
These are read-only readings from the Linux host's `/sys/class/power_supply`,
|
||||
not Android's container battery view. The probe prefers the documented Frame
|
||||
fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral
|
||||
(`scope=Device`) and absent batteries, and otherwise uses a deterministic
|
||||
battery fallback. The UI shows the selected source path.
|
||||
|
||||
| Reading | Linux attribute | Interpretation |
|
||||
|---|---|---|
|
||||
| Charge | `capacity` | Integer 0–100 percent; zero is a real reading |
|
||||
| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable |
|
||||
| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame |
|
||||
| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings |
|
||||
| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage |
|
||||
| Charge cycles | `cycle_count` | Driver-reported count, including zero |
|
||||
| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh |
|
||||
| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime |
|
||||
| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state |
|
||||
|
||||
Unavailable means the field is absent, unreadable or invalid. One missing
|
||||
sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second
|
||||
to 7 days and battery temperatures outside −273.15 to 200 °C are rejected
|
||||
as invalid readings; these bounds are not thermal warning thresholds.
|
||||
The ratio may exceed 100% because full capacity is a gauge estimate, so the app
|
||||
does not clamp it or invent a wear warning. The app does not estimate health
|
||||
from charge percentage or assign temperature thresholds. Existing thermal
|
||||
alerts use the kernel's own hot/critical trip points.
|
||||
|
||||
A connected charger does not necessarily mean charging: the Frame can still
|
||||
drain under load or sit idle near full charge. A 100% capacity reading alone
|
||||
does not prove the driver reports Full. Missing status is explicitly unknown;
|
||||
near-zero measured battery power with a charger is shown as not charging.
|
||||
Failed, stale or disconnected status samples clear Control Center readings.
|
||||
|
||||
**No charge controls are provided.** Frame's SteamOS charge-limit backend is
|
||||
unsupported; see [charge-limit research](charge-limit.md). Monitoring never
|
||||
writes to power supplies, pauses charging, changes current or sets a limit.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_battery.py` uses temporary sysfs-like files for conversion,
|
||||
malformed/missing values, permission failures, source selection and partial
|
||||
sensor support. `tests/control_center.cjs` exercises charging states, optional
|
||||
metrics and failed/stale/disconnected sample handling. These tests do not
|
||||
connect to a headset. On 2026-10-24, the PR's uninstalled parser was also
|
||||
piped over SSH to a real Frame running SteamOS 0.5.6 (build
|
||||
`20261015.6141207`). Its read-only sysfs result selected
|
||||
`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C,
|
||||
Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity
|
||||
(108.1% fuel-gauge estimate). The Control Center UI was not run against this
|
||||
headset; Windows and macOS were not tested. Optional fields are not claimed to
|
||||
be available on every firmware build.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Battery monitoring now selects the Frame fuel gauge predictably, handles
|
||||
missing or invalid readings, and shows optional cycle count, full/design
|
||||
capacity and estimated capacity health in Control Center. Unknown charging
|
||||
status stays explicit. Monitoring is read-only; charge limits remain
|
||||
unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Battery charge limit (80%): research
|
||||
|
||||
**Question:** can Frame Control set an 80% battery charge limit on the Frame
|
||||
over SSH or adb, safely?
|
||||
|
||||
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
|
||||
charge-limit setting uses on the Deck) is not configured for the Frame. The
|
||||
only control the Frame has is a vendor charger attribute that Valve's own
|
||||
charging daemon also manages. Research as of 2026-10-23, confirmed with the
|
||||
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
|
||||
|
||||
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
|
||||
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
|
||||
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
|
||||
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
|
||||
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
|
||||
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
|
||||
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
|
||||
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
|
||||
|
||||
For safe read-only readings in Control Center, see [battery and health
|
||||
monitoring](battery-health.md). It does not provide charging controls.
|
||||
|
||||
## How Steam does it on other devices
|
||||
|
||||
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
|
||||
SteamOS 3.7.8 (beta client 2025-05-08)
|
||||
([GamingOnLinux](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)).
|
||||
- Steam calls SteamOS Manager on the session bus:
|
||||
`com.steampowered.SteamOSManager1.BatteryChargeLimit1`, with properties
|
||||
`MaxChargeLevel` (`i`, read/write; `-1` resets to the default) and
|
||||
`SuggestedMinimumLimit` (`i`, read-only). The CLI is
|
||||
`steamosctl get-max-charge-level` / `set-max-charge-level` /
|
||||
`suggested-minimum-charge-limit`. Source: `data/interfaces/com.steampowered.SteamOSManager1.xml`
|
||||
and `steamos-manager/src/bin/steamosctl.rs` in
|
||||
[holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager),
|
||||
read at commit `83981ab` (2026-10-21).
|
||||
- The root daemon writes the value to sysfs only if the device's TOML in
|
||||
`data/devices/` has a `[battery_charge_limit]` section. Two methods exist
|
||||
(`steamos-manager/src/power.rs`, `BatteryChargeLimitMethod`):
|
||||
- `hwmon_attribute`: the Deck writes `max_battery_charge_level` on
|
||||
`steamdeck_hwmon` (`data/devices/steam-deck.toml`).
|
||||
- `acpi_sb`: ROG Ally, MSI Claw and others write the standard
|
||||
`charge_control_end_threshold` on the ACPI battery
|
||||
(`/sys/bus/{platform,acpi}/drivers/…battery…/PNP0C0A:00/…/power_supply/*/`).
|
||||
On ARM there's no ACPI, so this path can't match there either.
|
||||
- With no `[battery_charge_limit]` section, the interface fails with
|
||||
`UnknownInterface` (Steam can still show its toggle on unsupported hardware:
|
||||
[steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044)).
|
||||
|
||||
## What the Frame has
|
||||
|
||||
- **Upstream `data/devices/steam-frame.toml`** (matched by
|
||||
`dt.compatible = "qcom,sm8650"`, variant `Deckard`) configures GPU clocks
|
||||
and performance profiles only. It has **no `[battery_charge_limit]`**, so
|
||||
SteamOS Manager on the Frame has no charge-limit backend. Its git history
|
||||
(2026-09-10 to 2026-10-12) never added one.
|
||||
- **Battery driver:** the Frame's battery is
|
||||
`/sys/class/power_supply/max1720x_bat_7-36`, and the charger is a
|
||||
`tcpm-source-psy-…` USB-PD supply ([how-the-frame-works.md](how-the-frame-works.md)).
|
||||
Both were verified 2026-09-25, and `ui/frame_status.py` `battery()` and
|
||||
`power_source()` read them. The Maxim MAX1720x is a **fuel gauge** and
|
||||
can't control charging. Mainline `drivers/power/supply/max1720x_battery.c`
|
||||
exposes only read-only properties (capacity, voltage, current, temp,
|
||||
charge_full…), with no `charge_control_end_threshold`. Valve's build is
|
||||
patched: it reports `time_to_full_now`, which mainline doesn't have. Even
|
||||
so, a charge stop would have to come from the separate charger IC, not this
|
||||
node.
|
||||
- **Not the qcom_battmgr path.** Some other SM8650/SM8750 handhelds get
|
||||
`charge_control_end_threshold` from Qualcomm's `qcom_battmgr`
|
||||
(pmic_glink) driver, clamped at 55% or above. A third-party distro enabled
|
||||
Steam's limit on those by patching steamos-manager
|
||||
([armada-os/armada#758](https://github.com/armada-os/armada/pull/758)). The
|
||||
Frame's battery isn't exposed by `qcom_battmgr`, so that doesn't carry over.
|
||||
(Unverified: whether a `qcom_battmgr` node also exists on the Frame. The
|
||||
read-only checks below answer this.)
|
||||
- **What third parties found:**
|
||||
[y0usaf/frame-charge](https://github.com/y0usaf/frame-charge) reports that
|
||||
the Frame has no `charge_control_end_threshold` and falls back to a vendor
|
||||
`charging_pause` attribute. It also reports that `constant_charge_current`
|
||||
changes on its own, which it attributes to Valve's `deckard_charge_daemon`
|
||||
(`deckard-charger.service`). By its own README, that tool has only run its
|
||||
read-only `status` on a Frame and has **not** been tested against real
|
||||
charging. Two questions are still open: whether pausing stops the current,
|
||||
and whether Valve's daemon undoes it.
|
||||
|
||||
## Why not build it now
|
||||
|
||||
- **No supported endpoint.** The only Valve-supported interface,
|
||||
`BatteryChargeLimit1`, isn't backed on the Frame. Writing sysfs ourselves
|
||||
would bypass Steam and SteamOS Manager, and it needs root (`sudo -S` with
|
||||
the user's password).
|
||||
- **A rival owner.** Valve's charge daemon already adjusts charging
|
||||
parameters. Toggling `charging_pause` underneath it could be undone without
|
||||
warning, fight it in a loop, or leave charging paused.
|
||||
- **Stuck at a low charge.** If the app or the Frame dies while charging is
|
||||
paused, the Frame could sit at a low charge on the charger. Whether a
|
||||
reboot clears `charging_pause` is unknown.
|
||||
- **The UI would mislead.** Steam's setting (if it shows at all) wouldn't
|
||||
reflect our limit.
|
||||
- **adb doesn't help.** Wi-Fi adb (`frame:5555`) reaches the Lepton Android
|
||||
container only. USB-C adb reaches the Linux side as `steamos` (non-root, the
|
||||
same as SSH, [ssh.md](ssh.md)). Neither adds a charging-control route.
|
||||
Android's `dumpsys battery` in Lepton is a container view, not the hardware.
|
||||
|
||||
**Revisit when** a SteamOS update adds `[battery_charge_limit]` to
|
||||
`steam-frame.toml` (or `steamosctl get-max-charge-level` stops returning
|
||||
`UnknownInterface` on the Frame). Then the feature is just a SteamOS Manager
|
||||
call:
|
||||
|
||||
- Read: `steamosctl get-max-charge-level`
|
||||
- Set: `steamosctl set-max-charge-level 80`
|
||||
- Undo: `steamosctl set-max-charge-level -- -1`
|
||||
|
||||
That runs as `steamos` with no sudo. It's the same value Steam's own
|
||||
Settings → Power shows, so both stay in sync.
|
||||
|
||||
## Headset checks (read-only first)
|
||||
|
||||
Run these over SSH as `steamos`. Steps 1–4 only read.
|
||||
|
||||
```sh
|
||||
# 1. Does SteamOS Manager offer the interface on this build?
|
||||
steamosctl get-max-charge-level; steamosctl suggested-minimum-charge-limit
|
||||
busctl --user introspect com.steampowered.SteamOSManager1 \
|
||||
/com/steampowered/SteamOSManager1 | grep -i -A3 ChargeLimit
|
||||
grep -n battery_charge_limit /usr/share/steamos-manager/devices/*frame* 2>/dev/null
|
||||
|
||||
# 2. Which power supplies exist, and which drivers back them?
|
||||
for d in /sys/class/power_supply/*; do
|
||||
echo "== $d ($(cat $d/type)) driver=$(basename "$(readlink -f $d/device/driver)" 2>/dev/null)"
|
||||
ls $d
|
||||
done
|
||||
|
||||
# 3. Any charge-control attributes, with their values and permissions?
|
||||
ls -l /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
grep -H . /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
|
||||
# 4. What is Valve's charger daemon, and what does it touch?
|
||||
systemctl status deckard-charger.service --no-pager
|
||||
systemctl cat deckard-charger.service
|
||||
journalctl -b -u deckard-charger.service --no-pager | tail -50
|
||||
```
|
||||
|
||||
If step 1 succeeds on some build, test `steamosctl set-max-charge-level 80`
|
||||
on a charger at a charge above 80%. Watch `current_now` on the
|
||||
`max1720x_bat_*` node fall to about 0, then `steamosctl set-max-charge-level -- -1` and
|
||||
confirm charging resumes. Do this on a supervised Frame only. If only step 3
|
||||
finds a vendor attribute, **stop**: don't write it, and leave the question
|
||||
with Valve.
|
||||
|
||||
## Sources
|
||||
|
||||
- steamos-manager source, commit `83981ab` (2026-10-21):
|
||||
`data/devices/steam-frame.toml`, `data/devices/steam-deck.toml`,
|
||||
`data/interfaces/com.steampowered.SteamOSManager1.xml`,
|
||||
`steamos-manager/src/power.rs`, `steamos-manager/src/manager/user.rs`.
|
||||
[gitlab.steamos.cloud/holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager)
|
||||
- Mainline [`max1720x_battery.c`](https://github.com/torvalds/linux/blob/master/drivers/power/supply/max1720x_battery.c)
|
||||
- [ValveSoftware/steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044):
|
||||
the Steam toggle shows on unsupported hardware
|
||||
- [armada-os/armada#758](https://github.com/armada-os/armada/pull/758):
|
||||
`qcom_battmgr` threshold on other SM8650/SM8750 devices
|
||||
- [y0usaf/frame-charge](https://github.com/y0usaf/frame-charge): third-party,
|
||||
untested on real charging
|
||||
- [GamingOnLinux: SteamOS 3.7.7 beta charge limit](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)
|
||||
- Repo: [how-the-frame-works.md](how-the-frame-works.md) (battery and
|
||||
charger nodes), [ssh.md](ssh.md) (adb scope), `ui/frame_status.py`
|
||||
@@ -97,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||
|
||||
## The connection, stage by stage
|
||||
|
||||
When a connection fails, the header pill keeps its compact offline state and
|
||||
retry countdown; its tooltip and connection dialog contain the full reason.
|
||||
Devices shows a short reason in the headset row (with the full reason in its
|
||||
tooltip) and a wrapping recovery explanation in the selected headset's details.
|
||||
**Use this headset** also selects that headset's details.
|
||||
|
||||
The connector runs in the server (`frame_link.Link`) and moves through:
|
||||
|
||||
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"date": "2026-10-20T03:53:25Z",
|
||||
"build_id": "20260925.6191901",
|
||||
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
|
||||
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
|
||||
"sibling_discovery": true,
|
||||
"automatic_import": "passed",
|
||||
"closed_after_import": true,
|
||||
"independent_readback": [
|
||||
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
|
||||
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
|
||||
],
|
||||
"cleanup": {
|
||||
"test_install_removed": true,
|
||||
"running_instances_before": {},
|
||||
"running_instances_after": {},
|
||||
"battery_before": [
|
||||
"100",
|
||||
"Full",
|
||||
"Charging"
|
||||
],
|
||||
"battery_after": [
|
||||
"99",
|
||||
"Discharging",
|
||||
"Charging"
|
||||
],
|
||||
"shared_lock_released": true
|
||||
},
|
||||
"limits": [
|
||||
"No headset rendering or gameplay assertion; headset unworn.",
|
||||
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
|
||||
"A game that exits before its Lepton instance can accept data was not tested."
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,119 @@
|
||||
# iOS foreground screenshot downloads — 2026-10-24
|
||||
|
||||
This is implementation and proof for the foreground iOS portion of
|
||||
[#108](https://github.com/saphid/frame-control/issues/108). It is not the full
|
||||
requested background/cross-platform feature and is not released.
|
||||
|
||||
## Source and merge
|
||||
|
||||
Dedicated worktree: `/Users/saphid/.t3/worktrees/steam-frame/feat-phone-screenshot-autosync-108`.
|
||||
Branch: `feat/phone-screenshot-autosync-108`.
|
||||
|
||||
First merged fetched main
|
||||
[1d4c57d6](https://github.com/saphid/frame-control/commit/1d4c57d6)
|
||||
into the existing Mac-space parent
|
||||
[3fdc6c46](https://github.com/saphid/frame-control/commit/3fdc6c46).
|
||||
Merge commit: [52772323](https://github.com/saphid/frame-control/commit/52772323).
|
||||
The dirty primary checkout was left untouched. Review found and fixed the PC
|
||||
helper's missing public route for the extracted `mac-stream.js` dependency.
|
||||
Independent merge re-review ran 45 tests successfully, one skipped, and found
|
||||
no remaining actionable findings in its seven-file target.
|
||||
|
||||
## Implemented behavior
|
||||
|
||||
- Native polling every four seconds while active, independent of the visible web tab.
|
||||
- First successful listing becomes the durable baseline; only new captures queue.
|
||||
- Two equal metadata observations, exact byte count and complete ImageIO decode
|
||||
precede an atomic original write outside purgeable caches.
|
||||
- Receipts and originals distinguish pinned headset identity, Steam account,
|
||||
app ID and filename. Reconnect/relaunch resumes pending work.
|
||||
- Photos is enabled by default. Add-only permission is requested in foreground.
|
||||
Denied/failed exports keep originals; app-only captures never automatically
|
||||
export after re-enabling the setting.
|
||||
- Native downloads sheet exposes retained files through ShareLink, including
|
||||
during connection failure/relaunch. Ambiguous interrupted Photos commits need
|
||||
explicit recovery rather than risking an automatic duplicate.
|
||||
- Backgrounding stops polling; no unrelated background mode is used.
|
||||
|
||||
## Automated verification
|
||||
|
||||
Normal signed Simulator build and full native test suite: **20 tests passed,
|
||||
zero failures**, including eight screenshot tests. Result bundle:
|
||||
`/tmp/frame-108-ios-all-r3.xcresult`; log: `/tmp/frame-108-ios-all-r3.log`.
|
||||
Both command exit status and xcresult test count were checked. Private
|
||||
DerivedData cleanup completed. Test target was the independently created
|
||||
Simulator `F250F91E-2E7D-4833-B029-A275F11FDC7D` on iOS 26.5.
|
||||
|
||||
Screenshot tests cover baseline/stability, truncated data, restart dedup,
|
||||
Photos failure/retry, changes during download/consent, cancellation,
|
||||
account/app identity, corrupt-ledger failure, uncertain export handling,
|
||||
late success and failure across journal replacement, default preference and
|
||||
offline app-only originals. Download/export dependencies in these tests are
|
||||
injected; the live test below exercises actual transport and PhotoKit.
|
||||
|
||||
Python iOS packaging/Foundation contracts: 5 passed. Page scripts: 5 passed.
|
||||
`swiftc -typecheck` for the journal and scoped `git diff --check` passed.
|
||||
|
||||
## Actual headset → Simulator → Photos proof
|
||||
|
||||
Ran the production signed app against the real headset over its pinned SSH
|
||||
connection and the actual deployed `/api/shots` and `/api/shots/image` endpoints.
|
||||
Created two temporary valid PNGs under a unique synthetic Steam account/app
|
||||
directory after the app had baselined the existing nine screenshots.
|
||||
|
||||
| Check | Observed result |
|
||||
|---|---|
|
||||
| Default Photos ON | Original downloaded and receipt marked `saved`; Photos contained one byte-identical original |
|
||||
| Source / app copy / Photos SHA-256 | `a36eb2d497e6ecce17c767189a70b3b7e732a22309d2421cdc715cf13a7668ac` |
|
||||
| Photos OFF | Second original downloaded with `appOnly`; no matching Photos original |
|
||||
| Re-enable + relaunch | Second receipt stayed `appOnly`; first still had exactly one Photos original |
|
||||
|
||||
Machine readback: `/tmp/frame-108-live-proof.json`. Photos readback hashed files
|
||||
in this dedicated Simulator's own Media/DCIM directory, independently of the
|
||||
app receipt. No personal phone Photos database was accessed. This is a
|
||||
synthetic capture-file test, not proof of the headset screenshot-button path,
|
||||
capture latency, physical-device behavior, or background delivery.
|
||||
|
||||
The OFF preference was applied through Simulator defaults on restart, not a
|
||||
UI tap. The production setting setter and persisted opt-out were exercised in
|
||||
the native tests. Astra inspected the real native sheet in both states and
|
||||
found no major clipping, overlap or actionable visual defect.
|
||||
|
||||

|
||||
|
||||
An initial unsigned test build could not retain a stable Keychain identity;
|
||||
the real transport test used normal Simulator signing. App launches with a
|
||||
console attached were kept alive for the live checks.
|
||||
|
||||
## Review provenance
|
||||
|
||||
Preferred Opus routes failed before work with API rate limits. Implementation
|
||||
used the current GPT-6.1 Sol session; reviews are disclosed as fresh same-model
|
||||
checks, not different-model evidence.
|
||||
|
||||
Provider `codex`, model `gpt-6.1-sol`, high, completed:
|
||||
|
||||
- r1: `node:delegated-task:command%3Amcp%3Ac03980f6-1e71-4cc4-af1a-1d0961b9aaad%3Adelegate-task%3Aframe-108-ios-sync-review-20261024-r1`.
|
||||
Found stale export callback persistence and missing offline access. Both fixed.
|
||||
- r2: `node:delegated-task:command%3Amcp%3Ac03980f6-1e71-4cc4-af1a-1d0961b9aaad%3Adelegate-task%3Aframe-108-ios-sync-review-20261024-r2`.
|
||||
No actionable findings in the complete executable diff against `52772323`
|
||||
plus the four new Swift files. Report: `/tmp/frame-108-ios-sync-review-r2.md`.
|
||||
|
||||
Visual inspection used provider `codex`, `gpt-6-astra`, completed task
|
||||
`frame-108-downloads-proof-inspection-20261024-r3`; report:
|
||||
`/tmp/frame-108-downloads-proof-inspection.md`.
|
||||
|
||||
## Remaining release requirements
|
||||
|
||||
- Actual APNs delivery, notification extension/shared state, persistent headset
|
||||
event source and authenticated delivery service are still unimplemented.
|
||||
Main-app push capability is not configured; no usable provider credential
|
||||
location was found in the bounded checks. Existing App Store Connect signing
|
||||
access is not an APNs provider key.
|
||||
- Physical locked/background iPhone download and Photos export remain unproven.
|
||||
- A new native Android companion was subsequently authorized and implemented.
|
||||
See [Android phone](../android-phone.md) for its explicit foreground-service
|
||||
behavior and [Android evidence](android-phone-screenshot-sync-2026-10-24.md)
|
||||
for the build and emulator checks. This does not supply physical-device proof.
|
||||
- No TestFlight upload, store submission or release was performed. The user's
|
||||
release condition requires the full behavior to be tested and working.
|
||||
|
After Width: | Height: | Size: 403 KiB |
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"osVersion": "0.5.6",
|
||||
"osBuild": "20261015.6141207",
|
||||
"commands": [
|
||||
"scripts/frame-power-sample.sh --count 3 --interval 1",
|
||||
"scripts/frame-power-sample.sh --count 1 --csv"
|
||||
],
|
||||
"jsonSampleCount": 3,
|
||||
"elapsedSeconds": [
|
||||
0.078011,
|
||||
1.088542,
|
||||
2.107291
|
||||
],
|
||||
"stopped": null,
|
||||
"unavailableGroups": [
|
||||
[
|
||||
"proximity"
|
||||
],
|
||||
[
|
||||
"proximity"
|
||||
],
|
||||
[
|
||||
"proximity"
|
||||
]
|
||||
],
|
||||
"selectedSummary": {
|
||||
"battery.percent": {
|
||||
"unit": "%",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 99,
|
||||
"max": 99,
|
||||
"mean": 99.0,
|
||||
"first": 99,
|
||||
"last": 99
|
||||
},
|
||||
"battery.status": {
|
||||
"unit": "state",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"states": [
|
||||
"Not charging"
|
||||
]
|
||||
},
|
||||
"battery.tempC": {
|
||||
"unit": "C",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 26.400000000000002,
|
||||
"max": 26.400000000000002,
|
||||
"mean": 26.4,
|
||||
"first": 26.400000000000002,
|
||||
"last": 26.400000000000002
|
||||
},
|
||||
"battery.watts": {
|
||||
"unit": "W",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": -0.12,
|
||||
"max": -0.02,
|
||||
"mean": -0.07,
|
||||
"first": -0.02,
|
||||
"last": -0.07
|
||||
},
|
||||
"cpu.percent": {
|
||||
"unit": "%",
|
||||
"sources": [
|
||||
"/proc/stat"
|
||||
],
|
||||
"available": 2,
|
||||
"unavailable": 1,
|
||||
"min": 8.7,
|
||||
"max": 11.0,
|
||||
"mean": 9.85,
|
||||
"first": 11.0,
|
||||
"last": 8.7
|
||||
},
|
||||
"fan.pwmRaw": {
|
||||
"unit": "raw",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon50/pwm1"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 41,
|
||||
"max": 41,
|
||||
"mean": 41.0,
|
||||
"first": 41,
|
||||
"last": 41
|
||||
},
|
||||
"fan.slg4ax46073v.fan1_input": {
|
||||
"unit": "RPM",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon50/fan1_input"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 8624,
|
||||
"max": 8638,
|
||||
"mean": 8628.666667,
|
||||
"first": 8638,
|
||||
"last": 8624
|
||||
},
|
||||
"proximity.raw": {
|
||||
"unit": "raw",
|
||||
"sources": [
|
||||
"/sys/bus/iio/devices/iio:device2/in_proximity_raw"
|
||||
],
|
||||
"available": 0,
|
||||
"unavailable": 3,
|
||||
"states": []
|
||||
},
|
||||
"rail.max34417_9_10.vph.power1": {
|
||||
"unit": "W",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon53/power1_input"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 4.656299,
|
||||
"max": 4.804066,
|
||||
"mean": 4.705555,
|
||||
"first": 4.656299,
|
||||
"last": 4.804066
|
||||
}
|
||||
},
|
||||
"csvRows": 92,
|
||||
"csvSummaryMetrics": 91,
|
||||
"csvHasExplicitUnavailable": true,
|
||||
"limits": [
|
||||
"Read-only short transport/parser check; no controlled baseline or worn/load/battery experiment.",
|
||||
"Proximity unavailable; numeric thermistors are not proof of sensor accuracy.",
|
||||
"No settings, services, device files or application state changed; no remote installation.",
|
||||
"No smoke lock at preflight or sampler checks; advisory checks cannot exclude a concurrent lock appearing during a read."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
# Windows VM and real Frame test, 2026-10-24
|
||||
|
||||
The v0.4.10 installer could enumerate windows/screens and encode a synthetic
|
||||
Media Foundation stream, but every desktop capture failed at pipeline parsing:
|
||||
`no property "capture-api" in element "d3d11screencapturesrc"`.
|
||||
MSYS2's GStreamer 1.28.8 package disables WGC. After the fix, the same installed
|
||||
bundle captured the actual Windows desktop with Desktop Duplication and sent
|
||||
H.264 to a real Frame: 219 sent frame records, 215 decoded and drawn before the
|
||||
snapshot. Individual windows are explicitly unavailable with this bundle.
|
||||
|
||||
The base was `e9cbb8fd5555f5cfceeba1babadf2d7d35dcd3be` (`v0.4.10`, origin/main,
|
||||
including merged #121), in isolated branch `t3/test-windows-vm-headset`.
|
||||
|
||||
## Evidence
|
||||
|
||||
- [Capture before the fix](windows-vm-2026-10-24/capture-before.json): five
|
||||
synthetic H.264 frames and five JPEG frames; zero desktop/window frames,
|
||||
with the missing-property error for both codecs.
|
||||
- [Actual bundled properties](windows-vm-2026-10-24/plugin-properties.json):
|
||||
`monitor-handle` exists; `capture-api`, `window-handle`, `show-border` do not.
|
||||
[MSYS2 package build](https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-gst-plugins-bad/PKGBUILD)
|
||||
sets `-Dd3d11-wgc=disabled`. Upstream
|
||||
[installs those properties conditionally](https://github.com/GStreamer/gstreamer/blob/main/subprojects/gst-plugins-bad/sys/d3d11/gstd3d11screencapturesrc.cpp).
|
||||
- [Capture after the fix](windows-vm-2026-10-24/capture-after.json): the real
|
||||
display produces five H.264 and five JPEG frames. Window sources are hidden
|
||||
with an explicit reason, and the display selects `capture_api=dxgi`.
|
||||
- [Raw locked headset session](windows-vm-2026-10-24/headset-session.json):
|
||||
Windows helper, Media Foundation encoder, reverse SSH tunnel, production
|
||||
panel launcher and shared viewer. The image below was captured from that
|
||||
viewer's own X window on the Frame, rather than the VM's console.
|
||||
- [Windows tests](windows-vm-2026-10-24/windows-tests.txt) and
|
||||
[guest system details](windows-vm-2026-10-24/windows-system.txt).
|
||||
|
||||

|
||||
|
||||
## Host and VM prerequisites
|
||||
|
||||
lxso2: Intel Core i7-8700, 12 logical CPUs, VT-x/KVM; Docker 29.1.3 and Compose
|
||||
2.40.3. Before starting: 31 GiB RAM, 15 GiB available; 147 GiB free disk.
|
||||
Swap was already full. No other VM or service workload was started or stopped.
|
||||
Docker listed no Windows VM, and ports 2222/8006/13389 were unused.
|
||||
|
||||
The execution sandbox hid KVM and denied Docker/network access. Checks outside
|
||||
it found `/dev/kvm`, `/dev/net/tun` and the Docker daemon. Direct user access to
|
||||
KVM was denied, but a disposable Docker container with `--device /dev/kvm`
|
||||
opened it successfully; no host permissions changed.
|
||||
|
||||
Created only `frame-winvm-priority3`, labelled
|
||||
`frame-control.task=priority3-windows-test`: 4 vCPUs, 8 GiB guest RAM, 32 GiB
|
||||
growable disk, no restart, 150-second stop timeout. SSH 2222 and console 8006
|
||||
bind only 127.0.0.1; RDP is unpublished. No GPU passthrough. Image:
|
||||
`dockurr/windows@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea`
|
||||
(Windows for Docker v6.06, QEMU v11.1.1).
|
||||
|
||||
The container downloaded Windows 11 from Microsoft, with no activation changes.
|
||||
It took about 16 minutes to reach the first desktop. Guest build 26200.8037,
|
||||
Red Hat VirtIO GPU DOD driver 100.103.104.30200. The old WindowsProductName
|
||||
registry field still says Windows 10 Pro; the actual desktop/setup is Windows
|
||||
11. After installing the app, the 32 GiB disk had about 9.3 GB free.
|
||||
|
||||
Official `Frame-Control-Setup-x64.exe` v0.4.10 SHA-256:
|
||||
`30165bc5aa39f941cacc697d195dc5c30b01ad0b9ffb472da8a18a5220549202`.
|
||||
Its installer exited 0. The VM stream used an intermediate Python/UI-only
|
||||
fix with the released DLL bundle and bundled Python 3.12.14. A later CI failure
|
||||
showed that probing GStreamer through a separately loaded DLL could see a
|
||||
different, uninitialized instance. The final fix probes optional properties
|
||||
through the capture DLL itself. Windows CI checks that native change; the VM
|
||||
stream was not repeated with the final DLL.
|
||||
|
||||
OEM setup added OpenSSH Server, PowerShell and a dedicated public login key;
|
||||
password authentication is disabled. Its config needed an explicit
|
||||
`AuthorizedKeysFile C:/ProgramData/ssh/administrators_authorized_keys` before
|
||||
key login worked. The default informational share was replaced with a private
|
||||
writable staging share inside this container. These were test-VM setup steps,
|
||||
not app defects. Host setup files and the stopped VM disk are retained under
|
||||
`/tmp/frame-winvm-priority3/`; no private keys are committed or printed.
|
||||
The Frame test key was generated inside Windows and its private key stayed
|
||||
there. Only its public key was temporarily authorized on the Frame. After
|
||||
testing, the guest key and scheduled task were removed; `docker stop -t 150`
|
||||
shut down the dedicated VM. Inspection confirmed `exited`, restart `no`
|
||||
(container exit 143; logs show the ACPI shutdown signal, waiting for Windows,
|
||||
and `Shutdown completed!`). The disk is retained
|
||||
for reuse; the VM is not running.
|
||||
|
||||
## Checks
|
||||
|
||||
- Baseline PC-focused suite: 24 tests passed, one native class skipped.
|
||||
- After fix: full Linux unittest suite **1260 tests, OK, 15 skipped**, 144.366 s;
|
||||
focused final PC suite **27 tests, OK, one native class skipped**. The C
|
||||
controller's frozen 600-state trace ran on Linux.
|
||||
- Actual Windows installer bundle with the intermediate Python/UI fix:
|
||||
**33 tests, OK, four skipped** (no C compiler, Linux PipeWire test, two
|
||||
CI-only x264 tests). Its GObject property probe, host/encoder check and
|
||||
x264-absence check ran. The final native probe is checked in the PR's
|
||||
Windows CI build, not by this VM run.
|
||||
- Browser check with the real production HTML/JavaScript and mocked Windows API
|
||||
responses: DXGI whole-screen Show remains available, the limitation appears
|
||||
once, WGC window sources retain Show, off hides controls, and the limitation
|
||||
text is escaped. This used a static asset server, without headset API access.
|
||||
- `node --test app/test/*.test.js`: passed, three test files.
|
||||
- `node --test site/test/*.test.mjs`: passed, one test file.
|
||||
- The first full Linux run inside the sandbox failed (11 failures, 139 errors),
|
||||
including loopback socket `PermissionError`. The successful full runs were
|
||||
outside that socket restriction.
|
||||
- An initial guest test archive omitted `apk-catalog`, causing one import
|
||||
error; the complete archive produced the 33-test passing Windows run.
|
||||
|
||||
Raw local logs: `/tmp/frame-windows-{pc,app,site}-tests.log`,
|
||||
`/tmp/frame-windows-full-tests-unrestricted.log`,
|
||||
`/tmp/frame-windows-full-fixed-tests.log`, and
|
||||
`/tmp/frame-winvm-priority3/`. The committed artifacts above preserve the
|
||||
capture, Windows test and headset evidence independently of those local files.
|
||||
|
||||
## Shared Frame session and cleanup
|
||||
|
||||
Read-only checks first: SteamOS 0.5.6, BUILD_ID `20261015.6141207`, battery 99%,
|
||||
Not charging (another supply reports Full). The lock was free.
|
||||
|
||||
Both attempts acquired `/tmp/frame-test.lock` atomically before any key or
|
||||
viewer changes, and removed only the lock acquired by this task. The first,
|
||||
35-second attempt failed because the test wrapper sent CRLF script input and
|
||||
left SSH stdin open. Production already sends script bytes and closes stdin;
|
||||
correcting the wrapper resolved both. Its key was removed and lock released.
|
||||
|
||||
The successful session held the lock **03:09:36–03:10:05 UTC (29 seconds)**.
|
||||
It used a dedicated `pc-view-priority3` profile and port range 47960–47964,
|
||||
leaving existing viewers/profiles alone. The app's Windows `PCView` captured
|
||||
`display:65537`, 1280×800, Light preset (30 fps ceiling), and opened panel
|
||||
`valve.steam.desktopgame.2001396898`. The viewer reported H.264 software decode,
|
||||
218 received frames, 215 decoded/drawn before the snapshot (219 sent records).
|
||||
|
||||
This mostly idle, roughly ten-second sample reported 19 fps and content delay
|
||||
p50/p95 51.6/75.2 ms. These are observations, not a performance-target pass.
|
||||
|
||||
Cleanup stopped the helper and tunnel, closed the own viewer, removed its two
|
||||
possible profile paths and log, then removed the exact public-key line. The
|
||||
`authorized_keys` SHA-256 was identical before/after. A follow-up login from
|
||||
Windows with that key failed with exit 255. Separate checks found no task-owned
|
||||
viewer processes, the profile absent, and the shared lock released. Battery
|
||||
remained 99%; the sampled gamescope focus properties matched before/after.
|
||||
No reboot, global settings changes, game launch or Steam/SteamVR stop occurred.
|
||||
|
||||
## Limits
|
||||
|
||||
This verifies a Windows VM's desktop capture, Media Foundation encoding and
|
||||
real Frame viewer decode/rendering. It does not verify physical-PC hardware
|
||||
encoding, WGC or individual-window capture, mixed DPI, multiple displays,
|
||||
physical laser alignment/clicks, keyboard layouts, resize/minimise, busy-scene
|
||||
performance, bitrate/encoder restarts, or worn-headset comfort. No input was
|
||||
injected during the real Frame session. The installed app’s Tools navigation was not exercised; its streaming
|
||||
controller, tunnel and viewer were exercised directly. No independent review was performed.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windowCaptureReason": "Individual windows are unavailable in this build. Choose a whole screen instead.",
|
||||
"windows": [],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"capture_api": "dxgi"
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 65
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 113339
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
|
||||
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": true,
|
||||
"mfh264enc": true,
|
||||
"jpegenc": true,
|
||||
"x264enc": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"src": "window:131836",
|
||||
"id": 131836,
|
||||
"title": "Administrator: C:\\WINDOWS\\system32\\cmd.exe - powershell -noprofile -executionpolicy bypass -file \\\\host.lan\\data\\bootstrap.ps1",
|
||||
"app": "Windows",
|
||||
"w": 1115,
|
||||
"h": 628,
|
||||
"x": 51,
|
||||
"y": 52
|
||||
},
|
||||
{
|
||||
"src": "window:327926",
|
||||
"id": 327926,
|
||||
"title": "Administrator: Install",
|
||||
"app": "Windows",
|
||||
"w": 979,
|
||||
"h": 512,
|
||||
"x": 85,
|
||||
"y": 78
|
||||
},
|
||||
{
|
||||
"src": "window:65856",
|
||||
"id": 65856,
|
||||
"title": "Program Manager",
|
||||
"app": "Windows",
|
||||
"w": 1280,
|
||||
"h": 800,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
],
|
||||
"displays": [
|
||||
{
|
||||
"src": "display:65537",
|
||||
"name": "Display 1",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1280,
|
||||
"h": 800
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "h264",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 154
|
||||
},
|
||||
{
|
||||
"source": "test",
|
||||
"codec": "jpeg",
|
||||
"frames": 5,
|
||||
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"last_bytes": 7336
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "display:65537",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "h264",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
},
|
||||
{
|
||||
"source": "window:131836",
|
||||
"codec": "jpeg",
|
||||
"frames": 0,
|
||||
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
|
||||
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"gstreamer": "1.28.8",
|
||||
"plugins": [
|
||||
"coreelements",
|
||||
"videotestsrc",
|
||||
"videoconvertscale",
|
||||
"app",
|
||||
"jpeg",
|
||||
"videoparsersbad",
|
||||
"d3d11",
|
||||
"mediafoundation"
|
||||
],
|
||||
"packages": [
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gst-plugins-bad-libs 1.28.8-2",
|
||||
"mingw-w64-ucrt-x86_64-gstreamer 1.28.8-1"
|
||||
],
|
||||
"elements": {
|
||||
"d3d11screencapturesrc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"blocksize": "Size in bytes to read per buffer (-1 = default)",
|
||||
"num-buffers": "Number of buffers to output before sending EOS (-1 = unlimited)",
|
||||
"typefind": "Run typefind before negotiating (deprecated, non-functional)",
|
||||
"do-timestamp": "Apply current stream time to buffers",
|
||||
"automatic-eos": "Automatically EOS when the segment is done",
|
||||
"monitor-index": "Zero-based index for monitor to capture (-1 = primary monitor)",
|
||||
"monitor-handle": "A HMONITOR handle of monitor to capture",
|
||||
"show-cursor": "Whether to show mouse cursor",
|
||||
"crop-x": "Horizontal coordinate of top left corner for the screen capture area",
|
||||
"crop-y": "Vertical coordinate of top left corner for the screen capture area",
|
||||
"crop-width": "Width of screen capture area (0 = maximum)",
|
||||
"crop-height": "Height of screen capture area (0 = maximum)"
|
||||
},
|
||||
"d3d12screencapturesrc": null,
|
||||
"mfh264enc": {
|
||||
"name": "The name of the object",
|
||||
"parent": "The parent of the object",
|
||||
"qos": "Handle Quality-of-Service events from downstream",
|
||||
"min-force-key-unit-interval": "Minimum interval between force-keyunit requests in nanoseconds",
|
||||
"bitrate": "Bitrate in kbit/sec",
|
||||
"rc-mode": "Rate Control Mode",
|
||||
"adaptive-mode": "Adaptive Mode",
|
||||
"vbv-buffer-size": "VBV(HRD) Buffer Size in bytes (0 = MFT default)",
|
||||
"max-bitrate": "The maximum bitrate applied when rc-mode is \"pcvbr\" in kbit/sec",
|
||||
"quality-vs-speed": "Quality and speed tradeoff, [0, 33]: Low complexity, [34, 66]: Medium complexity, [67, 100]: High complexity",
|
||||
"cabac": "Enable CABAC entropy coding",
|
||||
"sps-id": "The SPS id to use",
|
||||
"pps-id": "The PPS id to use",
|
||||
"bframes": "The maximum number of consecutive B frames",
|
||||
"gop-size": "The number of pictures from one GOP header to the next. Depending on GPU vendor implementation, zero gop-size might produce only one keyframe at the beginning (-1 for automatic)",
|
||||
"threads": "The number of worker threads used by a encoder, (0 = MFT default)",
|
||||
"content-type": "Indicates the type of video content",
|
||||
"qp": "QP applied when rc-mode is \"qvbr\"",
|
||||
"low-latency": "Enable low latency encoding",
|
||||
"min-qp": "The minimum allowed QP applied to all rc-mode",
|
||||
"max-qp": "The maximum allowed QP applied to all rc-mode",
|
||||
"qp-i": "QP applied to I frames",
|
||||
"qp-p": "QP applied to P frames",
|
||||
"qp-b": "QP applied to B frames",
|
||||
"ref": "The number of reference frames",
|
||||
"d3d11-aware": "Whether device can support Direct3D11 interop"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
WindowsProductName WindowsVersion OsBuildNumber
|
||||
------------------ -------------- -------------
|
||||
Windows 10 Pro 2009 26200
|
||||
|
||||
|
||||
|
||||
Name DriverVersion Status
|
||||
---- ------------- ------
|
||||
Red Hat VirtIO GPU DOD controller 100.103.104.30200 OK
|
||||
|
||||
|
||||
|
||||
Used Free
|
||||
---- ----
|
||||
23951253504 9314816000
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
python.exe : test_original_mac_congestion_and_recovery
|
||||
(test_pc_controller.ControllerTrace.test_original_mac_congestion_and_recovery) ... skipped 'C
|
||||
compiler not installed; native build jobs cover this trace'
|
||||
At line:12 char:1
|
||||
+ & $python -B -m unittest discover -s tests -p 'test_pc*.py' -v 2>&1 | ...
|
||||
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
+ CategoryInfo : NotSpecified: (test_original_m...ver this trace':String) [], Remote
|
||||
Exception
|
||||
+ FullyQualifiedErrorId : NativeCommandError
|
||||
|
||||
test_controller_is_inert_after_close
|
||||
(test_pcview.AdapterTests.test_controller_is_inert_after_close) ... ok
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties (test_pcview.AdapterTests.
|
||||
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties) ... ok
|
||||
test_idle_source_is_not_a_stall (test_pcview.AdapterTests.test_idle_source_is_not_a_stall) ... ok
|
||||
test_library_path_prepend_adds_no_empty_entry
|
||||
(test_pcview.AdapterTests.test_library_path_prepend_adds_no_empty_entry) ... ok
|
||||
test_linux_uses_only_portal_fd_and_node
|
||||
(test_pcview.AdapterTests.test_linux_uses_only_portal_fd_and_node) ... ok
|
||||
test_malformed_viewer_input_is_dropped_not_fatal
|
||||
(test_pcview.AdapterTests.test_malformed_viewer_input_is_dropped_not_fatal) ... ok
|
||||
test_pc_card_receives_window_capture_limitation
|
||||
(test_pcview.AdapterTests.test_pc_card_receives_window_capture_limitation) ... ok
|
||||
test_pc_reuses_launcher_and_namespaces_panel_ids
|
||||
(test_pcview.AdapterTests.test_pc_reuses_launcher_and_namespaces_panel_ids) ... ok
|
||||
test_portal_input_clamps_and_releases_held_state
|
||||
(test_pcview.AdapterTests.test_portal_input_clamps_and_releases_held_state) ... ok
|
||||
test_refused_windows_focus_never_clicks_the_covering_app
|
||||
(test_pcview.AdapterTests.test_refused_windows_focus_never_clicks_the_covering_app) ... ok
|
||||
test_session_end_releases_native_input_only_once
|
||||
(test_pcview.AdapterTests.test_session_end_releases_native_input_only_once) ... ok
|
||||
test_stats_keep_capture_time_and_bound_records
|
||||
(test_pcview.AdapterTests.test_stats_keep_capture_time_and_bound_records) ... ok
|
||||
test_windows_release_and_negative_monitor_origin
|
||||
(test_pcview.AdapterTests.test_windows_release_and_negative_monitor_origin) ... ok
|
||||
test_windows_sources_follow_actual_wgc_capabilities
|
||||
(test_pcview.AdapterTests.test_windows_sources_follow_actual_wgc_capabilities) ... ok
|
||||
test_windows_uses_wgc_and_media_foundation
|
||||
(test_pcview.AdapterTests.test_windows_uses_wgc_and_media_foundation) ... ok
|
||||
test_never_offered_off_windows_even_when_turned_on
|
||||
(test_pcview.GatingTests.test_never_offered_off_windows_even_when_turned_on) ... ok
|
||||
test_off_by_default_starts_nothing (test_pcview.GatingTests.test_off_by_default_starts_nothing)
|
||||
... ok
|
||||
test_only_windows_gets_the_pc_host (test_pcview.GatingTests.test_only_windows_gets_the_pc_host)
|
||||
... ok
|
||||
test_server_enable_action (test_pcview.GatingTests.test_server_enable_action) ... ok
|
||||
test_turning_off_stops_streams_and_the_helper
|
||||
(test_pcview.GatingTests.test_turning_off_stops_streams_and_the_helper) ... ok
|
||||
test_turning_on_checks_for_the_bundled_libraries
|
||||
(test_pcview.GatingTests.test_turning_on_checks_for_the_bundled_libraries) ... ok
|
||||
test_expiry_and_stop_before_redeem (test_pcview.GrantsTests.test_expiry_and_stop_before_redeem)
|
||||
... ok
|
||||
test_source_bound_retry_ack_and_stop
|
||||
(test_pcview.GrantsTests.test_source_bound_retry_ack_and_stop) ... ok
|
||||
test_agent_reports_its_host_and_encoder
|
||||
(test_pcview.NativeTests.test_agent_reports_its_host_and_encoder) ... ok
|
||||
test_auth_and_real_h264_stats (test_pcview.NativeTests.test_auth_and_real_h264_stats) ... skipped
|
||||
'x264 is only in CI test bundles, not the installer'
|
||||
test_bundled_pipewire_context_needs_no_system_modules
|
||||
(test_pcview.NativeTests.test_bundled_pipewire_context_needs_no_system_modules) ... skipped
|
||||
'Linux PipeWire client libraries'
|
||||
test_installer_bundle_leaves_out_gpl_x264
|
||||
(test_pcview.NativeTests.test_installer_bundle_leaves_out_gpl_x264) ... ok
|
||||
test_last_raw_picture_survives_a_closed_gate
|
||||
(test_pcview.NativeTests.test_last_raw_picture_survives_a_closed_gate) ... skipped 'x264 is only
|
||||
in CI test bundles, not the installer'
|
||||
test_native_property_probe_checks_the_instantiated_element
|
||||
(test_pcview.NativeTests.test_native_property_probe_checks_the_instantiated_element) ... ok
|
||||
test_card_says_experimental_and_what_is_unverified
|
||||
(test_pcview.PageTests.test_card_says_experimental_and_what_is_unverified) ... ok
|
||||
test_opt_in_is_remembered_with_the_client_preferences
|
||||
(test_pcview.PageTests.test_opt_in_is_remembered_with_the_client_preferences) ... ok
|
||||
test_tool_link_is_windows_only_and_marked_experimental
|
||||
(test_pcview.PageTests.test_tool_link_is_windows_only_and_marked_experimental) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 33 tests in 1.684s
|
||||
|
||||
OK (skipped=4)
|
||||
@@ -15,6 +15,10 @@ depends on SSH working through the `frame` alias from `scripts/connect.sh`.
|
||||
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
|
||||
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
|
||||
|
||||
Screenshots and Steam clips have their own buttons on **Tools → Capture &
|
||||
Live**, and **Settings → Copy to this computer** copies new ones here
|
||||
automatically: see [recording.md](recording.md#copying-new-ones-automatically).
|
||||
|
||||
## Clipboard
|
||||
|
||||
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
|
||||
@@ -36,3 +40,34 @@ desktop has to be running in the headset. It's text only, and pastes over about
|
||||
|
||||
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
|
||||
headset.
|
||||
|
||||
### In Frame Control
|
||||
|
||||
**Tools → Type on the headset** (also on Home, and press `T` anywhere) has the
|
||||
clipboard next to the keyboard and trackpad, both ways:
|
||||
|
||||
- **Send text** or **Send this computer's clipboard** puts text on the headset's
|
||||
clipboard, as above.
|
||||
- **Copy the headset's clipboard** brings it back: copy something in a desktop
|
||||
app on the Frame, then press it, and paste on your computer. It calls
|
||||
Klipper's `getClipboardContents` on the same D-Bus bus, and sends the text
|
||||
back as base64, so accents, emoji and line breaks arrive unchanged. The
|
||||
desktop app and the iPhone app put it on their own clipboard. With the server
|
||||
in a browser, the server sets the clipboard of the computer it runs on:
|
||||
`pbcopy` on macOS, PowerShell's `Set-Clipboard` (or `clip.exe`) on Windows,
|
||||
`wl-copy`, `xclip` or `xsel` on Linux.
|
||||
|
||||
It's text only, and copying back takes up to 1 MB; a bigger clipboard is
|
||||
refused with a message, so send big things as a file. An empty clipboard, or
|
||||
one holding an image, says so. Neither the server nor its log ever records what
|
||||
was on the clipboard.
|
||||
|
||||
**Verified on a Frame, 2026-10-24:** SteamOS 0.5.6, build
|
||||
`20261015.6141207`. With a temporary nested desktop open, Frame Control sent
|
||||
a 47-character test marker to Klipper, then **Copy the headset's clipboard**
|
||||
read it through `getClipboardContents` and set a Linux PC's X11 clipboard.
|
||||
Reading that PC clipboard returned the exact marker. The prior headset
|
||||
clipboard was restored and the test desktop was closed. The PC side used a
|
||||
temporary X display on lxso2; macOS and Windows clipboard backends, a user's
|
||||
copy action in a headset app, and image/size error cases remain untested on a
|
||||
real headset. The fake Frame (`tests/fakeframe`) covers the protocol cases.
|
||||
@@ -25,7 +25,7 @@ settings, reports), **Tools** (in the headset: your computer's windows, the medi
|
||||
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||
power), **Devices** (your headsets and their addresses), and **Control** (the
|
||||
searchable Control Center, metrics, supported settings and headset guidance).
|
||||
Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
|
||||
Keys 1–6 switch between them, and `T` opens Type on the headset. The gear in the header (key 7) opens **Settings**:
|
||||
privacy and updates, library artwork and the assistant. The battery chip opens
|
||||
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
|
||||
build. Files can be dropped anywhere in the window. A
|
||||
@@ -49,11 +49,27 @@ counts them while they run.
|
||||
gamescope's flat layer instead.
|
||||
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
|
||||
save them to `~/Pictures/SteamFrame`.
|
||||
- **Record** what the headset shows, with its sound, to an `.mp4` in
|
||||
`~/Videos/SteamFrame`, and **Steam clips**: export clips and background
|
||||
recordings from Steam's game recording at any length, without re-encoding.
|
||||
See [recording.md](recording.md). Not tested on a headset yet.
|
||||
- **Backup & recovery** (Tools): copy game saves, settings and Android app data
|
||||
to `~/Documents/Frame Control Backups/<time>/` with a checksummed manifest,
|
||||
restore chosen parts after a factory reset, and **Return to Game Mode** when
|
||||
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
|
||||
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
|
||||
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||
the desktop, Lepton and xrdp are running.
|
||||
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
|
||||
- **Force quit** a game that has stopped responding: Steam games, sideloaded
|
||||
titles, non-Steam shortcuts and Android apps. **Running now** at the top of
|
||||
Games lists what's running; each game's own **Force quit…** (on its library
|
||||
tile, or in a sideloaded title's ⋯ menu) and the Control Center's **Force quit
|
||||
a game** card do the same. Each asks first, because unsaved progress is lost.
|
||||
Steam is asked to quit the game, then its processes get SIGTERM and SIGKILL;
|
||||
see [steam-games.md](steam-games.md#force-quitting-a-running-game).
|
||||
- **Get games**: every game you own with its Steam Frame rating (Verified,
|
||||
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
||||
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
||||
@@ -80,8 +96,14 @@ counts them while they run.
|
||||
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
|
||||
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
|
||||
Runtime picked from the program's header), listed under **Sideloaded titles**
|
||||
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
|
||||
Frame clipboard.
|
||||
with Launch and Remove; see [sideloading.md](sideloading.md).
|
||||
- **Type on the headset** (on Home, in Tools, in the Control Center, or press
|
||||
`T`): use your computer's keyboard and mouse, or a phone's touchscreen, in the
|
||||
Frame's apps through KDE Connect, and move clipboard text both ways. Send
|
||||
typed text or your computer's clipboard to the headset, or copy the headset's
|
||||
clipboard back: copy in a desktop app on the Frame, then **Copy the headset's
|
||||
clipboard**. The clipboard needs the desktop panel open in the headset; it's
|
||||
text only, up to 1 MB. See [file-transfer.md](file-transfer.md#clipboard).
|
||||
- **Mac in the headset** (macOS): show any Mac window, or a whole screen, as
|
||||
its own panel in the headset. Place it with the SteamVR dashboard, click and
|
||||
scroll with the laser, and type on the Mac. Streams hardware H.264 through
|
||||
@@ -127,6 +149,12 @@ GPU time is render time, not GPU utilisation. The temperature is the hottest
|
||||
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||
not a guessed universal temperature threshold.
|
||||
|
||||
**Force quit a game** lists what's running only when you choose **Check what's
|
||||
running**, and says so when nothing is. Each **Force quit…** names the game and
|
||||
warns that unsaved progress will be lost before anything is sent; it uses the
|
||||
selected headset, refuses a second quit while one is running, and drops the
|
||||
list when you switch headsets.
|
||||
|
||||
**Audio** controls the default output sink through the existing `/api/volume`
|
||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||
@@ -139,7 +167,7 @@ rejects invalid types, out-of-range levels and invalid mute states before SSH.
|
||||
Other settings link to the existing forms and their validation, progress,
|
||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
||||
sources and artwork. Android display links reveal **On your Frame** before
|
||||
sources and artwork. Android display links reveal the **Library** before
|
||||
scrolling to the form. Mac streaming remains searchable on all platforms, with
|
||||
its action shown only when the server is on macOS.
|
||||
|
||||
@@ -220,8 +248,8 @@ Google publishes no arm64 Linux platform-tools. Set Up Connection runs
|
||||
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
|
||||
and others). The log is at
|
||||
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
|
||||
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
|
||||
`xclip` (X11).
|
||||
instead of the app, sending the clipboard, or copying the headset's to this
|
||||
computer, needs `wl-clipboard` (Wayland), `xclip` or `xsel` (X11).
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Frame doctor
|
||||
|
||||
Run `scripts/frame-doctor.sh` from a checkout on macOS or Linux to diagnose
|
||||
first-time setup, pairing and common Frame failures without applying repairs.
|
||||
It needs Bash, Python 3 and OpenSSH on the computer, and Python 3 on the Frame.
|
||||
Windows users can run `python scripts/frame_doctor.py` with Python and OpenSSH
|
||||
installed; the desktop app does not bundle this command.
|
||||
|
||||
```sh
|
||||
scripts/frame-doctor.sh
|
||||
scripts/frame-doctor.sh --alias bedroom --lan frame.local
|
||||
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
|
||||
```
|
||||
|
||||
Use only addresses belonging to your intended headset. The command tries the
|
||||
existing `frame` SSH alias first (Tailscale if setup configured it that way),
|
||||
then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb`
|
||||
override those defaults. It reports the successful path without printing its
|
||||
address. It neither discovers other devices nor enables Tailscale.
|
||||
|
||||
The existing alias supplies the user, port, keys and known-hosts file. LAN and
|
||||
USB overrides preserve its `HostKeyAlias`, or its configured hostname if no
|
||||
alias was set. A first connection therefore needs trusted setup first. The
|
||||
doctor never accepts a new host key, deletes a saved key, generates keys or
|
||||
sends a pairing request. It uses your trusted OpenSSH configuration; OpenSSH
|
||||
can evaluate `Match exec` conditions in that configuration. Proxy commands,
|
||||
jumps, local commands, forwarding and connection sharing are disabled for
|
||||
these direct diagnostic connections.
|
||||
|
||||
## Reading the result
|
||||
|
||||
Each of the eleven runbook sections has one line: `OK`, `WARN`, `BROKEN` or
|
||||
`UNKNOWN`. `OK` means only that the stated check passed, not that the whole
|
||||
feature works. Optional features that are off may produce a warning.
|
||||
|
||||
| Section | Read-only evidence and limits |
|
||||
|---|---|
|
||||
| 1. Boot and crash history | Previous-boot kernel-oops signatures; one day of coredump history. An oops alone does not prove an unclean reset. |
|
||||
| 2. Wi-Fi | NetworkManager's wlan0 state, without SSIDs or connection profiles. |
|
||||
| 3. Displays and SteamVR | DSI timeout signatures in the recent kernel journal; no display, compositor or game is launched. |
|
||||
| 4. Steam and storage | Free space in home and `devkit-game`, including separate mounts; missing Steam install marker. No CRC scan or restart. |
|
||||
| 5. Idle sleep and keep-awake | Existing keep-awake unit state; never takes ownership or changes idle timers. |
|
||||
| 6. Remote wake | WoWLAN state, without arming it or testing suspend. Wake with the power button. |
|
||||
| 7. Lepton | Recent port-clash, binder and data-clearing signatures; no Android/container commands. |
|
||||
| 8. Chromium XR | Crash history; no browser profile or environment inspection. |
|
||||
| 9. KDE Connect | Process presence; absence is normal when sharing is off. |
|
||||
| 10. Streaming and capture | ffmpeg crash history; no capture or encoder is started. |
|
||||
| 11. Panels | Always `UNKNOWN`: placement requires the user's visual confirmation. |
|
||||
|
||||
Journal samples are capped at 2,000 entries; current-boot samples cover the
|
||||
last ten minutes. This can miss older or high-volume failures. Empty,
|
||||
inaccessible or permission-limited journals are `UNKNOWN`, never zero errors.
|
||||
Commands time out individually, and each SSH probe is bounded to 90 seconds.
|
||||
|
||||
Exit codes: **0** if all reported checks are OK; **1** for warnings, broken or
|
||||
unknown checks after connecting; **2** if no authenticated SSH path exists or
|
||||
local SSH setup is unavailable. Panel verification currently means a connected
|
||||
run normally exits 1. Automation should read the labelled checks rather than
|
||||
treating a warning as proof the headset is unusable.
|
||||
|
||||
## First-time setup and pairing
|
||||
|
||||
- **No SSH response:** wake the headset with its power button. Check the cable
|
||||
or Steam Settings → Internet, then enable Developer Mode. Sleep and network
|
||||
failure can look alike; a timeout cannot distinguish them.
|
||||
- **Key rejected:** open **Set Up Connection** in Frame Control. In the headset,
|
||||
open Steam Settings → Developer → **Pair new host**, then approve setup's
|
||||
request. The doctor only checks whether devkit port 32000 responds; that
|
||||
does not prove pairing mode is open or the service is healthy.
|
||||
- **Unknown or changed identity:** verify the intended headset through setup
|
||||
or Devices → **Check its key**. Do not delete `known_hosts` to bypass this.
|
||||
- **No devkit response:** check Developer Mode and the address. See
|
||||
[SSH setup](ssh.md) for the manual fallback. The doctor cannot turn SSH on.
|
||||
|
||||
No password is requested. Enter any password required by the separate setup
|
||||
flow only in its local terminal; never paste it into an issue or report.
|
||||
|
||||
## Restart safety and recovery
|
||||
|
||||
Before deeper checks, and again afterwards, the doctor samples Steam and
|
||||
SteamVR `NRestarts` and health-check tracker sizes two seconds apart. Rising
|
||||
counters stop diagnostics immediately. Nonempty health-check trackers also stop
|
||||
deeper checks even when stable, since a short sample could miss a slower loop.
|
||||
Missing counters or counters that reset stop deeper checks as `UNKNOWN`. Stable
|
||||
counters with empty trackers still cannot rule out a slower restart loop.
|
||||
|
||||
The [historical runbook from PR #33](https://github.com/saphid/frame-control/blob/cce3d940302054ab5246c92d740afaefc4e58d38/docs/frame-doctor.md)
|
||||
is the source for these section numbers and observed signatures (September
|
||||
2026 builds). Its section 4 describes freezing and verifying health-check
|
||||
trackers before prolonged recovery during a live loop. That changes headset
|
||||
state, so this read-only implementation stops and directs you there instead.
|
||||
It does not implement issue #34's automatic fixes, before/after repair runs,
|
||||
or full recovery automation. Review build-specific recovery instructions with
|
||||
the person using the headset; a repair can remove data.
|
||||
|
||||
Never reload `ath12k`, suspend remotely, kill gamescope to diagnose a loop,
|
||||
reset or force power off during extraction/repair, or write passwords to disk.
|
||||
The doctor never restarts services, signals processes, repairs Steam, changes
|
||||
WoWLAN/standby settings, takes keep-awake ownership, installs anything or writes
|
||||
a report. Nothing needs restoring after its checks. Manual/device tests must
|
||||
follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test).
|
||||
|
||||
## Privacy and verification
|
||||
|
||||
Only fixed diagnostic summaries leave the remote probe. Raw journals, process
|
||||
arguments, environment variables, SSH errors, account IDs, network addresses,
|
||||
SSIDs, keys and tokens are not printed. No telemetry or HTTP request is sent;
|
||||
the only pairing-service probe is a TCP connection. SSH produces normal access
|
||||
logs. Review summaries before sharing them; feature availability can still be
|
||||
personal information. Redirecting stdout saves a report only if you choose it.
|
||||
|
||||
Unit tests use mocked boundaries. `scripts/e2e.sh test_doctor` covers asleep,
|
||||
disk-full, SSH/devkit-off and missing-key faults against the fake Frame. Its
|
||||
stable restart counters are explicitly simulated, not proof about SteamOS.
|
||||
On 2026-10-24 the script ran read-only against a real Frame, SteamOS build
|
||||
`20261015.6141207`. It verified the configured SSH alias and key login,
|
||||
then reported `WARN Restart safety`: nonempty health-check trackers. It
|
||||
correctly skipped all eleven deeper sections and exited 1. No raw logs,
|
||||
addresses or keys were printed, and no repair or restart was attempted.
|
||||
This verifies the safety stop on that device; the deeper checks are not
|
||||
claimed as headset-tested.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Game storage
|
||||
|
||||
Open **Tools → Game storage** for a read-only snapshot of the headset's user
|
||||
filesystem and Steam libraries. Refresh after an installation, download or move.
|
||||
The page never moves, deletes or modifies content, starts Steam, requests root,
|
||||
or recursively scans game files. It works through the existing SSH connection.
|
||||
|
||||
## What the numbers mean
|
||||
|
||||
- **Total, free and used** come from `statvfs` on the headset user's home and
|
||||
each library path. Free is space available to that user; reserved blocks mean
|
||||
used + free can be less than total. These are filesystem sizes, not advertised
|
||||
drive capacity, and do not include separate system partitions.
|
||||
- Libraries come from `~/.local/share/Steam/steamapps/libraryfolders.vdf` plus
|
||||
the default Steam library. Modern nested and older numbered-path records are
|
||||
accepted; canonical duplicate paths are shown once. No drive is mounted.
|
||||
- Two paths can share one filesystem. Their capacity must **not** be added.
|
||||
An unmounted drive whose mount directory still exists can report the parent
|
||||
filesystem's capacity: the page labels this as the filesystem containing the
|
||||
path, rather than claiming the removable drive is connected.
|
||||
- Game sizes are Steam's `SizeOnDisk` records in `appmanifest_<appid>.acf`, sorted
|
||||
largest first, with unknown sizes last. A missing, negative or invalid size
|
||||
is **Unknown**, while an explicit zero remains zero. Records may be stale,
|
||||
including during downloads or moves; they do not measure allocated blocks.
|
||||
- Games/apps and runtimes/tools have separate subtotals of **known recorded**
|
||||
sizes. Runtime classification uses names (Proton, Steam Linux Runtime,
|
||||
Steamworks Common, FEX and Lepton Development), so it is only a convenience.
|
||||
Additional apps or runtimes may fall in the game/app group.
|
||||
- Saves, Proton prefixes, shaders, workshops, downloads, Android apps,
|
||||
sideloaded games, media and OS files are **unmeasured other usage**. The page
|
||||
does not subtract manifest totals from filesystem usage to invent a size for
|
||||
them. Recorded content is not an exhaustive storage breakdown.
|
||||
|
||||
Permission denied, missing/disconnected paths, unavailable filesystem reads,
|
||||
invalid metadata and partial inventories are reported separately. A failed read
|
||||
is never proof of an empty library. If library discovery fails, other libraries
|
||||
may be missing from the view. Refresh clears the old results immediately; a
|
||||
headset switch clears the snapshot and ignores late replies from the old device.
|
||||
|
||||
## Moving a game safely
|
||||
|
||||
Use Steam's own **Settings → Storage** on the headset (or in desktop Steam):
|
||||
choose the source drive, select the games and choose **Move**, then pick a
|
||||
Steam-supported destination. A second Steam library must already be available.
|
||||
Steam may withhold Move or a destination when it cannot perform the operation.
|
||||
Let Steam finish, follow Steam's errors/recovery prompts, then Refresh here.
|
||||
Do not manually delete files to make room or interrupt an in-progress move.
|
||||
|
||||
[Valve's moving guide](https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move)
|
||||
was read in a browser on 2026-10-24 and documents this UI. This is an instructional
|
||||
handoff: the page does not send a URI or silently open a UI on the headset.
|
||||
The guide's separate Windows instructions for moving the entire Steam client
|
||||
are outside this feature's scope.
|
||||
|
||||
### Why Frame Control does not automate moving
|
||||
|
||||
The repository's Valve devkit tools register and launch developer titles; no
|
||||
supported game-move operation is provided there. The existing Steam CEF bridge
|
||||
uses internal client APIs, which are not a verified supported contract for a
|
||||
move transaction. [Valve's public ISteamApps reference](https://partner.steamgames.com/doc/api/ISteamApps) provides install-directory
|
||||
and installed-depot queries but no documented move transaction with completion,
|
||||
cancellation, rollback and error guarantees. The public support guide describes
|
||||
the user-operated Storage UI, rather than such an automation interface.
|
||||
|
||||
Consequently this feature adds no move endpoint or guessed internal Steam call,
|
||||
manifest rewrite, copy/delete fallback, or custom rollback. Automating a move
|
||||
requires a supported interface and verified behaviour for full disks, disconnects,
|
||||
running games, downloads, cancellation and Steam restart first.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
`tests/test_storage.py` runs the actual probe against temporary Steam metadata
|
||||
and filesystem/error fixtures. It covers multiple libraries, duplicate paths,
|
||||
zero/unknown/invalid sizes, corrupt manifests, unreadable directories and files,
|
||||
missing discovery/drives, reserved capacity and read-only file preservation.
|
||||
A loopback HTTP test executes the real guarded endpoint with SSH mocked.
|
||||
`tests/storage.cjs` runs the production renderer, navigation and refresh handlers
|
||||
with DOM/transport boundaries faked, including escaping, offline errors,
|
||||
out-of-order refreshes and headset switches. It runs through unittest discovery.
|
||||
|
||||
The production storage component was rendered with synthetic data at 390px and
|
||||
900px widths; both fit without horizontal overflow. This checks component
|
||||
layout, not the full connected application or headset browser.
|
||||
|
||||
On 2026-10-24 the uninstalled storage parser was piped read-only over SSH
|
||||
to a real Frame running SteamOS build `20261015.6141207`. It reported
|
||||
710.4 GiB free on the user filesystem, one available Steam library, 29
|
||||
readable app manifests, zero unknown sizes and zero parse/permission issues.
|
||||
No game names or raw metadata were printed in the test report. The
|
||||
connected UI, removable media, Steam's Move UI and an actual game move were
|
||||
not tested. UI and error-state fixtures remain synthetic.
|
||||
@@ -96,5 +96,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
|
||||
- Backing up saves and settings, restoring after a reset, leaving Desktop Mode: [backup-and-recovery.md](backup-and-recovery.md)
|
||||
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -57,6 +57,25 @@ makes.
|
||||
| Sleep, restart, shut down ask in a terminal | The page asks for the Developer Mode password |
|
||||
| Compatibility reports kept on the computer | Kept on the Frame (`~/.local/share/Frame Control`) |
|
||||
|
||||
## Automatic screenshot downloads (in development)
|
||||
|
||||
The screenshot-download implementation on this branch copies new Steam screenshots
|
||||
while Frame Control is open and connected. It starts with the first successful
|
||||
screenshot listing as its baseline; older captures still use the existing Save
|
||||
action. Originals remain inside the app, and **Save screenshots to Photos** is
|
||||
enabled by default, subject to add-only Photos permission.
|
||||
|
||||
Open **Downloads & Photos** on the Screenshots page, or **Screenshot downloads**
|
||||
on the native connection screen. Turn the Photos setting off to keep new downloads
|
||||
only in Frame Control. Re-enabling it does not export those app-only captures.
|
||||
The local list and its Share buttons work without a headset connection. Failed
|
||||
Photos exports retain the original and retry while the app is open; an interrupted
|
||||
Photos transaction needs a manual check before saving again to avoid duplicates.
|
||||
|
||||
Background notification delivery and Android support are not implemented by this
|
||||
change, and it has not been released. See the [verification record](evidence/phone-screenshot-sync-2026-10-24.md)
|
||||
and [remaining work](https://github.com/saphid/frame-control/issues/108).
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
@@ -69,6 +88,88 @@ The build packs the Frame bundle from the checkout, so the phone always runs
|
||||
the page and server from the same commit. Running on a phone needs your own
|
||||
signing team in Xcode (Signing & Capabilities).
|
||||
|
||||
## TestFlight
|
||||
|
||||
Join the [Frame Control beta](https://testflight.apple.com/join/9xjW91g6),
|
||||
install Apple's TestFlight app, then tap **Install** or **Update** next to Frame
|
||||
Control. Internal account testers can also use their invitation. The first
|
||||
working phone build was version 0.1.0, build 202610140516, uploaded on
|
||||
2026-10-14. On 2026-10-16, build **202610160824** was uploaded and approved for
|
||||
internal and external testing. App Store Connect confirmed both builds were in
|
||||
the external **Public Beta** group, with its public link enabled and a limit of
|
||||
50 testers. The new build declares the encryption-compliance answer in the app
|
||||
and preserves the previous build's exact server bundle.
|
||||
|
||||
### Upload a build
|
||||
|
||||
You need Xcode, XcodeGen, a paid Apple Developer Program team, an App Store
|
||||
Connect app record for `com.saphid.framecontrol`, and an App Store Connect team
|
||||
API key with the signing access your release setup needs. This Mac relies on
|
||||
Apple's cloud-managed distribution certificates: an **App Manager** key was
|
||||
rejected with `Cloud signing permission error`, so this setup needs **Admin**
|
||||
access. A setup with a local distribution signing identity may be able to use
|
||||
a narrower role. Create the key under **Users and Access →
|
||||
Integrations → App Store Connect API**. Download it directly to
|
||||
`~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8`, outside the repository,
|
||||
and restrict it to its owner (`chmod 600`). Apple allows one download. Keep
|
||||
the private key out of chat, screenshots, logs and Git.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
ASC_KEY_ID=YOUR_KEY_ID \
|
||||
ASC_ISSUER_ID=YOUR_ISSUER_UUID \
|
||||
ASC_KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_YOUR_KEY_ID.p8" \
|
||||
TEAM_ID=YOUR_TEAM_ID \
|
||||
ios/scripts/testflight.sh
|
||||
```
|
||||
|
||||
The script regenerates the Xcode project, archives with automatic signing,
|
||||
checks the archived build number, and uploads to App Store Connect. `TEAM_ID`
|
||||
defaults to this project's team, `NJZUMEA4BN`. `BUILD_NUMBER` defaults to UTC
|
||||
`YYYYMMDDHHMM`; wait for a new minute between uploads, or supply a unique,
|
||||
higher build number. The archive, export options and export output are under
|
||||
`ios/build/testflight/<BUILD_NUMBER>/` (ignored by Git).
|
||||
|
||||
Build from a reviewed, clean checkout. The build packs the server, web pages
|
||||
and helpers from the current checkout, including uncommitted changes; it does
|
||||
not limit the bundle to the last commit.
|
||||
|
||||
An upload succeeding means Apple received the build. Wait for it to finish
|
||||
processing in **App Store Connect → Frame Control → TestFlight** and check
|
||||
that it is ready for testing. `ios/project.yml` declares
|
||||
`ITSAppUsesNonExemptEncryption: false`, so each generated app declares that it
|
||||
uses no non-exempt encryption, matching the previous builds' export-compliance
|
||||
answers. Reassess that declaration if the app's cryptography changes.
|
||||
|
||||
To replace a key, create and securely download the replacement, verify that it
|
||||
can access this app, then revoke the old key in Users and Access. Update the
|
||||
upload environment to use the replacement. Revocation does not remove an
|
||||
already uploaded TestFlight build from testers' phones.
|
||||
|
||||
### Add testers
|
||||
|
||||
**Internal:** add an existing App Store Connect user with an eligible role to
|
||||
an internal testing group, and add the build to that group (or enable automatic
|
||||
distribution). New account users must first accept their account invitation.
|
||||
Internal testing grants App Store Connect access; use external testing for
|
||||
people who only need to try the app.
|
||||
|
||||
**External:** create an external testing group, complete Beta App Information
|
||||
and review contact details, add a build and its **What to Test** notes, then
|
||||
submit it for TestFlight App Review when required. Use
|
||||
<https://framecontrol.fun/privacy/> for the privacy policy and
|
||||
<https://framecontrol.fun/feedback/> for feedback. Review notes must explain
|
||||
that the app requires a Steam Frame with Developer Mode enabled and a user
|
||||
password; do not provide your own headset credentials to reviewers. Apple may
|
||||
need a way to evaluate features that require the hardware.
|
||||
|
||||
After Apple approves the build for external testing, enable the group's public
|
||||
link and choose a tester limit. Share that link to let testers join without an
|
||||
App Store Connect account. Creating a group alone does not make a build public.
|
||||
Do not describe the link as ready until the approved build is in the group and
|
||||
the link is enabled.
|
||||
|
||||
## Verified
|
||||
|
||||
<img src="img/iphone-tabs.jpg" alt="The four tabs in the iPhone app, connected to a Frame" width="900">
|
||||
@@ -102,11 +203,80 @@ app installed opens its App Store page.
|
||||
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
|
||||
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
|
||||
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
|
||||
Sending text to the Frame's clipboard needs the desktop panel open in the
|
||||
headset, as on the desktop app.
|
||||
Sending text to the Frame's clipboard, or copying the Frame's to the phone
|
||||
(**Type on the headset → Copy the headset's clipboard**), needs the desktop
|
||||
panel open in the headset, as on the desktop app.
|
||||
|
||||
Not yet exercised: Android display changes through podman (no Android app was
|
||||
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
||||
On a physical iPhone, 2026-10-14: Alex reported build 202610140516 was working
|
||||
after pairing and trying the password visibility button. A server process
|
||||
started by the phone and its SSH key on the Frame confirmed the connection.
|
||||
This was a setup/connection smoke check; it did not verify every app feature.
|
||||
|
||||
On a physical iPhone 16 Pro running iOS 27.2, 2026-10-16: the same build launched
|
||||
and automatically reconnected to the Frame. A device screenshot showed the
|
||||
green connection indicator, 100% headset battery and the loaded Android
|
||||
catalog. This confirms connection and rendering, not status refresh or actions
|
||||
inside the app. A separate signed automation runner was installed and passed
|
||||
its health check. Initial interaction attempts were blocked by the phone's
|
||||
lock; testing resumed once Alex unlocked it and kept the screen awake.
|
||||
|
||||
<img src="img/iphone-physical-20261016.jpg" alt="Frame Control connected to the Frame on a physical iPhone, showing battery and the Android catalog" width="360">
|
||||
|
||||
In that continuation, still on build **202610140516**, the status header
|
||||
refreshed, the Games tab loaded 14 installed titles with sizes and Play
|
||||
controls, and a uniquely named 114-byte text file was selected through the
|
||||
iPhone's Files picker and sent to `~/Downloads` on the Frame. An SSH readback
|
||||
matched the file's contents exactly; this verifies phone-to-Frame upload,
|
||||
not a download back to the phone. Both the iCloud source fixture and the
|
||||
uploaded test file were removed and checked absent.
|
||||
|
||||
Capture returned a blank headset frame with an asleep/unworn explanation.
|
||||
Save opened the native share sheet and reached the Photos permission prompt;
|
||||
no completed Photos save was verified. A final Settings check showed
|
||||
**Add Photos Only** enabled, without photo-library read access. A test string
|
||||
sent through the clipboard control was refused with the expected prerequisite:
|
||||
`plasmashell` was not running, so the desktop panel must be opened in the
|
||||
headset. The phone's existing clipboard was not read. Live mode started and
|
||||
reported **30 fps**, but its visible frames remained blank, so meaningful
|
||||
video playback is still unverified. Android listed 10 installed apps; a
|
||||
separate SSH check found zero running containers and zero Android ADB
|
||||
listeners, so display-setting changes were not exercised.
|
||||
|
||||
<img src="img/iphone-games-physical-20261016.jpg" alt="Frame Control's Games tab on the physical iPhone, showing the installed library and Play controls" width="360">
|
||||
|
||||
<img src="img/iphone-live-physical-20261016.jpg" alt="Live mode reporting 30 fps on the physical iPhone with blank headset content and the page horizontally clipped" width="360">
|
||||
|
||||
Testing also exposed an enlarged, horizontally clipped page after text entry
|
||||
and navigation. Its cause has not yet been isolated. Restarting Frame Control
|
||||
restored the normal layout and automatically reconnected to the headset.
|
||||
|
||||
### Mac follow-up
|
||||
|
||||
On 2026-10-17, the installed Mac app **0.4.6** was tested with the headset
|
||||
unworn. Connection/status worked, and its connection test reported
|
||||
`Answered in 90 ms · SSH works`. Still capture completed but both eyes were
|
||||
blank. Live video failed with `Not a video capture device` when opening
|
||||
`/dev/video99`, then fell back to blank still captures. No visible scene or
|
||||
video playback was verified. The desktop was inactive; no headset services
|
||||
or settings were changed. Live was stopped and the existing app left on Home.
|
||||
These are Mac results; they do not verify the iPhone build or establish the
|
||||
cause of its earlier blank output.
|
||||
|
||||
<img src="img/mac-live-error-20261017.png" alt="Mac Frame Control activity log reporting Live video failed: Not a video capture device" width="900">
|
||||
|
||||
### Physical-phone follow-up checklist
|
||||
|
||||
- With the headset awake and worn, capture visible headset content, save it to
|
||||
Photos, then verify live video.
|
||||
- Send test text to the Frame's clipboard with its desktop panel open.
|
||||
- With an Android app running, change and restore its display settings.
|
||||
- Reproduce and fix the page enlargement and horizontal clipping after text
|
||||
entry and navigation.
|
||||
- After saving headset work, deliberately test sleep, restart and shut down.
|
||||
|
||||
These remaining checks are pending on a physical phone. Android display
|
||||
changes through podman and real sleep/restart/shut down also remain unverified
|
||||
by the Simulator checks above.
|
||||
|
||||
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
|
||||
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
|
||||
|
||||
@@ -4,7 +4,8 @@ Frame Control can show any Mac window, or a whole Mac screen, as its own panel
|
||||
in the Steam Frame. You place each panel anywhere in the room with the SteamVR
|
||||
dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
|
||||
the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
|
||||
only).
|
||||
only). On Windows there's an experimental port,
|
||||
[PC in the headset](pc-in-headset.md).
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Mac space: the Mac's desktop and its windows, placed in the room
|
||||
|
||||
Design notes, started 2026-09-28. The goal is Apple Vision Pro's Mac Virtual
|
||||
Display, plus something Vision Pro can't do: pulling any Mac window out
|
||||
to float on its own. Labels as in [ssh.md](ssh.md): **verified**,
|
||||
**documented**, **reported**, **inferred**.
|
||||
|
||||
## What to match (Vision Pro's Mac Virtual Display)
|
||||
|
||||
Researched 2026-09-28. Sources: Apple Support
|
||||
[118521](https://support.apple.com/en-us/118521) and the
|
||||
[Vision Pro guide](https://support.apple.com/guide/apple-vision-pro/use-mac-virtual-display-tan357ede966/visionos);
|
||||
[9to5Mac](https://9to5mac.com/2024/01/30/heres-what-its-like-using-vision-pro-as-an-external-mac-display/),
|
||||
[UploadVR](https://www.uploadvr.com/visionos-2-2-ultrawide-mac-virtual-display/)
|
||||
and [AppleInsider](https://appleinsider.com/inside/apple-vision-pro/tips/how-to-use-mac-virtual-display-for-apple-vision-pro-with-desktop-macs-and-macbooks).
|
||||
|
||||
- **Connecting.** Look at the Mac and a **Connect** button floats above it.
|
||||
There's also a Control Center route. The Mac's own screen goes dark
|
||||
(**documented**/**reported**).
|
||||
- **Placement.** The display appears where you were looking, near the Mac,
|
||||
at a large-monitor size. After that it stays put in the room: moving the
|
||||
laptop doesn't move it. Holding the Digital Crown recenters everything
|
||||
(**documented**).
|
||||
- **Handling.**
|
||||
- A **window bar** under the display moves it, with pinch and drag, and
|
||||
also moves it nearer or farther.
|
||||
- Its bottom **corners** resize it.
|
||||
- **Close** sits to the left of the bar (**documented**).
|
||||
- **Modes.** Standard (16:9), Wide (21:9) and Ultrawide (32:9, about 10K
|
||||
pixels across). All are curved since visionOS 2.2 (**documented**).
|
||||
- **Input.**
|
||||
- The Mac's keyboard and trackpad drive it.
|
||||
- The pointer moves to whichever window you look at (Universal Control).
|
||||
- Mac audio plays in the headset (**documented**).
|
||||
- **What it can't do.** You can't pull a single Mac window out of the
|
||||
virtual display (**reported**, several sources). That's our
|
||||
differentiator.
|
||||
|
||||
## What the Frame allows
|
||||
|
||||
- **Moving SteamVR's panels from our own process: no** (**verified**
|
||||
2026-09-28). `FindOverlay("valve.steam.desktopgame.<id>")` works from a
|
||||
separate OpenVR app, but `SetOverlayTransformAbsolute` returns 12
|
||||
(`VROverlayError_PermissionDenied`). The panel reported transform type 5
|
||||
(dashboard tab). Only SteamVR's dashboard, which runs in `vrwebhelper`,
|
||||
moves them, through its privileged `VRHTML.VROverlay`, and it has no
|
||||
DevTools port. See `/tmp/fmv-space/ovprobe.py` for the probe, which stays
|
||||
outside the repo.
|
||||
- **WebXR in Chromium XR: yes** (**verified** 2026-09-26/27,
|
||||
[webxr-chromium.md](webxr-chromium.md)).
|
||||
- `immersive-vr` runs at 72–75 fps.
|
||||
- Passthrough shows wherever the page leaves pixels transparent.
|
||||
- Controllers are `tracked-pointer`s with `xr-standard` gamepads, and
|
||||
squeeze events arrive.
|
||||
- Hands are tracked with 25 joints.
|
||||
- **Our own OpenVR overlays: possible, but most work.** We would own their
|
||||
transforms and get laser input as mouse events for free. But the Frame
|
||||
would need native H.264 decoding and texture upload.
|
||||
|
||||
## Decision: a WebXR "Mac space" page first
|
||||
|
||||
The WebXR page (`ui/mac-space.html`) holds everything, with passthrough
|
||||
behind it:
|
||||
|
||||
- **The desktop.** A large curved panel shows the Mac's main display,
|
||||
placed above the laptop. The first time, you place it yourself; after
|
||||
that it's remembered in the room.
|
||||
- **Windows.** Each separated window gets its own panel.
|
||||
- **Grabbing.** A bar under each panel moves it (squeeze, or pinch with a
|
||||
hand). Its corners resize it, and there's a close button to the bar's
|
||||
left, as on Vision Pro.
|
||||
- **Pulling a window out.** Grab a window's title bar on the desktop panel
|
||||
and drag it off the edge. The page starts a `separate:<window id>`
|
||||
stream, the window moves to its own display on the Mac, and its new
|
||||
panel follows your hand until you let go. Dropping a panel back onto the
|
||||
desktop panel stops the stream, and the window goes back.
|
||||
- **Input.** The laser and trigger click and drag, the thumbstick scrolls,
|
||||
and the Mac's keyboard types, as today.
|
||||
|
||||
Trade-offs:
|
||||
|
||||
- It's the headset's active app while you use it. Other VR apps pause, but
|
||||
SteamVR's own overlays still draw on top.
|
||||
- Text sharpness depends on how the page renders. WebXR quad layers (the
|
||||
`layers` feature) would let SteamVR's compositor sample each panel
|
||||
directly, so they're worth checking first.
|
||||
|
||||
## Building blocks
|
||||
|
||||
- **One module for the protocol.** `ui/mac-stream.js` holds the stream
|
||||
protocol (connect, clock sync, decoding, acks and reports, input), shared
|
||||
by `mac-view.html` (the 2D panel) and `mac-space.html`.
|
||||
- **A space key from the agent.** Frame Control gets a ticket with
|
||||
`src=space`, and the page trades it once for a **space key**, kept in
|
||||
memory. That key lists the windows and displays (with their positions,
|
||||
for picking the one under the pointer) and opens streams for any of
|
||||
them. Stop All revokes it.
|
||||
- **Anchoring.**
|
||||
- v1: manual placement, remembered in the room (to be checked: whether
|
||||
`local-floor` stays put between sessions on the Frame).
|
||||
- v2: the Mac shows a marker on its screen for a moment, and the headset
|
||||
finds it. That needs camera access, which is **unknown** on the Frame:
|
||||
the passthrough cameras are monochrome, and `IVRTrackedCamera` hasn't
|
||||
been tried.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Frame rate with several software-decoded streams at once.
|
||||
- Whether `local-floor` placements survive restarts.
|
||||
- Whether WebXR quad layers work in Chromium XR's OpenXR path.
|
||||
- Whether Chromium grants `requestSession` without a click in the page
|
||||
(needed for a one-click "Open Mac space").
|
||||
@@ -143,6 +143,15 @@ check in the headset:
|
||||
- Keys from SteamVR's on-screen keyboard.
|
||||
- Whether Flathub Chromium decodes H.264 (otherwise use Compatible).
|
||||
|
||||
## Battery charge limit (2026-10-23)
|
||||
|
||||
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
|
||||
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
|
||||
no backend in the Frame's device config, and the battery node is a
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
|
||||
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
|
||||
on a headset.
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||
|
||||
@@ -1,199 +1,155 @@
|
||||
# PC in the headset
|
||||
# PC in the headset (Experimental)
|
||||
|
||||
Windows and Linux hosts use **Tools → PC in the headset**. The host shares a
|
||||
window or screen, and the existing Frame viewer makes it a SteamVR panel.
|
||||
Move it with the dashboard's Float in World, Move and Size controls.
|
||||
**Tools → PC in the headset**, on Windows only. It's the Windows version of
|
||||
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Individual windows are available only
|
||||
with streaming libraries built with Windows Graphics Capture (WGC); the shipped
|
||||
MSYS2 libraries disable WGC, so this build explains that limitation and offers
|
||||
whole screens. Place the panel with the dashboard's Float
|
||||
in World, Move and Size, point and click with the laser, scroll with the
|
||||
thumbstick, and type on the PC's keyboard.
|
||||
|
||||
**Inferred / not yet verified on a desktop host:** the Windows and Linux
|
||||
capture and input paths below. This work was developed on a Mac with no
|
||||
Windows or Linux desktop VM. A native build or test-pattern test in CI does
|
||||
not establish that desktop capture, a permission dialog, hardware encoding
|
||||
or laser input works. Keep this feature in the draft/testing stage until
|
||||
those paths have been tried on real hosts.
|
||||
**It's experimental.** A Windows 11 VM has captured its real desktop with
|
||||
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
|
||||
hardware encoding and physical laser input remain unverified. See
|
||||
[the dated evidence](evidence/windows-vm-2026-10-24.md) for results and limits.
|
||||
Reports are welcome through the
|
||||
[feedback form](https://frame-control.pages.dev/feedback/).
|
||||
|
||||
## Own implementation, platform APIs and bundled libraries
|
||||
## Turning it on
|
||||
|
||||
Frame Control owns the host agent, input routing, authentication, streaming
|
||||
protocol, panel launcher and adaptation. It does not launch or require
|
||||
Sunshine, OBS or another desktop-streaming app. GStreamer and its codec
|
||||
plugins are ordinary libraries bundled with the Windows and Linux app;
|
||||
users do not install a GStreamer application. The shared library build keeps
|
||||
license texts and package provenance alongside the libraries. Linux also
|
||||
bundles the PipeWire client’s dynamically loaded SPA/protocol modules and a
|
||||
private client configuration; it does not change the desktop’s configuration.
|
||||
It's off by default. Until you tick **Turn on PC in the headset** on the card,
|
||||
Frame Control starts no helper, loads none of the streaming libraries and lists
|
||||
no windows. The choice is remembered on this computer. Turning it off ends
|
||||
every stream, closes the viewers in the headset and stops the helper.
|
||||
|
||||
First-party alternatives considered (**documented**): Valve Remote Play
|
||||
streams a game/desktop, rather than providing this per-window panel protocol;
|
||||
Windows Remote Desktop opens a remote session; Linux's desktop portal is the
|
||||
consent mechanism for sharing the current desktop. The chosen paths are:
|
||||
The card and its Tools link only appear in the Windows app. On macOS the card
|
||||
is Mac in the headset, as before; on Linux and the iPhone app there's no card.
|
||||
|
||||
| Host | Capture | Encoding | Input |
|
||||
|---|---|---|---|
|
||||
| Windows | Windows.Graphics.Capture, through `d3d11screencapturesrc capture-api=wgc`; HWND or HMONITOR | Hardware Media Foundation (`mfh264enc`), low latency, no B-frames | `SendInput`, with source bounds and per-monitor DPI awareness |
|
||||
| Linux | RemoteDesktop + ScreenCast portal, then the returned PipeWire fd/node | VA-API (`vah264enc`) where registered; x264 otherwise | RemoteDesktop portal notifications, using only granted pointer/keyboard devices |
|
||||
## How it works
|
||||
|
||||
API choices are **documented**, not device verification:
|
||||
The ported pieces are from [PR #38](https://github.com/saphid/frame-control/pull/38).
|
||||
Frame Control owns the host helper, input, authentication, streaming protocol,
|
||||
panel launcher and adaptation. It doesn't need Sunshine, OBS or any other
|
||||
streaming app. GStreamer is bundled as ordinary libraries.
|
||||
|
||||
| | Windows |
|
||||
|---|---|
|
||||
| Capture | GStreamer's `d3d11screencapturesrc`: Desktop Duplication for whole screens in the shipped build; Windows.Graphics.Capture by window (HWND) or screen (HMONITOR) when that implementation is present |
|
||||
| Encoding | Media Foundation's H.264 encoder (`mfh264enc`), low latency, no B-frames. Whether it uses the GPU depends on the PC; **not verified** |
|
||||
| Input | `SendInput`, mapped through the source's bounds, with per-monitor DPI awareness |
|
||||
|
||||
API references:
|
||||
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
|
||||
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
|
||||
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html),
|
||||
[ScreenCast portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html),
|
||||
[RemoteDesktop portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html).
|
||||
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html).
|
||||
|
||||
Windows needs a WGC-capable Windows 10/11 desktop and an available hardware
|
||||
Media Foundation H.264 encoder. Elevated windows and the secure desktop
|
||||
cannot be driven by an ordinary Frame Control process. If Windows refuses
|
||||
to focus a selected window, input is refused rather than sent to the app
|
||||
covering it; bring the selected window forward, then Stop and Show again. Minimized/closed
|
||||
windows may stop producing frames. Protected content is not supported.
|
||||
It needs Windows 10 or 11 with Desktop Duplication and a Media Foundation
|
||||
H.264 encoder (Windows "N" editions need the Media Feature Pack). WGC-enabled
|
||||
builds can also capture individual windows. Clicks and
|
||||
keys can't reach administrator apps or the sign-in screen, which an ordinary
|
||||
process can't drive. If Windows won't bring the chosen window to the front,
|
||||
input is refused rather than sent to the app covering it: bring the window
|
||||
forward, then Stop and Show again. Minimised or closed windows stop sending
|
||||
pictures. Protected video isn't supported. Mac in the headset's "own display
|
||||
per window" is Mac-only.
|
||||
|
||||
On Linux, press **Choose a window or screen…** and approve the desktop's
|
||||
sharing dialog. Choose another source to add another panel. Stop releases
|
||||
that source's portal session; sharing it again asks for consent again.
|
||||
A desktop must implement both ScreenCast and RemoteDesktop for this path;
|
||||
a ScreenCast-only compositor cannot provide laser input through this API.
|
||||
Cancelling or denying a dialog is reported on the card. No portal permission
|
||||
is bypassed, and Frame Control does not open `/dev/uinput` or the unrestricted
|
||||
PipeWire daemon on the host.
|
||||
The pieces it shares with Mac in the headset:
|
||||
|
||||
The host's own keyboard still works. Input from the viewer uses normalized
|
||||
picture coordinates, maps through the selected source's bounds, and releases
|
||||
held buttons/keys on blur, disconnect and Stop. Linux requires the pointer
|
||||
and keyboard grants. **Untested:** desktop-specific consent, mixed-DPI
|
||||
Windows input alignment, multi-monitor layouts, hardware encoder behavior,
|
||||
window resize/minimize, and non-US keyboard layouts.
|
||||
- `ui/frame_macview.py` owns the SSH tunnel, reconnects, quality presets and
|
||||
panel launch. `ui/frame_pcview.py` picks the Windows helper
|
||||
(`ui/frame_pc_agent.py`, `ui/frame_pc_capture.py`, `desktop/capture.c`) and
|
||||
holds the on/off switch. `/api/macview` is the endpoint for both.
|
||||
- `ui/mac-view.html` is the one viewer in the headset. Its frame header, H.264
|
||||
and JPEG payloads, reconnect handshake, clock sync, timing reports and input
|
||||
messages are unchanged.
|
||||
- `desktop/controller.c` is a C port of the Mac helper's Swift rate controller,
|
||||
for the Windows helper. The Mac keeps its Swift original;
|
||||
`tests/test_pc_controller.py` checks the port against a frozen 600-state
|
||||
trace from it. Capture is gated before encoding, so encoded frames are never
|
||||
dropped, and an idle window's last picture still goes out once the link
|
||||
clears.
|
||||
- Stream records use the Mac helper's JSON schema (`ui/frame_stream_stats.py`),
|
||||
so the benchmark's analysis is shared.
|
||||
|
||||
## Shared pieces
|
||||
## Packaging
|
||||
|
||||
- `ui/frame_macview.py` owns the SSH tunnel, reconnect supervision, quality
|
||||
presets and panel launch for all hosts. `ui/frame_pcview.py` selects the PC
|
||||
helper; `/api/macview` remains the compatible endpoint.
|
||||
- `ui/mac-view.html` is the one viewer. The 17-byte big-endian frame header,
|
||||
Annex-B H.264/JPEG payloads, `hello`/`ack` reconnect handshake, clock sync,
|
||||
`rx`/`fd` timing reports and input messages are unchanged.
|
||||
- `desktop/controller.c` is the rate controller shared by the Mac Swift
|
||||
binding and the PC Python binding. Capture is gated **before** encoding;
|
||||
encoded reference frames are never discarded. A bounded raw-frame queue
|
||||
keeps the newest picture, including the last update of an idle window,
|
||||
until the gate opens. A native one-frame-source test covers that case.
|
||||
It keeps the Mac's bitrate demand protection and tier hysteresis.
|
||||
- PC records use the existing `Stats.swift` JSON schema, with bounded
|
||||
4096-frame/512-input storage in `ui/frame_stream_stats.py`. The benchmark's
|
||||
analysis, targets and network shaping are shared, not reimplemented.
|
||||
Capture timestamps describe the native pipeline's source time; they do
|
||||
not prove the time at which the host compositor displayed the pixels.
|
||||
- Mac virtual-display separation remains Mac-only. Windows WGC and the
|
||||
Linux portal share the selected window directly.
|
||||
The Windows installer carries `resources/desktop/bundle`: `pc-host.dll` (our
|
||||
adapter and the controller), the GStreamer plugins it uses (`coreelements`,
|
||||
`videotestsrc`, `videoconvertscale`, `app`, `jpeg`, `videoparsersbad`,
|
||||
`d3d11`, `mediafoundation`) and the DLLs they depend on, all from MSYS2's
|
||||
UCRT64 packages. `libraries.json` records the GStreamer version, the plugins,
|
||||
each copied DLL and the exact package versions; licence texts are in
|
||||
`licenses/`.
|
||||
|
||||
PC capture follows the shared frame-rate and resolution tiers. x264 updates
|
||||
bitrate while running. Hardware encoders are drained and reopened when the
|
||||
budget changes materially, at most once a second, because their live property
|
||||
support varies. Reopening starts a new keyframe and retains the consented
|
||||
portal session. **Untested:** hardware reconfiguration latency and whether a
|
||||
particular desktop permits reconnecting its PipeWire stream this way.
|
||||
MSYS2's `gst-plugins-bad` build disables `d3d11-wgc`. The presence of
|
||||
`d3d11screencapturesrc` alone does not prove WGC support: its `capture-api`,
|
||||
`window-handle` and `show-border` properties are conditional. The helper queries
|
||||
those properties on the instantiated element, advertises only supported
|
||||
sources, and omits all three properties for the DXGI whole-screen pipeline.
|
||||
It never substitutes a screen crop for an unavailable window capture.
|
||||
|
||||
## Build and measure
|
||||
The x264 encoder is **not** in the installer. It's GPL, and Windows encodes
|
||||
with Media Foundation, so only CI's test builds include it
|
||||
(`desktop/build.py --with-x264`). The macOS and Linux installers are unchanged.
|
||||
|
||||
Packaged Windows/Linux builds include `desktop/bundle/pc-host` and its shared
|
||||
libraries. Source checkouts build them with `python3 desktop/build.py` after
|
||||
installing GStreamer development packages (see the `PC host libraries` CI
|
||||
workflow). The feature reports a missing bundle; it does not download or
|
||||
install a streaming app on first use.
|
||||
|
||||
The existing benchmark now accepts a PC host:
|
||||
From a source checkout on Windows, build the bundle from an MSYS2 UCRT64 shell
|
||||
with the packages listed in `.github/workflows/pc-host.yml`:
|
||||
|
||||
```sh
|
||||
python3 scripts/macview-bench.py run --pc --scenario test --label pc-test
|
||||
# Linux: select a real source in the desktop's sharing dialog
|
||||
python3 scripts/macview-bench.py run --pc --scenario capture --source choose --label linux-window
|
||||
# Windows: use the HWND/monitor source ID shown by the host's /windows or /displays
|
||||
python3 scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
|
||||
python desktop/build.py # what the installer ships
|
||||
python desktop/build.py --with-x264 # for the native tests
|
||||
```
|
||||
|
||||
The synthetic PC pattern uses bundled x264 so headless CI can verify the
|
||||
wire protocol without claiming that a GPU was exercised. The `capture`
|
||||
scenario measures the selected real source without injecting input or
|
||||
assuming that it animates at 60 fps. Mac-only Chrome/virtual-display typing
|
||||
and scrolling automation is not run on PC hosts. Results retain the same
|
||||
latency stages and record `host_platform`, `pc_host` and `source`. CPU sampling
|
||||
on PC hosts is explicitly unavailable. `--net` and `--delay` still use the
|
||||
same bounded shaping relay, without administrator privileges.
|
||||
A missing bundle is reported on the card; nothing is downloaded on first use.
|
||||
|
||||
## Evidence
|
||||
The Linux adapter from #38 (desktop portal, PipeWire, VA-API) is in the tree,
|
||||
because it shares `capture.c` and the controller, and CI still builds and tests
|
||||
it. The app never offers it and the Linux installers don't include it.
|
||||
|
||||
- **Verified, Mac, 2026-09-28:** the final full unit/integration suite ran
|
||||
183 tests successfully, including the real Mac helper's H.264, ticket, timing
|
||||
and input-echo tests. The native PC test class was skipped locally because
|
||||
its libraries were absent; the PC adapter and shared-controller tests passed.
|
||||
- **Verified, real Frame, 2026-09-28, BUILD_ID 20260925.6191901:** the base
|
||||
helper's synthetic source created panel `valve.steam.desktopgame.2001639889`,
|
||||
and the shared Chromium viewer decoded H.264. It recorded 286 frames over
|
||||
the short probe, with a two-second summary of 19.5 fps shown and total
|
||||
latency p50/p95 83/156.5 ms. This establishes the existing viewer/transport
|
||||
route, not Windows/Linux capture, input or a latency target. The probe's
|
||||
helper, tunnel and viewer were stopped afterward.
|
||||
- **Verified in CI:** native library builds and real x264 protocol tests passed
|
||||
on Windows, Ubuntu x64 and Ubuntu ARM64 in
|
||||
[run 36422214445](https://github.com/saphid/frame-control/actions/runs/36422214445).
|
||||
All four installer builds passed in
|
||||
[run 36422214425](https://github.com/saphid/frame-control/actions/runs/36422214425).
|
||||
These are build/synthetic tests, not desktop-host verification. No VM was used.
|
||||
- **Verified, real Frame, same build/date:** the ARM64 PC agent and its
|
||||
bundled libraries ran from a temporary user directory, using x264's moving
|
||||
test pattern. Traffic travelled Frame → Mac SSH relay → Frame viewer.
|
||||
The shared bench recorded 351 drawn frames, content p50/p95 15.1/80.3 ms,
|
||||
and 34.8 fps. The frame-rate and late-frame targets failed. This checks the
|
||||
new agent and real viewer together; it is not a representative PC link.
|
||||
The helper exited 0, its viewer/tunnels stopped, and its directory was removed.
|
||||
[Raw benchmark result](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
|
||||
This first probe's input echo measured message receipt to the next capture,
|
||||
not a visible pattern response; later builds make test clicks change its color.
|
||||
- **Verified, Mac:** all 600 states in a 60-second congestion/recovery trace
|
||||
matched the original Swift controller. `tests/test_pc_controller.py` retains
|
||||
the original trace digest as a regression check.
|
||||
- **Verified, real Frame, agent at `cd20243`:** the repeated synthetic
|
||||
probe drew 400 frames at 39.3 fps, content p50/p95 15.1/28.6 ms, and synthetic
|
||||
input-to-drawn p50 76.6 ms. Test clicks now change the pattern color before
|
||||
injection is timestamped. The frame-rate/late-frame targets still failed;
|
||||
this remains a Frame-hosted x264 test through a Mac relay, not a desktop or
|
||||
physical-laser measurement. Helper exit 0 and cleanup succeeded.
|
||||
[Latest device probe result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json).
|
||||
- **Untested:** real Windows WGC → Media Foundation → Frame; real Linux
|
||||
portal → PipeWire → VA-API/x264 → Frame; physical laser input on either.
|
||||
No benchmark numbers for those desktop paths are claimed.
|
||||
## Measuring
|
||||
|
||||
## Independent review availability
|
||||
The Mac benchmark accepts the PC host:
|
||||
|
||||
A direct read-only review was attempted with
|
||||
`devin -p --model swe-2-max --permission-mode auto --prompt-file …`. The first
|
||||
attempt exited 0 after rejecting a tool that needed interactive permission;
|
||||
it did not inspect the diff. A full inline-diff attempt returned no output
|
||||
for 15 minutes and was terminated (shell exit 143). A smaller inline native
|
||||
code review returned no output within 300 seconds (process exit -15).
|
||||
SWE-2 Max was requested; no completed review or findings were received, so
|
||||
independent review is **unverified**, not a passed check. The PR remains draft.
|
||||
```sh
|
||||
python scripts/macview-bench.py run --pc --scenario test --label pc-test
|
||||
python scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
|
||||
```
|
||||
|
||||
## Device follow-up, 2026-09-29
|
||||
`capture` needs a window or screen id from the helper's `/windows` or
|
||||
`/displays`. CPU sampling isn't available on PC hosts. See
|
||||
[Mac in the headset](mac-in-headset.md) for the targets and shaping options.
|
||||
|
||||
**Verified, read-only, SteamOS BUILD_ID 20260925.6191901:** the Frame was
|
||||
reachable, charging (44% initially, 63% at the end), and SteamVR reported
|
||||
activity level 3 (standby). No Plasma desktop was running. Introspection of
|
||||
the active `org.freedesktop.portal.Desktop` service exposed ScreenCast with
|
||||
source types 3, but no RemoteDesktop interface. **Inferred:** this gamescope
|
||||
session cannot provide this feature's required consented input path; a
|
||||
ScreenCast-only session is insufficient.
|
||||
## What's verified
|
||||
|
||||
**Blocked, not a device execution result:** the shared `/tmp/frame-test.lock`
|
||||
was held by another thread. Ten lock-acquisition attempts over five minutes
|
||||
all failed, after earlier preparation-time attempts also found it occupied.
|
||||
The current `a992f6d` ARM64 CI bundle was downloaded to the Mac, but nothing
|
||||
was installed, launched or stopped on the Frame. No new screenshots or
|
||||
current-revision device timings were obtained. The lock was not removed and
|
||||
no global settings were changed. The earlier synthetic results above remain
|
||||
valid only for their named revisions; standby visibility, physical laser
|
||||
input and worn-headset performance remain unverified.
|
||||
|
||||
**Verified, Mac:** a fresh full suite ran 309 tests successfully, with one
|
||||
native-PC class skipped. Current-revision CI passed
|
||||
[native hosts](https://github.com/saphid/frame-control/actions/runs/36504546909),
|
||||
[installers](https://github.com/saphid/frame-control/actions/runs/36504546800)
|
||||
and [general checks](https://github.com/saphid/frame-control/actions/runs/36504546887).
|
||||
- **Verified in CI, this PR:** on `windows-latest`, the native adapter builds
|
||||
and a real x264 synthetic stream passes the ticket, reconnect, timing and
|
||||
input-echo tests through the helper. The Windows installer builds with the
|
||||
bundle, and its bundled Python loads the bundle, starts the helper and
|
||||
confirms x264 isn't in it. Unit tests check the Windows-only, off-by-default
|
||||
gating, the card and the Tools link on macOS, Windows and Linux.
|
||||
- **Verified, #38, 2026-09-28, real Frame on SteamOS BUILD_ID 20260925.6191901:**
|
||||
the PC helper (ARM64, on the Frame itself, x264 test pattern) streamed
|
||||
through a Mac SSH relay into the shared viewer: 400 frames at 39.3 fps,
|
||||
content latency p50/p95 15.1/28.6 ms, synthetic input p50 76.6 ms, at
|
||||
revision `cd20243`. The frame-rate and late-frame targets failed. This
|
||||
proves the helper and the viewer work together; it isn't Windows capture or
|
||||
a real PC link.
|
||||
[Raw result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json),
|
||||
[earlier probe](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
|
||||
- **Verified, #38, on a Mac:** the C controller matched all 600 states of the
|
||||
Swift controller's congestion and recovery trace.
|
||||
- **Verified, 2026-10-24:** Windows 11 VM build 26200.8037 on lxso2, using
|
||||
the v0.4.10 installer with this fix, captured its actual desktop and streamed
|
||||
H.264 to Frame BUILD_ID `20261015.6141207`: 219 sent frame records, 215 decoded
|
||||
and drawn before the snapshot. The own-panel screenshot shows the Windows
|
||||
desktop. This short, mostly idle run does not establish performance targets.
|
||||
[Evidence, raw traces and cleanup](evidence/windows-vm-2026-10-24.md).
|
||||
- **Not verified:** Windows.Graphics.Capture, physical-PC capture, hardware
|
||||
Media Foundation encoding, encoder restarts when the bitrate
|
||||
changes, mixed-DPI and multi-monitor input alignment, window resize and
|
||||
minimise, non-US keyboard layouts, physical laser input, and how it performs
|
||||
in a worn headset. No numbers are claimed for these. Not tested with a
|
||||
worn headset. See the dated VM evidence for the separate automated checks.
|
||||
- **Not reviewed independently.** #38 tried an independent review that never
|
||||
completed.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Read-only power and heat measurements
|
||||
|
||||
Issue [#52](https://github.com/saphid/frame-control/issues/52) starts with
|
||||
measurement. `scripts/frame-power-sample.sh` is a bounded, on-demand sampler
|
||||
over the existing `ssh frame` alias. It needs Python 3 on both computers and
|
||||
an already paired, awake, reachable Frame. It uses strict SSH host-key checking
|
||||
and batch mode; it never pairs, prompts for a password or changes SSH security.
|
||||
|
||||
```sh
|
||||
# Twelve samples, five seconds apart (about 55 seconds), JSON report:
|
||||
scripts/frame-power-sample.sh > power.json
|
||||
|
||||
# Five-minute observation: samples at 0, 5, ... 300 seconds:
|
||||
scripts/frame-power-sample.sh --count 61 --interval 5 > baseline.json
|
||||
|
||||
# Long-form CSV samples; JSON summary goes separately to stderr:
|
||||
scripts/frame-power-sample.sh --count 12 --interval 5 --csv > power.csv 2> summary.json
|
||||
```
|
||||
|
||||
Count is limited to 1–720; interval to finite values from 1–60 seconds. These
|
||||
are target start-to-start intervals, not guarantees: read latency can delay a
|
||||
sample. Wall-clock `time` and monotonic `elapsedSeconds` record actual timing.
|
||||
The first sample has no CPU utilization: two valid `/proc/stat` observations
|
||||
are required. CPU utilization then covers the interval between observations,
|
||||
averaged across the machine, rather than the utilization of one process.
|
||||
|
||||
## What it reads
|
||||
|
||||
- Battery percentage, driver charging state, signed battery watts and battery
|
||||
temperature, using the same parser as Frame Control's battery details.
|
||||
Positive battery watts mean charging on the Frame fuel gauge; negative means
|
||||
discharge. `Not charging` alone does not prove a charger is connected.
|
||||
- CPU utilization from `/proc/stat`; GPU clock from the known Frame devfreq path.
|
||||
- Named thermal zones in °C, with zone names retained. A thermal zone is not
|
||||
automatically a CPU sensor; there is no inferred “CPU temperature” or safe
|
||||
temperature threshold.
|
||||
- hwmon power channels, labels and driver names in watts. `vph` is the measured
|
||||
system rail, not power at the wall. CPU `apc*` and GPU `gfx` rails are separate
|
||||
readings; do not sum all channels as if they were independent power consumers.
|
||||
- Fan RPM and the Frame fan driver's raw `pwm1`. No generic percentage conversion
|
||||
is assumed. The historical Frame driver reported a percent-like value of 41.
|
||||
- Named IIO board temperatures, PM8550B input/charge current in amps, and raw
|
||||
VCNL4000 proximity if the account can read it. Raw proximity has no inferred
|
||||
worn/unworn threshold.
|
||||
|
||||
Each metric includes its unit, source path and availability state. Missing,
|
||||
unreadable or invalid values are JSON `null`, not zero. A missing sensor family
|
||||
is also listed in each sample's `unavailableGroups`; CSV adds unavailable group
|
||||
rows. Numeric zero remains a valid reading. The OS build is recorded per sample
|
||||
when `/etc/os-release` is readable; hostnames, addresses, machine IDs, process
|
||||
lists, logs, screenshots and application inventories are not collected.
|
||||
|
||||
The JSON report contains raw samples and a summary per metric: available and
|
||||
unavailable counts, numeric minimum/maximum/arithmetic mean/first/last, or the
|
||||
observed charging states. Means exclude unavailable readings. They are sample
|
||||
means, not time-weighted energy or battery-runtime estimates. Different sample
|
||||
availability and intervals must be considered when comparing sessions.
|
||||
|
||||
Numeric validity does not establish sensor accuracy. Historical readings
|
||||
already showed an apparently absent/broken right-display thermistor near
|
||||
−16 °C. This firmware also reports one charger-lite die sensor at −60 °C.
|
||||
Those raw numbers remain visible for diagnosis; neither should be used to
|
||||
infer cooling performance or safety. This tool does not diagnose heat, explain
|
||||
a spike, predict battery health, or promise power savings.
|
||||
|
||||
## Safety and shared-device use
|
||||
|
||||
Only file reads under `/proc`, `/sys` and `/etc/os-release` run on the Frame.
|
||||
The Python sources are sent on SSH stdin and not installed on the headset.
|
||||
There is no sudo, OpenVR initialization, service call, settings write, process
|
||||
control, journal query, wake, suspend or reboot. No daemon, telemetry upload or
|
||||
automatic measurement is added. Redirects above write only to your computer.
|
||||
The observer itself uses a little CPU and SSH traffic, so label baseline
|
||||
conditions and avoid interpreting very small changes as savings.
|
||||
|
||||
The sampler checks `/tmp/frame-test.lock` on the computer before SSH, and on
|
||||
the Frame before any probe and before each sample. If a smoke session is active,
|
||||
it exits 75. A lock appearing mid-run stops the next sample, retains earlier
|
||||
samples, sets `stopped`, and exits 75. It never removes or acquires another
|
||||
session's lock. This is an advisory check, not an atomic lease: a smoke session
|
||||
can start during a file read. Coordinate sessions and defer measurement when
|
||||
timing overlaps. It does not block someone else from starting a smoke test.
|
||||
|
||||
SSH failures/timeouts return a nonzero exit and an explanation, without
|
||||
presenting a zero or an old report as a successful measurement. Fully missing
|
||||
sensors can still produce a successful report with explicit unavailable states.
|
||||
On a connection failure, partial samples buffered on the headset can be lost.
|
||||
|
||||
## Evidence and remaining work
|
||||
|
||||
Read-only checks on **2026-10-24**, SteamOS **0.5.6**, build
|
||||
**20261015.6141207**, confirmed battery, CPU ticks, thermal zones, rail power,
|
||||
fan, GPU clock, board temperatures and charger current through the final
|
||||
sampler. Proximity was unavailable. Both JSON and CSV paths were exercised;
|
||||
see [the recorded results](evidence/power-sampler-2026-10-24.json).
|
||||
No smoke lock was present at the preflight or sampler checks. No settings,
|
||||
services, application state or device files were changed. This short run is
|
||||
a parser/transport check, not a controlled baseline or a worn-headset test.
|
||||
|
||||
The original planning document is in commit `fe132e3` (2026-09-29). It recorded
|
||||
an off-head, charging idle baseline of about 4.71 W, CPU zones around 40–45 °C,
|
||||
and about 8,350 fan RPM on build 20260925.6191901. Those conditions and firmware
|
||||
differ from the current check; comparisons do not establish a regression.
|
||||
|
||||
This change completes only the sampler portion of “Plan: leave it on, but
|
||||
cheaply.” Controlled worn/home-space, streaming/game load and unplugged
|
||||
baselines, and the cause of the 2026-09-28 95 °C spike, remain uninvestigated.
|
||||
Quiet mode, compositor changes, CPU/fan/charging changes and remote-wake tests
|
||||
are separate work. No controls are offered here. Follow the shared-device
|
||||
procedure in [testing.md](testing.md#headset-smoke-test) for future experiments.
|
||||
|
||||
Offline validation:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_power_sample.py -v
|
||||
bash -n scripts/frame-power-sample.sh
|
||||
python3 -m py_compile scripts/frame_power_sample.py
|
||||
```
|
||||
|
||||
Fixtures cover units, signed and zero values, missing/malformed/permission-denied
|
||||
readings, named/discovered channels, unknown charging state, CPU warmup,
|
||||
summary counts, CSV quoting, strict SSH transport and smoke-lock interruption.
|
||||
@@ -203,9 +203,11 @@ and display name of the headset being tried (whole, whatever their case), and
|
||||
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
|
||||
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
|
||||
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
|
||||
in some other wording can still get through, so check the log lines in **Show
|
||||
exactly what's included** before sending. A failure that repeats on every
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. Whole display names
|
||||
are hidden even when they contain a scrubber token such as `<host>` or overlap
|
||||
ssh's wording. Those overlaps also preserve redaction of URLs, home folders,
|
||||
and keys. A host name ssh mentions in some other wording can still get through,
|
||||
so check the log lines in **Show exactly what's included** before sending. A failure that repeats on every
|
||||
retry is written at most once every 5 minutes.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
@@ -287,3 +289,12 @@ then every 6 hours: the latest release's `update.json` on GitHub, or
|
||||
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
|
||||
Those requests carry no id. To stop it, set
|
||||
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
|
||||
|
||||
## Screenshots and clips
|
||||
|
||||
Copying screenshots and Steam clips to your computer, by hand or with
|
||||
**Settings → Copy to this computer**, goes straight from the headset to your
|
||||
computer over the same SSH connection as everything else. Nothing is uploaded
|
||||
anywhere. What automatic copying has copied is listed, by file id, in
|
||||
`auto-sync.json` in Frame Control's data folder; see
|
||||
[recording.md](recording.md#copying-new-ones-automatically).
|
||||
@@ -0,0 +1,165 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
Frame Control can record what the headset shows, with its sound, and save it as
|
||||
an `.mp4` on your computer. It can also export the clips and background
|
||||
recordings from Steam's own game recording, at any length, as one `.mp4` each.
|
||||
Both are on **Tools → Capture & Live**. Everything is saved to
|
||||
`~/Videos/SteamFrame`, next to the screenshots in `~/Pictures/SteamFrame`.
|
||||
Nothing needs installing on the computer or on the Frame: the work is done by
|
||||
the `ffmpeg` that SteamOS ships.
|
||||
|
||||
**Recording is tested on a headset; Steam clip export isn't yet.** On a Frame
|
||||
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
|
||||
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
|
||||
second, and nothing was left running or stored on the Frame. That Frame had no
|
||||
Steam recordings, so the clip list came back empty and export has only been
|
||||
tested with files in Steam's format made by `ffmpeg`. The parts we're least sure
|
||||
of are listed under [What hasn't been checked](#what-hasnt-been-checked).
|
||||
|
||||
## Record
|
||||
|
||||
Press **Record** next to **Live**. The button turns into **Stop**, and a line
|
||||
under the viewer counts the time. Press **Stop**: the recording is finished on
|
||||
the Frame, converted to `.mp4` there (without re-encoding) and copied to
|
||||
`~/Videos/SteamFrame/rec-<date>-<time>.mp4`. The line says when it's saved.
|
||||
|
||||
- **What's in it**: the same picture as **Live**, SteamVR's copy of the headset
|
||||
view (one undistorted 1920×1080 eye, from `/dev/video99`), at 30 fps and
|
||||
8 Mbit/s, encoded with x264 (the Frame's hardware encoder crashes `ffmpeg`).
|
||||
The sound is what the headset plays: the monitor of PipeWire's default
|
||||
output, read through its PulseAudio server (`-f pulse -i @DEFAULT_MONITOR@`).
|
||||
The microphone isn't recorded.
|
||||
- **Without sound**: before each recording Frame Control checks for a fraction
|
||||
of a second that `ffmpeg` can read that audio. If it can't, the recording goes
|
||||
ahead as video only, and the page and the saved message say so.
|
||||
- **Limits**: a recording stops by itself after 20 minutes and is saved as if
|
||||
you'd pressed Stop. It needs about 2.9 GB free on the Frame to start (room for
|
||||
a full-length recording twice, while it's converted), and checks the space on
|
||||
your computer before copying. One recording runs at a time.
|
||||
- **Where it waits on the Frame**: `~/.cache/frame-control/video/`, not `/tmp`,
|
||||
which is a RAM disk on SteamOS. It's deleted once copied.
|
||||
- **If the connection drops or the app quits** while recording, the Frame
|
||||
stops `ffmpeg` cleanly when the SSH channel closes: it types `q` into
|
||||
`ffmpeg`'s input, as at its keyboard, and kills it if it's still there 10
|
||||
seconds later. Not a signal: SteamOS's `ffmpeg` 7.0 catches SIGTERM and
|
||||
SIGINT and carries on recording. A recording that was never copied is
|
||||
deleted when the next one starts.
|
||||
|
||||
Recording and **Live** can run together; both read the same device.
|
||||
|
||||
## Steam clips
|
||||
|
||||
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
|
||||
clips and background recordings, newest first, with the game, length and size.
|
||||
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
|
||||
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
Steam's own export is reported to stop at about 60 seconds. Exports run in the
|
||||
background; ones already on your computer are marked.
|
||||
|
||||
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
|
||||
recording session.
|
||||
|
||||
```
|
||||
~/.local/share/Steam/userdata/<account>/gamerecordings/
|
||||
video/bg_<appid>_<YYYYMMDD>_<HHMMSS>/ background recording
|
||||
clips/clip_<appid>_<YYYYMMDD>_<HHMMSS>/video/bg_<...>/ a saved clip
|
||||
session.mpd
|
||||
init-stream0.m4s chunk-stream0-00001.m4s chunk-stream0-00002.m4s ...
|
||||
init-stream1.m4s chunk-stream1-00001.m4s ...
|
||||
```
|
||||
|
||||
Each stream's `init-stream` segment followed by its chunks, in number order, is
|
||||
a playable fragmented MP4 of that stream. On the Frame, `ui/frame_clips.py`
|
||||
builds those files and has `ffmpeg` put the video and audio into one `.mp4`
|
||||
with `-c copy`, so nothing is re-encoded and it takes seconds. The result is
|
||||
copied to the computer and deleted from the Frame. The export needs free space
|
||||
on the Frame of about twice the recording's size while it runs.
|
||||
|
||||
The listing doesn't rely on the folder names: any folder under `clips/` or
|
||||
`video/` (up to four levels down) holding `init-stream*.m4s` counts. A stream
|
||||
missing its init segment or its chunks is left out. Missing chunk numbers are
|
||||
shown as "pieces missing"; the rest still exports.
|
||||
|
||||
## Copying new ones automatically
|
||||
|
||||
**Settings → Copy to this computer** copies new screenshots and Steam clips
|
||||
here on their own. It's off until you turn it on, with a switch each for
|
||||
screenshots and Steam clips. While a headset is connected, Frame Control looks
|
||||
about once a minute and copies what's new with the same code as **Save** and
|
||||
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
|
||||
`~/Videos/SteamFrame`.
|
||||
|
||||
- **Only what's new.** It copies what you take after turning it on, so the
|
||||
shots and clips already on the headset don't all arrive at once. **Copy
|
||||
everything now** copies everything that isn't on this computer yet, once.
|
||||
- **Deleted stays deleted.** What it has copied is remembered, per headset, in
|
||||
`auto-sync.json` in Frame Control's data folder, so a file you delete here
|
||||
isn't copied again (**Copy everything now** would bring it back).
|
||||
- **Saved clips only.** Steam's background recording is a rolling buffer, not
|
||||
something you chose to keep, so it isn't copied; export it by hand. A clip
|
||||
Steam wrote to in the last 30 seconds is still being saved and waits for the
|
||||
next look.
|
||||
- **It gives way.** It waits while an install, an export, a recording or
|
||||
anything else is running on the headset, and stops between files if one
|
||||
starts. While a copy is running, switching headsets waits for it, as it does
|
||||
for an export. When the headset can't be reached it waits longer each time,
|
||||
up to 15 minutes, and starts over once it answers.
|
||||
- **When this computer is full** (under 512 MB free, or a copy runs out of
|
||||
space) it pauses and says so in Settings and above the lists. Free some space
|
||||
and press **Resume**.
|
||||
- **With several headsets** it copies from the one you're connected to.
|
||||
|
||||
Settings shows when it last checked, how many files it last copied and when,
|
||||
and the last problem it had. The screenshot and clip lists say when it's on.
|
||||
|
||||
## For developers
|
||||
|
||||
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
|
||||
copying, in `ui/frame_autosync.py`, does):
|
||||
|
||||
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
|
||||
computer's side; the server passes how to reach the Frame.
|
||||
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
|
||||
run as `python3 - list` / `python3 - export ID` with the file on stdin.
|
||||
- `Recorder` runs one recording from Start to the copy.
|
||||
- `parse_id` checks a clip id (`<account>/<clips|video>/<folder>...`, plain
|
||||
names only, no `.` or `..`) before it reaches a path or a shell.
|
||||
|
||||
The API, with the same guards as every other request:
|
||||
|
||||
| Request | Does |
|
||||
|---|---|
|
||||
| `GET /api/record` | The recording's state: `idle`, `starting`, `recording` (with `elapsed`, `audio`, `limit`), `stopping`, `saving`, `saved` (with `file`) or `failed` (with `error`) |
|
||||
| `POST /api/record {"action": "start"\|"stop"}` | Start or stop |
|
||||
| `GET /api/clips` | Steam's recordings on the Frame, each with `saved` |
|
||||
| `POST /api/clips/export {"id"}` | Starts an export; poll `/api/job` |
|
||||
| `POST /api/open {"what": "videos"}` | Opens `~/Videos/SteamFrame` |
|
||||
| `GET /api/autosync` | Automatic copying: `screenshots`, `clips`, `since` (the watermark), `checked`, `copied`, `copied_time`, `error`, `paused`, `waiting` (clips still being written) |
|
||||
| `POST /api/autosync {"action": "settings", "screenshots", "clips"}` | Turn either on or off; turning it on sets `since` to now |
|
||||
| `POST /api/autosync {"action": "now"\|"resume"}` | Copy everything not here yet, once; or resume after running out of space |
|
||||
|
||||
They aren't available when the server runs on the Frame itself (the iPhone
|
||||
app), since they save to a computer.
|
||||
|
||||
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
|
||||
copying (with a fake headset). They run the recording's shell under bash with a
|
||||
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
|
||||
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
|
||||
recording with Steam's file names and export it with the real `ffmpeg`.
|
||||
|
||||
## What hasn't been checked
|
||||
|
||||
The 2026-10-24 read-only clip list on build `20261015.6141207` again returned
|
||||
zero clips: its `gamerecordings` directory held only `gamerecording.pb`. No
|
||||
Steam recording was created during that check, so a real Steam clip export is
|
||||
still unverified. The recording and Stop path were verified earlier on that
|
||||
build, as described above. Remaining checks:
|
||||
|
||||
- Audio and video sync. Both inputs are stamped with the wall clock, because
|
||||
the camera device and the audio server otherwise count from different clocks.
|
||||
- The exact layout of `gamerecordings` on the Frame's SteamOS build, and
|
||||
whether a clip's segments start mid-recording. The parser is deliberately
|
||||
loose about the layout.
|
||||
- How much x264 at 1080p30 costs the Frame while a game is running.
|
||||
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
|
||||
finished saving a clip, and how a once-a-minute listing feels on the Frame.
|
||||
@@ -4,6 +4,9 @@ Where to get the Steam Frame's operating system, what's inside it, and how to
|
||||
run it for testing without the headset. For recovering a Frame that won't boot,
|
||||
see the boot menu and boot-loop entries in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
|
||||
To keep your game saves, settings and Android app data through a factory reset
|
||||
(`Erase User Data`) or a re-image, and to get out of Desktop Mode, see
|
||||
[backup-and-recovery.md](backup-and-recovery.md).
|
||||
|
||||
## Downloads
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ run on Linux. On Windows, use the app.
|
||||
|
||||
## Minimum typing on the headset
|
||||
|
||||
If setup or pairing fails, run `scripts/frame-doctor.sh` from this checkout.
|
||||
It checks existing SSH access without requesting passwords, pairing or applying
|
||||
repairs. See the [Frame doctor guide](frame-doctor.md) for results and next steps.
|
||||
|
||||
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
|
||||
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
|
||||
thing you type on the headset is a password you choose.
|
||||
@@ -89,6 +93,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
|
||||
| Script | Runs on | Purpose |
|
||||
|---|---|---|
|
||||
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
|
||||
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
|
||||
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
|
||||
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
|
||||
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
|
||||
|
||||
@@ -30,7 +30,8 @@ it.) A dialog shows:
|
||||
|
||||
Install copies it to the Frame and registers it with Steam; progress shows in
|
||||
the bar and the activity log. **Sideloaded titles** lists what's installed,
|
||||
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
|
||||
with Launch, Remove and, for a game that has stopped responding, **Force
|
||||
quit…** in its ⋯ menu ([how](steam-games.md#force-quitting-a-running-game)). **Copy to ~/Downloads instead** keeps the old
|
||||
behaviour for a zip that isn't a game.
|
||||
|
||||
From a terminal:
|
||||
|
||||
@@ -24,7 +24,7 @@ No SideQuest implementation code was copied.
|
||||
| Installed-app management (launch, uninstall, backup) | List, launch, stop, remove, probe | Already mostly covered. Backup added here. |
|
||||
| Update notices / account library | Compatible-version lookup; no source-aware installed update notices | Useful later, medium; needs original version code and source identity recorded on install. |
|
||||
| Custom repositories | Built-in F-Droid catalogue and compatible-version indexes | Separate user-repos worker. Legacy SideQuest source has a fixed SideQuestRepos index; arbitrary current custom-repo support was not verified. |
|
||||
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | OBB backend added here; future UI can call it. UI drop wiring is not included. |
|
||||
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | The [Android backup importer](android-backups.md) now groups the APK and matching OBBs, detects desktop siblings and guides installation and data setup. |
|
||||
| Tags, price, headset filters, reviews | Text search and Lepton verdicts, not Quest headset metadata | Useful, medium; search worker owns filters. Keep source headset claims distinct from tested Frame compatibility. |
|
||||
| Screenshot/video capture and streaming | Frame screenshots/VR capture already present | Reuse existing tools; do not port Quest capture commands. |
|
||||
| Device settings and ADB utilities | Frame/Android display settings, SSH and own-instance tools | Borrow selectively; Quest CPU/GPU presets and wireless-ADB setup do not map directly to Lepton. |
|
||||
@@ -85,6 +85,9 @@ local SHA-256 alone must not be called publisher verification.
|
||||
|
||||
## OBB files
|
||||
|
||||
For the app's guided APK and OBB import, see [Import an Android game backup](android-backups.md).
|
||||
The CLI helper below remains available for a running instance.
|
||||
|
||||
```sh
|
||||
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb
|
||||
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.42.org.example.game.obb
|
||||
|
||||
@@ -101,6 +101,62 @@ Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
|
||||
returns nothing without `cc`, so Frame Control takes the country from
|
||||
`SteamClient.User.GetIPCountry()` on the Frame.
|
||||
|
||||
## Force quitting a running game
|
||||
|
||||
**Running now**, at the top of Games, lists what's running on the Frame: Steam
|
||||
games, sideloaded titles, non-Steam shortcuts and Android apps. A game's library
|
||||
tile, a sideloaded title's ⋯ menu and the Control Center's **Force quit a game**
|
||||
card have **Force quit…** too. Each asks first ("Unsaved progress will be
|
||||
lost"); from a game's own menu it checks that the game is running and says so if
|
||||
it isn't. SteamVR (app 250820, and its beta 1009850) is never listed or quit:
|
||||
on the Frame it shows the headset view, so quitting it isn't a way out of a game.
|
||||
|
||||
`ui/frame_running.py` does the work on the Frame, piped over SSH with
|
||||
`frame_steam.py` as its DevTools client:
|
||||
|
||||
```sh
|
||||
scp ui/frame_steam.py ui/frame_running.py frame:/tmp/
|
||||
ssh frame 'python3 /tmp/frame_running.py list' # {"running": [{appid, gameid, name, kind, ...}]}
|
||||
ssh frame 'python3 /tmp/frame_running.py quit 620' # {"name", "method", "message"}
|
||||
```
|
||||
|
||||
1. **What's running.** Steam starts every game, shortcut and devkit title under
|
||||
its `reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper
|
||||
stays the parent of the game's processes. So a running game is one of your
|
||||
own processes whose program is named `reaper`, with `SteamLaunch` and an
|
||||
`AppId=` before `--`; the game is that reaper's process tree. Steam's own list
|
||||
(`SteamUIStore.RunningApps`) and Android apps whose Lepton container is up
|
||||
(`podman ps`) are added. Names come from `appStore`, else the app manifest,
|
||||
the Android app's `meta.json` or the devkit title's folder.
|
||||
2. **Steam first.** `SteamClient.Apps.TerminateApp(<game id>, false)`, the
|
||||
call Steam's own Stop makes for Android apps ([vr-apks.md](vr-apks.md)). The
|
||||
game id is the appid, or `(appid << 32) | 0x02000000` for a non-Steam
|
||||
shortcut. An Android app's container is also stopped, as the Apps tab's Stop
|
||||
does (`podman stop -t 5 lepton-steamlaunch-<instance>`).
|
||||
3. **Then signals.** If the game's processes are still there after 5 seconds,
|
||||
they get SIGTERM, deepest first; whatever is left 3 seconds later gets
|
||||
SIGKILL, the reaper last, so nothing it holds is orphaned unsignalled. A pid
|
||||
is only signalled while its start time still matches, so a reused pid is safe.
|
||||
|
||||
Nothing is found by name. These are never signalled, even inside a game's tree:
|
||||
init, the helper itself and every process above it (the SSH session), other
|
||||
users' processes, and anything named `steam`, `steamwebhelper`, `gamescope`,
|
||||
`gamescope-wl`, `gamescopereaper`, `gamescope-session`, `sshd`, `sshd-session`,
|
||||
`ssh-agent`, `systemd`, `dbus-daemon`, `dbus-broker`, `pipewire`,
|
||||
`pipewire-pulse`, `wireplumber`, `plasmashell`, `kwin_wayland`, `kwin_x11`,
|
||||
`Xwayland`, `sddm`, `vrserver`, `vrcompositor`, `vrmonitor`, `vrdashboard`,
|
||||
`vrwebhelper` or `vrstartup`, with everything under them.
|
||||
|
||||
**Not tested on a headset.** The reaper command line is how Steam launches games
|
||||
on Linux desktops and the Steam Deck, **inferred** for the Frame.
|
||||
`SteamUIStore.RunningApps` comes from Steam's client code as plugin loaders use
|
||||
it and hasn't been seen on the Frame's build; without it, the reapers alone
|
||||
decide. `TerminateApp` with a shortcut's game id is the call Steam's Stop was
|
||||
seen making for an Android app (docs/vr-apks.md); for Steam games it is
|
||||
**inferred**. Unit tests (`tests/test_force_quit.py`) cover the parsing, the
|
||||
process selection and the escalation; the fake Frame launches its games under a
|
||||
stand-in `reaper` so `tests/e2e/test_running.py` force quits a real process tree.
|
||||
|
||||
## Not yet checked
|
||||
|
||||
- Free-to-play games: `steam://install` should stop at state 3 (free license)
|
||||
|
||||
@@ -144,7 +144,7 @@ capture it.
|
||||
|
||||
## Input: type and point in the Frame from the Mac or iPhone
|
||||
|
||||
**Built: Home → Keyboard and trackpad**, in every version of Frame Control
|
||||
**Built: Home → Type on the headset** (or press `T`), in every version of Frame Control
|
||||
(Mac, Windows, Linux, iPhone and iPad), with nothing to install on the device
|
||||
you're holding. On a phone the panel is a trackpad (drag to move, tap to click,
|
||||
two fingers to scroll, two-finger tap to right-click) plus a text field that
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
# Tailscale: use the Frame from anywhere
|
||||
# Tailscale: advanced remote-access setup
|
||||
|
||||
With Tailscale on the Frame, the `frame` SSH alias works off the home LAN, and
|
||||
so does everything built on it: Frame Control, the scripts and the Mac app.
|
||||
Tailscale is intended to make the `frame` SSH alias available off the home LAN,
|
||||
including to Frame Control and the scripts. Off-LAN access has not yet been
|
||||
verified; the checks below cover the direct LAN path only. Read the exposure
|
||||
section before enabling it: services normally restricted to loopback become
|
||||
reachable by other devices on your tailnet.
|
||||
When the Mac and the Frame are on the same network, Tailscale connects them
|
||||
directly, so there's no relay in the way (`tailscale ping frame` → `via
|
||||
192.168.1.50:41641`, 20 ms).
|
||||
|
||||
These are source-checkout commands for macOS, not packaged-app menu actions
|
||||
or Windows PowerShell commands. The connection script currently uses macOS
|
||||
netcat options; this route is not supported on Linux. Install Git, OpenSSH,
|
||||
zsh, Python 3 and curl on your Mac; the desktop app's bundled Python is not
|
||||
available to these scripts. Then obtain the scripts:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/saphid/frame-control.git
|
||||
cd frame-control
|
||||
```
|
||||
|
||||
Enable Developer Mode on the awake headset and establish working SSH access
|
||||
first using the [connection guide](frame-control.md). Run from the checkout:
|
||||
|
||||
```sh
|
||||
scripts/tailscale-on-frame.sh # install or update, then approve the login URL
|
||||
scripts/connect.sh frame.<tailnet>.ts.net # point the alias at Tailscale (the script prints this)
|
||||
|
||||
@@ -12,6 +12,12 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
|
||||
## Unit tests
|
||||
|
||||
The read-only power/heat sampler has fixture and transport tests in
|
||||
`tests/test_power_sample.py`. Run
|
||||
`python3 -m unittest discover -s tests -p test_power_sample.py -v`.
|
||||
See [power-and-heat.md](power-and-heat.md) for sampling, smoke-lock behavior and
|
||||
real-device verification limits.
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
@@ -44,6 +50,20 @@ them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
## The fake Frame
|
||||
|
||||
Frame doctor has focused offline tests in `tests/test_frame_doctor.py` and
|
||||
fake-Frame fault tests in `tests/e2e/test_doctor.py`:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_frame_doctor.py -v
|
||||
scripts/e2e.sh test_doctor
|
||||
```
|
||||
|
||||
They cover privacy filtering, host-key preservation, restart safety and missing
|
||||
journal evidence, plus asleep, full-title-storage, SSH/devkit-off and missing-key
|
||||
faults. The fake's doctor-only `systemctl show` responses assume stable restart
|
||||
counters and inactive keep-awake; unsupported queries fail. These tests do not
|
||||
exercise a real SteamOS user manager or touch a headset.
|
||||
|
||||
`tests/fakeframe/` builds a container that stands in for the headset, and a
|
||||
second one for the computer Frame Control runs on. `scripts/e2e.sh` builds
|
||||
both, starts them with `docker compose`, runs `tests/e2e` in the host
|
||||
@@ -76,8 +96,8 @@ its guards want.
|
||||
| `sshd` with key and password logins, `rsync`, `python3` | Real |
|
||||
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
|
||||
| Valve's devkit-utils, copied over by Frame Control itself | Real |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs. Games it really runs start under a stand-in `reaper SteamLaunch AppId=<id> -- <command>` (bash with that command line), so force quit finds a real process tree | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF. `SteamUIStore.RunningApps` lists launches that haven't exited; `TerminateApp` ends only launches that never ran a process, so a running program needs Frame Control's signals | The JS engine is real; the objects are fake |
|
||||
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
|
||||
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
|
||||
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
|
||||
@@ -164,6 +184,43 @@ does). A cleanup that fails counts as a failed step. Results go to
|
||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||
isn't reachable.
|
||||
|
||||
Smoke process cleanup runs before and after title removal and during the
|
||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
|
||||
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
|
||||
an exact title directory in `/proc` cwd/exe or an individual absolute command
|
||||
argument; similarly named folders and shell command substrings do not count.
|
||||
Deleted cwd/exe paths still count, so a process left after file removal can be
|
||||
found. It sends SIGTERM, waits up to three seconds, then uses SIGKILL with
|
||||
another three-second bound, rescanning for survivors. PID handles prevent PID
|
||||
reuse from signalling another process. Linux Python 3.9+ with pidfd support
|
||||
is required; missing support, signal errors and survivors fail the
|
||||
cleanup step. Stopped PIDs are recorded in the results JSON.
|
||||
If the initial leftover cleanup fails, the run skips installs and pairing and
|
||||
records the failure instead of overwriting an active test title.
|
||||
|
||||
This deliberately does not stop shared Wine/Proton services by name or prefix,
|
||||
Steam, SteamVR, process groups or arbitrary descendants. A process that drops
|
||||
all title-path evidence cannot safely be attributed and is outside this
|
||||
check. Unreadable `/proc` fields provide no ownership evidence; other readable
|
||||
fields are still checked, and a wholly unattributable process is left alone.
|
||||
Real Frame verification, 2026-10-24, SteamOS build `20261015.6141207`: the
|
||||
fixed Python smoke entry point passed all 13 steps under the shared device lock.
|
||||
The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
|
||||
remove step stopped five test-owned wrapper/Proton PIDs before removing the
|
||||
title; the following process sweep found none. The final cleanup found no
|
||||
test titles or processes, the test desktop was closed, and the battery
|
||||
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
|
||||
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
|
||||
the same entry point instead.
|
||||
The local regression suite runs real Linux processes in temporary fake title
|
||||
directories (including deleted folders and a TERM-resistant wrapper); it checks
|
||||
exit, escalation and survival of unrelated processes without SSH or a headset:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
|
||||
```
|
||||
|
||||
`--pair` asks the devkit service to pair a new RSA key, which needs someone
|
||||
in the headset to open **Settings → Developer → Pair new host** and approve
|
||||
it; the key is checked and then taken out of `authorized_keys` again.
|
||||
@@ -189,20 +246,34 @@ Steam accepts.
|
||||
|
||||
## PC panel host tests
|
||||
|
||||
`tests/test_pcview.py` checks source-bound tickets, reconnect/Stop revocation,
|
||||
input release, source-coordinate mapping and use of the existing panel
|
||||
launcher. The `PC host libraries` workflow builds the bundled native adapter
|
||||
on Windows, Ubuntu x64 and Ubuntu ARM64, then runs a real x264 synthetic
|
||||
stream through the HTTP/WebSocket agent. `FRAME_PC_REQUIRE_NATIVE=1` makes a
|
||||
missing native bundle fail CI instead of skipping. These tests do not grant a
|
||||
portal dialog, capture a desktop, exercise a hardware encoder or validate
|
||||
laser alignment. See [PC host evidence](pc-in-headset.md#evidence).
|
||||
PC in the headset (Experimental, Windows only). `tests/test_pcview.py` checks
|
||||
that only Windows gets the PC host and that it starts nothing until turned on,
|
||||
the Tools link and card wording, source-bound tickets, reconnect/Stop
|
||||
revocation, input release, source-coordinate mapping and use of the existing
|
||||
panel launcher. `tests/test_pc_controller.py` checks the C rate controller
|
||||
against a frozen trace of the Mac's Swift one.
|
||||
|
||||
The `PC host libraries` workflow builds the native adapter on Windows (and
|
||||
the unshipped Linux adapter on Ubuntu x64 and ARM64), then runs a real x264
|
||||
synthetic stream through the HTTP/WebSocket agent. x264 is GPL, so only these
|
||||
CI builds include it (`desktop/build.py --with-x264`); the release workflow
|
||||
tests the installer's own bundle with its bundled Python and checks x264 isn't
|
||||
in it. `FRAME_PC_REQUIRE_NATIVE=1` and `FRAME_PC_REQUIRE_X264=1` make a missing
|
||||
bundle or encoder fail CI instead of skipping.
|
||||
|
||||
These automated CI checks do not capture a desktop, exercise Media Foundation
|
||||
or a GPU, or check laser alignment. A [Windows test VM](#windows-test-vm) run
|
||||
on 2026-10-24 captured its actual desktop with Desktop Duplication, encoded
|
||||
with Media Foundation and streamed to a real Frame. It found MSYS2 disables
|
||||
WGC: an element name alone does not prove its optional capture properties
|
||||
exist. Native property-probe tests and source-selection regressions now cover
|
||||
that boundary. [Dated evidence and limits](evidence/windows-vm-2026-10-24.md);
|
||||
[what's verified](pc-in-headset.md#whats-verified).
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
depends on the Windows desktop (Remote Desktop, the installer, the bundled
|
||||
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
|
||||
Windows CI runs the unit tests and checks installer contents. Desktop capture,
|
||||
installer launch and interactive behavior need a Windows machine. This is a Windows 11 VM in a
|
||||
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
|
||||
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
|
||||
|
||||
@@ -222,7 +293,14 @@ Setting it up, once, on the Linux machine:
|
||||
`~/.ssh/id_ed25519_winvm` on the Mac) in
|
||||
`C:\ProgramData\ssh\administrators_authorized_keys`.
|
||||
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
|
||||
and the tests.
|
||||
and the tests. Inspect free memory/disk, KVM access and existing VMs before
|
||||
starting; leave other VMs alone. The first install can take tens of minutes
|
||||
(the 2026-10-24 run took about 16 minutes to reach the desktop), rather than
|
||||
the 1–2 minute startup of an already installed guest.
|
||||
- For PC streaming, check the actual bundled capture properties. MSYS2's
|
||||
GStreamer build disables WGC: this app falls back to Desktop Duplication for
|
||||
whole screens and explains why individual windows are unavailable. A VM
|
||||
capture test does not prove physical GPU encoding or laser alignment.
|
||||
|
||||
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
|
||||
|
||||
@@ -278,6 +356,17 @@ connect it to the Frame and follow Remote Desktop through to the Frame's
|
||||
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
|
||||
`frame-key` left `authorized_keys` byte-for-byte as it was.
|
||||
|
||||
## Force quit
|
||||
|
||||
`tests/test_force_quit.py` feeds `ui/frame_running.py` made-up process tables:
|
||||
the `reaper SteamLaunch AppId=` parsing, naming from Steam, manifests, Android
|
||||
`meta.json` and devkit folders, and a Frame's worth of Steam, gamescope, SteamVR,
|
||||
sshd and session processes that must never be signalled. Processes that ignore
|
||||
SIGTERM, survive SIGKILL or exit and have their pid reused check the escalation.
|
||||
The Control Center's confirmation, duplicate and headset-switch handling run in
|
||||
`tests/control_center.cjs`. `tests/e2e/test_running.py` force quits real process
|
||||
trees on the fake Frame. None of it has run on a headset.
|
||||
|
||||
## Owned media player
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
@@ -325,3 +414,47 @@ Chromium rendering and the same helper through HTTP. Close the switcher after
|
||||
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
|
||||
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
|
||||
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
|
||||
|
||||
## Backup and recovery kit
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script `ui/frame_backup_remote.py`
|
||||
for real against a temporary folder laid out like the Frame's home, with only
|
||||
SSH faked: path selection and the secrets it must never copy, the manifest and
|
||||
checksums, restore checks after a reset, restore writing only its own files,
|
||||
unsafe archives and backup names, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
|
||||
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
|
||||
A full backup and an identical-file Proton save restore were checked on a
|
||||
real Frame running build `20261015.6141207`; an after-reset restore and
|
||||
Android data restore remain unverified. See the linked device evidence.
|
||||
|
||||
## Game storage
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_storage.py -v` exercises the
|
||||
read-only metadata probe, guarded HTTP endpoint and Node renderer/navigation
|
||||
regressions. All headset reads are temporary fixtures or mocked SSH. See
|
||||
[game storage](game-storage.md#verification-and-limits) for coverage and device
|
||||
verification limits. This suite does not move games or contact a headset.
|
||||
|
||||
## Battery monitoring
|
||||
|
||||
`tests/test_battery.py` covers real file parsing with temporary sysfs-like
|
||||
files, source preference, missing/invalid sensors and permission errors.
|
||||
`tests/control_center.cjs` covers charging states and optional health fields,
|
||||
alongside existing stale/offline handling. No headset is used. See
|
||||
[battery monitoring](battery-health.md) for sources, units and limitations.
|
||||
|
||||
## Host-key recovery presentation
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
|
||||
connection/banner/selection functions in Node and checks the recovery CSS guard.
|
||||
`test_link.py` covers connection failures and short probe reasons with fake SSH
|
||||
and local sockets. Neither suite uses a real headset.
|
||||
|
||||
Browser verification on 2026-10-24 used the current page's markup, styles and
|
||||
recovery renderers with synthetic changed-key state and two headsets at
|
||||
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
|
||||
rule, which the original source-only assertion missed. Check computed wrapping,
|
||||
the complete explanation, tooltip punctuation, Refresh bounds and selection
|
||||
after **Use this headset** when changing these styles. This fixture does not
|
||||
exercise Electron integration or a real SSH identity change.
|
||||
@@ -7,11 +7,13 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0CF0B365CE5974753E8C3C2B /* ScreenshotJournal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D48FCBB02F0F2212C7DE3C1 /* ScreenshotJournal.swift */; };
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
|
||||
41A697B9924018DA48F24A1F /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237D9AF04EEA257AB382F60E /* Keys.swift */; };
|
||||
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */; };
|
||||
46EF7595BE5B36664A11AEB5 /* NativeSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F1CAE943C2556A960890079 /* NativeSessionTests.swift */; };
|
||||
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */; };
|
||||
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24E1BCD4F69A24C7DEF02F /* RootView.swift */; };
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
|
||||
@@ -19,8 +21,12 @@
|
||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
|
||||
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */; };
|
||||
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
|
||||
C046F310BA857BCEC98BE00E /* ScreenshotDownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EBD10B73DF1E95F67A3B074 /* ScreenshotDownloadsView.swift */; };
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
||||
DFD89170E502260F005EEC73 /* ScreenshotDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07617FD9B214626526FB1F03 /* ScreenshotDownloads.swift */; };
|
||||
E2FD56BB5BC1D9AF5CDBF210 /* ScreenshotSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8610920C7D797E5963CF88 /* ScreenshotSyncTests.swift */; };
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
|
||||
F0ECE06D5DD29F19CC41FDFB /* NativeSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E8BE9A8C421AD40A0F4105B /* NativeSessionState.swift */; };
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -35,17 +41,23 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
07617FD9B214626526FB1F03 /* ScreenshotDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotDownloads.swift; sourceTree = "<group>"; };
|
||||
0D48FCBB02F0F2212C7DE3C1 /* ScreenshotJournal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotJournal.swift; sourceTree = "<group>"; };
|
||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
|
||||
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
|
||||
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = "<group>"; };
|
||||
5EBD10B73DF1E95F67A3B074 /* ScreenshotDownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotDownloadsView.swift; sourceTree = "<group>"; };
|
||||
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
|
||||
8F1CAE943C2556A960890079 /* NativeSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionTests.swift; sourceTree = "<group>"; };
|
||||
8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlApp.swift; sourceTree = "<group>"; };
|
||||
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||
9E8BE9A8C421AD40A0F4105B /* NativeSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionState.swift; sourceTree = "<group>"; };
|
||||
A7F6ED116569D0ABABF6ED65 /* PortForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortForwarder.swift; sourceTree = "<group>"; };
|
||||
AB8610920C7D797E5963CF88 /* ScreenshotSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotSyncTests.swift; sourceTree = "<group>"; };
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
|
||||
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
|
||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
||||
@@ -102,6 +114,9 @@
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */,
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
|
||||
9E8BE9A8C421AD40A0F4105B /* NativeSessionState.swift */,
|
||||
07617FD9B214626526FB1F03 /* ScreenshotDownloads.swift */,
|
||||
0D48FCBB02F0F2212C7DE3C1 /* ScreenshotJournal.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
@@ -111,6 +126,8 @@
|
||||
children = (
|
||||
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||
8F1CAE943C2556A960890079 /* NativeSessionTests.swift */,
|
||||
AB8610920C7D797E5963CF88 /* ScreenshotSyncTests.swift */,
|
||||
);
|
||||
path = FrameControlTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -119,6 +136,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9B24E1BCD4F69A24C7DEF02F /* RootView.swift */,
|
||||
5EBD10B73DF1E95F67A3B074 /* ScreenshotDownloadsView.swift */,
|
||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
@@ -266,8 +284,12 @@
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
|
||||
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
|
||||
41A697B9924018DA48F24A1F /* Keys.swift in Sources */,
|
||||
F0ECE06D5DD29F19CC41FDFB /* NativeSessionState.swift in Sources */,
|
||||
476D8858DC2C9E6616B084BC /* PortForwarder.swift in Sources */,
|
||||
765661DBC0E6798A27CC60DB /* RootView.swift in Sources */,
|
||||
DFD89170E502260F005EEC73 /* ScreenshotDownloads.swift in Sources */,
|
||||
C046F310BA857BCEC98BE00E /* ScreenshotDownloadsView.swift in Sources */,
|
||||
0CF0B365CE5974753E8C3C2B /* ScreenshotJournal.swift in Sources */,
|
||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */,
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */,
|
||||
);
|
||||
@@ -279,6 +301,8 @@
|
||||
files = (
|
||||
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||
46EF7595BE5B36664A11AEB5 /* NativeSessionTests.swift in Sources */,
|
||||
E2FD56BB5BC1D9AF5CDBF210 /* ScreenshotSyncTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,22 @@ final class AppModel: ObservableObject {
|
||||
/// Install links that arrived before the page was ready for them.
|
||||
@Published var pendingInstallLinks: [InstallLink] = []
|
||||
|
||||
/// Native recovery remains reachable even when WebKit cannot draw a page.
|
||||
@Published private(set) var webRecoveryMessage: String?
|
||||
@Published private(set) var webReloadID = 0
|
||||
let screenshots = ScreenshotDownloads()
|
||||
@Published var showScreenshotDownloads = false
|
||||
|
||||
func webContentFailed(at url: URL, message: String) {
|
||||
guard phase == .ready(url) else { return }
|
||||
webRecoveryMessage = message
|
||||
}
|
||||
|
||||
func retryWebContent() {
|
||||
webRecoveryMessage = nil
|
||||
webReloadID += 1
|
||||
}
|
||||
|
||||
private var link: FrameLink?
|
||||
private var server: HeadsetServer?
|
||||
private var forwarder: PortForwarder?
|
||||
@@ -31,6 +47,7 @@ final class AppModel: ObservableObject {
|
||||
let saved = UserDefaults.standard.data(forKey: Self.settingsKey).flatMap { try? JSONDecoder().decode(FrameSettings.self, from: $0) }
|
||||
settings = saved
|
||||
phase = saved == nil ? .setup : .connecting("Connecting")
|
||||
if let identity = UserDefaults.standard.string(forKey: Self.hostKeyKey) { screenshots.load(identity: identity) }
|
||||
}
|
||||
|
||||
var deviceName: String { UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" }
|
||||
@@ -99,18 +116,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
/// "host", "host:port" or "[v6]:port", plus a user name.
|
||||
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
|
||||
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
|
||||
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
|
||||
let rest = target.host[target.host.index(after: close)...]
|
||||
if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port }
|
||||
target.host = String(target.host[target.host.index(after: target.host.startIndex)..<close])
|
||||
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
|
||||
let port = Int(target.host[target.host.index(after: colon)...]) {
|
||||
target.port = port
|
||||
target.host = String(target.host[..<colon])
|
||||
}
|
||||
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
|
||||
return target
|
||||
FrameSettings.parse(host: host, user: user)
|
||||
}
|
||||
|
||||
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
|
||||
@@ -143,8 +149,10 @@ final class AppModel: ObservableObject {
|
||||
/// Every connection attempt has a number; anything that finishes after a newer
|
||||
/// attempt started (or the user went back to setup) closes what it made and stops.
|
||||
private func invalidate() {
|
||||
screenshots.stop()
|
||||
attempt += 1
|
||||
retrying = false
|
||||
webRecoveryMessage = nil
|
||||
}
|
||||
|
||||
/// quiet: a background retry, which leaves the failure screen up until it works.
|
||||
@@ -196,6 +204,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
phase = .ready(URL(string: page)!)
|
||||
startScreenshots()
|
||||
// Runs at once if it stopped in the moment since the check above.
|
||||
server.whenExited { [weak self] tail in
|
||||
Task { @MainActor in self?.lost(mine, "Frame Control on the headset stopped. \(tail.suffix(200))") }
|
||||
@@ -250,6 +259,7 @@ final class AppModel: ObservableObject {
|
||||
/// Called when the app comes back to the foreground: iOS may have dropped the
|
||||
/// connection, or left it looking open, while it was in the background.
|
||||
func resume() {
|
||||
startScreenshots()
|
||||
switch phase {
|
||||
case .ready:
|
||||
let mine = attempt
|
||||
@@ -280,6 +290,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func teardown() async {
|
||||
screenshots.stop()
|
||||
forwarder?.stop()
|
||||
forwarder = nil
|
||||
server = nil
|
||||
@@ -294,4 +305,11 @@ final class AppModel: ObservableObject {
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func suspend() { screenshots.stop() }
|
||||
|
||||
private func startScreenshots() {
|
||||
guard case .ready(let url) = phase, let hostKey else { return }
|
||||
screenshots.start(url: url, identity: hostKey)
|
||||
}
|
||||
}
|
||||