Compare commits

..
Author SHA1 Message Date
SAPHID 342bf78b05 Merge main and preserve tracking and Windows test guidance 2026-10-18 00:06:01 +00:00
SAPHID b51fab9872 chore: resolve conflicts with main 2026-10-17 22:10:01 +00:00
saphidandClaude Sonnet 5.5 8fb00b263c fix(tracking): review 7 - rescan on cleanup, non-finite Health values
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:29:56 +10:00
saphidandClaude Sonnet 5.5 4937caf310 fix(tracking): don't call an unworn eye-camera artifact a pulse
Unworn Frame runs gave a steady 'clear' 90-96 BPM. pulse now reads the
proximity sensor and refuses to report when the headset is not worn.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:20:23 +10:00
saphidandClaude Sonnet 5.5 128e7a4c94 fix(tracking): never signal the eye-camera capture; finish cleanup exhaustively
Terminating eyetracking --calib left the DSP eye camera stuck streaming on the
Frame. Capture now lets the bounded run end by itself, deleting images
meanwhile, and only kills as a last resort. Also hardens Ctrl-C cleanup and
heart-check error handling.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:15:24 +10:00
saphidandClaude Opus 5.5 1a439f05c2 fix(tracking): make eye-image cleanup exhaustive and confirm capture ownership
Follow-up review findings: remove() now tries every capture directory and
reports any it could not delete; each cleanup step runs even if an earlier
one fails; the directory the capture tool reports (once its output is
flushed) must match the one we reduced, and is always removed. heart-check
keeps readings with an unrecognised flag and reports unreadable references
without a traceback.

Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:07:33 +10:00
saphidandClaude Opus 5.5 016d2b8c2d fix(tracking): address independent review of the pulse experiment
- Watch for a second capture directory on every poll; stop and remove every
  directory that appeared, and fail loudly if an eye image can't be deleted.
- Start the worker pool inside the cleanup block.
- Flat patches no longer rank first (zero-power SNR is 0, not infinity).
- Align eyes to the truly nearest frame.
- Keep patch grids as float arrays (a 300 s run no longer needs ~0.4 GB).
- heart-check: tolerate unusual flags, close the Health archive, give a clear
  error when export.xml is missing, and build the lookup index once.

Found by a SWE-2 Max read-only review. Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:59:13 +10:00
saphidandClaude Opus 5.5 42ec68ed51 fix(tracking): reduce eye images while capturing, with a backlog cap
The capture tool's stdout is block-buffered, so waiting for its directory name
left every image on disk until the capture ended (3,554 PNGs in a 20 s run on
the Frame). Detect the new capture directory instead, decode in three worker
processes, and stop the capture if more than 900 images wait. On the Frame a
30 s run now peaks at 7 images on disk.

Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:44:51 +10:00
saphidandClaude Opus 5.5 fa49fab5bf feat(tracking): experimental eye-camera pulse estimate and heart-rate comparison tool
Adds 'pulse', which captures the IR eye cameras through SteamVR's own
eyetracking --calib mode, reduces each image to patch averages and deletes it
immediately, then estimates pulse from skin brightness. Adds heart-check.py to
show OSC readings live and compare recordings with an Apple Health export or
CSV, and a synthetic Mac BLE strap for relay tests.

Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:15:05 +10:00
saphid ce9e476ec7 feat(tracking): add local OpenXR OSC and BlueZ heart-rate tools 2026-09-28 22:33:02 +10:00
259 changed files with 3827 additions and 33058 deletions

No files matched your search

