Compare commits

..
Author SHA1 Message Date
saphid c6ed6c9ea1 Merge remote-tracking branch 'origin/main' into analytics-and-updates
# Conflicts:
#	docs/frame-control.md
#	ui/server.py
2026-09-29 09:38:44 +10:00
Alex Southwell 6d03317970 Merge pull request #15 from saphid/docs-announcements
Docs: house style for announcing features and fixes
2026-09-29 09:26:22 +10:00
Alex Southwell 976008065f Merge pull request #16 from saphid/keep-awake
Keep the Frame awake during agent work
2026-09-29 09:16:03 +10:00
Alex Southwell 8b5ada1272 Merge pull request #35 from saphid/frame-mcp
Add Frame Control MCP tools and an opt-in assistant panel
2026-09-29 09:04:49 +10:00
saphidandClaude Opus 5.5 48a9914124 Skip reverse-DNS lookup when binding the loopback server
HTTPServer.server_bind calls socket.getfqdn, which stalled past the MCP
backend's 10-second startup window on GitHub's macOS runners.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:54:17 +10:00
saphid 002c859572 Set up self-contained MCP startup and inspect Frame computer-use capabilities 2026-09-29 08:18:47 +10:00
saphid b5cf8253e6 Bind approval UI to current request and verify panel cleanup 2026-09-28 22:29:18 +10:00
saphid 6a8e3fadbf Open assistant on Frame and document verified agent workflows 2026-09-28 22:21:22 +10:00
saphid 643cb65c79 Add key-free MCP tools, human approvals and opt-in assistant 2026-09-28 22:21:22 +10:00
saphidandClaude Opus 5.5 33a92a2e1d Tests: run in a sandbox that can't touch real app data, telemetry or the shared database
On a maintainer's Mac the compatibility-database key is in the Keychain, so a
test that reached install reporting published fake reports. Every test module
now imports tests/sandbox.py first, which points app data at a throwaway
directory (new FRAME_CONTROL_DATA_DIR), turns telemetry off and sends the
database nowhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:20:27 +10:00
saphidandClaude Opus 5.5 f076527722 Send analytics to the existing PostHog project; make bug reports private
- Analytics go to the maintainer's PostHog US project 343535, tagged
  $lib = frame-control. Every event carries $ip 0.0.0.0, since PostHog
  stores the sender's address otherwise (checked live), including events
  queued by earlier versions.
- Report a problem sends a private problem_report event to PostHog instead
  of a public GitHub issue, with its own random id so a contact address
  can't be linked to analytics. The dialog asks how to reach the person and
  shows a reference. Maintainers read reports on the PostHog dashboard or
  with `python3 ui/frame_report.py inbox`.
- Community sync pages by timestamp in UTC: PostHog refuses OFFSET for
  personal API keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:14:36 +10:00
saphidandClaude Opus 5.5 e67802f15d Tests: keep the blocked-upload test from reaching real install reporting
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:11:48 +10:00
saphidandClaude Opus 5.5 73eef14ecd Report APKs refused before install; offer a compatibility test after installing an alternative
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:44:44 +10:00
saphidandClaude Opus 5.5 c3ceea9bcd Merge main into analytics-and-updates: keep APK alternatives alongside Report a problem
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:39:38 +10:00
Alex Southwell dcf9689f64 Merge pull request #11 from saphid/apk-alternatives
Offer older APK versions that fit when Lepton refuses an app
2026-09-28 17:38:35 +10:00
saphidandClaude Opus 5.5 97d70d0c80 Merge origin/main: background install jobs and tabbed pages
Flatpak installs record their outcome inside main's background job; failed
jobs are diagnostics too. The Privacy panel lives on the Tools page (#privacy
opens it), tab analytics use the four page names, and "Test it now?" reads the
install job's result.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:37:28 +10:00
saphidandClaude Opus 5.5 9eeca79b5d Analytics, self-update and Report a problem
- Anonymous PostHog analytics (ui/frame_telemetry.py): usage on by default
  after a first-run notice; compatibility results and error details opt-in,
  offered together by the notice's "Share more to help fix problems" button.
  Random id, no person profiles or GeoIP, scrubbed text, an offline outbox,
  and "Show what's been sent" in the new Privacy panel. Inert without a
  project key, from a source checkout, or with DO_NOT_TRACK=1.
- APK installs now record install_failed when the APK itself won't install,
  and offer a 20-second test after installing. Opted-in reports reach the
  shared database through PostHog and `frame_compat_db.py sync`.
- The desktop app updates itself from published releases (app/updater.js):
  update.json from releases/latest/download, SHA-256 checked, no downgrades;
  macOS bundle swap, Windows NSIS, Linux AppImage, otherwise the release page.
  scripts/publish-release.sh publishes a tested draft with its manifest.
- Report a problem (header button, Privacy panel, Help menu) files a GitHub
  issue through the website's feedback API, with a previewed, scrubbed
  diagnostics snapshot; activity and logs only when asked for.

Reviewed by GPT-6 Astra (xhigh, read-only) three times; all findings fixed.
Docs: docs/privacy.md, docs/releasing.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:34:21 +10:00
saphidandClaude Opus 5.5 224340edc9 APK alternatives: refresh the catalogue after an install job; pin the test's premise
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:29:29 +10:00
saphidandClaude Opus 5.5 b393e90854 Keep the Frame awake during agent work
Steam's own idle timer (60 min on AC, 15 on battery) suspends the Frame,
and SSH work doesn't count as activity. scripts/keep-awake.sh on sets both
timers to Never through Steam's DevTools (reusing ui/frame_steam.py) and
holds a logind sleep inhibitor as a user unit; off releases the inhibitor
and restores the saved timers. Findings recorded in how-the-frame-works.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:23:06 +10:00
saphidandClaude Opus 5.5 45f720883a Docs: house style for announcing features and fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:22:55 +10:00
saphidandClaude Opus 5.5 c814cb95d0 Merge main into apk-alternatives: install other versions as background jobs
Main now runs Android installs as background jobs and maps SSH failures to
one offline message. Alternative-version installs go through the same job,
the alternatives dialog waits on it with runJob, and send_error_json keeps
the apk blocker that opens the dialog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 17:21:55 +10:00
Alex Southwell ff2c4ebfe0 Merge pull request #8 from fbl100/mac-mirror-verified
Mac → Frame mirror: verified, with fixes for scaling, login and the cursor
2026-09-28 17:20:22 +10:00
Alex Southwell 03322d3166 Merge pull request #5 from saphid/iphone-app
iPhone and iPad app: the same features, served from the Frame
2026-09-28 17:20:18 +10:00
Alex Southwell 2d08486278 Merge pull request #4 from saphid/ui-tabs-reliability
Tabs, one offline banner, background installs, drop anywhere
2026-09-28 17:19:46 +10:00
Alex Southwell f780ab2c6a Merge pull request #14 from saphid/site-polish
Website: crop the Tools screenshot and tighten the phone footer
2026-09-28 16:43:21 +10:00
saphidandClaude Opus 5.5 396f2a830a Website: crop the empty half off the Tools screenshot; tighten wrapped footer links
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:39:01 +10:00
Alex Southwell 59761ae015 Merge pull request #12 from saphid/website-kofi
Website: Ko-fi donate buttons and visual fixes
2026-09-28 16:34:18 +10:00
saphidandClaude Opus 5.5 48eedbc2eb Website: let the hero platform list wrap on phones so large text isn't clipped
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:31:39 +10:00
saphidandClaude Opus 5.5 2b89eeba1d Website: fix visual bugs found in a page-by-page review
- Gradient buttons showed a dark sliver on the right: the background shorthand reset
  background-origin, so the gradient repeated under the transparent border.
- Phone header: keep the brand on one line and drop the GitHub button under 480px.
- Hero pill: put the platform list on its own line on phones instead of orphaning one item.
- Privacy page: plain sections instead of open accordions whose x looked like a close button.
- Same header (GitHub button) and footer links on every page; legend spacing on the form.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:26:43 +10:00
saphidandClaude Opus 5.5 75e92db2fa Website: point the donate buttons at ko-fi.com/alexsouthwell; add FUNDING.yml
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:18:12 +10:00
Alex Southwell 10f96656e3 Merge pull request #10 from saphid/website
Website, feedback form that opens issues, and pi's contributor gate
2026-09-28 16:07:23 +10:00
saphidandClaude Opus 5.5 a1fa4ce140 Fix the cross-provider review's findings on APK alternatives
One malformed or unreachable repo no longer hides the others; skip bad
index entries; a refreshed raw index outdates its reduced copy; style the
dialog like the others; validate package ids with PKG_RE.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:15:11 +10:00
saphidandClaude Opus 5.5 a7663b3a5e Website feedback: double backslashes so escapes can't rebuild references; queue every approval
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:51:18 +10:00
saphidandClaude Opus 5.5 a6fff434a4 Website feedback: attribution first, escape <, wait out the fill timer; maintainers only in the approval queue
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:45:54 +10:00
saphidandClaude Opus 5.5 cd40a194ed Website feedback: escape & so entities can't rebuild mentions; queue only lgtm comments
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:39:54 +10:00
saphidandClaude Opus 5.5 0037b1ef4b Website feedback: fixes from review
- Rate limiting fails open on KV errors instead of dropping feedback
- Break owner/repo#1, GH-1 and github.com references in user text
- Time the form with the browser's monotonic clock, not wall-clock
- Serialize lgtm approvals and rebase before pushing

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:33:42 +10:00
saphidandClaude Opus 5.5 50405ccf88 Add IzzyOnDroid to APK alternatives; keep the catalogue's index file
Reducing an index no longer deletes apk-catalog/data/index-v2.json, which
the catalogue build reads. Drop the measurement log from docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:30:15 +10:00
saphid 32196b4260 Reduce F-Droid indexes and fetch APK alternatives asynchronously 2026-09-28 14:26:40 +10:00
saphidandClaude Opus 5.5 7d328e20a5 Website with a feedback form that opens GitHub issues, and pi's contributor gate
- site/: landing page, /feedback/ and /privacy/ on Cloudflare Pages
  (frame-control.pages.dev). POST /api/feedback validates the form and opens
  a labelled issue with a fine-grained token; honeypot, minimum fill time and
  KV rate limits keep spam out. Ko-fi donate buttons appear once the page
  name is set in site/public/js/site.js.
- .github: the issue and PR gate from badlogic/pi-mono. New contributors'
  issues and PRs are auto-closed; a maintainer replying lgtmi/lgtm approves
  them via APPROVED_CONTRIBUTORS. Issue templates and CONTRIBUTING.md.
- CI runs the website tests; README points feedback at the form.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 14:26:33 +10:00
saphid fbe7ba9575 Find installable APK alternatives in F-Droid main and archive 2026-09-28 14:16:43 +10:00
Frank LevineandClaude Opus 5.5 5e12245932 Streaming doc: Mac → Frame mirror verified; move answered open questions
Tested on Frame BUILD_ID 20260925.6191901 with macOS 27.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:49:12 -04:00
Frank LevineandClaude Opus 5.5 df1810bae3 Add mac-cursor-ring.lua: show the Mac pointer in the VNC mirror
macOS leaves the pointer out of the Screen Sharing framebuffer, and neither Remmina showcursor setting brings it back. A Hammerspoon ring around the pointer is a real window, so it gets mirrored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:49:12 -04:00
Frank LevineandClaude Opus 5.5 3f0e7b198a install-apps: scale the Mac screen profile to fit; warn about the Mac login
Without scale-to-fit a Retina Mac shows 1:1 as a zoomed-in corner. macOS offers Apple auth ahead of plain VNC auth, so Remmina asks for the Mac account login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:49:12 -04:00
saphidandClaude Opus 5.5 9110557e23 README: link the Frame Control trailer
A thumbnail under the screenshot opens the 66-second trailer, which is attached to the 'trailer' release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 22:07:44 +10:00
saphid 0016d9200c Merge remote-tracking branch 'origin/iphone-app' into iphone-app 2026-09-27 21:27:10 +10:00
saphidandClaude Opus 5.5 fe87a9d826 Keep the page clear of iOS safe areas everywhere; research typing, pointing and mirroring into the Frame
- Header, content, tab bar, status strip, drawer and toasts add the notch,
  Dynamic Island, rounded-corner and home-indicator insets on every side
  (zero on desktops). Phones on their side use the bottom tab bar layout.
- The phone tab bar hides while a text field has focus, instead of riding
  on the keyboard.
- DEBUG hook FRAME_TEST_LANDSCAPE for checking this in the Simulator.
- docs/streaming.md: iPhone mirroring (UxPlay, broadcast extension) and
  keyboard/mouse input (uinput needs no sudo on the Frame, verified).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:26:32 +10:00
Alex Southwell 1b26c4f92c Merge pull request #7 from saphid/fake-frame-tests
Regression tests against a fake Frame, plus a headset smoke test
2026-09-27 21:25:36 +10:00
saphidandClaude Opus 5.5 3db311da13 iPhone app: declare photo saving so Save Image appears; record what was tested
The share sheet only offers Save Image when the app declares
NSPhotoLibraryAddUsageDescription; without it screenshots couldn't be saved to
Photos. docs/iphone.md now lists what was verified against the Frame (Bonjour
discovery, waiting and reconnecting, upload, share sheet, install links,
opening Steam Link) and the two permission prompts iOS shows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 18:49:25 +10:00
saphidandClaude Opus 5.5 c711e136d4 iPhone app: a first screen that says exactly what to do
The app finds the Frame by itself (Valve's _steamos-devkit._tcp Bonjour
service, else the saved address or frame.local on port 22), so setup is two
numbered steps: wake your Frame (Looking… / Found ✓, and after a few seconds
exactly what to check), then the Developer Mode password and Connect. Manual
address and key options sit under Other ways to connect.

