Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 03eaae2d39 Docs: join the list item
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:04:00 +10:00
saphidandClaude Opus 5.5 fe132e3e69 Docs: what the Frame spends power and heat on while idle, the controls, and a plan to leave it on cheaply
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:03:50 +10:00
saphidandClaude Opus 5.5 cce3d94030 Frame doctor: placeholder for the tailnet name, as docs/tailscale.md does; fix a section reference
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:41:53 +10:00
saphidandClaude Opus 5.5 e12464c8a6 Record the remote-wake tests and a Frame doctor runbook
Wake-on-WLAN arms without sudo, but the Frame never woke from a magic
packet in deep or s2idle: the WCN7850 comes back in MHI RESET and Wi-Fi
stays dead until a clean reboot. Unloading ath12k to recover oopsed the
kernel, which left truncated Steam files and broken displays for a boot.

docs/frame-doctor.md collects checks and fixes for that incident and every
other crash and failure found in the Frame's journal, coredumps and these
docs, as the spec for a future scripts/frame-doctor.sh. Reviewed over three
rounds by GPT-6 Astra (xhigh).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:13:11 +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
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 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 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
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
66 changed files with 3499 additions and 473 deletions

No files matched your search

+2
View File
@@ -28,6 +28,8 @@ desktop or panels.
| Launch an app inside the desktop panel | the script's header comment | `scripts/run-on-frame.sh` |
| Mac GUI over all of this | `README.md` → Frame Control | `scripts/frame-ui.sh` |
| iPhone/iPad app (server runs on the Frame, `FRAME_LOCAL=1`) | `docs/iphone.md` | `ios/`, `ui/local-bin/ssh` |
| Frame unreachable, Wi-Fi dead, Steam won't start (doctor runbook) | `docs/frame-doctor.md` | — |
| Power draw, heat, fan, battery wear, quiet-mode plan | `docs/power-and-heat.md` | — |
| Recovery images, factory reset, boot loops | `docs/recovery-and-images.md`, `docs/how-the-frame-works.md` | `~/Downloads/steam-frame-recovery/` |
| Test without the headset (the Frame OS image's own sshd) | `tests/frame-container/README.md` | `tests/frame-container/frame-image.sh` |
| What's still unverified | `docs/open-questions.md` | — |
+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,
});
+2
View File
@@ -36,6 +36,8 @@ jobs:
run: python -m unittest discover -s tests -v
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js
- 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 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,
});
+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.
+12 -3
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)
[![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>
<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>
</div>
@@ -170,13 +175,17 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
## Feedback
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)
with:
Windows and Linux. The quickest way is the
[feedback form](https://frame-control.pages.dev/feedback/): no GitHub account
needed, and it opens an issue here. Please include:
- what you tried and what happened
- your computer's OS and your SteamOS build (Steam Settings → System)
- 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
This repo also holds the scripts behind the app and field notes on how the
+490
View File
@@ -0,0 +1,490 @@
# Frame doctor runbook
Checks and fixes for a Frame that's unreachable, crashing, or whose Steam,
SteamVR, Lepton or panels misbehave. A future `scripts/frame-doctor.sh` should
run the checks in section order, print OK, WARN or BROKEN for each, and apply
only the fixes marked **safe**. Anything marked **ask** needs the user's OK,
and anything marked **user** needs a hand on the headset.
Sources are the Frame's own journal and `coredumpctl` history (boots from
2026-09-25 to 2026-09-28) and this repo's docs. Each entry cites where it came
from. Dates are when a fact was seen. BUILD_IDs were 20260922.6101926 until
2026-09-26 and 20260925.6191901 after.
## Never do these
- `modprobe -r ath12k` on a wedged Wi-Fi chip. It oopsed the kernel on
2026-09-28 and caused the displays-broken, Steam-damaged boot in section 3.
- Leave WoWLAN armed. The next sleep breaks Wi-Fi until reboot (section 2).
- Suspend the Frame from a script. Nothing can wake it remotely (section 6).
- Let the SteamOS health checks count up to their repair. The SteamVR one
re-extracts Steam at 3 failures and tries to switch OS slots at 4 (section 4).
- Kill `gamescope` to stop a gamescope crash loop. Kill the orphaned SteamVR
processes instead (section 3).
- Write the sudo password to disk or logs.
- Leave `power.pauseCompositorOnStandby` or `power.turnOffScreensTimeout`
changed after testing (section 3).
- Force a power-off (holding Power) or reset while Steam is extracting or
repairing. That's how files got truncated on 2026-09-28. Use an orderly
`systemctl reboot`/`poweroff` or the power menu. Holding Power is for an
unresponsive Frame, with the user's involvement.
- Run long diagnostics while a Steam or SteamVR restart loop is live without
freezing the health-check trackers first (section 4). The repair threshold is
3 SteamVR failures, and a loop reaches it in under a minute.
## 0. Reaching the Frame
| Path | How | Works when |
|---|---|---|
| Tailscale | `ssh frame` (`frame.<tailnet>.ts.net`) | Wi-Fi up, and Tailscale on the Mac and the Frame |
| LAN | `ssh -o HostName=192.168.1.237 -o HostKeyAlias=frame.<tailnet>.ts.net frame`, or `frame.local` | Wi-Fi up. The alias avoids "Host key verification failed" |
| USB-C | Same, with `HostName=10.86.200.233` | Cable to the Mac, even with Wi-Fi dead. The Mac gets `en9` "Steam Frame" 10.86.200.234/29 (`networksetup -listallhardwareports`) |
| ADB over USB-C | `adb -s frame shell` | SSH refused, for example after Developer Mode was lost ([how-the-frame-works.md](how-the-frame-works.md), boot-loop row) |
- **Asleep means off the network (verified 2026-09-27, unreachable for about
2.5 h).** Every path times out and nothing remote wakes it
(section 6). **user**: press power. `tailscale status | grep frame` on the
Mac shows "offline, last seen N ago".
- **`frame` alias doesn't resolve.** The Mac's Tailscale is off. Use
`frame.local` ([tailscale.md](tailscale.md)). Bare `frame` doesn't resolve on
macOS. Check with `dns-sd -G v4 frame.local` ([ssh.md](ssh.md)).
- **Pairing answers `403 "please put the Steam client in pairing mode"`.**
**user**: Steam, then Settings → Developer → Pair new host. `connect.sh`
retries for 2 min ([ssh.md](ssh.md)).
- **iPhone app can't use devkit pairing.** It only installs an RSA key, and
Citadel signs RSA with SHA-1, which OpenSSH 9.7 rejects. Use ed25519 and
password pairing instead ([ssh.md](ssh.md), 2026-09-27).
- **Locked out after `connect.sh --harden`.** Undo with `sudo rm
/etc/ssh/sshd_config.d/01-frame-keys-only.conf && sudo systemctl reload sshd`
(**ask**, over USB-C or ADB) ([ssh.md](ssh.md)).
- **No SSH at all (Developer Mode off).** Run `scripts/serve-bootstrap.sh`, and
the **user** types `curl -fsS mac.local:8765|bash` in Konsole. Stop the
server afterwards, because it's plain HTTP ([ssh.md](ssh.md)).
- **Tailscale exposes every loopback port** (8080 Steam DevTools, 5555
unauthenticated ADB, 27062, 3389) to the tailnet. Check read-only with
`~/.local/bin/tailscale debug prefs | grep ShieldsUp` (the CLI isn't on `PATH`; verified 2026-09-28) and the tailnet ACLs. Report it
as a WARN. `~/.local/bin/tailscale set --shields-up` is a mitigation, not a check, and it
also blocks inbound SSH over Tailscale, so it's **ask**, and only with
another way in available ([tailscale.md](tailscale.md)).
- **sudo:** `printf '%s\n' "$PW" | ssh frame 'sudo -S -p "" …'`. It's the
password the user set on the Frame.
## 1. Boot and crash history
```sh
ssh frame 'uptime; journalctl --list-boots --no-pager | tail -n 6'
ssh frame 'journalctl -b -1 -k --no-pager -q | grep -aE "Unable to handle kernel|Internal error|Kernel panic" | tail -n 3'
ssh frame 'coredumpctl list --no-pager --since -1d'
```
- **Kernel oops in the previous boot.** Report "oops observed". An oops alone
doesn't prove a reset, because Linux can keep running after one. Classify the
reset as unclean only if the oops is among the last lines of that boot and no
shutdown lines follow
(`journalctl -b -1 -q -n 30 | grep -aE "systemd-shutdown|Reached target.*(Reboot|Power)"`
is empty). On 2026-09-28 the oops was the last thing logged at 20:57:53. After
an unclean reset, check sections 3 and 4 closely.
- **A boot ending with no shutdown lines and no errors.** On 2026-09-26 there
were five boots of 0–12 min like this (−12, −9, −8, −7, −5), with nothing
failing beforehand. They were probably hard power-offs during setup. Treat
them as unexplained, not as crashes.
- **Crash signatures seen so far** (all `coredumpctl`, UID 1000):
| When | What crashed | Cause | Section |
|---|---|---|---|
| 09-25 21:02–21:03 | vrcompositor SEGV, steamwebhelper SEGV, then Android composer, surfaceflinger and gamescope ABRT | Lepton crash cascade. Two `pasta` processes were both failing to listen on port 16385 just before | 7 |
| 09-25 22:30–22:42 | `app_process64` ×3 | Android apps during APK testing | 7 |
| 09-25 23:20 | `ffmpeg` | hardware H.264 encoder | 10 |
| 09-26 13:56–22:45, 09-27 09:51 | `chromium-xr/chrome` ×16 | Chromium XR (panels, Mac view) | 8 |
| 09-26 21:11–21:49 | XRService ABRT ×9, vrcompositor SEGV ×5, gamescope ABRT ×4 | leftover SteamVR processes from a failed start (29 Steam restarts, 31 SteamVR failures that boot) | 3 |
| 09-28 16:27–17:49 | `app_process64` ×4 | Android runtime amid `binder_user_error` floods | 7 |
| 09-28 17:01 | `kdeconnectd` | SMS plugin during device teardown | 9 |
| 09-28 20:58–21:39 | vrcompositor SEGV ×10, XRService ×15, steamwebhelper ×2 | broken displays after a kernel oops | 3 |
Per-boot counters a doctor should print:
```sh
ssh frame 'for b in 0 -1; do
k=$(journalctl -b $b -k -q) || { echo "boot $b: journal unreadable"; continue; }
u=$(journalctl -b $b --user -u steam.service -q) || { echo "boot $b: user journal unreadable"; continue; }
s=$(journalctl -b $b -q) || { echo "boot $b: journal unreadable"; continue; }
echo "boot $b dsi=$(grep -ac "wait for video done" <<<"$k") steam_restarts=$(grep -ac "Scheduled restart" <<<"$u") steamvr_fail=$(grep -ac "steamvr.service: Failed" <<<"$s")"
done'
```
Report an unreadable journal as unknown, not as zero. What matters is
whether the counts are **still rising**, so run it twice a minute apart. One
or two SteamVR start failures around boot are normal
([how-the-frame-works.md](how-the-frame-works.md), boot-loop row). Healthy
boots on 2026-09-28 were 0 / 0 / 0. The 2026-09-26 21:22 boot reached
0 / 29 / 31 (leftover processes), and the 2026-09-28 20:58 boot reached
424 / 61 / 62 (broken displays), both rising every ~15 s.
## 2. Wi-Fi
| Check | Healthy | Broken |
|---|---|---|
| `nmcli -t d \| grep ^wlan0` | `wlan0:wifi:connected:…` | `wlan0:wifi:unavailable:` |
| `journalctl -b -k \| grep -a ath12k` | none, or a few at boot | `failed to wakeup from wow: -110`, `Resuming from non M3 state (RESET)`, `WMI_PDEV_SET_PARAM_CMDID timeout`, `fail to start mac operations` |
| `iw phy phy0 wowlan show` | `WoWLAN is disabled.` | `wake up on magic packet` |
- **WoWLAN armed (safe).** Disarm it by UUID, because the user may have made
same-name duplicates. `default` means "use NetworkManager's global
`wifi.wake-on-wlan`". The Frame sets none (checked 2026-09-28), so it falls
back to `ignore`, which leaves the chip untouched and doesn't clear an armed
chip. `0` disarms it:
```sh
set -e
U=$(nmcli -t -f UUID,DEVICE c show --active | awk -F: '$2=="wlan0"{print $1}')
[ -n "$U" ] || { echo "no active connection on wlan0"; exit 1; }
nmcli -g 802-11-wireless.wake-on-wlan c show "$U" # record the old value
systemd-run --user --wait --pipe -q nmcli c modify "$U" 802-11-wireless.wake-on-wlan 0
systemd-run --user --wait --pipe -q nmcli device modify wlan0 802-11-wireless.wake-on-wlan 0
iw phy phy0 wowlan show | grep -q "WoWLAN is disabled" || { echo "still armed"; exit 1; }
```
Use the **active** connection on wlan0, because there can be same-name
duplicates. `c modify` saves the setting. `device modify` changes only
WoWLAN on the live device, unlike `device reapply`, which would also apply
any other saved changes such as IP or DNS. Tested 2026-09-28: Wi-Fi stayed
connected. NetworkManager only allows the
modify under `systemd-run --user`. From SSH it's `auth`. Leave the profile
at `0`, since that stays safe even if a global `wifi.wake-on-wlan` is added
later. Setting the recorded old value back is **ask**. On 2026-09-28 the
profile was set back to `default` by hand. If the Wi-Fi is `unavailable`,
there's no active connection, so this has to wait for the reboot, and then
arming comes from the profile.
- **`unavailable` after resume (user/ask).** Do a **clean** reboot: power
menu, or `sudo systemctl reboot` over USB-C. Never reload the module.
- **Duplicate "ThisIsTheWifi" profiles.** Ones with `TIMESTAMP-REAL` `never`
are unused. Deleting them is **ask**.
## 3. Displays, SteamVR, gamescope
| Check | Healthy | Broken |
|---|---|---|
| `journalctl -b -k \| grep -ac "wait for video done"` | `0` | hundreds (`msm_dsi ae94000.dsi / ae96000.dsi`) |
| `coredumpctl list vrcompositor --since -10min` | none | SEGV every ~15 s |
| `grep -a "failed to wait for present" ~/.local/share/Steam/logs/vrcompositor.txt` | none recent | `WaitForPendingPresent: failed to wait for present` |
| `journalctl -b --user -u steamvr.service \| grep -a "left-over process"` | none | `Found left-over process … (vrserver) … (vrcompositor) in control group` |
| journal `gamescope` | quiet | `rendervulkan.cpp:2181 … Assertion '!modifiers.empty()'` about once a second |
- **Broken displays (DSI timeouts).** The chain is: the GPU can't present,
vrcompositor SEGVs on its first frame, `steamvr.service` fails and stops the
gamescope VR session, and gamescope and Steam get SIGKILLed. The user sees
"There was an issue launching Steam". Fix (**ask/user**): a **clean**
reboot. An unclean reset after a kernel oops caused it, and the clean reboot
had 0 DSI errors (2026-09-28). Don't touch Steam while this is happening.
- **Leftover SteamVR processes (2026-09-26 21:22 boot).** A first
`steamvr.service` start failed on `dependency`, its vrserver, XRService and
vrcompositor kept running, and each restart crashed against them. The same
fix as the next item applies, with the same guard.
- **gamescope crash loop on `!modifiers.empty()`** (verified 2026-09-25,
[apks.md](apks.md)). gamescope keeps attaching to SteamVR processes orphaned
from a dead session. The broad fix is
`for p in vrdashboard vrcompositor vrserver; do pkill -TERM -x $p; done`,
and it recovers within about a minute. That kills **every** matching
process, including a working session, so it's always **ask**. A doctor may
signal automatically only **individually verified stale PIDs**, and only
when **all** of these hold:
- The loop is live: new vrcompositor/gamescope crashes in the last 2
minutes, and `NRestarts` rising between two reads.
- The process started before the current `steamvr.service` main process:
compare `ps -o pid,lstart,args -C vrserver,vrcompositor,vrdashboard`
with `systemctl --user show steamvr.service -p ExecMainStartTimestamp`.
- The journal ties it to the failed run:
`Found left-over process <pid> (…) in control group`.
Re-read `/proc/<pid>/stat` start time and `comm` just before signalling, and
signal by number, never by name. A process that's merely outside
`steamvr.service`'s cgroup could be a legitimate launch, so that's **ask**.
- **Standby test settings left on.** Check that `vrcmd --get-settings`
(or `~/.config/openvr/config/steamvr.vrsettings`) shows
`power.pauseCompositorOnStandby` = 1 and `power.turnOffScreensTimeout` = 5.
If they differ, report a WARN and leave them alone (**ask**), since the user
may want them. If a doctor run changes them for a test, it must snapshot both
values first, including whether they were set in `steamvr.vrsettings` at all.
Afterwards it restores exactly those values, removing the keys if they were
absent, rather than the defaults 1 and 5.
The bool setter needs `1`/`0`, not `true`
([how-the-frame-works.md](how-the-frame-works.md)).
- **Dashboard open over an app** (`visible-blurred` just means it's open).
Run `SteamClient.OpenVR.VROverlay.HideDashboard()` over CDP on port 8080
only when the doctor itself is driving an app test. Otherwise it's **ask**,
because the user may have opened it.
- **Steam launch stuck in standby** at `ShowInterstitials`/`CreatingProcess`
(`console_log.txt`). Run `SteamClient.Apps.ContinueGameAction(<action id>,
"<appid>", "<task>")` over CDP.
## 4. Steam client and the SteamOS health checks
| Check | Healthy | Broken |
|---|---|---|
| `systemctl --user show steam.service -p NRestarts` | `0` or stable | climbing every ~15 s |
| `tail -n 40 ~/.local/share/Steam/logs/connection_log.txt \| grep -a "Logged On"` | `[Logged On, …] [U:1:<id>]` | only `[Logged Off, 0, 0] [U:1:0]` |
| `grep -a BVerifyInstalledFiles ~/.local/share/Steam/logs/steam_output.log` | none | `<file> is N bytes, expected M`, `bad symlink …` |
| last line of `steam_output.log` | client running | `Installing update...` or `Extracting package...` for minutes |
| `pgrep -af child-update-ui` + `/proc/<pid>/wchan` | none | `drm_syncobj_array_wait_timeout` |
| `cat /run/user/1000/steam{,vr}-short-session-tracker; ls -l` those files | empty | `frog…` / `frog:glasses:…` building up |
Check section 3 first. If the displays are broken, Steam can't get past its
first frame, whatever the files look like.
**The two health checks** (read from `/usr/share/deckard/`, 2026-09-28):
- `steam-health-check` (run by `steam.service`) appends `frog` to
`steam-short-session-tracker` for each run that fails in under 120 s or lasts
under 5 s. At 5 it runs `do_repair`. On BUILD_ID 20260925.6191901 the
script deletes `~/.steam` (keeping `registry.vdf`) and then either extracts
`/usr/lib/steam/steam.tar.zst` (705 MB) over `~/.local/share/Steam` (the
"unpacked" install this Frame has) or, on an overlay install, deletes the
upper-dir files that shadow `/usr/local/steam`. Then it touches
`.install-complete`. It also repairs at **every Steam start** if
`.install-complete` is missing, whatever the counter says.
- `steamvr-health-check` (run by `steamvr.service`) appends `frog:glasses:`
for each failed or under-10-s SteamVR run. At 3 it runs `steam-health-check
--repair-now`. At 4 it also runs `steamos-bootconf set-mode reboot-other`,
which fails as non-root.
- **What a repair erases isn't consistent across notes.** On 2026-09-26
(BUILD_ID 20260922.6101926) the boot-loop row in
[how-the-frame-works.md](how-the-frame-works.md) records that all of
`~/.local/share/Steam` was deleted, including games, login and Developer
Mode. On 2026-09-28 the scripts above only extract over it, a repair ran at
21:13 (`.install-complete` mtime), and the login survived. Treat any repair
as possibly destructive. Before a restart that could trigger one, check that
`.install-complete` exists.
- **Stop them counting while you fix the cause (safe, resets at boot).** Do
this **first**, right after connecting, if `NRestarts` or either tracker is
rising, before any long checks:
```sh
rc=0
for f in /run/user/1000/steam-short-session-tracker /run/user/1000/steamvr-short-session-tracker; do
{ [ -e "$f" ] || : > "$f"; } && chmod u+w "$f" && : > "$f" && chmod 444 "$f" || rc=1
# verify: empty and not writable
[ -e "$f" ] && [ ! -s "$f" ] && [ ! -w "$f" ] && echo "frozen $f" || { echo "NOT frozen $f"; rc=1; }
done
exit $rc
```
A doctor must stop and not restart Steam or SteamVR unless this exits 0.
It's idempotent, so run it on files that are already 444. Both were
already 444 on 2026-09-28, applied by an earlier session. This only stops
the **counting**. The start-time repair when `.install-complete` is missing
still runs. Re-apply after every reboot while the loop's cause is unfixed.
Fixes:
- **Verify files yourself (safe, read-only).** The record is
`~/.local/share/Steam/package/steam_client_<branch>_linuxarm64.installed`,
with lines of `path,size;mtime;crc32` (size `-1` is a directory). Compare
sizes and `zlib.crc32` (13,518 files on 2026-09-28). `steam_output.log` is
rewritten on every launch, so copy it before the next restart. `bad symlink`
reports taken mid-extraction are transient.
- **Truncated files (ask).** Restart Steam (`systemctl --user restart
steam.service`), and it re-verifies and re-extracts from `package/`.
Preconditions:
- The displays are healthy.
- The trackers are frozen.
- `.install-complete` exists.
- The updater is idle: the `steam_output.log` tail hasn't changed for 60 s
and the steam process isn't writing (`/proc/<pid>/io` `write_bytes` is
steady).
- No game or app is running.
Re-verify afterwards.
- **Updater deadlocked on the update UI.** Kill only the `-child-update-ui`
process, and the install continues (worked 2026-09-26). On 2026-09-28 it
was followed by a truncated `steamui.so`, so re-verify afterwards. If the
deadlock came from broken displays, fix those first.
- **Stale pending install (ask, with backup).** `package/steam_client_<branch>_linuxarm64`
with no extension means an install is pending. Only act when all of these hold:
- `cmp` shows it's identical to `.manifest`.
- The verify is clean.
- The updater is idle (as above).
Then stop Steam, move it to `~/.cache/frame-control/…pending-backup`, and
start Steam. The log should
show `Nothing to do`, then `Verification complete`, then webhelpers.
- **Heavy repair (ask).** `steam-health-check --repair-now`, or the boot
menu's `Repair Steam Installation` (section 12).
- **Dead ends (don't repeat).**
- `STEAM_EXTRA_ARGS=-no-child-update-ui` still draws GLX in-process and
blocks.
- With `DISPLAY` unset, Steam exits ("XOpenDisplay failed"), with no text
fallback.
- Xvfb has no GLX visual here.
- Steam's launch path is `steam.service` → `/usr/share/deckard/select_steam.sh
RUNSTEAM.sh`. Runtime drop-ins in `/run/user/1000/systemd/user/steam.service.d/`
are cleared at reboot. Remove any you add.
- **`create-shortcut` refuses ids with hyphens** (`missing/invalid arguments`).
Ids must match `^[A-Za-z_][A-Za-z0-9_.]+$`. It reports "Steam client is not
running" when Steam is down, and re-running finishes the install without a
re-upload ([sideloading.md](sideloading.md)).
## 5. Idle sleep and keep-awake
- **Frame slept mid-task.** SSH doesn't count as activity, and Steam's idle
timer (`system_idle_suspend_ac_sec` 3600, `…_battery_sec` 900) suspends it.
The journal shows `Switching to power state: [ k_ESystemPowerState_Sleep ]`.
Fix (**safe**): `scripts/keep-awake.sh on` before long work and `off` after.
It uses one shared unit and one saved-settings file. So a doctor records
whether `fc-keep-awake` was already active, and runs `off` only if it was
the one that turned it on. Otherwise it releases another task's lock and
restores that task's saved timers.
It sets both timers to 0 and holds the `fc-keep-awake` user-unit inhibitor.
A plain SSH-session inhibitor is refused.
- **Check:** `systemctl --user is-active fc-keep-awake`,
`systemd-inhibit --list | grep "Frame Control"`. WARN if it's held with no
agent working, since that drains the battery on battery power.
- **Charging shows "Discharging" at ~0 W while full on a charger.** This is a
reporting quirk. Treat under 0.5 W on a charger as "not charging"
([how-the-frame-works.md](how-the-frame-works.md)).
## 6. Remote wake
It doesn't work on this build. WoWLAN arms, but the WCN7850 is reset in both
`deep` and `s2idle`, packets don't wake it, and Wi-Fi is dead after resume.
Tested 2026-09-28. Details are in [how-the-frame-works.md](how-the-frame-works.md)
and [open-questions.md](open-questions.md). Doctor checks: `cat
/sys/power/mem_sleep` should read `s2idle [deep]` (resets at boot), and WoWLAN
should be disabled.
## 7. Lepton (Android)
| Check | Broken sign |
|---|---|
| `podman ps --format '{{.Names}} {{.Ports}}'` | two containers with the same name or instance, or both bound to the same host port. Several instances with different ports are normal ([apks.md](apks.md)) |
| journal `pasta` | `Listen failed for HOST TCP port 0.0.0.0/16385: Address already in use` repeating |
| journal | `android.hardware.graphics.composer@2.1-service` or `surfaceflinger` aborts right after an app crash |
| `dmesg` / journal | floods of `binder_user_error: N callbacks suppressed` near `app_process64` crashes |
| journal | `Clearing baked app data due to non steamlaunch container` |
- **Duplicate Lepton containers or port clash (2026-09-25 21:01).** Two
`pasta` instances fought over 16385, and a minute later the compositor,
webhelper, Android composer, surfaceflinger and gamescope crashed together.
Fix (**ask**): find the two instances that share the port (`podman ps`,
`ss -ltnp | grep 16385`) and stop only the duplicate. Leave other instances
running.
- **Lepton's graphics HAL aborts after an app crash, taking the container down**
(3 times on 2026-09-25). It's intermittent, so retry ([apks.md](apks.md)).
Then check section 3 for a gamescope loop.
- **All ADB-installed apps gone.** Lepton Development wipes its data whenever
it exits outside a Steam launch. Use `install-apk.sh` (a per-app Steam
launch, whose data survives), or the launch option `LEPTON_NO_CLEANUP=1
%command%` (inferred) ([apks.md](apks.md)).
- **App dies on first file write with `ENOENT`.** Its `STEAM_COMPAT_DATA_PATH`
is outside `~/.local/share/Steam`. Use `steamapps/compatdata/<id>`.
- **Lepton won't start outside Steam.** Set `IS_PARENT=true` and use `setsid
--wait`. "unbound variable" means `STEAM_COMPAT_SHADER_PATH` is unset
([apks.md](apks.md)).
- **App compatibility, not a fault** ([apks.md](apks.md)):
- Compose older than 1.11, SDL2/Kivy and Godot 4.3 crash on the missing
clipboard service.
- `INSTALL_FAILED_OLDER_SDK` means minSdk is over 30.
- `INSTALL_FAILED_NO_MATCHING_ABIS` means there's no arm64 build.
- `monkey` returning `-5` means you should launch the activity directly.
`--brief` prints a metadata line first, so take the last line:
`adb -s $S shell am start -W -n "$(adb -s $S shell cmd package resolve-activity --brief -c android.intent.category.LAUNCHER <pkg> | tail -n 1)"`.
## 8. Chromium XR (panels, Mac view, WebXR)
- **16 crashes on 2026-09-26/27.** The logs showed `Failed to create a
temporary file for memory-mapping: No such process (3)`, then `Received
signal 11 SEGV_MAPERR`. The cause isn't known yet. Check with
`coredumpctl list chrome --since -1d`.
- **Zygote crash about 30 s after a Steam-launched start.** Steam's
`gameoverlayrenderer.so` is in `LD_PRELOAD`, and the launcher strips it
(verified 2026-09-27, [webxr-chromium.md](webxr-chromium.md)). Check that
the running chrome's `/proc/<pid>/environ` has no `gameoverlayrenderer`.
- **XR process seccomp crash (syscall 209) or `VRInitError_Init_Internal`.**
The launcher runs with `--disable-seccomp-filter-sandbox`. Use that profile
only for VR sites ([webxr-chromium.md](webxr-chromium.md)).
- **Mac view kept working when Steam was down** (2026-09-28). It's a separate
Chromium talking to the Mac over the LAN. It's a useful way in when Steam is
broken, but it's killed by any reboot and needs relaunching.
## 9. KDE Connect
- **`kdeconnectd` crashed on 2026-09-28 17:01** in `kdeconnect_sms.so` under
`Device::~Device` (device teardown). Check whether it's still running with
`pgrep -f frame-control/kdeconnect/root/usr/lib/kdeconnectd`. Fix
(**safe**): restart it the way this repo launches it. A doctor should
report a missing daemon rather than guess.
## 10. Streaming and capture
- **`ffmpeg` crash with `h264_v4l2m2m`** (hardware encoder, 2026-09-25/26).
Use `libx264 -preset ultrafast -tune zerolatency`
([how-the-frame-works.md](how-the-frame-works.md)).
- **`vrcmd --screenshot` writes nothing.** Use `ui/frame_vrshot.py`
(`IVRScreenshots`).
- **No Mac cursor in the VNC mirror.** Load `scripts/mac-cursor-ring.lua` in
Hammerspoon on the Mac (`dofile(".../scripts/mac-cursor-ring.lua")` in
`~/.hammerspoon/init.lua`). It isn't a standalone script. Toggle it with
ctrl+alt+cmd+M.
- **Remmina asks for the Mac login password.** macOS offers RFB type 30
first. Seed the password with `--update-profile … --set-option password`
([streaming.md](streaming.md)).
## 11. Panels
- **Window stays on the default panel.** Run one `panel-on-frame.sh` at a
time. Tag windows by hand with `DISPLAY=:0 xprop -id <win> -f STEAM_GAME 32c
-set STEAM_GAME <id>` ([panels.md](panels.md)).
- **Single-instance apps** (Remmina, KDE). Close them in Plasma first.
- **Wayland-only apps** can't be floated this way.
- **Dragging selects instead of scrolling.** That's by design for tagged
windows (laser mode).
- **`Failed to get app info`** for a made-up id is benign.
## 12. Boot loop, recovery, re-image
Work down this list, least destructive first ([recovery-and-images.md](recovery-and-images.md)).
The boot menu is inferred from Valve's docs and hasn't been tried on this Frame.
1. **If the Frame is reachable, freeze the health-check trackers first and
verify them** (section 4), then diagnose. A reboot clears the freeze and
restarts the failing services, and 3 SteamVR failures trigger a repair.
2. **Clean reboot** only when the diagnosed fault needs one (broken displays,
dead Wi-Fi). Straight after reconnecting, freeze and verify the trackers
again before anything else.
3. **Boot menu (user):** shut down cleanly if the Frame responds. Hold Power
~10 s until the LED is off only if it doesn't, and never while Steam is
extracting or repairing. Then power on holding **AUX** (top button). Choose `Previous` (the other A/B slot, keeps data),
then try `Repair Steam Installation`.
4. **`Erase User Data`** wipes `~`: SSH keys, Tailscale, Flatpaks and setup
(**ask**).
5. **Re-image** with `steamframe-oobe-repair-<build>` over USB or cable/EDL
(`qdl`). Copies are in `~/Downloads/steam-frame-recovery/` (**ask**).
## What a doctor script should do
1. Find a path (Tailscale, then LAN, then USB), and report which one worked.
2. Print uptime, the last boots, and whether the previous boot ended in an
oops.
3. Run the read-only checks in sections 2–11 and print one line each: OK,
WARN or BROKEN.
4. **Order matters.** If a restart loop is live (`NRestarts` or a tracker
rising between two reads a few seconds apart), freeze the trackers
**before** any other check. Then fix displays before Steam. After any
reboot, check the trackers again, because they reset.
5. Apply only **safe** fixes, printing each command. For reboots, deleting
profiles, heavy repairs and anything touching a user profile: print the
fix and ask.
6. Never do anything under "Never do these".
7. Re-run the checks after any fix and report the before and after.
8. The fake-Frame harness (`fakeframe-ctl sleep|disk-full|sshd|devkit-service|keys`,
[testing.md](testing.md)) can exercise the unreachable, disk-full and
no-SSH branches without a headset.
## Incident 2026-09-28
| Time | What happened |
|---|---|
| 19:36 | WoWLAN armed, `deep` suspend, magic packets sent. No wake. Power-button resume: chip in MHI RESET, Wi-Fi dead. User restarted. |
| 20:10 | WoWLAN disarmed. Clean boot, no DSI errors. |
| 20:29 | `s2idle` via sudo, WoWLAN re-armed, suspend. No wake, same chip reset, Wi-Fi `unavailable`. |
| 20:57:53 | Over USB-C: `modprobe -r ath12k`, and the **kernel oopsed** and the Frame reset itself. |
| 20:58 | Boot with `steamclient.so` truncated (18.6 of 50.3 MB) and DSI timeouts from +28 s. Steam "couldn't connect", then "There was an issue launching Steam". Mac view still worked. |
| 21:00–21:13 | Steam re-extracts and hangs on "Installing update..." (update UI stuck on the GPU). Killing the UI child let it continue, and `steamui.so` was later found truncated. The health-check repair ran at 21:13. |
| 21:28 | Own CRC check: all 13,518 files OK. |
| 21:30 | Moved aside the identical pending manifest. Steam reached login, then died every ~15 s: vrcompositor SEGV on DSI timeouts. |
| 21:39 | User did a clean reboot. 0 DSI errors, Steam logged on 21:40:30, NRestarts 0. |
+3
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`) |
| 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 |
| **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) |
| 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 |
@@ -54,6 +55,8 @@ 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)) |
| **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 |
| **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 |
| **Wake-on-WLAN doesn't work from `deep` or `s2idle`, and leaving it on breaks Wi-Fi after resume. Leave it off.** Sleep is `PM: suspend entry (deep)` (`/sys/power/mem_sleep` = `s2idle [deep]`). The Wi-Fi is a WCN7850 on `ath12k_pci` (PCIe, SM8650). Magic-packet WoWLAN can be armed without sudo: under `systemd-run --user --wait --pipe`, `nmcli c modify <connection> 802-11-wireless.wake-on-wlan magic` then `nmcli device reapply wlan0` makes `iw phy phy0 wowlan show` report `wake up on magic packet` (from SSH, `settings.modify.system` is only `auth`). **Tested 2026-09-28**, BUILD_ID 20260925.6191901, on the charger: after `PM: suspend entry (deep)` at 19:36:55, a unicast magic packet (UDP 9 and 7, to 192.168.1.237, with the Mac's ARP entry still present) and broadcast packets (192.168.1.255 and 255.255.255.255) got no wake in 30 s each. On a manual power-button wake 12 min later, the journal showed the chip had been reset during sleep: `mhi mhi0: Resuming from non M3 state (RESET)`, then `ath12k_pci: failed to wakeup from wow: -110`, WMI timeouts, `wiphy_resume returns -11`. Wi-Fi then disconnected and didn't come back, and the Frame needed a restart. So WoW did arm (no power-down fallback), but the WCN7850 loses power in `deep`. To turn it off, `wake-on-wlan default` alone doesn't clear the chip. `default` means NM's global `wifi.wake-on-wlan`, and the Frame sets none, so it falls back to `ignore`, which leaves the chip untouched. Set `0` and reapply (`WoWLAN is disabled.`), then set `default` again, or leave `0`. The Steam Deck with iwd fails differently: the NM setting never reached the driver there ([Switchboard](https://github.com/lfkdsk/Switchboard/blob/main/docs/steam-deck.md#wake-on-wlan)). `s2idle` failed the same way (tested 2026-09-28, set with `echo s2idle | sudo tee /sys/power/mem_sleep`, which lasts until reboot): `PM: suspend entry (s2idle)` at 20:29:57, no wake from unicast or broadcast packets, and on the power-button wake the same `Resuming from non M3 state (RESET)` and `failed to wakeup from wow`. Wi-Fi stayed `unavailable` until a restart. So the WCN7850 is reset during sleep either way. That points at ath12k WoW on this kernel/firmware rather than at the sleep depth. `systemctl suspend -i` under `systemd-run --user` asks for authentication, and plain `systemctl suspend` is refused while keep-awake's block inhibitor is held. | Waking the Frame remotely, [open-questions.md](open-questions.md) |
| **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) |
| **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: 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
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
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
+45 -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
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.
The Frame can reach the Mac's Screen Sharing port (5900). The Remmina
connection itself hasn't been tried in the headset yet (part of 11).
The Frame can reach the Mac's Screen Sharing port (5900).
- **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
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).
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)
@@ -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'`.
10. **Can Flatpaks be installed `--user` over SSH, and do they appear in the
headset's desktop?** Test with `./scripts/install-apps.sh remmina`.
11. **Remmina → macOS Screen Sharing:** does it connect, and is it usable at
Retina resolutions? Is the pre-seeded profile path
(`~/.var/app/org.remmina.Remmina/data/remmina/`) the one Remmina
actually reads?
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** worth trying only if
VNC is too slow.
11. ~~**Remmina → macOS Screen Sharing**~~: answered 2026-09-27; see above
and [streaming.md](streaming.md).
12. **Moonlight Flatpak (aarch64) + Sunshine on macOS:** VNC works but is
noticeably laggy, so this is worth trying.
13. **KDE Connect**: is it preinstalled or installable on the Frame, and does
it pair with KDE Connect for macOS?
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).
Still open: reaching the Frame from outside the home network, and the service
starting after a reboot.
17. **Floating panels in the headset** (see [panels.md](panels.md)): do the
panels from `panel-on-frame.sh` show up, take input, and offer **Float in
17. **Floating panels in the headset** (see [panels.md](panels.md)): panels
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
reopening the app, or a reboot?
18. **`LEPTON_NO_CLEANUP=1 %command%`** as Lepton Development's launch
@@ -129,9 +133,37 @@ 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
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
reports, not tested with the Frame.
- `connect.sh --harden`, `serve-bootstrap.sh` and
`bootstrap-on-frame.sh` haven't run against real hardware.
## Remote wake (2026-09-28)
Goal: the Frame sleeps on the charger but Frame Control can wake it to reach
it over SSH. Findings so far are in the Wake-on-WLAN row of
[how-the-frame-works.md](how-the-frame-works.md). Independent review: GPT-6
Astra (xhigh), 2026-09-28.
- **Magic packet from `deep`: no (tested 2026-09-28).** Unicast and broadcast packets didn't wake it, the chip came back in MHI RESET, and Wi-Fi stayed broken until a restart. Details are in how-the-frame-works.md. Don't leave WoWLAN on with `deep`.
- **`s2idle`: no (tested 2026-09-28).** Same chip reset and broken Wi-Fi as `deep`. SteamOS doesn't pick `deep` itself (no `sleep.conf.d`, no `mem_sleep_default`, and no sleep hook touching ath12k), so this was a clean one-setting test. Wake over Wi-Fi is out until a SteamOS/ath12k update. Re-test after updates.
- **Recovering from the broken Wi-Fi: reboot cleanly, never `modprobe -r ath12k`.** On 2026-09-28, unloading the wedged driver oopsed the kernel (`Unable to handle kernel paging request`) and the Frame reset itself. The next boot had truncated Steam files (`steamclient.so`, then `steamui.so`), and the displays were broken for the whole boot (`msm_dsi … wait for video done timed out` from 28 s in, 240 times). `vrcompositor` segfaulted on its first present, `steamvr.service` took the gamescope session and Steam down every ~15 s, and the screen said "There was an issue launching Steam". Steam's updater also hung on the same GPU wait. The fixes: Steam re-verified its files, a leftover pending-install manifest identical to the `.manifest` was moved aside, and a clean reboot brought the displays back (0 DSI errors). The USB-C cable gives SSH at 10.86.200.233 when Wi-Fi is down. Checks and fixes are in [frame-doctor.md](frame-doctor.md).
- **Charger / smart-plug wake (hypothesis):** during confirmed sleep, test
physically attaching the charger, detaching it, and switching off its AC
supply, each separately. If one works, a Home Assistant smart plug on the
charger can wake it while it keeps deep sleep.
- **RTC dark wake:** a root `WakeSystem=yes` timer that checks for queued
work and suspends again. Needs a root unit.
- **Controller wake:** does a paired controller's button wake it? `hci0` is a
UART radio with no paired devices listed, so the controllers may use a
separate link.
- **AC-only Never:** `system_idle_suspend_ac_sec = 0`, with battery left at
15 min. This is Steam's own setting and needs no sudo, but the Frame stays
awake with its displays off rather than suspended. Measure wall power and
confirm the displays blank.
- **Off the LAN:** a sleeping Frame's Tailscale can't receive anything, so
something awake on the LAN has to send the packet (for example the
EdgeRouter's `etherwake`, already used for lxso2, or the Mac).
- **Staying on instead of sleeping:** what draws power and heat while idle, the
controls, and the step-by-step plan are in
[power-and-heat.md](power-and-heat.md).
+175
View File
@@ -0,0 +1,175 @@
# Power, heat and what you can control
What the Frame spends power on while it's on, where the heat comes from, and
which controls exist. Everything here was **read** on the Frame on
2026-09-29 (BUILD_ID 20260925.6191901). Nothing was changed. Numbers under
load haven't been measured yet.
## Sensors you can read without sudo
| What | Where |
|---|---|
| Power per rail (W ×10⁶) | hwmon `max34417_10`: `vph` (whole system), `s1c`, `s3c`, `s6c`. `max34417_12`: `apc0`/`apc1`/`apc2` (CPU clusters), `nsp1`. `max34417_1a`: `gfx` (GPU), `nsp2`, `bob`. Each `powerN_input` has a `powerN_label` |
| Board temperatures (m°C) | `/sys/bus/iio/devices/iio:device0/in_temp_*_input`: battery, left and right display, heatsink fins, fan exhaust, Wi-Fi, flash, 40-pin connector, nRF radio, PMIC and charger die |
| CPU, GPU and modem zones | `/sys/class/thermal/thermal_zone*/{type,temp}` (per-core top and bottom, `gpuss-*`, `nsp*`, `video`) |
| Charger input and charge current | `iio:device0/in_current_pm8550b_{iin,ichg}_fb_input` (µA) |
| Battery | `/sys/class/power_supply/max1720x_bat_7-36/uevent` (cycle count, health, current) |
| Fan | hwmon `slg4ax46073v`: `fan1_input` (RPM), `pwm1` (%) |
| Proximity (worn or not) | `/sys/bus/iio/devices/iio:device2/in_proximity_raw` |
| Why the fan ramped | `journalctl -u deckard-fan-control` (`C3 temperature of 95.36 greater than max 95! Setting fan to max speed.`) |
The right display thermistor reads −16 °C, so it's absent or broken. Ignore it.
## Idle baseline (2026-09-29 08:05)
Conditions:
- On the 12 V USB-C charger, battery 100%, 5 cycles.
- Headset off-head, SteamVR in standby, backlight 0.
- Steam, SteamVR, the tracking service and one Chromium (Mac view) running.
- `pauseCompositorOnStandby` = false, set by another session this morning for testing.
30 s average:
| Rail | W | Notes |
|---|---|---|
| `vph` (everything) | **4.71** | |
| CPU `apc0+1+2` | 0.61 | 91% idle overall |
| GPU `gfx` | 0.25 | GPU at 366 of 903 MHz |
| NSP `nsp1+2` | 0.08 | Neural and DSP processors |
| `s1c` + `s3c` + `s6c` + `bob` | 1.01 | SoC, memory and peripheral supplies (which rail is which isn't documented) |
| Unmetered remainder | ~2.8 | Cameras, display link, Wi-Fi, fan, sensors, conversion losses (inferred) |
Temperatures:
| Sensor | °C |
|---|---|
| CPU cores | 40–45 |
| Board (heatsink, Wi-Fi, flash) | 34–37 |
| Left display thermistor | 46 (the warmest) |
| Charger IC | 37 |
| Battery | 23 |
The fan ran at about 8,350 RPM at `pwm1` 41.
What was running while idle (`top`):
- The compositor was at about 14% of one core.
- Steam was at about 7%.
- The tracking service (XRService) was at about 4%, and still had 4 camera nodes open (`/dev/video0,3,9,13`).
- vrserver and gamescope were at about 3% each.
## Why it's warm while "doing nothing"
- **The compositor keeps rendering in standby** when
`power.pauseCompositorOnStandby` is false. The default is true. Check
`~/.config/openvr/config/steamvr.vrsettings`.
- **The tracking cameras stay open in standby.** XRService holds them so
tracking resumes instantly.
- **The fan never goes below 40% on the charger.** That's by design in
`/usr/share/deckard-fan-control/deckard-config.yaml`:
`fan_charging_min_speed: 40`, against `fan_min_speed: 30` on battery.
Charging, including topping up at 100%, heats the charger IC and battery
area.
- **The display link stays up at backlight 0.** The DSI connector reports
`dpms=On` while the backlight is 0, so the panels are dark but still driven.
- **Heavy load reaches the throttle limit.** On 2026-09-28 at 22:09–22:12,
cores C3, C5 and C7 hit 95 °C and the fan went to max. The cause wasn't
investigated. It came around a Steam restart after the reboot.
## Controls
No sudo needed:
| Control | How | Effect | Caveat |
|---|---|---|---|
| Steam idle sleep | `scripts/keep-awake.sh`, `system_idle_suspend_{ac,battery}_sec` | When it sleeps | Sleep has no remote wake |
| Compositor pause in standby | `vrcmd --set-settings-bool power.pauseCompositorOnStandby 1` | Stops rendering while off-head | Other sessions toggle it for testing, so coordinate |
| Screens-off delay | `vrcmd --set-settings-float power.turnOffScreensTimeout <s>` | Backlight off sooner or later | Default 5 s |
| Stop the whole VR stack | `systemctl --user stop steamvr.service` | Cameras, tracking and compositor off | Also stops the gamescope VR session and Steam (seen 2026-09-28), so panels and Mac view go too. The restart cost hasn't been measured |
| Background apps | Close Mac-view Chromium, stop Lepton containers (`podman`) | Less CPU and memory | Mac view needs relaunching |
| Brightness | Steam settings | Panel power while worn | — |
Needs root (reset at reboot unless made persistent):
| Control | Where | Effect |
|---|---|---|
| CPU governor and max frequency per cluster | `/sys/devices/system/cpu/cpufreq/policy{0,2,5,7}/scaling_{governor,max_freq}` (`powersave`, `conservative`, `schedutil`, …) | Caps CPU power and heat |
| Take cores offline | `/sys/devices/system/cpu/cpuN/online` | Fewer active cores |
| GPU max frequency | `/sys/class/devfreq/3d00000.gpu/max_freq` | Caps GPU power (it hurts VR smoothness when worn) |
| Fan curve | The service reads `/usr/share/…/deckard-config.yaml`, which is on the read-only rootfs. It could be overridden with a systemd drop-in pointing at a copy in `/etc` | Quieter fan while charging, but hotter parts |
| Charge current | `pm8550b-charger` `constant_charge_current` (1.0 A, max 1.2 A) | Writability unverified. There's **no** charge-limit or end-threshold file, so the battery sits at 100% on the charger |
None of these have been tried yet.
## Plan: leave it on, but cheaply
Goal: the Frame stays on the charger, reachable over SSH, drawing as little
power and making as little heat, fan wear and battery wear as possible while
nobody wears it. When someone puts it on, everything comes back quickly.
Sleeping isn't an option until remote wake works (see
[open-questions.md](open-questions.md#remote-wake-2026-09-28)).
Rules for every step:
- Change one thing at a time. Snapshot the setting first, measure 5 minutes
(`vph` plus temperatures and fan), then restore it unless it's being kept.
- Freeze the Steam and SteamVR health-check trackers first
([frame-doctor.md](frame-doctor.md) §4).
- Anything that could leave the Frame unreachable, and every root change, waits
until someone is home to recover it.
- `power.pauseCompositorOnStandby` belongs to another session's testing. Ask
before touching it.
### 1. Measure (read-only, safe remotely)
- [x] Idle baseline off-head on the charger (above).
- [ ] A sampler script (`scripts/frame-power-sample.sh`) that logs `vph`, the
CPU/GPU rails, key temperatures, fan RPM, the proximity sensor and the
charger current every few seconds to a CSV. Every later step uses it.
- [ ] The same baseline worn, idle in the home space.
- [ ] Under load: Mac view streaming, and one VR game.
- [ ] On battery (unplugged) to separate charging heat from everything else.
- [ ] Find what caused the 95 °C spike on 2026-09-28 22:09–22:12 (journal and
process history around the Steam restart).
### 2. Settings without sudo (one at a time, measured)
- [ ] Compositor pause in standby (after asking the owning session).
- [ ] Shorter screens-off delay.
- [ ] Stop `steamvr.service` while off-head. Measure the saving and how long it
takes to come back, since it also stops the gamescope session and Steam.
- [ ] Close Mac-view Chromium and stop idle Lepton containers.
- [ ] Steam's "never sleep on AC" (`system_idle_suspend_ac_sec = 0`), keeping
the battery timer. Confirm the displays go dark.
### 3. Root settings (at home, with approval)
- [ ] CPU: `powersave` or a lower max frequency while off-head.
- [ ] Fan: a copy of the fan config with a lower charging minimum, through a
systemd drop-in. Only if temperatures in steps 1–2 leave headroom.
- [ ] Battery: check whether charge current is writable. There's no charge
limit, so the fallback is a Home Assistant smart plug that lets the
battery cycle between roughly 80% and 100%.
- [ ] Decide which root changes to make persistent (drop-ins in `/etc`, which
survive SteamOS updates, unlike `/usr`).
### 4. A quiet mode
- [ ] Put the kept settings behind one switch: off-head for N minutes → quiet
mode; on-head (proximity sensor) or a Frame Control request → normal.
- [ ] Run it from Frame Control / keep-awake, not a hand-edited setting, so it
can always be undone.
- [ ] Add a doctor check that reports whether quiet mode is on and that it
restores cleanly.
### 5. Remote wake (at home)
- [ ] Charger wake: during confirmed sleep, plug in, unplug, and switch the
charger's AC off and on, one at a time.
- [ ] Controller-button wake.
- [ ] RTC dark wake (root timer that wakes, checks for queued work, sleeps).
- [ ] Re-test WoWLAN after each SteamOS or kernel update.
### 6. Doctor script
- [ ] Turn [frame-doctor.md](frame-doctor.md) into `scripts/frame-doctor.sh`:
read-only checks by default, fixes only with a flag, and **ask** fixes
never automatic. Add the sensor reads from this page.
+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/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/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/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**) |
+67 -15
View File
@@ -1,9 +1,11 @@
# 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.
- **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).
@@ -32,7 +34,7 @@ flat 2D desktop streaming into a window on the Frame's Linux desktop.
| 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. |
| 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. |
@@ -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
`~/.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
still be asked for the VNC password in the headset the first time, unless you
choose to save it. Remmina stores passwords encrypted with a per-install key,
so the script doesn't try to write the password. (The Remmina file format is
standard; the Flatpak data path is inferred.)
SSH. The profile then appears in Remmina's list, and you just click it. It
scales the Mac's desktop to fit the window (`scale=1`, `viewmode=1`). Without
that, Remmina shows a Retina Mac's native pixels 1:1, so you see a zoomed-in
corner. (Verified 2026-09-27.)
## 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
avoid the virtual keyboard. Road to VR says there are "only a few things
you'd actually want to do" on the Linux desktop unless you connect a
keyboard and mouse.
(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)).
```sh
printf '%s' "$PASSWORD" | flatpak run org.remmina.Remmina \
--update-profile ~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina \
--set-option password
```
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
that RDP session.
+64 -69
View File
@@ -3,9 +3,11 @@
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.
A standalone, public version of this build (build script, the SO_PEERCRED
patch, and a Frame-side installer that adds "Chromium XR" to the Steam library)
is at [saphid/chromium-webxr-steam-frame](https://github.com/saphid/chromium-webxr-steam-frame).
The build and installer now live in their own public repo,
[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
@@ -45,76 +47,52 @@ gets both.
SteamVR (`bin/linuxarm64/vrclient.so`, `VALVE_runtime_is_steamvr`). The
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)
cross-compiles arm64 Linux Chromium on an x64 Linux host. It doesn't need
sudo: the arm64 sysroot comes from Chromium's own script. It needs about
90 GB of disk. It shallow-fetches the CL ref (patchset 44), runs
`gclient sync --no-history`, installs the sysroot, applies one extra seccomp
fix (below), builds `chrome` with `symbol_level=0` and proprietary codecs, and
packs `chromium-xr-arm64.tar.xz` (about 145 MB, GPU libraries included).
Progress is logged to `~/chromium-xr/stage`. The build aborts if the disk
holding `~/chromium-xr` drops below 12 GB free.
Follow the [public repo's README](https://github.com/saphid/chromium-webxr-steam-frame#build).
In short: `build/build.sh` on an x64 Linux host (no sudo, about 90 GB of
disk) produces `chromium-xr-arm64.tar.xz` (about 145 MB), and
`frame/install.sh` on the Frame unpacks it to `~/chromium-xr`, installs the
`chromium-xr` launcher in `~/.local/bin`, and adds the Steam library shortcut
through the Steam client's DevTools port, the same way as T3 Code
([apks.md](apks.md)). Launching the shortcut gives Chromium its own panel,
`valve.steam.desktopgame.<appid>`, like any other app.
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
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`,
so the XR process died with a seccomp crash (arm64 syscall 209). The script
allows that one option. That's needed but not enough: `launch` still turns
so the XR process died with a seccomp crash (arm64 syscall 209). The patch
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.
## 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):
```sh
BUILD_HOST=my-linux-box scripts/chromium-xr.sh install # your build host; scp, unpack to ~/chromium-xr
scripts/chromium-xr.sh launch [URL] # its own VR panel, --enable-features=OpenXR
scripts/chromium-xr.sh steam # adds "Chromium XR" to the Steam library
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.
**Upstream (2026-09-27).** CL 8441736 (the XR sandbox) has merged into
Chromium, still refusing `getsockopt`; CL 8132979 is still in review. Valve
and the CLs' author are working on Steam Frame support
([utzcoz/chromium-webxr-linux#5](https://github.com/utzcoz/chromium-webxr-linux/issues/5)).
Both sandbox problems above, with the patch, are reported in
[utzcoz/chromium-webxr-linux#7](https://github.com/utzcoz/chromium-webxr-linux/issues/7).
**Verified 2026-09-26** (Frame BUILD_ID 20260922.6101926, SteamVR 2.17.10,
this build):
@@ -138,9 +116,26 @@ this build):
[`webxr_vr_video`](https://threejs.org/examples/webxr_vr_video.html) demo,
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
looked fine).
- Third-party VR180 players (DeoVR and DL8 web embeds).
- Controller and hand input inside a WebXR page.
- **Frame rate and input, measured 2026-09-27** (standby workaround, red
test session): 72 fps with every frame at 13.9–14 ms over 16 s, and SteamVR
dropped frames only at startup. The right controller showed up as an
`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 */; };
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
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 */; };
/* End PBXBuildFile section */
@@ -36,6 +37,7 @@
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = "<group>"; };
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
@@ -74,6 +76,7 @@
5064A5B6FE5B17B18E5FA4B8 /* SSH */ = {
isa = PBXGroup;
children = (
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */,
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */,
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */,
237D9AF04EEA257AB382F60E /* Keys.swift */,
@@ -255,6 +258,7 @@
files = (
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
+1 -1
View File
@@ -92,7 +92,7 @@ final class AppModel: ObservableObject {
}
/// "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))
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
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
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
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.
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
let f = pair.components(separatedBy: "|")
+7 -1
View File
@@ -43,8 +43,14 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_steamos-devkit._tcp</string>
</array>
<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>
<dict>
<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)
case .connecting(let step):
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):
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
retry: { Task { await model.connect() } }, change: { model.showSetup() })
@@ -73,3 +76,46 @@ struct FailedView: View {
.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
}
}
}
}
}
+166 -51
View File
@@ -1,82 +1,197 @@
import SwiftUI
import UIKit
/// Pairing: the Developer Mode password is used once, to add this phone's own
/// key to the Frame. It isn't stored.
/// First run: two steps. Wake the Frame (the app finds it by itself), then type the
/// Developer Mode password once. Everything else waits under "Other ways to connect".
struct SetupView: View {
@ObservedObject var model: AppModel
@State private var host = ""
@State private var user = "steamos"
@StateObject private var finder = FrameFinder()
@State private var password = ""
@FocusState private var focus: Field?
private enum Field { case host, user, password }
@State private var manualHost = ""
@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 {
NavigationStack {
Form {
Section {
VStack(alignment: .leading, spacing: 10) {
Image("AppIconImage").resizable().frame(width: 64, height: 64).clipShape(RoundedRectangle(cornerRadius: 14))
ScrollView {
VStack(alignment: .leading, spacing: 22) {
header
StepCard(number: 1, title: "Wake your Frame", done: finder.found != nil) { wakeStep }
StepCard(number: 2, title: "Enter its Developer Mode password", done: false) { passwordStep }
otherWays
}
.padding(20)
.frame(maxWidth: 560)
.frame(maxWidth: .infinity)
}
.scrollDismissesKeyboard(.interactively)
.background(Color.frameBackground)
.onAppear {
manualHost = model.settings?.host ?? ""
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 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("See what the headset sees, install games and Android apps, and send files and text, from this \(model.deviceName).")
Text("One time only. After this, the app connects by itself whenever your Frame is awake.")
.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)
// 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).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.")
.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)
}
Section {
Button(action: pair) {
Text("Pair").frame(maxWidth: .infinity).fontWeight(.semibold)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(password.isEmpty)
}
.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() } }
// 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)
}
}
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 }
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) }
}
.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)
.background(Color.frameBackground)
.navigationTitle("Frame Control")
.navigationBarTitleDisplayMode(.inline)
if let saved = model.settings {
Button("Forget \(saved.host)", role: .destructive) { Task { await model.forget() } }
}
.onAppear {
host = model.settings?.host ?? "frame.local"
user = model.settings?.user ?? "steamos"
}
.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 pair() {
private func connect() {
guard !password.isEmpty else { passwordFocused = true; return }
let (h, u, p) = (host, user, password)
password = ""
finder.stop()
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~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
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:
NSAllowsLocalNetworking: true
LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]
-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
viewonly=0
showcursor=1
scale=1
viewmode=1
window_maximize=1
EOF
echo \"wrote \$d/mac-screen-sharing.remmina\"
"
print "On the Mac: System Settings > General > Sharing > Screen Sharing (i) >"
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
+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()
+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"
+24 -16
View File
@@ -27,8 +27,8 @@
}
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* class display rules would otherwise win */
html { scroll-behavior: smooth; scroll-padding-top: 76px; }
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: 46px;
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
@@ -39,7 +39,7 @@
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
height: 64px; padding: 0 28px; background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
.brand svg { width: 30px; height: 30px; }
@@ -57,10 +57,10 @@
.dot.on { background: var(--green); box-shadow: 0 0 6px rgba(89,191,64,.8); }
.dot.off { background: var(--bad); }
main { max-width: 1480px; margin: 0 auto; padding: 22px 28px 30px; display: flex; flex-direction: column; gap: 26px; }
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: 0 18px; } nav a { padding: 0 10px; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 820px) { .brand b { display: none; } }
nav svg { display: none; }
html { -webkit-text-size-adjust: 100%; }
@@ -231,21 +231,21 @@
.links div { margin: 5px 0; }
/* ---- bottom bar (Steam's DOWNLOADS strip) ---- */
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: 42px; display: flex; align-items: center; gap: 14px;
padding: 0 28px; background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: calc(42px + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: 14px;
padding: 0 calc(28px + env(safe-area-inset-right)) env(safe-area-inset-bottom) calc(28px + env(safe-area-inset-left)); background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
.bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); }
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
.bottombar .last.e { color: #ff8a73; }
.drawer { position: fixed; left: 0; right: 0; bottom: 42px; z-index: 19; max-height: 0; overflow: hidden;
.drawer { position: fixed; left: 0; right: 0; bottom: calc(42px + env(safe-area-inset-bottom)); z-index: 19; max-height: 0; overflow: hidden;
background: rgba(14,20,27,.98); transition: max-height .25s ease; box-shadow: 0 -10px 30px rgba(0,0,0,.5); }
.drawer.open { max-height: 300px; }
.log { font: 12px/1.55 ui-monospace, "SF Mono", Menlo, monospace; color: #b8c4d2; max-height: 300px; overflow: auto;
white-space: pre-wrap; padding: 12px 28px; }
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
.toast { position: fixed; bottom: 58px; right: 28px; z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
.toast { position: fixed; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
.toast.show { opacity: 1; transform: none; }
@@ -322,15 +322,17 @@
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
/* Touch screens can't hover: keep the library's name and Play button showing. */
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
/* ---- phones: tabs move to a bottom bar, as in iOS apps; the page clears the notch and home indicator ---- */
@media (max-width: 640px) {
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
/* No blur here: it would make the header the containing block of the fixed tab bar. */
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) 14px 0; gap: 10px;
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
backdrop-filter: none; background: rgb(23, 29, 37); }
.brand svg { width: 26px; height: 26px; }
header .chip { height: 30px; padding: 0 10px; }
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
height: calc(58px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom);
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
nav a { flex: 1; flex-direction: column; justify-content: center; gap: 3px; padding: 0; font-size: 10.5px;
letter-spacing: .5px; border-bottom: 0; color: var(--muted); }
@@ -338,9 +340,9 @@
nav svg { display: block; width: 23px; height: 23px; }
.bottombar, .drawer { display: none; }
body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
.toast { left: 14px; right: 14px; max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
.toast { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
main, .page { gap: 16px; }
main { padding: 12px 12px 20px; }
main { padding: 12px calc(12px + env(safe-area-inset-right)) 20px calc(12px + env(safe-area-inset-left)); }
.panel { padding: 14px; }
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
.toolbar { gap: 8px; }
@@ -356,6 +358,8 @@
button.small { height: 32px; }
.actions button { height: 46px; }
.cat-tools select { max-width: none; flex: 1 1 100%; }
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
body.typing nav { display: none; }
}
</style>
</head>
@@ -2120,6 +2124,10 @@ function showPage() {
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
}
window.addEventListener("hashchange", showPage);
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=checkbox], [type=radio], [type=file], [type=button])');
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
showPage();
document.addEventListener("keydown", e => {
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;