-1
View File
@@ -22,7 +22,6 @@ 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` |
+1 -1
View File
@@ -7,7 +7,7 @@ body:
value: |
**Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md).
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. The [website feedback form](https://framecontrol.fun/feedback/) skips that queue.
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. The [website feedback form](https://frame-control.pages.dev/feedback/) skips that queue.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
+1 -1
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Feedback form (no GitHub account needed, skips the queue)
url: https://framecontrol.fun/feedback/
url: https://frame-control.pages.dev/feedback/
about: Bugs, ideas and questions from the website become issues here without being auto-closed.
-22
View File
@@ -1,22 +0,0 @@
name: Android phone
on:
pull_request:
paths: ['android/**', 'ui/**', 'scripts/**', 'frame/**', 'apk-catalog/**', 'ios/scripts/make_frame_bundle.py', '.github/workflows/android.yml']
push:
branches: [main]
paths: ['android/**', 'ui/**', 'scripts/**', 'frame/**', 'apk-catalog/**', 'ios/scripts/make_frame_bundle.py', '.github/workflows/android.yml']
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3
- uses: gradle/actions/setup-gradle@v4
- run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- run: ./android/gradlew -p android assembleDebug assembleDebugAndroidTest testDebugUnitTest lintDebug --no-daemon
+2 -2
View File
@@ -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: App tests
run: node --test app/test/*.test.js
- name: Updater tests
run: node --test app/test/updater.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
+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
'',
`Maintainers review auto-closed issues regularly and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'Just want to report a bug or share an idea? The [website feedback form](https://framecontrol.fun/feedback/) skips this queue.',
'Just want to report a bug or share an idea? The [website feedback form](https://frame-control.pages.dev/feedback/) skips this queue.',
'',
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open. The command must be at the start of the reply (optionally after one or more `@username` mentions) or at the end.',
'',
-53
View File
@@ -1,53 +0,0 @@
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/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
workflow_dispatch:
jobs:
linux:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Native libraries
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 --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
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
# 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 --with-x264
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
+2 -37
View File
@@ -3,14 +3,14 @@ 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
# Windows PC helper libraries, the macOS helper, or this workflow) build the same installers as
# macOS helper it compiles, 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:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
@@ -75,28 +75,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "24"
# 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
# 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
- name: Build
working-directory: app
shell: bash
@@ -117,19 +95,6 @@ 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
-10
View File
@@ -7,15 +7,5 @@ compat-db/.lakebed/
tests/smoke/results/
mac/bin/
# Shared desktop streaming controller build products
desktop/controller.dll
desktop/controller.dylib
desktop/controller.so
desktop/*.obj
desktop/*.lib
desktop/*.exp
desktop/bundle/
app/build/desktop/
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
frame/kdeconnect/packages/
-55
View File
@@ -1,55 +0,0 @@
# 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.
+10 -39
View File
@@ -12,7 +12,7 @@ See what the headset sees, install games and Android apps, move files and text a
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE)
[**Get started**](https://framecontrol.fun) · [**Download**](https://framecontrol.fun/#download) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
<br>
@@ -44,16 +44,13 @@ 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, search the store, and force quit a game that stops responding.
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
</td>
<td valign="top">
@@ -66,17 +63,17 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
<tr>
<td valign="top">
**📁 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.
**📁 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.
</td>
<td valign="top">
**📸 Screenshots and recordings**<br>
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record 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)).
**📸 Screenshots**<br>
Browse the shots you take in the headset and save them to your Pictures folder.
**⌨️ 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.
**⌨️ 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.
</td>
</tr>
@@ -92,14 +89,6 @@ 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>
@@ -116,17 +105,6 @@ already ships (sideloading a game copies Valve's own devkit scripts to
## Install
**New here? [Get Frame Control and follow the setup guide on the website](https://framecontrol.fun/#download).**
Install it on your **computer, not the headset**. You don't need to clone this
repository or install Python. Download for your computer, install and open the
app, then choose **Set Up Connection** with the Frame awake on the same network.
- **macOS:** open the `.dmg`, drag Frame Control to Applications, then open it there.
- **Windows:** run the installer and launch Frame Control from Start, or extract the portable `.zip` and run `Frame Control.exe`.
- **Linux:** make the AppImage executable and open it, or install the `.deb` and launch Frame Control.
If the app is blocked, see the first-launch notes below before proceeding.
| | Download | Needs |
|---|---|---|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
@@ -137,10 +115,6 @@ If the app is blocked, see the first-launch notes below before proceeding.
**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
@@ -215,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
This is a first public test, so reports are really useful, especially from
Windows and Linux. The quickest way is **Report a problem** in the app (the
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
warning-sign button at the top, or **Help → Report a Problem…**). It adds
diagnostics with personal details removed, shows you exactly what's included,
and sends it privately to the maintainer; nothing is published. Without the app,
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
@@ -244,13 +218,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) | 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 |
| [Eye tracking and heart rate](docs/tracking.md) | Our OpenXR → OSC bridge, BlueZ heart-rate panel and optional local session log; SlimeVR feasibility notes |
| [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 |
| [Open questions](docs/open-questions.md) | What's still unchecked |
@@ -294,5 +267,3 @@ 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.
+1 -3
View File
@@ -10,8 +10,6 @@ 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. On Windows, Frame Control's own
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
it doesn't link to it or include its code.
-5
View File
@@ -1,5 +0,0 @@
.gradle/
**/build/
local.properties
*.keystore
*.jks
-35
View File
@@ -1,35 +0,0 @@
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'
}
@@ -1,686 +0,0 @@
package com.saphid.framecontrol;
import static org.junit.Assert.*;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.*;
import android.database.Cursor;
import android.graphics.*;
import android.net.Uri;
import android.provider.MediaStore;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.*;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class ScreenshotDeviceTest {
private Context context;
private final Set<Uri> cleanup = new LinkedHashSet<>();
@Before
public void setup() {
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
}
@After
public void cleanup() {
for (Uri uri : cleanup) context.getContentResolver().delete(uri, null, null);
}
/** Fixture Steam accounts exist on the simulated headset from the first poll. */
private static SyncEngine.Listing listing(SyncEngine.Shot... shots) {
return new SyncEngine.Listing(Set.of("123", "999999999991"), List.of(shots));
}
private String name(String headset, SyncEngine.Shot shot) throws Exception {
String file = shot.id.substring(shot.id.lastIndexOf('/') + 1);
int dot = file.lastIndexOf('.');
return file.substring(0, dot)
+ "_"
+ FrameConnection.hash(headset + "/" + shot.id).substring(0, 20)
+ file.substring(dot);
}
private Uri row(String headset, SyncEngine.Shot shot) throws Exception {
Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
@SuppressWarnings("deprecation")
Uri includingPending = MediaStore.setIncludePending(collection);
try (Cursor c =
context
.getContentResolver()
.query(
includingPending,
new String[] {"_id"},
MediaStore.Images.Media.DISPLAY_NAME
+ "=? AND "
+ MediaStore.Images.Media.RELATIVE_PATH
+ "=?",
new String[] {name(headset, shot), "Pictures/Frame Control/"},
null)) {
if (c != null && c.moveToFirst()) {
Uri uri = ContentUris.withAppendedId(collection, c.getLong(0));
cleanup.add(uri);
assertFalse("Duplicate Gallery row", c.moveToNext());
return uri;
}
return null;
}
}
@Test
public void actualGalleryOriginalAndAppOnlySurviveRelaunch() throws Exception {
String headset = "instrumented-" + UUID.randomUUID();
File root = new File(context.getFilesDir(), "instrumented/" + headset);
Object gate = new Object();
Bitmap bitmap = Bitmap.createBitmap(48, 32, Bitmap.Config.ARGB_8888);
bitmap.eraseColor(Color.rgb(25, 184, 143));
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes);
bitmap.recycle();
byte[] png = bytes.toByteArray();
SyncEngine.Transport transport =
new SyncEngine.Transport() {
public void download(SyncEngine.Shot shot, File target) throws Exception {
Files.write(target.toPath(), png);
}
public boolean validImage(File f) {
Bitmap b = BitmapFactory.decodeFile(f.getPath());
if (b == null) return false;
b.recycle();
return true;
}
};
GalleryExporter exporter = new GalleryExporter(context, headset, gate);
SyncEngine engine = new SyncEngine(root, true);
engine.reconcile(listing(), transport, exporter);
SyncEngine.Shot shot =
new SyncEngine.Shot("999999999991/108/20261024000000_1.png", png.length, 1792800000);
engine.reconcile(listing(shot), transport, exporter);
assertNull(row(headset, shot));
engine.reconcile(listing(shot), transport, exporter);
Uri saved = row(headset, shot);
assertNotNull(saved);
try (InputStream in = context.getContentResolver().openInputStream(saved)) {
assertArrayEquals(png, read(in));
}
assertEquals("saved", engine.items().get(0).destination);
engine = new SyncEngine(root, true);
engine.reconcile(listing(shot), transport, exporter);
assertEquals(saved, row(headset, shot));
engine.setGallery(false);
SyncEngine.Shot second =
new SyncEngine.Shot("999999999991/108/20261024000001_1.png", png.length, 1792800001);
engine.reconcile(listing(shot, second), transport, exporter);
engine.reconcile(listing(shot, second), transport, exporter);
engine.setGallery(true);
engine = new SyncEngine(root, true);
engine.reconcile(listing(shot, second), transport, exporter);
assertNull(row(headset, second));
assertTrue(
engine.items().stream()
.anyMatch(
i ->
i.shot.id.equals(second.id)
&& i.destination.equals("appOnly")
&& i.file.exists()));
}
@Test
public void interruptedMediaStoreRowIsRewrittenWithoutDuplicate() throws Exception {
String headset = "pending-" + UUID.randomUUID();
SyncEngine.Shot shot =
new SyncEngine.Shot("999999999992/108/20261024000002_1.png", 4, 1792800002);
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, name(headset, shot));
values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/Frame Control/");
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
values.put(MediaStore.Images.Media.IS_PENDING, 1);
Uri pending =
context
.getContentResolver()
.insert(
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values);
assertNotNull(pending);
cleanup.add(pending);
File file = File.createTempFile("gallery-proof", ".png", context.getCacheDir());
byte[] original = {1, 2, 3, 4};
Files.write(file.toPath(), original);
GalleryExporter exporter = new GalleryExporter(context, headset, new Object());
assertTrue(exporter.export(shot, file, () -> true));
assertEquals(pending, row(headset, shot));
assertTrue(exporter.export(shot, file, () -> true));
assertEquals(pending, row(headset, shot));
try (InputStream in = context.getContentResolver().openInputStream(pending)) {
assertArrayEquals(original, read(in));
}
file.delete();
}
/**
* Stop/restart overlap: a stopped session's exporter is still writing its pending row when the
* next session exports the same screenshot. The old writer reads its original from a FIFO, so
* it stays mid-write, with the row open, until the test supplies the bytes. Without the
* process-wide export lock the next session publishes that row first and the old session's
* cancellation then deletes it, leaving a "saved" receipt with no Gallery image.
*/
@Test
public void stoppedSessionExportCannotDeleteNextSessionsGalleryImage() throws Exception {
String headset = "handoff-" + UUID.randomUUID();
Bitmap bitmap = Bitmap.createBitmap(16, 16, Bitmap.Config.ARGB_8888);
bitmap.eraseColor(Color.rgb(184, 25, 143));
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes);
bitmap.recycle();
byte[] png = bytes.toByteArray();
SyncEngine.Shot shot =
new SyncEngine.Shot("999999999993/108/20261026000000_1.png", png.length, 1792900000);
File original = File.createTempFile("handoff", ".png", context.getCacheDir());
Files.write(original.toPath(), png);
File fifo = new File(context.getCacheDir(), "handoff-" + UUID.randomUUID() + ".fifo");
Os.mkfifo(fifo.getPath(), 0600);
// Both sessions share one settings lock, as both use SyncService.SETTINGS_LOCK.
Object settings = new Object();
GalleryExporter stopped = new GalleryExporter(context, headset, settings);
GalleryExporter next = new GalleryExporter(context, headset, settings);
AtomicBoolean stoppedSessionCurrent = new AtomicBoolean(true);
AtomicReference<Object> stoppedResult = new AtomicReference<>();
AtomicReference<Object> nextResult = new AtomicReference<>();
Thread old =
exporting(stoppedResult, () -> stopped.export(shot, fifo, stoppedSessionCurrent::get));
Thread successor = null;
FileDescriptor writer = null;
try {
old.start();
writer = openWriter(fifo, old);
// The old export has inserted its pending row and waits for the original's bytes.
stoppedSessionCurrent.set(false); // Stop closed the old engine.
successor = exporting(nextResult, () -> next.export(shot, original, () -> true));
successor.start();
Thread waiting = successor;
// Fixed: the successor queues behind the old export. Former code: it publishes and ends.
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
while (waiting.isAlive() && !GalleryExporter.EXPORTS.hasQueuedThread(waiting)) {
assertTrue("The next session neither waited nor finished", System.nanoTime() < deadline);
Thread.sleep(10);
}
int written = Os.write(writer, png, 0, png.length);
assertEquals(png.length, written);
} finally {
// An exporter blocked opening the FIFO is not interruptible; a writer's EOF releases it.
if (writer == null) {
try {
writer = Os.open(fifo.getPath(), OsConstants.O_WRONLY | OsConstants.O_NONBLOCK, 0);
} catch (ErrnoException ignored) {
// No reader is waiting.
}
}
if (writer != null) Os.close(writer);
join(old);
if (successor != null) join(successor);
fifo.delete();
original.delete();
try {
row(headset, shot); // Registers whatever row a failed run left for cleanup.
} catch (Throwable ignored) {
// Cleanup only; the assertions below report the outcome.
}
}
assertEquals("The stopped session must not save", Boolean.FALSE, result(stoppedResult));
assertEquals("The next session must save", Boolean.TRUE, result(nextResult));
Uri saved = row(headset, shot);
assertNotNull("The next session's Gallery image must survive the old callback", saved);
@SuppressWarnings("deprecation")
Uri includingPending = MediaStore.setIncludePending(saved);
try (Cursor c =
context
.getContentResolver()
.query(
includingPending,
new String[] {MediaStore.Images.Media.IS_PENDING},
null,
null,
null)) {
assertTrue(c != null && c.moveToFirst());
assertEquals("The Gallery image must be published", 0, c.getInt(0));
}
try (InputStream in = context.getContentResolver().openInputStream(saved)) {
assertArrayEquals(png, read(in));
}
}
private interface Export {
boolean run() throws Exception;
}
private static Thread exporting(AtomicReference<Object> result, Export export) {
return new Thread(
() -> {
try {
result.set(export.run());
} catch (Throwable e) {
result.set(e);
}
},
"handoff-export");
}
/** Opens the FIFO's write end once the exporter has opened its read end; bounded. */
private static FileDescriptor openWriter(File fifo, Thread reader) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
while (true) {
try {
return Os.open(fifo.getPath(), OsConstants.O_WRONLY | OsConstants.O_NONBLOCK, 0);
} catch (ErrnoException e) {
if (e.errno != OsConstants.ENXIO) throw e; // ENXIO: no reader yet.
}
assertTrue("The old export ended before reading its original", reader.isAlive());
assertTrue("The old export never started reading", System.nanoTime() < deadline);
Thread.sleep(10);
}
}
private static void join(Thread thread) throws InterruptedException {
thread.join(TimeUnit.SECONDS.toMillis(60));
if (thread.isAlive()) thread.interrupt();
assertFalse("Export thread did not finish", thread.isAlive());
}
private static Object result(AtomicReference<Object> result) {
Object value = result.get();
if (value instanceof Throwable) throw new AssertionError("Export failed", (Throwable) value);
return value;
}
@Test
public void galleryOptOutLatchesPreviouslyPairedLibraries() throws Exception {
assertFalse("Instrumentation must own a stopped product", SyncService.running());
File files = Files.createTempDirectory(context.getCacheDir().toPath(), "libraries-").toFile();
String preferences = "library-test-" + UUID.randomUUID();
Context isolated =
new ContextWrapper(context) {
@Override
public File getFilesDir() {
return files;
}
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
return context.getSharedPreferences(preferences, mode);
}
};
try {
SyncEngine.Transport transfer =
new SyncEngine.Transport() {
public void download(SyncEngine.Shot shot, File target) throws Exception {
Files.write(target.toPath(), new byte[] {1, 2, 3, 4});
}
public boolean validImage(File file) {
return true;
}
};
SyncEngine.Exporter unavailable = (shot, file, eligible) -> false;
SyncEngine.Shot shot = new SyncEngine.Shot("123/108/20261024120000_1.png", 4, 100);
File a = SyncService.library(isolated, "headset-a");
File b = SyncService.library(isolated, "headset-b");
// A retained library still in the version 1 format (no account set), never polled since.
File legacy = SyncService.library(isolated, "headset-legacy");
assertTrue(legacy.mkdirs());
Files.write(
new File(legacy, "receipts.json").toPath(),
("{\"version\":1,\"initialized\":true,\"gallery\":true,\"records\":{\""
+ shot.id
+ "\":{\"shot\":{\"id\":\""
+ shot.id
+ "\",\"size\":4,\"time\":100},\"state\":\"queued\",\"observations\":2,"
+ "\"destination\":\"pending\"}}}")
.getBytes(StandardCharsets.UTF_8));
for (File root : List.of(a, b)) {
SyncEngine engine = new SyncEngine(root, true);
engine.reconcile(listing(), transfer, unavailable);
engine.reconcile(listing(shot), transfer, unavailable);
engine.reconcile(listing(shot), transfer, unavailable);
assertEquals("pending", engine.items().get(0).destination);
}
assertTrue(
SyncService.prefs(isolated)
.edit()
.putString("pin", "headset-b")
.putBoolean("gallery", true)
.commit());
SyncService.gallery(isolated, false);
// The latched legacy journal must stay readable immediately, by the screen and an engine.
assertEquals("appOnly", SyncEngine.snapshot(legacy).get(0).destination);
assertEquals("appOnly", new SyncEngine(legacy, false).items().get(0).destination);
SyncService.gallery(isolated, true);
for (File root : List.of(a, b, legacy)) {
SyncEngine engine = new SyncEngine(root, true);
assertEquals("appOnly", engine.items().get(0).destination);
engine.reconcile(
listing(shot),
transfer,
(s, f, eligible) -> {
fail("Opted-out items must never be exported after re-pairing");
return false;
});
}
} finally {
SyncService.prefs(isolated).edit().clear().commit();
try (java.util.stream.Stream<java.nio.file.Path> paths = Files.walk(files.toPath())) {
for (java.nio.file.Path path :
paths.sorted(Comparator.reverseOrder()).collect(java.util.stream.Collectors.toList()))
Files.delete(path);
}
}
}
@Test
public void changingHeadsetsRebindsShareToTheSelectedOriginal() throws Exception {
assertFalse(SyncService.running());
String aPin = "switch-a-" + UUID.randomUUID(), bPin = "switch-b-" + UUID.randomUUID();
File a = SyncService.library(context, aPin), b = SyncService.library(context, bPin);
SharedPreferences prefs = SyncService.prefs(context);
String previousPin = prefs.getString("pin", null);
boolean previousGallery = prefs.getBoolean("gallery", true);
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
AtomicReference<Intent> shared = new AtomicReference<>();
Instrumentation.ActivityMonitor monitor =
new Instrumentation.ActivityMonitor() {
@Override
public Instrumentation.ActivityResult onStartActivity(Intent intent) {
if (!Intent.ACTION_CHOOSER.equals(intent.getAction())) return null;
shared.set(intent.getParcelableExtra(Intent.EXTRA_INTENT));
return new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null);
}
};
Activity activity = null;
try {
SyncEngine.Shot shot = new SyncEngine.Shot("123/108/20261024120000_1.png", 4, 100);
for (File root : List.of(a, b)) {
byte[] original = root.equals(a) ? new byte[] {1, 2, 3, 4} : new byte[] {5, 6, 7, 8};
SyncEngine.Transport transfer =
new SyncEngine.Transport() {
public void download(SyncEngine.Shot s, File file) throws Exception {
Files.write(file.toPath(), original);
}
public boolean validImage(File file) {
return true;
}
};
SyncEngine engine = new SyncEngine(root, false);
engine.reconcile(listing(), transfer, (s, f, e) -> false);
engine.reconcile(listing(shot), transfer, (s, f, e) -> false);
engine.reconcile(listing(shot), transfer, (s, f, e) -> false);
}
assertTrue(prefs.edit().putString("pin", aPin).putBoolean("gallery", false).commit());
activity =
instrumentation.startActivitySync(
new Intent(context, MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK));
instrumentation.addMonitor(monitor);
Activity screen = activity;
// The library loads on a background executor, so wait for it to render, not for idle.
Uri first = shareWhenRendered(instrumentation, screen, shared, null);
try (InputStream in = context.getContentResolver().openInputStream(first)) {
assertArrayEquals(new byte[] {1, 2, 3, 4}, read(in));
}
assertTrue(prefs.edit().putString("pin", bPin).commit());
// Until the screen notices the new pin it may still share A; it then clears A's list
// (no button while B loads) and shows only B's original.
Uri selected = shareWhenRendered(instrumentation, screen, shared, first);
try (InputStream in = context.getContentResolver().openInputStream(selected)) {
assertArrayEquals(new byte[] {5, 6, 7, 8}, read(in));
}
// Once B is shown, A's list is gone: one button, and sharing again still targets B.
AtomicReference<Integer> buttons = new AtomicReference<>();
shared.set(null);
instrumentation.runOnMainSync(
() -> {
View content = screen.findViewById(android.R.id.content);
buttons.set(shareButtons(content));
Button button = shareButton(content);
if (button != null) assertTrue(button.performClick());
});
assertEquals("The previous headset's original must be gone", Integer.valueOf(1), buttons.get());
assertNotNull(shared.get());
assertEquals(selected, shared.get().getParcelableExtra(Intent.EXTRA_STREAM));
} finally {
instrumentation.removeMonitor(monitor);
Activity screen = activity;
if (screen != null) instrumentation.runOnMainSync(screen::finish);
SharedPreferences.Editor restore = prefs.edit().putBoolean("gallery", previousGallery);
if (previousPin == null) restore.remove("pin");
else restore.putString("pin", previousPin);
assertTrue(restore.commit());
for (File root : List.of(a, b)) {
if (!root.exists()) continue;
try (java.util.stream.Stream<java.nio.file.Path> paths = Files.walk(root.toPath())) {
for (java.nio.file.Path path :
paths.sorted(Comparator.reverseOrder()).collect(java.util.stream.Collectors.toList()))
Files.delete(path);
}
}
}
}
/**
* Clicks the share button once the asynchronously loaded library shows one and the chosen
* original differs from {@code previous}. A missing button is the expected loading state.
*/
private static Uri shareWhenRendered(
Instrumentation instrumentation,
Activity screen,
AtomicReference<Intent> shared,
Uri previous)
throws InterruptedException {
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
do {
shared.set(null);
instrumentation.runOnMainSync(
() -> {
Button button = shareButton(screen.findViewById(android.R.id.content));
if (button != null) assertTrue(button.performClick());
});
Intent sent = shared.get();
if (sent != null) {
Uri uri = sent.getParcelableExtra(Intent.EXTRA_STREAM);
assertNotNull(uri);
if (!uri.equals(previous)) return uri;
}
// Re-check after the screen's next refresh or library load; bounded by the deadline.
Thread.sleep(50);
} while (System.nanoTime() < deadline);
fail("The selected headset's original never became shareable");
return null;
}
private static int shareButtons(View view) {
if (view instanceof Button && "Open / share original".contentEquals(((Button) view).getText()))
return 1;
int count = 0;
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) count += shareButtons(group.getChildAt(i));
}
return count;
}
private static Button shareButton(View view) {
if (view instanceof Button && "Open / share original".contentEquals(((Button) view).getText()))
return (Button) view;
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) {
Button found = shareButton(group.getChildAt(i));
if (found != null) return found;
}
}
return null;
}
private Uri insert(String name, boolean pending, byte[] bytes) throws Exception {
ContentResolver resolver = context.getContentResolver();
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, name);
values.put(MediaStore.Images.Media.RELATIVE_PATH, GalleryExporter.FOLDER);
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
values.put(MediaStore.Images.Media.IS_PENDING, 1);
Uri uri =
resolver.insert(
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values);
assertNotNull(uri);
cleanup.add(uri);
try (OutputStream out = resolver.openOutputStream(uri, "wt")) {
out.write(bytes);
}
if (!pending) {
ContentValues done = new ContentValues();
done.put(MediaStore.Images.Media.IS_PENDING, 0);
assertEquals(1, resolver.update(uri, done, null, null));
}
return uri;
}
private boolean exists(Uri uri) {
@SuppressWarnings("deprecation")
Uri includingPending = MediaStore.setIncludePending(uri);
try (Cursor c =
context.getContentResolver().query(includingPending, new String[] {"_id"}, null, null, null)) {
return c != null && c.moveToFirst();
}
}
@Test
public void optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages() throws Exception {
assertFalse(SyncService.running());
File files = Files.createTempDirectory(context.getCacheDir().toPath(), "pending-").toFile();
String preferences = "pending-test-" + UUID.randomUUID();
Context isolated =
new ContextWrapper(context) {
@Override
public File getFilesDir() {
return files;
}
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
return context.getSharedPreferences(preferences, mode);
}
};
try {
Bitmap bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes);
bitmap.recycle();
// A pending row with app-only receipts, from a retained (not currently paired) library.
SyncEngine.Shot shot = new SyncEngine.Shot("123/108/20261024120003_1.png", 4, 100);
SyncEngine engine = new SyncEngine(SyncService.library(isolated, "retained"), true);
engine.reconcile(listing(), null, null);
Uri interrupted = insert(name("retained", shot), true, bytes.toByteArray());
Uri published = insert("published-" + UUID.randomUUID() + ".png", false, bytes.toByteArray());
assertTrue(SyncService.prefs(isolated).edit().putBoolean("gallery", true).commit());
SyncService.gallery(isolated, false);
assertFalse("Hidden pending row must not outlive the opt-out", exists(interrupted));
// Proven deleted by the product; deleting it again throws SecurityException on API 30+.
cleanup.remove(interrupted);
assertTrue("A published Gallery image must stay", exists(published));
SyncService.gallery(isolated, true);
assertTrue(exists(published));
assertFalse(exists(interrupted));
} finally {
SyncService.prefs(isolated).edit().clear().commit();
try (java.util.stream.Stream<java.nio.file.Path> paths = Files.walk(files.toPath())) {
for (java.nio.file.Path path :
paths.sorted(Comparator.reverseOrder()).collect(java.util.stream.Collectors.toList()))
Files.delete(path);
}
}
}
/** Optional real-headset provisioning. Only this test APK exposes the hook. */
@Test
public void provisionRealHeadsetIfRequested() throws Exception {
android.os.Bundle args = InstrumentationRegistry.getArguments();
Assume.assumeTrue("Optional real-headset provisioning", "true".equals(args.getString("live")));
String host = args.getString("host"), pin = args.getString("pin");
assertNotNull(host);
assertNotNull(pin);
assertEquals(
"Probe must return the expected host key before authentication",
pin,
FrameConnection.probe(host, 22, "steamos"));
String publicKey = new PhoneIdentity().authorizedKey(SyncService.install(context));
Files.write(
new File(context.getFilesDir(), "live-test-public.pub").toPath(),
(publicKey + "\n").getBytes(StandardCharsets.US_ASCII));
assertTrue(
SyncService.prefs(context)
.edit()
.putString("host", host)
.putString("user", "steamos")
.putInt("port", 22)
.putString("pin", pin)
.commit());
}
@Test
public void verifyRealHeadsetIfRequested() throws Exception {
android.os.Bundle args = InstrumentationRegistry.getArguments();
String id = args.getString("verifyId");
Assume.assumeNotNull(id);
String pin = SyncService.prefs(context).getString("pin", null);
assertNotNull(pin);
// Read-only: verification must not rewrite the product's journal.
SyncEngine.Item item =
SyncEngine.snapshot(SyncService.library(context, pin)).stream().filter(i -> i.shot.id.equals(id)).findFirst().orElseThrow();
assertTrue(item.file.exists());
String expected = args.getString("sha256");
assertNotNull(expected);
assertEquals(expected, sha(Files.readAllBytes(item.file.toPath())));
boolean gallery = "true".equals(args.getString("gallery"));
Uri saved = row(pin, item.shot);
cleanup.clear();
if (gallery) {
assertEquals("saved", item.destination);
assertNotNull(saved);
try (InputStream in = context.getContentResolver().openInputStream(saved)) {
assertEquals(expected, sha(read(in)));
}
} else {
assertEquals("appOnly", item.destination);
assertNull(saved);
}
}
private static String sha(byte[] bytes) throws Exception {
StringBuilder out = new StringBuilder();
for (byte b : java.security.MessageDigest.getInstance("SHA-256").digest(bytes))
out.append(String.format(java.util.Locale.ROOT, "%02x", b & 255));
return out.toString();
}
private static byte[] read(InputStream input) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] b = new byte[4096];
int n;
while ((n = input.read(b)) != -1) out.write(b, 0, n);
return out.toByteArray();
}
}
-19
View File
@@ -1,19 +0,0 @@
<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>
@@ -1,461 +0,0 @@
package com.saphid.framecontrol;
import android.content.Context;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.jcraft.jsch.*;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.*;
/** All headset traffic goes through one pinned, authenticated SSH connection. */
final class FrameConnection implements AutoCloseable, SyncEngine.Transport {
private final Context context;
private Session session;
private ChannelExec server;
private PipedOutputStream serverInput;
private int localPort;
private String apiKey;
private static final String HOST_ALGORITHMS = "ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256";
FrameConnection(Context context) {
this.context = context.getApplicationContext();
}
static String fingerprint(String pin) throws Exception {
return "SHA256:"
+ Base64.getEncoder()
.withoutPadding()
.encodeToString(
MessageDigest.getInstance("SHA-256").digest(Base64.getDecoder().decode(pin)));
}
static String hash(String value) throws Exception {
byte[] bytes =
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder();
for (byte b : bytes) result.append(String.format(Locale.ROOT, "%02x", b & 255));
return result.toString();
}
static String quote(String s) {
return "'" + s.replace("'", "'\\''") + "'";
}
private static Session session(JSch jsch, String host, int port, String user) throws Exception {
if (host.trim().isEmpty()
|| host.length() > 253
|| host.contains("/")
|| host.chars().anyMatch(Character::isWhitespace)
|| port < 1
|| port > 65535
|| !user.matches("[a-zA-Z0-9_-]{1,32}"))
throw new IOException("Check the headset address, port and username.");
Session s = jsch.getSession(user, host, port);
s.setConfig("StrictHostKeyChecking", "yes");
s.setConfig("server_host_key", HOST_ALGORITHMS);
s.setConfig("PreferredAuthentications", "publickey");
s.setTimeout(20000);
s.setServerAliveInterval(15000);
s.setServerAliveCountMax(2);
return s;
}
private static final class PinRepository implements HostKeyRepository {
String observed;
final String expected;
PinRepository(String expected) {
this.expected = expected;
}
@Override
public int check(String host, byte[] key) {
observed = Base64.getEncoder().encodeToString(key);
return observed.equals(expected) ? OK : CHANGED;
}
@Override
public void add(HostKey k, UserInfo u) {}
@Override
public void remove(String h, String t) {}
@Override
public void remove(String h, String t, byte[] k) {}
@Override
public String getKnownHostsRepositoryID() {
return "Frame Control pinned headset";
}
@Override
public HostKey[] getHostKey() {
return new HostKey[0];
}
@Override
public HostKey[] getHostKey(String h, String t) {
return new HostKey[0];
}
}
/** Intentionally refuses the key exchange before any authentication is sent. */
static String probe(String host, int port, String user) throws Exception {
JSch jsch = new JSch();
PinRepository pin = new PinRepository(null);
jsch.setHostKeyRepository(pin);
Session s = session(jsch, host, port, user);
try {
s.connect(10000);
} catch (JSchException e) {
if (pin.observed == null) throw e;
} finally {
s.disconnect();
}
return pin.observed;
}
static void pair(String host, int port, String user, String pin, String password, String install)
throws Exception {
JSch jsch = new JSch();
jsch.setHostKeyRepository(new PinRepository(pin));
Session s = session(jsch, host, port, user);
s.setConfig("PreferredAuthentications", "password,keyboard-interactive");
s.setPassword(password);
s.setUserInfo(
new UserInfo() {
public String getPassphrase() {
return null;
}
public String getPassword() {
return password;
}
public boolean promptPassword(String x) {
return true;
}
public boolean promptPassphrase(String x) {
return false;
}
public boolean promptYesNo(String x) {
return false;
}
public void showMessage(String x) {}
});
try {
s.connect(15000);
run(s, authorizeCommand(new PhoneIdentity().authorizedKey(install)), 10000);
} finally {
s.disconnect();
}
// Verify the new key before storing any pairing settings.
JSch verified = new JSch();
verified.setHostKeyRepository(new PinRepository(pin));
verified.addIdentity(new PhoneIdentity(), null);
Session check = session(verified, host, port, user);
try {
check.connect(15000);
} finally {
check.disconnect();
}
}
/**
* Idempotently appends {@code key}. A last line without a newline is terminated first, so the
* existing key is never merged with the new one (the same guard as the iPhone app).
*/
static String authorizeCommand(String key) {
String file = "~/.ssh/authorized_keys";
return "umask 077; mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch "
+ file
+ " && chmod 600 "
+ file
+ " && { grep -qxF "
+ quote(key)
+ " "
+ file
+ " || { if [ -s "
+ file
+ " ] && [ -n \"$(tail -c 1 "
+ file
+ ")\" ]; then printf '\\n' >> "
+ file
+ "; fi; printf '%s\\n' "
+ quote(key)
+ " >> "
+ file
+ "; }; }";
}
/**
* Lists Steam account directories, including ones with no screenshots. Prints "absent" when
* Steam has not created userdata yet; any other failure exits non-zero.
*/
static final String ACCOUNTS_COMMAND =
"d=\"$HOME/.local/share/Steam/userdata\"; if [ ! -e \"$d\" ]; then echo absent; else"
+ " cd \"$d\" || exit 1; echo present; for a in *; do case \"$a\" in ''|*[!0-9]*) ;;"
+ " *) if [ -d \"$a\" ]; then printf '%s\\n' \"$a\"; fi ;; esac; done; fi";
static Set<String> parseAccounts(String output) throws IOException {
String[] lines = output.split("\n", -1);
Set<String> accounts = new TreeSet<>();
if (lines.length == 2 && lines[0].equals("absent") && lines[1].isEmpty()) return accounts;
if (lines.length < 2 || !lines[0].equals("present") || !lines[lines.length - 1].isEmpty())
throw new IOException("Could not read Steam accounts on the headset.");
for (int i = 1; i < lines.length - 1; i++) {
if (!lines[i].matches("[0-9]+")) throw new IOException("Unexpected Steam account entry.");
// The headset server ignores longer ids; so does the screenshot listing.
if (lines[i].length() <= 12) accounts.add(lines[i]);
}
return accounts;
}
static String run(Session s, String command, int timeout) throws Exception {
ChannelExec channel = (ChannelExec) s.openChannel("exec");
channel.setCommand(command);
channel.setInputStream(null);
channel.setErrStream(
new OutputStream() {
public void write(int b) {}
public void write(byte[] b, int offset, int length) {}
});
InputStream input = channel.getInputStream();
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeout);
ByteArrayOutputStream output = new ByteArrayOutputStream();
try {
channel.connect(timeout);
while (true) {
while (input.available() > 0) {
byte[] b = new byte[Math.min(input.available(), 8192)];
int n = input.read(b);
if (n > 0) output.write(b, 0, n);
if (output.size() > 2 * 1024 * 1024)
throw new IOException("Headset command returned too much data.");
}
if (channel.isClosed() && input.available() == 0) break;
if (System.nanoTime() > deadline) throw new IOException("The headset command timed out.");
Thread.sleep(25);
}
if (channel.getExitStatus() != 0)
throw new IOException("The headset command failed (" + channel.getExitStatus() + ").");
return output.toString(StandardCharsets.UTF_8.name());
} finally {
channel.disconnect();
}
}
void connect(String host, int port, String user, String pin, String install) throws Exception {
JSch jsch = new JSch();
jsch.setHostKeyRepository(new PinRepository(pin));
jsch.addIdentity(new PhoneIdentity(), null);
session = session(jsch, host, port, user);
session.connect(15000);
String version;
try (InputStream in = context.getAssets().open("frame-bundle.version")) {
version = new String(readBounded(in, 128), StandardCharsets.US_ASCII).trim();
}
if (!version.matches("[0-9a-f]{16}")) throw new IOException("Invalid bundled server version.");
String dir = ".cache/frame-control/" + version;
String present =
run(session, "test -f " + dir + "/ui/server.py && printf ready || true", 10000);
if (!present.equals("ready")) {
run(session, "mkdir -p .cache/frame-control", 10000);
String temp = dir + ".android-" + UUID.randomUUID();
ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp");
try (InputStream in = context.getAssets().open("frame-bundle.tar.gz")) {
sftp.connect(10000);
sftp.put(in, temp + ".tar.gz");
} finally {
sftp.disconnect();
}
run(
session,
"mkdir "
+ temp
+ " && tar xzf "
+ temp
+ ".tar.gz -C "
+ temp
+ " && "
+ "(test -f "
+ dir
+ "/ui/server.py && rm -rf "
+ temp
+ " || mv "
+ temp
+ " "
+ dir
+ ") && rm -f "
+ temp
+ ".tar.gz",
60000);
}
apiKey = UUID.randomUUID().toString().replace("-", "");
server = (ChannelExec) session.openChannel("exec");
serverInput = new PipedOutputStream();
server.setInputStream(new PipedInputStream(serverInput));
server.setCommand(
"cd "
+ dir
+ " && FRAME_LOCAL=1 FRAME_UI_KEY="
+ apiKey
+ " FRAME_DEVICE="
+ quote(host)
+ " FRAME_CLIENT="
+ quote(install)
+ " exec python3 -I -u -B \"$PWD/ui/server.py\" --port 0 --exit-on-eof 2>&1");
InputStream input = server.getInputStream();
server.connect(10000);
ByteArrayOutputStream startup = new ByteArrayOutputStream();
Pattern portPattern = Pattern.compile("Frame Control on http://127\\.0\\.0\\.1:([0-9]+)\\s");
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(40);
int portFound = 0;
while (System.nanoTime() < deadline) {
while (input.available() > 0) {
startup.write(input.read());
if (startup.size() > 65536) throw new IOException("The headset server did not start.");
}
Matcher m = portPattern.matcher(startup.toString(StandardCharsets.UTF_8.name()));
if (m.find()) {
portFound = Integer.parseInt(m.group(1));
break;
}
if (server.isClosed())
throw new IOException("The headset server stopped before it was ready.");
Thread.sleep(50);
}
if (portFound < 1 || portFound > 65535)
throw new IOException("The headset server did not answer in time.");
localPort = session.setPortForwardingL("127.0.0.1", 0, "127.0.0.1", portFound);
// Drain the session-bound server output; never log session keys or user data.
Thread drain =
new Thread(
() -> {
try {
byte[] b = new byte[4096];
while (input.read(b) != -1) {}
} catch (IOException ignored) {
}
},
"frame-server-output");
drain.setDaemon(true);
drain.start();
}
String page() {
return "http://127.0.0.1:" + localPort + "/?key=" + apiKey;
}
boolean connected() {
return session != null && session.isConnected() && server != null && !server.isClosed();
}
private HttpURLConnection request(String path) throws Exception {
HttpURLConnection c =
(HttpURLConnection) new URL("http://127.0.0.1:" + localPort + path).openConnection();
c.setConnectTimeout(10000);
c.setReadTimeout(20000);
c.setInstanceFollowRedirects(false);
c.setRequestProperty("X-Frame-UI", apiKey);
return c;
}
/** Accounts are read first; one that appears before the screenshot query is still new. */
SyncEngine.Listing listing() throws Exception {
Set<String> accounts = parseAccounts(run(session, ACCOUNTS_COMMAND, 10000));
return new SyncEngine.Listing(accounts, shots());
}
List<SyncEngine.Shot> shots() throws Exception {
HttpURLConnection c = request("/api/shots");
try (InputStream in = c.getInputStream()) {
byte[] body = readBounded(in, 2 * 1024 * 1024);
JsonArray array =
JsonParser.parseString(new String(body, StandardCharsets.UTF_8))
.getAsJsonObject()
.getAsJsonArray("shots");
List<SyncEngine.Shot> shots = new ArrayList<>();
for (JsonElement e : array) {
var s = e.getAsJsonObject();
shots.add(
new SyncEngine.Shot(
s.get("id").getAsString(), s.get("size").getAsLong(), s.get("time").getAsDouble()));
}
return shots;
} finally {
c.disconnect();
}
}
private static byte[] readBounded(InputStream input, int limit) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] b = new byte[8192];
int n;
while ((n = input.read(b)) != -1) {
if (out.size() + n > limit) throw new IOException("The headset response is too large.");
out.write(b, 0, n);
}
return out.toByteArray();
}
@Override
public void download(SyncEngine.Shot shot, File destination) throws Exception {
HttpURLConnection c =
request("/api/shots/image?id=" + URLEncoder.encode(shot.id, StandardCharsets.UTF_8.name()));
try (InputStream in = c.getInputStream();
FileOutputStream out = new FileOutputStream(destination)) {
byte[] b = new byte[65536];
long count = 0;
int n;
while ((n = in.read(b)) != -1) {
count += n;
if (count > shot.size) throw new IOException("Screenshot changed while downloading.");
out.write(b, 0, n);
}
out.getFD().sync();
} finally {
c.disconnect();
}
}
@Override
public boolean validImage(File file) {
android.graphics.BitmapFactory.Options options = new android.graphics.BitmapFactory.Options();
options.inJustDecodeBounds = true;
android.graphics.BitmapFactory.decodeFile(file.getPath(), options);
if (options.outWidth <= 0
|| options.outHeight <= 0
|| options.outWidth > 16384
|| options.outHeight > 16384
|| (long) options.outWidth * options.outHeight > 60_000_000) return false;
options.inJustDecodeBounds = false;
options.inSampleSize = Math.max(1, Math.max(options.outWidth, options.outHeight) / 2048);
android.graphics.Bitmap b = android.graphics.BitmapFactory.decodeFile(file.getPath(), options);
if (b == null) return false;
b.recycle();
return true;
}
@Override
public void close() {
try {
if (serverInput != null) serverInput.close();
} catch (IOException ignored) {
}
if (server != null) server.disconnect();
if (session != null) session.disconnect();
}
}
@@ -1,161 +0,0 @@
package com.saphid.framecontrol;
import android.content.*;
import android.database.Cursor;
import android.net.Uri;
import android.provider.MediaStore;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BooleanSupplier;
/** Deterministic, app-owned MediaStore rows make interrupted exports retryable. */
final class GalleryExporter implements SyncEngine.Exporter {
static final String FOLDER = "Pictures/Frame Control/";
/**
* Serializes every export in the process, across exporter instances and service sessions. A
* stopped session's exporter may still be writing when a new session starts; without this, the
* new one could publish the same deterministic row and the old one's cancellation would then
* delete it. Lock order is EXPORTS, then the settings lock. Never take EXPORTS while holding
* the settings lock: {@link #discardPending} runs under it and must not wait for exports.
*/
static final ReentrantLock EXPORTS = new ReentrantLock();
private static final long EXPORT_WAIT_SECONDS = 30;
private final Context context;
private final String headset;
private final Object settingsLock;
GalleryExporter(Context context, String headset, Object settingsLock) {
this.context = context.getApplicationContext();
this.headset = headset;
this.settingsLock = settingsLock;
}
/**
* Best-effort removal of this app's hidden pending rows in the Frame Control folder. Only an
* interrupted export leaves one; published (non-pending) images are never touched. Callers
* hold the settings lock, so no export can be publishing a row at the same time.
*/
static int discardPending(Context context) {
ContentResolver resolver = context.getContentResolver();
Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
@SuppressWarnings("deprecation")
Uri includingPending = MediaStore.setIncludePending(collection);
List<Uri> rows = new ArrayList<>();
int removed = 0;
try {
try (Cursor cursor =
resolver.query(
includingPending,
new String[] {"_id"},
MediaStore.Images.Media.IS_PENDING
+ "=1 AND "
+ MediaStore.Images.Media.RELATIVE_PATH
+ "=? AND "
+ MediaStore.Images.Media.OWNER_PACKAGE_NAME
+ "=?",
new String[] {FOLDER, context.getPackageName()},
null)) {
while (cursor != null && cursor.moveToNext())
rows.add(ContentUris.withAppendedId(collection, cursor.getLong(0)));
}
for (Uri row : rows) removed += resolver.delete(row, null, null);
} catch (RuntimeException ignored) {
// Android 11+ also expires abandoned pending rows; never fail the setting change here.
}
return removed;
}
@Override
public boolean export(SyncEngine.Shot shot, File file, BooleanSupplier eligible)
throws Exception {
// Interruptible and bounded: Stop still ends a waiting session, and a stuck earlier write
// only delays this item to a later poll.
if (!EXPORTS.tryLock(EXPORT_WAIT_SECONDS, TimeUnit.SECONDS))
throw new IOException("Gallery is still finishing an earlier save.");
try {
return exportLocked(shot, file, eligible);
} finally {
EXPORTS.unlock();
}
}
private boolean exportLocked(SyncEngine.Shot shot, File file, BooleanSupplier eligible)
throws Exception {
String name = shot.id.substring(shot.id.lastIndexOf('/') + 1);
int dot = name.lastIndexOf('.');
name =
name.substring(0, dot)
+ "_"
+ FrameConnection.hash(headset + "/" + shot.id).substring(0, 20)
+ name.substring(dot);
String folder = FOLDER;
ContentResolver resolver = context.getContentResolver();
Uri collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
// API 29 excludes even app-owned pending rows unless explicitly included.
@SuppressWarnings("deprecation")
Uri recoveryCollection = MediaStore.setIncludePending(collection);
Uri row = null;
boolean pending = true;
try (Cursor cursor =
resolver.query(
recoveryCollection,
new String[] {"_id", MediaStore.Images.Media.IS_PENDING},
MediaStore.Images.Media.DISPLAY_NAME
+ "=? AND "
+ MediaStore.Images.Media.RELATIVE_PATH
+ "=? AND "
+ MediaStore.Images.Media.OWNER_PACKAGE_NAME
+ "=?",
new String[] {name, folder, context.getPackageName()},
null)) {
if (cursor != null && cursor.moveToFirst()) {
row = ContentUris.withAppendedId(collection, cursor.getLong(0));
pending = cursor.getInt(1) != 0;
}
}
// A completed row is the commit receipt, including a crash before JSON save.
if (row != null && !pending) return true;
if (!eligible.getAsBoolean()) return false;
if (row == null) {
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, name);
values.put(MediaStore.Images.Media.RELATIVE_PATH, folder);
values.put(
MediaStore.Images.Media.MIME_TYPE, shot.id.endsWith(".png") ? "image/png" : "image/jpeg");
values.put(MediaStore.Images.Media.IS_PENDING, 1);
values.put(MediaStore.Images.Media.DATE_TAKEN, (long) (shot.time * 1000));
row = resolver.insert(collection, values);
if (row == null) throw new IOException("Gallery could not create the screenshot.");
}
try {
try (InputStream in = new FileInputStream(file);
OutputStream out = resolver.openOutputStream(row, "wt")) {
if (out == null) throw new IOException("Gallery could not open the screenshot.");
byte[] b = new byte[65536];
int n;
while ((n = in.read(b)) != -1) out.write(b, 0, n);
}
synchronized (settingsLock) {
if (!eligible.getAsBoolean()) {
// Holding EXPORTS, no other exporter can have published this row since we found it.
resolver.delete(row, null, null);
return false;
}
ContentValues done = new ContentValues();
done.put(MediaStore.Images.Media.IS_PENDING, 0);
if (resolver.update(row, done, null, null) != 1)
throw new IOException("Gallery could not finish saving the screenshot.");
}
return true;
} catch (Exception e) {
// A pending row is retained and rewritten on retry. A completed row is
// discovered before any future insert, even after process death.
throw e;
}
}
}
@@ -1,553 +0,0 @@
package com.saphid.framecontrol;
import android.Manifest;
import android.app.*;
import android.content.*;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.net.Uri;
import android.os.*;
import android.text.InputType;
import android.view.*;
import android.webkit.*;
import android.widget.*;
import androidx.core.content.FileProvider;
import java.util.*;
import java.util.concurrent.*;
public final class MainActivity extends Activity {
private final ExecutorService network = Executors.newSingleThreadExecutor();
// Journal parsing and file checks stay off the main thread.
private final ExecutorService library = Executors.newSingleThreadExecutor();
private boolean loading;
private String renderedPin;
private final Handler handler = new Handler(Looper.getMainLooper());
private LinearLayout root, downloads;
private FrameLayout content;
private ScrollView companion;
private EditText host, port, user;
private TextView status, empty;
private Switch gallery;
private Button pair, connect, stop;
private WebView web;
private String loadedPage, librarySignature = "";
private int downloadLimit = 100;
private boolean busy, visible, controls;
private ValueCallback<Uri[]> fileSelection;
private static final int INK = Color.rgb(232, 240, 238),
MUTED = Color.rgb(164, 183, 179),
ACCENT = Color.rgb(124, 216, 195);
private static final String EMPTY =
"Pair and start syncing. Existing headset screenshots stay on the headset; new captures"
+ " will appear here.";
private final Runnable refresh =
new Runnable() {
public void run() {
if (!visible) return;
update();
handler.postDelayed(this, 1500);
}
};
private int dp(int n) {
return Math.round(getResources().getDisplayMetrics().density * n);
}
private TextView text(String value, int size, int color) {
TextView v = new TextView(this);
v.setText(value);
v.setTextSize(size);
v.setTextColor(color);
v.setPadding(0, dp(6), 0, dp(6));
return v;
}
private Button button(String label, Runnable action) {
Button b = new Button(this);
b.setText(label);
b.setAllCaps(false);
b.setOnClickListener(v -> action.run());
return b;
}
private EditText field(String label, String value, int type) {
companionBody.addView(text(label, 13, MUTED));
EditText v = new EditText(this);
v.setSingleLine(true);
v.setText(value);
v.setHint(label);
v.setContentDescription(label);
v.setInputType(type);
v.setTextColor(INK);
v.setSaveEnabled(false);
companionBody.addView(v);
return v;
}
private LinearLayout companionBody;
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(Color.rgb(17, 24, 25));
root.setPadding(dp(20), dp(12), dp(20), dp(12));
root.setOnApplyWindowInsetsListener(
(v, insets) -> {
if (Build.VERSION.SDK_INT >= 30) {
android.graphics.Insets i =
insets.getInsets(
WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout());
v.setPadding(dp(20) + i.left, dp(12) + i.top, dp(20) + i.right, dp(12) + i.bottom);
} else
v.setPadding(
dp(20) + insets.getSystemWindowInsetLeft(),
dp(12) + insets.getSystemWindowInsetTop(),
dp(20) + insets.getSystemWindowInsetRight(),
dp(12) + insets.getSystemWindowInsetBottom());
return insets;
});
root.addView(text("FRAME CONTROL", 13, ACCENT));
root.addView(text("Your headset, closer.", 27, INK));
status = text(SyncService.state(), 14, MUTED);
status.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE);
root.addView(status);
LinearLayout tabs = new LinearLayout(this);
tabs.addView(
button("Screenshots", () -> show(false)), new LinearLayout.LayoutParams(0, dp(48), 1));
tabs.addView(
button("Headset controls", () -> show(true)), new LinearLayout.LayoutParams(0, dp(48), 1));
root.addView(tabs);
content = new FrameLayout(this);
root.addView(content, new LinearLayout.LayoutParams(-1, 0, 1));
companion = new ScrollView(this);
companion.setFillViewport(true);
companionBody = new LinearLayout(this);
companionBody.setOrientation(LinearLayout.VERTICAL);
companion.addView(companionBody);
SharedPreferences p = SyncService.prefs(this);
companionBody.addView(text("Connect your Steam Frame", 20, INK));
companionBody.addView(
text(
"Enable Developer Mode and connect to the same network, or use your headset’s Tailscale"
+ " address.",
14,
MUTED));
host =
field(
"Headset address",
p.getString("host", "frame.local"),
InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
port = field("SSH port", Integer.toString(p.getInt("port", 22)), InputType.TYPE_CLASS_NUMBER);
user =
field(
"SSH username",
p.getString("user", "steamos"),
InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD);
pair = button("Pair headset", this::probe);
companionBody.addView(pair);
LinearLayout actions = new LinearLayout(this);
connect = button("Start syncing", this::startSync);
stop = button("Stop", () -> stopService(new Intent(this, SyncService.class)));
actions.addView(connect, new LinearLayout.LayoutParams(0, dp(52), 1));
actions.addView(stop, new LinearLayout.LayoutParams(0, dp(52), 1));
companionBody.addView(actions);
companionBody.addView(text("Screenshot downloads", 20, INK));
gallery = new Switch(this);
gallery.setText("Save screenshots to Gallery");
gallery.setTextColor(INK);
gallery.setTextSize(16);
gallery.setMinHeight(dp(56));
gallery.setChecked(p.getBoolean("gallery", true));
gallery.setOnCheckedChangeListener(
(b, value) -> {
try {
SyncService.gallery(this, value);
librarySignature = "";
} catch (Exception e) {
gallery.setOnCheckedChangeListener(null);
gallery.setChecked(!value);
error(SyncService.message(e));
recreate();
}
});
companionBody.addView(gallery);
companionBody.addView(
text(
"New captures download automatically while sync is running. A notification keeps the"
+ " connection visible. Stop syncing to disconnect. Android may delay transfers"
+ " while the phone is locked or in battery-saving modes.",
14,
MUTED));
companionBody.addView(
text(
"Turning Gallery off keeps new downloads in this app. Turning it on again won’t export"
+ " earlier app-only downloads.",
14,
MUTED));
empty = text(EMPTY, 14, MUTED);
companionBody.addView(empty);
downloads = new LinearLayout(this);
downloads.setOrientation(LinearLayout.VERTICAL);
companionBody.addView(downloads);
show(false);
setContentView(root);
}
private void show(boolean useControls) {
controls = useControls;
content.removeAllViews();
if (!controls) {
content.addView(companion, new FrameLayout.LayoutParams(-1, -1));
return;
}
String page = SyncService.page();
if (page == null) {
loadedPage = null;
if (web != null) web.loadUrl("about:blank");
content.addView(text("Start syncing to open your headset’s controls.", 17, MUTED));
return;
}
if (web == null) {
web = new WebView(this);
web.setBackgroundColor(Color.rgb(17, 24, 25));
WebSettings settings = web.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
settings.setSaveFormData(false);
web.setWebViewClient(
new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
Uri url = request.getUrl();
Uri origin = Uri.parse(loadedPage == null ? "" : loadedPage);
if ("http".equals(url.getScheme())
&& "127.0.0.1".equals(url.getHost())
&& url.getPort() == origin.getPort()) return false;
if (request.hasGesture()
&& ("https".equals(url.getScheme()) || "http".equals(url.getScheme()))) {
try {
startActivity(new Intent(Intent.ACTION_VIEW, url));
} catch (ActivityNotFoundException ignored) {
}
}
return true;
}
});
web.setWebChromeClient(
new WebChromeClient() {
@Override
public boolean onShowFileChooser(
WebView w, ValueCallback<Uri[]> callback, FileChooserParams params) {
if (fileSelection != null) fileSelection.onReceiveValue(null);
fileSelection = callback;
Intent choose =
new Intent(Intent.ACTION_OPEN_DOCUMENT)
.setType("*/*")
.addCategory(Intent.CATEGORY_OPENABLE);
choose.putExtra(
Intent.EXTRA_ALLOW_MULTIPLE,
params.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE);
try {
startActivityForResult(choose, 110);
} catch (ActivityNotFoundException e) {
fileSelection.onReceiveValue(null);
fileSelection = null;
return false;
}
return true;
}
});
web.setDownloadListener(
(url, ua, disposition, mime, length) -> {
show(false);
Toast.makeText(
this,
"New screenshot originals are in Screenshot downloads.",
Toast.LENGTH_LONG)
.show();
});
}
content.addView(web, new FrameLayout.LayoutParams(-1, -1));
if (!page.equals(loadedPage)) {
loadedPage = page;
web.loadUrl(page);
}
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
super.onActivityResult(request, result, data);
if (request == 110 && fileSelection != null) {
fileSelection.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(result, data));
fileSelection = null;
}
}
private void update() {
if (!busy) status.setText(SyncService.state());
boolean paired = SyncService.prefs(this).contains("pin");
boolean running = SyncService.running();
connect.setEnabled(paired && !busy && !running);
pair.setEnabled(!busy && !running);
stop.setEnabled(!busy && running);
host.setEnabled(!running && !busy);
port.setEnabled(!running && !busy);
user.setEnabled(!running && !busy);
if (controls && !Objects.equals(loadedPage, SyncService.page())) show(true);
String pin = SyncService.prefs(this).getString("pin", null);
if (!Objects.equals(pin, renderedPin)) {
// Never leave another headset's originals shareable while the new library loads.
renderedPin = pin;
librarySignature = "";
downloads.removeAllViews();
empty.setText(EMPTY);
empty.setVisibility(View.VISIBLE);
}
if (loading) return;
loading = true;
library.execute(
() -> {
List<SyncEngine.Item> items = null;
Set<SyncEngine.Item> present = new HashSet<>();
Exception failure = null;
try {
items = SyncService.items(this, pin);
for (SyncEngine.Item item : items) if (item.file.exists()) present.add(item);
} catch (Exception e) {
failure = e;
}
List<SyncEngine.Item> loaded = items;
Exception error = failure;
runOnUiThread(
() -> {
loading = false;
if (isFinishing() || isDestroyed()) return;
// A result for a previously paired headset is discarded; the next tick reloads.
if (!Objects.equals(pin, SyncService.prefs(this).getString("pin", null))) return;
if (error != null) {
librarySignature = "";
empty.setVisibility(View.VISIBLE);
empty.setText("Could not read screenshot downloads: " + SyncService.message(error));
return;
}
render(loaded, present);
});
});
}
private void render(List<SyncEngine.Item> items, Set<SyncEngine.Item> present) {
StringBuilder signature = new StringBuilder();
for (var item : items)
signature
.append(item.file.getAbsolutePath())
.append('|')
.append(item.destination)
.append('|')
.append(present.contains(item))
.append('\n');
if (signature.toString().equals(librarySignature)) return;
librarySignature = signature.toString();
downloads.removeAllViews();
empty.setText(EMPTY);
empty.setVisibility(items.isEmpty() ? View.VISIBLE : View.GONE);
int shown = 0;
for (SyncEngine.Item item : items) {
if (shown++ >= downloadLimit) {
downloads.addView(
button(
"Show 100 more downloads",
() -> {
downloadLimit += 100;
librarySignature = "";
update();
}));
break;
}
String label = item.shot.id.substring(item.shot.id.lastIndexOf('/') + 1);
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.setPadding(0, dp(12), 0, dp(12));
card.addView(text(label, 16, INK));
String where =
item.destination.equals("saved")
? "Saved to Gallery"
: item.destination.equals("appOnly") ? "In Frame Control" : "Gallery save pending";
boolean exists = present.contains(item);
if (!exists) where = "Download pending";
card.addView(text(where + " · Game " + item.shot.id.split("/")[1], 13, MUTED));
if (exists) card.addView(button("Open / share original", () -> share(item)));
downloads.addView(card);
}
}
private void share(SyncEngine.Item item) {
Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".files", item.file);
Intent send =
new Intent(Intent.ACTION_SEND)
.setType(item.shot.id.endsWith(".png") ? "image/png" : "image/jpeg")
.putExtra(Intent.EXTRA_STREAM, uri)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(Intent.createChooser(send, "Screenshot original"));
}
private void startSync() {
if (Build.VERSION.SDK_INT >= 33
&& checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[] {Manifest.permission.POST_NOTIFICATIONS}, 108);
return;
}
startForegroundService(new Intent(this, SyncService.class));
}
@Override
public void onRequestPermissionsResult(int request, String[] permissions, int[] grants) {
super.onRequestPermissionsResult(request, permissions, grants);
if (request == 108) {
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED)
startForegroundService(new Intent(this, SyncService.class));
else
error(
"Allow notifications in Android Settings to start a visible screenshot sync session.");
}
}
private void probe() {
final String address = host.getText().toString().trim(),
username = user.getText().toString().trim();
final int sshPort;
try {
sshPort = Integer.parseInt(port.getText().toString());
} catch (NumberFormatException e) {
error("Enter an SSH port from 1 to 65535.");
return;
}
stopService(new Intent(this, SyncService.class));
busy = true;
pair.setEnabled(false);
status.setText("Checking headset identity…");
network.execute(
() -> {
try {
String pin = FrameConnection.probe(address, sshPort, username);
String fingerprint = FrameConnection.fingerprint(pin);
runOnUiThread(
() -> {
if (isFinishing() || isDestroyed()) return;
busy = false;
new AlertDialog.Builder(this)
.setTitle("Trust this headset?")
.setMessage(
address
+ ":"
+ sshPort
+ "\n\n"
+ fingerprint
+ "\n\n"
+ "Compare this SSH fingerprint with your headset before entering its"
+ " Developer Mode password.")
.setNegativeButton("Cancel", null)
.setPositiveButton(
"Trust and pair", (d, w) -> password(address, sshPort, username, pin))
.show();
});
} catch (Exception e) {
runOnUiThread(
() -> {
busy = false;
error(SyncService.message(e));
});
}
});
}
private void password(String address, int sshPort, String username, String pin) {
EditText password = new EditText(this);
password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
password.setHint("Developer Mode password");
password.setSaveEnabled(false);
new AlertDialog.Builder(this)
.setTitle("Pair with " + address)
.setMessage(
"Used once to authorize this phone’s protected SSH key. The password is not saved.")
.setView(password)
.setNegativeButton("Cancel", (d, w) -> password.setText(""))
.setPositiveButton(
"Pair",
(d, w) -> {
String secret = password.getText().toString();
password.setText("");
busy = true;
status.setText("Pairing this phone…");
network.execute(
() -> {
try {
FrameConnection.pair(
address, sshPort, username, pin, secret, SyncService.install(this));
if (!SyncService.prefs(this)
.edit()
.putString("host", address)
.putInt("port", sshPort)
.putString("user", username)
.putString("pin", pin)
.commit()) throw new java.io.IOException("Could not save pairing.");
runOnUiThread(
() -> {
busy = false;
status.setText("Paired. Start syncing when you’re ready.");
update();
});
} catch (Exception e) {
runOnUiThread(
() -> {
busy = false;
error(SyncService.message(e));
});
}
});
})
.show();
}
private void error(String message) {
if (isFinishing() || isDestroyed()) return;
new AlertDialog.Builder(this)
.setTitle("Frame Control")
.setMessage(message)
.setPositiveButton("OK", null)
.show();
}
@Override
protected void onResume() {
super.onResume();
visible = true;
if (web != null) web.onResume();
handler.post(refresh);
}
@Override
protected void onPause() {
visible = false;
handler.removeCallbacks(refresh);
if (web != null) web.onPause();
super.onPause();
}
@Override
protected void onDestroy() {
network.shutdownNow();
library.shutdownNow();
if (fileSelection != null) fileSelection.onReceiveValue(null);
if (web != null) {
web.destroy();
web = null;
}
super.onDestroy();
}
}
@@ -1,115 +0,0 @@
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. */
}
}
@@ -1,71 +0,0 @@
package com.saphid.framecontrol;
import java.util.function.Consumer;
/**
* Owns the visible sync status. Each service instance gets one session token; once a session
* ends, its late worker can no longer change the status, page or notification, even after a
* newer session has started.
*/
final class SessionGate {
static final String DISCONNECTED = "Disconnected";
private long issued;
private long current;
private String state = DISCONNECTED;
private String page;
synchronized long begin() {
current = ++issued;
page = null;
return current;
}
synchronized boolean active(long session) {
return session != 0 && session == current;
}
synchronized boolean running() {
return current != 0;
}
synchronized String state() {
return state;
}
synchronized String page() {
return page;
}
/** Publishes a status and runs {@code show} (the notification update) only while current. */
synchronized boolean status(long session, String text, Consumer<String> show) {
if (!active(session)) return false;
state = text;
if (show != null) show.accept(text);
return true;
}
synchronized boolean page(long session, String value) {
if (!active(session)) return false;
page = value;
return true;
}
/** Runs {@code action} only while the session is current, serialized against {@link #end}. */
synchronized boolean ifActive(long session, Runnable action) {
if (!active(session)) return false;
action.run();
return true;
}
/** Ends only the named session. Progress text becomes Disconnected; a final error stays. */
synchronized boolean end(long session) {
if (!active(session)) return false;
current = 0;
page = null;
if (state.startsWith("Syncing")
|| state.startsWith("Waiting")
|| state.startsWith("Connecting")
|| state.startsWith("Screenshot pending")) state = DISCONNECTED;
return true;
}
}
@@ -1,724 +0,0 @@
package com.saphid.framecontrol;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.Strictness;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.BooleanSupplier;
import java.util.regex.Pattern;
/** One reusable engine per root. The caller supplies an account/headset-specific root. */
public final class SyncEngine {
private static final long MAX_SIZE = 30L * 1024 * 1024;
private static final Pattern ID = Pattern.compile(
"^[0-9]{1,12}/[0-9]{1,20}/[0-9]{14}_[0-9]{1,4}\\.(jpg|png)$");
private static final Pattern ACCOUNT = Pattern.compile("^[0-9]{1,12}$");
private static final Gson GSON = new GsonBuilder().registerTypeAdapter(Shot.class,
new TypeAdapter<Shot>() {
@Override public void write(JsonWriter writer, Shot shot) throws IOException {
writer.beginObject();
writer.name("id").value(shot.id);
writer.name("size").value(shot.size);
writer.name("time").value(shot.time);
writer.endObject();
}
@Override public Shot read(JsonReader reader) throws IOException {
String id = null;
Long size = null;
Double time = null;
Set<String> fields = new HashSet<>();
reader.beginObject();
while (reader.hasNext()) {
String field = reader.nextName();
if (!fields.add(field)) throw new IOException("Duplicate screenshot field");
switch (field) {
case "id":
if (reader.peek() != JsonToken.STRING) throw new IOException("Invalid id");
id = reader.nextString();
break;
case "size":
if (reader.peek() != JsonToken.NUMBER) throw new IOException("Invalid size");
size = reader.nextLong();
break;
case "time":
if (reader.peek() != JsonToken.NUMBER) throw new IOException("Invalid time");
time = reader.nextDouble();
break;
default: throw new IOException("Unknown screenshot field");
}
}
reader.endObject();
if (id == null || size == null || time == null) throw new IOException("Incomplete shot");
return new Shot(id, size, time);
}
}).create();
public static final class Shot {
public final String id;
public final long size;
public final double time;
public Shot(String id, long size, double time) {
this.id = id;
this.size = size;
this.time = time;
}
}
/**
* One headset poll. {@code accounts} names every Steam account directory present on the
* headset, including accounts with no screenshots yet. An absent userdata root is an empty set.
*/
public static final class Listing {
public final Set<String> accounts;
public final List<Shot> shots;
public Listing(Set<String> accounts, List<Shot> shots) {
this.accounts = accounts;
this.shots = shots;
}
}
public interface Transport {
void download(Shot shot, File destination) throws Exception;
boolean validImage(File f) throws Exception;
}
public interface Exporter {
boolean export(Shot shot, File original, BooleanSupplier eligible) throws Exception;
}
public static final class Item {
public final Shot shot;
/** Future original path; may not exist while the shot is observing or queued. */
public final File file;
public final String destination;
private Item(Shot shot, File file, String destination) {
this.shot = shot;
this.file = file;
this.destination = destination;
}
}
// Boxed fields make missing journal fields distinguishable from legitimate zero values.
private static final class Ledger {
Integer version;
Boolean initialized;
Boolean gallery;
/** Accounts already baselined. Absent only in version 1 journals. */
Set<String> accounts;
Map<String, Receipt> records;
}
private static final class Poll {
final Map<String, Shot> eligible = new LinkedHashMap<>();
final Set<String> accounts = new TreeSet<>();
int skipped;
}
private static final class Receipt {
Shot shot;
String state;
Integer observations;
String destination;
Receipt(Shot shot, String state, int observations, String destination) {
this.shot = shot;
this.state = state;
this.observations = observations;
this.destination = destination;
}
}
/** Only external callbacks and rejected transfer contents are safe to retry per item. */
private static final class ItemFailure extends IOException {
private static final long serialVersionUID = 1L;
ItemFailure(String message, Exception cause) {
super(message, cause);
}
}
private final Object lock = new Object();
private final Path root;
private final Path originals;
private final Path journal;
private Ledger ledger;
private long generation;
private long active;
private boolean closed;
private volatile int skipped;
public SyncEngine(File root, boolean gallery) throws Exception {
this(root, Boolean.valueOf(gallery));
}
/** A null gallery opens a read-only view that never creates, upgrades or rewrites anything. */
private SyncEngine(File root, Boolean gallery) throws Exception {
if (root == null || Files.isSymbolicLink(root.toPath())) {
throw new IOException("Unsafe sync root");
}
this.root = root.getCanonicalFile().toPath();
if (gallery == null) {
closed = true;
} else {
List<Path> missingDirectories = new ArrayList<>();
for (Path path = this.root; path != null && !Files.exists(path); path = path.getParent()) {
missingDirectories.add(path);
}
Files.createDirectories(this.root);
for (Path directory : missingDirectories) syncDirectory(directory.getParent());
}
originals = this.root.resolve("originals");
journal = this.root.resolve("receipts.json");
checkPath(originals);
checkPath(journal);
if (Files.exists(originals, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(originals, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid originals directory");
}
if (Files.exists(journal, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(journal, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid sync journal");
}
// Never recover a damaged or incomplete journal by establishing another baseline.
try (JsonReader reader = new JsonReader(new InputStreamReader(
Files.newInputStream(journal), StandardCharsets.UTF_8))) {
reader.setStrictness(Strictness.STRICT);
ledger = GSON.fromJson(reader, Ledger.class);
if (reader.peek() != JsonToken.END_DOCUMENT) {
throw new IOException("Trailing sync journal data");
}
} catch (RuntimeException e) {
throw new IOException("Corrupt sync journal", e);
}
validateLedger();
if (gallery != null) setGallery(gallery);
} else {
if (Files.exists(originals, LinkOption.NOFOLLOW_LINKS)) {
try (java.util.stream.Stream<Path> paths = Files.walk(originals)) {
if (paths.anyMatch(p -> !Files.isDirectory(p, LinkOption.NOFOLLOW_LINKS))) {
throw new IOException("Originals exist without a sync journal");
}
}
}
ledger = emptyLedger();
if (gallery == null) {
ledger.gallery = false;
} else {
ledger.gallery = gallery;
persist(ledger);
}
}
}
/** Post-baseline receipts read without locks, directory creation or journal writes. */
public static List<Item> snapshot(File root) throws Exception {
return new SyncEngine(root, (Boolean) null).items();
}
/** Returns false, without writing, once this engine is closed. */
public boolean setGallery(boolean enabled) throws Exception {
synchronized (lock) {
if (closed) return false;
if (ledger.gallery == enabled) return true;
Ledger next = copy();
next.gallery = enabled;
if (!enabled) {
for (Receipt receipt : next.records.values()) {
if ("pending".equals(receipt.destination)) receipt.destination = "appOnly";
}
}
commit(next);
return true;
}
}
/**
* A second live reconcile is rejected. stop() cancels its generation and permits a new run;
* close() cancels it permanently. The journal is rewritten only when a receipt changes.
*/
public void reconcile(Listing listing, Transport transport, Exporter exporter) throws Exception {
final long run;
synchronized (lock) {
if (closed) throw new IllegalStateException("Sync stopped");
if (active != 0) throw new IllegalStateException("Sync already in progress");
run = ++generation;
active = run;
}
try {
Poll poll = poll(listing);
synchronized (lock) {
if (active != run) return;
skipped = poll.skipped;
Ledger next = copy();
if (!next.initialized) {
for (Shot shot : poll.eligible.values()) {
next.records.put(shot.id, new Receipt(shot, "baseline", 0, null));
}
next.initialized = true;
next.version = 2;
next.accounts = poll.accounts;
commit(next);
return;
}
boolean changed = false;
Set<String> known = next.accounts;
if (known == null) {
// Version 1 journals did not record accounts. Trust only accounts that already
// have receipts; any other account present now is history and is baselined.
known = new TreeSet<>();
for (String id : next.records.keySet()) known.add(account(id));
// The account comparison below always records the set with this version.
next.version = 2;
changed = true;
}
// Missing observations break consecutiveness, including across restarts.
for (Receipt receipt : next.records.values()) {
if (!poll.eligible.containsKey(receipt.shot.id) && receipt.observations != 0
&& ("observing".equals(receipt.state) || "queued".equals(receipt.state))) {
receipt.observations = 0;
changed = true;
}
}
for (Shot shot : poll.eligible.values()) {
Receipt receipt = next.records.get(shot.id);
if (receipt == null && !known.contains(account(shot.id))) {
// A newly appearing account (or a returning one) starts with its own
// baseline, so its existing screenshots are never imported.
next.records.put(shot.id, new Receipt(shot, "baseline", 0, null));
changed = true;
} else if (receipt == null) {
// Latch eligibility at discovery, before waiting for a stable second sample.
next.records.put(shot.id, new Receipt(shot, "observing", 1,
next.gallery ? "pending" : "appOnly"));
changed = true;
} else if ("observing".equals(receipt.state) || "queued".equals(receipt.state)) {
boolean stable = same(receipt.shot, shot);
int observations = stable ? Math.min(2, receipt.observations + 1) : 1;
if (!stable || observations != receipt.observations) changed = true;
receipt.observations = observations;
receipt.shot = shot;
if (receipt.observations == 2 && "observing".equals(receipt.state)) {
receipt.state = "queued";
}
}
}
// Accounts absent now are forgotten; if one returns it is baselined again.
if (!poll.accounts.equals(next.accounts)) {
next.accounts = poll.accounts;
changed = true;
}
// Queue and its disposition reach disk before any download.
if (changed) commit(next);
}
List<String> ids;
synchronized (lock) {
if (active != run) return;
ids = new ArrayList<>(ledger.records.keySet());
}
ItemFailure failure = null;
for (String id : ids) {
Shot download = null;
synchronized (lock) {
if (active != run) return;
Receipt receipt = ledger.records.get(id);
if ("queued".equals(receipt.state) && receipt.observations == 2) {
download = receipt.shot;
}
}
try {
if (download != null) download(run, download, transport);
export(run, id, exporter);
} catch (ItemFailure e) {
if (failure == null) failure = e;
else failure.addSuppressed(e);
}
}
// Surface retryable errors to the service after independent items have progressed.
// Untagged local I/O/journal failures escape immediately and halt the run.
if (failure != null) throw failure;
} catch (Exception e) {
synchronized (lock) {
if (active != run) return; // A late failed callback belongs to a cancelled run.
}
throw e;
} finally {
synchronized (lock) {
if (active == run) active = 0;
}
}
}
/** Snapshot of all post-baseline receipts, including downloads still pending. */
public List<Item> items() {
synchronized (lock) {
List<Item> result = new ArrayList<>();
for (Receipt receipt : ledger.records.values()) {
if (!"baseline".equals(receipt.state)) {
result.add(new Item(receipt.shot, originals.resolve(receipt.shot.id).toFile(),
receipt.destination));
}
}
result.sort(Comparator.comparingDouble((Item item) -> item.shot.time).reversed()
.thenComparing(item -> item.shot.id));
return Collections.unmodifiableList(result);
}
}
/** Listed files left out of the last poll because they were empty or over 30 MiB. */
public int skipped() {
return skipped;
}
public void stop() {
synchronized (lock) {
++generation;
active = 0;
}
}
/**
* Permanently cancels this engine. Once it returns, no in-flight or later call can write the
* journal, so another engine may safely take over the same root.
*/
public void close() {
synchronized (lock) {
closed = true;
++generation;
active = 0;
}
}
public boolean isClosed() {
synchronized (lock) {
return closed;
}
}
private void download(long run, Shot shot, Transport transport) throws Exception {
Path temporary;
synchronized (lock) {
if (active != run) return;
temporary = Files.createTempFile(root, ".download-", ".part");
}
try {
try {
transport.download(shot, temporary.toFile());
} catch (InterruptedException e) {
throw e;
} catch (Exception e) {
throw new ItemFailure("Download failed: " + shot.id + ": " + e.getMessage(), e);
}
synchronized (lock) {
if (active != run) return;
}
checkTransfer(temporary, shot);
boolean valid;
try {
valid = transport.validImage(temporary.toFile());
} catch (InterruptedException e) {
throw e;
} catch (Exception e) {
throw new ItemFailure("Image validation failed: " + shot.id + ": " + e.getMessage(), e);
}
if (!valid) throw new ItemFailure("Invalid image: " + shot.id, null);
checkTransfer(temporary, shot);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
channel.force(true);
}
synchronized (lock) {
if (active != run) return;
Path target = originals.resolve(shot.id);
createParents(target.getParent());
checkPath(target);
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
syncDirectory(target.getParent());
Ledger next = copy();
// Read the current destination: setGallery may have changed it during the transfer.
next.records.get(shot.id).state = "downloaded";
commit(next);
}
} finally {
Files.deleteIfExists(temporary);
}
}
private void export(long run, String id, Exporter exporter) throws Exception {
Shot shot;
File original;
synchronized (lock) {
if (!eligible(run, id)) return;
Receipt receipt = ledger.records.get(id);
shot = receipt.shot;
Path path = originals.resolve(id);
checkDownload(path, shot);
original = path.toFile();
}
boolean saved;
try {
saved = exporter.export(shot, original, () -> {
synchronized (lock) {
return eligible(run, id);
}
});
} catch (InterruptedException e) {
throw e;
} catch (Exception e) {
throw new ItemFailure("Gallery export failed: " + shot.id + ": " + e.getMessage(), e);
}
synchronized (lock) {
if (active != run || !saved) return;
Ledger next = copy();
// A true result means the exporter actually saved (or recovered) the MediaStore item.
next.records.get(id).destination = "saved";
commit(next);
}
}
private boolean eligible(long run, String id) {
Receipt receipt = ledger.records.get(id);
return active == run && ledger.gallery && receipt != null
&& "downloaded".equals(receipt.state) && "pending".equals(receipt.destination);
}
private static Poll poll(Listing listing) throws IOException {
if (listing == null || listing.shots == null || listing.accounts == null) {
throw new IOException("Missing screenshot listing");
}
Poll result = new Poll();
for (String account : listing.accounts) {
if (account == null || !ACCOUNT.matcher(account).matches()) {
throw new IOException("Invalid Steam account");
}
result.accounts.add(account);
}
Map<String, Shot> seen = new LinkedHashMap<>();
for (Shot shot : listing.shots) {
// Unsafe ids, non-finite times and conflicting duplicates reject the whole listing.
validateIdentity(shot);
Shot previous = seen.put(shot.id, shot);
if (previous != null && !same(previous, shot)) {
throw new IOException("Conflicting screenshot identity: " + shot.id);
}
// The account directory may appear between the account and screenshot queries.
result.accounts.add(account(shot.id));
}
for (Shot shot : seen.values()) {
// An empty, still-writing or oversized file is only this item's problem: treat it
// as absent from this poll so it never blocks other screenshots or the baseline.
if (eligibleSize(shot)) result.eligible.put(shot.id, shot);
else result.skipped++;
}
return result;
}
private static String account(String id) {
return id.substring(0, id.indexOf('/'));
}
private static void validateIdentity(Shot shot) throws IOException {
if (shot == null || shot.id == null || !ID.matcher(shot.id).matches()
|| !Double.isFinite(shot.time)) {
throw new IOException("Invalid screenshot metadata");
}
}
private static boolean eligibleSize(Shot shot) {
return shot.size > 0 && shot.size <= MAX_SIZE;
}
private static void validateShot(Shot shot) throws IOException {
validateIdentity(shot);
if (!eligibleSize(shot)) throw new IOException("Invalid screenshot metadata");
}
private static boolean same(Shot a, Shot b) {
return a.size == b.size && Double.compare(a.time, b.time) == 0;
}
private void validateLedger() throws IOException {
if (ledger == null || ledger.version == null || ledger.initialized == null
|| ledger.gallery == null || ledger.records == null
|| (ledger.version == 1 && ledger.accounts != null)
|| (ledger.version == 2 && ledger.accounts == null)
|| (ledger.version != 1 && ledger.version != 2)
|| (!ledger.initialized && !ledger.records.isEmpty())
|| (!ledger.initialized && ledger.accounts != null && !ledger.accounts.isEmpty())) {
throw new IOException("Invalid sync journal header");
}
if (ledger.accounts != null) {
for (String account : ledger.accounts) {
if (account == null || !ACCOUNT.matcher(account).matches()) {
throw new IOException("Invalid sync journal header");
}
}
ledger.accounts = new TreeSet<>(ledger.accounts);
}
for (Map.Entry<String, Receipt> entry : ledger.records.entrySet()) {
Receipt receipt = entry.getValue();
if (receipt == null) throw new IOException("Invalid sync receipt");
validateShot(receipt.shot);
if (!entry.getKey().equals(receipt.shot.id) || receipt.observations == null
|| receipt.observations < 0 || receipt.observations > 2) {
throw new IOException("Invalid sync receipt identity");
}
boolean baseline = "baseline".equals(receipt.state);
boolean observing = "observing".equals(receipt.state);
boolean queued = "queued".equals(receipt.state);
boolean downloaded = "downloaded".equals(receipt.state);
if ((!baseline && !observing && !queued && !downloaded)
|| (baseline && receipt.destination != null)
|| (baseline && receipt.observations != 0)
|| (observing && receipt.observations > 1)
|| (downloaded && receipt.observations != 2)
|| ((observing || queued || downloaded) && !"pending".equals(receipt.destination)
&& !"appOnly".equals(receipt.destination) && !"saved".equals(receipt.destination))
|| ((observing || queued) && "saved".equals(receipt.destination))
|| (!ledger.gallery && "pending".equals(receipt.destination))) {
throw new IOException("Invalid sync receipt state");
}
Path path = originals.resolve(receipt.shot.id);
checkPath(path);
if (downloaded) checkDownload(path, receipt.shot);
}
}
private void checkDownload(Path path, Shot shot) throws IOException {
checkPath(path);
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) != shot.size) {
throw new IOException("Incomplete original: " + shot.id);
}
}
private void checkTransfer(Path path, Shot shot) throws IOException {
checkPath(path); // Unsafe paths and local filesystem errors remain fatal.
BasicFileAttributes attributes;
try {
attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
} catch (NoSuchFileException e) {
throw new ItemFailure("Missing downloaded image: " + shot.id, e);
}
if (!attributes.isRegularFile() || attributes.size() != shot.size) {
throw new ItemFailure("Incomplete downloaded image: " + shot.id, null);
}
}
private void checkPath(Path path) throws IOException {
if (!path.normalize().startsWith(root)) throw new IOException("Path escaped sync root");
Path current = root;
if (Files.isSymbolicLink(current)) throw new IOException("Symlink in sync root");
for (Path component : root.relativize(path)) {
current = current.resolve(component);
if (Files.isSymbolicLink(current)) throw new IOException("Symlink in sync path");
}
}
private void createParents(Path directory) throws IOException {
checkPath(directory);
Path current = root;
for (Path component : root.relativize(directory)) {
Path parent = current;
current = current.resolve(component);
if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectory(current);
syncDirectory(parent);
} else if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid originals directory");
}
}
}
/**
* Keeps the version: a version 1 journal (no account set) stays version 1 through setting
* changes, and only reconcile upgrades it, recording the version and accounts together.
*/
private Ledger copy() {
Ledger next = emptyLedger();
next.version = ledger.version;
next.initialized = ledger.initialized;
next.gallery = ledger.gallery;
next.accounts = ledger.accounts == null ? null : new TreeSet<>(ledger.accounts);
for (Map.Entry<String, Receipt> entry : ledger.records.entrySet()) {
Receipt receipt = entry.getValue();
next.records.put(entry.getKey(), new Receipt(receipt.shot, receipt.state,
receipt.observations, receipt.destination));
}
return next;
}
private static Ledger emptyLedger() {
Ledger result = new Ledger();
result.version = 2;
result.initialized = false;
result.accounts = new TreeSet<>();
result.records = new LinkedHashMap<>();
return result;
}
private void commit(Ledger next) throws IOException {
if (closed) throw new IOException("Sync journal is read-only");
persist(next);
ledger = next;
}
private void persist(Ledger next) throws IOException {
checkPath(journal);
Path temporary = Files.createTempFile(root, ".receipts-", ".tmp");
try {
try (FileOutputStream output = new FileOutputStream(temporary.toFile());
Writer writer = new OutputStreamWriter(output, StandardCharsets.UTF_8)) {
GSON.toJson(next, writer);
writer.flush();
output.getFD().sync();
}
Files.move(temporary, journal, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
syncDirectory(root);
} finally {
Files.deleteIfExists(temporary);
}
}
private static void syncDirectory(Path directory) {
// Some Android filesystems reject directory channels. File contents were already fsynced;
// the rename is atomic either way. Do not report rollback after a committed rename.
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (IOException | UnsupportedOperationException ignored) {
// Best available portable Java directory durability.
}
}
}
@@ -1,266 +0,0 @@
package com.saphid.framecontrol;
import android.app.*;
import android.content.*;
import android.content.pm.ServiceInfo;
import android.os.*;
import java.io.File;
import java.util.*;
import java.util.concurrent.*;
/** Explicit user-started connected-device session, with a visible Stop action. */
public final class SyncService extends Service {
static final String CHANNEL = "headset-sync";
static final Object SETTINGS_LOCK = new Object();
private static final int NOTIFICATION = 108;
private static final SessionGate GATE = new SessionGate();
private static volatile SyncService instance;
private final ExecutorService worker = Executors.newSingleThreadExecutor();
private long session;
private volatile boolean stopping;
private volatile FrameConnection connection;
private volatile SyncEngine engine;
private volatile String pin;
static SharedPreferences prefs(Context c) {
return c.getSharedPreferences("frame", MODE_PRIVATE);
}
static String state() {
return GATE.state();
}
static String page() {
return GATE.page();
}
static boolean running() {
SyncService service = instance;
return service != null && !service.stopping && GATE.active(service.session);
}
static String install(Context c) {
SharedPreferences p = prefs(c);
String id = p.getString("install", null);
if (id == null) {
id = UUID.randomUUID().toString();
if (!p.edit().putString("install", id).commit())
throw new IllegalStateException("Could not save this phone's identity.");
}
return id;
}
static File library(Context c, String pin) throws Exception {
return new File(c.getFilesDir(), "screenshots/" + FrameConnection.hash(pin));
}
static void gallery(Context c, boolean value) throws Exception {
synchronized (SETTINGS_LOCK) {
SyncService service = instance;
// Journal first: a failed disk write must not appear as a saved setting.
String pin = prefs(c).getString("pin", null);
SyncEngine live = service == null ? null : service.engine;
// A closed engine belongs to a stopped session; update its library from disk instead.
if (live != null && !live.setGallery(value)) live = null;
if (!value) {
// The setting is global. Latch the opt-out in every retained library,
// including headsets that are not currently paired or connected.
File libraries = new File(c.getFilesDir(), "screenshots");
if (libraries.exists()) {
File[] roots = libraries.listFiles();
if (roots == null) throw new java.io.IOException("Could not read screenshot libraries.");
File activeRoot = live != null && pin != null ? library(c, pin) : null;
for (File root : roots) {
if (!root.getName().matches("[0-9a-f]{64}")) continue;
if (activeRoot != null && root.equals(activeRoot)) continue;
new SyncEngine(root, false).setGallery(false);
}
}
} else if (live == null && pin != null) {
new SyncEngine(library(c, pin), true).setGallery(true);
}
if (!prefs(c).edit().putBoolean("gallery", value).commit())
throw new java.io.IOException("Could not save the Gallery setting.");
// Interrupted exports can leave hidden pending rows; app-only receipts never retry them.
if (!value) GalleryExporter.discardPending(c);
}
}
/** Read-only, for a background thread. Never creates or rewrites a library. */
static List<SyncEngine.Item> items(Context c, String pin) throws Exception {
if (pin == null) return Collections.emptyList();
SyncService service = instance;
SyncEngine live = service == null ? null : service.engine;
if (live != null && !live.isClosed() && pin.equals(service.pin)) return live.items();
return SyncEngine.snapshot(library(c, pin));
}
@Override
public void onCreate() {
super.onCreate();
session = GATE.begin();
instance = this;
stopping = false;
NotificationManager manager = getSystemService(NotificationManager.class);
manager.createNotificationChannel(
new NotificationChannel(
CHANNEL, "Headset screenshot sync", NotificationManager.IMPORTANCE_LOW));
}
private Notification notification(String message) {
PendingIntent open =
PendingIntent.getActivity(
this,
0,
new Intent(this, MainActivity.class),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
PendingIntent stop =
PendingIntent.getService(
this,
1,
new Intent(this, SyncService.class).setAction("STOP"),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
return new Notification.Builder(this, CHANNEL)
.setSmallIcon(com.saphid.framecontrol.R.drawable.ic_frame)
.setContentTitle("Frame Control")
.setContentText(message)
.setContentIntent(open)
.setOngoing(true)
.addAction(new Notification.Action.Builder(null, "Stop syncing", stop).build())
.build();
}
/** A stale worker cannot reach the notification once its session has ended. */
private void status(String text) {
GATE.status(
session,
text,
t -> getSystemService(NotificationManager.class).notify(NOTIFICATION, notification(t)));
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && "STOP".equals(intent.getAction())) {
stopSelf();
return START_NOT_STICKY;
}
if (connection != null || stopping) return START_NOT_STICKY;
String text = "Connecting to headset";
GATE.status(session, text, null);
if (Build.VERSION.SDK_INT >= 34)
startForeground(
NOTIFICATION, notification(text), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE);
else startForeground(NOTIFICATION, notification(text));
connection = new FrameConnection(this);
worker.execute(this::runSession);
return START_NOT_STICKY;
}
private void runSession() {
try {
SharedPreferences p = prefs(this);
String pin = p.getString("pin", null);
if (pin == null) throw new java.io.IOException("Pair with your headset first.");
SyncEngine created;
synchronized (SETTINGS_LOCK) {
boolean gallery = p.getBoolean("gallery", true);
created = new SyncEngine(library(this, pin), gallery);
// Retry cleanup of hidden rows if an earlier opt-out's cleanup was interrupted.
if (!gallery) GalleryExporter.discardPending(this);
this.pin = pin;
engine = created;
}
// Pairs with onDestroy: it sets stopping before reading engine.
if (stopping) created.close();
while (!stopping) {
try {
FrameConnection active = new FrameConnection(this);
connection = active;
if (stopping) break;
status("Connecting to headset");
active.connect(
p.getString("host", ""),
p.getInt("port", 22),
p.getString("user", "steamos"),
pin,
install(this));
if (stopping) {
active.close();
break;
}
GATE.page(session, active.page());
status("Syncing new screenshots");
GalleryExporter exporter = new GalleryExporter(this, pin, SETTINGS_LOCK);
while (!stopping && active.connected()) {
try {
SyncEngine.Listing listing = active.listing();
created.reconcile(listing, active, exporter);
status(progress(listing, created.skipped()));
} catch (Exception e) {
status("Screenshot pending · " + message(e));
}
Thread.sleep(4000);
}
if (!stopping) throw new java.io.IOException("Connection lost. Reconnecting…");
} catch (Exception e) {
GATE.page(session, null);
FrameConnection c = connection;
if (c != null) c.close();
if (stopping) break;
status("Waiting for headset · " + message(e));
Thread.sleep(10000);
}
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
} catch (Exception e) {
GATE.status(session, message(e), null);
} finally {
GATE.page(session, null);
FrameConnection c = connection;
if (c != null) c.close();
// Only a still-current session may stop the service; Stop already ended a stale one.
GATE.ifActive(session, this::stopSelf);
}
}
static String progress(SyncEngine.Listing listing, int skipped) {
if (listing.accounts.isEmpty()) return "Waiting for a Steam account on the headset";
String text = "Syncing new screenshots";
if (skipped == 1) text += " · 1 file skipped (empty or over 30 MB)";
else if (skipped > 1) text += " · " + skipped + " files skipped (empty or over 30 MB)";
return text;
}
static String message(Exception e) {
String text = e.getMessage();
if (text == null || text.trim().isEmpty()) return "Try reconnecting.";
if (text.contains("HostKey")
|| text.contains("HostKey has been changed")
|| text.contains("reject HostKey")) return "Headset identity changed. Stop and pair again.";
if (text.contains("Auth fail")) return "Headset login refused. Pair again.";
return text.length() > 180 ? text.substring(0, 180) : text;
}
@Override
public void onDestroy() {
stopping = true;
// After this, the old worker cannot post status, page or notification updates.
GATE.end(session);
SyncEngine live = engine;
if (live != null) live.close();
FrameConnection c = connection;
if (c != null) c.close();
worker.shutdownNow();
if (instance == this) instance = null;
stopForeground(STOP_FOREGROUND_REMOVE);
// Also clear a notification posted just before the gate closed.
getSystemService(NotificationManager.class).cancel(NOTIFICATION);
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
}
@@ -1,3 +0,0 @@
<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>
@@ -1,9 +0,0 @@
<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>
@@ -1,4 +0,0 @@
<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>
@@ -1 +0,0 @@
<paths><files-path name="screenshots" path="screenshots/" /></paths>
@@ -1,4 +0,0 @@
<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>
@@ -1,106 +0,0 @@
package com.saphid.framecontrol;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Arrays;
import java.util.Collections;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.*;
/** Runs the generated headset shell against a temporary HOME. */
public class FrameConnectionTest {
@Rule public TemporaryFolder temporary = new TemporaryFolder();
private static final String KEY = "rsa-sha2-256 AAAAB3NzaC1yc2EAAAADAQAB frame-control 'phone'";
private static final String MAC = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMac mac";
private static final class Result {
final int exit;
final String output;
Result(int exit, String output) { this.exit = exit; this.output = output; }
}
private static Result sh(File home, String command) throws Exception {
ProcessBuilder builder = new ProcessBuilder("/bin/sh", "-c", command);
builder.environment().put("HOME", home.getPath());
builder.redirectError(ProcessBuilder.Redirect.to(new File("/dev/null")));
Process process = builder.start();
java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = process.getInputStream()) {
byte[] buffer = new byte[4096];
for (int n; (n = in.read(buffer)) != -1; ) bytes.write(buffer, 0, n);
}
String output = new String(bytes.toByteArray(), StandardCharsets.UTF_8);
assertTrue("Shell timed out", process.waitFor(10, TimeUnit.SECONDS));
return new Result(process.exitValue(), output);
}
private String authorize(String existing) throws Exception {
File home = temporary.newFolder();
File keys = new File(home, ".ssh/authorized_keys");
if (existing != null) {
assertTrue(keys.getParentFile().mkdirs());
Files.write(keys.toPath(), existing.getBytes(StandardCharsets.UTF_8));
}
for (int i = 0; i < 2; i++) assertEquals(0, sh(home, FrameConnection.authorizeCommand(KEY)).exit);
assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(keys.toPath())));
return new String(Files.readAllBytes(keys.toPath()), StandardCharsets.UTF_8);
}
@Test public void authorizingTerminatesALastLineWithoutNewlineAndIsIdempotent() throws Exception {
assertEquals(KEY + "\n", authorize(null));
assertEquals(KEY + "\n", authorize(""));
assertEquals(MAC + "\n" + KEY + "\n", authorize(MAC));
assertEquals(MAC + "\n" + KEY + "\n", authorize(MAC + "\n"));
assertEquals(MAC + "\nother\n" + KEY + "\n", authorize(MAC + "\nother"));
// Already present as an unterminated last line: left exactly as it was.
assertEquals(MAC + "\n" + KEY, authorize(MAC + "\n" + KEY));
}
@Test public void accountListingSeparatesAbsentUserdataFromEmptyAccounts() throws Exception {
File home = temporary.newFolder();
Result absent = sh(home, FrameConnection.ACCOUNTS_COMMAND);
assertEquals(0, absent.exit);
assertEquals("absent\n", absent.output);
assertTrue(FrameConnection.parseAccounts(absent.output).isEmpty());
File userdata = new File(home, ".local/share/Steam/userdata");
assertTrue(userdata.mkdirs());
assertEquals("present\n", sh(home, FrameConnection.ACCOUNTS_COMMAND).output);
for (String directory : Arrays.asList("123", "4567890", "anonymous", "0123456789012", "12a"))
assertTrue(new File(userdata, directory).mkdir());
Files.write(new File(userdata, "999").toPath(), new byte[]{1});
Result present = sh(home, FrameConnection.ACCOUNTS_COMMAND);
assertEquals(0, present.exit);
assertEquals(new TreeSet<>(Arrays.asList("123", "4567890")),
FrameConnection.parseAccounts(present.output));
// An unreadable root is an error, never an empty account list.
File broken = temporary.newFolder();
File file = new File(broken, ".local/share/Steam/userdata");
assertTrue(file.getParentFile().mkdirs());
Files.write(file.toPath(), new byte[]{1});
assertNotEquals(0, sh(broken, FrameConnection.ACCOUNTS_COMMAND).exit);
}
@Test public void unexpectedAccountOutputIsRejected() throws Exception {
for (String output : Arrays.asList("", "absent", "present", "present\n123", "absent\n123\n",
"present\nabc\n", "present\n\n", "ready\n", "present\n12 3\n")) {
try {
FrameConnection.parseAccounts(output);
fail("Accepted " + output);
} catch (IOException expected) {
// Fail closed: the poll is retried and no baseline changes.
}
}
assertEquals(Collections.emptySet(), FrameConnection.parseAccounts("present\n"));
}
}
@@ -1,99 +0,0 @@
package com.saphid.framecontrol;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.*;
public class SessionGateTest {
@Test public void endedSessionCannotPublishPageOrStop() {
SessionGate gate = new SessionGate();
List<String> shown = new ArrayList<>();
long session = gate.begin();
assertTrue(gate.status(session, "Syncing new screenshots", shown::add));
assertTrue(gate.page(session, "http://127.0.0.1:1/?key=a"));
assertTrue(gate.end(session));
assertFalse(gate.running());
assertNull(gate.page());
// The worker finishes its download after Stop and reports the closed tunnel.
assertFalse(gate.status(session, "Screenshot pending · Connection reset", shown::add));
assertFalse(gate.status(session, "Syncing new screenshots", shown::add));
assertFalse(gate.page(session, "http://127.0.0.1:1/?key=a"));
AtomicBoolean stopped = new AtomicBoolean();
assertFalse(gate.ifActive(session, () -> stopped.set(true)));
assertFalse(stopped.get());
assertEquals(Collections.singletonList("Syncing new screenshots"), shown);
assertEquals(SessionGate.DISCONNECTED, gate.state());
}
@Test public void lateWorkerCannotChangeANewerSession() {
SessionGate gate = new SessionGate();
List<String> shown = new ArrayList<>();
long first = gate.begin();
gate.end(first);
long second = gate.begin();
assertTrue(gate.status(second, "Connecting to headset", shown::add));
assertTrue(gate.page(second, "page-2"));
assertFalse(gate.status(first, "Syncing new screenshots", shown::add));
assertFalse(gate.page(first, null));
assertFalse(gate.end(first));
assertFalse(gate.ifActive(first, () -> fail("Stale session stopped the new one")));
assertEquals("Connecting to headset", gate.state());
assertEquals("page-2", gate.page());
assertTrue(gate.running());
assertEquals(Collections.singletonList("Connecting to headset"), shown);
long third = gate.begin(); // Even without an end, a newer session supersedes.
assertFalse(gate.status(second, "Syncing new screenshots", null));
assertTrue(gate.active(third));
assertNull(gate.page());
}
@Test public void endClearsProgressButKeepsFinalError() {
for (String progress : Arrays.asList("Syncing new screenshots",
"Syncing new screenshots · 1 file skipped (empty or over 30 MB)",
"Waiting for headset · Connection refused", "Waiting for a Steam account on the headset",
"Connecting to headset", "Screenshot pending · Download failed")) {
SessionGate gate = new SessionGate();
long session = gate.begin();
gate.status(session, progress, null);
gate.end(session);
assertEquals(progress, SessionGate.DISCONNECTED, gate.state());
}
SessionGate gate = new SessionGate();
long session = gate.begin();
gate.status(session, "Pair with your headset first.", null);
gate.end(session);
assertEquals("Pair with your headset first.", gate.state());
}
@Test public void endWaitsForAnInFlightNotificationAndNothingPostsAfterIt() throws Exception {
SessionGate gate = new SessionGate();
long session = gate.begin();
List<String> events = Collections.synchronizedList(new ArrayList<>());
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
Thread worker = new Thread(() -> gate.status(session, "Syncing new screenshots", text -> {
events.add("posting");
entered.countDown();
try { release.await(5, TimeUnit.SECONDS); } catch (InterruptedException e) { return; }
events.add("posted");
}));
worker.start();
assertTrue(entered.await(5, TimeUnit.SECONDS));
Thread stop = new Thread(() -> { gate.end(session); events.add("ended"); });
stop.start();
stop.join(200);
assertTrue("Stop must wait for the notification in flight", stop.isAlive());
release.countDown();
worker.join(5000);
stop.join(5000);
assertFalse(gate.status(session, "Syncing new screenshots", text -> events.add("late")));
assertEquals(Arrays.asList("posting", "posted", "ended"), events);
}
}
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
plugins { id 'com.android.application' version '9.1.0' apply false }
-2
View File
@@ -1,2 +0,0 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
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
-248
View File
@@ -1,248 +0,0 @@
#!/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" "$@"
-93
View File
@@ -1,93 +0,0 @@
@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
-13
View File
@@ -1,13 +0,0 @@
#!/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')
-4
View File
@@ -1,4 +0,0 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } }
rootProject.name = 'FrameControl'
include ':app'
-10
View File
@@ -11,7 +11,6 @@ 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);
@@ -29,7 +28,6 @@ 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;
@@ -282,14 +280,6 @@ 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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.13",
"version": "0.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.13",
"version": "0.4.7",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -12
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.13",
"version": "0.4.7",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -39,7 +39,6 @@
"preload.js",
"install-link.js",
"updater.js",
"preferences.js",
"meta-signin.js",
"package.json",
"build/icon.png"
@@ -198,16 +197,7 @@
"zip"
],
"icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}",
"extraResources": [
{
"from": "build/desktop/${os}-${arch}",
"to": "desktop/bundle",
"filter": [
"**/*"
]
}
]
"artifactName": "Frame-Control-win-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
-27
View File
@@ -1,27 +0,0 @@
// 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 };
+2 -7
View File
@@ -1,5 +1,5 @@
// 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
// 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
// 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,13 +11,8 @@
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
-68
View File
@@ -1,68 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { 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/);
});
@@ -1,311 +0,0 @@
{
"label": "pc-agent-frame-arm64-hairpin",
"date": "2026-09-28T22:36:19",
"commit": "c5fc552+dirty",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 351,
"frames_drawn": 351,
"frames_shown": 324,
"duration_s": 10.1,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.5,
"n": 351
},
"queue": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 351
},
"encode": {
"p50": 2.0,
"p95": 2.9,
"p99": 3.5,
"n": 351
},
"socket": {
"p50": 0.1,
"p95": 0.4,
"p99": 0.6,
"n": 351
},
"network": {
"p50": 11.6,
"p95": 75.7,
"p99": 130.1,
"n": 351
},
"decode": {
"p50": 0.7,
"p95": 2.1,
"p99": 4.1,
"n": 351
},
"draw": {
"p50": 0.4,
"p95": 0.8,
"p99": 1.1,
"n": 351
},
"present": {
"p50": 0.8,
"p95": 18.1,
"p99": 24.9,
"n": 324
},
"content": {
"p50": 15.1,
"p95": 80.3,
"p99": 134.2,
"n": 351
},
"content_shown": {
"p50": 21.0,
"p95": 71.8,
"p99": 146.3,
"n": 324
}
},
"fps": 34.8,
"fps_shown": 32.0,
"late_pct": 53.41,
"stall_max": 226.8,
"stalls_over_100ms": 7,
"mbps": 0.26,
"keyframes": 6,
"size": "1280x720",
"captured": 607,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 43.3,
"p95": 98.4,
"p99": 110.1,
"n": 18
},
"input_shown_ms": {
"p50": 54.7,
"p95": 98.9,
"p99": 114.0,
"n": 18
},
"input_parts_ms": {
"uplink": {
"p50": 17.1,
"p95": 48.3,
"p99": 53.1,
"n": 18
},
"mac": {
"p50": 12.5,
"p95": 32.9,
"p99": 35.7,
"n": 18
},
"back": {
"p50": 16.8,
"p95": 50.0,
"p99": 55.9,
"n": 18
}
},
"inputs": {
"asked": 18,
"sent": 18,
"seen": 18
},
"timeline": [
{
"t": 0,
"fps": 38,
"mbps": 0.3,
"content_p50": 14.8,
"content_p95": 25.3,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 33,
"mbps": 0.24,
"content_p50": 15.6,
"content_p95": 103.5,
"bitrate": 3845448,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 39,
"mbps": 0.28,
"content_p50": 15.6,
"content_p95": 29.9,
"bitrate": 5283790,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 25,
"mbps": 0.21,
"content_p50": 17.8,
"content_p95": 175.9,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 34,
"mbps": 0.22,
"content_p50": 15.4,
"content_p95": 69.8,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 38,
"mbps": 0.29,
"content_p50": 17.7,
"content_p95": 68.7,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 38,
"mbps": 0.26,
"content_p50": 15.0,
"content_p95": 18.2,
"bitrate": 3091280,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 30,
"mbps": 0.22,
"content_p50": 19.2,
"content_p95": 111.0,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 35,
"mbps": 0.26,
"content_p50": 13.4,
"content_p95": 102.6,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.28,
"content_p50": 12.3,
"content_p95": 20.9,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 3,
"mbps": 0.02,
"content_p50": 20.9,
"content_p95": 24.7,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 80.3,
"input_p50": 43.3,
"input_p95": 98.4,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "acceptable",
"input_p50": "local",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "acceptable"
},
"controller": {
"target": 3442081,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 13.334,
"inFlight": 1,
"slack": 71.738,
"adapt": true
},
"events": [
{
"t": 3.77,
"e": "target 2764800 bit/s; tier 0"
},
{
"t": 7.42,
"e": "target 1725204 bit/s; tier 0"
}
],
"source_fps": 60.1,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
"show_s": 1.88,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
@@ -1,302 +0,0 @@
{
"label": "pc-agent-frame-arm64-final",
"date": "2026-09-28T22:55:27",
"commit": "cd20243",
"config": {
"quality": "balanced",
"mode": "native",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 400,
"frames_drawn": 400,
"frames_shown": 339,
"duration_s": 10.2,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.1,
"p99": 0.2,
"n": 400
},
"queue": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.4,
"n": 400
},
"encode": {
"p50": 2.0,
"p95": 2.7,
"p99": 3.1,
"n": 400
},
"socket": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 400
},
"network": {
"p50": 11.8,
"p95": 25.2,
"p99": 37.8,
"n": 400
},
"decode": {
"p50": 0.7,
"p95": 1.5,
"p99": 2.0,
"n": 400
},
"draw": {
"p50": 0.3,
"p95": 0.7,
"p99": 1.0,
"n": 400
},
"present": {
"p50": 0.8,
"p95": 19.3,
"p99": 26.8,
"n": 339
},
"content": {
"p50": 15.1,
"p95": 28.6,
"p99": 41.3,
"n": 400
},
"content_shown": {
"p50": 17.6,
"p95": 39.0,
"p99": 47.2,
"n": 339
}
},
"fps": 39.3,
"fps_shown": 33.4,
"late_pct": 50.25,
"stall_max": 69.3,
"stalls_over_100ms": 0,
"mbps": 0.35,
"keyframes": 6,
"size": "1280x720",
"captured": 612,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 76.6,
"p95": 98.2,
"p99": 127.8,
"n": 19
},
"input_shown_ms": {
"p50": 80.5,
"p95": 107.8,
"p99": 128.1,
"n": 16
},
"input_parts_ms": {
"uplink": {
"p50": 36.5,
"p95": 57.6,
"p99": 83.2,
"n": 19
},
"mac": {
"p50": 14.6,
"p95": 31.3,
"p99": 31.7,
"n": 19
},
"back": {
"p50": 15.1,
"p95": 32.8,
"p99": 36.2,
"n": 19
}
},
"inputs": {
"asked": 19,
"sent": 19,
"seen": 19
},
"timeline": [
{
"t": 0,
"fps": 40,
"mbps": 0.34,
"content_p50": 11.7,
"content_p95": 27.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 39,
"mbps": 0.37,
"content_p50": 11.9,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 41,
"mbps": 0.35,
"content_p50": 11.4,
"content_p95": 16.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 39,
"mbps": 0.37,
"content_p50": 12.7,
"content_p95": 28.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 38,
"mbps": 0.33,
"content_p50": 15.3,
"content_p95": 30.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 39,
"mbps": 0.37,
"content_p50": 16.7,
"content_p95": 28.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 41,
"mbps": 0.38,
"content_p50": 15.9,
"content_p95": 29.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 41,
"mbps": 0.31,
"content_p50": 15.6,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 38,
"mbps": 0.38,
"content_p50": 15.5,
"content_p95": 25.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.29,
"content_p50": 16.7,
"content_p95": 28.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 6,
"mbps": 0.03,
"content_p50": 17.6,
"content_p95": 23.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 28.6,
"input_p50": 76.6,
"input_p95": 98.2,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "acceptable",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "local"
},
"controller": {
"target": 5529600,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 12.332,
"inFlight": 0,
"slack": 74.12,
"adapt": true
},
"events": [],
"source_fps": 60.0,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
"show_s": 1.75,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env python3
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
import os
from pathlib import Path
import subprocess
import sys
root = Path(__file__).resolve().parent
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
out = root / ('controller' + suffix)
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
else:
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
subprocess.run(cmd, cwd=root, check=True)
print(out)
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
"""Build our native PC adapter and bundle its ordinary shared libraries.
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
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parent
def output(*args):
return subprocess.check_output(args, text=True).strip()
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']
if not win:
modules += ['gio-unix-2.0']
import shlex
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
if not win:
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
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']
if with_x264:
names.append('x264')
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
pending = [lib]
for name in names:
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
if not matches:
raise SystemExit('Missing GStreamer library: ' + name)
for source in matches:
target = plugins_out / source.name
shutil.copy2(source, target)
pending.append(source)
if not win:
# PipeWire dynamically loads support and protocol modules. ldd cannot
# discover those; bundle them with a private client-only config instead
# of accidentally loading a different distro's client modules/config.
libdir = plugin_dir.parent
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
'pipewire-0.3/libpipewire-module-client-node.so',
'pipewire-0.3/libpipewire-module-adapter.so',
'spa-0.2/support/libspa-support.so',
'spa-0.2/support/libspa-dbus.so',
'spa-0.2/videoconvert/libspa-videoconvert.so'):
source = libdir / relative
if not source.is_file():
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
target = destination / 'lib' / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
pending.append(source)
config = destination / 'share' / 'pipewire'
config.mkdir(parents=True, exist_ok=True)
(config / 'client.conf').write_text(
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
'context.modules = [\n'
' { name = libpipewire-module-protocol-native }\n'
' { name = libpipewire-module-client-node }\n'
' { name = libpipewire-module-adapter }\n'
']\n')
copied = set()
while pending:
binary = pending.pop()
if win:
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
dependencies = [prefix / 'bin' / name for name in imported]
else:
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
for dep in dependencies:
if not dep.is_file() or dep.name in copied:
continue
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
continue
shutil.copy2(dep, libs_out / dep.name)
copied.add(dep.name)
pending.append(dep)
# License texts and package provenance accompany the dynamically linked
# libraries. Distribution builders keep the upstream package/source URLs.
licenses = destination / 'licenses'
licenses.mkdir(exist_ok=True)
if win:
source = prefix / 'share' / 'licenses'
if source.exists():
shutil.copytree(source, licenses, dirs_exist_ok=True)
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
else:
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
# Debian copyright files contain licenses and source homepage details.
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'),
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')
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)
-134
View File
@@ -1,134 +0,0 @@
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
#include "controller.h"
#include <gst/gst.h>
#include <gst/app/gstappsink.h>
#include <gst/video/video-event.h>
#include <string.h>
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
typedef struct {
GstElement *pipeline, *encoder, *sink, *raw_queue;
Gate gate;
GstSample *sample;
GstMapInfo map;
int mapped;
char error[512];
} Capture;
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
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;
int64_t now=fc_now(), cap=now;
GstClock *clock=gst_element_get_clock(c->pipeline);
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
}
if(clock)gst_object_unref(clock);
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
int phase=0;
for(;;) {
/* While congested, prefer a newer raw picture queued upstream. If
* nothing changed, retain this last picture until the gate opens;
* an idle window must not stay stale after a dropped final update. */
guint queued=0;
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
if(queued)return GST_PAD_PROBE_DROP;
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
phase=2;g_usleep(2000);
}
}
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
c->pipeline=gst_parse_launch(pipeline,&e);
if(e || !c->pipeline) {
g_strlcpy(error,e?e->message:"No pipeline",capacity);
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
if(!c->encoder || !c->sink || !raw) {
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
GstPad *p=gst_element_get_static_pad(raw,"src");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
p=gst_element_get_static_pad(c->encoder,"sink");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
return c;
}
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
GstBus *bus=gst_element_get_bus(c->pipeline);
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
if(m) {
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
gst_message_unref(m);return -1;
}
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
if(!c->sample) {
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
}
return 0;
}
GstBuffer *b=gst_sample_get_buffer(c->sample);
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
}
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
FC_API void fc_capture_bitrate(Capture *c,int bps) {
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
}
FC_API void fc_capture_test(Capture *c,uint32_t input) {
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
if(source) {
/* Each benchmark click visibly changes the ball, before injection is
* timestamped. This is a response, not merely a protocol input echo. */
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
}
}
FC_API void fc_capture_key(Capture *c) {
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
}
FC_API void fc_capture_close(Capture *c) {
gst_element_set_state(c->pipeline,GST_STATE_NULL);
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
if(c->sample)gst_sample_unref(c->sample);
if(c->raw_queue)gst_object_unref(c->raw_queue);
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
}
-130
View File
@@ -1,130 +0,0 @@
/* 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>
#define CAP 1024
#define MIN(a,b) ((a)<(b)?(a):(b))
#define MAX(a,b) ((a)>(b)?(a):(b))
typedef struct { int64_t t, v; uint32_t seq; } Sample;
typedef struct { Sample a[CAP]; int n; } Samples;
struct FCController {
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
int64_t decrease, increase, below, above, slack;
Samples flight, rtts, acked, sent, captures;
};
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
static const double floors[] = {.45,.28,.16,.08,0};
static void remove_first(Samples *s, int n) {
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
}
static void add(Samples *s, Sample v, int cap) {
if (s->n >= cap) remove_first(s, 1);
s->a[s->n++] = v;
}
static void expire(Samples *s, int64_t oldest) {
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
remove_first(s,n);
}
static int64_t base(FCController *c) {
int64_t v=0;
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
return v;
}
static int compare(const void *a, const void *b) {
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
return (x>y)-(x<y);
}
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
int64_t a[CAP]; int n=0;
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
if(!n) return 0;
qsort(a,(size_t)n,sizeof(int64_t),compare);
return a[n*numerator/denominator];
}
static int rate(Samples *s) {
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
return (int)MIN(total*16,2147483647);
}
FCController *fc_new(int max_fps,int enabled) {
FCController *c=calloc(1,sizeof(*c));
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
return c;
}
void fc_free(FCController *c) {free(c);}
void fc_ceiling(FCController *c,int bps) {
if(!c->target || c->target>bps) c->target=bps;
c->ceiling=bps;
}
int fc_gate(FCController *c,int64_t now,int counts) {
if(!c->enabled || !c->saw_ack) return 1;
expire(&c->flight,now-2000000);
if(!c->flight.n) return 1;
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
if(counts) c->held++;
return 0;
}
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
}
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
c->saw_ack=1;
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
}
return 0;
}
int fc_update(FCController *c,int64_t now) {
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
expire(&c->captures,now-1000000);
if(!c->enabled || c->ceiling<=0) return 0;
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
int64_t jitter=spread ? spread-baseline : 0;
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
int64_t recent=quantile(&c->rtts,now-300000,1,2);
int64_t queue=recent ? recent-baseline : 0;
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
int delivered=rate(&c->acked),sending=rate(&c->sent);
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
int congested=signal && c->signal;c->signal=signal;c->held=0;
if(congested && now-c->decrease>300000) {
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
c->target=next;c->decrease=now;
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
}
double share=(double)c->target/MAX(c->ceiling,1);
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
if(!c->below)c->below=now;
if(now-c->below>500000) {
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
c->below=0;
}
} else c->below=0;
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
if(!c->above)c->above=now;
if(now-c->above>2000000) {c->tier--;c->above=0;}
} else c->above=0;
return c->target;
}
int64_t fc_value(FCController *c,int field) {
switch(field) {
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
default:return 0;
}
}
-29
View File
@@ -1,29 +0,0 @@
/* Shared latency controller. Times are monotonic host microseconds.
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
* encoded inter frame would break the decoder's reference chain. */
#ifndef FRAME_CONTROLLER_H
#define FRAME_CONTROLLER_H
#include <stdint.h>
#ifdef _WIN32
#define FC_API __declspec(dllexport)
#else
#define FC_API
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct FCController FCController;
FC_API FCController *fc_new(int fps, int enabled);
FC_API void fc_free(FCController *c);
FC_API void fc_ceiling(FCController *c, int bps);
FC_API int fc_gate(FCController *c, int64_t now, int counts);
FC_API void fc_capture(FCController *c, int64_t now);
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
FC_API int fc_update(FCController *c, int64_t now);
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
FC_API int64_t fc_value(FCController *c, int field);
#ifdef __cplusplus
}
#endif
#endif
-147
View File
@@ -1,147 +0,0 @@
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
* Input uses only the devices granted in Start's response. */
#ifndef _WIN32
#include "controller.h"
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <unistd.h>
#include <string.h>
#define BUS "org.freedesktop.portal.Desktop"
#define PATH "/org/freedesktop/portal/desktop"
#define RD "org.freedesktop.portal.RemoteDesktop"
#define SC "org.freedesktop.portal.ScreenCast"
typedef struct {
GDBusConnection *bus;
GMainContext *context;
char *session;
int fd;
unsigned node, devices;
int width,height;
} Portal;
typedef struct {GVariant *result;int done;unsigned code;} Response;
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
const char *signal,GVariant *params,gpointer data) {
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
}
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
const char *token,char *error,int capacity) {
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
Response r={0};GError *e=NULL;
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
if(reply)g_variant_unref(reply);
int64_t deadline=g_get_monotonic_time()+120000000;
while(!e && !r.done && g_get_monotonic_time()<deadline) {
while(g_main_context_iteration(p->context,FALSE)) {}
g_usleep(10000);
}
if(!r.done) {
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(closed)g_variant_unref(closed);
}
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
if(e || !r.done || r.code) {
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
}
return r.result;
}
FC_API void fc_portal_close(Portal *p) {
if(!p)return;
if(p->session && p->bus) {
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(r)g_variant_unref(r);
}
if(p->fd>=0){close(p->fd);p->fd=-1;}
g_free(p->session);if(p->bus)g_object_unref(p->bus);
if(p->context)g_main_context_unref(p->context);g_free(p);
}
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
* previously consumed protocol socket is not a new connection.
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
* pipewiresrc never takes the fd it is given: its core connects with
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
* close; not closing it would leak one socket per pipeline reopen. The
* caller closes the previous pipeline before asking for a new fd. */
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
GUnixFDList *fds=NULL;GError *e=NULL;
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
if(p->fd>=0)close(p->fd);
p->fd=fd;return fd;
}
FC_API Portal *fc_portal_select(char *error,int capacity) {
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
g_main_context_push_thread_default(p->context);
GError *e=NULL;GVariant *r=NULL;
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
GVariantBuilder b;char token[64],session[64];
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"devices","u",&p->devices);
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
if(streams && g_variant_n_children(streams)>0) {
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
g_variant_unref(props);g_variant_unref(entry);
}
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
g_main_context_pop_thread_default(p->context);return p;
fail:
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
}
FC_API int fc_portal_value(Portal *p,int field) {
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
}
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
if(type==0 && (p->devices&2)) {
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
} else if(type==1 && (p->devices&2)) {
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else if(type==2 && (p->devices&2)) {
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
} else if(type==3 && (p->devices&1)) {
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else return 0;
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
}
#endif
+1 -1
View File
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
## Assistant panel
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
To put the same page in the headset, with the HTTP server still running:
```sh
-57
View File
@@ -1,57 +0,0 @@
# 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.
-107
View File
@@ -1,107 +0,0 @@
# Frame Control for Android phones
The native Android companion lives in `android/`. It requires Android 10 or
later and a Steam Frame with Developer Mode and SSH enabled. This is a new
implementation, separate from the headset's `frame/android` Lepton tools.
## Pair and sync
1. Enter the headset address, SSH port and username (normally `steamos`).
2. Choose **Pair headset**, compare the displayed SSH fingerprint with the
headset's identity, and accept it. Enter the Developer Mode password once.
The app installs this phone's SSH public key and verifies key login before
storing the pairing. The private key stays in Android Keystore; the
password is not saved. A changed headset host key is refused.
3. Choose **Start syncing** and allow notifications. The persistent notification
shows the active connected-device session and offers **Stop syncing**.
4. Take a new screenshot. Its original downloads into Frame Control. **Save
screenshots to Gallery** is enabled by default and adds it to
`Pictures/Frame Control`. Android 10+ lets an app create its own Gallery
items without broad photo-reading permission.
The first successful listing establishes a history baseline. Existing screenshots
are not imported automatically. The baseline is kept per Steam account: the app
also lists the headset's account folders, so an account that is present (even
with no screenshots yet) is established, and its later captures download. If
Steam's userdata folder is missing, or an account folder appears later (a new
sign-in, or one removed and restored), that account's screenshots at that moment
become its baseline instead of being imported. The status reads "Waiting for a
Steam account on the headset" while no account exists.
The app waits for two equal size/time listings, checks the original's byte count
and image decode, and saves it atomically. Identity includes the paired headset,
Steam account, game and filename. An empty (still being written) or over-30 MB
file is skipped for that poll only and does not hold up other screenshots; the
status shows how many were skipped. A capture still being written when the
baseline is taken downloads once it is complete. Unsafe identifiers, non-finite
times and conflicting duplicate rows still reject the whole listing. The receipt
journal is rewritten only when a receipt changes, not on every poll.
Turning Gallery off keeps automatic downloads inside Frame Control. Pending
exports become app-only; enabling it again does not export those earlier items.
Turning it off also removes this app's hidden, unfinished Gallery rows left by an
interrupted export, for every retained headset library (best effort; retried
when sync starts with Gallery off). Published Gallery images are never removed.
Previously saved Gallery items stay there. Original files remain available from
**Open / share original** when disconnected. The app keeps each headset's
library separately.
Gallery export uses a deterministic app-owned MediaStore row. A pending row is
rewritten after interruption, and a completed row is recognized before inserting
another. A failed export leaves the app original available for retry. Preferences
and the final Gallery commit are serialized so a disabled setting is respected.
Gallery exports are also serialized across sync sessions, so an export still
finishing after **Stop syncing** cannot remove or overwrite an image that a newer
session saves.
**Headset controls** opens the shared Frame Control web interface through a
loopback-only SSH tunnel. The server has a fresh session key and listens only on
the headset's loopback interface. The tunnel and server end with the phone's
SSH session. This initial Android shell does not implement all iPhone-specific
web bridges; native screenshot originals use the downloads screen.
## Background behavior and release boundary
Sync is an explicit, user-started Android `connectedDevice` foreground service.
It can continue after leaving the app while its notification is present and
reconnects when the headset returns. It is not a Firebase push receiver.
Android battery-saving/Doze, a locked phone with its screen off, loss of
network, force-stop or process termination can delay or end the session. The app
holds no wake lock; downloads may wait until the phone wakes. The app does not request battery-optimization
exemption, use unrelated background modes, or restart itself after force-stop.
Restart sync from the app if the session ends.
The full cross-platform request still requires actual locked-phone background
proof. iOS APNs delivery is a separate unimplemented dependency. An emulator
test is not evidence that either physical platform meets that requirement.
Stopping sync ends that session's status and notification; a transfer still
finishing in the background cannot bring them back or change a newer session.
Do not publish this as a verified always-on screenshot feature.
## Build and verify
Use JDK 17 and an Android SDK containing platform 36/build-tools 36.0.0:
```sh
export JAVA_HOME=/path/to/jdk17
export ANDROID_HOME=/path/to/android-sdk
./android/gradlew -p android assembleDebug testDebugUnitTest assembleDebugAndroidTest lintDebug
```
The Gradle wrapper pins version 9.4.1 and its distribution checksum. The build
generates the same reproducible headset bundle as iOS, including the existing
SHA-256-checked KDE Connect package fetch. Generated assets and build outputs
are ignored. No signing key, headset password or machine-specific SDK path is
committed. `assembleDebug` produces `android/app/build/outputs/apk/debug/app-debug.apk`.
It uses a development signing identity, not a configured Play release key.
`assembleDebugAndroidTest` builds instrumentation for real MediaStore round-trip,
retry recovery and app-only behavior. Optional real-headset hooks exist only in
the separate test APK (`ScreenshotDeviceTest`), not in the product app. They
provision a public test key and permit original/Gallery hash readback without
adding a product authentication bypass. Remove the temporary headset key and
fixtures after live tests.
No Play app creation, store upload, invitation or public release is part of a
debug build. Preserve a dedicated release signing identity and verify physical
devices before a production release.
-132
View File
@@ -183,92 +183,6 @@ 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
@@ -431,49 +345,3 @@ 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.
-174
View File
@@ -1,174 +0,0 @@
# 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.
-59
View File
@@ -1,59 +0,0 @@
# 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.
-9
View File
@@ -1,9 +0,0 @@
# 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.
-168
View File
@@ -1,168 +0,0 @@
# 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`
+3 -20
View File
@@ -3,9 +3,8 @@
Frame Control can manage more than one Steam Frame, and each headset can be
reached at more than one address: a LAN IP at home, another at the office, its
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
tab (key 5) lists them. The connection pill in the header says whether the one in
use is connected and how (Tailscale, or the network's name); click it to add,
edit or reorder that headset's addresses, or to see each step of connecting.
tab (key 5) lists them, and the connection pill in the header shows what the
app is doing to reach the one in use, step by step, as it happens.
The code is in three modules, all stdlib-only Python on your computer:
@@ -58,7 +57,7 @@ and ranks them:
5. addresses that only ever worked on other networks;
6. Tailscale addresses while Tailscale is off.
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
Your order on the Devices tab breaks ties. The best-ranked address that answers
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
still trying. If SSH to the winner fails in a way another address could fix
(a different device answered there, or the link dropped), the next one that
@@ -73,16 +72,6 @@ status --json`, including the Mac app's own CLI) with buttons to add their
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
services and checks `ALIAS.local` and `frame.local`.
**The pill's dialog** lists the same addresses, with what each one answered, and
can add, edit, reorder and remove them without leaving the page you're on. When
the headset reports a LAN IP on the same network as this computer and that IP
isn't saved, it offers to add it. The offer puts the address first in the list,
so on that network it wins over the Tailscale name; away from home the Tailscale
name still leads. A new or edited address is tested straight away. While
connected, **Reconnect** applies your changes now rather than at the next
connection: it tries the addresses again, ranked as above, and the best-ranked
one that answers promptly wins. It doesn't pick a particular address.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
@@ -97,12 +86,6 @@ 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.
-34
View File
@@ -1,34 +0,0 @@
{
"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."
]
}
@@ -1,97 +0,0 @@
# Android phone companion: review fixes (2026-10-26)
Base: `ae9df354`. This evidence covers fixes for seven findings from an
independent review of the Android companion (issue #108).
## Status of earlier evidence
The APK, emulator and physical-device evidence in
`android-phone-screenshot-sync-2026-10-24.md` was captured from `ae9df354`.
It is **historical**. It does not show that the fixes below work on a device.
No emulator, phone or headset was used for the initial implementation round.
Later parent verification is recorded below.
## What changed
| Finding | Fix | Covered by |
| --- | --- | --- |
| F1 Stop could leave an ongoing notification or wrong status | `SessionGate` gives each service instance one session token, so an ended session cannot post status, page or notification updates. `SyncEngine.close()` stops all writes before a new session opens the library. `onDestroy` also cancels notification 108. | `SessionGateTest` (4), `closedEngineCannotWriteAfterAnotherEngineTakesOver` |
| F2 Pairing could merge a key into an unterminated last line | `FrameConnection.authorizeCommand` uses the same newline guard as the iPhone app. | `FrameConnectionTest` runs the generated shell against a temporary HOME |
| F3 Every 4-second poll rewrote and fsynced the journal | `reconcile` commits only when a receipt or the account set changes. | 3 tests that turn any journal write into a failure |
| F4 One empty or over-30 MiB file blocked every listing | Out-of-range sizes are skipped for that poll only. Unsafe ids, non-finite times and conflicting duplicates still reject the whole listing. | `emptyOrOversizedFiles…` |
| F5 The screen parsed, and could write, the journal on the UI thread | `SyncEngine.snapshot` is a read-only view. The screen loads it on a background thread, drops results for a headset that is no longer paired, and clears the list when the pairing changes. | `readOnlySnapshotNeverCreatesUpgradesOrRewrites`; existing native `changingHeadsetsRebindsShareToTheSelectedOriginal` compiles |
| F6 An opt-out after an interrupted export left a hidden pending row | `GalleryExporter.discardPending` removes this app's pending rows in `Pictures/Frame Control/` when Gallery is turned off, and again when sync starts with Gallery off. Published rows are kept. | Native `optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages` (compiled, not run) |
| F7 Missing userdata, or a new account, could import its history | Each Steam account gets its own baseline. The app lists the account folders over SSH, so an account with no screenshots yet still counts as established. Version 1 journals upgrade to version 2 and trust only the accounts they already have receipts for. `ui/server.py` is unchanged. | 6 JVM tests plus `accountListingSeparatesAbsentUserdataFromEmptyAccounts` |
Nonblocking items: the workflow's `push` trigger is now `main` only, and the
docs and app text now say that a locked phone can delay transfers.
## Verification
Command (JDK 17, Android SDK platform 36):
```sh
./android/gradlew -p android clean assembleDebug testDebugUnitTest assembleDebugAndroidTest lintDebug --no-daemon
```
- Exit 0. There are 53 JVM tests (34 before), all passing, none skipped.
- Lint reports 0 errors and 12 warnings. All 12 are pre-existing kinds (version hints, i18n, WebView JavaScript).
- Mutation check: restoring the old unconditional commit and the old key append made 4 tests fail.
- `app-debug.apk` SHA-256: `e4a1f498fef6fbc9919ccb4077b8e15b609caea6a2192d3f1bd2b13ea78bcb15`
- `app-debug-androidTest.apk` SHA-256: `f9acb6bc002665cff6a31d0aaf2d528f4b1eaaaa82ead9fb383867e1154f6053`
## Round 2: fixes for the independent Sol review
An independent review (GPT-6.1 Sol, high reasoning) of the fixes above found two
product defects and one test timing defect. All three are addressed below.
| Finding | Fix | Covered by |
| --- | --- | --- |
| R1 A Gallery change before the first poll on a version 1 journal wrote an unreadable journal | A setting-only write now keeps the journal's version, so a version 1 journal stays a valid version 1 journal. Only the first poll upgrades it, and it writes version 2 and the account set together. The baseline policy is unchanged. | JVM `versionOneJournalStaysValidWhenGalleryChangesBeforeFirstPoll` (constructor and toggle paths, immediate reopen and snapshot, then the upgrade). Native `galleryOptOutLatchesPreviouslyPairedLibraries` now includes a retained version 1 library. |
| R2 After Stop, a late export from the old session could delete the Gallery image that the next session had just saved | `GalleryExporter.EXPORTS` is one process-wide lock. Each export holds it from the row lookup through publish or cancellation, across exporter instances and sessions. It is taken before the settings lock and never while holding it. The wait is interruptible and limited to 30 seconds. After that, the item fails for this poll and is retried. | Native `stoppedSessionExportCannotDeleteNextSessionsGalleryImage`. A JVM harness outside the repository runs the real exporter and engine with an in-memory resolver: with the lock, 10 of 10 runs keep the image; with the lock removed, 10 of 10 runs lose it. |
| R3 The headset-switch share test assumed the background library load had finished | The test waits, up to a time limit, until the expected original is shareable. It accepts no button while a library loads. It then checks that only one share button remains and that it shares the new headset's original. | `changingHeadsetsRebindsShareToTheSelectedOriginal` |
The parent's first native run (API 36, APK `e4a1f498…`) ran 5 tests: 4 passed.
`optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages` reached and
passed its assertions, then its cleanup threw `SecurityException` deleting a
row the product had already removed. The test now drops that row from cleanup
after it proves the row is gone.
The parent also ran the account-listing command read-only against the real
headset. It exited 0 and listed one account. That is not proof of the phone's
download pipeline.
Round 2 verification (same command, JDK 17, platform 36):
- Exit 0. There are 54 JVM tests, all passing: `SyncEngineTest` 47, `SessionGateTest` 4, `FrameConnectionTest` 3.
- Lint reports 0 errors and 12 warnings, the same pre-existing kinds as round 1.
- Mutation check for R1: removing the version-preserving line made only the new R1 test fail, with the invalid version 2 header the review described.
- Two clean builds gave the same hashes. They replace the round-1 APKs:
- `app-debug.apk` SHA-256: `139443c8f3246527ed629731b4f2a099939a2ba7590b628cde415f3f0d7ab996`
- `app-debug-androidTest.apk` SHA-256: `3a84246a7b40436fd5fc521154541603050805270e21e052e26b31a5fa1e0cc2`
## Parent verification of the round-2 APKs
The parent installed both round-2 APKs above on the isolated API 36 emulator
and ran the six non-live instrumentation methods explicitly. All six passed,
none skipped; the supervisor exited 0. This includes actual MediaStore
round-trip and retry, retained version 1 library opt-out, asynchronous
headset-switch sharing, hidden pending-row removal, and the FIFO-controlled
Stop/restart export overlap. The chooser was intercepted; no original was
sent to another app.
The native overlap test exercised real MediaProvider rows: after both old and
new exporters finished, the successor's image remained published with matching
original bytes. This verifies the overlap mechanism, rather than a complete
service Stop/restart against a live headset.
The parent also checked the 54 passing JVM XML results, the build's recorded
exit 0, whitespace, and both APK hashes. Native output and sanitized metadata
are in `android-phone-review-fixes-2026-10-26/` beside this document.
## Not verified
- API 29 runtime behavior and physical Android locked/Doze behavior remain unverified.
- The account-listing command ran against the real headset; the final phone download pipeline and password-pairing UI have not been rerun against it.
- A complete service Stop/restart during a live headset download is not proven by the exporter overlap test.
- iOS APNs delivery, physical locked-phone Photos export, production signing, and release remain open.
@@ -1,101 +0,0 @@
1
Performing Streamed Install
Success
Performing Streamed Install
Success
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=
com.saphid.framecontrol.ScreenshotDeviceTest:
INSTRUMENTATION_STATUS: test=changingHeadsetsRebindsShareToTheSelectedOriginal
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=1
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=changingHeadsetsRebindsShareToTheSelectedOriginal
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=2
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=
INSTRUMENTATION_STATUS: test=stoppedSessionExportCannotDeleteNextSessionsGalleryImage
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=2
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=stoppedSessionExportCannotDeleteNextSessionsGalleryImage
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=3
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=
INSTRUMENTATION_STATUS: test=optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=3
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=optOutDiscardsInterruptedPendingRowsButKeepsPublishedImages
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=4
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=
INSTRUMENTATION_STATUS: test=galleryOptOutLatchesPreviouslyPairedLibraries
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=4
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=galleryOptOutLatchesPreviouslyPairedLibraries
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=5
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=
INSTRUMENTATION_STATUS: test=actualGalleryOriginalAndAppOnlySurviveRelaunch
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=5
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=actualGalleryOriginalAndAppOnlySurviveRelaunch
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=6
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=
INSTRUMENTATION_STATUS: test=interruptedMediaStoreRowIsRewrittenWithoutDuplicate
INSTRUMENTATION_STATUS_CODE: 1
INSTRUMENTATION_STATUS: class=com.saphid.framecontrol.ScreenshotDeviceTest
INSTRUMENTATION_STATUS: current=6
INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
INSTRUMENTATION_STATUS: numtests=6
INSTRUMENTATION_STATUS: stream=.
INSTRUMENTATION_STATUS: test=interruptedMediaStoreRowIsRewrittenWithoutDuplicate
INSTRUMENTATION_STATUS_CODE: 0
INSTRUMENTATION_RESULT: stream=
Time: 3.171
OK (6 tests)
INSTRUMENTATION_CODE: -1
@@ -1,51 +0,0 @@
# Independent Android fixes review — 2026-10-26, round 2
**Verdict: no actionable findings in the frozen Android hardening delta.** The previous Sol findings R1, R2 and R3 are addressed. This is a source-slice review, not a release-readiness verdict.
Reviewer: current T3 Codex runtime, `gpt-6.1-sol`, high reasoning. I performed the review myself, without delegation.
## Exact target and boundaries
- Worktree: `/Users/saphid/.t3/worktrees/steam-frame/feat-phone-screenshot-autosync-108`.
- Branch: `feat/phone-screenshot-autosync-108`.
- HEAD: `ae9df3544239afc1f2cab17ec10fe07f6fe9e657`, plus the frozen uncommitted delta.
- All **15 paths** in `/tmp/frame108-android-fixes-review-20261026-r2.json` matched their SHA-256 values before and after review. Manifest SHA-256: `e3417c56926d08faecb6e5beacaf081c9e4fc395ef494c4de999a4b2282900ad`.
- I inspected the complete tracked delta, the untracked SessionGate and test files, the evidence files, and surrounding engine, exporter, service, activity and connection logic. I checked callers and lock order as well as the changed lines.
- I read the original Opus review, first Opus fixes report, previous Sol review and reproductions, latest Opus fixes report, and `docs/evidence/android-phone-review-fixes-2026-10-26.md`.
- No `CODING_STANDARDS.md` exists in the worktree, its ancestor directories, or the primary checkout root. I read `CONTRIBUTING.md`; no relevant violated rule was found.
I made no repository edits, did not read `.frame_pwd` or unrelated dirty primary-checkout files, and used no device, headset or browser. I made no external writes, commits, pushes, PR changes or releases. Review outputs and local test classes are under `/tmp`.
## Previous Sol findings
- **R1 — addressed.** `SyncEngine.java:668-673` now preserves both the existing journal version and its account-set representation in `copy()`. Setting-only writes on v1 stay valid v1. Reconcile upgrades to v2 alongside its account set at `:285-304` and `:337-343`. The new JVM regression covers constructor preference reconciliation and an explicit toggle before any poll, immediate snapshot/reopen, repeated toggles, and the subsequent migration/baseline behavior. It passed in my independent run. The retained-v1 instrumentation path is present and passed in the parent's supplied API 36 log.
- **R2 — addressed.** `GalleryExporter.java:74-84` acquires the process-wide `EXPORTS` lock interruptibly with a 30-second timeout, and releases it in `finally`. It covers deterministic row lookup, byte copying, publication and cancellation deletion across exporter instances. The order is EXPORTS → settings monitor → engine monitor. Neither Gallery toggle nor startup cleanup acquires EXPORTS while holding the settings monitor; I found no inverse acquisition path. The old exporter must finish its row work before its successor recovers/inserts a row. I independently recompiled the frozen exporter and engine and ran the existing bounded HandoffRace reproduction with an in-memory resolver: the successor waited, the old cancellation deleted only its pending row, and the successor's published row survived with a saved receipt. The native FIFO test separately checks actual published-row existence and original bytes; its passing result is parent-supplied evidence, not my own device run.
- **R3 — addressed.** `ScreenshotDeviceTest.java:487-517` tolerates a missing button while the background library loads and waits up to 15 seconds for a share URI different from the previous one. The test verifies A and B bytes, then checks exactly one final share button targeting B. It no longer treats main-queue idleness as library completion. The cleanup adjustment removes only the fixture URI already proven deleted; other cleanup errors remain visible. I inspected this code and the parent's passing instrumentation result; I did not independently execute instrumentation.
## Fresh pass over the earlier fixes
- **F1:** SessionGate serializes visible status/page/notification effects against session end. Permanent engine close rejects subsequent reconciles and setting writes, and invalidates in-flight journal commits. Late engine assignment and stale-worker finalization are guarded. Gate and takeover tests passed; complete live-service lifecycle behavior remains outside this review.
- **F2:** the generated authorized_keys command terminates an unterminated existing line before appending and remains idempotent. The actual shell fixture cases passed locally.
- **F3:** unchanged listings no longer persist the journal, while retryable downloads/exports continue. Observation resets and account-set changes still commit. The write-blocking regression tests passed.
- **F4:** invalid sizes are excluded per item; unsafe identities, non-finite times and conflicting duplicates still reject a poll. Stability resets and completion after a skipped observation are covered by passing JVM tests.
- **F5:** library snapshots do not create or rewrite storage. Activity parsing/file checks run on its library executor, live-engine selection matches the requested pin, pin changes clear the old list, and results for a no-longer-selected pin are discarded. I found no further actionable sharing regression.
- **F6:** cleanup selects app-owned pending rows in the expected folder, serialized against final publication, and keeps published images. It remains best effort. Provider failure can leave hidden appOnly bytes, particularly on API 29 if Gallery is re-enabled before another cleanup. This documented residual limit is not a newly discovered blocker.
- **F7:** per-account baselines handle absent userdata, established empty accounts, newly appearing accounts, and accounts observed between the SSH and screenshot queries. The migration preserves the intended baseline policy. The shell/parser and account-baseline tests passed.
The workflow trigger and documentation changes are consistent with this scope. I found no new concrete regression requiring a fix.
## Actual verification
All review-generated artifacts are in `/tmp/frame108-android-sol-review-fixes-20261026-r2-evidence/`.
1. **Independent JVM execution:** compiled current SyncEngine, SessionGate, FrameConnection, PhoneIdentity and all three JVM test classes with JDK 17, cached declared dependencies and SDK 36 into a fresh `/tmp` classes directory. Direct JUnit run: **54 tests passed**, exit 0. No Gradle task or repository build output was changed. Commands, compile log/exit and JUnit log/exit are saved there.
2. **Independent local handoff execution:** compiled the current GalleryExporter against those classes and the existing HandoffRace Android substitutes. Compile and execution both exited 0. Output ended with `exists=true pending=false receipt=saved` and `RESULT: published row survived`. This reuses the inspected existing harness and proves the application-level interleaving; it is not MediaProvider instrumentation. See `race.log` and `race.rc`.
3. **Parent evidence inspected:** final clean build log and exit file record success; existing XML totals are 47 engine + 4 gate + 3 connection tests, all zero failures/errors/skips. The frozen API 36 instrumentation log records **six passing tests, no skips**, and supervisor exit 0. I did not rerun assemble, lint or instrumentation. The parent's lint result is 0 errors and 12 existing warnings.
4. **APK hashes independently recomputed:** app `139443c8f3246527ed629731b4f2a099939a2ba7590b628cde415f3f0d7ab996`; test `3a84246a7b40436fd5fc521154541603050805270e21e052e26b31a5fa1e0cc2`. Both match the supplied round-2 evidence.
5. **Freeze/whitespace:** all 15 source/evidence hashes matched again after checks; branch and HEAD matched; `git diff --check` exited 0. Final hashes and whitespace result are saved in the evidence directory.
## Verification limits
Physical locked/Doze behavior, API 29 runtime behavior, complete service Stop/restart during a live headset download, the final phone-to-headset pipeline, iOS APNs/locked Photos behavior, production signing and release are not established here. A stuck old provider write can delay successor exports; the timed lock makes those attempts retryable but does not cancel the stuck I/O. Gallery-toggle I/O on the main thread is an existing disclosed limitation.
The successful read-only headset account listing supports that command only. Historical device evidence from the committed ae9df354 implementation does not establish the frozen fixes. These limits do not create an actionable defect in this reviewed slice or justify a release claim.
@@ -1,32 +0,0 @@
{
"taskId": "node:delegated-task:command%3Amcp%3A3ef9db23-13cf-4959-afe1-b003a3f2d950%3Adelegate-task%3Aframe108-android-sol-review-fixes-20261026-r2",
"providerInstanceId": "codex_secondary",
"model": "gpt-6.1-sol",
"reasoningEffort": "high",
"status": "completed",
"findings": "No actionable findings in the frozen Android hardening delta; R1-R3 addressed.",
"frozenFiles": {
".github/workflows/android.yml": "aaea48149abd1030f18a844d9f245d9a0ad249152e5df838cd53c8863f7f2220",
"android/app/src/androidTest/java/com/saphid/framecontrol/ScreenshotDeviceTest.java": "87f9629858a75a2ba4ced1a0f4ccc792d03a40bd39fed7e25e211cd6c1d15cbf",
"android/app/src/main/java/com/saphid/framecontrol/FrameConnection.java": "edb7b62b4d9a01e8e6802e8758504044c965f6d1b2939536617a3f57832b13a9",
"android/app/src/main/java/com/saphid/framecontrol/GalleryExporter.java": "b0c0c5331ea1184baf780f67329908d481491e7383bfb98c09733ada84582b5d",
"android/app/src/main/java/com/saphid/framecontrol/MainActivity.java": "8069a9c6ac3ab0e5e6bb315055dd2bef2a28b6f1c0084f8355db237857b736c1",
"android/app/src/main/java/com/saphid/framecontrol/SyncEngine.java": "0096ae5002c7ba5d1c7b8128aeee920eb4f29d8d94ad71d885ce86c5be70c854",
"android/app/src/main/java/com/saphid/framecontrol/SyncService.java": "dd852b8ef7f45fc472a8d790ba7e335040355889ef8d082ba8f1e80a1d4fd690",
"android/app/src/test/java/com/saphid/framecontrol/SyncEngineTest.java": "df1fa6ce8873098ea78fca3f5069e31712eae5eedde54824dd83216b6ac67f09",
"docs/android-phone.md": "a274daed1118be64b83313eaad03ddbf080fbbc7dad6e39ce601843bc227c1a3",
"android/app/src/main/java/com/saphid/framecontrol/SessionGate.java": "470a164820ececcbe897f55b3f4c90498c215d9dd1b93554e08d15fcc22a9fa2",
"android/app/src/test/java/com/saphid/framecontrol/FrameConnectionTest.java": "05010052ef11e9d9e019ae4ccb2959a8931a03b2d23528832d4821d184623727",
"android/app/src/test/java/com/saphid/framecontrol/SessionGateTest.java": "5e1e88bee54c82188ce0a1ac8c37d081565fc41c827c67b7d5ef0070b00ab15e",
"docs/evidence/android-phone-review-fixes-2026-10-26.md": "eb2a961abe935541c04e65d43ad8d61036b73afe8f63696891771f5e8bc6b7b1",
"docs/evidence/android-phone-review-fixes-2026-10-26/api36-instrumentation.txt": "d31e729b455ddd8ff3122e5a68bd2221361e27ef3553ba737447598a39d89bd9",
"docs/evidence/android-phone-review-fixes-2026-10-26/verification.json": "2bd1644b0c77c83f3ce85ae1713baba1ea7c0cedce0161bc35e3eff587bce7a7"
},
"manifestSha256": "e3417c56926d08faecb6e5beacaf081c9e4fc395ef494c4de999a4b2282900ad",
"independentVerification": {
"jvmTests": 54,
"jvmFailures": 0,
"localExportHandoff": "passed"
},
"productionReleaseReady": false
}
@@ -1,23 +0,0 @@
{
"platform": "Android API 36 emulator",
"nativeTests": 6,
"nativeFailures": 0,
"nativeSkipped": 0,
"supervisorExit": 0,
"jvmTests": 54,
"jvmFailures": 0,
"jvmErrors": 0,
"jvmSkipped": 0,
"buildExit": 0,
"headsetAccountListing": {
"sshExit": 0,
"state": "present",
"accountCount": 1,
"numericEntriesOnly": true
},
"apkSha256": {
"android/app/build/outputs/apk/debug/app-debug.apk": "139443c8f3246527ed629731b4f2a099939a2ba7590b628cde415f3f0d7ab996",
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk": "3a84246a7b40436fd5fc521154541603050805270e21e052e26b31a5fa1e0cc2"
},
"productionReleased": false
}
@@ -1,168 +0,0 @@
# 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.
![Native saved receipt with Gallery enabled](android-phone-screenshot-sync-2026-10-24/gallery-on.png)
![Gallery re-enabled without backfilling app-only downloads](android-phone-screenshot-sync-2026-10-24/reenabled.png)
![Native connection and Gallery default](android-phone-screenshot-sync-2026-10-24/setup.png)
![Shared controls loaded from the real headset](android-phone-screenshot-sync-2026-10-24/controls.png)
## 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.
@@ -1 +0,0 @@
{"removedTestKeys": 1, "removedFixtures": ["999999999990/108/20261024100101_1.png", "999999999990/108/20261024100102_1.png", "999999999990/108/20261024101413_1.png"], "testKeyAbsent": true, "allFixturesAbsent": true}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

@@ -1,57 +0,0 @@
{
"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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

@@ -1,119 +0,0 @@
# iOS foreground screenshot downloads — 2026-10-24
This is implementation and proof for the foreground iOS portion of
[#108](https://github.com/saphid/frame-control/issues/108). It is not the full
requested background/cross-platform feature and is not released.
## Source and merge
Dedicated worktree: `/Users/saphid/.t3/worktrees/steam-frame/feat-phone-screenshot-autosync-108`.
Branch: `feat/phone-screenshot-autosync-108`.
First merged fetched main
[1d4c57d6](https://github.com/saphid/frame-control/commit/1d4c57d6)
into the existing Mac-space parent
[3fdc6c46](https://github.com/saphid/frame-control/commit/3fdc6c46).
Merge commit: [52772323](https://github.com/saphid/frame-control/commit/52772323).
The dirty primary checkout was left untouched. Review found and fixed the PC
helper's missing public route for the extracted `mac-stream.js` dependency.
Independent merge re-review ran 45 tests successfully, one skipped, and found
no remaining actionable findings in its seven-file target.
## Implemented behavior
- Native polling every four seconds while active, independent of the visible web tab.
- First successful listing becomes the durable baseline; only new captures queue.
- Two equal metadata observations, exact byte count and complete ImageIO decode
precede an atomic original write outside purgeable caches.
- Receipts and originals distinguish pinned headset identity, Steam account,
app ID and filename. Reconnect/relaunch resumes pending work.
- Photos is enabled by default. Add-only permission is requested in foreground.
Denied/failed exports keep originals; app-only captures never automatically
export after re-enabling the setting.
- Native downloads sheet exposes retained files through ShareLink, including
during connection failure/relaunch. Ambiguous interrupted Photos commits need
explicit recovery rather than risking an automatic duplicate.
- Backgrounding stops polling; no unrelated background mode is used.
## Automated verification
Normal signed Simulator build and full native test suite: **20 tests passed,
zero failures**, including eight screenshot tests. Result bundle:
`/tmp/frame-108-ios-all-r3.xcresult`; log: `/tmp/frame-108-ios-all-r3.log`.
Both command exit status and xcresult test count were checked. Private
DerivedData cleanup completed. Test target was the independently created
Simulator `F250F91E-2E7D-4833-B029-A275F11FDC7D` on iOS 26.5.
Screenshot tests cover baseline/stability, truncated data, restart dedup,
Photos failure/retry, changes during download/consent, cancellation,
account/app identity, corrupt-ledger failure, uncertain export handling,
late success and failure across journal replacement, default preference and
offline app-only originals. Download/export dependencies in these tests are
injected; the live test below exercises actual transport and PhotoKit.
Python iOS packaging/Foundation contracts: 5 passed. Page scripts: 5 passed.
`swiftc -typecheck` for the journal and scoped `git diff --check` passed.
## Actual headset → Simulator → Photos proof
Ran the production signed app against the real headset over its pinned SSH
connection and the actual deployed `/api/shots` and `/api/shots/image` endpoints.
Created two temporary valid PNGs under a unique synthetic Steam account/app
directory after the app had baselined the existing nine screenshots.
| Check | Observed result |
|---|---|
| Default Photos ON | Original downloaded and receipt marked `saved`; Photos contained one byte-identical original |
| Source / app copy / Photos SHA-256 | `a36eb2d497e6ecce17c767189a70b3b7e732a22309d2421cdc715cf13a7668ac` |
| Photos OFF | Second original downloaded with `appOnly`; no matching Photos original |
| Re-enable + relaunch | Second receipt stayed `appOnly`; first still had exactly one Photos original |
Machine readback: `/tmp/frame-108-live-proof.json`. Photos readback hashed files
in this dedicated Simulator's own Media/DCIM directory, independently of the
app receipt. No personal phone Photos database was accessed. This is a
synthetic capture-file test, not proof of the headset screenshot-button path,
capture latency, physical-device behavior, or background delivery.
The OFF preference was applied through Simulator defaults on restart, not a
UI tap. The production setting setter and persisted opt-out were exercised in
the native tests. Astra inspected the real native sheet in both states and
found no major clipping, overlap or actionable visual defect.
![Photos enabled and app-only setting](phone-screenshot-sync-2026-10-24/settings-proof.png)
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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

-149
View File
@@ -1,149 +0,0 @@
{
"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."
]
}
-151
View File
@@ -1,151 +0,0 @@
# 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).
![Windows VM desktop decoded in the Frame's own viewer](windows-vm-2026-10-24/headset-view.png)
## 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.
@@ -1,53 +0,0 @@
{
"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
}
]
}
@@ -1,96 +0,0 @@
{
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
"elements": {
"d3d11screencapturesrc": true,
"mfh264enc": true,
"jpegenc": true,
"x264enc": false
},
"windows": [
{
"src": "window:131836",
"id": 131836,
"title": "Administrator: C:\\WINDOWS\\system32\\cmd.exe - powershell -noprofile -executionpolicy bypass -file \\\\host.lan\\data\\bootstrap.ps1",
"app": "Windows",
"w": 1115,
"h": 628,
"x": 51,
"y": 52
},
{
"src": "window:327926",
"id": 327926,
"title": "Administrator: Install",
"app": "Windows",
"w": 979,
"h": 512,
"x": 85,
"y": 78
},
{
"src": "window:65856",
"id": 65856,
"title": "Program Manager",
"app": "Windows",
"w": 1280,
"h": 800,
"x": 0,
"y": 0
}
],
"displays": [
{
"src": "display:65537",
"name": "Display 1",
"x": 0,
"y": 0,
"w": 1280,
"h": 800
}
],
"runs": [
{
"source": "test",
"codec": "h264",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 154
},
{
"source": "test",
"codec": "jpeg",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 7336
},
{
"source": "display:65537",
"codec": "h264",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "display:65537",
"codec": "jpeg",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "window:131836",
"codec": "h264",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "window:131836",
"codec": "jpeg",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
}
]
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

@@ -1,65 +0,0 @@
{
"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"
}
}
}
@@ -1,18 +0,0 @@
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
@@ -1,79 +0,0 @@
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)
+1 -1
View File
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
## Casting
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
**Cast headset view** starts the existing headset Live view and requests full
screen where supported. Show that screen to people in the room, or use the
computer/phone's own screen mirroring. It creates no new stream transport,
public URL or LAN server. iPhone uses the inline viewer if full screen is not
-35
View File
@@ -15,10 +15,6 @@ 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
@@ -40,34 +36,3 @@ 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.
+15 -49
View File
@@ -17,21 +17,15 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has six tabs: **Home** (what the headset's doing and what you'd do
next, the headset view, screenshots, family and comfort, the keyboard and
trackpad, and VR performance, folded away), **Games** (installed games,
sideloaded titles, getting games), **Android** (apps, the catalogue, display
settings, reports), **Tools** (in the headset: your computer's windows, the media
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, 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
connection pill in the header always shows which headset and whether it's
connected, and how (Tailscale, or the network's name). Click it to add, edit or
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
The window has six tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports), **Tools** (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. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset, which network this
computer is on, the address in use or being tried, and each step of connecting
as it happens; click it for the whole timeline. When the Frame can't be
reached, a banner says why in plain words, what was tried, and counts down to
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
@@ -49,27 +43,11 @@ 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
@@ -96,14 +74,8 @@ 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).
- **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).
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
Frame 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
@@ -149,12 +121,6 @@ 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,
@@ -167,7 +133,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 the **Library** before
sources and artwork. Android display links reveal **On your Frame** before
scrolling to the form. Mac streaming remains searchable on all platforms, with
its action shown only when the server is on macOS.
@@ -248,8 +214,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, or copying the headset's to this
computer, needs `wl-clipboard` (Wayland), `xclip` or `xsel` (X11).
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
`xclip` (X11).
## Building
@@ -273,5 +239,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
require a separate user approval; power also retains its password prompt. The
assistant uses a user-chosen endpoint and sends nothing until the user opts in
for a message. Screenshot context is separately opt-in. Model replies cannot
operate the headset. Settings → Open assistant settings opens the page; the linked guide
operate the headset. Tools → Open assistant opens the page; the linked guide
covers putting it in a Chromium panel on the Frame.
-121
View File
@@ -1,121 +0,0 @@
# 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.
-91
View File
@@ -1,91 +0,0 @@
# 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.
+9 -1
View File
@@ -20,6 +20,15 @@ SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + pane
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
```
## Tracking additions (verified 2026-09-28)
On SteamOS 0.4.1, build `20260925.6191901`, SteamVR exposes combined gaze
through `XR_EXT_eye_gaze_interaction` in a headless OpenXR 1.0 session. Our
reader obtained valid tracked samples and sent OSC to a configured loopback
receiver. BlueZ LE discovery works; GTK4/GI can render our heart-rate panel.
No BLE strap or SlimeVR trackers were attached. See [tracking](tracking.md)
for the evidence, privacy defaults and untested integration boundaries.
## Facts worth knowing
| Fact | Where it matters |
@@ -96,6 +105,5 @@ 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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

+6 -176
View File
@@ -57,25 +57,6 @@ makes.
| Sleep, restart, shut down ask in a terminal | The page asks for the Developer Mode password |
| Compatibility reports kept on the computer | Kept on the Frame (`~/.local/share/Frame Control`) |
## Automatic screenshot downloads (in development)
The screenshot-download implementation on this branch copies new Steam screenshots
while Frame Control is open and connected. It starts with the first successful
screenshot listing as its baseline; older captures still use the existing Save
action. Originals remain inside the app, and **Save screenshots to Photos** is
enabled by default, subject to add-only Photos permission.
Open **Downloads & Photos** on the Screenshots page, or **Screenshot downloads**
on the native connection screen. Turn the Photos setting off to keep new downloads
only in Frame Control. Re-enabling it does not export those app-only captures.
The local list and its Share buttons work without a headset connection. Failed
Photos exports retain the original and retry while the app is open; an interrupted
Photos transaction needs a manual check before saving again to avoid duplicates.
Background notification delivery and Android support are not implemented by this
change, and it has not been released. See the [verification record](evidence/phone-screenshot-sync-2026-10-24.md)
and [remaining work](https://github.com/saphid/frame-control/issues/108).
## Building
```sh
@@ -88,88 +69,6 @@ The build packs the Frame bundle from the checkout, so the phone always runs
the page and server from the same commit. Running on a phone needs your own
signing team in Xcode (Signing & Capabilities).
## TestFlight
Join the [Frame Control beta](https://testflight.apple.com/join/9xjW91g6),
install Apple's TestFlight app, then tap **Install** or **Update** next to Frame
Control. Internal account testers can also use their invitation. The first
working phone build was version 0.1.0, build 202610140516, uploaded on
2026-10-14. On 2026-10-16, build **202610160824** was uploaded and approved for
internal and external testing. App Store Connect confirmed both builds were in
the external **Public Beta** group, with its public link enabled and a limit of
50 testers. The new build declares the encryption-compliance answer in the app
and preserves the previous build's exact server bundle.
### Upload a build
You need Xcode, XcodeGen, a paid Apple Developer Program team, an App Store
Connect app record for `com.saphid.framecontrol`, and an App Store Connect team
API key with the signing access your release setup needs. This Mac relies on
Apple's cloud-managed distribution certificates: an **App Manager** key was
rejected with `Cloud signing permission error`, so this setup needs **Admin**
access. A setup with a local distribution signing identity may be able to use
a narrower role. Create the key under **Users and Access →
Integrations → App Store Connect API**. Download it directly to
`~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8`, outside the repository,
and restrict it to its owner (`chmod 600`). Apple allows one download. Keep
the private key out of chat, screenshots, logs and Git.
From the repository root:
```sh
ASC_KEY_ID=YOUR_KEY_ID \
ASC_ISSUER_ID=YOUR_ISSUER_UUID \
ASC_KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_YOUR_KEY_ID.p8" \
TEAM_ID=YOUR_TEAM_ID \
ios/scripts/testflight.sh
```
The script regenerates the Xcode project, archives with automatic signing,
checks the archived build number, and uploads to App Store Connect. `TEAM_ID`
defaults to this project's team, `NJZUMEA4BN`. `BUILD_NUMBER` defaults to UTC
`YYYYMMDDHHMM`; wait for a new minute between uploads, or supply a unique,
higher build number. The archive, export options and export output are under
`ios/build/testflight/<BUILD_NUMBER>/` (ignored by Git).
Build from a reviewed, clean checkout. The build packs the server, web pages
and helpers from the current checkout, including uncommitted changes; it does
not limit the bundle to the last commit.
An upload succeeding means Apple received the build. Wait for it to finish
processing in **App Store Connect → Frame Control → TestFlight** and check
that it is ready for testing. `ios/project.yml` declares
`ITSAppUsesNonExemptEncryption: false`, so each generated app declares that it
uses no non-exempt encryption, matching the previous builds' export-compliance
answers. Reassess that declaration if the app's cryptography changes.
To replace a key, create and securely download the replacement, verify that it
can access this app, then revoke the old key in Users and Access. Update the
upload environment to use the replacement. Revocation does not remove an
already uploaded TestFlight build from testers' phones.
### Add testers
**Internal:** add an existing App Store Connect user with an eligible role to
an internal testing group, and add the build to that group (or enable automatic
distribution). New account users must first accept their account invitation.
Internal testing grants App Store Connect access; use external testing for
people who only need to try the app.
**External:** create an external testing group, complete Beta App Information
and review contact details, add a build and its **What to Test** notes, then
submit it for TestFlight App Review when required. Use
<https://framecontrol.fun/privacy/> for the privacy policy and
<https://framecontrol.fun/feedback/> for feedback. Review notes must explain
that the app requires a Steam Frame with Developer Mode enabled and a user
password; do not provide your own headset credentials to reviewers. Apple may
need a way to evaluate features that require the hardware.
After Apple approves the build for external testing, enable the group's public
link and choose a tester limit. Share that link to let testers join without an
App Store Connect account. Creating a group alone does not make a build public.
Do not describe the link as ready until the approved build is in the group and
the link is enabled.
## Verified
<img src="img/iphone-tabs.jpg" alt="The four tabs in the iPhone app, connected to a Frame" width="900">
@@ -203,80 +102,11 @@ 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, 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.
Sending text to the Frame's clipboard needs the desktop panel open in the
headset, as on the desktop app.
On a physical iPhone, 2026-10-14: Alex reported build 202610140516 was working
after pairing and trying the password visibility button. A server process
started by the phone and its SSH key on the Frame confirmed the connection.
This was a setup/connection smoke check; it did not verify every app feature.
On a physical iPhone 16 Pro running iOS 27.2, 2026-10-16: the same build launched
and automatically reconnected to the Frame. A device screenshot showed the
green connection indicator, 100% headset battery and the loaded Android
catalog. This confirms connection and rendering, not status refresh or actions
inside the app. A separate signed automation runner was installed and passed
its health check. Initial interaction attempts were blocked by the phone's
lock; testing resumed once Alex unlocked it and kept the screen awake.
<img src="img/iphone-physical-20261016.jpg" alt="Frame Control connected to the Frame on a physical iPhone, showing battery and the Android catalog" width="360">
In that continuation, still on build **202610140516**, the status header
refreshed, the Games tab loaded 14 installed titles with sizes and Play
controls, and a uniquely named 114-byte text file was selected through the
iPhone's Files picker and sent to `~/Downloads` on the Frame. An SSH readback
matched the file's contents exactly; this verifies phone-to-Frame upload,
not a download back to the phone. Both the iCloud source fixture and the
uploaded test file were removed and checked absent.
Capture returned a blank headset frame with an asleep/unworn explanation.
Save opened the native share sheet and reached the Photos permission prompt;
no completed Photos save was verified. A final Settings check showed
**Add Photos Only** enabled, without photo-library read access. A test string
sent through the clipboard control was refused with the expected prerequisite:
`plasmashell` was not running, so the desktop panel must be opened in the
headset. The phone's existing clipboard was not read. Live mode started and
reported **30 fps**, but its visible frames remained blank, so meaningful
video playback is still unverified. Android listed 10 installed apps; a
separate SSH check found zero running containers and zero Android ADB
listeners, so display-setting changes were not exercised.
<img src="img/iphone-games-physical-20261016.jpg" alt="Frame Control's Games tab on the physical iPhone, showing the installed library and Play controls" width="360">
<img src="img/iphone-live-physical-20261016.jpg" alt="Live mode reporting 30 fps on the physical iPhone with blank headset content and the page horizontally clipped" width="360">
Testing also exposed an enlarged, horizontally clipped page after text entry
and navigation. Its cause has not yet been isolated. Restarting Frame Control
restored the normal layout and automatically reconnected to the headset.
### Mac follow-up
On 2026-10-17, the installed Mac app **0.4.6** was tested with the headset
unworn. Connection/status worked, and its connection test reported
`Answered in 90 ms · SSH works`. Still capture completed but both eyes were
blank. Live video failed with `Not a video capture device` when opening
`/dev/video99`, then fell back to blank still captures. No visible scene or
video playback was verified. The desktop was inactive; no headset services
or settings were changed. Live was stopped and the existing app left on Home.
These are Mac results; they do not verify the iPhone build or establish the
cause of its earlier blank output.
<img src="img/mac-live-error-20261017.png" alt="Mac Frame Control activity log reporting Live video failed: Not a video capture device" width="900">
### Physical-phone follow-up checklist
- With the headset awake and worn, capture visible headset content, save it to
Photos, then verify live video.
- Send test text to the Frame's clipboard with its desktop panel open.
- With an Android app running, change and restore its display settings.
- Reproduce and fix the page enlargement and horizontal clipping after text
entry and navigation.
- After saving headset work, deliberately test sleep, restart and shut down.
These remaining checks are pending on a physical phone. Android display
changes through podman and real sleep/restart/shut down also remain unverified
by the Simulator checks above.
Not yet exercised: Android display changes through podman (no Android app was
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
@@ -284,8 +114,8 @@ don't.
## Family and comfort
The shared Home card sets session limits, breaks and check-ins; **Cast to this
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
The shared Home card sets session limits, breaks and check-ins, and offers
**Cast headset view**. **Enable / test notifications** requests iOS notification
permission and sends a local test. These are local notifications, not APNs push;
iOS background suspension can interrupt phone alerts. The headset timer still
runs. See [the behavior and verification limits](family-comfort.md).
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more