A paired Frame that doesn't answer is almost always asleep: instead of an
error, Waiting for your Frame says how to wake it and connects as soon as its
SSH port answers (checked every 3 s; 4 s after the stand-in came back).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 18:16:14 +10:00
saphidandClaude Opus 5.5 13f629af35 WebXR doc: drop the stale not-verified list
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:55:09 +10:00
saphidandClaude Opus 5.5 7efce19f4c WebXR doc: measured frame rate and controller input
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:54:48 +10:00
saphidandClaude Opus 5.5 623ce683d4 Document testing VR without wearing the headset; Steam-launched WebXR verified
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 16:47:57 +10:00
saphidandClaude Opus 5.5 e5f3c96275 WebXR doc: keep only the verified DevTools launch recipe
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 09:47:33 +10:00
saphidandClaude Opus 5.5 0cb289d571 WebXR: point to the public chromium-webxr-steam-frame repo
The build script, launcher and Steam shortcut helper now live in
saphid/chromium-webxr-steam-frame, so drop the duplicate copies here.
The doc keeps the findings, verification and upstream status.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 07:06:42 +10:00
110 changed files with 7444 additions and 547 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# GitHub handles approved to bypass contribution auto-close
# Format: <username> <capability>
# capability:
# issue future issues stay open
# pr future issues and PRs stay open
# Maintainers add people by replying `lgtmi` or `lgtm` on an issue
# (.github/workflows/approve-contributor.yml); editing this file by hand works too.
fbl100 pr
+1
View File
@@ -0,0 +1 @@
ko_fi: alexsouthwell
+51
View File
@@ -0,0 +1,51 @@
name: Bug report
description: Report something that's broken
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
**Before you start:** read [CONTRIBUTING.md](https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md).
Issues from new contributors are auto-closed by default. A maintainer reviews them and reopens worthwhile ones. The [website feedback form](https://frame-control.pages.dev/feedback/) skips that queue.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: textarea
id: description
attributes:
label: What happened?
description: Be specific. Include error messages and the last lines of the server log (Frame → Show Server Log).
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Minimal steps to trigger the bug.
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: false
- type: input
id: version
attributes:
label: Frame Control version
description: e.g. v0.3.1
validations:
required: false
- type: input
id: os
attributes:
label: Computer and SteamOS build
description: e.g. Windows 11, SteamOS 20260922.6101926 (Steam Settings → System)
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Feedback form (no GitHub account needed, skips the queue)
url: https://frame-control.pages.dev/feedback/
about: Bugs, ideas and questions from the website become issues here without being auto-closed.
+36
View File
@@ -0,0 +1,36 @@
name: Idea or contribution proposal
description: Propose a change or feature (required for new contributors before opening a PR)
labels: ["enhancement"]
body:
- type: markdown
attributes:
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.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: textarea
id: what
attributes:
label: What do you want to change?
description: Be specific and concise.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why?
description: What problem does this solve?
validations:
required: true
- type: textarea
id: how
attributes:
label: How? (optional)
description: Brief technical approach, and whether you'd like to implement it yourself.
validations:
required: false
+238
View File
@@ -0,0 +1,238 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: Approve Contributor
on:
issue_comment:
types: [created]
jobs:
approve:
# Only maintainers' comments that might approve someone join the queue, and
# they run one at a time so two lgtm replies can't race on APPROVED_CONTRIBUTORS.
# (The script below still checks for write access.)
if: >-
contains(github.event.comment.body, 'lgtm') &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
concurrency:
group: approve-contributor
cancel-in-progress: false
queue: max
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
- name: Update contributor approval
id: update
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require('fs');
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const issueAuthor = context.payload.issue.user.login;
const commenter = context.payload.comment.user.login;
const commentBody = (context.payload.comment.body || '').trim();
const approvalAtStartPattern = /^[\s.]*(?:@[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\s*,\s*|[.:]\s*|\s+))*(lgtmi|lgtm)(?=$|[\s]|[^\p{L}\p{N}_\s])/iu;
const approvalAtEndPattern = /(?:^|[\s.])(lgtmi|lgtm)\s*(?:[^\p{L}\p{N}_\s])?\s*$/iu;
const approvalMatch = commentBody.match(approvalAtStartPattern) ?? commentBody.match(approvalAtEndPattern);
if (!approvalMatch) {
console.log('Comment does not start or end with lgtm or lgtmi');
core.setOutput('status', 'skipped');
return;
}
const targetCapability = approvalMatch[1].toLowerCase() === 'lgtmi' ? 'issue' : 'pr';
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: commenter,
});
if (!['admin', 'maintain', 'write'].includes(permissionLevel.permission)) {
console.log(`${commenter} does not have write access`);
core.setOutput('status', 'skipped');
return;
}
} catch {
console.log(`${commenter} does not have collaborator access`);
core.setOutput('status', 'skipped');
return;
}
function parseMentionedUsers(body) {
const users = [];
const seenUsers = new Set();
const mentionPattern = /(^|[^A-Za-z0-9_])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)(?![A-Za-z0-9-]|\/)/g;
for (const match of body.matchAll(mentionPattern)) {
const username = match[2];
const normalizedUser = username.toLowerCase();
if (seenUsers.has(normalizedUser)) {
continue;
}
seenUsers.add(normalizedUser);
users.push(username);
}
return users;
}
function parseApprovedUsers(content) {
const lines = content.split('\n');
const entries = [];
const users = new Map();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
entries.push({ type: 'other', line });
continue;
}
const parts = trimmed.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${line}`);
entries.push({ type: 'other', line });
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${line}`);
entries.push({ type: 'other', line });
continue;
}
const normalizedUser = username.toLowerCase();
const entry = { type: 'user', username, normalizedUser, capability: normalizedCapability };
entries.push(entry);
users.set(normalizedUser, entry);
}
return { entries, users };
}
function stringifyApprovedUsers(entries) {
const normalizedEntries = [...entries];
while (normalizedEntries.length > 0) {
const lastEntry = normalizedEntries[normalizedEntries.length - 1];
if (lastEntry.type !== 'other' || lastEntry.line.trim() !== '') {
break;
}
normalizedEntries.pop();
}
return `${normalizedEntries
.map((entry) => (entry.type === 'user' ? `${entry.username} ${entry.capability}` : entry.line))
.join('\n')}\n`;
}
const content = fs.readFileSync(APPROVED_FILE, 'utf8');
const { entries, users } = parseApprovedUsers(content);
const mentionedUsers = parseMentionedUsers(commentBody);
const approvalTargets = mentionedUsers.length > 0 ? mentionedUsers : [issueAuthor];
const changedTargets = [];
const alreadyTargets = [];
for (const username of approvalTargets) {
const normalizedUser = username.toLowerCase();
const existingEntry = users.get(normalizedUser);
const existingCapability = existingEntry?.capability ?? null;
if (existingCapability === 'pr' || existingCapability === targetCapability) {
alreadyTargets.push(existingEntry?.username ?? username);
console.log(`${username} is already approved for ${existingCapability}`);
continue;
}
if (existingEntry) {
existingEntry.capability = targetCapability;
changedTargets.push(existingEntry.username);
} else {
const entry = { type: 'user', username, normalizedUser, capability: targetCapability };
entries.push(entry);
users.set(normalizedUser, entry);
changedTargets.push(username);
}
console.log(`Set ${username} capability to ${targetCapability}`);
}
core.setOutput('capability', targetCapability);
core.setOutput('changed_targets', JSON.stringify(changedTargets));
core.setOutput('already_targets', JSON.stringify(alreadyTargets));
if (changedTargets.length === 0) {
core.setOutput('status', 'already');
return;
}
fs.writeFileSync(APPROVED_FILE, stringifyApprovedUsers(entries));
core.setOutput('status', 'changed');
- name: Commit and push
if: steps.update.outputs.status == 'changed'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .github/APPROVED_CONTRIBUTORS
git diff --staged --quiet || git commit -m "chore: approve contributors from issue #${{ github.event.issue.number }}"
# main may have moved since checkout; replay the approval on top of it.
git pull --rebase origin "${{ github.event.repository.default_branch }}"
git push
- name: Comment on issue
if: steps.update.outputs.status == 'changed' || steps.update.outputs.status == 'already'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
CAPABILITY: ${{ steps.update.outputs.capability }}
CHANGED_TARGETS: ${{ steps.update.outputs.changed_targets }}
ALREADY_TARGETS: ${{ steps.update.outputs.already_targets }}
with:
script: |
const capability = process.env.CAPABILITY;
const changedTargets = JSON.parse(process.env.CHANGED_TARGETS || '[]');
const alreadyTargets = JSON.parse(process.env.ALREADY_TARGETS || '[]');
const defaultBranch = context.payload.repository.default_branch;
const formatTargets = (targets) => targets.map((target) => `@${target}`).join(', ');
const bodyLines = [];
if (changedTargets.length > 0) {
if (capability === 'issue') {
bodyLines.push(`${formatTargets(changedTargets)} approved for issues. Future issues will not be auto-closed. PRs still require \`lgtm\` at the start of a maintainer reply (optionally after one or more \`@username\` mentions) or at the end.`);
} else {
bodyLines.push(`${formatTargets(changedTargets)} approved for issues and PRs. Future issues and PRs will not be auto-closed.`);
}
}
if (alreadyTargets.length > 0) {
const verb = alreadyTargets.length === 1 ? 'is' : 'are';
bodyLines.push(`${formatTargets(alreadyTargets)} ${verb} already approved.`);
}
bodyLines.push('', `See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`);
const body = bodyLines.join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+5 -1
View File
@@ -35,7 +35,11 @@ jobs:
- name: Server tests - name: Server tests
run: python -m unittest discover -s tests -v run: python -m unittest discover -s tests -v
- name: App syntax - 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 run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
- name: Updater tests
run: node --test app/test/updater.test.js
- name: Website
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
# The server runs on each desktop OS the app ships for, on the Python version # The server runs on each desktop OS the app ships for, on the Python version
# the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one. # the app bundles (app/build/fetch-deps.js) and, on Ubuntu, a newer one.
+134
View File
@@ -0,0 +1,134 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: Issue Gate
on:
issues:
types: [opened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Check issue author
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
const issueAuthor = context.payload.issue.user.login;
const defaultBranch = context.payload.repository.default_branch;
const isBotAuthor = issueAuthor.endsWith('[bot]');
if (TRUSTED_BOT_AUTHORS.has(issueAuthor)) {
console.log(`Skipping trusted bot: ${issueAuthor}`);
return;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
function parseApprovedUsers(content) {
const users = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${rawLine}`);
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${rawLine}`);
continue;
}
users.set(username.toLowerCase(), normalizedCapability);
}
return users;
}
const permission = await getPermission(issueAuthor);
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${issueAuthor} is a collaborator with ${permission} access`);
return;
}
const approvedContent = await getTextFile(APPROVED_FILE);
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(issueAuthor.toLowerCase());
if (!isBotAuthor && (capability === 'issue' || capability === 'pr')) {
console.log(`${issueAuthor} is approved for ${capability}`);
return;
}
const message = [
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
'',
`Maintainers review auto-closed issues regularly and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'Just want to report a bug or share an idea? The [website feedback form](https://frame-control.pages.dev/feedback/) skips this queue.',
'',
'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.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message,
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['untriaged'],
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});
+145
View File
@@ -0,0 +1,145 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: Issue Triage Labels
on:
issues:
types: [reopened, labeled]
jobs:
update-labels:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Update triage labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const UNTRIAGED_LABEL = 'untriaged';
const NO_ACTION_LABEL = 'no-action';
const LAST_READ_LABEL = 'last-read';
const TO_DISCUSS_LABEL = 'to-discuss';
const INPROGRESS_LABEL = 'inprogress';
function issueHasLabel(issue, labelName) {
return (issue.labels ?? []).some((label) => label.name === labelName);
}
async function removeLabelIfPresent(issueNumber, issue, labelName) {
if (!issueHasLabel(issue, labelName)) {
console.log(`Issue #${issueNumber} does not have ${labelName}`);
return;
}
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: labelName,
});
console.log(`Removed ${labelName} from #${issueNumber}`);
} catch (error) {
if (error.status === 404) {
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
return;
}
throw error;
}
}
if (context.payload.action === 'reopened') {
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
return;
}
if (context.payload.action === 'labeled' && context.payload.label?.name === NO_ACTION_LABEL) {
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
return;
}
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
console.log('Not a last-read label event');
return;
}
const currentIssueNumber = context.issue.number;
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: LAST_READ_LABEL,
per_page: 100,
});
const previousIssueNumbers = lastReadIssues
.filter((issue) => !issue.pull_request)
.map((issue) => issue.number)
.filter((issueNumber) => issueNumber !== currentIssueNumber);
if (previousIssueNumbers.length === 0) {
console.log('No previous last-read issue found');
return;
}
const previousIssueNumber = Math.max(...previousIssueNumbers);
if (currentIssueNumber <= previousIssueNumber) {
console.log(
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
);
return;
}
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: UNTRIAGED_LABEL,
per_page: 100,
});
const issuesToMark = untriagedIssues
.filter((issue) => !issue.pull_request)
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
.sort((a, b) => a.number - b.number);
if (issuesToMark.length === 0) {
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
return;
}
for (const issue of issuesToMark) {
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [NO_ACTION_LABEL],
});
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
console.log(`Closed #${issue.number} as not planned`);
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: UNTRIAGED_LABEL,
});
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
}
+131
View File
@@ -0,0 +1,131 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: PR Gate
on:
pull_request_target:
types: [opened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check if contributor is approved
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const TRUSTED_BOT_AUTHORS = new Set(['dependabot[bot]', 'sentry[bot]', 'claude[bot]']);
const prAuthor = context.payload.pull_request.user.login;
const defaultBranch = context.payload.repository.default_branch;
const isBotAuthor = prAuthor.endsWith('[bot]');
if (TRUSTED_BOT_AUTHORS.has(prAuthor)) {
console.log(`Skipping trusted bot: ${prAuthor}`);
return;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
function parseApprovedUsers(content) {
const users = new Map();
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length !== 2) {
console.log(`Skipping malformed line: ${rawLine}`);
continue;
}
const [username, capability] = parts;
const normalizedCapability = capability.toLowerCase();
if (!VALID_CAPABILITIES.has(normalizedCapability)) {
console.log(`Skipping line with invalid capability: ${rawLine}`);
continue;
}
users.set(username.toLowerCase(), normalizedCapability);
}
return users;
}
async function closePullRequest(message) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: message,
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed',
});
}
const permission = await getPermission(prAuthor);
if (!isBotAuthor && ['admin', 'maintain', 'write'].includes(permission)) {
console.log(`${prAuthor} is a collaborator with ${permission} access`);
return;
}
const approvedContent = await getTextFile(APPROVED_FILE);
const approvedUsers = parseApprovedUsers(approvedContent);
const capability = approvedUsers.get(prAuthor.toLowerCase());
if (!isBotAuthor && capability === 'pr') {
console.log(`${prAuthor} is approved for PRs`);
return;
}
console.log(`${prAuthor} is not approved, closing PR`);
const message = [
'This PR was auto-closed. Only contributors approved with `lgtm` can open PRs. Open an issue first and ask a maintainer for approval.',
'',
`Maintainers review auto-closed issues regularly. 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.`,
'',
'If a maintainer replies `lgtmi`, 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.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await closePullRequest(message);
@@ -0,0 +1,34 @@
# Contributor gate adapted from badlogic/pi-mono (MIT) at 6f7551516b84.
# See CONTRIBUTING.md for how it works.
name: Remove In Progress Label On Close
on:
issues:
types: [closed]
jobs:
remove-label:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Remove inprogress label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const labelName = 'inprogress';
const labels = context.payload.issue.labels ?? [];
const hasLabel = labels.some((label) => label.name === labelName);
if (!hasLabel) {
console.log(`Issue does not have ${labelName} label`);
return;
}
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: labelName,
});
+1 -1
View File
@@ -1,7 +1,7 @@
.DS_Store .DS_Store
__pycache__/ __pycache__/
apk-catalog/data/cache/ apk-catalog/data/cache/
apk-catalog/data/index-v2.json* apk-catalog/data/index-v2*.json*
compat-db/.env.lakebed.server compat-db/.env.lakebed.server
compat-db/.lakebed/ compat-db/.lakebed/
tests/smoke/results/ tests/smoke/results/
+71
View File
@@ -0,0 +1,71 @@
# Contributing to Frame Control
This guide exists to save both sides time. The process is borrowed from
[pi](https://github.com/badlogic/pi-mono/blob/main/CONTRIBUTING.md).
## Just want to report something?
Use the [feedback form](https://frame-control.pages.dev/feedback/). It needs no
GitHub account, and what you send becomes an issue here that stays open.
## The One Rule
**You must understand your code.** If you can't explain what your change does
and how it interacts with the rest of the app, your PR will be closed.
Using AI to write code is fine. Submitting AI-generated slop you don't
understand is not.
## Contribution gate
Issues and PRs opened on GitHub by new contributors are auto-closed by default.
A maintainer reviews auto-closed issues regularly and reopens worthwhile ones.
Issues that don't meet the quality bar below won't be reopened or get a reply.
Approval happens through maintainer replies on issues:
- `lgtmi`: your future issues won't be auto-closed
- `lgtm`: your future issues and PRs won't be auto-closed
The word must be at the start of the reply (optionally after one or more
`@username` mentions) or at the end. Only `lgtm` lets you open PRs. Approved
people are listed in [`.github/APPROVED_CONTRIBUTORS`](.github/APPROVED_CONTRIBUTORS).
## Quality bar for issues
Use one of the issue templates, and keep it short, concrete and worth reading.
- If it doesn't fit on one screen, it's too long.
- Write in your own voice. If you must use an LLM, say so in a clearly labelled
follow-up comment.
- State the bug or request clearly, and why it matters.
- For bugs, include your OS, your SteamOS build (Steam Settings → System), and
the server log (**Frame → Show Server Log** in the app).
- If you want to implement the change yourself, say so.
## Before opening a PR
Don't open a PR until a maintainer has approved you with `lgtm`. Open an
[idea or contribution proposal](https://github.com/saphid/frame-control/issues/new?template=idea.yml)
first.
Then check your change:
```sh
python3 -m unittest discover -s tests # server tests; no headset needed
node --test site/test/*.test.mjs # website feedback function
```
Say what you tested, and whether you tried it on a real Steam Frame.
## Blocking
If you ignore this document twice, or spam the tracker with agent-generated
issues, your GitHub account will be blocked from the repo.
## Why auto-close?
This is a hobby project with one maintainer. Auto-closing is a buffer against
burnout and tracker spam: issues get reviewed on the maintainer's schedule, and
the good ones are reopened. Short, concrete, reproducible reports and thoughtful
contributions are welcome.
+21 -4
View File
@@ -12,12 +12,17 @@ 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) [![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) [![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE)
[**Download**](#install) · [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> <br>
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900"> <img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
<a id="trailer"></a>
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub> <sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
</div> </div>
@@ -102,6 +107,10 @@ already ships (sideloading a game copies Valve's own devkit scripts to
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md). a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
The app brings its own Python and `adb`; SSH is built into macOS and Windows. The app brings its own Python and `adb`; SSH is built into macOS and Windows.
From 0.4 it updates itself: when a new version is published, a banner offers
**Update and restart**. It sends anonymous usage statistics, which you can turn
off. Sharing compatibility results and error details is opt-in. See
[docs/privacy.md](docs/privacy.md).
Google doesn't publish `adb` for arm64 Linux, so that build uses your Google doesn't publish `adb` for arm64 Linux, so that build uses your
distribution's. If you already have `adb`, the app uses yours. distribution's. If you already have `adb`, the app uses yours.
@@ -170,13 +179,19 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
## Feedback ## Feedback
This is a first public test, so reports are really useful, especially from This is a first public test, so reports are really useful, especially from
Windows and Linux. Please [open an issue](https://github.com/saphid/steam-frame/issues/new) Windows and Linux. The quickest way is **Report a problem** in the app (the
with: 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:
- what you tried and what happened - what you tried and what happened
- your computer's OS and your SteamOS build (Steam Settings → System) - your computer's OS and your SteamOS build (Steam Settings → System)
- the server log: **Frame → Show Server Log** in the app - the server log: **Frame → Show Server Log** in the app
Issues and PRs opened directly on GitHub by new contributors are auto-closed
until a maintainer approves them; see [CONTRIBUTING.md](CONTRIBUTING.md).
## Going further ## Going further
This repo also holds the scripts behind the app and field notes on how the This repo also holds the scripts behind the app and field notes on how the
@@ -195,6 +210,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes | | [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 iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset | | [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test | | [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
| [Open questions](docs/open-questions.md) | What's still unchecked | | [Open questions](docs/open-questions.md) | What's still unchecked |
@@ -228,7 +244,8 @@ cd app && npm install && npm start # run the app from the checkout
The server is Python stdlib only; the app is Electron. GitHub Actions runs the The server is Python stdlib only; the app is Electron. GitHub Actions runs the
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
into the release. See [building](docs/frame-control.md#building). into a draft release, which reaches users once published. See
[building](docs/frame-control.md#building) and [releasing](docs/releasing.md).
## License ## License
+98 -2
View File
@@ -10,6 +10,7 @@ const net = require("net");
const os = require("os"); const os = require("os");
const path = require("path"); const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link"); const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const updater = require("./updater");
const run = promisify(execFile); const run = promisify(execFile);
@@ -114,7 +115,11 @@ function ping(target) {
} }
async function startServer() { async function startServer() {
// The version and whether this is a built app go to ui/frame_telemetry.py, which
// sends nothing from a source checkout.
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1", const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) }; ...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env); python = await findPython(env);
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`); if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
@@ -244,6 +249,9 @@ function fromUi(e) {
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); }); ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// frame-control://install links from websites (docs/web-install.md). They can // frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch), // arrive before the window or server exists (macOS open-url on a cold launch),
@@ -276,6 +284,81 @@ ipcMain.on("install-link:ready", (e) => {
deliverLinks(); deliverLinks();
}); });
// ---- updates (app/updater.js, docs/releasing.md) ----
// Checked shortly after launch and every few hours; the page shows a banner and
// the Update button calls installUpdate.
const UPDATE_EVERY = 6 * 3600 * 1000;
const update = { status: "idle", current: app.getVersion(), latest: null, error: null, progress: 0, how: null };
function publicUpdate() {
const r = update.latest;
return { status: update.status, current: update.current, error: update.error, progress: update.progress,
latest: r && { version: r.version, notes: r.notes, page: r.page },
canInstall: !!update.how && update.how.method !== "manual", why: update.how && update.how.why };
}
function setUpdate(fields) {
Object.assign(update, fields);
if (win && linkPage === win.webContents) win.webContents.send("update:state", publicUpdate());
}
async function checkForUpdate({ manual = false } = {}) {
if (["checking", "downloading", "ready"].includes(update.status)) return;
setUpdate({ status: "checking", error: null });
try {
const latest = await updater.latestRelease();
const how = updater.updateMethod({ platform: process.platform, isPackaged: app.isPackaged,
execPath: process.execPath, env: process.env,
exists: fs.existsSync, writable: updater.writable });
if (updater.isNewer(latest.version, update.current)) {
setUpdate({ status: "available", latest, how });
if (manual) offerUpdateDialog();
} else {
setUpdate({ status: "none", latest, how });
if (manual) dialog.showMessageBox(win, { type: "info", message: "Frame Control is up to date",
detail: `You have ${update.current}, the newest version.` });
}
} catch (e) {
// A failed check: nothing to install, and never an older release kept from before.
setUpdate({ status: "check-failed", error: e.message, latest: null });
if (manual) dialog.showMessageBox(win, { type: "warning", message: "Couldn't check for updates", detail: e.message });
}
}
async function offerUpdateDialog() {
const r = update.latest;
const { response } = await dialog.showMessageBox(win, {
type: "info", message: `Frame Control ${r.version} is available`,
detail: `You have ${update.current}.` + (update.how.method === "manual" ? ` Download it from the release page (${update.how.why}).` : ""),
buttons: [update.how.method === "manual" ? "Open Release Page" : "Update and Restart", "Later"], defaultId: 0, cancelId: 1,
});
if (response === 0) installUpdate();
}
async function installUpdate() {
// "error" here only ever means an install failed, so trying again is safe.
if (update.status !== "available" && update.status !== "error") return;
if (!update.latest || !updater.isNewer(update.latest.version, update.current)) return;
if (!update.how || update.how.method === "manual") { shell.openExternal(update.latest.page); return; }
setUpdate({ status: "downloading", progress: 0, error: null });
try {
const start = await updater.prepare(update.latest, update.how,
(done, total) => { if (total) setUpdate({ progress: done / total }); }, update.current);
setUpdate({ status: "ready", progress: 1 });
start();
quitting = true;
app.quit();
} catch (e) {
setUpdate({ status: "error", error: e.message });
}
}
function scheduleUpdateChecks() {
if (process.env.FRAME_CONTROL_NO_UPDATE_CHECK === "1") return;
setTimeout(checkForUpdate, 8000);
setInterval(checkForUpdate, UPDATE_EVERY).unref();
}
function registerScheme() { function registerScheme() {
// A checkout runs as `electron .`, so the OS must be told the script too. // A checkout runs as `electron .`, so the OS must be told the script too.
// (macOS takes the scheme from Info.plist, which only the built app has.) // (macOS takes the scheme from Info.plist, which only the built app has.)
@@ -337,7 +420,11 @@ async function setUpConnection() {
function buildMenu() { function buildMenu() {
const template = [ const template = [
...(IS_MAC ? [{ role: "appMenu" }] : []), ...(IS_MAC ? [{ label: app.name, submenu: [
{ role: "about" }, { label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) },
{ type: "separator" }, { role: "services" }, { type: "separator" },
{ role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" },
] }] : []),
{ role: "fileMenu" }, { role: "fileMenu" },
{ role: "editMenu" }, { role: "editMenu" },
{ {
@@ -364,7 +451,15 @@ function buildMenu() {
...(IS_MAC ? [{ role: "windowMenu" }] : []), ...(IS_MAC ? [{ role: "windowMenu" }] : []),
{ {
role: "help", role: "help",
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }], submenu: [
...(IS_MAC ? [] : [{ label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) }]),
{ label: "Report a Problem…", click: () => {
if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
} },
{ label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") },
],
}, },
]; ];
Menu.setApplicationMenu(Menu.buildFromTemplate(template)); Menu.setApplicationMenu(Menu.buildFromTemplate(template));
@@ -387,6 +482,7 @@ if (!app.requestSingleInstanceLock()) {
registerScheme(); registerScheme();
buildMenu(); buildMenu();
createWindow(); createWindow();
scheduleUpdateChecks();
}); });
app.on("activate", () => { if (!win) createWindow(); }); app.on("activate", () => { if (!win) createWindow(); });
app.on("window-all-closed", () => app.quit()); app.on("window-all-closed", () => app.quit());
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "frame-control", "name": "frame-control",
"version": "0.3.1", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frame-control", "name": "frame-control",
"version": "0.3.1", "version": "0.4.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"electron": "^44.4.5", "electron": "^44.4.5",
+4 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "frame-control", "name": "frame-control",
"productName": "Frame Control", "productName": "Frame Control",
"version": "0.3.1", "version": "0.4.0",
"description": "Desktop app for managing a Valve Steam Frame over SSH", "description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true, "private": true,
"main": "main.js", "main": "main.js",
@@ -37,6 +37,7 @@
"main.js", "main.js",
"preload.js", "preload.js",
"install-link.js", "install-link.js",
"updater.js",
"package.json", "package.json",
"build/icon.png" "build/icon.png"
], ],
@@ -46,7 +47,8 @@
"to": "ui", "to": "ui",
"filter": [ "filter": [
"*.py", "*.py",
"*.html" "*.html",
"telemetry.json"
] ]
}, },
{ {
+16
View File
@@ -5,12 +5,28 @@
// It can open Set Up Connection when the headset can't be reached. // It can open Set Up Connection when the headset can't be reached.
// It also receives frame-control://install links (docs/web-install.md): only // It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first. // what the link asked for, never an install; the page asks the user first.
// And it passes update state both ways: see app/updater.js.
const { contextBridge, ipcRenderer, webUtils } = require("electron"); const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", { contextBridge.exposeInMainWorld("frameApp", {
readClipboard: () => ipcRenderer.invoke("clipboard:read"), readClipboard: () => ipcRenderer.invoke("clipboard:read"),
setUpConnection: () => ipcRenderer.invoke("connection:setup"), setUpConnection: () => ipcRenderer.invoke("connection:setup"),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } }, pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
// Updates (app/updater.js): the page shows a banner and an Update button.
update: {
get: () => ipcRenderer.invoke("update:get"),
check: () => ipcRenderer.invoke("update:check"),
install: () => ipcRenderer.invoke("update:install"),
onState: (cb) => {
ipcRenderer.removeAllListeners("update:state");
ipcRenderer.on("update:state", (_e, s) => cb(s));
},
},
// Help → Report a Problem… opens the page's report dialog (ui/frame_report.py).
onReportProblem: (cb) => {
ipcRenderer.removeAllListeners("report:open");
ipcRenderer.on("report:open", () => cb());
},
onInstallLink: (cb) => { onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link"); ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target })); ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
+59
View File
@@ -0,0 +1,59 @@
// Run: node --test app/test/
const test = require("node:test");
const assert = require("node:assert");
const { isNewer, assetName, updateMethod, macBundle } = require("../updater");
test("versions compare numerically, and a release beats its pre-releases", () => {
assert.ok(isNewer("0.3.10", "0.3.9"));
assert.ok(isNewer("v1.0.0", "0.9.9"));
assert.ok(!isNewer("0.3.1", "0.3.1"));
assert.ok(!isNewer("0.3.0", "0.3.1"));
assert.ok(isNewer("1.0.0", "1.0.0-beta.1"));
assert.ok(!isNewer("1.0.0-beta.1", "1.0.0"));
assert.ok(!isNewer("garbage", "0.1.0"));
});
test("asset names match what electron-builder publishes", () => {
assert.strictEqual(assetName("darwin", "arm64", "mac-zip"), "Frame-Control-mac-arm64.zip");
assert.strictEqual(assetName("win32", "x64", "nsis"), "Frame-Control-Setup-x64.exe");
assert.strictEqual(assetName("linux", "x64", "appimage"), "Frame-Control-linux-x86_64.AppImage");
assert.strictEqual(assetName("linux", "arm64", "appimage"), "Frame-Control-linux-arm64.AppImage");
});
const base = { isPackaged: true, env: {}, exists: () => false, writable: () => true };
test("macOS updates in place only from a writable, non-translocated location", () => {
const exe = "/Applications/Frame Control.app/Contents/MacOS/Frame Control";
assert.strictEqual(macBundle(exe), "/Applications/Frame Control.app");
assert.deepStrictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe }),
{ method: "mac-zip", bundle: "/Applications/Frame Control.app" });
const dmg = "/Volumes/Frame Control 0.3.1/Frame Control.app/Contents/MacOS/Frame Control";
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: dmg }).method, "manual");
const trans = "/private/var/folders/x/AppTranslocation/ABC/d/Frame Control.app/Contents/MacOS/Frame Control";
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: trans }).method, "manual");
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe, writable: () => false }).method, "manual");
});
test("Windows needs the installer's copy; Linux needs an AppImage", () => {
const exe = "C:\\Users\\a\\AppData\\Local\\Programs\\Frame Control\\Frame Control.exe";
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe, exists: () => true }).method, "nsis");
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe }).method, "manual");
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/x", env: { APPIMAGE: "/home/a/F.AppImage" } }).method,
"appimage");
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/Frame Control/frame-control" }).method, "manual");
assert.strictEqual(updateMethod({ ...base, isPackaged: false, platform: "darwin", execPath: "x" }).method, "manual");
});
test("update.json assets always download from this repository's release", () => {
const r = require("../updater").fromManifest({ version: "0.4.0", notes: "n", assets: [
{ name: "Frame-Control-mac-arm64.zip", url: "https://evil.example/x.zip", digest: "sha256:" + "a".repeat(64) }] });
assert.strictEqual(r.assets[0].url, "https://github.com/saphid/frame-control/releases/download/v0.4.0/Frame-Control-mac-arm64.zip");
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
});
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
const { prepare } = require("../updater");
const release = { version: "0.3.1", assets: [] };
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.4.0"), /isn't newer/);
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.3.1"), /isn't newer/);
});
+250
View File
@@ -0,0 +1,250 @@
// Update checks and self-update for the desktop app (docs/releasing.md).
//
// The newest version is GitHub's "latest" release of saphid/frame-control. Drafts
// and pre-releases never count, so a build reaches people only when the
// maintainer publishes it after testing (scripts/publish-release.sh). That
// script attaches update.json (version, notes, each asset's SHA-256), read
// through github.com's latest/download link: the REST API allows only 60
// unauthenticated requests an hour per IP address, shared by everyone behind
// the same router, so it's only the fallback.
//
// Every download is checked against the SHA-256 digest GitHub records for the
// asset before anything is replaced. How the update is applied:
// macOS the .zip: unpacked next to the running app, swapped in by a small
// script once the app has quit, then reopened.
// Windows the NSIS installer, run silently over the current install; it
// reopens the app. A copy unpacked from the .zip is updated by hand.
// Linux the AppImage replaces itself; .deb installs are updated by hand.
// When the app can't update itself it opens the release page instead.
const { execFile, spawn } = require("child_process");
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
const os = require("os");
const path = require("path");
const REPO = "saphid/frame-control"; // renamed from saphid/steam-frame; GitHub redirects the old name
const LATEST = `https://api.github.com/repos/${REPO}/releases/latest`;
const MANIFEST = `https://github.com/${REPO}/releases/latest/download/update.json`;
const RELEASES = `https://github.com/${REPO}/releases`;
// "0.3.1" or "v0.3.1" -> [0, 3, 1]; pre-release suffixes sort before the release.
function parseVersion(v) {
const m = String(v || "").trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
return m ? { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null } : null;
}
function isNewer(candidate, current) {
const a = parseVersion(candidate), b = parseVersion(current);
if (!a || !b) return false;
for (let i = 0; i < 3; i++) if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
if (a.pre === b.pre) return false;
if (!a.pre) return true; // 1.0.0 is newer than 1.0.0-beta
if (!b.pre) return false;
return a.pre > b.pre;
}
// The asset this copy of the app updates from, by the names electron-builder gives them.
function assetName(platform, arch, method) {
if (method === "mac-zip") return `Frame-Control-mac-${arch}.zip`;
if (method === "nsis") return `Frame-Control-Setup-${arch}.exe`;
if (method === "appimage") return `Frame-Control-linux-${arch === "x64" ? "x86_64" : arch}.AppImage`;
return null;
}
// How this copy can update itself: mac-zip, nsis, appimage, or manual (with why).
function updateMethod({ platform, isPackaged, execPath, env, exists, writable }) {
if (!isPackaged) return { method: "manual", why: "running from a source checkout" };
if (platform === "darwin") {
const bundle = macBundle(execPath);
if (!bundle) return { method: "manual", why: "can't find the app bundle" };
if (bundle.includes("/AppTranslocation/") || bundle.startsWith("/Volumes/")) {
return { method: "manual", why: "move Frame Control to Applications first" };
}
if (!writable(path.dirname(bundle))) return { method: "manual", why: `${path.dirname(bundle)} isn't writable` };
return { method: "mac-zip", bundle };
}
if (platform === "win32") {
// electron-builder's NSIS install puts its uninstaller next to the app.
const dir = path.dirname(execPath);
if (exists(path.join(dir, "Uninstall Frame Control.exe"))) return { method: "nsis" };
return { method: "manual", why: "not installed with the installer" };
}
if (platform === "linux" && env.APPIMAGE) {
if (!writable(path.dirname(env.APPIMAGE))) return { method: "manual", why: "the AppImage's folder isn't writable" };
return { method: "appimage", appImage: env.APPIMAGE };
}
return { method: "manual", why: "installed from a package" };
}
function macBundle(execPath) {
const i = execPath.indexOf(".app/Contents/MacOS/");
return i < 0 ? null : execPath.slice(0, i + 4);
}
function get(url, { headers = {}, timeout = 20000, redirects = 5 } = {}) {
return new Promise((resolve, reject) => {
const req = https.get(url, { headers: { "user-agent": "FrameControl-updater", ...headers }, timeout }, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirects > 0) {
res.resume();
const next = new URL(res.headers.location, url);
if (next.protocol !== "https:") return reject(new Error("refusing a non-HTTPS redirect"));
return resolve(get(next.href, { headers, timeout, redirects: redirects - 1 }));
}
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} from ${new URL(url).host}`)); }
resolve(res);
});
req.on("timeout", () => req.destroy(new Error("timed out")));
req.on("error", reject);
});
}
async function getJson(url, headers) {
const res = await get(url, { headers });
let body = "";
for await (const chunk of res) body += chunk;
return JSON.parse(body);
}
// update.json and the API's release both become { version, notes, page, assets }.
function fromManifest(m) {
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
page: m.page || RELEASES,
// Assets always come from this repository's release, whatever the manifest says.
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
size: a.size, digest: a.digest || null })) };
}
function fromApi(r) {
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
page: r.html_url || RELEASES,
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
digest: a.digest || null })) };
}
async function latestRelease() {
try {
return fromManifest(await getJson(MANIFEST));
} catch (e) {
if (!/HTTP 404/.test(e.message)) throw e; // releases before update.json existed
}
return fromApi(await getJson(LATEST, { accept: "application/vnd.github+json" }));
}
async function download(asset, dest, onProgress) {
const m = /^sha256:([0-9a-f]{64})$/.exec(asset.digest || "");
if (!m) throw new Error(`GitHub has no SHA-256 for ${asset.name}, so it can't be checked`);
const res = await get(asset.url, { timeout: 60000 });
const total = +res.headers["content-length"] || asset.size || 0;
const hash = crypto.createHash("sha256");
// "wx": a new file only, never through an existing file or symlink at that path.
const out = fs.createWriteStream(dest, { mode: 0o755, flags: "wx" });
let done = 0;
await new Promise((resolve, reject) => {
res.on("data", (chunk) => { hash.update(chunk); done += chunk.length; onProgress && onProgress(done, total); });
res.on("error", reject);
out.on("error", reject);
out.on("finish", resolve);
res.pipe(out);
});
if (hash.digest("hex") !== m[1]) {
fs.rmSync(dest, { force: true });
throw new Error(`${asset.name} didn't match its SHA-256; nothing was changed`);
}
}
const run = (cmd, args) => new Promise((resolve, reject) =>
execFile(cmd, args, { timeout: 120000 }, (err, stdout, stderr) => err ? reject(new Error((stderr || err.message).trim())) : resolve(stdout)));
// Waits for this process to exit, swaps the new bundle in (putting the old one
// back if that fails), and reopens the app.
const MAC_SWAP = `set -u
pid="$1"; app="$2"; new="$3"; stage="$4"
while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done
old="$stage/old.app"
if mv "$app" "$old"; then
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
fi
xattr -dr com.apple.quarantine "$app" 2>/dev/null
rm -rf "$stage"
open "$app"
`;
async function applyMac(release, bundle, onProgress) {
const asset = release.assets.find((a) => a.name === assetName("darwin", process.arch, "mac-zip"));
if (!asset) throw new Error(`the release has no ${assetName("darwin", process.arch, "mac-zip")}`);
// Staged beside the app, so the final move stays on one volume.
const stage = fs.mkdtempSync(path.join(path.dirname(bundle), ".frame-control-update-"));
try {
const zip = path.join(stage, asset.name);
await download(asset, zip, onProgress);
await run("/usr/bin/ditto", ["-x", "-k", zip, stage]);
fs.rmSync(zip, { force: true });
const name = fs.readdirSync(stage).find((n) => n.endsWith(".app"));
if (!name) throw new Error("the download has no app in it");
const fresh = path.join(stage, name);
const version = (await run("/usr/bin/plutil", ["-extract", "CFBundleShortVersionString", "raw",
path.join(fresh, "Contents", "Info.plist")])).trim();
if (version !== release.version) throw new Error(`the download is version ${version}, not ${release.version}`);
const script = path.join(stage, "swap.sh");
fs.writeFileSync(script, MAC_SWAP);
return () => spawn("/bin/sh", [script, String(process.pid), bundle, fresh, stage],
{ detached: true, stdio: "ignore" }).unref();
} catch (e) {
fs.rmSync(stage, { recursive: true, force: true });
throw e;
}
}
async function applyNsis(release, onProgress) {
const asset = release.assets.find((a) => a.name === assetName("win32", process.arch, "nsis"));
if (!asset) throw new Error(`the release has no ${assetName("win32", process.arch, "nsis")}`);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-control-update-"));
const exe = path.join(dir, asset.name);
await download(asset, exe, onProgress);
// /S: silent, into the existing install. --force-run: open the app afterwards.
return () => spawn(exe, ["--updated", "/S", "--force-run"], { detached: true, stdio: "ignore" }).unref();
}
async function applyAppImage(release, appImage, onProgress) {
const asset = release.assets.find((a) => a.name === assetName("linux", process.arch, "appimage"));
if (!asset) throw new Error(`the release has no ${assetName("linux", process.arch, "appimage")}`);
// A private folder beside the AppImage, so the final rename stays on one filesystem.
const stage = fs.mkdtempSync(path.join(path.dirname(appImage), ".frame-control-update-"));
try {
const next = path.join(stage, asset.name);
await download(asset, next, onProgress);
fs.chmodSync(next, 0o755);
fs.renameSync(next, appImage); // the running copy keeps its open file
} finally {
fs.rmSync(stage, { recursive: true, force: true });
}
// Without FUSE the AppImage runs extracted (--appimage-extract-and-run, which isn't passed
// on to the app); a FUSE mount lives under /tmp/.mount_*. Keep the same mode on restart.
const extracted = process.env.APPIMAGE_EXTRACT_AND_RUN === "1" || !process.execPath.includes("/.mount_");
const env = { ...process.env, APPIMAGE: appImage, ...(extracted ? { APPIMAGE_EXTRACT_AND_RUN: "1" } : {}) };
// Started only once this process has exited, or the new copy would lose the single-instance lock.
return () => spawn("/bin/sh", ["-c", 'while kill -0 "$1" 2>/dev/null; do sleep 0.2; done; exec "$2"',
"sh", String(process.pid), appImage], { detached: true, stdio: "ignore", env }).unref();
}
// Downloads and prepares the update; returns a function that starts the swap,
// to be called just before the app quits.
async function prepare(release, how, onProgress, current) {
if (!isNewer(release.version, current)) throw new Error(`${release.version} isn't newer than ${current}`);
if (how.method === "mac-zip") return applyMac(release, how.bundle, onProgress);
if (how.method === "nsis") return applyNsis(release, onProgress);
if (how.method === "appimage") return applyAppImage(release, how.appImage, onProgress);
throw new Error(how.why || "this copy can't update itself");
}
function writable(dir) {
try { fs.accessSync(dir, fs.constants.W_OK); return true; } catch { return false; }
}
module.exports = { REPO, RELEASES, parseVersion, isNewer, assetName, updateMethod, macBundle, latestRelease,
fromManifest, fromApi,
download, prepare, writable };
+26 -3
View File
@@ -1,9 +1,32 @@
# compat-db: Frame Control's compatibility database # compat-db: Frame Control's compatibility database
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
reports for Android apps on the Steam Frame. For now only the maintainer's reports for Android apps on the Steam Frame. Only the maintainer's copy of
copy of Frame Control has the key to read or write it. Everyone else's reports Frame Control has the key to read or write it (see `shared()` in
stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`). `ui/frame_compat_db.py`). Everyone else's reports stay on their computer
unless they turn on **Share compatibility results**. Then the reports also go
to PostHog as `compat_report` events, and the maintainer syncs them in (below).
## Community reports
```sh
python3 ui/frame_compat_db.py sync --dry-run # what would be added
python3 ui/frame_compat_db.py sync # add them
```
`sync` reads `compat_report` events through PostHog's query API and adds
them with `via` set to `community`, `community-probe` or `community-install`.
It skips invalid reports and anything over 30 per reporter per day. Each run
re-reads the last 30 days, because an offline copy sends its reports late,
with the time they were made. `posthog-sync.json`, next to the outbox,
remembers which reports it has handled and each reporter's daily count, so
nothing is added twice and the cap holds across runs.
It needs:
- the PostHog project id: `"project"` in `ui/telemetry.json`
- a personal API key with `query:read`: `POSTHOG_PERSONAL_API_KEY`, or in the
Keychain (service `frame-control-posthog`, account `personal-api-key`)
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`, - Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
claimed, so it doesn't expire). The browser page only says it's private. claimed, so it doesn't expire). The browser page only says it's private.
+185
View File
@@ -0,0 +1,185 @@
# Frame Control for AI agents
**Documented interface:** Frame Control's own stdlib Python MCP adapter wraps
its loopback HTTP API. No API key, hosted service, model SDK or third-party
helper app is needed. The assistant is our HTML/Python implementation hosted
in the platform Chromium browser. Its optional LLM endpoint is user configuration.
Installing other apps is an optional management action, never a prerequisite.
## Connect an MCP client
The default MCP command starts a private HTTP backend on a free loopback port,
with a fresh local access key. It stops that backend when the MCP client closes
stdin or sends SIGTERM. It uses its own SSH control socket, so closing it does
not close the desktop app's connection. No manually started server is needed.
Add this stdio server to your MCP client (use absolute paths):
```json
{
"mcpServers": {
"frame-control": {
"command": "python3",
"args": ["/absolute/path/frame-control/ui/frame_mcp.py"]
}
}
}
```
For Codex, the equivalent registration is:
```sh
codex mcp add frame-control -- python3 /absolute/path/frame-control/ui/frame_mcp.py
```
New agent sessions load the entry. An already running session may need its MCP
connections reloaded; registration does not retroactively add tools to its
initial tool inventory. Keep the checkout at that path while it is registered.
Use `codex mcp remove frame-control` to remove only this registration.
To reuse a running server instead, pass `--url http://127.0.0.1:47810`.
The desktop app uses a random port; use that port with `--url`, or run the
checkout server above. If the HTTP server uses `FRAME_UI_KEY`, pass the same
value in the MCP process environment. This is local access control, not an LLM
API key. The adapter only accepts loopback HTTP servers, refuses redirects and
ignores environment proxies. Stdout contains newline-delimited JSON-RPC only.
It supports MCP initialization, ping, tool listing and tool calls; no sampling,
resources, prompts or streaming transport.
| Tool | Arguments | Effect |
|---|---|---|
| `computer_state` | none | Read-only gamescope window IDs/focus and bounded AT-SPI tree; reports incomplete observations |
| `status` | none | Battery, services, installed games and Flatpaks |
| `screenshot` | `view`: `headset` (default) or `desktop` | Returns PNG image content to the MCP client |
| `job` | `id` | Background install status; poll until `done`, inspect `error` |
| `launch` | `appid` | Launch an installed Steam app |
| `install` / `uninstall` | `id` | Install from Flathub / remove a user Flatpak |
| `send_text` | `text` | Frame desktop clipboard; desktop must be open |
| `send_file` | `path` | File on the HTTP server computer, up to 16 MiB, copied to Frame `~/Downloads` |
| `panel` | `id` | Launch an installed Flatpak as a panel using the existing launcher |
| `power` | `action`: `suspend`, `reboot`, `poweroff` | Open a terminal for the user to enter the sudo password |
| `keep_awake` | `action`: `on`, `off`, `status` | Optional keep-awake script interface |
Only install free software with its developer's consent. There is no purchase,
entitlement bypass or arbitrary shell tool. `install` returns a background job
ID; it does not claim the installation has finished. APK and sideloaded title
installs remain in the main UI for now.
### Approval is a separate human action
Every mutation first returns an `approvalUrl`, exact action and `confirmation`
token. Ask the user to open that URL and choose **Approve this action** or
**Reject**. Then repeat the same tool and arguments with the token in
`confirmation`. The server refuses execution before approval, changed arguments,
expired tokens and reuse. A file approval binds the content hash as well as the
path. Approvals last five minutes and disappear when the HTTP server restarts.
A failed execution also consumes the approval; review a fresh request to retry.
The panel does not execute an action merely because it was approved.
MCP has no approval tool. This is protection against accidental model tool
calls, not a sandbox against a client with independent shell/HTTP access to your
computer. Grant the MCP client only the access you intend. Status, captures and computer-state observations
are returned directly to that client, which may forward them to its configured
model. The assistant's separate opt-in does not govern an external MCP client.
Power still requires the existing password prompt in a local terminal. MCP
never receives passwords. Power via `FRAME_LOCAL=1` is unsupported: use the main
UI. The panel launcher and keep-awake adapter require zsh on the computer.
[PR #16](https://github.com/saphid/frame-control/pull/16) owns
`scripts/keep-awake.sh on|off|status`. This branch does not copy or change it.
Until that script is present, the tool reports it unavailable. Keep-awake is
never automatic: `on` changes the shared idle timers; explicitly approve `off`
to restore them after work. It is not a per-agent lease; coordinate with other
users. No changes are made to the analytics/update interfaces in
[PR #17](https://github.com/saphid/frame-control/pull/17). Prompts, keys, model
replies, screenshots and approval payloads are not sent to analytics.
## Assistant panel
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
python3 scripts/assistant-on-frame.py --port 47810
```
This starts an SSH reverse forward bound to Frame loopback (port 47812 by
default), then a dedicated Chromium profile tagged as a SteamVR panel. Keep the
command running. Ctrl-C closes this browser profile and the tunnel; it leaves
other Chromium windows and the existing HTTP server alone. A failed cleanup
prints the temporary profile path so it can be removed when the Frame returns.
Use `--frame-port` if the default is busy. Chromium must already be available as
`org.chromium.Chromium`; the launcher never installs anything automatically.
Place the panel with SteamVR's normal docking controls.
Enter your full **chat-completions endpoint**, model name and optional key.
An OpenAI-compatible local server works without a key; no OpenAI account is
required. HTTP is allowed only on loopback; other endpoints require HTTPS.
Loopback refers to the computer running the HTTP server, even in the headset.
Endpoints with embedded credentials, query strings or redirects are refused.
Check the message consent box and press **Send message**. Screenshot context is
a separate unchecked box and sends one fresh capture with that request. Both
boxes reset after sending, and changing endpoint/model revokes consent. Nothing
is sent when opening the page or entering configuration. There is no model
list fetch, saved history, automatic screenshot capture or assistant telemetry.
Each send is independent: previous messages and replies are not included.
Configuration, credentials and chat remain in page memory; close/reload the page
or choose **Clear everything** to clear them. A request already sent cannot be
recalled. Only the chosen endpoint gets the request; proxy environment variables
and redirects are disabled. Its privacy and retention policy still applies.
Replies are plain text and cannot call tools or operate the Frame. A model must
support image inputs to accept screenshot context.
## Evidence and limits
**Verified 2026-09-28, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** loopback HTTP
status through an SSH reverse tunnel; platform Chromium created a separate
SteamVR panel (confirmed in `GAMESCOPE_FOCUSABLE_APPS`); headset capture returned
a PNG. These checks preceded the UI implementation. No power or global settings
were changed.
**Inferred:** visual comfort and controller keyboard usability while wearing
the headset; panel creation in gamescope alone does not establish these.
Windows/Linux launcher support, live third-party model endpoints, installs,
uninstalls, power and keep-awake changes are not covered by that feasibility
check. See the PR for the final unit and end-to-end results.
**Verified end to end on the same Frame/build (2026-09-28):** a stdio MCP client
initialized, read status, retrieved a headset PNG, and transferred a test file
only after approval through the Chromium page. Remote file bytes matched;
reusing the confirmation was rejected. The actual headset Chromium page sent
text and then separately opted-in image context to a local test endpoint and
displayed its replies. Without consent there were zero endpoint requests.
The test endpoint returned canned replies: model inference and a live external
provider remain **unverified**. The launcher’s Ctrl-C cleanup was checked;
profiles, SSH tunnels and the test file were removed. No installs, removals,
launches of user games, power operations or keep-awake changes were performed.
**Verified locally:** unit coverage includes the stdio subprocess, approval
binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP
endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame
regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked
because the Docker daemon was unavailable. The ARM64 fake-Frame CI job passed
on this branch (run 36421345682).
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
browser profile and SSH tunnel and remove the profile and panel log.
![Assistant in Frame Chromium, after an opted-in request to the local test endpoint](img/assistant-panel.png)
## Computer-use coverage
MCP is the tool transport, not a limit on what an agent can do. A screenshot,
accessibility snapshot, click or keystroke can all be MCP tools when we have a
reliable underlying implementation. See [the investigation](computer-use.md)
for the verified boundaries. `computer_state` adds observation, not an input
channel: it cannot click an approval button or send keyboard/mouse events.
**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** the command saved
by `codex mcp add` launched without a prestarted server, negotiated MCP, listed
12 tools, read live Frame status and returned X11 window state plus AT-SPI
observations. It exited 0 at EOF. Steam's accessibility tree had inaccessible
children, reported as `incomplete: true`; this is not a complete actionable UI.
+144
View File
@@ -0,0 +1,144 @@
# Announcing changes
How we tell people about Frame Control features and fixes as they merge. The
same few sentences feed the X post, the release notes and the website, so they
are written once, in the pull request, while the change is fresh.
## What gets announced
| Kind | Announce? | Example |
|---|---|---|
| **New** — something you can now do | Yes, its own post | Stream Mac windows into the Frame as panels |
| **Better** — something existing got noticeably easier, faster or wider | Yes, its own post or a roundup | APKs install without the Android SDK |
| **Fixed** — something broken that users hit | Yes if people reported it or it blocked a flow; otherwise the next roundup | Mac mirror showed a zoomed-in corner |
| **Release** — a tagged build | Always, one post linking the release | Frame Control 0.3.1 |
| Tests, refactors, CI, docs-only, website polish | No | Fake Frame tests, screenshot crop |
If a change isn't worth a sentence to someone who owns a Frame, it isn't
announced.
## The voice
Write it the way the README and release notes already read.
- **Lead with what the person can now do**, in their words: "Install older
versions of an app when the newest won't run on the Frame", not "Add APK
version fallback resolver".
- **Plain and specific.** Name the thing, give the number: "about 30 fps",
"4,500 apps", "up to 8 older versions". No "blazing", "game-changing",
"excited to announce", "huge", or exclamation marks.
- **Say where it works.** Platforms and what it was tested on, briefly:
"Tested on a real Frame from macOS 27." Don't claim what wasn't tested.
- **Say the catch.** If it needs a setup step, an unsigned build, or only works
on one OS, say so in the same post.
- **Sentence case**, full sentences, British spelling to match the docs.
Contractions are fine.
- **No emoji in the text.** One image, GIF or short clip carries the tone
instead. The only symbol is the kind label below.
- **Unofficial, always.** Never imply Valve made or endorses it. Say "Steam
Frame" for the headset and "Frame Control" for the app.
- **Credit people.** If a user reported the bug or suggested the feature and is
happy to be named, thank them by handle.
## The formats
Every announceable PR ends with an `## Announcement` section holding these.
The reviewer checks it like code.
### 1. The post (X, and any other social account)
```
<Kind>: <what you can do now, one sentence>
<one or two sentences: how it works, the catch, or what it was tested on>
<link>
```
- `<Kind>` is `New`, `Better` or `Fixed`.
- 280 characters maximum including the link (X counts any link as 23).
- One link: the release if it has shipped, otherwise the PR.
- One visual when the change is visible: a screenshot from the app, a GIF, or a
short clip from the headset. Alt text describes what it shows.
- No hashtags, except `#SteamFrame` on releases and on posts about something
new, because people search for it.
### 2. The release-note line
One bullet under **New in x.y.z**, same as the current release notes: the
first half of the post's first sentence, no kind label, no link.
### 3. Release post
```
Frame Control <version>: <the headline change>
<one sentence on the headline change>. Also: <two or three short items>.
Windows, macOS and Linux: <release link>
#SteamFrame
```
The release title on GitHub uses the same `Frame Control <version>: <headline>`
line, as 0.3.0 and 0.3.1 already do.
### Roundups
Small fixes that don't earn their own post wait for a roundup, posted with the
next release or when three or more have piled up:
```
Fixed in Frame Control this week:
- <fix>
- <fix>
- <fix>
<link>
```
## Examples from what has already merged
**#11, older APK versions**
```
New: when an Android app is too new for the Frame, Frame Control now offers
older versions that will install.
It checks F-Droid, its archive and IzzyOnDroid, and verifies each download
before it goes on the headset.
https://github.com/saphid/steam-frame/pull/11
```
**#8, Mac mirror fixes**
```
Fixed: mirroring your Mac into the Steam Frame now fits the whole desktop in
the panel, asks for the right password, and shows the cursor.
Tested end to end on a real Frame from macOS 27.
https://github.com/saphid/steam-frame/pull/8
```
**v0.3.1**
```
Frame Control 0.3.1: install APKs without the Android SDK
Frame Control now reads APK files itself, so there's nothing extra to install.
Also: Linux and Windows game sideloading, and one-click install links.
Windows, macOS and Linux: https://github.com/saphid/steam-frame/releases/tag/v0.3.1
#SteamFrame
```
## Posting
Nothing is posted without a person approving it. The flow is:
1. The PR carries its `## Announcement` section.
2. On merge, the post is drafted from that section (manually for now).
3. Alex approves or edits it, then it's posted from the project account.
4. Replies and questions that turn out to be bugs become GitHub issues labelled
`feedback`, same as the website form.
+27
View File
@@ -46,6 +46,33 @@ Lepton Development must be installed once. Over SSH,
`ssh frame 'steam steam://install/3056000'` queues it, but the install still `ssh frame 'steam steam://install/3056000'` queues it, but the install still
needs to be confirmed or started in the headset. needs to be confirmed or started in the headset.
## When an app needs a newer Android
Lepton is Android 11 (API 30), with arm64-v8a only. If Frame Control refuses
an APK, it shows compatible versions from F-Droid's main and archive repos and
IzzyOnDroid. It shows at most eight version names, newest first, preferring an
arm64-only build, and says how many compatible builds it found in total.
Each index is reduced to its compatible builds once a day and cached (about
16 MB). The first lookup takes about 30 s and 100 MB of memory; later ones are
instant.
Choose **Install** to download a listed version, verify its SHA-256 against
the index, and install it as its own app.
You can also inspect a file or look up a package from the command line:
```sh
python3 ui/frame_android.py info some-app.apk
python3 ui/frame_android.py versions some-app.apk
python3 ui/frame_android.py versions org.example.app
```
The search links open APKMirror, APKPure, Uptodown, F-Droid and GitHub. Pick a
version whose minimum is Android 11 or lower and that has an arm64-v8a build
(or no native code). Frame Control does not fetch APKs from those search sites.
Older versions may lack fixes, and being installable does not guarantee an
app will run: see the missing services below. Android may refuse a downgrade
or an update signed by a different publisher; removing the app deletes its data.
## Installed apps disappear when Lepton Development closes (verified 2026-09-25) ## Installed apps disappear when Lepton Development closes (verified 2026-09-25)
Lepton Development runs in a throwaway "dev" context. When it exits for any Lepton Development runs in a throwaway "dev" context. When it exits for any
+67
View File
@@ -0,0 +1,67 @@
# Computer use through Frame Control MCP
The MCP transport can carry semantic actions or visual computer-use actions.
The limits are the Frame's underlying interfaces, permissions and whether an
action can be targeted and verified. A stereoscopic headset screenshot alone
is not a reliable coordinate system for clicking a particular app window.
## What exists, and the right route
| Surface | Evidence and route | Remaining work or boundary |
|---|---|---|
| Frame management | **Verified:** existing SSH/HTTP operations for status, capture and file transfer work through MCP. Typed install/launch/power tools wrap the existing API. | Extend typed operations before adding generic mouse automation. Preserve explicit approval for consequential changes. |
| App/window observation | **Verified 2026-09-29:** `computer_state` reads gamescope X11 window/app/process triples, focused app and the installed AT-SPI library. | Bounded to 96 accessible nodes and six levels. Trees may be truncated, stale, hidden or incomplete. Snapshot paths and XIDs are observations, never durable action permissions. |
| Chromium page content | **Verified previously:** the assistant rendered and could be exercised through CDP in an isolated Frame Chromium profile. | A shipped click/type surface needs exact owned browser/target binding, fresh element references, lifecycle cleanup, consent and post-action readback. Do not expose unrestricted JavaScript or attach to arbitrary existing profiles automatically. |
| Steam UI | **Verified 2026-09-29:** the AT-SPI service listed the Steam client's Chromium process and frame nodes, but child traversal was incomplete. Existing `frame_steam.py` uses Steam's loopback CDP endpoint for specific operations. | Prefer those narrow Steam interfaces. Presence of AT-SPI does not prove controls are actionable, and generic pointer injection is not proved for VR menus. |
| Other Linux apps | **Verified 2026-09-29:** Frame ships libX11, libXtst and libatspi; `/dev/uinput` is writable by the current user. | Library presence and access permissions do not prove that a game accepts input. Global virtual input can affect whichever app has focus. Do not ship a blind keyboard/mouse tool on this evidence alone. |
| Panel focus and layouts | **Documented in [#41](https://github.com/saphid/frame-control/pull/41):** `POST /api/panels` accepts `list`, `focus` and `open`. Focus was verified there. | Reuse that owned interface after integration. Its tested gamescope-owned overlay transform setters return `PermissionDenied`; no reliable saved spatial-layout interface was established. Do not duplicate its implementation here. |
| Shared keyboard/trackpad | **Documented in [#19](https://github.com/saphid/frame-control/pull/19):** `/api/input` supplies state/start and event submission, implemented with a bundled KDE Connect daemon. | This branch does not import, launch or depend on that daemon. The user's own-implementation rule remains authoritative. A first-party input implementation or permitted bundled-library route needs its own delivery evidence before MCP integration. |
| Physical/device boundaries | **Documented:** an asleep Frame may be off the network; power authorization can require the user's password; physical pairing and headset fit/comfort require the user. | MCP cannot bypass offline hardware, consent, compositor permissions or physical verification. Keep explicit human handoffs. |
## Reusing the existing computer-use work
**Documented:** the installed `cua-driver` skill has the right control pattern:
observe an exact window, use a semantic target if available, fall back to pixels
from that same snapshot, then read back the result. Its browser route requires
an exact process/window/target binding and session-scoped element references.
Those are useful design rules for Frame tools.
**Verified locally 2026-09-29:** `cua-driver describe get_window_state` describes
host-local process/window IDs and macOS AX inspection. It does not establish an
SSH Frame target. The installed skill's advertised Linux companion file is
missing. A native ARM64 Frame backend, its dependencies and remote transport
have not been verified. We therefore do not claim that the existing Mac driver
can control the Frame by passing it a Frame PID or screenshot, and we do not
make the feature depend on installing that application.
Frame Control's `computer_state` is our own Python implementation over installed
platform libraries. It sends the probe over SSH stdin, writes no helper to disk,
and exits after one observation. Missing displays/libraries return explicit
errors; a 15-second process deadline prevents a stalled accessibility call from
leaving a probe behind. Window names and accessibility text are untrusted app
content, never instructions to an agent.
**Recommended next implementation:** an isolated Chromium session with typed
snapshot/click/type/scroll tools and exact fresh target binding, then individually
verified native app actions. Use the headset capture to judge appearance, not to
invent a screen-to-window coordinate transform. Direct tool calls must retain
approval rules; a generic computer-use tool must not become a route around the
MCP approval panel, install confirmation or power confirmation.
## Isolated browser input proof
**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** a temporary
Frame Chromium profile loaded a local test page through an SSH reverse tunnel.
CDP `Input.insertText` entered the test string in its own input. A CDP
`Input.dispatchMouseEvent` press/release on its own button copied that string
to the page's result; DOM readback matched exactly. The browser profile,
loopback forwards and panel log were removed afterward. No user app was typed
into, no global settings were changed and no third-party helper app was used.
AT-SPI did **not** expose the test page's controls in that same probe, even with
Chromium's renderer-accessibility flag. It returned the partial Steam-client
tree instead. The reason remains **unverified**; this is an evidence gap, not
proof that Frame accessibility cannot work. For a first implementation,
Chromium's proven page-specific CDP route is stronger than assuming complete
AT-SPI coverage. This proof does not ship unrestricted click/type tools or
establish input delivery to SteamVR's menus.
+18 -5
View File
@@ -56,9 +56,11 @@ counts them while they run.
Steam library), then launch, stop, test or remove it. **Report an APK** records Steam library), then launch, stop, test or remove it. **Report an APK** records
whether any APK worked (F-Droid or not: pick a file, type a package, or use an whether any APK worked (F-Droid or not: pick a file, type a package, or use an
installed app). Your reports are saved on your computer and change the verdicts installed app). Your reports are saved on your computer and change the verdicts
you see. They aren't uploaded anywhere: the shared database is maintainer-only you see. With **Share compatibility results** on (Privacy & updates), they also
for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled go to the shared database ([privacy.md](privacy.md),
`adb`, or yours if you have one. [compat-db/README.md](../compat-db/README.md)). A failed install records
itself when the APK was the problem, and after an install the app offers a
20-second test. Uses the app's bundled `adb`, or yours if you have one.
- **Android display**: pick a running Lepton instance (by the app in it) and set - **Android display**: pick a running Lepton instance (by the app in it) and set
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
@@ -148,5 +150,16 @@ npm run dist:win # Windows: installer and .zip
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64 npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
``` ```
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the Pushing a `v*` tag builds all three in GitHub Actions and attaches them to a
release (`.github/workflows/release.yml`). draft release (`.github/workflows/release.yml`). Running copies are offered it
once you publish it: see [releasing.md](releasing.md).
## AI agents and assistant
**Documented:** [the MCP adapter and assistant panel](agents.md) are Frame
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. Tools → Open assistant opens the page; the linked guide
covers putting it in a Chromium panel on the Frame.
+2
View File
@@ -33,6 +33,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) | | SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card | | Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn | | `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
| **Testing VR apps without wearing the headset.** In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. `vrcmd` (in `/opt/steamvr/bin/linuxarm64`) settings use `section.key`: `vrcmd --set-settings-bool power.pauseCompositorOnStandby 0` and `vrcmd --set-settings-float power.turnOffScreensTimeout 3600`, then `vrcmd --handlewakeup`, keep the compositor running, and the scene app becomes visible. If it stays `visible-blurred`, the Steam dashboard is open: `SteamClient.OpenVR.VROverlay.HideDashboard()` in Steam's `SharedJSContext` (CDP on 8080) closes it. The headset view then captures with `ui/frame_vrshot.py`. Restore afterwards with `--set-settings-bool power.pauseCompositorOnStandby 1` and `--set-settings-float power.turnOffScreensTimeout 5`. The bool setter reads `true` as false, so use 1/0. A Steam launch that stalls in standby at `ShowInterstitials` or `CreatingProcess` (see `console_log.txt`) continues with `SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>")`. **Verified 2026-09-27.** | Proving VR output remotely, [webxr-chromium.md](webxr-chromium.md) |
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) | | The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks | | SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging | | The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid<appid>`. It's the quickest way to see panels come and go. | Debugging |
@@ -54,6 +55,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| **Tools on the image:** Python 3.12.3, `ffmpeg`, `openssl`, `curl`, `rsync`, `zip`/`unzip`, `flatpak`, `wpctl`, `podman`. **No `adb`.** `steamos` is uid 1000, in `wheel`, and sudoers has `%wheel ALL=(ALL) ALL`, so `sudo -S` takes the Developer Mode password on stdin. **Verified 2026-09-27.** | Running Frame Control's server on the Frame (`FRAME_LOCAL=1`, [iphone.md](iphone.md)) | | **Tools on the image:** Python 3.12.3, `ffmpeg`, `openssl`, `curl`, `rsync`, `zip`/`unzip`, `flatpak`, `wpctl`, `podman`. **No `adb`.** `steamos` is uid 1000, in `wheel`, and sudoers has `%wheel ALL=(ALL) ALL`, so `sudo -S` takes the Developer Mode password on stdin. **Verified 2026-09-27.** | Running Frame Control's server on the Frame (`FRAME_LOCAL=1`, [iphone.md](iphone.md)) |
| **Each Lepton instance is a podman container** named `lepton-steamlaunch-<instance id>`, labelled with its ADB port (`podman ps --format '{{.Names}} {{.Labels.adb_port}}'`). `podman exec <container> /system/bin/sh -c '…'` runs Android's shell inside it with no adb at all (used for `pidof` and `logcat` by the app tester). Running `wm size`/`wm density` that way is untested. **Verified 2026-09-27.** | `ui/frame_android.py`, the iPhone app's display settings | | **Each Lepton instance is a podman container** named `lepton-steamlaunch-<instance id>`, labelled with its ADB port (`podman ps --format '{{.Names}} {{.Labels.adb_port}}'`). `podman exec <container> /system/bin/sh -c '…'` runs Android's shell inside it with no adb at all (used for `pidof` and `logcat` by the app tester). Running `wm size`/`wm density` that way is untested. **Verified 2026-09-27.** | `ui/frame_android.py`, the iPhone app's display settings |
| **Asleep means off the network.** In standby the Frame stops answering on its LAN address, `frame.local` and Tailscale alike (`Host is down`, `No route to host`, timeouts), and ping fails. It was unreachable for about 2.5 hours until woken. Nothing over SSH can wake it. **Verified 2026-09-27.** | Frame Control's offline banner and retries | | **Asleep means off the network.** In standby the Frame stops answering on its LAN address, `frame.local` and Tailscale alike (`Host is down`, `No route to host`, timeouts), and ping fails. It was unreachable for about 2.5 hours until woken. Nothing over SSH can wake it. **Verified 2026-09-27.** | Frame Control's offline banner and retries |
| **What puts it to sleep is Steam's idle timer**, not logind. The journal shows `steamui_system: Switching to power state: [ k_ESystemPowerState_Sleep ] reason: 'ComputeNextPowerState: active: 3600 < 3600 (k_EACState_Connected)'`, then Steam suspends. SSH work doesn't count as activity. The timers are the client settings `system_idle_suspend_ac_sec` (3600) and `system_idle_suspend_battery_sec` (900); 0 means Never (Settings → Power → Sleep after inactivity). They can be written over DevTools the way the settings page does. logind refuses a `systemd-inhibit --mode=block` sleep lock from an SSH session (`Interactive authentication required`) but accepts one started with `systemd-run --user`. `scripts/keep-awake.sh on|off|status` does both and restores the old timers on `off`. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. Whether Steam's suspend honours the inhibitor on its own is **inferred** (polkit gives `steamos` no `suspend-ignore-inhibit`), not tested. | Keeping the Frame awake for agent work |
| **Battery at full on a charger** can read `Discharging` at about 0 W (for example 99 %, 0.0 W, USB-C PD 18 W). Treat under 0.5 W on a charger as "not charging", not "draining". **Verified 2026-09-27.** | Frame Control's battery card | | **Battery at full on a charger** can read `Discharging` at about 0 W (for example 99 %, 0.0 W, USB-C PD 18 W). Treat under 0.5 W on a charger as "not charging", not "draining". **Verified 2026-09-27.** | Frame Control's battery card |
| **The OS image is downloadable.** Valve's recovery images for the Frame are at `https://steamdeck-images.steamos.cloud/recovery/`. The root filesystem inside is btrfs, and it runs as an SSH test target on ARM64 Linux without the headset (`tests/frame-container/frame-image.sh`). **Verified 2026-09-27.** | [recovery-and-images.md](recovery-and-images.md) | | **The OS image is downloadable.** Valve's recovery images for the Frame are at `https://steamdeck-images.steamos.cloud/recovery/`. The root filesystem inside is btrfs, and it runs as an SSH test target on ARM64 Linux without the headset (`tests/frame-container/frame-image.sh`). **Verified 2026-09-27.** | [recovery-and-images.md](recovery-and-images.md) |
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-oobe-repair-<build>.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-oobe-repair-qdl-<build>.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, 3.8 GiB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. File names, checksums and what's inside: [recovery-and-images.md](recovery-and-images.md). Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop | | **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-oobe-repair-<build>.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-oobe-repair-qdl-<build>.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, 3.8 GiB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. File names, checksums and what's inside: [recovery-and-images.md](recovery-and-images.md). Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop |
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+14
View File
@@ -87,6 +87,20 @@ check (a wrong or missing password refused; the right one reaches `systemctl`),
a changed host key refused with "Pair with the Frame again", and a wrong a changed host key refused with "Pair with the Frame again", and a wrong
pairing password reported the same way. pairing password reported the same way.
Also verified in the Simulator against the Frame (2026-09-27): the setup screen
found the Frame by itself over Bonjour (`frame · 192.168.1.237`); a paired app
waiting for a sleeping Frame connected 4 s after it answered; an upload from the
app's web view landed in `~/Downloads`; the share sheet offers Save Image
(needs `NSPhotoLibraryAddUsageDescription`, now declared); an install link opens
the confirm dialog and downloads nothing until Install; Steam Link without the
app installed opens its App Store page.
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
Sending text to the Frame's clipboard needs the desktop panel open in the
headset, as on the desktop app.
Not yet exercised: Android display changes through podman (no Android app was 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. running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
+15 -13
View File
@@ -33,14 +33,19 @@ build 20260922.6101926, kernel 6.18, aarch64):
- **10.** `install-apps.sh remmina --vnc-host <mac>.local` installed Remmina as - **10.** `install-apps.sh remmina --vnc-host <mac>.local` installed Remmina as
a `--user` Flatpak over SSH and wrote the profile. The desktop's a `--user` Flatpak over SSH and wrote the profile. The desktop's
`XDG_DATA_DIRS` includes the user Flatpak exports, so it shows up in the menu. `XDG_DATA_DIRS` includes the user Flatpak exports, so it shows up in the menu.
The Frame can reach the Mac's Screen Sharing port (5900). The Remmina The Frame can reach the Mac's Screen Sharing port (5900).
connection itself hasn't been tried in the headset yet (part of 11). - **11.** Answered 2026-09-27 (BUILD_ID 20260925.6191901, macOS 27.0): the
pre-seeded profile connects and shows the Mac in its own panel. It asks for
the Mac account login rather than the VNC password, needs scale-to-fit at
Retina resolutions, and doesn't show the Mac cursor without
`scripts/mac-cursor-ring.lua`. It's usable but noticeably laggy. See
[streaming.md](streaming.md).
- **Panels.** An X11 window on gamescope's `:0` with its own `STEAM_GAME` id - **Panels.** An X11 window on gamescope's `:0` with its own `STEAM_GAME` id
gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were gets its own SteamVR overlay (`valve.steam.desktopgame.<id>`). Three were
created side by side with `panel-on-frame.sh`. See [panels.md](panels.md). created side by side with `panel-on-frame.sh`. See [panels.md](panels.md).
Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a reboot), 17–21. Still open: 4, 6, 7, 12–15, 16 (off-LAN and after a reboot), 17–21.
## Check on the headset (in order) ## Check on the headset (in order)
@@ -70,12 +75,10 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
`ssh frame 'command -v wl-copy xclip rsync flatpak'`. `ssh frame 'command -v wl-copy xclip rsync flatpak'`.
10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the 10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the
headset's desktop?** Test with `./scripts/install-apps.sh remmina`. headset's desktop?** Test with `./scripts/install-apps.sh remmina`.
11. **Remmina → macOS Screen Sharing:** does it connect, and is it usable at 11. ~~**Remmina → macOS Screen Sharing**~~: answered 2026-09-27; see above
Retina resolutions? Is the pre-seeded profile path and [streaming.md](streaming.md).
(`~/.var/app/org.remmina.Remmina/data/remmina/`) the one Remmina 12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** VNC works but is
actually reads? noticeably laggy, so this is worth trying.
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** worth trying only if
VNC is too slow.
13. **KDE Connect**: is it preinstalled or installable on the Frame, and does 13. **KDE Connect**: is it preinstalled or installable on the Frame, and does
it pair with KDE Connect for macOS? it pair with KDE Connect for macOS?
14. **Bluetooth keyboard pairing** on the Frame, for the rare times you do need 14. **Bluetooth keyboard pairing** on the Frame, for the rare times you do need
@@ -86,8 +89,9 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md). runs as a lingering user service with no sudo; see [tailscale.md](tailscale.md).
Still open: reaching the Frame from outside the home network, and the service Still open: reaching the Frame from outside the home network, and the service
starting after a reboot. starting after a reboot.
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the 17. **Floating panels in the headset** (see [panels.md](panels.md)): panels
panels from `panel-on-frame.sh` show up, take input, and offer **Float in from `panel-on-frame.sh` show up and take controller input (verified
2026-09-27 with `mac-screen`). Still open: do they offer **Float in
World** / **Move** / **Size**? Do floating positions survive closing and World** / **Move** / **Size**? Do floating positions survive closing and
reopening the app, or a reboot? reopening the app, or a reboot?
18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch 18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch
@@ -129,8 +133,6 @@ Still open: 4, 6, 7, 11 (in-headset connect), 12–15, 16 (off-LAN and after a r
- `/home` and `/etc` persist across Frame OS updates. This is inferred from - `/home` and `/etc` persist across Frame OS updates. This is inferred from
Steam Deck behaviour. Steam Deck behaviour.
- The whole Mac → Frame desktop path (VNC → Remmina). Each part is documented
separately, but the combination is untested.
- Steam Remote Play with a Mac as host is broken. That's based on community - Steam Remote Play with a Mac as host is broken. That's based on community
reports, not tested with the Frame. reports, not tested with the Frame.
- `connect.sh --harden`, `serve-bootstrap.sh` and - `connect.sh --harden`, `serve-bootstrap.sh` and
+143
View File
@@ -0,0 +1,143 @@
# Privacy and analytics
Frame Control sends anonymous analytics to [PostHog](https://posthog.com)
(US cloud) so the maintainer can see how many people use it, which features
matter and where installs fail. You choose how much in **Privacy & updates**,
the last panel on the page. `ui/frame_telemetry.py` is the whole
implementation.
## The three levels
| Level | Default | What it sends |
|---|---|---|
| Anonymous usage statistics | On, after a notice on first run | The events in the table below |
| Share compatibility results | Off | Your Android compatibility reports and tests |
| Send error details | Off | Scrubbed error messages and tracebacks |
Nothing is sent until the first-run notice has been shown. The notice's
**Share more to help fix problems** button turns on the second and third
levels together. Either can be turned off later. Turning a level off
deletes that level's events that haven't been sent yet.
**Show what's been sent** in the panel lists the last 50 events that left your
computer, exactly as they were sent.
## Anonymous
- Events carry a random id, made when Frame Control first runs and kept in
its data folder (`telemetry/settings.json`). It isn't derived from your
computer, account or network. To get a new one, delete that file.
- Events are sent without person profiles (`$process_person_profile: false`)
and without location lookup (`$geoip_disable: true`). Each carries a
placeholder address (`$ip: 0.0.0.0`), so PostHog stores that instead of
yours.
- Every event includes the app version, OS name (macOS, Windows or Linux),
CPU architecture and Python version.
## Usage events
| Event | When | Properties besides the common ones |
|---|---|---|
| `app_installed` | First run | |
| `app_updated` | First run of a new version | `from_version` |
| `app_opened` | At most once a day | |
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
| `tab_viewed` | The first click on each tab in a session | `tab` |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
`install_finished` never includes a file name, path or error message. An
error becomes one category from a fixed list (for example `apk_wrong_abi` or
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
is one. It names what was installed only when that's already public:
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
catalogue app's package name
- Flathub apps: `flatpak_id`
- Steam games: `steam_appid`
- A sideloaded title: only its runtime (Proton or Linux)
Any other APK is sent as `catalog: false`, with no name.
## Compatibility results (opt-in)
Each report becomes a `compat_report` event with the fields the Report dialog
shows: package, version, result or rating, your notes, how it was run, and the
SteamOS and Lepton builds. Before sending:
- the notes, app name and version are scrubbed like error messages (see
below)
- the APK's source is kept only if it's `F-Droid` or the public host name of
a download link (`https://example.com/…`). File names, user names,
passwords, ports, paths, IP addresses and local host names are dropped
When you turn this on, reports you made earlier on this computer are shared
too.
The maintainer's `python3 ui/frame_compat_db.py sync` copies these events
into the compatibility database, marked `via=community…`. It takes at most
30 per reporter per day.
## Error details (opt-in)
`$exception` events carry an error message, the Frame Control file, line and
function it came from, and the request that failed (for example
`POST /api/android install`). Before anything is sent, the message is
scrubbed:
- your home folder becomes `~`, and any user name becomes `<user>`
- IP and MAC addresses, email addresses, `.local`, `.lan` and Tailscale host
names, Steam ids, SSH and PEM keys, API tokens and long hex strings are
replaced
- URLs are cut down to their scheme and a public host name, or `<url>`. User
names, passwords, ports, paths and queries are dropped
- `token=`, `key=`, `password=` and similar values are replaced
The same error is sent at most once every 10 minutes.
## Report a problem
**Report a problem** is the warning-sign button in the header, also in the
Privacy panel and under **Help → Report a Problem…**. It sends the report
privately to Frame Control's PostHog project as a `problem_report` event, the
same way as the analytics above, so only the maintainer can read it and
nothing is published. It works whatever the analytics settings are, because
the person sends it deliberately. The report has the kind, title and text you
wrote, how to reach you if you gave it, a short reference shown after sending,
and the diagnostics below. It has its own random id, so it isn't linked to
your analytics events.
With **Include diagnostics** ticked (the default), the report adds:
- the app version and whether it's a built app
- the OS, its release and CPU, and the Python version
- the Frame's SteamOS build, if it has connected since the app started
- which analytics levels are on
**Also include recent activity and the server log** is off by default,
because those lines can name files and apps. When ticked, it adds the newest
Activity lines and server log lines, without the request lines.
Everything is scrubbed like error details and limited to what fits in the
report. Environment details are kept first, then the newest lines. **Show
exactly what's included** shows the snapshot that will be sent, and later
activity isn't added to it. If PostHog can't be reached, **Copy report** puts
the whole report on the clipboard.
The maintainer reads reports on the Frame Control dashboard in PostHog, or
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
API key as `frame_compat_db.py sync`.
## Turning it all off
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
the environment that starts Frame Control. A copy run from a source checkout
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
## Update checks
The desktop app asks GitHub for the latest release shortly after starting,
then every 6 hours: the latest release's `update.json` on GitHub, or
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
Those requests carry no id. To stop it, set
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
+59
View File
@@ -0,0 +1,59 @@
# Releasing and updates
Frame Control checks for updates itself. The desktop app offers a new version
only once it's GitHub's **latest release**, and drafts and pre-releases never
count. So a build reaches people only when you publish it, after testing it.
## Steps
1. Bump `version` in `app/package.json`, commit, and push a tag:
```sh
git tag v0.4.0 && git push origin v0.4.0
```
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
attaches everything to a **draft** release for that tag. Nobody is
offered a draft.
2. Download the draft's installers and test them. An installed copy of the
previous version won't offer the draft, so install it directly.
3. Write the release notes on the draft. The update banner links to them.
4. Publish:
```sh
scripts/publish-release.sh v0.4.0
```
The script checks that all eight installers are attached, each with the
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
notes and each installer's digest), then publishes the release and marks it
latest. From then on, running copies see the update. They check about 8
seconds after starting, then every 6 hours, and anyone can use **Check for
Updates…** (the app menu on macOS, the Help menu elsewhere).
To pull a bad release, mark the previous one as latest
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
Copies that already updated stay on it. Nothing downgrades them.
## How a copy updates itself
`app/updater.js` reads `update.json` from
`github.com/saphid/frame-control/releases/latest/download/`. It falls back to the
REST API only when a release has no manifest, because the API allows just 60
unauthenticated requests an hour per IP address, shared by a whole household.
Then it downloads the installer for its platform and checks it
against the SHA-256 digest GitHub publishes for the asset. It refuses if the
digest is missing or doesn't match. Then:
| Installed from | Update |
|---|---|
| macOS `.dmg`, app in a writable folder such as Applications | The `.zip` is unpacked next to the app and its version checked. After the app quits, a small script swaps the new app in, putting the old one back if that fails, and reopens it. Updates don't get the download quarantine, so there's no `xattr` step. |
| Windows installer | The new `Setup` runs silently over the install (`/S --force-run`) and reopens the app. |
| Linux AppImage | The new AppImage replaces the old file and is started. |
| macOS app still on the disk image or translocated, Windows `.zip`, Linux `.deb` | The banner opens the release page instead. |
Version 0.3.1 and earlier have no updater, so people on them have to download
the new version once by hand.
+2 -1
View File
@@ -93,7 +93,8 @@ controls to place each panel. See [docs/panels.md](panels.md).
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) | | `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) | | `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
| `scripts/install-apk.sh` | Mac → Frame | Install APKs, each as its own persistent Lepton instance with a Steam library shortcut (`--dev`: old ADB path into Lepton Development) (**verified**; see [docs/apks.md](apks.md)) | | `scripts/install-apk.sh` | Mac → Frame | Install APKs, each as its own persistent Lepton instance with a Steam library shortcut (`--dev`: old ADB path into Lepton Development) (**verified**; see [docs/apks.md](apks.md)) |
| `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**: overlays created; in-headset placement not yet checked) | | `scripts/panel-on-frame.sh` | Mac → Frame | Start an app as its own floating VR panel, outside the desktop (**verified**, including `mac-screen` in the headset) |
| `scripts/mac-cursor-ring.lua` | Mac | Hammerspoon script: a ring around the Mac pointer so it shows in the VNC mirror (**verified**) |
| `scripts/run-on-frame.sh` | Mac → Frame | Start an app on the headset desktop, e.g. `mac-screen` opens Remmina straight into the Mac (**verified**) | | `scripts/run-on-frame.sh` | Mac → Frame | Start an app on the headset desktop, e.g. `mac-screen` opens Remmina straight into the Mac (**verified**) |
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) | | `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) | | `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
+67 -15
View File
@@ -1,9 +1,11 @@
# Screen and desktop streaming # Screen and desktop streaming
This covers two directions: This covers three directions, plus input:
- **A. Frame → Mac**: see and control the headset from the Mac. - **A. Frame → Mac**: see and control the headset from the Mac.
- **B. Mac → Frame**: use the Mac's desktop inside the headset. - **B. Mac → Frame**: use the Mac's desktop inside the headset.
- **C. iPhone → Frame**: mirror the phone inside the headset.
- **Input**: type and point in the Frame from the Mac or iPhone.
The confidence labels are the same as in [ssh.md](ssh.md). The confidence labels are the same as in [ssh.md](ssh.md).
@@ -32,7 +34,7 @@ flat 2D desktop streaming into a window on the Frame's Linux desktop.
| Option | Setup | Confidence | Verdict | | Option | Setup | Confidence | Verdict |
|---|---|---|---| |---|---|---|---|
| **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://<mac>.local` | **Inferred.** Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Latency is fine for productivity but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). | | **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://<mac>.local` | **Verified 2026-09-27** (Frame BUILD_ID 20260925.6191901, macOS 27.0), in its own panel via `panel-on-frame.sh mac-screen`. Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Noticeable lag, even at lower Remmina quality settings on a good 5 GHz link, where neither Wi-Fi nor the Frame's CPU was the bottleneck. Usable for reading and coding, but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). |
| Sunshine (Mac) → Moonlight (Frame Flatpak) | `brew install` Sunshine on the Mac, then `./scripts/install-apps.sh moonlight` | Moonlight Flatpak supports **aarch64** ([Flathub](https://flathub.org/apps/com.moonlight_stream.Moonlight)). **Sunshine on macOS is poorly supported**: install problems on Apple Silicon/Sequoia, and no virtual gamepads ([LizardByte discussion #777](https://github.com/orgs/LizardByte/discussions/777)). | Try it if VNC is too laggy. Expect some friction. | | Sunshine (Mac) → Moonlight (Frame Flatpak) | `brew install` Sunshine on the Mac, then `./scripts/install-apps.sh moonlight` | Moonlight Flatpak supports **aarch64** ([Flathub](https://flathub.org/apps/com.moonlight_stream.Moonlight)). **Sunshine on macOS is poorly supported**: install problems on Apple Silicon/Sequoia, and no virtual gamepads ([LizardByte discussion #777](https://github.com/orgs/LizardByte/discussions/777)). | Try it if VNC is too laggy. Expect some friction. |
| Steam Remote Play with the Mac as host | Steam on the Mac, Steam Link/Remote Play on the Frame | macOS-hosted Remote Play is reported broken or flaky in 2024–2026 ([Steam discussion](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)) | Not recommended. It's only for games, if it works at all. | | Steam Remote Play with the Mac as host | Steam on the Mac, Steam Link/Remote Play on the Frame | macOS-hosted Remote Play is reported broken or flaky in 2024–2026 ([Steam discussion](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)) | Not recommended. It's only for games, if it works at all. |
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. | | Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
@@ -45,20 +47,70 @@ them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
`scripts/install-apps.sh remmina --vnc-host <your-mac>.local` writes `scripts/install-apps.sh remmina --vnc-host <your-mac>.local` writes
`~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina` on the Frame over `~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina` on the Frame over
SSH. The profile then appears in Remmina's list, and you just click it. You'll SSH. The profile then appears in Remmina's list, and you just click it. It
still be asked for the VNC password in the headset the first time, unless you scales the Mac's desktop to fit the window (`scale=1`, `viewmode=1`). Without
choose to save it. Remmina stores passwords encrypted with a per-install key, that, Remmina shows a Retina Mac's native pixels 1:1, so you see a zoomed-in
so the script doesn't try to write the password. (The Remmina file format is corner. (Verified 2026-09-27.)
standard; the Flatpak data path is inferred.)
## Input and text entry without the virtual keyboard **Expect a Mac login prompt, not the VNC password.** macOS offers Apple's own
authentication (RFB security type 30) ahead of plain VNC auth (type 2), and
Remmina picks it. So Remmina asks for your **Mac account name and login
password**; the "VNC viewers may control screen" password isn't used. To store
the password without typing it in the headset, run on the Frame:
- **A Bluetooth keyboard and mouse** paired to the Frame is the obvious way to ```sh
avoid the virtual keyboard. Road to VR says there are "only a few things printf '%s' "$PASSWORD" | flatpak run org.remmina.Remmina \
you'd actually want to do" on the Linux desktop unless you connect a --update-profile ~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina \
keyboard and mouse. --set-option password
(Pairing a BT keyboard on the Frame is inferred from SteamOS; not verified.) ```
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh` (see
[file-transfer.md](file-transfer.md#clipboard)). Remmina encrypts it into the profile with its own key, because there's no
secret service in the SSH session. (Verified 2026-09-27.)
### The Mac's cursor
The mirror doesn't show the Mac's pointer, with either `showcursor` value.
macOS keeps the pointer out of the picture it sends, and Remmina's cursor mode
draws the cursor shape only at the Frame's own pointer, which doesn't follow
the Mac trackpad. `scripts/mac-cursor-ring.lua` works around this: a
[Hammerspoon](https://www.hammerspoon.org/) script that draws a ring around the
Mac pointer as a real window, so it's part of the mirrored picture. Setup is in
its header. (Verified 2026-09-27.)
Going the other way, pointing a controller at the panel moves the Mac's mouse,
because Remmina forwards input (`viewonly=0`).
## C. Show the iPhone's screen inside the Frame
iOS only shares its screen two ways: **AirPlay** (Screen Mirroring in Control
Centre) or a **ReplayKit broadcast extension** in an app. Nothing else can
capture it.
| Option | What it takes | Confidence | Verdict |
|---|---|---|---|
| **UxPlay** (an open-source AirPlay receiver) on the Frame | Build it for aarch64 (no Flathub package; there's a Snap and distro packages), run it in `~` or a podman container, and advertise it over mDNS. The iPhone *and* the Mac then see "Frame" in Screen Mirroring, with nothing to install on either | **Inferred.** It runs on ARM64 Linux such as the Raspberry Pi ([UxPlay](https://github.com/FDH2/UxPlay)). Not tried on the Frame: needs mDNS registration and its ports (7000, 7001, 7100 and a UDP range) reachable | **Recommended to try first.** It's the only receiver-side option, and it covers the Mac too. The window shows in the Frame's Linux desktop panel |
| A broadcast extension in Frame Control | ReplayKit sends the screen to a small extension (50 MB memory limit), which encodes H.264 and sends it through the app's SSH tunnel to the page, shown the same way as the Frame's live view in reverse | **Inferred** from Apple's ReplayKit docs | Full control and no network setup, but several days' work, and the picture only shows where Frame Control's page is open in the headset |
## Input: type and point in the Frame from the Mac or iPhone
**Verified 2026-09-27** on the headset: `steamos` is in the `input` group and
`/dev/uinput` is `crw-rw-r-- root input`, so **our own code can create a
virtual keyboard and mouse without sudo**. The Frame has no `python-evdev`,
`ydotool`, `wtype` or KDE Connect; `kwin_wayland` and `plasmashell` run only
while the desktop panel is open in the headset.
| Option | Mac | iPhone | Notes |
|---|---|---|---|
| **A uinput keyboard and mouse in Frame Control's server** | ✓ | ✓ | **Recommended.** The server opens `/dev/uinput` with `ctypes` (standard library only) and the page sends key and pointer events through the tunnel it already has. On the phone: a trackpad area (drag to move, tap to click, two fingers to scroll) and the iOS keyboard for typing. On the Mac: a "control the Frame" mode that captures the keyboard and pointer (Esc to release). Uinput devices look like real hardware to the kernel, so libinput, KWin and gamescope should take them; [frame-voice](https://github.com/DeeJanuz/frame-voice) already types into a Frame through a uinput keyboard. **Untested**: which surfaces in VR (desktop panel, SteamVR dashboard, games, Android apps in Lepton) accept the pointer. About a day or two of work |
| **Bluetooth keyboard and mouse** | – | – | Real hardware paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
| **Deskflow** (formerly Input Leap / Barrier) | ✓ | – | Moves the Mac's own mouse and keyboard onto the Frame's screen edge. Flathub has an aarch64 build ([Flathub](https://flathub.org/apps/org.deskflow.deskflow)); on Wayland it needs the InputCapture/libei portal, and only works while Plasma is running. No iPhone client |
| **KDE Connect** | ~ | ✓ | Its iOS app has a remote touchpad and keyboard, but the Frame would need KDE Connect installed (not on Flathub; `pacman` on a read-only root). More moving parts than the uinput route |
| **Remmina / Steam Link / RDP** | ✓ | – | Input only reaches the streamed session, not the headset's own apps |
Other ways to get text in:
- **Clipboard from the Mac**: `scripts/paste-to-frame.sh`, or Frame Control's
clipboard box (see [file-transfer.md](file-transfer.md#clipboard)). Needs
the desktop panel open.
- **RDP session**: Windows App syncs the clipboard with xrdp, but only inside - **RDP session**: Windows App syncs the clipboard with xrdp, but only inside
that RDP session. that RDP session.
+8
View File
@@ -148,3 +148,11 @@ For example, on 2026-09-27 the smoke test found that Steam's `create-shortcut`
refuses ids with a hyphen (`missing/invalid arguments`), which the fake had refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
accepted. The fake now refuses them the same way, and Frame Control makes ids accepted. The fake now refuses them the same way, and Frame Control makes ids
Steam accepts. Steam accepts.
## Agent interfaces
`tests/test_agent.py` exercises MCP stdio, exact-action human approvals and the
assistant against an in-process HTTP endpoint with canned responses (no keys or
external calls). `tests/e2e/test_agents.py` runs the MCP/HTTP/SSH path against the
fake Frame for approved installs, clipboard and file transfer. Headset Chromium
rendering and real screenshots still need a device; see [agent evidence](agents.md#evidence-and-limits).
+64 -69
View File
@@ -3,9 +3,11 @@
Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples), Goal: open a web VR180 or 360 player (DeoVR and DL8 embeds, WebXR samples),
press its VR button, and watch in 3D in the headset. press its VR button, and watch in 3D in the headset.
A standalone, public version of this build (build script, the SO_PEERCRED The build and installer now live in their own public repo,
patch, and a Frame-side installer that adds "Chromium XR" to the Steam library) [saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame):
is at [saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame). a build script for an x86-64 Linux host, the SO_PEERCRED patch, and a
Frame-side installer that adds "Chromium XR" to the Steam library. This page
keeps the findings and what was verified on this Frame.
## Why Flathub Chromium can't ## Why Flathub Chromium can't
@@ -45,76 +47,52 @@ gets both.
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`). Linux backend uses Vulkan (`XR_USE_GRAPHICS_API_VULKAN`).
## Building it ## Building and installing it
[`scripts/build-chromium-xr.sh`](../scripts/build-chromium-xr.sh) Follow the [public repo's README](https://github.com/saphid/chromium-webxr-steam-frame#build).
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need In short: `build/build.sh` on an x64 Linux host (no sudo, about 90 GB of
sudo: the arm64 sysroot comes from Chromium's own script. It needs about disk) produces `chromium-xr-arm64.tar.xz` (about 145 MB), and
90 GB of disk. It shallow-fetches the CL ref (patchset 44), runs `frame/install.sh` on the Frame unpacks it to `~/chromium-xr`, installs the
`gclient sync --no-history`, installs the sysroot, applies one extra seccomp `chromium-xr` launcher in `~/.local/bin`, and adds the Steam library shortcut
fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and through the Steam client's DevTools port, the same way as T3 Code
packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included). ([apks.md](apks.md)). Launching the shortcut gives Chromium its own panel,
Progress is logged to `~/chromium-xr/stage`. The build aborts if the disk `valve.steam.desktopgame.<appid>`, like any other app.
holding `~/chromium-xr` drops below 12 GB free.
First run, 2026-09-25, on a 12-core, 31 GB x64 Linux box: 9 h 33 min for First build, 2026-09-25, on a 12-thread, 31 GB x64 Linux box: 9 h 33 min for
94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change 94,835 steps, giving Chromium 156.0.8071.0. A rebuild after a one-file change
takes under a minute, plus about 4 minutes to repack. takes under a minute, plus about 4 minutes to repack.
**The extra fix.** The CL's XR seccomp policy refuses `getsockopt`. SteamVR's To debug from the Mac, launch it as a panel with DevTools on the Frame
(verified 2026-09-27):
`scripts/panel-on-frame.sh -- '~/.local/bin/chromium-xr' --remote-debugging-port=9223 URL`
([panels.md](panels.md)). DevTools has no authentication. It listens on
loopback, but with the userspace Tailscale from [tailscale.md](tailscale.md)
running, loopback ports are reachable from your tailnet. Close the browser
when you're done. Chromium runs one browser per profile, so close the
Steam-launched one first or the flag is ignored.
**The SO_PEERCRED fix.** The XR seccomp policy refuses `getsockopt`. SteamVR's
client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`, client calls `getsockopt(SOL_SOCKET, SO_PEERCRED)` inside `xrCreateInstance`,
so the XR process died with a seccomp crash (arm64 syscall 209). The script so the XR process died with a seccomp crash (arm64 syscall 209). The patch
allows that one option. That's needed but not enough: `launch` still turns allows that one option. It's needed but not enough: the launcher still turns
seccomp off (below), so the patch only matters once that's fixed too. seccomp off (below), so the patch only matters once that's fixed too.
## Running it on the Frame **Seccomp is off.** The launcher passes `--disable-seccomp-filter-sandbox`.
With the XR seccomp policy on, SteamVR's client reads `/proc/self/status`
through Chrome's file broker and gets the broker's pid. SteamVR then binds
the app to the wrong process ("Unable to init path manager:
VRInitError_Init_Internal") and `xrCreateInstance` fails. The broker can't
answer `/proc/self` for another process, so fixing this needs a change in
Chromium's broker client or in the CL. The namespace sandbox stays on, but
seccomp is off for every process, so use this profile for VR sites rather
than everyday browsing.
[`scripts/chromium-xr.sh`](../scripts/chromium-xr.sh): **Upstream (2026-09-27).** CL 8441736 (the XR sandbox) has merged into
Chromium, still refusing `getsockopt`; CL 8132979 is still in review. Valve
```sh and the CLs' author are working on Steam Frame support
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr ([utzcoz/chromium-webxr-linux#5](https://github.com/utzcoz/chromium-webxr-linux/issues/5)).
scripts/chromium-xr.sh launch [URL] # its own VR panel, --enable-features=OpenXR Both sandbox problems above, with the patch, are reported in
scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library [utzcoz/chromium-webxr-linux#7](https://github.com/utzcoz/chromium-webxr-linux/issues/7).
scripts/chromium-xr.sh check # prints isSessionSupported('immersive-vr')
```
It runs natively, not as a Flatpak. `launch` opens it as its own panel on
gamescope's X display, the same way as [`panel-on-frame.sh`](../scripts/panel-on-frame.sh) ([panels.md](panels.md)), so
the Plasma desktop doesn't need to be open. It uses its own profile
(`~/.config/chromium-xr`) and DevTools on loopback port 9223, so it doesn't
collide with the Flatpak's 9222. When a page enters VR, Chrome asks
**Allow VR?** in the browser panel; choose *Allow this time* or *Allow while
visiting the site*.
Both ways of starting it run
[`frame/chromium-xr/launch.sh`](../frame/chromium-xr/launch.sh), copied to
`~/Applications/ChromiumXR/launch.sh` on the Frame, which holds Chrome's flags.
It sits outside `~/chromium-xr` so `install` doesn't delete it.
**From the Steam library (verified 2026-09-26).** `steam` adds a non-Steam
shortcut called "Chromium XR" (with the Flathub Chromium icon, if that's
installed) through the Steam client's DevTools port, the same way as T3 Code
([apks.md](apks.md)), without restarting Steam. It saves the app id in
`~/Applications/ChromiumXR/shortcut-appid`, so rerunning it, even after you
rename the shortcut in the library, doesn't add a second one. On this Frame
the shortcut app id is 2240749789. Launching it from the library gives
Chromium its own panel, `valve.steam.desktopgame.2240749789`, like any other
app. A Steam launch doesn't open a DevTools port, so `check` needs `launch`.
Chromium runs one browser per profile: while the Steam-launched one is open,
`launch` opens its URL in that window, without DevTools, then prints
`failed: ... exited` because no new window appeared. Close it first.
**Seccomp is off.** The wrapper passes `--disable-seccomp-filter-sandbox`. With
the XR seccomp policy on, SteamVR's client reads `/proc/self/status` through
Chrome's file broker and gets the broker's pid. SteamVR then binds the app to
the wrong process ("Unable to init path manager: VRInitError_Init_Internal")
and `xrCreateInstance` fails. The broker can't answer `/proc/self` for another
process, so fixing this needs a change in Chromium's broker client or in the
CL. The namespace sandbox stays on, but seccomp is off for every process, so
use this profile for VR sites rather than everyday browsing. DevTools on
port 9223 has no authentication. It listens on loopback, but with the
userspace Tailscale from [tailscale.md](tailscale.md) running, loopback ports
are reachable from your tailnet. Close the browser when you're done.
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10, **Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
this build): this build):
@@ -138,9 +116,26 @@ this build):
[`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo, [`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo,
a stereo 360 video, played in 3D after pressing Enter VR. a stereo 360 video, played in 3D after pressing Enter VR.
**Not verified yet:** - **Launched from the Steam library, verified remotely 2026-09-27** with
nobody wearing the headset (standby workaround in
[how-the-frame-works.md](how-the-frame-works.md)). The installer's Steam
shortcut starts Chromium, and SteamVR takes it as scene app
`steam.app.<shortcut id>`. A minimal WebXR session that clears every frame
to red ran at about 75 frames per second, and the stereo headset capture
showed both eyes solid red. Steam preloads its overlay
(`gameoverlayrenderer.so`), which crashed Chromium's zygote about 30 s after
a Steam launch. The public repo's launcher now removes it from
`LD_PRELOAD`. With the headset outside its playspace, SteamVR shows
passthrough wherever the page leaves transparent pixels.
- Frame rate and dropped frames during playback (nothing was measured; it - **Frame rate and input, measured 2026-09-27** (standby workaround, red
looked fine). test session): 72 fps with every frame at 13.9–14 ms over 16 s, and SteamVR
- Third-party VR180 players (DeoVR and DL8 web embeds). dropped frames only at startup. The right controller showed up as an
- Controller and hand input inside a WebXR page. `oculus-touch` `tracked-pointer` with an `xr-standard` gamepad and a
25-joint hand, with poses on every frame. A real squeeze reached the page
as `squeezestart`/`squeeze`. Haptics aren't exposed (no actuators).
Details are in the public repo's technical notes.
**Not verified yet:** trigger, thumbstick and face buttons, the left
controller, bare-hand tracking, and third-party VR180 players (DeoVR and
DL8 web embeds).
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
# Frame-side: start the WebXR Chromium build (~/chromium-xr). The Steam
# library shortcut "Chromium XR" runs this, and so does
# `scripts/chromium-xr.sh launch` (which adds a DevTools port). Extra
# arguments go to Chrome, so a URL opens that page.
#
# Lives in ~/Applications/ChromiumXR, outside ~/chromium-xr, so reinstalling
# the build doesn't delete it.
set -euo pipefail
CHROME="$HOME/chromium-xr/chrome"
[[ -x "$CHROME" ]] || { echo "launch.sh: no build at $CHROME (run chromium-xr.sh install)" >&2; exit 1; }
# Without --no-first-run and --password-store=basic, startup can stop at a
# first-run or keyring prompt.
# --disable-seccomp-filter-sandbox: under the XR seccomp policy, SteamVR's
# client reads /proc/self/status through the file broker, gets the broker's
# pid, and SteamVR binds the app to the wrong process, so xrCreateInstance
# fails. The namespace sandbox stays on, but seccomp is off for every
# process, so keep this profile for VR sites.
exec "$CHROME" \
--user-data-dir="$HOME/.config/chromium-xr" \
--enable-features=OpenXR \
--ozone-platform=x11 \
--no-first-run --no-default-browser-check --password-store=basic \
--disable-seccomp-filter-sandbox \
"$@"
@@ -19,6 +19,7 @@
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; }; 9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; }; A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; }; DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; }; F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -36,6 +37,7 @@
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; }; 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; }; 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; }; 237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = "<group>"; };
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; }; 8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
@@ -74,6 +76,7 @@
5064A5B6FE5B17B18E5FA4B8 /* SSH */ = { 5064A5B6FE5B17B18E5FA4B8 /* SSH */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */,
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */, 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */,
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */, EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */,
237D9AF04EEA257AB382F60E /* Keys.swift */, 237D9AF04EEA257AB382F60E /* Keys.swift */,
@@ -255,6 +258,7 @@
files = ( files = (
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */, F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */, 78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */, 12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */, 0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */, 4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
+1 -1
View File
@@ -92,7 +92,7 @@ final class AppModel: ObservableObject {
} }
/// "host", "host:port" or "[v6]:port", plus a user name. /// "host", "host:port" or "[v6]:port", plus a user name.
static func parse(host: String, user: String) -> FrameSettings? { nonisolated static func parse(host: String, user: String) -> FrameSettings? {
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces)) var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") { if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
let rest = target.host[target.host.index(after: close)...] let rest = target.host[target.host.index(after: close)...]
@@ -14,6 +14,11 @@ struct FrameControlApp: App {
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes // and connect to FRAME_TEST_HOST with it (`simctl launch` passes
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST). // SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)") print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
// FRAME_TEST_LANDSCAPE=1 turns the app on its side, to check the safe areas there.
if ProcessInfo.processInfo.environment["FRAME_TEST_LANDSCAPE"] != nil,
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscapeRight))
}
// FRAME_TEST_PAIR="host|user|password" runs the real password pairing. // FRAME_TEST_PAIR="host|user|password" runs the real password pairing.
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] { if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
let f = pair.components(separatedBy: "|") let f = pair.components(separatedBy: "|")
+7 -1
View File
@@ -43,8 +43,14 @@
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
</dict> </dict>
<key>NSBonjourServices</key>
<array>
<string>_steamos-devkit._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Frame Control connects to your Steam Frame over your local network with SSH.</string> <string>Frame Control finds your Steam Frame on your network and connects to it.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Frame Control saves headset captures and screenshots to your photo library when you ask it to.</string>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict> <dict>
<key>UIColorName</key> <key>UIColorName</key>
+125
View File
@@ -0,0 +1,125 @@
import Foundation
import Network
/// Finds the Frame on the local network so nobody has to type its address.
/// A Frame in Developer Mode advertises Valve's devkit service over Bonjour
/// (`_steamos-devkit._tcp`); failing that, `fallback` (the saved address, or
/// frame.local) is checked by opening its SSH port. Both repeat until stopped.
@MainActor
final class FrameFinder: ObservableObject {
struct Found: Equatable {
let host: String // what to connect to
let name: String // what to call it
}
@Published private(set) var found: Found?
/// When the search began, to tell "still looking" from "can't find it".
@Published private(set) var since = Date()
private var browser: NWBrowser?
private var probeTask: Task<Void, Never>?
private var fallback = "frame.local"
func start(fallback: String?) {
stop()
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
found = nil
since = Date()
browse()
probeTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let host = self.fallback
if self.found == nil, await Self.sshAnswers(host: host) {
self.found = Found(host: host, name: host)
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
}
func stop() {
browser?.cancel()
browser = nil
probeTask?.cancel()
probeTask = nil
}
private func browse() {
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
browser.browseResultsChangedHandler = { [weak self] results, _ in
for result in results {
guard case let .service(name, _, _, _) = result.endpoint else { continue }
Self.resolve(result.endpoint) { host in
Task { @MainActor in
guard let self, let host else { return }
// A found device is used over the fallback probe.
self.found = Found(host: host, name: name)
}
}
}
}
browser.start(queue: .main)
self.browser = browser
}
/// The device's IP address: connect to the service and read where it went.
nonisolated private static func resolve(_ endpoint: NWEndpoint, done: @escaping @Sendable (String?) -> Void) {
let connection = NWConnection(to: endpoint, using: .tcp)
let once = Once()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
var host: String?
if case let .hostPort(h, _)? = connection.currentPath?.remoteEndpoint {
host = "\(h)".components(separatedBy: "%").first // drop an IPv6 interface suffix
}
connection.cancel()
if once.claim() { done(host) }
case .failed, .cancelled:
if once.claim() { done(nil) }
default:
break
}
}
connection.start(queue: .global())
DispatchQueue.global().asyncAfter(deadline: .now() + 5) {
connection.cancel()
if once.claim() { done(nil) }
}
}
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
nonisolated static func sshAnswers(host address: String) async -> Bool {
var host = address, port: UInt16 = 22
if let target = AppModel.parse(host: address, user: "steamos") {
host = target.host
port = UInt16(target.port)
}
return await sshAnswers(host: host, port: port)
}
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
let connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port) ?? 22, using: .tcp)
let once = Once()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
connection.cancel()
if once.claim() { c.resume(returning: true) }
case .failed, .waiting:
connection.cancel()
if once.claim() { c.resume(returning: false) }
default:
break
}
}
connection.start(queue: .global())
DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
connection.cancel()
if once.claim() { c.resume(returning: false) }
}
}
}
}
+46
View File
@@ -11,6 +11,9 @@ struct RootView: View {
SetupView(model: model) SetupView(model: model)
case .connecting(let step): case .connecting(let step):
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() } ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
case .failed(let message) where model.retrying && !model.needsPairing:
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
case .failed(let message): case .failed(let message):
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing, FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
retry: { Task { await model.connect() } }, change: { model.showSetup() }) retry: { Task { await model.connect() } }, change: { model.showSetup() })
@@ -73,3 +76,46 @@ struct FailedView: View {
.frame(maxWidth: 480) .frame(maxWidth: 480)
} }
} }
/// A paired Frame that isn't answering is almost always asleep: say how to wake
/// it, and connect the moment it does (its SSH port is checked every 3 s).
struct WaitingView: View {
let host: String
let detail: String
let deviceName: String
let reachable: () -> Void
let change: () -> Void
@State private var pulse = false
var body: some View {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 17))
.opacity(pulse ? 1 : 0.55)
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
Text("Waiting for your Frame").font(.title3.bold())
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 10) {
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
}
.padding(14)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
Button("Connect to a different Frame", action: change).font(.footnote)
}
.padding(28)
.frame(maxWidth: 480)
.onAppear { pulse = true }
.task(id: host) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
reachable()
return
}
}
}
}
}
+177 -62
View File
@@ -1,82 +1,197 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
/// Pairing: the Developer Mode password is used once, to add this phone's own /// First run: two steps. Wake the Frame (the app finds it by itself), then type the
/// key to the Frame. It isn't stored. /// Developer Mode password once. Everything else waits under "Other ways to connect".
struct SetupView: View { struct SetupView: View {
@ObservedObject var model: AppModel @ObservedObject var model: AppModel
@State private var host = "" @StateObject private var finder = FrameFinder()
@State private var user = "steamos"
@State private var password = "" @State private var password = ""
@FocusState private var focus: Field? @State private var manualHost = ""
private enum Field { case host, user, password } @State private var user = "steamos"
@State private var showOther = false
@State private var showHelp = false
@FocusState private var passwordFocused: Bool
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
private var host: String {
let typed = manualHost.trimmingCharacters(in: .whitespaces)
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
}
var body: some View { var body: some View {
NavigationStack { ScrollView {
Form { VStack(alignment: .leading, spacing: 22) {
Section { header
VStack(alignment: .leading, spacing: 10) { StepCard(number: 1, title: "Wake your Frame", done: finder.found != nil) { wakeStep }
Image("AppIconImage").resizable().frame(width: 64, height: 64).clipShape(RoundedRectangle(cornerRadius: 14)) StepCard(number: 2, title: "Enter its Developer Mode password", done: false) { passwordStep }
Text("Connect to your Steam Frame").font(.title2.bold()) otherWays
Text("See what the headset sees, install games and Android apps, and send files and text, from this \(model.deviceName).")
.foregroundStyle(Color.frameMuted)
}
.padding(.vertical, 6)
.listRowBackground(Color.clear)
}
Section {
Label("On the Frame, open Steam Settings → System and turn on Developer Mode.", systemImage: "1.circle")
Label("Then Developer → Set User Password.", systemImage: "2.circle")
Label("Enter the headset's address and that password here, once.", systemImage: "3.circle")
} header: { Text("Before you start") }
Section {
TextField("frame.local or 192.168.1.20", text: $host)
.textContentType(.URL).keyboardType(.URL).autocorrectionDisabled().textInputAutocapitalization(.never)
.focused($focus, equals: .host).submitLabel(.next).onSubmit { focus = .password }
TextField("User", text: $user)
.autocorrectionDisabled().textInputAutocapitalization(.never).focused($focus, equals: .user)
SecureField("Developer Mode password", text: $password)
.textContentType(.password).focused($focus, equals: .password).submitLabel(.go).onSubmit(pair)
} header: { Text("Headset") } footer: {
Text("The password is only used to add this \(model.deviceName)'s own SSH key to the Frame; it isn't saved. The Frame and this \(model.deviceName) need to be on the same network, or both on Tailscale.")
}
Section {
Button(action: pair) {
Text("Pair").frame(maxWidth: .infinity).fontWeight(.semibold)
}
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty || password.isEmpty)
if model.settings != nil {
Button("Use \(model.settings!.host) again") { Task { await model.connect() } }
Button("Forget this headset", role: .destructive) { Task { await model.forget() } }
}
}
Section {
Text(model.authorizedKeysLine)
.font(.system(.caption2, design: .monospaced)).lineLimit(3).textSelection(.enabled)
Button("Copy this \(model.deviceName)'s key") { UIPasteboard.general.string = model.authorizedKeysLine }
Button("Connect with the key") {
let (h, u) = (host, user)
Task { await model.useKey(host: h, user: u) }
}
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty)
} header: { Text("Or add the key yourself") } footer: {
Text("If you already reach the Frame over SSH, add this line to ~/.ssh/authorized_keys there, then connect without a password.")
}
} }
.scrollContentBackground(.hidden) .padding(20)
.background(Color.frameBackground) .frame(maxWidth: 560)
.navigationTitle("Frame Control") .frame(maxWidth: .infinity)
.navigationBarTitleDisplayMode(.inline)
} }
.scrollDismissesKeyboard(.interactively)
.background(Color.frameBackground)
.onAppear { .onAppear {
host = model.settings?.host ?? "frame.local" manualHost = model.settings?.host ?? ""
user = model.settings?.user ?? "steamos" user = model.settings?.user ?? "steamos"
var fallback = model.settings?.host
#if DEBUG
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
#endif
finder.start(fallback: fallback)
}
.onDisappear { finder.stop() }
// After a few seconds of not finding it, say exactly what to check.
.task(id: finder.since) {
try? await Task.sleep(nanoseconds: 8_000_000_000)
showHelp = true
} }
} }
private func pair() { private var header: some View {
VStack(alignment: .leading, spacing: 8) {
Image("AppIconImage").resizable().frame(width: 56, height: 56).clipShape(RoundedRectangle(cornerRadius: 13))
Text("Connect to your Steam Frame").font(.title2.bold())
Text("One time only. After this, the app connects by itself whenever your Frame is awake.")
.foregroundStyle(Color.frameMuted)
}
}
// MARK: step 1
@ViewBuilder private var wakeStep: some View {
if let found = finder.found {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Found your Frame").fontWeight(.semibold)
Text(found.name == found.host ? found.host : "\(found.name) · \(found.host)")
.font(.footnote).foregroundStyle(Color.frameMuted)
}
} icon: {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
}
} else {
HStack(spacing: 10) {
ProgressView()
Text("Looking for it on this network…").foregroundStyle(Color.frameMuted)
}
Text("Put the headset on, or press its power button, so it's awake.")
if showHelp {
VStack(alignment: .leading, spacing: 10) {
Text("Still can't see it? Check:").font(.subheadline.weight(.semibold))
Tip(icon: "wifi", text: "The Frame and this \(model.deviceName) are on the same Wi-Fi.")
Tip(icon: "hammer", text: "Developer Mode is on: on the Frame, Steam Settings → System → Enable Developer Mode.")
Tip(icon: "network", text: "Local Network is allowed for Frame Control: \(model.deviceName) Settings → Apps → Frame Control.")
}
.padding(.top, 4)
}
}
}
// MARK: step 2
@ViewBuilder private var passwordStep: some View {
SecureField("Developer Mode password", text: $password)
.textContentType(.password)
.submitLabel(.go)
.focused($passwordFocused)
.onSubmit(connect)
.padding(12)
.background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10))
Text("Haven't set one? On the Frame: Steam Settings → Developer → Set User Password. It's only used now, to let this \(model.deviceName) in; it isn't saved.")
.font(.footnote).foregroundStyle(Color.frameMuted)
Button(action: connect) {
Text(finder.found == nil ? "Connect to \(host)" : "Connect")
.fontWeight(.semibold).frame(maxWidth: .infinity).padding(.vertical, 4)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(password.isEmpty)
}
// MARK: everything else, out of the way
private var otherWays: some View {
DisclosureGroup(isExpanded: $showOther) {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
.onSubmit { finder.start(fallback: manualHost) }
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
TextField("User", text: $user)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
Text("Typing an address here uses it instead of searching.").font(.caption).foregroundStyle(Color.frameMuted)
}
VStack(alignment: .leading, spacing: 6) {
Text("Already reach the Frame over SSH? Add this \(model.deviceName)'s key to ~/.ssh/authorized_keys there, then connect without a password.")
.font(.footnote).foregroundStyle(Color.frameMuted)
HStack {
Button("Copy key") { UIPasteboard.general.string = model.authorizedKeysLine }
Spacer()
Button("Connect with the key") {
let (h, u) = (host, user)
Task { await model.useKey(host: h, user: u) }
}
}
}
if let saved = model.settings {
Button("Forget \(saved.host)", role: .destructive) { Task { await model.forget() } }
}
}
.padding(.top, 10)
} label: {
Text("Other ways to connect").foregroundStyle(Color.frameMuted)
}
.onChange(of: manualHost) { _, value in
// A typed address replaces the search.
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
}
}
private func connect() {
guard !password.isEmpty else { passwordFocused = true; return }
let (h, u, p) = (host, user, password) let (h, u, p) = (host, user, password)
password = "" password = ""
finder.stop()
Task { await model.pair(host: h, user: u, password: p) } Task { await model.pair(host: h, user: u, password: p) }
} }
} }
/// A numbered step with a tick once it's done.
struct StepCard<Content: View>: View {
let number: Int
let title: String
let done: Bool
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
ZStack {
Circle().fill(done ? Color.green : Color.frameBlue).frame(width: 26, height: 26)
if done { Image(systemName: "checkmark").font(.caption.bold()) } else { Text("\(number)").font(.subheadline.bold()) }
}
.foregroundStyle(.white)
Text(title).font(.headline)
}
content
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 14))
}
}
struct Tip: View {
let icon: String
let text: String
var body: some View {
Label { Text(text).font(.subheadline).fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: icon).foregroundStyle(Color.frameBlue) }
}
}
+3 -1
View File
@@ -39,7 +39,9 @@ targets:
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
UIUserInterfaceStyle: Dark UIUserInterfaceStyle: Dark
NSLocalNetworkUsageDescription: Frame Control connects to your Steam Frame over your local network with SSH. NSLocalNetworkUsageDescription: Frame Control finds your Steam Frame on your network and connects to it.
NSBonjourServices: [_steamos-devkit._tcp]
NSPhotoLibraryAddUsageDescription: Frame Control saves headset captures and screenshots to your photo library when you ask it to.
NSAppTransportSecurity: NSAppTransportSecurity:
NSAllowsLocalNetworking: true NSAllowsLocalNetworking: true
LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp] LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Open Frame Control's assistant as a Chromium panel. Ctrl-C closes it and its SSH tunnel.
Start ui/server.py first. Requires the platform Chromium Flatpak and zsh on the
computer (the existing panel launcher). No model endpoint or key is configured.
"""
import argparse
import os
from pathlib import Path
import re
import shlex
import signal
import shutil
import subprocess
import sys
import uuid
ROOT = Path(__file__).resolve().parent.parent
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', type=int, default=47810, help='local Frame Control port')
parser.add_argument('--frame-port', type=int, default=47812, help='Frame loopback tunnel port')
args = parser.parse_args()
alias = os.environ.get('FRAME_ALIAS', 'frame')
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', alias) or any(not 1 <= p <= 65535 for p in (args.port, args.frame_port)):
parser.error('Invalid alias or port')
if not shutil.which('zsh'):
parser.error('The panel launcher requires zsh on this computer')
sys.path.insert(0, str(ROOT / 'ui'))
from frame_mcp import Client
Client('http://127.0.0.1:' + str(args.port), os.environ.get('FRAME_UI_KEY', '1')).request('/api/host')
profile = '/tmp/frame-control-assistant-' + uuid.uuid4().hex
log_path = ''
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
tunnel = subprocess.Popen(['ssh', '-N', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
'-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
'-o', 'ServerAliveCountMax=2', '-R',
f'127.0.0.1:{args.frame_port}:127.0.0.1:{args.port}', alias])
try:
# Check the forwarded page before starting a browser; no arbitrary sleeps.
probe = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
'curl --retry 5 --retry-connrefused --retry-delay 1 --max-time 10 -fsS ' +
shlex.quote(f'http://127.0.0.1:{args.frame_port}/assistant')],
stdout=subprocess.DEVNULL, timeout=30)
if probe.returncode or tunnel.poll() is not None:
raise RuntimeError('Could not forward Frame Control to the Frame')
launched = subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant',
'org.chromium.Chromium', '--user-data-dir=' + profile, '--no-first-run',
'--disable-background-networking', '--disable-sync',
f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45, stdout=subprocess.PIPE, text=True)
print(launched.stdout, end='', flush=True)
match = re.search(r'log (/tmp/panel-on-frame\.[A-Za-z0-9]+)', launched.stdout)
if match:
log_path = match.group(1)
print('Assistant panel open. Ctrl-C closes this panel and its tunnel.', flush=True)
tunnel.wait()
raise RuntimeError('SSH tunnel ended')
except KeyboardInterrupt:
return 0
finally:
tunnel.terminate()
try:
tunnel.wait(timeout=10)
except subprocess.TimeoutExpired:
tunnel.kill()
tunnel.wait()
# Only this unique browser profile, never a shared Chromium instance.
cleanup = '''import os, pathlib, signal, shutil, sys, time
profile = sys.argv[1]
needle = ('--user-data-dir=' + profile).encode()
owned = []
for p in pathlib.Path('/proc').iterdir():
try:
if p.name.isdigit() and p.stat().st_uid == os.getuid() and needle in (p / 'cmdline').read_bytes().split(b'\\0'):
owned.append(int(p.name))
except OSError:
pass
for sig in (signal.SIGTERM, signal.SIGKILL):
for pid in owned:
try: os.kill(pid, sig)
except ProcessLookupError: pass
time.sleep(.3)
shutil.rmtree(profile, ignore_errors=True)
if sys.argv[2]:
pathlib.Path(sys.argv[2]).unlink(missing_ok=True)
'''
result = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
'python3 - ' + shlex.quote(profile) + ' ' + shlex.quote(log_path)], input=cleanup, text=True, timeout=20)
if result.returncode:
print('Cleanup failed; close the assistant panel and remove ' + profile + ' on the Frame.', file=sys.stderr)
if __name__ == '__main__':
try:
sys.exit(main())
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
-135
View File
@@ -1,135 +0,0 @@
#!/bin/bash
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, so WebXR
# immersive-vr works on the Frame.
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
# Progress: ~/chromium-xr/stage. Output: ~/chromium-xr/chromium-xr-arm64.tar.xz,
# which scripts/chromium-xr.sh install copies to the Frame.
# Re-running resumes: existing checkout and out/XR are reused.
set -euo pipefail
W=~/chromium-xr
cd "$W"
stage(){ echo "$(date -Is) $*" | tee -a "$W/stage"; }
# Returns non-zero below 12 GB free; set -e turns that into an exit at top level.
guard(){ avail=$(df --output=avail -BG "$W" | tail -n 1 | tr -dc 0-9); if [ "$avail" -lt 12 ]; then stage "ABORT: only ${avail}G free for $W"; return 3; fi; }
[ -d depot_tools ] || git clone -q https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$W/depot_tools:$PATH" DEPOT_TOOLS_UPDATE=1 DEPOT_TOOLS_METRICS=0
CL_REF=refs/changes/79/8132979/44
if [ ! -f .gclient ]; then
cat > .gclient <<'G'
solutions = [{ "name": "src", "url": "https://chromium.googlesource.com/chromium/src.git",
"managed": False, "custom_deps": {}, "custom_vars": { "checkout_nacl": False } }]
target_os = ["linux"]
target_cpu = ["arm64"]
G
fi
# Keyed on a real commit, so an interrupted first fetch is retried on re-run.
if ! git -C src rev-parse -q --verify HEAD >/dev/null 2>&1; then
stage "clone src at $CL_REF"
mkdir -p src
[ -d src/.git ] || git -C src init -q
git -C src remote get-url origin >/dev/null 2>&1 || git -C src remote add origin https://chromium.googlesource.com/chromium/src.git
git -C src fetch -q --depth=1 origin "$CL_REF"
git -C src checkout -q FETCH_HEAD
fi
guard
stage "src at $(git -C src log -1 --format='%h %s')"
rev=$(git -C src rev-parse HEAD)
# Sync once per revision: once the patch below is applied, gclient sync
# refuses to run on the modified checkout, so re-runs must skip it.
if [ "$(cat "$W/synced" 2>/dev/null)" != "$rev" ]; then
stage "gclient sync"
gclient sync --nohooks --no-history -D --shallow --revision "src@$rev" -j 8
guard
stage "runhooks"
gclient runhooks
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
echo "$rev" > "$W/synced"
fi
guard
cd src
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
# process (verified on the Frame 2026-09-26). Allow only that option.
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
index 435e13d396..297453f582 100644
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
@@ -11,6 +11,7 @@
#include "sandbox/linux/system_headers/linux_syscalls.h"
#include "sandbox/policy/linux/sandbox_linux.h"
+using sandbox::bpf_dsl::AllOf;
using sandbox::bpf_dsl::Allow;
using sandbox::bpf_dsl::Arg;
using sandbox::bpf_dsl::Error;
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
switch (system_call_number) {
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
- // stay disallowed; add a narrow level/optname restriction if ever needed.
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
#if defined(__NR_getpeername)
case __NR_getpeername:
#endif
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
case __NR_get_robust_list:
#endif
return Allow();
+#if defined(__NR_getsockopt)
+ case __NR_getsockopt: {
+ // SteamVR's IPC client checks who is on the other end of its socket
+ // with SO_PEERCRED. Nothing else is readable.
+ const Arg<int> level(1);
+ const Arg<int> optname(2);
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
+ .Else(Error(EPERM));
+ }
+#endif
#if defined(__NR_kill)
case __NR_kill: {
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
P
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
printf '%s\n' "$PEERCRED_PATCH" | git apply
stage "applied SO_PEERCRED patch"
fi
mkdir -p out/XR
cat > out/XR/args.gn <<'A'
target_os = "linux"
target_cpu = "arm64"
is_debug = false
is_official_build = false
is_component_build = false
dcheck_always_on = false
symbol_level = 0
blink_symbol_level = 0
v8_symbol_level = 0
proprietary_codecs = true
ffmpeg_branding = "Chrome"
use_remoteexec = false
use_siso = true
treat_warnings_as_errors = false
A
stage "gn gen"
gn gen out/XR
gn args out/XR --list=enable_openxr --short | tee -a "$W/stage"
stage "build"
( while sleep 600; do guard || { pkill -u "$(id -u)" -f "siso|ninja"; exit 3; }; done ) &
GUARD=$!
trap 'kill $GUARD 2>/dev/null || true' EXIT
autoninja -C out/XR chrome chrome_sandbox chrome_crashpad_handler
stage "package"
cd out/XR
files=(chrome chrome_sandbox chrome_crashpad_handler *.pak *.bin icudtl.dat locales)
# GPU libraries aren't produced by every config; pack the ones that exist.
for f in libEGL.so libGLESv2.so libvk_swiftshader.so libvulkan.so.1 vk_swiftshader_icd.json; do
[ -e "$f" ] && files+=("$f")
done
tar -cJf "$W/chromium-xr-arm64.tar.xz" "${files[@]}"
stage "DONE $(ls -la $W/chromium-xr-arm64.tar.xz)"
-140
View File
@@ -1,140 +0,0 @@
#!/usr/bin/env zsh
# Mac-side: install and launch the WebXR-enabled Chromium build on the Frame.
#
# Flathub Chromium can't enter immersive WebXR on Linux: upstream only wires
# the OpenXR device on Windows (see docs/webxr-chromium.md). This deploys an
# arm64 build with the Linux OpenXR CLs, made on a Linux host by
# scripts/build-chromium-xr.sh, into ~/chromium-xr on the Frame (not a
# Flatpak, so SteamVR's sockets and the XR sandbox work unmodified).
#
# Usage:
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
# scripts/chromium-xr.sh launch [URL] # opens as its own panel in the headset
# scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
BUILD_HOST=${BUILD_HOST:-}
BUILD_TARBALL=${BUILD_TARBALL:-chromium-xr/chromium-xr-arm64.tar.xz}
DEVTOOLS_PORT=${DEVTOOLS_PORT:-9223}
STEAM_NAME=${STEAM_NAME:-Chromium XR}
here=${0:A:h}
wrapper='~/Applications/ChromiumXR/launch.sh'
# frame/chromium-xr/launch.sh holds Chrome's flags; both launch paths run it.
push_wrapper() {
# Write then rename, so a dropped connection can't leave a torn script.
ssh "$FRAME_ALIAS" 'mkdir -p ~/Applications/ChromiumXR && cd ~/Applications/ChromiumXR && cat > launch.sh.new && chmod +x launch.sh.new && mv launch.sh.new launch.sh' \
< "$here/../frame/chromium-xr/launch.sh"
}
case "${1:-}" in
install)
tarball=${2:-}
if [[ -z "$tarball" ]]; then
[[ -n "$BUILD_HOST" ]] || { print -u2 "Pass a tarball, or set BUILD_HOST to the build machine"; exit 2; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
tarball=$tmp/chromium-xr-arm64.tar.xz
scp -q "$BUILD_HOST:$BUILD_TARBALL" "$tarball"
fi
ssh "$FRAME_ALIAS" 'rm -rf ~/chromium-xr.new && mkdir -p ~/chromium-xr.new'
ssh "$FRAME_ALIAS" 'tar -xJf - -C ~/chromium-xr.new' < "$tarball"
# Check the new build runs before replacing the old one.
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
;;
launch)
# Its own VR panel on gamescope's X display, so the Plasma desktop doesn't
# need to be open. DevTools is only on for this path (for `check`).
push_wrapper
exec "$here/panel-on-frame.sh" --name chromium-xr -- "$wrapper" \
--remote-debugging-port="$DEVTOOLS_PORT" \
"${2:-https://immersive-web.github.io/webxr-samples/}"
;;
steam)
# A non-Steam shortcut, added through the Steam client's DevTools port
# without restarting Steam (see docs/apks.md). Launching it from the
# library gives Chromium its own panel like any game. Safe to rerun: it
# refreshes the wrapper and only adds the shortcut if it's missing.
ssh "$FRAME_ALIAS" 'test -x ~/chromium-xr/chrome' ||
{ print -u2 "No build in ~/chromium-xr on the Frame: run 'chromium-xr.sh install' first"; exit 1; }
push_wrapper
# The app id is kept next to the wrapper, so renaming the shortcut in the
# library doesn't make a rerun add a second one.
shortcuts=$here/../frame/android/steam_shortcuts.py
home=$(ssh "$FRAME_ALIAS" 'printf %s "$HOME"')
saved=$(ssh "$FRAME_ALIAS" 'cat ~/Applications/ChromiumXR/shortcut-appid 2>/dev/null || true')
existing=$(ssh "$FRAME_ALIAS" python3 - list < "$shortcuts" |
python3 -c 'import json,sys
apps = json.load(sys.stdin)
ids = [a["appid"] for a in apps if str(a["appid"]) == sys.argv[2]] or [a["appid"] for a in apps if a["name"] == sys.argv[1]]
print(ids[0] if ids else "")' "$STEAM_NAME" "$saved")
if [[ -n "$existing" ]]; then
print -r -- "The shortcut is already in the Steam library (app id $existing)"
else
icon=''
for dir in /var/lib/flatpak '~/.local/share/flatpak'; do
candidate=$dir/exports/share/icons/hicolor/256x256/apps/org.chromium.Chromium.png
if ssh "$FRAME_ALIAS" "test -f $candidate"; then icon=${candidate/#\~/$home}; break; fi
done
existing=$(ssh "$FRAME_ALIAS" python3 - add ${(q)STEAM_NAME} ${(q)home}/Applications/ChromiumXR/launch.sh ${(q)home} ${(q)icon} < "$shortcuts")
[[ "$existing" == <-> ]] || { print -u2 -r -- "Steam didn't return a shortcut app id: $existing"; exit 1; }
print -r -- "Added $STEAM_NAME to the Steam library (shortcut app id $existing)"
fi
ssh "$FRAME_ALIAS" "printf '%s\n' $existing > ~/Applications/ChromiumXR/shortcut-appid"
;;
check)
# DevTools listens on the Frame's loopback only; evaluate there.
ssh "$FRAME_ALIAS" python3 - "$DEVTOOLS_PORT" <<'EOF'
import json, sys, urllib.request, base64, os, socket, struct
port = int(sys.argv[1])
tabs = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=10))
page = next((t for t in tabs if t["type"] == "page"), None)
if page is None:
sys.exit("no open page: run 'chromium-xr.sh launch' first")
path = page["webSocketDebuggerUrl"].split(f":{port}", 1)[1]
s = socket.create_connection(("127.0.0.1", port), timeout=30)
key = base64.b64encode(os.urandom(16)).decode()
s.sendall(f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n".encode())
s.recv(4096)
msg = json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
"expression": "navigator.xr ? navigator.xr.isSessionSupported('immersive-vr') : 'no navigator.xr'",
"awaitPromise": True}}).encode()
mask = os.urandom(4)
hdr = bytes([0x81]) + (bytes([0x80 | len(msg)]) if len(msg) < 126
else bytes([0x80 | 126]) + struct.pack(">H", len(msg)))
s.sendall(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(msg)))
buf = b""
reply = None
while reply is None:
chunk = s.recv(65536)
if not chunk:
sys.exit("DevTools closed the connection")
buf += chunk
# Consume every complete frame already buffered before reading again.
while len(buf) >= 2:
n = buf[1] & 0x7F
off = 2
if n == 126:
if len(buf) < 4:
break
n, off = struct.unpack(">H", buf[2:4])[0], 4
elif n == 127:
if len(buf) < 10:
break
n, off = struct.unpack(">Q", buf[2:10])[0], 10
if len(buf) < off + n:
break
frame, buf = buf[off:off + n], buf[off + n:]
msg = json.loads(frame)
if msg.get("id") == 1:
reply = msg
break
print("immersive-vr supported:", reply["result"]["result"].get("value"))
EOF
;;
*) sed -n '2,14p' "$0"; exit 2 ;;
esac
+4
View File
@@ -59,9 +59,13 @@ colordepth=32
quality=9 quality=9
viewonly=0 viewonly=0
showcursor=1 showcursor=1
scale=1
viewmode=1
window_maximize=1
EOF EOF
echo \"wrote \$d/mac-screen-sharing.remmina\" echo \"wrote \$d/mac-screen-sharing.remmina\"
" "
print "On the Mac: System Settings > General > Sharing > Screen Sharing (i) >" print "On the Mac: System Settings > General > Sharing > Screen Sharing (i) >"
print " enable 'VNC viewers may control screen with password' and set one." print " enable 'VNC viewers may control screen with password' and set one."
print "Remmina may ask for your Mac account name + login password instead (Apple auth)."
fi fi
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env zsh
# Mac-side: stop the Steam Frame from going to sleep while an agent works on it.
#
# The Frame sleeps when Steam's own idle timer runs out ("Sleep after
# inactivity": 60 min on AC, 15 min on battery by default). SSH activity
# doesn't count as input, and asleep the Frame is off the network. `on` sets
# both timers to Never through Steam's UI (DevTools on 127.0.0.1:8080, via
# ui/frame_steam.py) and holds a logind sleep inhibitor as a user unit.
# `off` drops the inhibitor and restores the timers `on` saved.
#
# Usage:
# scripts/keep-awake.sh on
# scripts/keep-awake.sh off
# scripts/keep-awake.sh status
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
HERE=${0:A:h}
cmd=${1:-status}
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
# timers are client settings system_idle_suspend_{ac,battery}_sec (0 = Never),
# written the way Steam's settings page does (steamui module exporting the
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
# ("Interactive authentication required") but allows one from a user unit.
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
import json, os, subprocess, sys
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
from frame_steam import Page
cmd = sys.argv[1]
saved_path = os.path.expanduser("~/.cache/frame-control/keep-awake.json")
unit = "fc-keep-awake"
keys = ("system_idle_suspend_ac_sec", "system_idle_suspend_battery_sec")
if cmd == "off": # release the lock first, even if Steam's UI is down
subprocess.run(["systemctl", "--user", "stop", unit], stderr=subprocess.DEVNULL)
page = Page()
def read():
return {k: page.eval(f"settingsStore.clientSettings.{k}") for k in keys}
def write(values):
page.eval("""(async () => { let req;
webpackChunksteamui.push([[Symbol()], {}, r => { req = r }]);
const mod = Object.keys(req.m).map(id => req.m[id].toString().includes("Settings.SetSetting") ? req(id) : null).find(Boolean);
const set = Object.values(mod).find(f => typeof f == "function" && f.toString().includes("SetSetting("));
for (const [k, v] of Object.entries(%s)) await set(k, v);
await new Promise(r => setTimeout(r, 1000)); })()""" % json.dumps(values))
def inhibitor():
return subprocess.run(["systemctl", "--user", "is-active", "-q", unit]).returncode == 0
if cmd == "on":
current = read()
if not os.path.exists(saved_path):
with open(saved_path, "w") as f:
json.dump(current, f)
write({k: 0 for k in keys})
if not inhibitor():
subprocess.run(["systemd-run", "--user", "-q", f"--unit={unit}",
"--description=Frame Control: keep the Frame awake",
"systemd-inhibit", "--what=sleep:idle:handle-suspend-key:handle-power-key",
"--who=Frame Control", "--why=Keep the Frame awake while an agent works on it",
"--mode=block", "sleep", "infinity"], check=True)
elif cmd == "off":
if os.path.exists(saved_path): # no backup: leave the timers as they are
with open(saved_path) as f:
write(json.load(f))
os.remove(saved_path)
print(json.dumps({"timers": read(), "inhibitor": inhibitor()}))
EOF
+40
View File
@@ -0,0 +1,40 @@
-- Hammerspoon: draw a ring around the Mac pointer so it shows in the VNC
-- mirror on the Frame. macOS Screen Sharing leaves the pointer out of the
-- framebuffer; a real on-screen window is captured like anything else.
--
-- Install: brew install --cask hammerspoon, then in ~/.hammerspoon/init.lua:
-- dofile("/path/to/frame-control/scripts/mac-cursor-ring.lua")
-- Toggle: ctrl+alt+cmd+M. Polls the pointer position, so no Accessibility
-- permission is needed.
local SIZE, WIDTH = 34, 3
local COLOR = { red = 1, green = 0.2, blue = 0.2, alpha = 0.9 }
local ring = hs.canvas.new({ x = 0, y = 0, w = SIZE, h = SIZE })
ring:appendElements({
type = "circle", action = "stroke",
strokeColor = COLOR, strokeWidth = WIDTH,
radius = (SIZE - WIDTH) / 2,
})
ring:level(hs.canvas.windowLevels.cursor)
ring:behavior({ "canJoinAllSpaces", "stationary", "ignoresCycle" })
local last = {}
local function follow()
local p = hs.mouse.absolutePosition()
if p.x ~= last.x or p.y ~= last.y then
ring:topLeft({ x = p.x - SIZE / 2, y = p.y - SIZE / 2 })
last = p
end
end
frameCursorRing = { canvas = ring, timer = hs.timer.new(1 / 60, follow) }
local function show() follow(); ring:show(); frameCursorRing.timer:start() end
local function hide() frameCursorRing.timer:stop(); ring:hide() end
hs.hotkey.bind({ "ctrl", "alt", "cmd" }, "M", function()
if ring:isShowing() then hide() else show() end
end)
show()
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
# Publish a tested draft release so running copies of Frame Control offer it
# (docs/releasing.md). Checks every installer is attached with a SHA-256
# digest first, since the app's updater refuses assets without one, then
# attaches update.json, the manifest the updater reads.
# Usage: scripts/publish-release.sh v0.4.0
set -eu
tag="${1:?usage: $0 vX.Y.Z}"
repo=saphid/frame-control
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
missing=""
for name in $expected; do
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
d=json.load(sys.stdin); n=sys.argv[1]
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
case "$digest" in
sha256:*) echo "ok $name" ;;
absent) echo "MISSING $name"; missing=1 ;;
*) echo "NO HASH $name"; missing=1 ;;
esac
done
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
# update.json: what running copies read (app/updater.js), from github.com's
# latest/download link rather than the rate-limited REST API.
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
d=json.load(sys.stdin)
names=set(sys.argv[1].split())
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
echo "ok update.json"
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
echo "published $tag; running copies will offer it at their next check"
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars
+35
View File
@@ -0,0 +1,35 @@
# Website
The Frame Control website, <https://frame-control.pages.dev>, on Cloudflare Pages.
- `public/`: static pages. `/` is the landing page, `/feedback/` the feedback form, `/privacy/` the privacy note.
- `functions/api/feedback.js`: `POST /api/feedback`, which turns the form into a GitHub issue labelled `feedback`.
- `lib/feedback.js`: validation and issue formatting, tested by `test/feedback.test.mjs`.
- `public/js/site.js`: settings, including the Ko-fi page name for the donate buttons.
## Feedback → GitHub issues
The function needs a `GITHUB_TOKEN` secret: a fine-grained token with **Issues: read and write** on
`saphid/frame-control` only. Issues are opened as the token's owner, so they pass the contributor gate
(`.github/workflows/issue-gate.yml`) and stay open. Without the token the form answers 503 and offers a
prefilled GitHub issue instead.
```sh
cd site
npx wrangler pages secret put GITHUB_TOKEN --project-name frame-control
```
Spam protection: a hidden honeypot field, a 3-second minimum fill time, 5 submissions per hour per IP
(a salted hash, kept in the `FEEDBACK_RL` KV namespace for about an hour), and 100 a day in total.
User text has `@mentions` and `#123` references broken so nobody gets pinged.
## Run and deploy
```sh
cd site
node --test test/*.test.mjs
npx wrangler pages dev --port 8788 # local; put GITHUB_TOKEN/GITHUB_REPO in .dev.vars to test issues
npx wrangler pages deploy --branch main # production
```
Point `GITHUB_REPO` in `.dev.vars` at a scratch repo when testing locally so test issues don't land on the real tracker.
+86
View File
@@ -0,0 +1,86 @@
// POST /api/feedback: turns the website's feedback form into a GitHub issue.
//
// Environment (Cloudflare Pages → Settings → Variables and Secrets):
// GITHUB_TOKEN secret. Fine-grained token with Issues: read and write on GITHUB_REPO only.
// GITHUB_REPO owner/name, e.g. saphid/frame-control (wrangler.toml sets it).
// FEEDBACK_RL KV namespace binding for rate limits (optional; without it there is no limit).
import { buildIssue, hashIp, validate } from "../../lib/feedback.js";
const PER_IP_PER_HOUR = 5;
const TOTAL_PER_DAY = 100;
const json = (status, data) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
});
async function overLimit(kv, key, limit, ttl) {
const count = Number(await kv.get(key)) || 0;
if (count >= limit) return true;
await kv.put(key, String(count + 1), { expirationTtl: ttl });
return false;
}
export async function onRequestPost({ request, env }) {
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
return json(503, { error: "Feedback isn't connected to GitHub yet. Use the GitHub link instead." });
}
const origin = request.headers.get("origin");
if (origin && new URL(origin).host !== new URL(request.url).host) {
return json(403, { error: "Send feedback from the website's form." });
}
let input;
try {
input = await request.json();
} catch {
return json(400, { error: "Send the form as JSON." });
}
const checked = validate(input);
// Bots get a success-shaped answer so they don't learn what tripped them.
if (checked.spam) return json(200, { ok: true });
if (checked.error) return json(400, { error: checked.error });
// Best effort: KV is eventually consistent, so bursts can slip past, and a
// storage error lets the feedback through rather than losing it.
if (env.FEEDBACK_RL) try {
const ip = request.headers.get("cf-connecting-ip") || "unknown";
const hour = Math.floor(Date.now() / 3600e3);
const day = Math.floor(Date.now() / 86400e3);
// Salted with the secret token, so the stored hashes can't be reversed by trying every IP.
const who = await hashIp(ip, env.GITHUB_TOKEN);
if (await overLimit(env.FEEDBACK_RL, `ip:${who}:${hour}`, PER_IP_PER_HOUR, 3900)) {
return json(429, { error: "That's a lot of feedback in one hour. Try again later, or use GitHub." });
}
if (await overLimit(env.FEEDBACK_RL, `day:${day}`, TOTAL_PER_DAY, 90000)) {
return json(429, { error: "The form has had a busy day. Try again tomorrow, or use GitHub." });
}
} catch (err) {
console.log(`Rate limit check failed: ${err}`);
}
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, {
method: "POST",
headers: {
authorization: `Bearer ${env.GITHUB_TOKEN}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
"user-agent": "frame-control-website",
"content-type": "application/json",
},
body: JSON.stringify(buildIssue(checked.value)),
});
if (!res.ok) {
console.log(`GitHub answered ${res.status}: ${(await res.text()).slice(0, 500)}`);
return json(502, { error: "GitHub didn't accept it just now. Try again, or use the GitHub link." });
}
const issue = await res.json();
return json(201, { ok: true, number: issue.number, url: issue.html_url });
}
export const onRequest = () => json(405, { error: "POST only." });
+102
View File
@@ -0,0 +1,102 @@
// Feedback form → GitHub issue. Pure functions, so tests can run them without
// Cloudflare or GitHub (site/test/feedback.test.mjs).
export const KINDS = {
bug: { label: "bug", title: "Bug report" },
idea: { label: "enhancement", title: "Idea" },
question: { label: "question", title: "Question" },
other: { label: null, title: "Other feedback" },
};
export const LIMITS = { title: [5, 120], message: [10, 5000], field: 120 };
// Anyone who fills the form in under this many milliseconds is a script.
export const MIN_FILL_MS = 3000;
const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const oneLine = (value, max) => String(value ?? "").replace(/\s+/g, " ").trim().slice(0, max);
// Mentions in someone else's text would ping strangers, and issue references
// (#1, owner/repo#1, GH-1, github.com links) would add backlinks to other
// people's issues, so break them all with a zero-width space. Escaping & first
// stops &commat; and &num; from turning back into @ and # when GitHub renders,
// escaping < keeps out raw HTML such as an unclosed <!-- comment, and doubling
// backslashes stops GH\-1 or github\.com from being unescaped back into references.
const ZWSP = "\u200b";
export function defang(text) {
return text
.replace(/\\/g, "\\\\")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/@(?=[A-Za-z0-9])/g, `@${ZWSP}`)
.replace(/#(?=\d)/g, `#${ZWSP}`)
.replace(/\b(GH)-(?=\d)/gi, `$1${ZWSP}-`)
.replace(/\b(github)\.com/gi, `$1${ZWSP}.com`);
}
// Returns { error } or { value } with every field trimmed and bounded.
export function validate(input) {
if (!input || typeof input !== "object") return { error: "Send the form as JSON." };
if (oneLine(input.website, 200)) return { spam: true };
// Measured in the browser with a monotonic clock, so clock skew doesn't matter.
const elapsed = Number(input.elapsed);
if (!Number.isFinite(elapsed)) return { spam: true };
// The page waits this long before sending, so only scripts get here; say so anyway.
if (elapsed < MIN_FILL_MS) return { error: "That was quick. Send it again in a moment." };
const kind = Object.hasOwn(KINDS, input.kind) ? input.kind : "other";
const title = oneLine(input.title, LIMITS.title[1]);
const message = String(input.message ?? "").replace(/\r\n?/g, "\n").trim();
if (title.length < LIMITS.title[0]) return { error: "Give it a short title (at least 5 characters)." };
if (message.length < LIMITS.message[0]) return { error: "Tell us a little more (at least 10 characters)." };
if (message.length > LIMITS.message[1]) return { error: `Keep it under ${LIMITS.message[1]} characters.` };
const github = oneLine(input.github, 40).replace(/^@/, "");
if (github && !GITHUB_LOGIN.test(github)) return { error: "That doesn't look like a GitHub username." };
return {
value: {
kind,
title,
message,
github,
version: oneLine(input.version, LIMITS.field),
os: oneLine(input.os, LIMITS.field),
steamos: oneLine(input.steamos, LIMITS.field),
},
};
}
export function buildIssue(value) {
const kind = KINDS[value.kind];
const details = [
["Frame Control version", value.version],
["Computer", value.os],
["SteamOS build", value.steamos],
].filter(([, v]) => v);
// Our own lines go first, so nothing in the sender's text can hide them.
const lines = [
value.github
? `> Sent from the website feedback form by @${value.github}.`
: "> Sent from the website feedback form. The sender left no GitHub username, so they won't see replies here.",
"",
];
if (details.length) {
lines.push("| | |", "|---|---|", ...details.map(([k, v]) => `| ${k} | ${defang(v).replace(/\|/g, "\\|")} |`), "");
}
lines.push(defang(value.message));
return {
title: `${kind.title}: ${value.title}`,
body: lines.join("\n"),
labels: ["feedback", ...(kind.label ? [kind.label] : [])],
};
}
export async function hashIp(ip, salt) {
const bytes = new TextEncoder().encode(`${salt}:${ip}`);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)].slice(0, 12).map((b) => b.toString(16).padStart(2, "0")).join("");
}
+1
View File
@@ -0,0 +1 @@
{ "type": "module", "private": true }
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found · Frame Control</title>
<meta name="description" content="Page not found.">
<meta name="theme-color" content="#0e141b">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/css/site.css">
<script src="/js/site.js" defer></script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<nav aria-label="Sections">
<a href="/#features">Features</a>
<a href="/#setup">Setup</a>
<a href="/#download">Download</a>
<a href="/#faq">FAQ</a>
<a href="/feedback/">Feedback</a>
</nav>
<div class="end">
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
</div>
</div>
</header>
<main class="page">
<div class="wrap" style="text-align:center;padding:80px 24px">
<span class="kicker">404</span>
<h1>That page isn't here</h1>
<p style="color:var(--muted);margin-top:14px">It may have moved. Try the home page, or tell us what you were looking for.</p>
<div class="cta"><a class="btn primary" href="/">Home</a><a class="btn ghost" href="/feedback/">Send feedback</a></div>
</div>
</main>
<footer>
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<div class="cols">
<a href="https://github.com/saphid/frame-control">GitHub</a>
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
<a href="/feedback/">Feedback</a>
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
<a href="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
</div>
</footer>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'; connect-src 'self' https://api.github.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
/css/*
Cache-Control: public, max-age=3600
/js/*
Cache-Control: public, max-age=3600
/img/*
Cache-Control: public, max-age=86400
/media/*
Cache-Control: public, max-age=604800
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+201
View File
@@ -0,0 +1,201 @@
/* Frame Control website. Colours follow the app (ui/index.html): Steam's navy and blue. */
:root {
--bg: #0e141b; --bg-2: #131c26; --panel: #17222e; --panel-2: #1d2a38; --line: rgba(143, 152, 160, .16);
--text: #c7d0d8; --bright: #fff; --muted: #8f98a0; --dim: #5e6873;
--blue: #1a9fff; --link: #66c0f4; --green: #75b022; --warn: #d9a23a; --bad: #e0573c;
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
--action-hi: linear-gradient(to right, #5fcaff 5%, #2458d6 95%);
--radius: 14px; --wrap: 1160px;
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 80px; }
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.6 var(--font); -webkit-font-smoothing: antialiased; }
img, video { max-width: 100%; height: auto; display: block; }
a { color: var(--link); text-decoration: none; }
a:hover { color: var(--bright); }
::selection { background: var(--blue); color: #fff; }
:focus-visible { outline: 2px solid var(--link); outline-offset: 3px; border-radius: 4px; }
h1, h2, h3 { color: var(--bright); line-height: 1.15; margin: 0; letter-spacing: -.02em; }
h1 { font-size: clamp(38px, 6vw, 64px); font-weight: 800; }
h2 { font-size: clamp(28px, 3.6vw, 40px); font-weight: 750; }
h3 { font-size: 18px; font-weight: 650; letter-spacing: -.01em; }
p { margin: 0; }
code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rgba(255,255,255,.06); padding: 2px 6px; border-radius: 5px; color: var(--bright); }
.wrap { max-width: var(--wrap); margin: 0 auto; padding: 0 24px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
[hidden] { display: none !important; }
/* ---- header ---- */
.top { position: sticky; top: 0; z-index: 20; background: rgba(14, 20, 27, .78); backdrop-filter: saturate(160%) blur(14px);
-webkit-backdrop-filter: saturate(160%) blur(14px); border-bottom: 1px solid var(--line); }
.top .wrap { display: flex; align-items: center; gap: 28px; height: 64px; }
.brand { display: flex; align-items: center; gap: 10px; color: var(--bright); font-weight: 700; letter-spacing: 2.2px; font-size: 14px; text-transform: uppercase; }
.brand { white-space: nowrap; }
.brand img { width: 30px; height: 30px; }
.top nav { display: flex; gap: 22px; margin-left: 8px; }
.top nav a { color: var(--muted); font-size: 14.5px; font-weight: 500; }
.top nav a:hover, .top nav a[aria-current] { color: var(--bright); }
.top .end { margin-left: auto; display: flex; gap: 10px; align-items: center; }
@media (max-width: 880px) { .top nav { display: none; } }
@media (max-width: 480px) { .top .end .ghost { display: none; } }
/* ---- buttons ---- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 9px; height: 46px; padding: 0 22px; border-radius: 10px;
font: 600 15.5px var(--font); color: var(--bright); background: rgba(103, 112, 123, .22); border: 1px solid transparent;
cursor: pointer; transition: background .15s, transform .15s, box-shadow .15s; white-space: nowrap; }
.btn:hover { background: rgba(103, 112, 123, .4); color: var(--bright); }
.btn.primary { background: var(--action); box-shadow: 0 8px 28px rgba(26, 159, 255, .28); }
.btn.primary:hover { background: var(--action-hi); transform: translateY(-1px); }
/* The background shorthand resets this; without it the gradient repeats under the transparent border. */
.btn.primary, .btn.primary:hover { background-origin: border-box; }
.btn.ghost { background: transparent; border-color: var(--line); }
.btn.ghost:hover { border-color: rgba(143,152,160,.4); background: rgba(255,255,255,.03); }
.btn.small { height: 36px; padding: 0 14px; font-size: 14px; border-radius: 8px; }
.btn.coffee { background: #ff5e5b; box-shadow: 0 8px 24px rgba(255, 94, 91, .22); }
.btn.coffee:hover { background: #ff7471; }
.btn svg { width: 18px; height: 18px; flex: none; }
.btn[disabled] { opacity: .6; cursor: progress; transform: none; }
/* ---- hero ---- */
.hero { position: relative; padding: 88px 0 40px; overflow: hidden; text-align: center; }
.hero::before { content: ""; position: absolute; inset: -30% -10% auto; height: 900px; pointer-events: none;
background: radial-gradient(600px 380px at 30% 30%, rgba(26,159,255,.22), transparent 70%),
radial-gradient(520px 360px at 72% 20%, rgba(111,66,193,.2), transparent 70%); }
.hero > * { position: relative; }
.eyebrow { display: inline-block; max-width: 100%; padding: 6px 14px; border-radius: 999px; font-size: 13.5px;
color: var(--link); background: rgba(26,159,255,.1); border: 1px solid rgba(102,192,244,.22); margin-bottom: 26px; }
.eyebrow b { color: var(--bright); font-weight: 600; }
.eyebrow .plats { white-space: nowrap; }
@media (max-width: 520px) { .eyebrow { border-radius: 16px; } .eyebrow .sep { display: none; } .eyebrow .plats { display: block; white-space: normal; } }
.hero h1 { max-width: 880px; margin: 0 auto; }
.hero h1 span { background: linear-gradient(90deg, #66c0f4, #1a9fff 45%, #8a6cff); -webkit-background-clip: text; background-clip: text; color: transparent; }
.lede { max-width: 680px; margin: 22px auto 0; font-size: 19px; color: var(--text); }
.cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; margin-top: 34px; }
.fine { margin-top: 16px; font-size: 13.5px; color: var(--muted); }
.fine a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
.shot { margin: 64px auto 0; max-width: 1080px; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line);
box-shadow: 0 50px 120px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.02), 0 0 120px rgba(26,159,255,.12); }
.shot .bar { display: flex; gap: 7px; padding: 12px 14px; background: #10171f; border-bottom: 1px solid var(--line); }
.shot .bar i { width: 11px; height: 11px; border-radius: 50%; background: #2a3440; }
/* ---- sections ---- */
section { padding: 96px 0; }
section.alt { background: var(--bg-2); border-block: 1px solid var(--line); }
.head { max-width: 700px; margin: 0 auto 52px; text-align: center; }
.kicker { display: block; color: var(--link); font-size: 13px; font-weight: 650; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 12px; }
.head p { margin-top: 14px; font-size: 17.5px; color: var(--muted); }
.video { max-width: 1000px; margin: 0 auto; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #000;
box-shadow: 0 40px 100px rgba(0,0,0,.5); }
.video video { width: 100%; aspect-ratio: 16 / 9; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 24px; transition: border-color .2s, transform .2s; }
.card:hover { border-color: rgba(102,192,244,.3); transform: translateY(-2px); }
.card .ico { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; margin-bottom: 16px;
background: rgba(26,159,255,.12); color: var(--link); }
.card .ico svg { width: 22px; height: 22px; }
.card p { margin-top: 8px; color: var(--muted); font-size: 15px; }
.feature { display: grid; grid-template-columns: 1fr 1.35fr; gap: 56px; align-items: center; }
.feature + .feature { margin-top: 96px; }
.feature.flip { grid-template-columns: 1.35fr 1fr; }
.feature.flip .copy { order: 2; }
.feature .copy h2 { margin-top: 0; }
.feature .copy p { margin-top: 16px; color: var(--muted); font-size: 17px; }
.feature ul { margin: 20px 0 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.feature li { padding-left: 28px; position: relative; color: var(--text); }
.feature li::before { content: ""; position: absolute; left: 2px; top: 7px; width: 14px; height: 8px; border-left: 2px solid var(--link);
border-bottom: 2px solid var(--link); transform: rotate(-45deg); }
.feature img { border-radius: 12px; border: 1px solid var(--line); box-shadow: 0 30px 80px rgba(0,0,0,.45); }
@media (max-width: 880px) { .feature, .feature.flip { grid-template-columns: 1fr; gap: 28px; } .feature.flip .copy { order: 0; } }
.phones { display: flex; justify-content: center; gap: 28px; margin-top: 8px; }
.phones img { width: 260px; border-radius: 34px; border: 8px solid #0a0f15; box-shadow: 0 30px 80px rgba(0,0,0,.5), 0 0 0 1px var(--line); }
.phones img:last-child { transform: translateY(40px); }
@media (max-width: 600px) { .phones img { width: 44%; border-width: 5px; border-radius: 22px; } }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; counter-reset: step; }
.steps .card { position: relative; padding-top: 64px; }
.steps .card::before { counter-increment: step; content: counter(step); position: absolute; top: 22px; left: 24px; width: 30px; height: 30px;
border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 14px; color: var(--bright); background: var(--action); }
@media (max-width: 880px) { .steps { grid-template-columns: 1fr; } }
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
.dl { display: flex; flex-direction: column; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 26px; }
.dl.mine { border-color: rgba(26,159,255,.55); box-shadow: 0 0 0 1px rgba(26,159,255,.25), 0 20px 50px rgba(26,159,255,.1); }
.dl .os { display: flex; align-items: center; gap: 10px; }
.dl .os svg { width: 24px; height: 24px; color: var(--bright); }
.dl .need { color: var(--muted); font-size: 14px; }
.dl .links { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
.dl .links .btn { width: 100%; }
.dl .tag { margin-left: auto; font-size: 11.5px; font-weight: 650; letter-spacing: 1px; text-transform: uppercase; color: var(--link); }
.faq { max-width: 780px; margin: 0 auto; display: grid; gap: 10px; }
.faq details { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 0 22px; }
.faq summary { cursor: pointer; list-style: none; padding: 18px 0; color: var(--bright); font-weight: 600; display: flex; justify-content: space-between; gap: 16px; }
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after { content: "+"; color: var(--muted); font-size: 22px; line-height: 1; transition: transform .2s; }
.faq details[open] summary::after { transform: rotate(45deg); }
.faq details > div { padding: 0 0 20px; color: var(--muted); }
.faq details > div p + p { margin-top: 10px; }
.faq.notes section { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 18px 22px 20px; color: var(--muted); }
.faq.notes h2 { font-size: 16px; font-weight: 600; letter-spacing: 0; color: var(--bright); margin: 0 0 10px; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.split .card { padding: 36px; }
.split .card h3 { font-size: 24px; }
.split .card p { font-size: 16px; margin: 12px 0 24px; }
@media (max-width: 760px) { .split { grid-template-columns: 1fr; } }
/* ---- footer ---- */
footer { border-top: 1px solid var(--line); padding: 48px 0 56px; color: var(--dim); font-size: 14px; }
footer .wrap { display: flex; flex-wrap: wrap; gap: 24px 48px; justify-content: space-between; }
footer .cols { display: flex; gap: 12px 28px; flex-wrap: wrap; }
footer a { color: var(--muted); }
footer .legal { flex-basis: 100%; font-size: 13px; }
/* ---- feedback page ---- */
.page { padding: 72px 0 96px; }
.page .head { text-align: left; margin: 0 0 36px; max-width: none; }
.form-wrap { display: grid; grid-template-columns: 1fr 320px; gap: 28px; align-items: start; }
@media (max-width: 900px) { .form-wrap { grid-template-columns: 1fr; } }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 30px; }
aside.panel h3 { margin-bottom: 10px; }
aside.panel p { color: var(--muted); font-size: 15px; }
aside.panel p + h3 { margin-top: 26px; }
.field { display: grid; gap: 8px; margin-bottom: 22px; }
.field > label, .field > legend { color: var(--bright); font-weight: 600; font-size: 14.5px; padding: 0; }
.field > legend { margin-bottom: 10px; } /* fieldset grids ignore gap for the legend */
.field small { color: var(--muted); font-size: 13px; font-weight: 400; }
.row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
@media (max-width: 640px) { .row3 { grid-template-columns: 1fr; } }
fieldset { border: 0; padding: 0; margin: 0 0 22px; min-width: 0; }
input[type=text], textarea { width: 100%; font: 15.5px/1.5 var(--font); color: var(--bright); background: #0f161e; border: 1px solid rgba(143,152,160,.22);
border-radius: 9px; padding: 11px 13px; transition: border-color .15s, box-shadow .15s; }
input[type=text]::placeholder, textarea::placeholder { color: var(--dim); }
input[type=text]:focus, textarea:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(26,159,255,.2); }
textarea { min-height: 180px; resize: vertical; }
.kinds { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
@media (max-width: 640px) { .kinds { grid-template-columns: repeat(2, 1fr); } }
.kinds label { position: relative; cursor: pointer; }
.kinds input { position: absolute; opacity: 0; inset: 0; pointer-events: none; }
.kinds span { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 14px 8px; border-radius: 10px; font-size: 14px; font-weight: 600;
color: var(--muted); background: #0f161e; border: 1px solid rgba(143,152,160,.18); transition: all .15s; }
.kinds span svg { width: 20px; height: 20px; }
.kinds input:checked + span { color: var(--bright); border-color: var(--blue); background: rgba(26,159,255,.1); }
.kinds input:focus-visible + span { outline: 2px solid var(--link); outline-offset: 2px; }
.trap { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-top: 8px; }
.note { color: var(--muted); font-size: 13.5px; }
.alert { border-radius: 10px; padding: 14px 16px; margin-bottom: 20px; font-size: 15px; }
.alert.err { background: rgba(224,87,60,.12); border: 1px solid rgba(224,87,60,.4); color: #ffb4a6; }
.alert.err a { color: #ffd2c9; text-decoration: underline; }
.done { text-align: center; padding: 40px 10px; }
.done .tick { width: 64px; height: 64px; border-radius: 50%; margin: 0 auto 20px; display: grid; place-items: center; background: rgba(117,176,34,.15); color: #a4d007; }
.done .tick svg { width: 30px; height: 30px; }
.done p { color: var(--muted); margin: 10px auto 24px; max-width: 460px; }
.done .cta { margin-top: 0; }
.page h1 { font-size: clamp(34px, 5vw, 48px); }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+143
View File
@@ -0,0 +1,143 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Send feedback · Frame Control</title>
<meta name="description" content="Report a bug, suggest an idea or ask a question about Frame Control. No GitHub account needed.">
<meta name="theme-color" content="#0e141b">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/css/site.css">
<script src="/js/site.js" defer></script>
<script src="/js/feedback.js" defer></script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<nav aria-label="Sections">
<a href="/#features">Features</a>
<a href="/#setup">Setup</a>
<a href="/#download">Download</a>
<a href="/#faq">FAQ</a>
<a href="/feedback/" aria-current="page">Feedback</a>
</nav>
<div class="end">
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
</div>
</div>
</header>
<main class="page">
<div class="wrap">
<div class="head">
<span class="kicker">Feedback</span>
<h1>Tell us what happened</h1>
<p>Bugs, ideas and questions all help. What you send becomes a public issue on GitHub, where you can follow it.</p>
</div>
<div class="form-wrap">
<div>
<form class="panel" id="feedback" novalidate>
<div class="alert err" id="error" role="alert" hidden></div>
<fieldset class="field">
<legend>What kind of feedback?</legend>
<div class="kinds">
<label><input type="radio" name="kind" value="bug" checked><span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2l1.9 1.9M16 2l-1.9 1.9M9 7.1V6a3 3 0 1 1 6 0v1.1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6zM12 20v-9M6.5 13H3M21 13h-3.5M6 9 3.5 7M18 9l2.5-2M6 17l-2.5 2M18 17l2.5 2"/></svg>Bug</span></label>
<label><input type="radio" name="kind" value="idea"><span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7V16h8v-1.3A7 7 0 0 0 12 2z"/></svg>Idea</span></label>
<label><input type="radio" name="kind" value="question"><span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/></svg>Question</span></label>
<label><input type="radio" name="kind" value="other"><span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/></svg>Other</span></label>
</div>
</fieldset>
<div class="field">
<label for="title">Title</label>
<input type="text" id="title" name="title" maxlength="120" required placeholder="e.g. Live view stops after a minute on Windows">
</div>
<div class="field">
<label for="message">Details <small id="message-hint">What you tried, what happened, and what you expected.</small></label>
<textarea id="message" name="message" maxlength="5000" required></textarea>
</div>
<div class="row3">
<div class="field">
<label for="os">Your computer</label>
<input type="text" id="os" name="os" maxlength="120" placeholder="e.g. Windows 11">
</div>
<div class="field">
<label for="version">App version</label>
<input type="text" id="version" name="version" maxlength="120" placeholder="e.g. v0.3.1">
</div>
<div class="field">
<label for="steamos">SteamOS build</label>
<input type="text" id="steamos" name="steamos" maxlength="120" placeholder="Steam Settings → System">
</div>
</div>
<div class="field">
<label for="github">GitHub username <small>Optional. Add it to get notified when someone replies.</small></label>
<input type="text" id="github" name="github" maxlength="40" autocomplete="username" placeholder="@yourname">
</div>
<div class="trap" aria-hidden="true">
<label for="website">Leave this empty</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="actions">
<button class="btn primary" type="submit" id="send">Send feedback</button>
<span class="note">Everything you send is public. Don't include passwords, IP addresses or personal details.</span>
</div>
</form>
<div class="panel done" id="done" hidden>
<div class="tick"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 5 5L20 7"/></svg></div>
<h2>Thanks, that's sent</h2>
<p id="done-text">Your feedback is now an issue on GitHub.</p>
<div class="cta">
<a class="btn primary" id="issue-link" href="https://github.com/saphid/frame-control/issues" hidden>View your issue</a>
<a class="btn ghost" href="/feedback/">Send more</a>
</div>
</div>
</div>
<aside class="panel">
<h3>Reporting a bug?</h3>
<p>The server log helps most: in the app, choose <b>Frame → Show Server Log</b> and paste the last few lines into Details.</p>
<h3>Prefer GitHub?</h3>
<p>You can <a href="https://github.com/saphid/frame-control/issues/new/choose">open an issue there directly</a>. First-time issues are closed until a maintainer reviews them; see <a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a>. This form skips that queue.</p>
<h3 data-needs-kofi>Enjoying it?</h3>
<p data-needs-kofi>Frame Control is free. <a data-kofi href="#" target="_blank" rel="noopener">Buy me a coffee</a> if it saved you some time.</p>
</aside>
</div>
</div>
</main>
<footer>
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<div class="cols">
<a href="https://github.com/saphid/frame-control">GitHub</a>
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
<a href="/feedback/">Feedback</a>
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
<a href="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
</div>
</footer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+313
View File
@@ -0,0 +1,313 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Frame Control: manage your Steam Frame from your computer</title>
<meta name="description" content="Free, open-source app for the Valve Steam Frame. See what the headset sees, install Steam games and Android apps, move files and text across, and check battery and status. macOS, Windows, Linux and iPhone.">
<meta name="theme-color" content="#0e141b">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta property="og:type" content="website">
<meta property="og:title" content="Frame Control">
<meta property="og:description" content="Manage your Valve Steam Frame from your computer. Free and open source.">
<meta property="og:image" content="/img/og.jpg">
<meta name="twitter:card" content="summary_large_image">
<link rel="stylesheet" href="/css/site.css">
<script src="/js/site.js" defer></script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<nav aria-label="Sections">
<a href="#features">Features</a>
<a href="#setup">Setup</a>
<a href="#download">Download</a>
<a href="#faq">FAQ</a>
<a href="/feedback/">Feedback</a>
</nav>
<div class="end">
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
</div>
</div>
</header>
<main>
<section class="hero">
<div class="wrap">
<span class="eyebrow"><b>Free and open source</b><span class="sep"> · </span><span class="plats">macOS · Windows · Linux · iPhone</span></span>
<h1>Your Steam Frame, <span>managed from your desk.</span></h1>
<p class="lede">See what the headset sees, install games and Android apps, move files and text across, and keep an eye on battery and status. All over SSH, with nothing to install on the Frame.</p>
<div class="cta">
<a class="btn primary" id="hero-download" href="#download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 20h16"/></svg>
<span data-label>Download Frame Control</span>
</a>
<a class="btn ghost" href="#trailer">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg>
Watch the trailer
</a>
</div>
<p class="fine"><span hidden>Latest: <span data-version></span> · </span><a href="#download">All platforms</a> · MIT licence · Not affiliated with Valve</p>
<div class="shot">
<div class="bar"><i></i><i></i><i></i></div>
<img src="/img/home-live.jpg" width="1600" height="1000" alt="Frame Control's Home tab: a live view of the headset beside battery, storage, temperature and Wi-Fi.">
</div>
</div>
</section>
<section id="trailer" class="alt">
<div class="wrap">
<div class="head">
<span class="kicker">Trailer</span>
<h2>Sixty-six seconds of Frame Control</h2>
<p>Recorded against a real Steam Frame. Turn the sound on.</p>
</div>
<div class="video">
<video controls playsinline preload="none" poster="/media/poster.jpg">
<source src="/media/trailer.mp4" type="video/mp4">
</video>
</div>
</div>
</section>
<section id="features">
<div class="wrap">
<div class="head">
<span class="kicker">Features</span>
<h2>Everything the headset needs, one window away</h2>
<p>Frame Control uses what SteamOS already ships. It only changes what you click.</p>
</div>
<div class="grid">
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="10" rx="4"/><circle cx="8" cy="12" r="2"/><circle cx="16" cy="12" r="2"/></svg></div>
<h3>Headset view</h3>
<p>Live video of what the lenses show at about 30 fps, or a still of both eyes. Zoom, pan, full screen, save as PNG.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="17" height="10" rx="2"/><path d="M22 11v2M6 10v4M9.5 10v4"/></svg></div>
<h3>Battery and status</h3>
<p>Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 11h4M8 9v4M15 12h.01M18 10h.01"/><path d="M17.3 5H6.7a4 4 0 0 0-3.98 3.59l-.7 7A3 3 0 0 0 5 19c1 0 1.5-.5 2-1l1.4-1.4A2 2 0 0 1 9.8 16h4.4a2 2 0 0 1 1.4.6L17 18c.5.5 1 1 2 1a3 3 0 0 0 2.98-3.41l-.7-7A4 4 0 0 0 17.3 5z"/></svg></div>
<h3>Steam games</h3>
<p>Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="8" width="14" height="12" rx="3"/><path d="M8 8a4 4 0 0 1 8 0M8 4l1.5 2M16 4l-1.5 2M10 13h.01M14 13h.01"/></svg></div>
<h3>Android apps</h3>
<p>About 4,500 F-Droid apps rated for the Frame. One click installs each as its own app in your Steam library.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M12 18v-6m0 0-3 3m3-3 3 3"/></svg></div>
<h3>Files, games and clipboard</h3>
<p>Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library. Send your clipboard to the headset's desktop.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8a2 2 0 0 1 2-2h2l2-2h4l2 2h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><circle cx="12" cy="13" r="3.5"/></svg></div>
<h3>Screenshots</h3>
<p>Browse the shots you take in the headset and save them straight to your Pictures folder.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M17.5 14v7M14 17.5h7"/></svg></div>
<h3>Flatpaks and display</h3>
<p>Install desktop apps like Moonlight or VLC, and set each Android app's resolution and text size.</p>
</div>
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4 14h7l-1 8 9-12h-7z"/></svg></div>
<h3>One-click tools</h3>
<p>SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.</p>
</div>
</div>
</div>
</section>
<section class="alt">
<div class="wrap">
<div class="feature">
<div class="copy">
<span class="kicker">Games</span>
<h2>Your Steam library, rated for the Frame</h2>
<p>See every game you own with how well it runs on the Frame, then install it onto the headset without putting it on.</p>
<ul>
<li>Live download progress, straight from the Steam client on the headset</li>
<li>Search the Steam store from your computer</li>
<li>Sideload your own Linux or Windows games, with Proton or the Linux runtime picked for you</li>
</ul>
</div>
<img src="/img/games.jpg" width="1600" height="1000" loading="lazy" alt="The Games tab: owned games with Steam Frame ratings and install buttons.">
</div>
<div class="feature flip">
<div class="copy">
<span class="kicker">Android</span>
<h2>Android apps, one click each</h2>
<p>A catalogue of about 4,500 F-Droid apps, each tested and rated on a real Frame. Every app gets its own Lepton instance and its own tile in your Steam library.</p>
<ul>
<li>Filter by what works on the Frame</li>
<li>Install any APK you have, no Android SDK needed</li>
<li>Set resolution and text size per app</li>
</ul>
</div>
<img src="/img/android.jpg" width="1600" height="1000" loading="lazy" alt="The Android tab: installed apps and the rated F-Droid catalogue.">
</div>
<div class="feature">
<div class="copy">
<span class="kicker">Tools</span>
<h2>The fiddly bits, done for you</h2>
<p>Open an SSH session or SFTP, start Steam Link or remote desktop, change the volume, or put the headset to sleep, all from one tab.</p>
</div>
<img src="/img/tools.jpg" width="1600" height="600" loading="lazy" alt="The Tools tab: SSH, SFTP, Steam Link, remote desktop and power controls.">
</div>
</div>
</section>
<section>
<div class="wrap">
<div class="head">
<span class="kicker">iPhone and iPad</span>
<h2>Also in your pocket</h2>
<p>The same features on iPhone and iPad, served from the Frame itself, so there's nothing to run on a computer. Build it from the repo in Xcode.</p>
</div>
<div class="phones">
<img src="/img/phone-home.jpg" width="600" height="1304" loading="lazy" alt="Frame Control on iPhone: headset view and device status.">
<img src="/img/phone-games.jpg" width="600" height="1304" loading="lazy" alt="Frame Control on iPhone: the Games tab.">
</div>
</div>
</section>
<section id="setup" class="alt">
<div class="wrap">
<div class="head">
<span class="kicker">Setup</span>
<h2>One password, typed once</h2>
<p>Everything else happens on your computer.</p>
</div>
<div class="steps">
<div class="card">
<h3>Turn on Developer Mode</h3>
<p>On the Frame: Steam Settings → System → <b>Enable Developer Mode</b>, then <b>Set User Password</b> in the Developer section.</p>
</div>
<div class="card">
<h3>Set up the connection</h3>
<p>Open Frame Control and choose <b>Set Up Connection</b>. It finds the headset, makes an SSH key, and asks for that password once.</p>
</div>
<div class="card">
<h3>That's it</h3>
<p>The app reaches the headset whenever it's awake and on the same network. For anywhere else, use <a href="https://github.com/saphid/frame-control/blob/main/docs/tailscale.md">Tailscale</a>.</p>
</div>
</div>
</div>
</section>
<section id="download">
<div class="wrap">
<div class="head">
<span class="kicker">Download</span>
<h2>Get Frame Control</h2>
<p>Free, with Python and adb built in. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
</div>
<div class="downloads">
<div class="dl" data-os="mac">
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.37 12.6c-.02-2.2 1.8-3.26 1.88-3.31-1.02-1.5-2.62-1.7-3.19-1.72-1.36-.14-2.65.8-3.34.8-.69 0-1.75-.78-2.88-.76a4.27 4.27 0 0 0-3.6 2.19c-1.53 2.66-.39 6.6 1.1 8.76.73 1.06 1.6 2.24 2.74 2.2 1.1-.04 1.51-.71 2.84-.71 1.32 0 1.7.71 2.86.69 1.18-.02 1.93-1.08 2.65-2.14.83-1.23 1.18-2.41 1.2-2.47-.03-.01-2.3-.88-2.32-3.5zM14.2 6.13c.6-.73 1.01-1.75.9-2.76-.87.04-1.92.58-2.54 1.3-.56.64-1.05 1.67-.92 2.66.97.08 1.96-.49 2.56-1.2z"/></svg><h3>macOS</h3><span class="tag" hidden>Your system</span></div>
<span class="need">Apple Silicon</span>
<div class="links">
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg">Download .dmg</a>
</div>
</div>
<div class="dl" data-os="windows">
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5 10 4.5v7H3zM11 4.35 21 3v8.5H11zM3 12.5h7v7L3 18.5zM11 12.5h10V21l-10-1.4z"/></svg><h3>Windows</h3><span class="tag" hidden>Your system</span></div>
<span class="need">Windows 10 or 11, x64</span>
<div class="links">
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe">Download installer</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip">Portable .zip</a>
</div>
</div>
<div class="dl" data-os="linux">
<div class="os"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/></svg><h3>Linux</h3><span class="tag" hidden>Your system</span></div>
<span class="need">x64 or arm64 · needs <code>ssh</code></span>
<div class="links">
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage">AppImage (x64)</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb">.deb (x64)</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage">AppImage (arm64)</a>
</div>
</div>
</div>
</div>
</section>
<section id="faq" class="alt">
<div class="wrap">
<div class="head">
<span class="kicker">FAQ</span>
<h2>Good questions</h2>
</div>
<div class="faq">
<details>
<summary>macOS says the app is damaged or can't be checked</summary>
<div><p>The app isn't notarized, because there's no paid Apple developer account behind it. Drag it to Applications, then clear the download quarantine once in Terminal:</p>
<p><code>xattr -dr com.apple.quarantine "/Applications/Frame Control.app"</code></p></div>
</details>
<details>
<summary>Windows SmartScreen says it protected my PC</summary>
<div><p>The installer isn't code-signed. Choose <b>More info → Run anyway</b>, or use the portable .zip: unzip it anywhere and run <code>Frame Control.exe</code>.</p></div>
</details>
<details>
<summary>What does it change on my headset?</summary>
<div><p>Only what you click. Installs go to your user account on the Frame, and nothing needs <code>sudo</code> except the power buttons. On your computer it adds a <code>Host frame</code> entry to <code>~/.ssh/config</code> and two SSH keys.</p></div>
</details>
<details>
<summary>Is it safe to leave Developer Mode on?</summary>
<div><p>With Developer Mode on, SSH, ADB and remote desktop are reachable on your local network. Use trusted networks, turn Developer Mode off when you don't need it, and never port-forward those ports from your router. For remote access, use Tailscale. <a href="https://github.com/saphid/frame-control#readme">Security notes</a></p></div>
</details>
<details>
<summary>Is this made by Valve?</summary>
<div><p>No. It's an unofficial hobby project, free and MIT-licensed. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p></div>
</details>
</div>
</div>
</section>
<section>
<div class="wrap split">
<div class="card">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/></svg></div>
<h3>Tell us what you think</h3>
<p>Found a bug, or want something added? Reports from Windows and Linux are especially useful. No GitHub account needed.</p>
<a class="btn primary" href="/feedback/">Send feedback</a>
</div>
<div class="card">
<div class="ico" style="background:rgba(255,94,91,.14);color:#ff8a87"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4zM6 2v3M10 2v3M14 2v3"/></svg></div>
<h3 data-needs-kofi>Buy me a coffee</h3>
<h3 data-no-kofi hidden>Like it? Pass it on</h3>
<p data-needs-kofi>Frame Control is free and always will be. If it saves you time, a coffee keeps it going.</p>
<p data-no-kofi hidden>Frame Control is free and always will be. The best way to help is to star the repo and tell a friend.</p>
<a class="btn coffee" data-kofi href="#" target="_blank" rel="noopener">Support on Ko-fi</a>
<a class="btn ghost" data-no-kofi hidden href="https://github.com/saphid/frame-control">Star on GitHub</a>
</div>
</div>
</section>
</main>
<footer>
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<div class="cols">
<a href="https://github.com/saphid/frame-control">GitHub</a>
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
<a href="/feedback/">Feedback</a>
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
<a href="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
</div>
</footer>
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
// The feedback form: posts to /api/feedback (site/functions/api/feedback.js), which opens a GitHub issue.
const form = document.getElementById("feedback");
const errorBox = document.getElementById("error");
const send = document.getElementById("send");
const started = performance.now();
const HINTS = {
bug: "What you tried, what happened, and what you expected.",
idea: "What you'd like, and what it would help you do.",
question: "What you'd like to know.",
other: "Anything you'd like to tell us.",
};
// Prefill the computer field; people rarely know the exact wording otherwise.
(function guessOs() {
const ua = navigator.userAgent;
const guess = /Windows/.test(ua) ? "Windows" : /Macintosh/.test(ua) ? "macOS" : /iPhone|iPad/.test(ua) ? "iOS"
: /Android/.test(ua) ? "" : /Linux/.test(ua) ? "Linux" : "";
if (guess) form.elements.os.value = guess;
})();
form.addEventListener("change", (e) => {
if (e.target.name === "kind") document.getElementById("message-hint").textContent = HINTS[e.target.value];
});
// A prefilled GitHub issue form (.github/ISSUE_TEMPLATE), for when this form can't reach GitHub itself.
function githubUrl(data) {
const params = data.kind === "idea"
? new URLSearchParams({ template: "idea.yml", title: data.title, what: data.message })
: new URLSearchParams({ template: "bug.yml", title: data.title, description: data.message,
version: data.version, os: [data.os, data.steamos && `SteamOS ${data.steamos}`].filter(Boolean).join(", ") });
return `https://github.com/saphid/frame-control/issues/new?${params}`;
}
function showError(message, data) {
errorBox.textContent = message + " ";
if (data) {
const a = document.createElement("a");
a.href = githubUrl(data);
a.target = "_blank";
a.rel = "noopener";
a.textContent = "Open it on GitHub instead";
errorBox.append(a);
}
errorBox.hidden = false;
errorBox.scrollIntoView({ behavior: "smooth", block: "center" });
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorBox.hidden = true;
const data = Object.fromEntries(new FormData(form));
if (data.title.trim().length < 5) {
form.elements.title.focus();
return showError("Give it a short title (at least 5 characters).");
}
if (data.message.trim().length < 10) {
form.elements.message.focus();
return showError("Tell us a little more in Details.");
}
send.disabled = true;
send.textContent = "Sending…";
try {
// The server treats anything sent sooner as a script (site/lib/feedback.js MIN_FILL_MS).
const wait = 3100 - (performance.now() - started);
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...data, elapsed: Math.round(performance.now() - started) }),
});
const reply = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(reply.error || "Something went wrong sending that.");
form.hidden = true;
document.getElementById("done").hidden = false;
if (reply.url) {
const link = document.getElementById("issue-link");
link.href = reply.url;
link.hidden = false;
document.getElementById("done-text").textContent = `Your feedback is now issue #${reply.number} on GitHub.`
+ (data.github ? " You'll be notified when someone replies." : " Bookmark it to follow along.");
}
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (err) {
showError(err.message || "Couldn't reach the server.", data);
} finally {
send.disabled = false;
send.textContent = "Send feedback";
}
});
+63
View File
@@ -0,0 +1,63 @@
// Shared by every page. Change the settings here, not in the HTML.
const SITE = {
repo: "saphid/frame-control",
// Ko-fi page name, the part after ko-fi.com/. Donate buttons stay hidden while it's empty.
kofi: "alexsouthwell",
};
const RELEASE = `https://github.com/${SITE.repo}/releases/latest/download/`;
// Donate buttons.
for (const el of document.querySelectorAll("[data-kofi]")) {
if (SITE.kofi) el.href = `https://ko-fi.com/${SITE.kofi}`;
else el.hidden = true;
}
for (const el of document.querySelectorAll("[data-needs-kofi]")) el.hidden = !SITE.kofi;
for (const el of document.querySelectorAll("[data-no-kofi]")) el.hidden = !!SITE.kofi;
// Best guess at the visitor's platform, for the hero button and the download cards.
function detectPlatform() {
const ua = navigator.userAgent;
const hint = navigator.userAgentData?.platform || navigator.platform || "";
if (/iPhone|iPad|iPod/.test(ua) || (/Mac/.test(hint) && navigator.maxTouchPoints > 1)) return "ios";
if (/Android/.test(ua)) return null;
if (/Mac/.test(hint) || /Macintosh/.test(ua)) return "mac";
if (/Win/.test(hint) || /Windows/.test(ua)) return "windows";
if (/Linux/.test(hint) || /Linux/.test(ua)) return /aarch64|arm64/i.test(ua + hint) ? "linux-arm" : "linux";
return null;
}
const PLATFORMS = {
mac: { name: "macOS", file: "Frame-Control-mac-arm64.dmg" },
windows: { name: "Windows", file: "Frame-Control-Setup-x64.exe" },
linux: { name: "Linux", file: "Frame-Control-linux-x86_64.AppImage" },
"linux-arm": { name: "Linux (arm64)", file: "Frame-Control-linux-arm64.AppImage" },
};
const platform = detectPlatform();
const hero = document.getElementById("hero-download");
if (hero && PLATFORMS[platform]) {
hero.href = RELEASE + PLATFORMS[platform].file;
hero.querySelector("[data-label]").textContent = `Download for ${PLATFORMS[platform].name}`;
}
const card = platform && document.querySelector(`.dl[data-os="${platform.replace("-arm", "")}"]`);
if (card) {
card.classList.add("mine");
card.querySelector(".tag").hidden = false;
}
// Latest version number, so the page never goes stale. Fails quietly.
const versionEls = document.querySelectorAll("[data-version]");
if (versionEls.length) {
fetch(`https://api.github.com/repos/${SITE.repo}/releases/latest`, { headers: { accept: "application/vnd.github+json" } })
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((release) => {
for (const el of versionEls) {
el.textContent = release.tag_name;
el.closest("[hidden]")?.removeAttribute("hidden");
}
})
.catch(() => {});
}
document.querySelectorAll("[data-year]").forEach((el) => (el.textContent = new Date().getFullYear()));
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Privacy · Frame Control</title>
<meta name="description" content="What the Frame Control website and app collect.">
<meta name="theme-color" content="#0e141b">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/css/site.css">
<script src="/js/site.js" defer></script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<nav aria-label="Sections">
<a href="/#features">Features</a>
<a href="/#setup">Setup</a>
<a href="/#download">Download</a>
<a href="/#faq">FAQ</a>
<a href="/feedback/">Feedback</a>
</nav>
<div class="end">
<a class="btn small ghost" href="https://github.com/saphid/frame-control">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
<a class="btn small coffee" data-kofi href="#" target="_blank" rel="noopener">Support</a>
</div>
</div>
</header>
<main class="page">
<div class="wrap" style="max-width:780px">
<div class="head">
<span class="kicker">Privacy</span>
<h1>Privacy</h1>
<p>Short version: the app collects nothing, and the website keeps only what you choose to send.</p>
</div>
<div class="faq notes">
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no analytics and no accounts.</p></section>
<section><h2>The feedback form</h2><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>. To stop abuse, the form keeps a one-way hash of your IP address for about an hour to count submissions. The address itself isn't stored or published.</p></section>
<section><h2>This website</h2><p>Hosted on Cloudflare Pages. No cookies, no analytics, no trackers. The download section asks GitHub for the latest version number. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></section>
</div>
</div>
</main>
<footer>
<div class="wrap">
<a class="brand" href="/"><img src="/img/icon.png" alt="">Frame Control</a>
<div class="cols">
<a href="https://github.com/saphid/frame-control">GitHub</a>
<a href="https://github.com/saphid/frame-control/releases">Releases</a>
<a href="https://github.com/saphid/frame-control/blob/main/docs/frame-control.md">Docs</a>
<a href="/feedback/">Feedback</a>
<a href="https://github.com/saphid/frame-control/blob/main/CONTRIBUTING.md">Contributing</a>
<a href="/privacy/">Privacy</a>
</div>
<p class="legal">© <span data-year>2026</span> saphid · MIT licence. Unofficial and not affiliated with or endorsed by Valve. Steam, Steam Frame and SteamVR are trademarks of Valve Corporation.</p>
</div>
</footer>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+74
View File
@@ -0,0 +1,74 @@
// node --test site/test/*.test.mjs
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildIssue, defang, hashIp, MIN_FILL_MS, validate } from "../lib/feedback.js";
const form = (over = {}) => ({
kind: "bug",
title: "Live view freezes",
message: "After about a minute the live view stops updating.",
elapsed: MIN_FILL_MS + 1,
...over,
});
test("accepts a normal report and trims it", () => {
const { value, error } = validate(form({ title: " Live view freezes ", os: " macOS 26 " }));
assert.equal(error, undefined);
assert.equal(value.title, "Live view freezes");
assert.equal(value.os, "macOS 26");
assert.equal(value.kind, "bug");
});
test("flags the honeypot as spam and asks fast senders to retry", () => {
assert.deepEqual(validate(form({ website: "http://spam" })), { spam: true });
assert.match(validate(form({ elapsed: 500 })).error, /again/);
assert.deepEqual(validate(form({ elapsed: undefined })), { spam: true });
});
test("rejects short, long and malformed input", () => {
assert.match(validate(form({ title: "hi" })).error, /title/);
assert.match(validate(form({ message: "short" })).error, /more/);
assert.match(validate(form({ message: "x".repeat(5001) })).error, /under/);
assert.match(validate(form({ github: "not a user!" })).error, /GitHub/);
assert.match(validate(null).error, /JSON/);
});
test("unknown kinds become other feedback", () => {
assert.equal(validate(form({ kind: "__proto__" })).value.kind, "other");
});
test("builds a labelled issue that credits a GitHub user", () => {
const { value } = validate(form({ github: "@octocat", version: "0.3.1", steamos: "20260922" }));
const issue = buildIssue(value);
assert.equal(issue.title, "Bug report: Live view freezes");
assert.deepEqual(issue.labels, ["feedback", "bug"]);
assert.match(issue.body, /\| Frame Control version \| 0\.3\.1 \|/);
assert.match(issue.body, /^> Sent from the website feedback form by @octocat\./);
assert.ok(issue.body.endsWith(value.message));
});
test("anonymous feedback says replies won't reach the sender", () => {
const issue = buildIssue(validate(form({ kind: "other" })).value);
assert.deepEqual(issue.labels, ["feedback"]);
assert.match(issue.body, /won't see replies/);
});
test("breaks mentions, issue refs and table cells in user text", () => {
assert.equal(defang("ping @valve about #12"), "ping @\u200bvalve about #\u200b12");
assert.equal(defang("email me@example.com"), "email me@\u200bexample.com");
assert.equal(defang("see valve/steam#7 and GH-8"), "see valve/steam#\u200b7 and GH\u200b-8");
assert.equal(defang("&commat;valve &#64;valve &num;3"), "&amp;commat;valve &amp;#\u200b64;valve &amp;num;3");
assert.equal(defang("end <!--"), "end &lt;!--");
assert.equal(defang("GH\\-1 github\\.com"), "GH\\\\-1 github\\\\.com");
assert.equal(defang("https://github.com/a/b/issues/1"), "https://github\u200b.com/a/b/issues/1");
const issue = buildIssue(validate(form({ os: "a | b" })).value);
assert.match(issue.body, /\| a \\\| b \|/);
});
test("hashes IPs without keeping them", async () => {
const a = await hashIp("203.0.113.9", "salt");
assert.equal(a.length, 24);
assert.equal(a, await hashIp("203.0.113.9", "salt"));
assert.notEqual(a, await hashIp("203.0.113.10", "salt"));
assert.ok(!a.includes("203"));
});
+12
View File
@@ -0,0 +1,12 @@
# Cloudflare Pages project for the Frame Control website. Deploy: see site/README.md.
name = "frame-control"
pages_build_output_dir = "public"
compatibility_date = "2026-07-01"
[vars]
GITHUB_REPO = "saphid/frame-control"
# Rate limits for /api/feedback (site/functions/api/feedback.js).
[[kv_namespaces]]
binding = "FEEDBACK_RL"
id = "4852de5aae9e4d9f989f6dc2bc3b2b6b"
+43
View File
@@ -0,0 +1,43 @@
// Run the actual page script with a tiny DOM/fetch fixture; no browser dependency.
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const elements = new Map();
const events = new Map();
const requests = [];
const element = id => {
if (!elements.has(id)) elements.set(id, {value:'', checked:false, disabled:false, textContent:'',
addEventListener(){}, reset(){}});
return elements.get(id);
};
const context = {
document:{getElementById:element}, location:{hash:''}, URLSearchParams,
window:{addEventListener:(name, fn) => events.set(name, fn)},
fetch:(path, options) => new Promise(resolve => requests.push({path, options, resolve})),
};
const html = fs.readFileSync(process.argv[2], 'utf8');
vm.runInNewContext(html.match(/<script>([\s\S]*?)<\/script>/)[1].replace('__FRAME_KEY__', '"test"'), context);
const answer = (index, data) => requests[index].resolve({ok:true,json:async () => data});
(async () => {
context.location.hash = '#confirm=first';
const first = events.get('hashchange')();
context.location.hash = '#confirm=second';
const second = events.get('hashchange')();
answer(1, {action:{name:'second'},approved:false});
await second;
answer(0, {action:{name:'first'},approved:false});
await first;
assert.match(element('action').textContent, /second/);
assert.doesNotMatch(element('action').textContent, /first/);
const approved = element('approve').onclick();
assert.equal(JSON.parse(requests[2].options.body).confirmation, 'second');
context.location.hash = '#confirm=third';
const third = events.get('hashchange')();
answer(3, {action:{name:'third'},approved:false});
await third;
answer(2, {message:'Approved for one use'});
await approved;
assert.equal(element('approval-status').textContent, '');
assert.match(element('action').textContent, /third/);
console.log('Approval navigation races: pass');
})().catch(error => { console.error(error); process.exitCode=1; });
+46
View File
@@ -0,0 +1,46 @@
"""Real HTTP/MCP adapter against fake-Frame SSH; no model service needed."""
import json
from pathlib import Path
import sys
import harness
from harness import api, ok, finished, ssh
sys.path.insert(0, str(harness.ROOT / 'ui'))
import frame_mcp
class Agents(harness.FrameTestCase):
def client(self):
return frame_mcp.Client('http://127.0.0.1:%d' % harness.Server.port)
def call(self, name, args):
return json.loads(frame_mcp.call(self.client(), name, args)['content'][0]['text'])
def approve(self, proposal):
ok('POST', '/api/agent/approval', {'confirmation': proposal['confirmation'], 'accept': True})
return proposal['confirmation']
def test_status_and_approved_install_job(self):
self.assertIn('battery', self.call('status', {}))
proposal = self.call('install', {'id': 'org.example.AgentTest'})
before = api('POST', '/api/agent/call', {'name': 'install', 'arguments': {'id': 'org.example.AgentTest'}, 'confirmation': proposal['confirmation']})
self.assertEqual(before[0], 400)
token = self.approve(proposal)
job = self.call('install', {'id': 'org.example.AgentTest', 'confirmation': token})
self.assertFalse(finished(job).get('error'))
self.assertIn('org.example.AgentTest', ssh('flatpak list --app --columns=application'))
denied = api('POST', '/api/agent/call', {'name': 'install', 'arguments': {'id': 'org.example.AgentTest'}, 'confirmation': token})
self.assertEqual(denied[0], 400)
def test_approved_file_and_text(self):
path = Path(self.path('agent-note.txt'))
path.write_text('MCP file content\n')
args = {'path': str(path)}
token = self.approve(self.call('send_file', args))
self.call('send_file', {**args, 'confirmation': token})
self.assertEqual(ssh('cat ~/Downloads/agent-note.txt'), path.read_text())
args = {'text': 'MCP clipboard text'}
token = self.approve(self.call('send_text', args))
self.call('send_text', {**args, 'confirmation': token})
self.assertEqual(harness.state()['clipboard'], ['MCP clipboard text'])
+16
View File
@@ -0,0 +1,16 @@
"""Imported first by every test module: nothing a test does reaches this person's
app data, their telemetry, or the shared compatibility database.
Must run before any ui module is imported, since those read these at import time.
"""
import atexit
import os
import shutil
import tempfile
_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
atexit.register(shutil.rmtree, _dir, ignore_errors=True)
os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
# A maintainer's machine holds the database key; send anything that slips through nowhere.
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
+253
View File
@@ -0,0 +1,253 @@
"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
import io
import json
import os
import shutil
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import unittest
from unittest import mock
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_agent as agent
import frame_assistant as assistant
import frame_mcp as mcp
import server
class Approvals(unittest.TestCase):
def test_requires_human_decision_exact_action_and_single_use(self):
gate = agent.Approvals()
action = {'name': 'power', 'arguments': {'action': 'reboot'}}
token = gate.request(action)['confirmation']
with self.assertRaises(ValueError):
gate.consume(token, action)
gate.decide(token, True)
with self.assertRaises(ValueError):
gate.consume(token, {'name': 'power', 'arguments': {'action': 'poweroff'}})
gate.consume(token, action)
with self.assertRaises(ValueError):
gate.consume(token, action)
def test_expiry_rejection_and_non_boolean_approval(self):
gate = agent.Approvals()
token = gate.request({})['confirmation']
gate.decide(token, 'true')
with self.assertRaises(ValueError):
gate.inspect(token)
token = gate.request({})['confirmation']
with mock.patch.object(agent.time, 'monotonic', return_value=float('inf')):
with self.assertRaises(ValueError):
gate.decide(token, True)
def test_concurrent_consumption_executes_once(self):
gate = agent.Approvals()
token = gate.request({})['confirmation']
gate.decide(token, True)
results = []
def consume():
try:
gate.consume(token, {})
results.append(True)
except ValueError:
results.append(False)
threads = [threading.Thread(target=consume) for _ in range(8)]
for thread in threads: thread.start()
for thread in threads: thread.join()
self.assertEqual(results.count(True), 1)
def test_action_never_runs_before_approval(self):
with mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'flatpak') as install:
body = {'name': 'install', 'arguments': {'id': 'org.example.App'}}
result = agent.call(server, body)
install.assert_not_called()
body['confirmation'] = result['confirmation']
with self.assertRaises(ValueError): agent.call(server, body)
agent.approvals.decide(body['confirmation'], True)
agent.call(server, body)
install.assert_called_once_with({'id': 'org.example.App', 'action': 'install'})
with self.assertRaises(ValueError): agent.call(server, body)
def test_file_content_change_invalidates_approval(self):
with tempfile.TemporaryDirectory() as tmp, mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'push_file') as push:
path = Path(tmp) / 'note.txt'
path.write_text('first')
body = {'name': 'send_file', 'arguments': {'path': str(path)}}
result = agent.call(server, body)
agent.approvals.decide(result['confirmation'], True)
body['confirmation'] = result['confirmation']
path.write_text('second')
with self.assertRaises(ValueError): agent.call(server, body)
push.assert_not_called()
def test_no_arbitrary_commands_or_arguments(self):
for name, args in [('shell', {'command': 'true'}), ('panel', {'id': 'org.example.App', 'args': '--evil'}),
('power', {'action': 'factory-reset'}), ('send_text', {'text': ''})]:
with self.assertRaises(ValueError): agent.call(server, {'name': name, 'arguments': args})
class Assistant(unittest.TestCase):
def setUp(self):
self.received = []
owner = self
class Endpoint(BaseHTTPRequestHandler):
def log_message(self, *args): pass
def do_POST(self):
owner.received.append((dict(self.headers), json.loads(self.rfile.read(int(self.headers['Content-Length'])))))
if self.path == '/redirect':
self.send_response(302)
self.send_header('Location', '/other')
self.end_headers()
return
data = json.dumps({'choices': [{'message': {'content': '<script>not executed</script>'}}]}).encode()
self.send_response(200)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
self.httpd = ThreadingHTTPServer(('127.0.0.1', 0), Endpoint)
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.body = {'endpoint': 'http://127.0.0.1:%d/chat' % self.httpd.server_port, 'model': 'local', 'prompt': 'Hello', 'consent': True}
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
self.thread.join()
def test_no_opt_in_no_request_or_capture(self):
capture = mock.Mock()
for consent in (False, None, 'true', 1):
with self.assertRaises(ValueError): assistant.chat({**self.body, 'consent': consent, 'screenshot': True}, capture)
capture.assert_not_called()
self.assertEqual(self.received, [])
def test_text_only_keyless_and_optional_screenshot(self):
capture = mock.Mock(return_value=b'png')
self.assertIn('script', assistant.chat(self.body, capture)['reply'])
capture.assert_not_called()
headers, body = self.received[-1]
self.assertNotIn('Authorization', headers)
self.assertEqual(body['messages'], [{'role': 'user', 'content': 'Hello'}])
assistant.chat({**self.body, 'screenshot': True, 'key': 'test-key'}, capture)
capture.assert_called_once()
headers, body = self.received[-1]
self.assertEqual(headers['Authorization'], 'Bearer test-key')
self.assertEqual(body['messages'][0]['content'][1]['image_url']['url'], 'data:image/png;base64,cG5n')
def test_redirects_do_not_forward_context_or_credentials(self):
with self.assertRaises(ValueError):
assistant.chat({**self.body, 'endpoint': self.body['endpoint'].replace('/chat', '/redirect'), 'key': 'secret'}, mock.Mock())
self.assertEqual(len(self.received), 1)
def test_bad_urls_fail_before_capture(self):
for url in ('file:///etc/passwd', 'http://example.com/chat', 'https://user:pass@example.com', 'https://example.com?key=secret'):
capture = mock.Mock()
with self.assertRaises(ValueError): assistant.chat({**self.body, 'endpoint': url, 'screenshot': True}, capture)
capture.assert_not_called()
class AssistantPage(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is required for the page script regression')
def test_approval_navigation_races(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(['node', str(root / 'tests/assistant_ui.cjs'), str(root / 'ui/assistant.html')],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class Protocol(unittest.TestCase):
def test_stdio_initialize_list_call_errors_and_eof(self):
messages = [
{'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', 'params': {'protocolVersion': '2025-06-18'}},
{'jsonrpc': '2.0', 'method': 'notifications/initialized'},
{'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'},
{'jsonrpc': '2.0', 'id': 3, 'method': 'tools/call', 'params': {'name': 'shell'}},
{'jsonrpc': '2.0', 'id': 4, 'method': 'ping'},
]
result = subprocess.run([sys.executable, str(Path(mcp.__file__))], input='\n'.join(map(json.dumps, messages)) + '\n', text=True, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
replies = list(map(json.loads, result.stdout.splitlines()))
self.assertEqual([r['id'] for r in replies], [1, 2, 3, 4])
self.assertEqual(replies[0]['result']['protocolVersion'], '2025-06-18')
self.assertIn('screenshot', [t['name'] for t in replies[1]['result']['tools']])
self.assertTrue(replies[2]['result']['isError'])
def test_mcp_cannot_approve_and_returns_review_url(self):
client = mock.Mock(url='http://127.0.0.1:47810')
client.request.return_value = {'approvalPath': '/assistant#confirm=token'}
result = mcp.call(client, 'power', {'action': 'reboot'})
self.assertIn('http://127.0.0.1:47810/assistant', result['content'][0]['text'])
with self.assertRaises(ValueError): mcp.call(client, 'approve', {'confirmation': 'token'})
with self.assertRaises(ValueError): mcp.call(client, 'status', {'path': '/api/open'})
def test_loopback_only_backend(self):
for url in ('https://example.com', 'http://127.0.0.1/api', 'http://secret@localhost:1234', 'file:///tmp/x'):
with self.assertRaises(ValueError): mcp.Client(url)
class ManagedBackend(unittest.TestCase):
def test_private_backend_auth_and_cleanup(self):
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
with mock.patch.dict(os.environ, {'FRAME_ALIAS': 'frame-control-test.invalid'}):
with mcp.backend() as client:
url = client.url
self.assertIn('os', client.request('/api/host'))
with self.assertRaises(HTTPError) as error:
urlopen(url + '/api/host', timeout=2)
self.assertEqual(error.exception.code, 403)
error.exception.close()
# A second client has its own backend and key.
with mcp.backend() as other:
self.assertNotEqual(client.url, other.url)
self.assertNotEqual(client.key, other.key)
self.assertIn('os', client.request('/api/host'))
with self.assertRaises(URLError):
urlopen(url + '/', timeout=2)
def test_private_ssh_socket_is_not_the_desktop_socket(self):
with mock.patch.object(server.frame_host, 'MUX', True), \
mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
class ComputerState(unittest.TestCase):
def test_gamescope_triplets_and_empty_focus(self):
import frame_computer
parsed = frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 16, 42, 123, 32, 55, 999\nGAMESCOPE_FOCUSED_APP(CARDINAL) = \n')
self.assertEqual(parsed['windows'], [{'windowId': '0x10', 'appid': 42, 'pid': 123}, {'windowId': '0x20', 'appid': 55, 'pid': 999}])
self.assertIsNone(parsed['focusedApp'])
with self.assertRaises(ValueError):
frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 1, 2')
with self.assertRaises(ValueError):
frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = untrusted')
with self.assertRaises(ValueError):
frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS: no such atom on any window.')
def test_partial_snapshot_reports_failure_not_empty_success(self):
import frame_computer
with mock.patch.object(frame_computer.subprocess, 'run', side_effect=OSError('no display')), \
mock.patch.object(frame_computer, 'accessibility', side_effect=OSError('no AT-SPI')):
result = frame_computer.snapshot()
self.assertIn('windowError', result)
self.assertIn('accessibilityError', result)
self.assertFalse(result['inputEnabled'])
self.assertNotIn('windows', result)
def test_mcp_computer_state_is_read_only(self):
client = mock.Mock()
client.request.return_value = {'windows': []}
mcp.call(client, 'computer_state', {})
client.request.assert_called_once_with('/api/computer/state')
spec = next(t for t in mcp.TOOLS if t['name'] == 'computer_state')
self.assertTrue(spec['annotations']['readOnlyHint'])
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -2,6 +2,7 @@
Run: python3 -m unittest discover -s tests Run: python3 -m unittest discover -s tests
""" """
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os import os
import sys import sys
import tempfile import tempfile
+1
View File
@@ -3,6 +3,7 @@ steamos-devkit-service, the ~/.ssh/config block, and the mDNS output parsers.
Run: python3 -m unittest discover -s tests Run: python3 -m unittest discover -s tests
""" """
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json import json
import socket import socket
import sys import sys
+5 -2
View File
@@ -1,4 +1,5 @@
"""frame_apk against a small APK built here: binary manifest plus resource table.""" """frame_apk against a small APK built here: binary manifest plus resource table."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io import io
import os import os
import struct import struct
@@ -35,7 +36,7 @@ def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign
foreign_label adds a non-android `label` attribute after android:label. foreign_label adds a non-android `label` attribute after android:label.
""" """
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk', strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
'application', package, 'junk', 'label'] # the second 'label' has no android id 'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c) resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
@@ -48,7 +49,8 @@ def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign
none = 0xffffffff none = 0xffffffff
chunks = (pool(strings) + resmap chunks = (pool(strings) + resmap
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8), + element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
(2, none, frame_apk.T_REF, version_ref)]) (2, none, frame_apk.T_REF, version_ref),
(11, none, frame_apk.T_INT_DEC, 210)])
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)]) + element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)] + element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else []))) + ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
@@ -108,6 +110,7 @@ class ApkInfo(unittest.TestCase):
self.assertEqual(info['package'], 'com.example.demo') self.assertEqual(info['package'], 'com.example.demo')
self.assertEqual(info['label'], 'App label') # the default, not French self.assertEqual(info['label'], 'App label') # the default, not French
self.assertEqual(info['version'], '2.1') self.assertEqual(info['version'], '2.1')
self.assertEqual(info['version_code'], 210)
self.assertEqual(info['min_sdk'], 26) self.assertEqual(info['min_sdk'], 26)
self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64']) self.assertEqual(info['abis'], ['arm64-v8a', 'x86_64'])
self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon self.assertEqual(info['icon_png'], b'hi') # largest-density PNG, skipping the XML icon
+285
View File
@@ -0,0 +1,285 @@
"""Offline version lookup with small index-v2 fixtures."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
import sys
import tempfile
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_apk_versions as versions
import frame_catalog
import frame_android
def build(code, sdk=30, abis=None):
return {'manifest': {'versionName': str(code), 'versionCode': code,
'usesSdk': {'minSdkVersion': sdk}, 'nativecode': abis or []},
'file': {'name': f'/example_{code}.apk', 'sha256': str(code).zfill(64)}}
class VersionsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
data = os.path.join(self.tmp.name, 'data')
os.mkdir(data)
for name, builds in [('index-v2.json', [build(5, 33), build(4, abis=['x86_64']),
build(3, abis=['arm64-v8a', 'x86_64']), build(2)]),
('index-v2.archive.json', [build(1, 21), build(2)]),
('index-v2.izzy.json', [])]:
with open(os.path.join(data, name), 'w') as f:
json.dump({'packages': {'org.example.app': {'versions': {str(i): b for i, b in enumerate(builds)}}}}, f)
self.enter_patch(patch.object(frame_catalog, 'CATALOG', self.tmp.name))
self.enter_patch(patch.dict(os.environ, {'FRAME_CONTROL_APP': ''}))
self.network = self.enter_patch(patch.object(frame_catalog.urllib.request, 'urlopen', side_effect=AssertionError('network used')))
def enter_patch(self, p):
result = p.start()
self.addCleanup(p.stop)
return result
def test_filter_order_archive_and_dedup(self):
result = versions.alternatives('org.example.app')
self.assertEqual([v['version_code'] for v in result['versions']], [3, 2, 1])
self.assertEqual(result['versions'][-1]['source'], 'F-Droid archive')
self.assertEqual(result['versions'][-1]['url'], 'https://f-droid.org/archive/example_1.apk')
self.assertEqual(result['errors'], [])
self.network.assert_not_called()
def test_current_version(self):
result = versions.alternatives('org.example.app', 3)
self.assertEqual([v['version_code'] for v in result['versions']], [2, 1])
def test_fallback(self):
result = versions.alternatives('com.missing.app')
self.assertEqual(result['versions'], [])
self.assertEqual([v['source'] for v in result['links']], ['APKMirror', 'APKPure', 'Uptodown', 'F-Droid', 'GitHub'])
self.assertTrue(all('com.missing.app' in v['url'] for v in result['links']))
self.assertIn('Android 11', result['note'])
self.assertIn('arm64-v8a', result['note'])
def test_failed_indexes_keep_search_links(self):
with patch.object(frame_catalog, 'load_index', side_effect=OSError('offline')):
result = versions.alternatives('org.example.app')
self.assertEqual(len(result['errors']), 3)
self.assertEqual(len(result['links']), 5)
def test_no_compatible_versions(self):
with patch.object(frame_catalog, 'load_index', return_value={}):
result = versions.alternatives('org.example.app')
self.assertEqual(result['versions'], [])
self.assertEqual(len(result['links']), 5)
def test_reduction_memory_cache_and_refresh(self):
repo = versions.REPOS[0][1]
index = frame_catalog.load_index(repo)
self.assertEqual([v['version_code'] for v in index['org.example.app']], [3, 2])
self.assertEqual(set(index['org.example.app'][0]),
{'version', 'version_code', 'min_sdk', 'abis', 'name', 'sha256'})
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
self.assertTrue(os.path.exists(raw)) # the catalogue build reads it
os.utime(raw, ns=(1, 1))
with patch.object(frame_catalog.json, 'load', side_effect=AssertionError('reparsed')):
self.assertIs(frame_catalog.load_index(repo), index)
path = raw + '.installable-v1'
with open(path, 'w') as f:
json.dump({}, f)
os.utime(path, ns=(1, 1))
self.assertEqual(frame_catalog.load_index(repo, cached_only=True), {})
payload = json.dumps({'packages': {'org.example.app': {'versions': {'x': build(9)}}}}).encode()
with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(payload)) as fetch:
self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 9)
fetch.assert_called_once()
self.assertTrue(os.path.exists(raw))
def test_malformed_entries_are_skipped(self):
raw = os.path.join(self.tmp.name, 'odd.json')
with open(raw, 'w') as f:
json.dump({'packages': {'a.b': {'versions': {'x': {'manifest': {}}, 'y': None, 'z': build(4)}},
'c.d': {'versions': None}, 'e.f': []}}, f)
self.assertEqual([v['version_code'] for v in frame_catalog._reduce_index(raw)['a.b']], [4])
def test_one_failing_repo_keeps_the_others(self):
real = frame_catalog.load_index
def load(repo, cached_only=False):
if 'izzy' in repo:
raise KeyError('file')
return real(repo, cached_only=cached_only)
with patch.object(frame_catalog, 'load_index', side_effect=load):
result = versions.alternatives('org.example.app')
self.assertEqual([v['version_code'] for v in result['versions']], [3, 2, 1])
self.assertEqual(len(result['errors']), 1)
def test_newer_raw_index_outdates_reduced_copy(self):
repo = versions.REPOS[0][1]
frame_catalog.load_index(repo)
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
with open(raw, 'w') as f:
json.dump({'packages': {'org.example.app': {'versions': {'x': build(8)}}}}, f)
os.utime(raw, ns=(time.time_ns() + 10**9,) * 2)
self.assertEqual(frame_catalog.load_index(repo)['org.example.app'][0]['version_code'], 8)
def test_concurrent_requests_share_download(self):
raw = os.path.join(self.tmp.name, 'data', 'index-v2.json')
os.remove(raw)
payload = json.dumps({'packages': {'org.example.app': {'versions': {'x': build(7)}}}}).encode()
with patch.object(frame_catalog.urllib.request, 'urlopen', side_effect=lambda *a, **k: io.BytesIO(payload)) as fetch:
with ThreadPoolExecutor(max_workers=4) as pool:
indexes = list(pool.map(frame_catalog.load_index, [versions.REPOS[0][1]] * 4))
fetch.assert_called_once()
self.assertTrue(all(index is indexes[0] for index in indexes))
def test_failed_refresh_preserves_cache(self):
repo = versions.REPOS[0][1]
index = frame_catalog.load_index(repo)
path = os.path.join(self.tmp.name, 'data', 'index-v2.json.installable-v1')
os.utime(path, ns=(1, 1))
os.utime(os.path.join(self.tmp.name, 'data', 'index-v2.json'), ns=(1, 1))
with patch.object(frame_catalog.urllib.request, 'urlopen', return_value=io.BytesIO(b'{')):
with self.assertRaises(ValueError):
frame_catalog.load_index(repo)
self.assertEqual(frame_catalog.load_index(repo, cached_only=True), index)
self.assertFalse(any(name.endswith('.part') for name in os.listdir(os.path.dirname(path))))
def test_stream_boundaries_and_invalid_index(self):
raw = os.path.join(self.tmp.name, 'stream.json')
with open(raw, 'w') as f:
json.dump({'repo': {'description': 'é' * 70000}, 'packages': {
'org.example.app': {'metadata': {'text': 'escaped " packages { }' * 6000},
'versions': {'x': build(7)}}}, 'tail': {}}, f)
self.assertEqual(frame_catalog._reduce_index(raw)['org.example.app'][0]['version_code'], 7)
for invalid in ('{}', '{"packages": []}', '{"packages": {', '{"packages": {}} trailing'):
with open(raw, 'w') as f:
f.write(invalid)
with self.assertRaises(ValueError):
frame_catalog._reduce_index(raw)
def test_cap_and_preferred_build(self):
records = [dict(version=str(i), version_code=i, min_sdk=21, abis=[],
name='/app_%s.apk' % i, sha256=str(i)) for i in range(20)]
records += [dict(records[-1], version_code=21, abis=['arm64-v8a'], name='/arm.apk'),
dict(records[-1], version_code=22, abis=['arm64-v8a', 'x86_64'], name='/all.apk')]
with patch.object(frame_catalog, 'load_index', return_value={'org.example.app': records}):
result = versions.alternatives('org.example.app')
self.assertEqual(result['total'], 22)
self.assertEqual(len(result['versions']), 8)
self.assertEqual(len({v['version'] for v in result['versions']}), 8)
self.assertEqual([v['version_code'] for v in result['versions']], [21, 18, 17, 16, 15, 14, 13, 12])
def test_android_names_and_verdict(self):
for sdk, name in [(23, 'Android 6.0'), (30, 'Android 11'), (32, 'Android 12L'), (33, 'Android 13'), (99, 'Android API 99')]:
self.assertEqual(versions.android_name(sdk), name)
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5.0',
'version_code': 50, 'min_sdk': 33, 'abis': ['arm64-v8a']}
description = versions.describe(info)
for text in ['org.example.app', '5.0', 'code 50', 'Android 13', 'arm64-v8a', 'cannot install']:
self.assertIn(text, description)
info.update(min_sdk=30, abis=[])
self.assertIn('can install', versions.describe(info))
info['abis'] = ['armeabi-v7a']
self.assertIn('no arm64-v8a build', versions.describe(info))
def test_install_resolves_index_hash(self):
versions.alternatives('org.example.app')
with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \
patch.object(frame_catalog, 'fetch_apk', return_value='/tmp/example.apk') as fetch, \
patch.object(frame_android, 'apk_info', return_value={'package': 'org.example.app', 'version_code': 1}), \
patch.object(frame_android, 'install', return_value={'label': 'Example'}) as install:
versions.install('org.example.app', 'https://f-droid.org/archive/example_1.apk')
self.assertEqual(fetch.call_args[0][0]['h'], str(1).zfill(64))
install.assert_called_once_with('/tmp/example.apk', source='F-Droid archive')
with self.assertRaises(frame_android.FrameError):
versions.install('org.example.app', 'https://evil.example/app.apk')
def test_install_checks_identity_without_network_refresh(self):
versions.alternatives('org.example.app')
for name in ('index-v2.json', 'index-v2.archive.json'):
os.utime(os.path.join(self.tmp.name, 'data', name + '.installable-v1'), ns=(1, 1))
for info in ({'package': 'wrong.package', 'version_code': 1},
{'package': 'org.example.app', 'version_code': 99}):
with patch.object(frame_catalog, 'fetch_apk', return_value='/tmp/example.apk'), \
patch.object(frame_android, 'apk_info', return_value=info), \
patch.object(frame_android, 'install') as install:
with self.assertRaises(frame_android.FrameError):
versions.install('org.example.app', 'https://f-droid.org/archive/example_1.apk')
install.assert_not_called()
self.network.assert_not_called()
class UploadVersionsTest(unittest.TestCase):
def test_endpoint_validation(self):
import server
for query in ('', 'package=', 'package=foo', 'package=a..b', 'package=a.1b',
'package=a.b/path', 'package=a.b&package=c.d', 'package=a.b&code=-1',
'package=a.b&code=x', 'package=a.b&code=', 'package=a.b&code=1&code=2'):
handler = object.__new__(server.Handler)
handler.path = '/api/apk-versions?' + query
with patch.object(handler, 'local_request', return_value=True), \
patch.object(handler, 'send_json') as reply, \
patch.object(versions, 'alternatives') as lookup:
handler.do_GET()
self.assertEqual(reply.call_args[0][1], 400, query)
lookup.assert_not_called()
handler.path = '/api/apk-versions?package=org.example_app.demo&code=123'
with patch.object(handler, 'local_request', return_value=True), \
patch.object(handler, 'send_json') as reply, \
patch.object(versions, 'alternatives', return_value={'total': 0}) as lookup:
handler.do_GET()
lookup.assert_called_once_with('org.example_app.demo', 123)
reply.assert_called_once_with({'total': 0})
def test_blocked_uploads_do_not_lookup_before_reply(self):
import server
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5',
'version_code': 5, 'min_sdk': 33, 'abis': [], 'icon_png': None}
for mode in ('apkinfo', 'apk'):
handler = object.__new__(server.Handler)
handler.headers = {'X-Filename': 'app.apk', 'X-Mode': mode, 'Content-Length': '1'}
handler.rfile = io.BytesIO(b'x')
with patch.object(frame_android, 'apk_info', return_value=dict(info)), \
patch.object(versions, 'alternatives', side_effect=AssertionError('lookup during upload')) as lookup, \
patch.object(frame_android, 'install_hooks', []), \
patch.object(server, 'ensure_master') as ssh:
if mode == 'apkinfo':
reply = handler.upload()
self.assertNotIn('alternatives', reply['apk'])
self.assertIn('API 33', reply['apk']['blocker'])
else:
with self.assertRaises(server.Failure) as error:
handler.upload()
self.assertEqual(error.exception.status, 400)
self.assertEqual(error.exception.apk['package'], info['package'])
lookup.assert_not_called()
ssh.assert_not_called()
def test_blocked_uploads_are_reported_like_failed_installs(self):
import server
info = {'package': 'org.example.app', 'label': 'Example', 'version': '5',
'version_code': 5, 'min_sdk': 33, 'abis': [], 'icon_png': None}
for apk_info, expected_info in ((dict(info), 'org.example.app'),
(frame_android.FrameError('not an APK'), None)):
handler = object.__new__(server.Handler)
handler.headers = {'X-Filename': 'app.apk', 'X-Mode': 'apk', 'Content-Length': '1'}
handler.rfile = io.BytesIO(b'x')
calls = []
patch_info = (patch.object(frame_android, 'apk_info', side_effect=apk_info)
if isinstance(apk_info, Exception) else
patch.object(frame_android, 'apk_info', return_value=apk_info))
with patch_info, patch.object(frame_android, 'install_hooks', [lambda *a: calls.append(a)]), \
patch.object(server, 'ensure_master'):
with self.assertRaises(server.Failure):
handler.upload()
self.assertEqual(len(calls), 1)
got_info, meta, error, _ = calls[0]
self.assertEqual((got_info or {}).get('package'), expected_info)
self.assertIsNone(meta)
self.assertIsInstance(error, frame_android.FrameError)
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -1,4 +1,5 @@
"""frame_titles without a headset: executable headers, launch targets, zips, runtimes.""" """frame_titles without a headset: executable headers, launch targets, zips, runtimes."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json import json
import os import os
import shutil import shutil
+18
View File
@@ -5,6 +5,7 @@ request guards and input validation, which all run before any SSH call.
Run: python3 -m unittest discover -s tests Run: python3 -m unittest discover -s tests
""" """
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client import http.client
import io import io
import json import json
@@ -212,6 +213,23 @@ class ServerGuards(unittest.TestCase):
self.assertNotEqual(status, 200, body) self.assertNotEqual(status, 200, body)
self.assertNotIn("job", body) self.assertNotIn("job", body)
def test_android_install_of_another_version_runs_as_a_job(self):
pkg = "org.example.frame_control.not_in_any_repo"
sys.path.insert(0, str(ROOT / "ui"))
import frame_apk_versions
# The job must fail on the cached lookup, before any download: nothing is cached for this package.
self.assertEqual(frame_apk_versions._versions(pkg, cached_only=True)[0], [])
status, started = self.post("/api/android", {"action": "install", "package": pkg,
"url": f"https://f-droid.org/repo/{pkg}_1.apk"})
self.assertEqual(status, 200, started)
for _ in range(200):
job = json.loads(self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"})[2])
if job["done"]:
break
time.sleep(0.05)
self.assertTrue(job["done"])
self.assertIn("no longer available", job["error"])
def test_unknown_routes(self): def test_unknown_routes(self):
self.assertEqual(self.request("GET", "/nope")[0], 404) self.assertEqual(self.request("GET", "/nope")[0], 404)
self.assertEqual(self.post("/api/nope", {})[0], 404) self.assertEqual(self.post("/api/nope", {})[0], 404)
+1
View File
@@ -2,6 +2,7 @@
Run: python3 -m unittest discover -s tests Run: python3 -m unittest discover -s tests
""" """
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json import json
import subprocess import subprocess
import sys import sys
+448
View File
@@ -0,0 +1,448 @@
"""Anonymous analytics (ui/frame_telemetry.py): what's collected at each level,
what's scrubbed, and that nothing is sent without a key, the notice, or consent.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import sys
import tempfile
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_compat_db as db # noqa: E402
import frame_report as fr # noqa: E402
import frame_telemetry as tm # noqa: E402
class Base(unittest.TestCase):
"""A packaged build with a key, its state in a temp folder."""
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
state = Path(tmp.name)
for name, value in (("STATE", state), ("SETTINGS", state / "settings.json"),
("OUTBOX", state / "outbox.jsonl"), ("SENT", state / "sent.jsonl")):
p = mock.patch.object(tm, name, value)
p.start()
self.addCleanup(p.stop)
env = mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_KEY": "phc_test", "FRAME_CONTROL_PACKAGED": "1",
"FRAME_CONTROL_POSTHOG_HOST": "http://127.0.0.1:9",
"FRAME_CONTROL_VERSION": "9.9.9"})
env.start()
self.addCleanup(env.stop)
for k in ("DO_NOT_TRACK", "FRAME_CONTROL_TELEMETRY"):
os.environ.pop(k, None)
tm._seen_errors.clear()
def queued(self):
return tm._read_lines(tm.OUTBOX)
class Gates(Base):
def test_blocked_without_key_or_in_a_checkout_or_by_do_not_track(self):
self.assertIsNone(tm.blocked())
with mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_KEY": ""}), \
mock.patch.object(tm, "HERE", Path(tempfile.gettempdir()) / "no-config-here"):
self.assertIn("key", tm.blocked())
with mock.patch.dict(os.environ, {"FRAME_CONTROL_PACKAGED": ""}):
self.assertIn("source checkout", tm.blocked())
with mock.patch.dict(os.environ, {"DO_NOT_TRACK": "1"}):
self.assertIn("DO_NOT_TRACK", tm.blocked())
self.assertFalse(tm.capture("app_opened"))
self.assertFalse(tm.OUTBOX.exists())
def test_usage_is_on_by_default_the_others_are_opt_in(self):
self.assertTrue(tm.capture("app_opened"))
self.assertFalse(tm.capture("compat_report", {}, level="compat"))
self.assertFalse(tm.capture("$exception", {}, level="diagnostics"))
self.assertEqual([e["event"] for e in self.queued()], ["app_opened"])
def test_events_are_anonymous(self):
tm.capture("app_opened")
e = self.queued()[0]
self.assertEqual(e["distinct_id"], tm.settings()["id"])
self.assertIs(e["properties"]["$process_person_profile"], False)
self.assertIs(e["properties"]["$geoip_disable"], True)
self.assertEqual(e["properties"]["app_version"], "9.9.9")
def test_turning_a_level_off_drops_its_unsent_events(self):
tm.update_settings({"diagnostics": True})
tm.capture("app_opened")
tm.diagnostic("somewhere", RuntimeError("boom"))
self.assertEqual(len(self.queued()), 2)
tm.update_settings({"diagnostics": False})
self.assertEqual([e["event"] for e in self.queued()], ["app_opened"])
tm.update_settings({"usage": False})
self.assertEqual(self.queued(), [])
self.assertFalse(tm.capture("app_opened"))
def test_the_same_error_is_sent_once_in_a_while(self):
tm.update_settings({"diagnostics": True})
for _ in range(3):
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
self.assertEqual(len(self.queued()), 1)
def test_page_events_are_checked(self):
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
self.assertEqual(self.queued()[0]["properties"].get("extra"), None)
with self.assertRaises(ValueError):
tm.page_event({"event": "anything_else"})
with self.assertRaises(ValueError):
tm.page_event({"event": "tab_viewed", "properties": {"tab": "/Users/me/secret"}})
class Lifecycle(Base):
def test_install_update_and_one_open_a_day(self):
tm.app_started()
tm.app_started()
self.assertEqual([e["event"] for e in self.queued()], ["app_installed", "app_opened"])
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
tm.app_started()
e = self.queued()[-1]
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
def test_frame_build_once(self):
tm.frame_seen("20260922.1", "3.8")
tm.frame_seen("20260922.1", "3.8")
self.assertEqual(len(self.queued()), 1)
class Sending(Base):
def serve(self, status=200):
got = []
class H(BaseHTTPRequestHandler):
def do_POST(self):
got.append((self.path, json.loads(self.rfile.read(int(self.headers["Content-Length"])))))
self.send_response(status)
self.end_headers()
self.wfile.write(b'{"status": 1}')
def log_message(self, *a):
pass
httpd = HTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
self.addCleanup(httpd.server_close)
self.addCleanup(httpd.shutdown)
os.environ["FRAME_CONTROL_POSTHOG_HOST"] = f"http://127.0.0.1:{httpd.server_port}"
return got
def test_nothing_is_sent_before_the_notice_was_shown(self):
got = self.serve()
tm.capture("app_opened")
self.assertEqual(tm.flush(), 0)
self.assertEqual(got, [])
tm.update_settings({"noticeShown": True})
self.assertEqual(tm.flush(), 1)
path, body = got[0]
self.assertEqual((path, body["api_key"], body["batch"][0]["event"]), ("/batch/", "phc_test", "app_opened"))
self.assertEqual(self.queued(), [])
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
def test_a_failed_send_keeps_the_events(self):
self.serve(status=500)
tm.update_settings({"noticeShown": True})
tm.capture("app_opened")
self.assertEqual(tm.flush(), 0)
self.assertEqual(len(self.queued()), 1)
class Scrub(unittest.TestCase):
def test_personal_details_are_removed(self):
home = str(Path.home())
text = (f"open {home}/Downloads/My Game.apk failed; ssh alex@192.168.1.20 (frame.local) "
"key ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM steam 76561198000000000 mac 3c:22:fb:12:34:56 "
"url https://example.com/private/path?token=abc phc_abcdefghijklmnopqrstu C:\\Users\\Bob\\x "
"/home/carol/y")
out = tm.scrub(text)
for leaked in (home, "192.168.1.20", "frame.local", "AAAAC3Nza", "76561198000000000", "3c:22:fb",
"private/path", "phc_abcdefghijklmnopqrstu", "Bob", "carol", "alex@"):
self.assertNotIn(leaked, out)
self.assertIn("https://example.com/…", out)
self.assertIn("~/Downloads", out)
def test_categories(self):
self.assertEqual(tm.categorize("adb: failed to install: INSTALL_FAILED_NO_MATCHING_ABIS: x"),
("android_installer", "INSTALL_FAILED_NO_MATCHING_ABIS"))
self.assertEqual(tm.categorize("X has no arm64-v8a build (armeabi-v7a)")[0], "apk_wrong_abi")
self.assertEqual(tm.categorize("ssh: connect to host 10.0.0.2 port 22: Connection refused")[0],
"frame_unreachable")
self.assertEqual(tm.categorize("something new")[0], "other")
class Compat(Base):
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
r = {"id": "r1", "package": "org.example", "version": "1.0", "rating": "works", "via": "user",
"notes": f"from {Path.home()}/x", "source": "MyPrivateBuild.apk", "date": "2026-09-28T10:00:00"}
self.assertFalse(tm.compat_report(r))
tm.update_settings({"compat": True})
self.assertTrue(tm.compat_report(r))
p = self.queued()[-1]["properties"]
self.assertEqual((p["package"], p["rating"], p["id"]), ("org.example", "works", "r1"))
self.assertNotIn("source", p)
self.assertNotIn(str(Path.home()), p["notes"])
r2 = dict(r, id="r2", source="https://f-droid.org/repo/org.example_1.apk")
tm.compat_report(r2)
self.assertEqual(self.queued()[-1]["properties"]["source"], "https://f-droid.org/…")
def test_opting_in_shares_earlier_local_reports(self):
with mock.patch.object(db, "shared", return_value=False), \
mock.patch.object(db, "_outbox", return_value=[{"id": "old1", "package": "org.a", "rating": "works",
"date": "2026-09-01T00:00:00"}]):
tm.update_settings({"compat": True})
tm.update_settings({"compat": True}) # already sent: not again
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["old1"])
class ApkInstallReports(unittest.TestCase):
"""server.apk_installed: an APK that won't install is reported; connection trouble isn't."""
def setUp(self):
import server
self.server = server
for target, name in ((server.frame_catalog, "add_report"), (server.frame_telemetry, "install_finished")):
p = mock.patch.object(target, name)
setattr(self, name, p.start())
self.addCleanup(p.stop)
def test_wrong_abi_is_an_install_failed_report(self):
info = {"package": "org.x", "version": "2.0", "label": "X"}
self.server.apk_installed(info, None, self.server.frame_android.FrameError(
"X has no arm64-v8a build (armeabi-v7a); Lepton is 64-bit ARM only"), 3.0)
args, kw = self.add_report.call_args
self.assertEqual((args[0], args[1], kw["result"], kw["via"]), ("org.x", "2.0", "install_failed", "install"))
self.assertIs(self.install_finished.call_args[0][1], False)
def test_connection_trouble_is_not_reported(self):
self.server.apk_installed({"package": "org.x", "version": "2.0"}, None,
self.server.frame_android.FrameError("timed out talking to frame"), 3.0)
self.add_report.assert_not_called()
def test_private_package_names_stay_here(self):
with mock.patch.dict(self.server.frame_catalog._cache, {"by_pkg": {"org.public": {}}}):
self.server.apk_installed({"package": "com.private.thing", "version": "1"}, {"package": "com.private.thing"},
None, 2.0)
self.assertIsNone(self.install_finished.call_args[1]["package"])
self.server.apk_installed({"package": "org.public", "version": "1"}, {"package": "org.public"}, None, 2.0)
self.assertEqual(self.install_finished.call_args[1]["package"], "org.public")
class CommunitySync(unittest.TestCase):
def ev(self, i, who="a", day="2026-09-28", **kw):
return ({"id": f"id{i}", "package": "org.x", "rating": "works", "date": f"{day}T00:00:00",
"via": "probe", **kw}, who, f"{day} 10:00:00")
def test_rows_are_validated_marked_and_capped_per_reporter(self):
events = [self.ev(i) for i in range(5)] + [self.ev(9, who="b", rating="nonsense"), self.ev(10, who="b")]
rows, skipped = db.community_rows(events, {}, cap=3)
self.assertEqual([r["id"] for r in rows], ["id0", "id1", "id2", "id10"])
self.assertTrue(all(r["via"] == "community-probe" for r in rows))
self.assertEqual(len(skipped), 3)
def test_the_cap_and_duplicates_hold_across_syncs(self):
state = {}
rows, _ = db.community_rows([self.ev(i) for i in range(3)], state, cap=3)
self.assertEqual(len(rows), 3)
rows, skipped = db.community_rows([self.ev(i) for i in range(6)], state, cap=3) # overlapping re-read
self.assertEqual(rows, [])
self.assertEqual([why for _, why in skipped], ["over the daily limit for one reporter"] * 3)
def test_a_malformed_event_is_skipped_not_fatal(self):
rows, skipped = db.community_rows([self.ev(1, via=["probe"]), ("not json", "a", "2026-09-28"), self.ev(2)], {})
self.assertEqual([r["id"] for r in rows], ["id2"])
self.assertEqual(len(skipped), 2)
class Regressions(Base):
"""Findings from the cross-provider review."""
def test_urls_lose_credentials_paths_and_private_hosts(self):
for text, leaked in (("https://alice:secret@example.com/private.apk?token=credential", ("alice", "secret", "private", "credential")),
("https://alice:secret@192.168.1.4/private.apk", ("alice", "192.168", "private")),
("fe80::1234 and 2001:db8::5", ("fe80", "2001:db8")),
("sk-proj-abcdefghijklmnopqrstuv", ("abcdefghijk",)),
("http://frame.local:8080/x", ("frame.local", "8080"))):
out = tm.scrub(text)
for s in leaked:
self.assertNotIn(s, out, (text, out))
def test_compat_labels_versions_and_sources_are_scrubbed(self):
tm.update_settings({"compat": True})
tm.compat_report({"id": "r9", "package": "org.x", "rating": "works", "date": "2026-09-28T00:00:00",
"label": "alice@example.com build", "version": "1.0-alice@example.com",
"source": "https://alice:secret@192.168.1.4/private.apk"})
p = self.queued()[-1]["properties"]
self.assertNotIn("alice", json.dumps(p))
self.assertNotIn("source", p)
def test_an_unsent_report_is_shared_again_after_opting_out_and_in(self):
with mock.patch.object(db, "shared", return_value=False), \
mock.patch.object(db, "_outbox", return_value=[{"id": "q1", "package": "org.a", "rating": "works",
"date": "2026-09-01T00:00:00"}]):
tm.update_settings({"compat": True})
tm.update_settings({"compat": False})
self.assertEqual(self.queued(), [])
tm.update_settings({"compat": True})
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["q1"])
def test_opting_out_waits_for_a_send_in_progress(self):
tm.update_settings({"noticeShown": True})
tm.capture("app_opened")
order = []
started = threading.Event()
def slow_open(req, timeout):
started.set()
time.sleep(0.3)
order.append("sent")
return mock.MagicMock(__enter__=lambda s: s, __exit__=lambda *a: False, read=lambda: b"{}")
with mock.patch.object(tm.urllib.request, "urlopen", side_effect=slow_open):
th = threading.Thread(target=tm.flush)
th.start()
started.wait(2)
tm.update_settings({"usage": False})
order.append("opted out")
th.join()
self.assertEqual(order, ["sent", "opted out"])
def test_project_id_comes_from_the_config(self):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_PROJECT": "12345"}):
self.assertEqual(tm.config()["project"], "12345")
class ReportProblem(Base):
"""Report a problem: diagnostics are scrubbed and bounded; the report goes privately to PostHog."""
def serve(self, status=200):
got = []
class H(BaseHTTPRequestHandler):
def do_POST(self):
got.append((self.path, json.loads(self.rfile.read(int(self.headers["Content-Length"])))))
self.send_response(status)
self.end_headers()
self.wfile.write(b'{"status":"Ok"}')
def log_message(self, *a):
pass
httpd = HTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
self.addCleanup(httpd.server_close)
self.addCleanup(httpd.shutdown)
p = mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_HOST": f"http://127.0.0.1:{httpd.server_port}"})
p.start()
self.addCleanup(p.stop)
return got
def test_diagnostics_are_scrubbed_and_include_the_log(self):
log = tm.STATE / "server.log"
log.write_text("GET /api/status 200\nTraceback: ssh alice@192.168.1.9 failed in %s/x\n" % Path.home())
with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
text = fr.diagnostics(["16:00 Install failed: https://bob:pw@example.com/a.apk"], include_logs=True, limit=5000)
self.assertIn("Frame Control 9.9.9", text)
self.assertIn("Traceback", text)
self.assertNotIn("GET /api/status", text)
for leaked in ("alice", "192.168.1.9", str(Path.home()), "bob", "pw@"):
self.assertNotIn(leaked, text)
def test_a_report_is_bounded_in_utf16_units(self):
body = {"title": "Live view stops", "message": "It stops 😀 " * 800, "diagnostics": "log 😀 line\n" * 2000}
title, text, diag = fr.compose(body)
self.assertLessEqual(fr.u16(text), fr.TEXT_MAX)
self.assertLessEqual(fr.u16(diag), fr.DIAG_MAX)
self.assertTrue(text.startswith("It stops"))
with self.assertRaises(ValueError):
fr.compose({"title": "hi", "message": "It stops after a minute."})
def test_logs_only_when_asked_and_environment_is_kept_first(self):
log = tm.STATE / "server.log"
log.write_text("".join(f"old line {i}\n" for i in range(200)) + "newest line\n")
with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
plain = fr.diagnostics(["Copy Jane Doe tax return.pdf to ~/Downloads"])
full = fr.diagnostics(["Install failed"], include_logs=True, limit=400)
self.assertNotIn("Jane Doe", plain)
self.assertNotIn("line", plain)
self.assertTrue(full.startswith("Frame Control 9.9.9"))
self.assertIn("Install failed", full)
self.assertIn("newest line", full)
self.assertLessEqual(fr.u16(full), 400)
def test_the_previewed_diagnostics_are_what_is_sent(self):
got = self.serve()
fr.send({"title": "Live view stops", "message": "It stops after a minute.",
"diagnostics": "Frame Control 9.9.9\nssh janes-mac.tail12345.ts.net failed"})
diag = got[0][1]["batch"][0]["properties"]["diagnostics"]
self.assertIn("Frame Control 9.9.9", diag)
self.assertNotIn("janes-mac", diag)
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
got = self.serve()
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com"})
path, body = got[0]
event = body["batch"][0]
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
props = event["properties"]
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
self.assertIn(res["id"], res["message"])
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["problem_report"])
def test_a_sent_report_is_not_an_error_if_the_local_log_fails(self):
self.serve()
with mock.patch.object(tm, "record_sent", side_effect=OSError("disk full")):
res = fr.send({"title": "Live view stops", "message": "It stops after a minute."})
self.assertTrue(res["id"])
def test_events_queued_by_older_versions_get_the_placeholder_address(self):
got = self.serve()
tm.update_settings({"noticeShown": True})
tm._write_lines(tm.OUTBOX, [{"event": "app_opened", "distinct_id": "x", "uuid": "u1",
"properties": {"level": "usage"}}])
self.assertEqual(tm.flush(), 1)
self.assertEqual(got[0][1]["batch"][0]["properties"]["$ip"], "0.0.0.0")
self.assertEqual(tm._read_lines(tm.SENT)[0]["properties"]["$ip"], "0.0.0.0")
def test_the_inbox_skips_malformed_reports(self):
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
"0.4.0", "macOS", "", ""]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
mock.patch("builtins.print") as out:
fr.main()
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("AB12CD34", printed)
self.assertIn("Hand-made", printed)
def test_a_refused_report_is_an_error(self):
self.serve(status=401)
with self.assertRaisesRegex(fr.ReportError, "HTTP 401"):
fr.send({"title": "Live view stops", "message": "It stops after a minute."})
self.assertEqual(tm._read_lines(tm.SENT), [])
def test_no_key_means_no_report(self):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_KEY": ""}), \
mock.patch.object(tm, "HERE", tm.STATE):
with self.assertRaisesRegex(fr.ReportError, "no PostHog project key"):
fr.send({"title": "Live view stops", "message": "It stops after a minute."})
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -4,6 +4,7 @@ the localhost-testing rule allows.
Run: python3 -m unittest discover -s tests Run: python3 -m unittest discover -s tests
""" """
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import hashlib import hashlib
import json import json
import os import os
+92
View File
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Frame Control · Assistant</title>
<style>
:root { color-scheme:dark; font:20px/1.5 system-ui,sans-serif; background:#171d25; color:#e4e9ef }
* { box-sizing:border-box } body { max-width:1050px; margin:0 auto; padding:28px }
h1 { font-size:30px; margin:0 } h2 { font-size:24px } p { color:#b8c6d5 }
a { color:#70c9ff } section { background:#202d3c; border:1px solid #425268; border-radius:12px; padding:24px; margin:22px 0 }
label { display:block; margin:14px 0 } input:not([type=checkbox]),textarea { display:block; width:100%; margin-top:6px; padding:12px; background:#101923; color:inherit; border:1px solid #728398; border-radius:6px; font:inherit }
input[type=checkbox] { width:24px; height:24px; vertical-align:middle; margin-right:10px } button { font:inherit; padding:12px 24px; min-height:52px; border:1px solid #728398; border-radius:6px; background:#30445b; color:white; cursor:pointer; margin:6px 12px 6px 0 }
button.primary { background:#176b9c } button:disabled { opacity:.5; cursor:wait } :focus-visible { outline:3px solid #70c9ff; outline-offset:3px }
summary { overflow-wrap:anywhere; cursor:pointer }
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:inherit; max-height:380px; overflow:auto } [hidden] { display:none!important } #status { min-height:1.5em } small { color:#b8c6d5 }
</style>
<header><h1>Frame Control · Assistant</h1><a href="/">Back to Frame Control</a></header>
<section id="approval" hidden aria-labelledby="approval-title">
<h2 id="approval-title">An agent wants to change your Frame</h2>
<p>Review the exact action below. Approve only if you asked for it. Approval expires after five minutes and works once.</p>
<pre id="action"></pre><button id="approve" class="primary">Approve this action</button><button id="reject">Reject</button>
<p id="approval-status" role="status"></p>
</section>
<section aria-labelledby="chat-title">
<h2 id="chat-title">Ask your chosen model</h2>
<p>Nothing is sent until you opt in and press Send. Each request sends only the message below and, if selected, a fresh headset screenshot. Replies cannot operate your Frame.</p>
<form id="chat">
<details id="settings" open><summary id="settings-label">Endpoint and model settings</summary>
<label>Chat-completions endpoint<input id="endpoint" type="url" placeholder="http://127.0.0.1:1234/v1/chat/completions" required autocomplete="off"></label>
<small>Use an OpenAI-compatible endpoint. Loopback means the computer running Frame Control. Remote endpoints require HTTPS.</small>
<label>Model<input id="model" required placeholder="Model name from your endpoint" autocomplete="off"></label>
<label>API key (optional)<input id="key" type="password" autocomplete="off"></label>
<small>Settings, keys and messages stay in this page’s memory. Reload or close to clear them. No analytics, saved chat history or automatic model discovery.</small></details>
<label><input id="consent" type="checkbox">I allow sending this message to the endpoint shown above.</label>
<label><input id="screenshot" type="checkbox">Also send one headset screenshot with this message. It may contain private information.</label>
<label>Message<textarea id="prompt" rows="3" maxlength="32000" required></textarea></label>
<button id="send" class="primary" type="submit">Send message</button><button id="clear" type="button">Clear everything</button>
</form>
<p id="status" role="status" aria-live="polite"></p><pre id="reply" aria-label="Model reply"></pre>
</section>
<script>
'use strict';
const $ = id => document.getElementById(id);
const key = __FRAME_KEY__;
let generation = 0;
async function api(path, body) {
const response = await fetch(path, {method:body === undefined ? 'GET' : 'POST',
headers:{'X-Frame-UI':key,'Content-Type':'application/json'},
body:body === undefined ? undefined : JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Request failed');
return data;
}
function revoke() { $('consent').checked = false; $('screenshot').checked = false; }
$('endpoint').addEventListener('input', revoke);
$('model').addEventListener('input', revoke);
$('clear').onclick = () => { generation++; $('chat').reset(); $('settings').open = true; $('settings-label').textContent = 'Endpoint and model settings'; $('reply').textContent = ''; $('status').textContent = 'Cleared. A request already sent cannot be recalled.'; };
$('chat').onsubmit = async event => {
event.preventDefault();
if (!$('consent').checked) { $('status').textContent = 'Opt in before sending a message.'; return; }
const current = ++generation;
const body = Object.fromEntries(['endpoint','model','key','prompt'].map(id => [id,$(id).value]));
Object.assign(body, {consent:true,screenshot:$('screenshot').checked});
$('settings-label').textContent = body.model + ' at ' + body.endpoint; $('settings').open = false; $('send').disabled = true; $('reply').textContent = ''; $('status').textContent = 'Sending to ' + body.endpoint + '…'; revoke();
try { const data = await api('/api/assistant/chat', body); if (current === generation) { $('reply').textContent = data.reply; $('status').textContent = 'Reply received.'; } }
catch (error) { if (current === generation) $('status').textContent = error.message; }
finally { $('send').disabled = false; }
};
let confirmation, approvalGeneration = 0;
async function loadApproval() {
const current = ++approvalGeneration;
confirmation = new URLSearchParams(location.hash.slice(1)).get('confirm');
$('approval').hidden = !confirmation;
if (!confirmation) return;
$('approve').disabled = $('reject').disabled = true;
try {
const data = await api('/api/agent/approval?confirmation=' + encodeURIComponent(confirmation));
if (current !== approvalGeneration) return;
$('action').textContent = JSON.stringify(data.action, null, 2);
$('approval-status').textContent = data.approved ? 'Already approved. Ask the agent to retry.' : '';
$('approve').disabled = data.approved; $('reject').disabled = false;
} catch (error) { if (current === approvalGeneration) { $('action').textContent = ''; $('approval-status').textContent = error.message; } }
}
for (const [id, accept] of [['approve',true],['reject',false]]) $(id).onclick = async () => {
const current = approvalGeneration;
$('approve').disabled = $('reject').disabled = true;
try { const data = await api('/api/agent/approval', {confirmation,accept}); if (current !== approvalGeneration) return; $('approval-status').textContent = data.message + (accept ? '. Ask the agent to retry now.' : '.'); }
catch (error) { if (current === approvalGeneration) $('approval-status').textContent = error.message; }
};
window.addEventListener('hashchange', loadApproval); loadApproval();
</script>
</html>
+140
View File
@@ -0,0 +1,140 @@
"""Agent actions and one-use human approvals. No model SDK or network calls here."""
import hashlib
from pathlib import Path
import secrets
import shutil
import subprocess
import threading
import time
class Approvals:
def __init__(self):
self.pending = {}
self.lock = threading.Lock()
def request(self, action):
with self.lock:
now = time.monotonic()
self.pending = {k: v for k, v in self.pending.items() if v['expires'] > now}
if len(self.pending) >= 100:
raise ValueError('Too many pending approvals; wait five minutes')
token = secrets.token_urlsafe(24)
self.pending[token] = {'action': action, 'approved': False, 'expires': now + 300}
return {'confirmation': token, 'action': action, 'approvalPath': '/assistant#confirm=' + token,
'message': 'Ask the user to review and approve this action in Frame Control, then retry with confirmation. Expires in five minutes.'}
def entry(self, token):
entry = self.pending.get(token)
if not entry or entry['expires'] <= time.monotonic():
raise ValueError('Approval expired or unknown; request a new one')
return entry
def inspect(self, token):
with self.lock:
entry = self.entry(token)
return {'action': entry['action'], 'approved': entry['approved']}
def decide(self, token, accept):
with self.lock:
entry = self.entry(token)
if accept is True:
entry['approved'] = True
else:
del self.pending[token]
return {'message': 'Approved for one use' if accept is True else 'Rejected'}
def consume(self, token, action):
with self.lock:
entry = self.entry(token)
if entry['action'] != action or not entry['approved']:
raise ValueError('This exact action needs approval in Frame Control')
del self.pending[token] # consume before starting, including on failure
approvals = Approvals()
def validate(name, args):
fields = {
'launch': {'appid'}, 'install': {'id'}, 'uninstall': {'id'},
'send_text': {'text'}, 'send_file': {'path'}, 'panel': {'id'},
'power': {'action'}, 'keep_awake': {'action'},
}
if name not in fields or not isinstance(args, dict) or set(args) != fields[name]:
raise ValueError('Unknown action or arguments')
if any(not isinstance(v, str) or not v or len(v) > 65536 for v in args.values()):
raise ValueError('Arguments must be nonempty strings (maximum 65536 characters)')
if name == 'power' and args['action'] not in ('suspend', 'reboot', 'poweroff'):
raise ValueError('Unknown power action')
if name == 'keep_awake' and args['action'] not in ('on', 'off', 'status'):
raise ValueError('Expected on, off or status')
action = {'name': name, 'arguments': dict(args)}
if name == 'send_file':
path = Path(args['path']).expanduser().resolve(strict=True)
if not path.is_file() or path.stat().st_size > 16 * 1024**2:
raise ValueError('Choose a regular file of at most 16 MiB')
# Bind approval to bytes, not just a mutable filename.
with path.open('rb') as stream:
data = stream.read(16 * 1024**2 + 1)
if len(data) > 16 * 1024**2:
raise ValueError('File grew beyond 16 MiB')
action['arguments']['path'] = str(path)
action['sha256'] = hashlib.sha256(data).hexdigest()
action['bytes'] = len(data)
return action
def call(server, body):
name, args = body.get('name'), body.get('arguments', {})
action = validate(name, args)
if name in ('install', 'uninstall', 'panel') and not server.FLATPAK_ID.fullmatch(args['id']):
raise ValueError('Expected a Flatpak application ID')
if name == 'launch' and not server.APPID.fullmatch(args['appid']):
raise ValueError('Expected a Steam app ID')
if name == 'keep_awake' and args['action'] == 'status':
return keep_awake(server, 'status')
token = body.get('confirmation')
if not token:
return approvals.request(action)
approvals.consume(token, action)
if name == 'launch':
return server.launch(args)
if name in ('install', 'uninstall'):
return server.flatpak({**args, 'action': name})
if name == 'send_text':
return server.clipboard(args)
if name == 'send_file':
# Stage the reviewed bytes before the existing transfer helper reads them.
import tempfile
with tempfile.TemporaryDirectory(prefix='frame-agent-') as tmp:
source = Path(action['arguments']['path'])
with source.open('rb') as stream:
data = stream.read(16 * 1024**2 + 1)
if hashlib.sha256(data).hexdigest() != action['sha256']:
raise ValueError('File changed after approval')
staged = Path(tmp) / source.name
staged.write_bytes(data)
return {'message': server.push_file(staged)}
if name == 'power':
if server.LOCAL:
raise ValueError('Use the Frame Control power controls to enter the password; MCP never takes passwords')
return server.open_thing({'what': args['action']})
if name == 'keep_awake':
return keep_awake(server, args['action'])
return run_script(server, 'panel-on-frame.sh', [args['id']])
def run_script(server, name, args):
script = server.HERE.parent / 'scripts' / name
if not script.exists() or not shutil.which('zsh') or server.LOCAL:
raise ValueError(name + ' requires a computer with zsh and the matching script installed')
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60)
if result.returncode:
raise ValueError(result.stderr.strip() or 'Script failed')
return {'message': result.stdout.strip()}
def keep_awake(server, action):
# PR #16 owns this interface. Never silently change timers or claim a lease.
return run_script(server, 'keep-awake.sh', [action])
+39 -11
View File
@@ -6,7 +6,7 @@ apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
in the Steam library and gets its own SteamVR panel. Nothing goes through in the Steam library and gets its own SteamVR panel. Nothing goes through
Lepton Development, which wipes its apps on exit. See docs/apks.md. Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py {install APK|list|launch PKG|stop PKG|remove PKG|probe PKG} Python stdlib only. CLI: python3 ui/frame_android.py {info APK|versions APK-or-PKG|install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
""" """
import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
@@ -106,16 +106,36 @@ def _write_meta(d, meta):
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1)) ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
# Called after every install, worked or not, as fn(info, meta, error, seconds):
# info is None if the APK couldn't be read, meta None and error set if it failed.
install_hooks = []
def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None): def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
info = apk_info(apk_path) start, info = time.time(), None
if icon_png: try:
info['icon_png'] = icon_png info = apk_info(apk_path)
check_installable(info) if icon_png:
pkg = info['package'] info['icon_png'] = icon_png
if not PKG_RE.match(pkg): check_installable(info)
raise FrameError(f'unexpected package name {pkg!r}') pkg = info['package']
with _install_lock: if not PKG_RE.match(pkg):
return _install(apk_path, info, pkg, flatscreen, name, source) raise FrameError(f'unexpected package name {pkg!r}')
with _install_lock:
meta = _install(apk_path, info, pkg, flatscreen, name, source)
except FrameError as e:
_after_install(info, None, e, start)
raise
_after_install(info, meta, None, start)
return meta
def _after_install(info, meta, error, start):
for hook in install_hooks:
try:
hook(info, meta, error, time.time() - start)
except Exception:
pass # reporting must never change an install's outcome
def _install(apk_path, info, pkg, flatscreen, name, source): def _install(apk_path, info, pkg, flatscreen, name, source):
@@ -276,7 +296,15 @@ def probe(pkg, wait=20):
def main(): def main():
cmd, *args = sys.argv[1:] or ['help'] cmd, *args = sys.argv[1:] or ['help']
try: try:
if cmd == 'install': if cmd in ('info', 'versions'):
import frame_apk_versions
if cmd == 'info':
print(frame_apk_versions.describe(apk_info(args[0])))
return
info = apk_info(args[0]) if os.path.isfile(args[0]) or args[0].lower().endswith('.apk') else None
r = frame_apk_versions.alternatives(
info['package'] if info else args[0], info.get('version_code') if info else None)
elif cmd == 'install':
r = install(args[0], flatscreen='--vr' not in args) r = install(args[0], flatscreen='--vr' not in args)
elif cmd == 'list': elif cmd == 'list':
r = list_apps() r = list_apps()
+1
View File
@@ -229,6 +229,7 @@ def apk_info(path):
min_sdk = sdk.get('minSdkVersion') min_sdk = sdk.get('minSdkVersion')
info = { info = {
'package': package, 'package': package,
'version_code': manifest.get('versionCode', (None, None))[1],
'version': _text(manifest.get('versionName'), res) or '', 'version': _text(manifest.get('versionName'), res) or '',
'label': _text(app.get('label'), res) or package, 'label': _text(app.get('label'), res) or package,
'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}), 'abis': sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}),
+90
View File
@@ -0,0 +1,90 @@
"""Explain APK requirements and find installable versions in F-Droid's indexes."""
from urllib.parse import quote, urlencode
import frame_android
import frame_catalog
ANDROID = dict(enumerate([
'1.0', '1.1', '1.5', '1.6', '2.0', '2.0.1', '2.1', '2.2', '2.3', '2.3.3',
'3.0', '3.1', '3.2', '4.0', '4.0.3', '4.1', '4.2', '4.3', '4.4', '4.4W',
'5.0', '5.1', '6.0', '7.0', '7.1', '8.0', '8.1', '9', '10', '11', '12',
'12L', '13', '14', '15', '16',
], 1))
REPOS = (('F-Droid', 'https://f-droid.org/repo/'),
('F-Droid archive', 'https://f-droid.org/archive/'),
('IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/'))
NOTE = ('Pick a version whose minimum is Android 11 or lower and that has an '
'arm64-v8a build (or no native code). Installable does not mean every feature works.')
def android_name(sdk):
return 'Android ' + ANDROID[sdk] if sdk in ANDROID else f'Android API {sdk}'
def describe(info):
sdk = info.get('min_sdk')
minimum = f'{android_name(sdk)} (API {sdk})' if sdk else 'not specified'
try:
frame_android.check_installable(info)
verdict = 'Lepton can install this APK. Features may still need services Lepton lacks.'
except frame_android.FrameError as e:
verdict = f'Lepton cannot install this APK: {e}'
return (f"{info['package']} · {info.get('version') or '?'} "
f"(code {info.get('version_code') if info.get('version_code') is not None else '?'})\n"
f"Minimum: {minimum}\nABIs: {', '.join(info['abis']) or 'no native code'}\n{verdict}")
def search_links(package):
q = quote(package, safe='')
return [{'source': name, 'url': url} for name, url in (
('APKMirror', 'https://www.apkmirror.com/?' + urlencode({'post_type': 'app_release', 's': package})),
('APKPure', 'https://apkpure.com/search?q=' + q),
('Uptodown', 'https://en.uptodown.com/android/search/' + q),
('F-Droid', 'https://search.f-droid.org/?q=' + q),
('GitHub', 'https://github.com/search?type=repositories&q=' + q),
)]
def _versions(package, cached_only=False):
versions, errors, seen = [], [], set()
for source, repo in REPOS:
try:
index = frame_catalog.load_index(repo, cached_only=cached_only)
except Exception as e: # one bad repo (dropped download, odd index) mustn't hide the others
errors.append(f'Could not check {source}: {e}')
continue
for v in index.get(package, []):
url = repo + v['name'].lstrip('/')
key = (v['version_code'], v.get('sha256') or url)
if key in seen:
continue
seen.add(key)
versions.append(dict(v, url=url, source=source))
return versions, errors
def alternatives(package, current_version_code=None):
"""At most eight releases, preferring arm64-only builds over universal builds."""
versions, errors = _versions(package)
versions = [v for v in versions if v['version_code'] != current_version_code]
total = len(versions)
versions.sort(key=lambda v: (v['abis'] == ['arm64-v8a'], v['version_code']), reverse=True)
releases = {}
for v in versions:
releases.setdefault(v['version'], v)
versions = sorted(releases.values(), key=lambda v: v['version_code'], reverse=True)[:8]
return {'package': package, 'versions': versions, 'total': total,
'links': search_links(package), 'note': NOTE, 'errors': errors}
def install(package, url):
# Resolve the selection again: the client cannot supply a trusted hash or arbitrary URL.
records, _ = _versions(package, cached_only=True)
version = next((v for v in records if v['url'] == url), None)
if not version:
raise frame_android.FrameError('That version is no longer available; check the APK again')
apk = frame_catalog.fetch_apk({'a': version['url'], 'h': version['sha256'], 'n': package})
info = frame_android.apk_info(apk)
if info['package'] != package or info.get('version_code') != version['version_code']:
raise frame_android.FrameError('The downloaded APK does not match the selected version')
return frame_android.install(apk, source=version['source'])
+53
View File
@@ -0,0 +1,53 @@
"""Explicit, per-request forwarding to a user-chosen chat-completions endpoint."""
import base64
import json
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
raise ValueError('Endpoint redirected; enter its final URL explicitly')
def chat(body, screenshot):
if body.get('consent') is not True:
raise ValueError('Opt in before sending a message')
endpoint, model, prompt = (body.get(k) for k in ('endpoint', 'model', 'prompt'))
if any(not isinstance(v, str) or not v.strip() for v in (endpoint, model, prompt)):
raise ValueError('Endpoint, model and message are required')
if len(prompt) > 32000 or len(model) > 200 or len(endpoint) > 2048:
raise ValueError('Message, model or endpoint is too long')
url = urlsplit(endpoint)
if not url.hostname or url.username or url.password or url.fragment or url.query:
raise ValueError('Use an endpoint URL without credentials, query or fragment')
if url.scheme != 'https' and not (url.scheme == 'http' and url.hostname in ('localhost', '127.0.0.1', '::1')):
raise ValueError('Use HTTPS, or HTTP on loopback for a local model')
key = body.get('key', '')
if not isinstance(key, str) or len(key) > 4096 or '\n' in key or '\r' in key:
raise ValueError('Invalid API key')
content = prompt
if body.get('screenshot') is True:
png = screenshot()
if len(png) > 12 * 1024**2:
raise ValueError('Screenshot is too large')
content = [{'type': 'text', 'text': prompt}, {'type': 'image_url', 'image_url': {
'url': 'data:image/png;base64,' + base64.b64encode(png).decode()}}]
payload = {'model': model, 'messages': [{'role': 'user', 'content': content}], 'stream': False}
headers = {'Content-Type': 'application/json'}
if key:
headers['Authorization'] = 'Bearer ' + key
request = Request(endpoint, data=json.dumps(payload).encode(), headers=headers)
# No environment proxy or redirects: credentials/context go only to the chosen URL.
try:
with build_opener(ProxyHandler({}), NoRedirect()).open(request, timeout=60) as response:
raw = response.read(2 * 1024**2 + 1)
if len(raw) > 2 * 1024**2:
raise ValueError('Endpoint response is too large')
answer = json.loads(raw)['choices'][0]['message']['content']
if not isinstance(answer, str):
raise ValueError('Expected a text reply')
except Exception:
# Provider error bodies and URLs can contain credentials or echoed prompts.
raise ValueError('Endpoint request failed or returned an unsupported reply; check URL, model and credentials') from None
return {'reply': answer}
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more